fischer-agentkit/src/agentkit/channels/slack.py

241 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Slack IM 适配器 (U12)。
实现 :class:`MessageAdapter` 协议,对接 Slack Events API 与 Slash Commands。
关键设计决策:
- 签名:``v0:{timestamp}:{body}`` 的 HMAC-SHA256与 ``X-Slack-Signature`` 比对。
- 时间戳窗口 300sSlack 官方推荐 5 分钟)。
- URL 验证:``url_verification`` 事件抛 :class:`URLVerificationChallenge`。
- Events API 与 Slash Commands 双流程JSON body → Events APIform-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 TokenURL 验证时校验)。
"""
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 bodyEvents API 或 URL 验证。
- form-encoded bodySlash 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 Commandform-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 Commandform-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