diff --git a/app/core/config.py b/app/core/config.py index a6081350..879b663c 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -57,7 +57,7 @@ class Settings(BaseSettings): INDEXER: str = "builtin" # 索引站点,站点域名关键字使用,分隔 INDEXER_SITES: str = "" - # 消息通知渠道 telegram/wechat + # 消息通知渠道 telegram/wechat/slack MESSAGER: str = "telegram" # WeChat企业ID WECHAT_CORPID: str = None @@ -81,6 +81,12 @@ class Settings(BaseSettings): TELEGRAM_USERS: str = "" # Telegram 管理员ID,使用,分隔 TELEGRAM_ADMINS: str = "" + # Slack Bot User OAuth Token + SLACK_OAUTH_TOKEN: str = "" + # Slack App-Level Token + SLACK_APP_TOKEN: str = "" + # Slack 频道名称 + SLACK_CHANNEL: str = "" # 下载器 qbittorrent/transmission DOWNLOADER: str = "qbittorrent" # Qbittorrent地址 diff --git a/app/modules/slack/__init__.py b/app/modules/slack/__init__.py new file mode 100644 index 00000000..93214391 --- /dev/null +++ b/app/modules/slack/__init__.py @@ -0,0 +1,200 @@ +import json +import re +from typing import Optional, Union, List, Tuple, Any + +from app.core.context import MediaInfo, Context +from app.core.config import settings +from app.log import logger +from app.modules import _ModuleBase +from app.modules.slack.slack import Slack + + +class SlackModule(_ModuleBase): + slack: Slack = None + + def init_module(self) -> None: + self.slack = Slack() + + def stop(self): + self.slack.stop() + + def init_setting(self) -> Tuple[str, Union[str, bool]]: + return "MESSAGER", "slack" + + def message_parser(self, body: Any, form: Any, args: Any) -> Optional[dict]: + """ + 解析消息内容,返回字典,注意以下约定值: + userid: 用户ID + username: 用户名 + text: 内容 + :param body: 请求体 + :param form: 表单 + :param args: 参数 + :return: 消息内容、用户ID + """ + """ + # 消息 + { + 'client_msg_id': '', + 'type': 'message', + 'text': 'hello', + 'user': '', + 'ts': '1670143568.444289', + 'blocks': [{ + 'type': 'rich_text', + 'block_id': 'i2j+', + 'elements': [{ + 'type': 'rich_text_section', + 'elements': [{ + 'type': 'text', + 'text': 'hello' + }] + }] + }], + 'team': '', + 'client': '', + 'event_ts': '1670143568.444289', + 'channel_type': 'im' + } + # 快捷方式 + { + "type": "shortcut", + "token": "XXXXXXXXXXXXX", + "action_ts": "1581106241.371594", + "team": { + "id": "TXXXXXXXX", + "domain": "shortcuts-test" + }, + "user": { + "id": "UXXXXXXXXX", + "username": "aman", + "team_id": "TXXXXXXXX" + }, + "callback_id": "shortcut_create_task", + "trigger_id": "944799105734.773906753841.38b5894552bdd4a780554ee59d1f3638" + } + # 按钮点击 + { + "type": "block_actions", + "team": { + "id": "T9TK3CUKW", + "domain": "example" + }, + "user": { + "id": "UA8RXUSPL", + "username": "jtorrance", + "team_id": "T9TK3CUKW" + }, + "api_app_id": "AABA1ABCD", + "token": "9s8d9as89d8as9d8as989", + "container": { + "type": "message_attachment", + "message_ts": "1548261231.000200", + "attachment_id": 1, + "channel_id": "CBR2V3XEX", + "is_ephemeral": false, + "is_app_unfurl": false + }, + "trigger_id": "12321423423.333649436676.d8c1bb837935619ccad0f624c448ffb3", + "client": { + "id": "CBR2V3XEX", + "name": "review-updates" + }, + "message": { + "bot_id": "BAH5CA16Z", + "type": "message", + "text": "This content can't be displayed.", + "user": "UAJ2RU415", + "ts": "1548261231.000200", + ... + }, + "response_url": "https://hooks.slack.com/actions/AABA1ABCD/1232321423432/D09sSasdasdAS9091209", + "actions": [ + { + "action_id": "WaXA", + "block_id": "=qXel", + "text": { + "type": "plain_text", + "text": "View", + "emoji": true + }, + "value": "click_me_123", + "type": "button", + "action_ts": "1548426417.840180" + } + ] + } + """ + # 校验token + token = args.get("token") + if not token or token != settings.API_TOKEN: + return None + try: + msg_json: dict = json.loads(body) + except Exception as err: + logger.error(f"解析Slack消息失败:{err}") + return None + if msg_json: + if msg_json.get("type") == "message": + userid = msg_json.get("user") + text = msg_json.get("text") + username = msg_json.get("user") + elif msg_json.get("type") == "block_actions": + userid = msg_json.get("user", {}).get("id") + text = msg_json.get("actions")[0].get("value") + username = msg_json.get("user", {}).get("name") + elif msg_json.get("type") == "event_callback": + userid = msg_json.get('event', {}).get('user') + text = re.sub(r"<@[0-9A-Z]+>", "", msg_json.get("event", {}).get("text"), flags=re.IGNORECASE).strip() + username = "" + elif msg_json.get("type") == "shortcut": + userid = msg_json.get("user", {}).get("id") + text = msg_json.get("callback_id") + username = msg_json.get("user", {}).get("username") + else: + return None + logger.info(f"收到Slack消息:userid={userid}, username={username}, text={text}") + return { + "userid": userid, + "username": username, + "text": text + } + return None + + def post_message(self, title: str, + text: str = None, image: str = None, + userid: Union[str, int] = None) -> Optional[bool]: + """ + 发送消息 + :param title: 标题 + :param text: 内容 + :param image: 图片 + :param userid: 用户ID + :return: 成功或失败 + """ + return self.slack.send_msg(title=title, text=text, image=image, userid=userid) + + def post_medias_message(self, title: str, items: List[MediaInfo], + userid: Union[str, int] = None) -> Optional[bool]: + """ + 发送媒体信息选择列表 + :param title: 标题 + :param items: 消息列表 + :param userid: 用户ID + :return: 成功或失败 + """ + return self.slack.send_meidas_msg(title=title, medias=items, userid=userid) + + def post_torrents_message(self, title: str, items: List[Context], + mediainfo: MediaInfo = None, + userid: Union[str, int] = None) -> Optional[bool]: + """ + 发送种子信息选择列表 + :param title: 标题 + :param items: 消息列表 + :param mediainfo: 媒体信息 + :param userid: 用户ID + :return: 成功或失败 + """ + return self.slack.send_torrents_msg(title=title, torrents=items, + userid=userid) diff --git a/app/modules/slack/slack.py b/app/modules/slack/slack.py new file mode 100644 index 00000000..1be91698 --- /dev/null +++ b/app/modules/slack/slack.py @@ -0,0 +1,325 @@ +import re +from threading import Lock +from typing import List, Optional + +import requests +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler +from slack_sdk import WebClient + +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.string import StringUtils + + +lock = Lock() + + +class Slack: + + _client: WebClient = None + _service: SocketModeHandler = None + + _ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/messages?token={settings.API_TOKEN}" + + def __init__(self): + + if not settings.SLACK_OAUTH_TOKEN or not settings.SLACK_APP_TOKEN: + return + + try: + slack_app = App(token=settings.SLACK_OAUTH_TOKEN, + ssl_check_enabled=False, + url_verification_enabled=False) + except Exception as err: + logger.error(f"Slack初始化失败: {err}") + return + self._client = slack_app.client + + # 注册消息响应 + @slack_app.event("message") + def slack_message(message): + local_res = requests.post(self._ds_url, json=message, timeout=10) + logger.debug("message: %s processed, response is: %s" % (message, local_res.text)) + + @slack_app.action(re.compile(r"actionId-\d+")) + def slack_action(ack, body): + ack() + local_res = requests.post(self._ds_url, json=body, timeout=60) + logger.debug("message: %s processed, response is: %s" % (body, local_res.text)) + + @slack_app.event("app_mention") + def slack_mention(say, body): + say(f"收到,请稍等... <@{body.get('event', {}).get('user')}>") + local_res = requests.post(self._ds_url, json=body, timeout=10) + logger.debug("message: %s processed, response is: %s" % (body, local_res.text)) + + @slack_app.shortcut(re.compile(r"/*")) + def slack_shortcut(ack, body): + ack() + local_res = requests.post(self._ds_url, json=body, timeout=10) + logger.debug("message: %s processed, response is: %s" % (body, local_res.text)) + + # 启动服务 + try: + self._service = SocketModeHandler( + slack_app, + settings.SLACK_APP_TOKEN + ) + self._service.connect() + logger.info("Slack消息接收服务启动") + except Exception as err: + logger.error("Slack消息接收服务启动失败: %s" % str(err)) + + def stop(self): + if self._service: + try: + self._service.close() + logger.info("Slack消息接收服务已停止") + except Exception as err: + logger.error("Slack消息接收服务停止失败: %s" % str(err)) + + def send_msg(self, title: str, text: str = "", image: str = "", url: str = "", userid: str = ""): + """ + 发送Telegram消息 + :param title: 消息标题 + :param text: 消息内容 + :param image: 消息图片地址 + :param url: 点击消息转转的URL + :param userid: 用户ID,如有则只发消息给该用户 + :user_id: 发送消息的目标用户ID,为空则发给管理员 + """ + if not self._client: + return False, "消息客户端未就绪" + if not title and not text: + return False, "标题和内容不能同时为空" + try: + if userid: + channel = userid + else: + # 消息广播 + channel = self.__find_public_channel() + # 拼装消息内容 + block = { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{title}*\n{text}" + } + } + # 消息图片 + if image: + block['accessory'] = { + "type": "image", + "image_url": f"{image}", + "alt_text": f"{title}" + } + blocks = [block] + # 链接 + if image and url: + blocks.append({ + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "查看详情", + "emoji": True + }, + "value": "click_me_url", + "url": f"{url}", + "action_id": "actionId-url" + } + ] + }) + # 发送 + result = self._client.chat_postMessage( + channel=channel, + blocks=blocks + ) + return True, result + except Exception as msg_e: + logger.error(f"Slack消息发送失败: {msg_e}") + return False, str(msg_e) + + def send_meidas_msg(self, medias: List[MediaInfo], userid: str = "", title: str = "") -> Optional[bool]: + """ + 发送列表类消息 + """ + if not self._client: + return False + if not medias: + return False + try: + if userid: + channel = userid + else: + # 消息广播 + channel = self.__find_public_channel() + # 消息主体 + title_section = { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{title}*" + } + } + blocks = [title_section] + # 列表 + if medias: + blocks.append({ + "type": "divider" + }) + index = 1 + for media in medias: + if media.get_poster_image(): + if media.get_star_string(): + text = f"{index}. *<{media.get_detail_url()}|{media.get_title_string()}>*" \ + f"\n类型:{media.type.value}" \ + f"\n{media.get_star_string()}" \ + f"\n{media.get_overview_string(50)}" + else: + text = f"{index}. *<{media.get_detail_url()}|{media.get_title_string()}>*" \ + f"\n类型:{media.type.value}" \ + f"\n{media.get_overview_string(50)}" + blocks.append( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": text + }, + "accessory": { + "type": "image", + "image_url": f"{media.get_poster_image()}", + "alt_text": f"{media.get_title_string()}" + } + } + ) + blocks.append( + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "选择", + "emoji": True + }, + "value": f"{index}", + "action_id": f"actionId-{index}" + } + ] + } + ) + index += 1 + # 发送 + result = self._client.chat_postMessage( + channel=channel, + blocks=blocks + ) + return True if result else False + except Exception as msg_e: + logger.error(f"Slack消息发送失败: {msg_e}") + return False + + def send_torrents_msg(self, torrents: List[Context], + userid: str = "", title: str = "") -> Optional[bool]: + """ + 发送列表消息 + """ + if not self._client: + return None + + try: + if userid: + channel = userid + else: + # 消息广播 + channel = self.__find_public_channel() + # 消息主体 + title_section = { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{title}*" + } + } + blocks = [title_section, { + "type": "divider" + }] + # 列表 + index = 1 + 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.get_season_episode_string()} " \ + f"{meta.get_resource_type_string()} " \ + f"{meta.get_resource_team_string()} " \ + f"{StringUtils.str_filesize(torrent.size)}" + title = re.sub(r"\s+", " ", title).strip() + free = torrent.get_volume_factor_string() + seeder = f"{torrent.seeders}↑" + description = torrent.description + text = f"{index}. 【{site_name}】[{title}]({link}) {free} {seeder}\n- {description}" + blocks.append( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": text + } + } + ) + blocks.append( + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "选择", + "emoji": True + }, + "value": f"{index}", + "action_id": f"actionId-{index}" + } + ] + } + ) + index += 1 + # 发送 + result = self._client.chat_postMessage( + channel=channel, + blocks=blocks + ) + return True if result else False + except Exception as msg_e: + logger.error(f"Slack消息发送失败: {msg_e}") + return False + + def __find_public_channel(self): + """ + 查找公共频道 + """ + if not self._client: + return "" + conversation_id = "" + try: + for result in self._client.conversations_list(): + if conversation_id: + break + for channel in result["channels"]: + if channel.get("name") == settings.SLACK_CHANNEL or "全体": + conversation_id = channel.get("id") + break + except Exception as e: + logger.error(f"查找Slack公共频道失败: {e}") + return conversation_id diff --git a/requirements.txt b/requirements.txt index e6c0fde0..0aa1ef7c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,4 +35,6 @@ pillow~=9.5.0 pyTelegramBotAPI~=4.12.0 playwright~=1.34.0 cf_clearance~=0.29.2 -torrentool~=1.2.0 \ No newline at end of file +torrentool~=1.2.0 +slack_bolt~=1.18.0 +slack_sdk \ No newline at end of file