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_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`
|
||||
|
||||
**注意**
|
||||
|
@ -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))
|
||||
|
@ -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"
|
||||
# 下载器监控开关
|
||||
|
@ -58,6 +58,8 @@ def checkMessage(channel_type: MessageChannel):
|
||||
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
|
||||
|
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
|
||||
# Slack开关
|
||||
slack: Optional[bool] = False
|
||||
# SynologyChat开关
|
||||
synologychat: Optional[bool] = False
|
||||
|
@ -109,3 +109,4 @@ class MessageChannel(Enum):
|
||||
Wechat = "微信"
|
||||
Telegram = "Telegram"
|
||||
Slack = "Slack"
|
||||
SynologyChat = "SynologyChat"
|
||||
|
Reference in New Issue
Block a user