Skip to content

Commit

Permalink
feat SynologyChat
Browse files Browse the repository at this point in the history
  • Loading branch information
jxxghp committed Sep 22, 2023
1 parent ea160af commit 7f6beb2
Show file tree
Hide file tree
Showing 8 changed files with 309 additions and 4 deletions.
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`设置项:

Expand All @@ -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`
Expand Down Expand Up @@ -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`

**注意**
Expand Down
4 changes: 3 additions & 1 deletion app/api/endpoints/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
4 changes: 4 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
# 下载器监控开关
Expand Down
2 changes: 2 additions & 0 deletions app/modules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
85 changes: 85 additions & 0 deletions app/modules/synologychat/__init__.py
Original file line number Diff line number Diff line change
@@ -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 changes: 204 additions & 0 deletions app/modules/synologychat/synologychat.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions app/schemas/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,5 @@ class NotificationSwitch(BaseModel):
telegram: Optional[bool] = False
# Slack开关
slack: Optional[bool] = False
# SynologyChat开关
synologychat: Optional[bool] = False
1 change: 1 addition & 0 deletions app/schemas/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,4 @@ class MessageChannel(Enum):
Wechat = "微信"
Telegram = "Telegram"
Slack = "Slack"
SynologyChat = "SynologyChat"

0 comments on commit 7f6beb2

Please sign in to comment.