241 lines
8.2 KiB
Python
241 lines
8.2 KiB
Python
"""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
|