import json import re from threading import Lock from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from urllib.parse import quote 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.http import RequestUtils from app.utils.string import StringUtils lock = Lock() class Slack: """Slack 通知与交互客户端。""" _client: WebClient = None _service: SocketModeHandler = None _ds_url = f"http://127.0.0.1:{settings.PORT}/api/v1/message?token={settings.API_TOKEN}" _channel = "" _oauth_token = "" _MAX_SLASH_COMMANDS = 50 _SLASH_COMMAND_USAGE_HINT = "MoviePilot 可选参数" def __init__(self, SLACK_OAUTH_TOKEN: Optional[str] = None, SLACK_APP_TOKEN: Optional[str] = None, SLACK_CHANNEL: Optional[str] = None, SLACK_APP_ID: Optional[str] = None, SLACK_APP_CONFIG_TOKEN: Optional[str] = None, SLACK_COMMAND_REQUEST_URL: Optional[str] = None, **kwargs): """ 初始化 Slack 客户端。 :param SLACK_OAUTH_TOKEN: Slack Bot User OAuth Token :param SLACK_APP_TOKEN: Slack Socket Mode App Token :param SLACK_CHANNEL: 默认发送频道 :param SLACK_APP_ID: Slack App ID,用于可选的 Manifest 命令自动注册 :param SLACK_APP_CONFIG_TOKEN: Slack App Configuration Token,用于可选的 Manifest 命令自动注册 :param SLACK_COMMAND_REQUEST_URL: Slash Command 请求 URL,Socket Mode 下可为空 """ if not SLACK_OAUTH_TOKEN or not SLACK_APP_TOKEN: logger.error("Slack 配置不完整!") return try: slack_app = App(token=SLACK_OAUTH_TOKEN, ssl_check_enabled=False, url_verification_enabled=False) except Exception as err: logger.error(f"Slack初始化失败: {str(err)}") return self._client = slack_app.client self._channel = SLACK_CHANNEL self._oauth_token = SLACK_OAUTH_TOKEN self._app_id = (SLACK_APP_ID or "").strip() self._command_request_url = (SLACK_COMMAND_REQUEST_URL or "").strip() self._manifest_client = ( WebClient(token=SLACK_APP_CONFIG_TOKEN) if SLACK_APP_CONFIG_TOKEN and self._app_id else None ) self._registered_command_names: set[str] = set() # 标记消息来源 if kwargs.get("name"): # URL encode the source name to handle special characters encoded_name = quote(kwargs.get('name'), safe='') self._ds_url = f"{self._ds_url}&source={encoded_name}" # 注册消息响应 @slack_app.event("message") def slack_message(message): with requests.post(self._ds_url, json=message, timeout=10) as local_res: logger.debug("message: %s processed, response is: %s" % (message, local_res.text)) @slack_app.action(re.compile(r"actionId-.*")) def slack_action(ack, body): ack() with requests.post(self._ds_url, json=body, timeout=60) as local_res: 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')}>") with requests.post(self._ds_url, json=body, timeout=10) as local_res: logger.debug("message: %s processed, response is: %s" % (body, local_res.text)) @slack_app.shortcut(re.compile(r"/*")) def slack_shortcut(ack, body): ack() with requests.post(self._ds_url, json=body, timeout=10) as local_res: logger.debug("message: %s processed, response is: %s" % (body, local_res.text)) @slack_app.command(re.compile(r"/*")) def slack_command(ack, body): ack() with requests.post(self._ds_url, json=body, timeout=10) as local_res: logger.debug("message: %s processed, response is: %s" % (body, local_res.text)) # 启动服务 try: self._service = SocketModeHandler( slack_app, 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 get_state(self) -> bool: """ 获取状态 """ return True if self._client else False def register_commands(self, commands: Dict[str, dict]) -> bool: """ 通过 Slack App Manifest 注册 Slash Commands。 :param commands: 命令字典,键为斜杠命令,值包含描述和分类等元数据 :return: 注册是否成功 """ if not self._manifest_client or not self._app_id: logger.debug("Slack 未配置 SLACK_APP_ID/SLACK_APP_CONFIG_TOKEN,跳过命令自动注册") return False return self._update_manifest_commands(commands or {}) def delete_commands(self) -> bool: """ 清理本实例自动注册过的 Slack Slash Commands。 :return: 清理是否成功 """ if not self._manifest_client or not self._app_id: logger.debug("Slack 未配置 SLACK_APP_ID/SLACK_APP_CONFIG_TOKEN,跳过命令清理") return False return self._update_manifest_commands({}) def _update_manifest_commands(self, commands: Dict[str, dict]) -> bool: """更新 Slack Manifest 中的 Slash Commands,保留非本实例管理的命令。""" try: manifest = self._export_manifest() if not manifest: return False features = manifest.setdefault("features", {}) existing_commands = features.get("slash_commands") or [] generated_commands = self._build_slash_commands(commands) managed_names = self._registered_command_names | { item["command"] for item in generated_commands } preserved_commands = [ item for item in existing_commands if ( isinstance(item, dict) and item.get("command") not in managed_names and item.get("usage_hint") != self._SLASH_COMMAND_USAGE_HINT ) ] available = max(self._MAX_SLASH_COMMANDS - len(preserved_commands), 0) if len(generated_commands) > available: logger.warning( f"Slack Slash Commands 超过平台上限,仅注册前 {available} 个" ) generated_commands = generated_commands[:available] features["slash_commands"] = preserved_commands + generated_commands result = self._manifest_client.apps_manifest_update( app_id=self._app_id, manifest=manifest, ) if result and result.get("ok") is False: logger.error(f"Slack Manifest 更新失败:{result.get('error')}") return False self._registered_command_names = { item["command"] for item in generated_commands } logger.info(f"Slack Slash Commands 已同步:{len(generated_commands)} 个") return True except Exception as err: logger.error(f"Slack Slash Commands 自动注册失败:{err}") return False def _export_manifest(self) -> Optional[Dict[str, Any]]: """导出 Slack App Manifest。""" result = self._manifest_client.apps_manifest_export(app_id=self._app_id) if result and result.get("ok") is False: logger.error(f"Slack Manifest 导出失败:{result.get('error')}") return None manifest = result.get("manifest") if result else None if isinstance(manifest, str): manifest = json.loads(manifest) return manifest if isinstance(manifest, dict) else None def _build_slash_commands(self, commands: Dict[str, dict]) -> List[Dict[str, Any]]: """构建 Slack Manifest Slash Commands 配置。""" slash_commands = [] seen_commands = set() for command_text, command_data in commands.items(): command = self._normalize_slack_command(command_text) if not command or command in seen_commands: logger.warning(f"跳过无效或重复的 Slack Slash Command:{command_text}") continue seen_commands.add(command) description = self._normalize_slack_description( command_data.get("description") if isinstance(command_data, dict) else None, command, ) item = { "command": command, "description": description, "should_escape": False, "usage_hint": self._SLASH_COMMAND_USAGE_HINT, } if self._command_request_url: item["url"] = self._command_request_url slash_commands.append(item) return slash_commands @staticmethod def _normalize_slack_command(command_text: str) -> str: """转换为 Slack Slash Command 名称。""" command = f"/{str(command_text or '').strip().lstrip('/').lower()}" if not re.fullmatch(r"/[a-z0-9_-]{1,31}", command): return "" return command @staticmethod def _normalize_slack_description( description: Optional[str], fallback: str, ) -> str: """整理 Slack Slash Command 描述。""" normalized = str(description or fallback or "MoviePilot").strip() return normalized[:2000] or "MoviePilot" def download_file(self, file_url: str) -> Optional[Tuple[bytes, str]]: """ 下载Slack私有文件 :param file_url: Slack文件URL :return: (文件内容, MIME类型) """ if not self._client or not self._oauth_token or not file_url: return None try: headers = { "Authorization": f"Bearer {self._oauth_token}", "User-Agent": settings.USER_AGENT, "Accept": "*/*", } resp = RequestUtils(headers=headers, timeout=30).get_res(file_url) if resp and resp.content: mime_type = resp.headers.get("Content-Type", "image/jpeg") return resp.content, mime_type.split(";")[0] except Exception as e: logger.error(f"下载Slack文件失败: {e}") return None def send_msg(self, title: str, text: Optional[str] = None, image: Optional[str] = None, link: Optional[str] = None, userid: Optional[str] = None, buttons: Optional[List[List[dict]]] = None, original_message_id: Optional[str] = None, original_chat_id: Optional[str] = None): """ 发送Slack消息 :param title: 消息标题 :param text: 消息内容 :param image: 消息图片地址 :param link: 点击消息转转的URL :param userid: 用户ID,如有则只发消息给该用户 :param buttons: 消息按钮列表,格式为 [[{"text": "按钮文本", "callback_data": "回调数据", "url": "链接"}]] :param original_message_id: 原消息的时间戳,如果提供则编辑原消息 :param original_chat_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() # 消息文本 message_text = "" # 结构体 blocks = [] if not image: message_text = f"{title}\n{text or ''}" else: # 消息图片 if image: # 拼装消息内容 blocks.append({"type": "section", "text": { "type": "mrkdwn", "text": f"*{title}*\n{text or ''}" }, 'accessory': { "type": "image", "image_url": f"{image}", "alt_text": f"{title}" }}) # 自定义按钮 if buttons: for button_row in buttons: elements = [] for button in button_row: if "url" in button: # URL按钮 elements.append({ "type": "button", "text": { "type": "plain_text", "text": button["text"], "emoji": True }, "url": button["url"], "action_id": f"actionId-url-{button.get('text', 'url')}-{len(elements)}" }) else: # 回调按钮 elements.append({ "type": "button", "text": { "type": "plain_text", "text": button["text"], "emoji": True }, "value": button["callback_data"], "action_id": f"actionId-{button['callback_data']}" }) if elements: blocks.append({ "type": "actions", "elements": elements }) elif link: # 默认链接按钮 blocks.append({ "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "查看详情", "emoji": True }, "value": "click_me_url", "url": f"{link}", "action_id": "actionId-url" } ] }) # 判断是编辑消息还是发送新消息 if original_message_id and original_chat_id: # 编辑消息 result = self._client.chat_update( channel=original_chat_id, ts=original_message_id, text=message_text[:1000], blocks=blocks or [] ) else: # 发送新消息 result = self._client.chat_postMessage( channel=channel, text=message_text[:1000], blocks=blocks, mrkdwn=True ) return True, result except Exception as msg_e: logger.error(f"Slack消息发送失败: {msg_e}") return False, str(msg_e) def send_file( self, file_path: str, title: Optional[str] = None, text: Optional[str] = None, userid: Optional[str] = None, file_name: Optional[str] = None, ): """ 发送本地文件到 Slack。 """ if not self._client: return False, "消息客户端未就绪" if not file_path: return False, "文件路径不能为空" local_file = Path(file_path) if not local_file.exists() or not local_file.is_file(): return False, f"文件不存在: {local_file}" try: if userid: channel = userid else: channel = self.__find_public_channel() comment_parts = [part for part in [title, text] if part] initial_comment = "\n".join(comment_parts) if comment_parts else None with local_file.open("rb") as fp: result = self._client.files_upload_v2( channel=channel, file=fp, filename=file_name or local_file.name, title=title or (file_name or local_file.name), initial_comment=initial_comment, ) return True, result except Exception as err: logger.error(f"Slack文件发送失败: {err}") return False, str(err) def add_reaction(self, channel: str, timestamp: str, emoji: str) -> bool: """ 为 Slack 消息添加 reaction,用作正在处理状态。 """ if not self._client or not channel or not timestamp or not emoji: return False try: result = self._client.reactions_add( channel=channel, timestamp=timestamp, name=emoji, ) return bool(result and result.get("ok", True)) except Exception as err: logger.error(f"Slack添加reaction失败: {err}") return False def remove_reaction(self, channel: str, timestamp: str, emoji: str) -> bool: """ 移除 Slack 消息 reaction。 """ if not self._client or not channel or not timestamp or not emoji: return False try: result = self._client.reactions_remove( channel=channel, timestamp=timestamp, name=emoji, ) return bool(result and result.get("ok", True)) except Exception as err: logger.error(f"Slack移除reaction失败: {err}") return False def send_medias_msg(self, medias: List[MediaInfo], userid: Optional[str] = None, title: Optional[str] = None, buttons: Optional[List[List[dict]]] = None, original_message_id: Optional[str] = None, original_chat_id: Optional[str] = None) -> Optional[bool]: """ 发送媒体列表消息 :param medias: 媒体信息列表 :param userid: 用户ID,如有则只发消息给该用户 :param title: 消息标题 :param buttons: 按钮列表,格式:[[{"text": "按钮文本", "callback_data": "回调数据"}]] :param original_message_id: 原消息的时间戳,如果提供则编辑原消息 :param original_chat_id: 原消息的频道ID,编辑消息时需要 """ 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 # 如果有自定义按钮,先添加所有媒体项,然后添加统一的按钮 if buttons: # 添加媒体列表(不带单独的选择按钮) for media in medias: if media.get_poster_image(): if media.vote_star: text = f"{index}. *<{media.detail_link}|{media.title_year}>*" \ f"\n类型:{media.type.value}" \ f"\n{media.vote_star}" \ f"\n{media.get_overview_string(50)}" else: text = f"{index}. *<{media.detail_link}|{media.title_year}>*" \ 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.title_year}" } } ) index += 1 # 添加统一的自定义按钮(在所有媒体项之后) for button_row in buttons: elements = [] for button in button_row: if "url" in button: elements.append({ "type": "button", "text": { "type": "plain_text", "text": button["text"], "emoji": True }, "url": button["url"], "action_id": f"actionId-url-{button.get('text', 'url')}-{len(elements)}" }) else: elements.append({ "type": "button", "text": { "type": "plain_text", "text": button["text"], "emoji": True }, "value": button["callback_data"], "action_id": f"actionId-{button['callback_data']}" }) if elements: blocks.append({ "type": "actions", "elements": elements }) else: # 使用默认的每个媒体项单独按钮 for media in medias: if media.get_poster_image(): if media.vote_star: text = f"{index}. *<{media.detail_link}|{media.title_year}>*" \ f"\n类型:{media.type.value}" \ f"\n{media.vote_star}" \ f"\n{media.get_overview_string(50)}" else: text = f"{index}. *<{media.detail_link}|{media.title_year}>*" \ 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.title_year}" } } ) # 使用默认选择按钮 blocks.append( { "type": "actions", "elements": [ { "type": "button", "text": { "type": "plain_text", "text": "选择", "emoji": True }, "value": f"{index}", "action_id": f"actionId-{index}" } ] } ) index += 1 # 判断是编辑消息还是发送新消息 if original_message_id and original_chat_id: # 编辑消息 result = self._client.chat_update( channel=original_chat_id, ts=original_message_id, text=title, blocks=blocks or [] ) else: # 发送新消息 result = self._client.chat_postMessage( channel=channel, text=title, 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: Optional[str] = None, title: Optional[str] = None, buttons: Optional[List[List[dict]]] = None, original_message_id: Optional[str] = None, original_chat_id: Optional[str] = None) -> Optional[bool]: """ 发送种子列表消息 :param torrents: 种子信息列表 :param userid: 用户ID,如有则只发消息给该用户 :param title: 消息标题 :param buttons: 按钮列表,格式:[[{"text": "按钮文本", "callback_data": "回调数据"}]] :param original_message_id: 原消息的时间戳,如果提供则编辑原消息 :param original_chat_id: 原消息的频道ID,编辑消息时需要 """ 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 # 如果有自定义按钮,先添加种子列表,然后添加统一的按钮 if buttons: # 添加种子列表(不带单独的选择按钮) for context in torrents: torrent = context.torrent_info site_name = torrent.site_name meta = MetaInfo(torrent.title, torrent.description) link = torrent.page_url title_text = f"{meta.season_episode} " \ f"{meta.resource_term} " \ f"{meta.video_term} " \ f"{meta.release_group}" title_text = re.sub(r"\s+", " ", title_text).strip() free = torrent.volume_factor seeder = f"{torrent.seeders}↑" description = torrent.description text = f"{index}. 【{site_name}】<{link}|{title_text}> " \ f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}\n" \ f"{description}" blocks.append( { "type": "section", "text": { "type": "mrkdwn", "text": text } } ) index += 1 # 添加统一的自定义按钮 for button_row in buttons: elements = [] for button in button_row: if "url" in button: elements.append({ "type": "button", "text": { "type": "plain_text", "text": button["text"], "emoji": True }, "url": button["url"], "action_id": f"actionId-url-{button.get('text', 'url')}-{len(elements)}" }) else: elements.append({ "type": "button", "text": { "type": "plain_text", "text": button["text"], "emoji": True }, "value": button["callback_data"], "action_id": f"actionId-{button['callback_data']}" }) if elements: blocks.append({ "type": "actions", "elements": elements }) else: # 使用默认的每个种子单独按钮 for context in torrents: torrent = context.torrent_info site_name = torrent.site_name meta = MetaInfo(torrent.title, torrent.description) link = torrent.page_url title_text = f"{meta.season_episode} " \ f"{meta.resource_term} " \ f"{meta.video_term} " \ f"{meta.release_group}" title_text = re.sub(r"\s+", " ", title_text).strip() free = torrent.volume_factor seeder = f"{torrent.seeders}↑" description = torrent.description text = f"{index}. 【{site_name}】<{link}|{title_text}> " \ f"{StringUtils.str_filesize(torrent.size)} {free} {seeder}\n" \ f"{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 # 判断是编辑消息还是发送新消息 if original_message_id and original_chat_id: # 编辑消息 result = self._client.chat_update( channel=original_chat_id, ts=original_message_id, text=title, blocks=blocks or [] ) else: # 发送新消息 result = self._client.chat_postMessage( channel=channel, text=title, blocks=blocks ) return True if result else False except Exception as msg_e: logger.error(f"Slack消息发送失败: {msg_e}") return False def delete_msg(self, message_id: str, chat_id: Optional[str] = None) -> Optional[bool]: """ 删除Slack消息 :param message_id: 消息时间戳(Slack消息ID) :param chat_id: 频道ID :return: 删除是否成功 """ if not self._client: return None try: # 确定要删除消息的频道ID if chat_id: target_channel = chat_id else: target_channel = self.__find_public_channel() if not target_channel: logger.error("无法确定要删除消息的Slack频道") return False # 删除消息 result = self._client.chat_delete( channel=target_channel, ts=message_id ) if result.get("ok"): logger.info(f"成功删除Slack消息: channel={target_channel}, ts={message_id}") return True else: logger.error(f"删除Slack消息失败: {result.get('error', 'unknown error')}") return False except Exception as e: logger.error(f"删除Slack消息异常: {str(e)}") return False def __find_public_channel(self): """ 查找公共频道 """ if not self._client: return "" conversation_id = "" try: for result in self._client.conversations_list(types="public_channel,private_channel"): if conversation_id: break for channel in result["channels"]: if channel.get("name") == (self._channel or "全体"): conversation_id = channel.get("id") break except Exception as e: logger.error(f"查找Slack公共频道失败: {str(e)}") return conversation_id