feat SynologyChat
This commit is contained in:
11
README.md
11
README.md
@ -84,7 +84,7 @@ docker pull jxxghp/moviepilot:latest
|
|||||||
- **SUBSCRIBE_MODE:** 订阅模式,`rss`/`spider`,默认`spider`,`rss`模式通过定时刷新RSS来匹配订阅(RSS地址会自动获取,也可手动维护),对站点压力小,同时可设置订阅刷新周期,24小时运行,但订阅和下载通知不能过滤和显示免费,推荐使用rss模式。
|
- **SUBSCRIBE_MODE:** 订阅模式,`rss`/`spider`,默认`spider`,`rss`模式通过定时刷新RSS来匹配订阅(RSS地址会自动获取,也可手动维护),对站点压力小,同时可设置订阅刷新周期,24小时运行,但订阅和下载通知不能过滤和显示免费,推荐使用rss模式。
|
||||||
- **SUBSCRIBE_RSS_INTERVAL:** RSS订阅模式刷新时间间隔(分钟),默认`30`分钟,不能小于5分钟。
|
- **SUBSCRIBE_RSS_INTERVAL:** RSS订阅模式刷新时间间隔(分钟),默认`30`分钟,不能小于5分钟。
|
||||||
- **SUBSCRIBE_SEARCH:** 订阅搜索,`true`/`false`,默认`false`,开启后会每隔24小时对所有订阅进行全量搜索,以补齐缺失剧集(一般情况下正常订阅即可,订阅搜索只做为兜底,会增加站点压力,不建议开启)。
|
- **SUBSCRIBE_SEARCH:** 订阅搜索,`true`/`false`,默认`false`,开启后会每隔24小时对所有订阅进行全量搜索,以补齐缺失剧集(一般情况下正常订阅即可,订阅搜索只做为兜底,会增加站点压力,不建议开启)。
|
||||||
- **MESSAGER:** 消息通知渠道,支持 `telegram`/`wechat`/`slack`,开启多个渠道时使用`,`分隔。同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`telegram`
|
- **MESSAGER:** 消息通知渠道,支持 `telegram`/`wechat`/`slack`/`synologychat`,开启多个渠道时使用`,`分隔。同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`telegram`
|
||||||
|
|
||||||
- `wechat`设置项:
|
- `wechat`设置项:
|
||||||
|
|
||||||
@ -109,6 +109,11 @@ docker pull jxxghp/moviepilot:latest
|
|||||||
- **SLACK_APP_TOKEN:** Slack App-Level Token
|
- **SLACK_APP_TOKEN:** Slack App-Level Token
|
||||||
- **SLACK_CHANNEL:** Slack 频道名称,默认`全体`
|
- **SLACK_CHANNEL:** Slack 频道名称,默认`全体`
|
||||||
|
|
||||||
|
- `synologychat`设置项:
|
||||||
|
|
||||||
|
- **SYNOLOGYCHAT_WEBHOOK:** 在Synology Chat中创建机器人,获取机器人`传入URL`
|
||||||
|
- **SYNOLOGYCHAT_TOKEN:** SynologyChat机器人`令牌`
|
||||||
|
|
||||||
|
|
||||||
- **DOWNLOADER:** 下载器,支持`qbittorrent`/`transmission`,QB版本号要求>= 4.3.9,TR版本号要求>= 3.0,同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`qbittorrent`
|
- **DOWNLOADER:** 下载器,支持`qbittorrent`/`transmission`,QB版本号要求>= 4.3.9,TR版本号要求>= 3.0,同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`qbittorrent`
|
||||||
|
|
||||||
@ -229,9 +234,9 @@ docker pull jxxghp/moviepilot:latest
|
|||||||
- 通过CookieCloud同步快速同步站点,不需要使用的站点可在WEB管理界面中禁用,无法同步的站点可手动新增。
|
- 通过CookieCloud同步快速同步站点,不需要使用的站点可在WEB管理界面中禁用,无法同步的站点可手动新增。
|
||||||
- 通过WEB进行管理,将WEB添加到手机桌面获得类App使用效果,管理界面端口:`3000`,后台API端口:`3001`。
|
- 通过WEB进行管理,将WEB添加到手机桌面获得类App使用效果,管理界面端口:`3000`,后台API端口:`3001`。
|
||||||
- 通过下载器监控或使用目录监控插件实现自动整理入库刮削(二选一)。
|
- 通过下载器监控或使用目录监控插件实现自动整理入库刮削(二选一)。
|
||||||
- 通过微信/Telegram/Slack远程管理,其中微信/Telegram将会自动添加操作菜单(微信菜单条数有限制,部分菜单不显示),微信需要在官方页面设置回调地址,地址相对路径为:`/api/v1/message/`。
|
- 通过微信/Telegram/Slack/SynologyChat远程管理,其中微信/Telegram将会自动添加操作菜单(微信菜单条数有限制,部分菜单不显示);微信需要在官方页面设置回调地址,SynologyChat需要设置机器人传入地址,地址相对路径为:`/api/v1/message/`。
|
||||||
- 设置媒体服务器Webhook,通过MoviePilot发送播放通知等。Webhook回调相对路径为`/api/v1/webhook?token=moviepilot`(`3001`端口),其中`moviepilot`为设置的`API_TOKEN`。
|
- 设置媒体服务器Webhook,通过MoviePilot发送播放通知等。Webhook回调相对路径为`/api/v1/webhook?token=moviepilot`(`3001`端口),其中`moviepilot`为设置的`API_TOKEN`。
|
||||||
- 将MoviePilot做为Radarr或Sonarr服务器添加到Overseerr或Jellyseerr(`3001`端口),可使用Overseerr/Jellyseerr浏览订阅。
|
- 将MoviePilot做为Radarr或Sonarr服务器添加到Overseerr或Jellyseerr(`API服务端口`),可使用Overseerr/Jellyseerr浏览订阅。
|
||||||
- 映射宿主机docker.sock文件到容器`/var/run/docker.sock`,以支持内建重启操作。实例:`-v /var/run/docker.sock:/var/run/docker.sock:ro`
|
- 映射宿主机docker.sock文件到容器`/var/run/docker.sock`,以支持内建重启操作。实例:`-v /var/run/docker.sock:/var/run/docker.sock:ro`
|
||||||
|
|
||||||
**注意**
|
**注意**
|
||||||
|
@ -73,7 +73,9 @@ def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
|||||||
switchs = SystemConfigOper().get(SystemConfigKey.NotificationChannels)
|
switchs = SystemConfigOper().get(SystemConfigKey.NotificationChannels)
|
||||||
if not switchs:
|
if not switchs:
|
||||||
for noti in NotificationType:
|
for noti in NotificationType:
|
||||||
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True, telegram=True, slack=True))
|
return_list.append(NotificationSwitch(mtype=noti.value, wechat=True,
|
||||||
|
telegram=True, slack=True,
|
||||||
|
synologychat=True))
|
||||||
else:
|
else:
|
||||||
for switch in switchs:
|
for switch in switchs:
|
||||||
return_list.append(NotificationSwitch(**switch))
|
return_list.append(NotificationSwitch(**switch))
|
||||||
|
@ -106,6 +106,10 @@ class Settings(BaseSettings):
|
|||||||
SLACK_APP_TOKEN: str = ""
|
SLACK_APP_TOKEN: str = ""
|
||||||
# Slack 频道名称
|
# Slack 频道名称
|
||||||
SLACK_CHANNEL: str = ""
|
SLACK_CHANNEL: str = ""
|
||||||
|
# SynologyChat Webhook
|
||||||
|
SYNOLOGYCHAT_WEBHOOK: str = ""
|
||||||
|
# SynologyChat Token
|
||||||
|
SYNOLOGYCHAT_TOKEN: str = ""
|
||||||
# 下载器 qbittorrent/transmission
|
# 下载器 qbittorrent/transmission
|
||||||
DOWNLOADER: str = "qbittorrent"
|
DOWNLOADER: str = "qbittorrent"
|
||||||
# 下载器监控开关
|
# 下载器监控开关
|
||||||
|
@ -58,6 +58,8 @@ def checkMessage(channel_type: MessageChannel):
|
|||||||
return None
|
return None
|
||||||
if channel_type == MessageChannel.Slack and not switch.get("slack"):
|
if channel_type == MessageChannel.Slack and not switch.get("slack"):
|
||||||
return None
|
return None
|
||||||
|
if channel_type == MessageChannel.SynologyChat and not switch.get("synologychat"):
|
||||||
|
return None
|
||||||
return func(self, message, *args, **kwargs)
|
return func(self, message, *args, **kwargs)
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
85
app/modules/synologychat/__init__.py
Normal file
85
app/modules/synologychat/__init__.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
from typing import Optional, Union, List, Tuple, Any
|
||||||
|
|
||||||
|
from app.core.context import MediaInfo, Context
|
||||||
|
from app.log import logger
|
||||||
|
from app.modules import _ModuleBase, checkMessage
|
||||||
|
from app.modules.synologychat.synologychat import SynologyChat
|
||||||
|
from app.schemas import MessageChannel, CommingMessage, Notification
|
||||||
|
|
||||||
|
|
||||||
|
class SynologyChatModule(_ModuleBase):
|
||||||
|
synologychat: SynologyChat = None
|
||||||
|
|
||||||
|
def init_module(self) -> None:
|
||||||
|
self.synologychat = SynologyChat()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def init_setting(self) -> Tuple[str, Union[str, bool]]:
|
||||||
|
return "MESSAGER", "synologychat"
|
||||||
|
|
||||||
|
def message_parser(self, body: Any, form: Any,
|
||||||
|
args: Any) -> Optional[CommingMessage]:
|
||||||
|
"""
|
||||||
|
解析消息内容,返回字典,注意以下约定值:
|
||||||
|
userid: 用户ID
|
||||||
|
username: 用户名
|
||||||
|
text: 内容
|
||||||
|
:param body: 请求体
|
||||||
|
:param form: 表单
|
||||||
|
:param args: 参数
|
||||||
|
:return: 渠道、消息体
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
message: dict = form
|
||||||
|
if not message:
|
||||||
|
return None
|
||||||
|
# 校验token
|
||||||
|
token = message.get("token")
|
||||||
|
if not token or not self.synologychat.check_token(token):
|
||||||
|
return None
|
||||||
|
# 文本
|
||||||
|
text = message.get("text")
|
||||||
|
# 用户ID
|
||||||
|
user_id = int(message.get("user_id"))
|
||||||
|
# 获取用户名
|
||||||
|
user_name = message.get("username")
|
||||||
|
if text and user_id:
|
||||||
|
logger.info(f"收到SynologyChat消息:userid={user_id}, username={user_name}, text={text}")
|
||||||
|
return CommingMessage(channel=MessageChannel.SynologyChat,
|
||||||
|
userid=user_id, username=user_name, text=text)
|
||||||
|
except Exception as err:
|
||||||
|
logger.debug(f"解析SynologyChat消息失败:{err}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@checkMessage(MessageChannel.SynologyChat)
|
||||||
|
def post_message(self, message: Notification) -> None:
|
||||||
|
"""
|
||||||
|
发送消息
|
||||||
|
:param message: 消息体
|
||||||
|
:return: 成功或失败
|
||||||
|
"""
|
||||||
|
self.synologychat.send_msg(title=message.title, text=message.text,
|
||||||
|
image=message.image, userid=message.userid)
|
||||||
|
|
||||||
|
@checkMessage(MessageChannel.SynologyChat)
|
||||||
|
def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> Optional[bool]:
|
||||||
|
"""
|
||||||
|
发送媒体信息选择列表
|
||||||
|
:param message: 消息体
|
||||||
|
:param medias: 媒体列表
|
||||||
|
:return: 成功或失败
|
||||||
|
"""
|
||||||
|
return self.synologychat.send_meidas_msg(title=message.title, medias=medias,
|
||||||
|
userid=message.userid)
|
||||||
|
|
||||||
|
@checkMessage(MessageChannel.SynologyChat)
|
||||||
|
def post_torrents_message(self, message: Notification, torrents: List[Context]) -> Optional[bool]:
|
||||||
|
"""
|
||||||
|
发送种子信息选择列表
|
||||||
|
:param message: 消息体
|
||||||
|
:param torrents: 种子列表
|
||||||
|
:return: 成功或失败
|
||||||
|
"""
|
||||||
|
return self.synologychat.send_torrents_msg(title=message.title, torrents=torrents, userid=message.userid)
|
204
app/modules/synologychat/synologychat.py
Normal file
204
app/modules/synologychat/synologychat.py
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Optional, List
|
||||||
|
from urllib.parse import quote
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.context import MediaInfo, Context
|
||||||
|
from app.core.metainfo import MetaInfo
|
||||||
|
from app.log import logger
|
||||||
|
from app.utils.http import RequestUtils
|
||||||
|
from app.utils.singleton import Singleton
|
||||||
|
from app.utils.string import StringUtils
|
||||||
|
|
||||||
|
lock = Lock()
|
||||||
|
|
||||||
|
|
||||||
|
class SynologyChat(metaclass=Singleton):
|
||||||
|
def __init__(self):
|
||||||
|
self._req = RequestUtils(content_type="application/x-www-form-urlencoded")
|
||||||
|
self._webhook_url = settings.SYNOLOGYCHAT_WEBHOOK
|
||||||
|
self._token = settings.SYNOLOGYCHAT_TOKEN
|
||||||
|
if self._webhook_url:
|
||||||
|
self._domain = StringUtils.get_base_url(self._webhook_url)
|
||||||
|
|
||||||
|
def check_token(self, token: str) -> bool:
|
||||||
|
return True if token == self._token else False
|
||||||
|
|
||||||
|
def send_msg(self, title: str, text: str = "", image: str = "", userid: str = "") -> Optional[bool]:
|
||||||
|
"""
|
||||||
|
发送Telegram消息
|
||||||
|
:param title: 消息标题
|
||||||
|
:param text: 消息内容
|
||||||
|
:param image: 消息图片地址
|
||||||
|
:param userid: 用户ID,如有则只发消息给该用户
|
||||||
|
:user_id: 发送消息的目标用户ID,为空则发给管理员
|
||||||
|
"""
|
||||||
|
if not title and not text:
|
||||||
|
logger.error("标题和内容不能同时为空")
|
||||||
|
return False
|
||||||
|
if not self._webhook_url or not self._token:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
# 拼装消息内容
|
||||||
|
titles = str(title).split('\n')
|
||||||
|
if len(titles) > 1:
|
||||||
|
title = titles[0]
|
||||||
|
if not text:
|
||||||
|
text = "\n".join(titles[1:])
|
||||||
|
else:
|
||||||
|
text = f"%s\n%s" % ("\n".join(titles[1:]), text)
|
||||||
|
|
||||||
|
if text:
|
||||||
|
caption = "*%s*\n%s" % (title, text.replace("\n\n", "\n"))
|
||||||
|
else:
|
||||||
|
caption = title
|
||||||
|
payload_data = {'text': quote(caption)}
|
||||||
|
if image:
|
||||||
|
payload_data['file_url'] = quote(image)
|
||||||
|
if userid:
|
||||||
|
payload_data['user_ids'] = [int(userid)]
|
||||||
|
else:
|
||||||
|
userids = self.__get_bot_users()
|
||||||
|
if not userids:
|
||||||
|
logger.error("SynologyChat机器人没有对任何用户可见")
|
||||||
|
return False
|
||||||
|
payload_data['user_ids'] = userids
|
||||||
|
|
||||||
|
return self.__send_request(payload_data)
|
||||||
|
|
||||||
|
except Exception as msg_e:
|
||||||
|
logger.error(f"SynologyChat发送消息错误:{str(msg_e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_meidas_msg(self, medias: List[MediaInfo], userid: str = "", title: str = "") -> Optional[bool]:
|
||||||
|
"""
|
||||||
|
发送列表类消息
|
||||||
|
"""
|
||||||
|
if not medias:
|
||||||
|
return False
|
||||||
|
if not self._webhook_url or not self._token:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
if not title or not isinstance(medias, list):
|
||||||
|
return False
|
||||||
|
index, image, caption = 1, "", "*%s*" % title
|
||||||
|
for media in medias:
|
||||||
|
if not image:
|
||||||
|
image = media.get_message_image()
|
||||||
|
if media.vote_average:
|
||||||
|
caption = "%s\n%s. [%s](%s)\n_%s,%s_" % (caption,
|
||||||
|
index,
|
||||||
|
media.title_year,
|
||||||
|
media.detail_link,
|
||||||
|
f"类型:{media.type.value}",
|
||||||
|
f"评分:{media.vote_average}")
|
||||||
|
else:
|
||||||
|
caption = "%s\n%s. [%s](%s)\n_%s_" % (caption,
|
||||||
|
index,
|
||||||
|
media.title_year,
|
||||||
|
media.detail_link,
|
||||||
|
f"类型:{media.type.value}")
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
if userid:
|
||||||
|
userids = [int(userid)]
|
||||||
|
else:
|
||||||
|
userids = self.__get_bot_users()
|
||||||
|
payload_data = {
|
||||||
|
"text": quote(caption),
|
||||||
|
"file_url": quote(image),
|
||||||
|
"user_ids": userids
|
||||||
|
}
|
||||||
|
return self.__send_request(payload_data)
|
||||||
|
|
||||||
|
except Exception as msg_e:
|
||||||
|
logger.error(f"SynologyChat发送消息错误:{str(msg_e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_torrents_msg(self, torrents: List[Context],
|
||||||
|
userid: str = "", title: str = "") -> Optional[bool]:
|
||||||
|
"""
|
||||||
|
发送列表消息
|
||||||
|
"""
|
||||||
|
if not self._webhook_url or not self._token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not torrents:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
index, caption = 1, "*%s*" % title
|
||||||
|
for context in torrents:
|
||||||
|
torrent = context.torrent_info
|
||||||
|
site_name = torrent.site_name
|
||||||
|
meta = MetaInfo(torrent.title, torrent.description)
|
||||||
|
link = torrent.page_url
|
||||||
|
title = f"{meta.season_episode} " \
|
||||||
|
f"{meta.resource_term} " \
|
||||||
|
f"{meta.video_term} " \
|
||||||
|
f"{meta.release_group}"
|
||||||
|
title = re.sub(r"\s+", " ", title).strip()
|
||||||
|
free = torrent.volume_factor
|
||||||
|
seeder = f"{torrent.seeders}↑"
|
||||||
|
description = torrent.description
|
||||||
|
caption = f"{caption}\n{index}.【{site_name}】[{title}]({link}) " \
|
||||||
|
f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}\n" \
|
||||||
|
f"_{description}_"
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
if userid:
|
||||||
|
userids = [int(userid)]
|
||||||
|
else:
|
||||||
|
userids = self.__get_bot_users()
|
||||||
|
|
||||||
|
payload_data = {
|
||||||
|
"text": quote(caption),
|
||||||
|
"user_ids": userids
|
||||||
|
}
|
||||||
|
return self.__send_request(payload_data)
|
||||||
|
except Exception as msg_e:
|
||||||
|
logger.error(f"SynologyChat发送消息错误:{str(msg_e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def __get_bot_users(self):
|
||||||
|
"""
|
||||||
|
查询机器人可见的用户列表
|
||||||
|
"""
|
||||||
|
if not self._domain or not self._token:
|
||||||
|
return []
|
||||||
|
req_url = f"{self._domain}" \
|
||||||
|
f"/webapi/entry.cgi?api=SYNO.Chat.External&method=user_list&version=2&token=" \
|
||||||
|
f"{self._token}"
|
||||||
|
ret = self._req.get_res(url=req_url)
|
||||||
|
if ret and ret.status_code == 200:
|
||||||
|
users = ret.json().get("data", {}).get("users", []) or []
|
||||||
|
return [user.get("user_id") for user in users]
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def __send_request(self, payload_data):
|
||||||
|
"""
|
||||||
|
发送消息请求
|
||||||
|
"""
|
||||||
|
payload = f"payload={json.dumps(payload_data)}"
|
||||||
|
ret = self._req.post_res(url=self._webhook_url, data=payload)
|
||||||
|
if ret and ret.status_code == 200:
|
||||||
|
result = ret.json()
|
||||||
|
if result:
|
||||||
|
errno = result.get('error', {}).get('code')
|
||||||
|
errmsg = result.get('error', {}).get('errors')
|
||||||
|
if not errno:
|
||||||
|
return True
|
||||||
|
logger.error(f"SynologyChat返回错误:{errno}-{errmsg}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
logger.error(f"SynologyChat返回:{ret.text}")
|
||||||
|
return False
|
||||||
|
elif ret is not None:
|
||||||
|
logger.error(f"SynologyChat请求失败,错误码:{ret.status_code},错误原因:{ret.reason}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
logger.error(f"SynologyChat请求失败,未获取到返回信息")
|
||||||
|
return False
|
@ -51,3 +51,5 @@ class NotificationSwitch(BaseModel):
|
|||||||
telegram: Optional[bool] = False
|
telegram: Optional[bool] = False
|
||||||
# Slack开关
|
# Slack开关
|
||||||
slack: Optional[bool] = False
|
slack: Optional[bool] = False
|
||||||
|
# SynologyChat开关
|
||||||
|
synologychat: Optional[bool] = False
|
||||||
|
@ -109,3 +109,4 @@ class MessageChannel(Enum):
|
|||||||
Wechat = "微信"
|
Wechat = "微信"
|
||||||
Telegram = "Telegram"
|
Telegram = "Telegram"
|
||||||
Slack = "Slack"
|
Slack = "Slack"
|
||||||
|
SynologyChat = "SynologyChat"
|
||||||
|
Reference in New Issue
Block a user