"""Slack IM 适配器 (U12)。 实现 :class:`MessageAdapter` 协议,对接 Slack Events API 与 Slash Commands。 关键设计决策: - 签名:``v0:{timestamp}:{body}`` 的 HMAC-SHA256,与 ``X-Slack-Signature`` 比对。 - 时间戳窗口 300s(Slack 官方推荐 5 分钟)。 - URL 验证:``url_verification`` 事件抛 :class:`URLVerificationChallenge`。 - Events API 与 Slash Commands 双流程:JSON body → Events API;form-encoded → Slash。 - ``<@U12345>`` 提及标记剥离。 - httpx 客户端懒构造。 """ from __future__ import annotations import hashlib import hmac import json import logging import re import time from urllib.parse import parse_qs from uuid import uuid4 import httpx from agentkit.channels.base import ( ChannelType, IncomingMessage, MessageAdapter, OutgoingMessage, URLVerificationChallenge, header_get, ) logger = logging.getLogger(__name__) # 签名时间戳允许的最大偏移(秒)— Slack 官方推荐 5 分钟 _SIGNATURE_MAX_AGE_SECONDS = 300 # Slack API 端点 _SEND_MESSAGE_URL = "https://slack.com/api/chat.postMessage" # Slack <@U12345> / <@U12345|name> 提及标记剥离 _MENTION_RE = re.compile(r"<@[^>]+>\s*") class SignatureVerificationError(Exception): """``verification_token`` 校验失败 — 拒绝处理。""" class SlackMessageAdapter(MessageAdapter): """Slack IM 适配器。 生命周期: ``__init__`` → :meth:`verify_signature` → :meth:`receive_message` → :meth:`send_message` → :meth:`close` Args: bot_token: Slack Bot User OAuth Token(``xoxb-`` 开头)。 signing_secret: Slack 应用 Signing Secret(签名校验)。 verification_token: 可选旧版 Verification Token(URL 验证时校验)。 """ def __init__( self, bot_token: str, signing_secret: str, verification_token: str | None = None, ) -> None: super().__init__() self.bot_token = bot_token self.signing_secret = signing_secret self.verification_token = verification_token # ------------------------------------------------------------------ # 签名验证 # ------------------------------------------------------------------ async def verify_signature(self, headers: dict[str, str], body: bytes) -> bool: """验证 Slack webhook 签名。 签名 = ``v0=`` + hmac-sha256(key=signing_secret, msg="v0:{timestamp}:{body}"), 与 ``X-Slack-Signature`` 头比对。时间戳超过 5 分钟视为重放攻击。 Args: headers: HTTP 请求头(键大小写不敏感查找)。 body: 原始请求体字节。 Returns: True 表示签名校验通过。 """ signature = header_get(headers, "X-Slack-Signature") timestamp_str = header_get(headers, "X-Slack-Request-Timestamp") if not signature or not timestamp_str: return False # 时间戳重放保护 try: ts = int(timestamp_str) except ValueError: return False now = time.time() if abs(now - ts) > _SIGNATURE_MAX_AGE_SECONDS: logger.warning("Slack webhook 时间戳超出 %ds 窗口 — 拒绝", _SIGNATURE_MAX_AGE_SECONDS) return False # 计算签名:v0={timestamp}:{body} base = f"v0:{timestamp_str}:{body.decode('utf-8')}" expected = ( "v0=" + hmac.new( self.signing_secret.encode("utf-8"), base.encode("utf-8"), hashlib.sha256, ).hexdigest() ) return hmac.compare_digest(signature, expected) # ------------------------------------------------------------------ # 消息接收 / 解析 # ------------------------------------------------------------------ async def receive_message(self, headers: dict[str, str], body: bytes) -> IncomingMessage: """解析 Slack webhook 事件为标准化 :class:`IncomingMessage`。 - JSON body:Events API 或 URL 验证。 - form-encoded body:Slash Command。 Raises: URLVerificationChallenge: URL 验证事件。 SignatureVerificationError: ``verification_token`` 不匹配。 """ # 优先尝试 JSON 解析(Events API / URL 验证) try: data: dict[str, object] = json.loads(body) except (json.JSONDecodeError, UnicodeDecodeError): data = {} if data: return self._parse_event(data) # 非 JSON — 视为 Slash Command(form-encoded) return self._parse_slash_command(body) def _parse_event(self, data: dict[str, object]) -> IncomingMessage: """解析 Events API 事件。""" # URL 验证流程 if data.get("type") == "url_verification": challenge = data.get("challenge", "") token = data.get("token", "") if self.verification_token is not None and not hmac.compare_digest( token, self.verification_token ): raise SignatureVerificationError("verification_token 不匹配") raise URLVerificationChallenge(challenge) event = data.get("event", {}) event_id = data.get("event_id", "") event_type = event.get("type", "") user = str(event.get("user", "")) channel = event.get("channel", "") text = event.get("text", "") ts = event.get("ts", "") # 仅支持 message / app_mention 事件 if event_type not in ("message", "app_mention"): return IncomingMessage( channel=ChannelType.SLACK, platform_message_id=event_id, user_id=user, chat_id=channel, content=f"[unsupported event type: {event_type}]", raw_event=data, timestamp=ts, ) # 剥离 <@U12345> 提及标记 text = _MENTION_RE.sub("", text).strip() return IncomingMessage( channel=ChannelType.SLACK, platform_message_id=event_id, user_id=user, chat_id=channel, content=text, raw_event=data, timestamp=ts, ) def _parse_slash_command(self, body: bytes) -> IncomingMessage: """解析 Slash Command(form-encoded body)。""" params = parse_qs(body.decode("utf-8")) text = params.get("text", [""])[0] user_id = params.get("user_id", [""])[0] channel_id = params.get("channel_id", [""])[0] command = params.get("command", [""])[0] return IncomingMessage( channel=ChannelType.SLACK, platform_message_id=f"slash-{uuid4()}", user_id=user_id, chat_id=channel_id, content=text, raw_event={"command": command, "form": {k: v[0] for k, v in params.items()}}, timestamp=str(time.time()), ) # ------------------------------------------------------------------ # 消息发送 # ------------------------------------------------------------------ async def send_message(self, message: OutgoingMessage) -> bool: """向 Slack 发送文本消息(chat.postMessage)。 Returns: True 表示 HTTP 200 且响应 ``ok == true``。 """ try: client = self._get_client() resp = await client.post( _SEND_MESSAGE_URL, headers={"Authorization": f"Bearer {self.bot_token}"}, json={"channel": message.chat_id, "text": message.content}, ) if resp.status_code != 200: logger.error("Slack send_message HTTP %d: %s", resp.status_code, resp.text[:200]) return False data = resp.json() if not data.get("ok"): logger.error( "Slack send_message 业务失败 ok=%s error=%s", data.get("ok"), data.get("error", "")[:200], ) return False return True except httpx.HTTPError as exc: logger.error("Slack send_message 网络错误: %s", exc) return False