diff --git a/app/api/endpoints/message.py b/app/api/endpoints/message.py index 2a768bd0..59fdd7b1 100644 --- a/app/api/endpoints/message.py +++ b/app/api/endpoints/message.py @@ -40,7 +40,7 @@ async def user_message(background_tasks: BackgroundTasks, request: Request): def wechat_verify(echostr: str, msg_signature: str, timestamp: Union[str, int], nonce: str) -> Any: """ - 用户消息响应 + 微信验证响应 """ logger.info(f"收到微信验证请求: {echostr}") try: @@ -60,6 +60,14 @@ def wechat_verify(echostr: str, msg_signature: str, return PlainTextResponse(sEchoStr) +@router.get("/", summary="VoceChat验证") +def vocechat_verify() -> Any: + """ + VoceChat验证响应 + """ + return {"status": "OK"} + + @router.get("/switchs", summary="查询通知消息渠道开关", response_model=List[NotificationSwitch]) def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any: """ @@ -72,7 +80,7 @@ def read_switchs(_: schemas.TokenPayload = Depends(verify_token)) -> Any: for noti in NotificationType: return_list.append(NotificationSwitch(mtype=noti.value, wechat=True, telegram=True, slack=True, - synologychat=True)) + synologychat=True, vocechat=True)) else: for switch in switchs: return_list.append(NotificationSwitch(**switch)) diff --git a/app/core/config.py b/app/core/config.py index 28a5a13d..7f0ac4d6 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -83,7 +83,7 @@ class Settings(BaseSettings): AUTH_SITE: str = "" # 交互搜索自动下载用户ID,使用,分割 AUTO_DOWNLOAD_USER: Optional[str] = None - # 消息通知渠道 telegram/wechat/slack,多个通知渠道用,分隔 + # 消息通知渠道 telegram/wechat/slack/synologychat/vocechat,多个通知渠道用,分隔 MESSAGER: str = "telegram" # WeChat企业ID WECHAT_CORPID: Optional[str] = None @@ -117,6 +117,12 @@ class Settings(BaseSettings): SYNOLOGYCHAT_WEBHOOK: str = "" # SynologyChat Token SYNOLOGYCHAT_TOKEN: str = "" + # VoceChat地址 + VOCECHAT_HOST: str = "" + # VoceChat ApiKey + VOCECHAT_API_KEY: str = "" + # VoceChat 频道ID + VOCECHAT_CHANNEL_ID: str = "" # 下载器 qbittorrent/transmission DOWNLOADER: str = "qbittorrent" # 下载器监控开关 diff --git a/app/modules/__init__.py b/app/modules/__init__.py index ae8fa8bf..98dbc526 100644 --- a/app/modules/__init__.py +++ b/app/modules/__init__.py @@ -67,6 +67,8 @@ def checkMessage(channel_type: MessageChannel): return None if channel_type == MessageChannel.SynologyChat and not switch.get("synologychat"): return None + if channel_type == MessageChannel.VoceChat and not switch.get("vocechat"): + return None return func(self, message, *args, **kwargs) return wrapper diff --git a/app/modules/vocechat/__init__.py b/app/modules/vocechat/__init__.py new file mode 100644 index 00000000..1ea546ee --- /dev/null +++ b/app/modules/vocechat/__init__.py @@ -0,0 +1,128 @@ +import json +from typing import Optional, Union, List, Tuple, Any, Dict + +from app.core.config import settings +from app.core.context import Context, MediaInfo +from app.log import logger +from app.modules import _ModuleBase, checkMessage +from app.modules.vocechat.vocechat import VoceChat +from app.schemas import MessageChannel, CommingMessage, Notification + + +class VoceChatModule(_ModuleBase): + vocechat: VoceChat = None + + def init_module(self) -> None: + self.vocechat = VoceChat() + + def stop(self): + pass + + def test(self) -> Tuple[bool, str]: + """ + 测试模块连接性 + """ + state = self.vocechat.get_state() + if state: + return True, "" + return False, "获取VoceChat频道失败" + + def init_setting(self) -> Tuple[str, Union[str, bool]]: + return "MESSAGER", "vocechat" + + def message_parser(self, body: Any, form: Any, + args: Any) -> Optional[CommingMessage]: + """ + 解析消息内容,返回字典,注意以下约定值: + userid: 用户ID + username: 用户名 + text: 内容 + :param body: 请求体 + :param form: 表单 + :param args: 参数 + :return: 渠道、消息体 + """ + try: + """ + { + "created_at": 1672048481664, //消息创建的时间戳 + "detail": { + "content": "hello this is my message to you", //消息内容 + "content_type": "text/plain", //消息类型,text/plain:纯文本消息,text/markdown:markdown消息,vocechat/file:文件类消息 + "expires_in": null, //消息过期时长,如果有大于0数字,说明该消息是个限时消息 + "properties": null, //一些有关消息的元数据,比如at信息,文件消息的具体类型信息,如果是个图片消息,还会有一些宽高,图片名称等元信息 + "type": "normal" //消息类型,normal代表是新消息 + }, + "from_uid": 7910, //来自于谁 + "mid": 2978, //消息ID + "target": { "gid": 2 } //发送给谁,gid代表是发送给频道,uid代表是发送给个人,此时的数据结构举例:{"uid":1} + } + """ + # URL参数 + print("----VoceChat Body----") + print(body) + print("----VoceChat from----") + print(form) + print("----VoceChat args----") + print(args) + # 报文体 + msg_body = json.loads(body) + # 类型 + msg_type = msg_body.get("detail", {}).get("type") + if msg_type != "normal": + # 非新消息 + return None + # 文本内容 + content = msg_body.get("detail", {}).get("content") + # 用户ID + gid = msg_body.get("target", {}).get("gid") + if gid and gid == settings.VOCECHAT_CHANNEL_ID: + # 来自监听频道的消息 + userid = f"GID#{gid}" + else: + # 来自个人的消息 + userid = f"UID#{msg_body.get('from_uid')}" + + # 处理消息内容 + if content and userid: + logger.info(f"收到VoceChat消息:userid={userid}, text={content}") + return CommingMessage(channel=MessageChannel.VoceChat, + userid=userid, username=userid, text=content) + except Exception as err: + logger.error(f"VoceChat消息处理发生错误:{str(err)}") + return None + + @checkMessage(MessageChannel.VoceChat) + def post_message(self, message: Notification) -> None: + """ + 发送消息 + :param message: 消息内容 + :return: 成功或失败 + """ + self.vocechat.send_msg(title=message.title, text=message.text, userid=message.userid) + + @checkMessage(MessageChannel.VoceChat) + def post_medias_message(self, message: Notification, medias: List[MediaInfo]) -> Optional[bool]: + """ + 发送媒体信息选择列表 + :param message: 消息内容 + :param medias: 媒体列表 + :return: 成功或失败 + """ + # 先发送标题 + self.vocechat.send_msg(title=message.title, userid=message.userid) + # 再发送内容 + return self.vocechat.send_medias_msg(title=message.title, medias=medias, userid=message.userid) + + @checkMessage(MessageChannel.VoceChat) + def post_torrents_message(self, message: Notification, torrents: List[Context]) -> Optional[bool]: + """ + 发送种子信息选择列表 + :param message: 消息内容 + :param torrents: 种子列表 + :return: 成功或失败 + """ + return self.vocechat.send_torrents_msg(title=message.title, torrents=torrents, userid=message.userid) + + def register_commands(self, commands: Dict[str, dict]): + pass diff --git a/app/modules/vocechat/vocechat.py b/app/modules/vocechat/vocechat.py new file mode 100644 index 00000000..01ebcd1b --- /dev/null +++ b/app/modules/vocechat/vocechat.py @@ -0,0 +1,196 @@ +import re +import threading +from typing import Optional, List + +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.common import retry +from app.utils.http import RequestUtils +from app.utils.string import StringUtils + +lock = threading.Lock() + + +class VoceChat: + # host + _host = None + # apikey + _apikey = None + # 频道ID + _channel_id = None + # 请求对象 + _client = None + + def __init__(self): + """ + 初始化 + """ + self._host = settings.VOCECHAT_HOST + if self._host: + if not self._host.endswith("/"): + self._host += "/" + if not self._host.startswith("http"): + self._playhost = "http://" + self._host + self._apikey = settings.VOCECHAT_API_KEY + self._channel_id = settings.VOCECHAT_CHANNEL_ID + if self._apikey and self._host and self._channel_id: + self._client = RequestUtils(headers={ + "content-type": "text/markdown", + "x-api-key": self._apikey, + "accept": "application/json; charset=utf-8" + }) + + def get_state(self): + """ + 获取状态 + """ + return True if self.get_groups() else False + + def get_groups(self): + """ + 获取频道列表 + """ + if not self._client: + return None + result = self._client.get_res(f"{self._host}api/bot") + if result and result.status_code == 200: + return result.json() + + def send_msg(self, title: str, text: str = "", userid: str = None) -> Optional[bool]: + """ + 微信消息发送入口,支持文本、图片、链接跳转、指定发送对象 + :param title: 消息标题 + :param text: 消息内容 + :param userid: 消息发送对象的ID,为空则发给所有人 + :return: 发送状态,错误信息 + """ + if not self._client: + return None + + if not title and not text: + logger.warn("标题和内容不能同时为空") + return False + + try: + if text: + caption = f"*{title}*\n{text}" + else: + caption = f"*{title}*" + + if userid: + chat_id = userid + else: + chat_id = f"GID#{self._channel_id}" + + return self.__send_request(userid=chat_id, caption=caption) + + except Exception as msg_e: + logger.error(f"发送消息失败:{msg_e}") + return False + + def send_medias_msg(self, title: str, medias: List[MediaInfo], userid: str = "") -> Optional[bool]: + """ + 发送列表类消息 + """ + if not self._client: + return None + + try: + 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: + chat_id = userid + else: + chat_id = f"GID#{self._channel_id}" + + return self.__send_request(userid=chat_id, caption=caption) + + except Exception as msg_e: + logger.error(f"发送消息失败:{msg_e}") + return False + + def send_torrents_msg(self, torrents: List[Context], + userid: str = "", title: str = "") -> Optional[bool]: + """ + 发送列表消息 + """ + if not self._client: + return None + + if not torrents: + return False + + try: + index, caption = 1, "*%s*" % title + mediainfo = torrents[0].media_info + 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}↑" + caption = f"{caption}\n{index}.【{site_name}】[{title}]({link}) " \ + f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}" + index += 1 + + if userid: + chat_id = userid + else: + chat_id = f"GID#{self._channel_id}" + + return self.__send_request(userid=chat_id, caption=caption, + image=mediainfo.get_message_image()) + + except Exception as msg_e: + logger.error(f"发送消息失败:{msg_e}") + return False + + @retry(Exception, logger=logger) + def __send_request(self, userid: str, caption: str) -> bool: + """ + 向VoceChat发送报文 + userid格式:UID#xxx / GID#xxx + """ + if not self._client: + return False + if userid.startswith("GID#"): + action = "send_to_group" + else: + action = "send_to_user" + idstr = userid[4:] + with lock: + try: + result = self._client.post_res(f"{self._host}api/bot/{action}/{idstr}", data=caption) + if result and result.status_code == 200: + return True + else: + logger.error(f"VoceChat发送消息失败:{result.text}") + return False + except Exception as msg_e: + logger.error(f"VoceChat发送消息错误:{str(msg_e)}") + return False diff --git a/app/schemas/message.py b/app/schemas/message.py index 5abcd5c0..051357e1 100644 --- a/app/schemas/message.py +++ b/app/schemas/message.py @@ -53,3 +53,5 @@ class NotificationSwitch(BaseModel): slack: Optional[bool] = False # SynologyChat开关 synologychat: Optional[bool] = False + # VoceChat开关 + vocechat: Optional[bool] = False diff --git a/app/schemas/types.py b/app/schemas/types.py index ee64ed62..528b8300 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -116,3 +116,4 @@ class MessageChannel(Enum): Telegram = "Telegram" Slack = "Slack" SynologyChat = "SynologyChat" + VoceChat = "VoceChat"