From 7f6beb2a78c6a442344a746a6ffb34b378fa54df Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 22 Sep 2023 15:40:23 +0800 Subject: [PATCH] feat SynologyChat --- README.md | 11 +- app/api/endpoints/message.py | 4 +- app/core/config.py | 4 + app/modules/__init__.py | 2 + app/modules/synologychat/__init__.py | 85 ++++++++++ app/modules/synologychat/synologychat.py | 204 +++++++++++++++++++++++ app/schemas/message.py | 2 + app/schemas/types.py | 1 + 8 files changed, 309 insertions(+), 4 deletions(-) create mode 100644 app/modules/synologychat/__init__.py create mode 100644 app/modules/synologychat/synologychat.py diff --git a/README.md b/README.md index e406b821a..b838d1d5c 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ docker pull jxxghp/moviepilot:latest - **SUBSCRIBE_MODE:** 订阅模式,`rss`/`spider`,默认`spider`,`rss`模式通过定时刷新RSS来匹配订阅(RSS地址会自动获取,也可手动维护),对站点压力小,同时可设置订阅刷新周期,24小时运行,但订阅和下载通知不能过滤和显示免费,推荐使用rss模式。 - **SUBSCRIBE_RSS_INTERVAL:** RSS订阅模式刷新时间间隔(分钟),默认`30`分钟,不能小于5分钟。 - **SUBSCRIBE_SEARCH:** 订阅搜索,`true`/`false`,默认`false`,开启后会每隔24小时对所有订阅进行全量搜索,以补齐缺失剧集(一般情况下正常订阅即可,订阅搜索只做为兜底,会增加站点压力,不建议开启)。 -- **MESSAGER:** 消息通知渠道,支持 `telegram`/`wechat`/`slack`,开启多个渠道时使用`,`分隔。同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`telegram` +- **MESSAGER:** 消息通知渠道,支持 `telegram`/`wechat`/`slack`/`synologychat`,开启多个渠道时使用`,`分隔。同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`telegram` - `wechat`设置项: @@ -108,6 +108,11 @@ docker pull jxxghp/moviepilot:latest - **SLACK_OAUTH_TOKEN:** Slack Bot User OAuth Token - **SLACK_APP_TOKEN:** Slack App-Level Token - **SLACK_CHANNEL:** Slack 频道名称,默认`全体` + + - `synologychat`设置项: + + - **SYNOLOGYCHAT_WEBHOOK:** 在Synology Chat中创建机器人,获取机器人`传入URL` + - **SYNOLOGYCHAT_TOKEN:** SynologyChat机器人`令牌` - **DOWNLOADER:** 下载器,支持`qbittorrent`/`transmission`,QB版本号要求>= 4.3.9,TR版本号要求>= 3.0,同时还需要配置对应渠道的环境变量,非对应渠道的变量可删除,推荐使用`qbittorrent` @@ -229,9 +234,9 @@ docker pull jxxghp/moviepilot:latest - 通过CookieCloud同步快速同步站点,不需要使用的站点可在WEB管理界面中禁用,无法同步的站点可手动新增。 - 通过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`。 -- 将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` **注意** diff --git a/app/api/endpoints/message.py b/app/api/endpoints/message.py index 691f0262c..2f979331e 100644 --- a/app/api/endpoints/message.py +++ b/app/api/endpoints/message.py @@ -73,7 +73,9 @@ def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any: switchs = SystemConfigOper().get(SystemConfigKey.NotificationChannels) if not switchs: 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: for switch in switchs: return_list.append(NotificationSwitch(**switch)) diff --git a/app/core/config.py b/app/core/config.py index bdd25549e..bd5c34079 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -106,6 +106,10 @@ class Settings(BaseSettings): SLACK_APP_TOKEN: str = "" # Slack 频道名称 SLACK_CHANNEL: str = "" + # SynologyChat Webhook + SYNOLOGYCHAT_WEBHOOK: str = "" + # SynologyChat Token + SYNOLOGYCHAT_TOKEN: str = "" # 下载器 qbittorrent/transmission DOWNLOADER: str = "qbittorrent" # 下载器监控开关 diff --git a/app/modules/__init__.py b/app/modules/__init__.py index e164bbef4..7116fd5b4 100644 --- a/app/modules/__init__.py +++ b/app/modules/__init__.py @@ -58,6 +58,8 @@ def wrapper(self, message: Notification, *args, **kwargs): return None if channel_type == MessageChannel.Slack and not switch.get("slack"): return None + if channel_type == MessageChannel.SynologyChat and not switch.get("synologychat"): + return None return func(self, message, *args, **kwargs) return wrapper diff --git a/app/modules/synologychat/__init__.py b/app/modules/synologychat/__init__.py new file mode 100644 index 000000000..7707aa4af --- /dev/null +++ b/app/modules/synologychat/__init__.py @@ -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) diff --git a/app/modules/synologychat/synologychat.py b/app/modules/synologychat/synologychat.py new file mode 100644 index 000000000..a24c92a7c --- /dev/null +++ b/app/modules/synologychat/synologychat.py @@ -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 diff --git a/app/schemas/message.py b/app/schemas/message.py index 2849d53aa..5abcd5c07 100644 --- a/app/schemas/message.py +++ b/app/schemas/message.py @@ -51,3 +51,5 @@ class NotificationSwitch(BaseModel): telegram: Optional[bool] = False # Slack开关 slack: Optional[bool] = False + # SynologyChat开关 + synologychat: Optional[bool] = False diff --git a/app/schemas/types.py b/app/schemas/types.py index 4d3b7c1ff..3527ebc28 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -109,3 +109,4 @@ class MessageChannel(Enum): Wechat = "微信" Telegram = "Telegram" Slack = "Slack" + SynologyChat = "SynologyChat"