ForcePilot/backend/package/yuxi/channels/adapters/slack/adapter.py
Kris df1a7c7bca refactor(slack-adapter): 整理导入顺序并修复格式问题
本次提交包含多个Slack适配器相关的代码优化:
1. 统一多个文件中datetime和UTC的导入顺序
2. 调整collection.abc导入的参数顺序
3. 修复normalizer.py的文件末尾空行问题
4. 重新排序blocks.py中的函数导入
5. 调整directory_config.py中的函数顺序
6. 重构http_handler中的channel_manager调用方式
7. 新增Slack原生流探测逻辑和相关状态管理
8. 扩展消息动作分类和默认配置
9. 新增大量Slack消息块构建工具函数
10. 大幅重构__init__.py的导出内容,整理导入顺序
11. 为adapter新增熔断机制、缓存持久化和更多API方法
12. 新增多种系统事件处理逻辑
2026-05-13 16:14:38 +08:00

2723 lines
112 KiB
Python

from __future__ import annotations
import asyncio
import hashlib
import hmac
import io
import os
import re
import time
from collections import OrderedDict
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from typing import Any, ClassVar
from slack_sdk.errors import SlackApiError
from slack_sdk.http_retry.builtin_async_handlers import AsyncRateLimitErrorRetryHandler
from slack_sdk.socket_mode.aiohttp import SocketModeClient
from slack_sdk.socket_mode.request import SocketModeRequest
from slack_sdk.web.async_client import AsyncWebClient
from yuxi.channels.adapters.slack.agent_prompt import SlackPromptHints
from yuxi.channels.adapters.slack.allowlist import AllowlistManager
from yuxi.channels.adapters.slack.approval import ApprovalManager
from yuxi.channels.adapters.slack.chunker import ChunkMode, resolve_text_chunks
from yuxi.channels.adapters.slack.commands import SlackCommandRegistry
from yuxi.channels.adapters.slack.interactive_replies import compile_interactive_replies, has_interactive_syntax
from yuxi.channels.adapters.slack.normalizer import normalize_slack_text
from yuxi.channels.adapters.slack.pairing import PairingManager
from yuxi.channels.adapters.slack.poll_manager import PollManager
from yuxi.channels.adapters.slack.reaction_notify import ReactionNotifyConfig
from yuxi.channels.adapters.slack.reconnect import Http401BackoffState
from yuxi.channels.adapters.slack.security import (
DmPolicy,
SecurityConfig,
SecurityDecision,
)
from yuxi.channels.adapters.slack.security_audit import (
audit_security_config,
auto_fix_security,
)
from yuxi.channels.adapters.slack.sent_cache import SentMessageCache
from yuxi.channels.adapters.slack.session import resolve_chat_id, resolve_chat_type
from yuxi.channels.adapters.slack.streaming_compat import StreamingConfig
from yuxi.channels.adapters.slack.vision.vision import SlackVisionConfig
from yuxi.channels.adapters.slack.voice.tts import SlackTTSConfig, synthesize_slack_tts
from yuxi.channels.base import BaseChannelAdapter
from yuxi.channels.capabilities import ChannelCapabilities, TTSCapabilities, TTSVoiceCapabilities
from yuxi.channels.exceptions import (
ChannelAuthenticationError,
ChannelException,
ChannelNotConnectedError,
)
from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError
from yuxi.channels.meta import ChannelMeta
from yuxi.channels.models import (
Attachment,
ChannelIdentity,
ChannelMessage,
ChannelResponse,
ChannelStatus,
ChannelType,
DeliveryResult,
EventType,
HealthStatus,
MentionsInfo,
MessageType,
)
from yuxi.channels.registry import register_builtin_adapter
from yuxi.utils.logging_config import logger
@register_builtin_adapter
class SlackAdapter(BaseChannelAdapter):
channel_id: ClassVar[str] = "slack"
channel_type: ClassVar[ChannelType] = ChannelType.SLACK
webhook_path: ClassVar[str | None] = "/api/webhook/slack"
text_chunk_limit: ClassVar[int] = 8000
supports_markdown: ClassVar[bool] = True
supports_streaming: ClassVar[bool] = True
streaming_modes: ClassVar[list[str]] = ["off", "partial", "block", "progress"]
max_media_size_mb: ClassVar[int] = 100
capabilities = ChannelCapabilities(
chat_types=["direct", "channel", "thread"],
reactions=True,
edit=True,
unsend=True,
reply=True,
threads=True,
media=True,
pin=True,
unpin=True,
list_pins=True,
native_commands=True,
supports_markdown=True,
supports_streaming=True,
streaming_modes=["off", "partial", "block", "progress"],
text_chunk_limit=8000,
max_media_size_mb=100,
polls=True,
group_management=True,
tts=TTSCapabilities(voice=TTSVoiceCapabilities(enabled=True, synthesis_target="voice-note")),
vision=True,
approval=True,
typing=True,
)
meta = ChannelMeta(
id="slack",
label="Slack",
aliases=["slack", "slackbot"],
markdown_capable=True,
selection_label="Slack (Socket Mode / HTTP)",
)
STREAM_UPDATE_MIN_INTERVAL_MS = 500
STREAM_TTL_SECONDS = 300
SOCKET_CONNECT_TIMEOUT_S = 30.0
MAX_RETRY_COUNT = 3
CHANNEL_EVENT_HEARTBEAT_TIMEOUT_S = 600
MAX_PROCESSED_EVENT_IDS = 1000
HTTP_REQUEST_TIMEOUT_S = 30.0
HTTP_MAX_BODY_BYTES = 1_048_576
PAIRING_TTL_SECONDS = 300.0
COALESCE_MIN_CHARS: ClassVar[int] = 1500
COALESCE_IDLE_MS: ClassVar[int] = 1000
_APP_TOKEN_PATTERN: ClassVar = re.compile(r"^xapp-\d-[a-z0-9]+-", re.IGNORECASE)
def __init__(self, config: dict[str, Any] | None = None):
super().__init__(config)
self._status: ChannelStatus = ChannelStatus.DISCONNECTED
self._client: AsyncWebClient | None = None
self._socket_handler: SocketModeClient | None = None
self._socket_task: asyncio.Task | None = None
self._connected_event = asyncio.Event()
self._connected_at: datetime | None = None
self._bot_user_id: str = ""
self._bot_id: str = ""
self._team: str = ""
self._team_id: str = ""
self._streaming_messages: dict[str, dict] = {}
self._stream_lock = asyncio.Lock()
self._stream_cleanup_task: asyncio.Task | None = None
self._stream_cleanup_interval_s = 60
self._last_channel_event_at: float = 0.0
self._channel_heartbeat_task: asyncio.Task | None = None
self._processed_event_ids: OrderedDict[str, None] = OrderedDict()
self._reconnect_count = 0
self._http_401_backoff = Http401BackoffState()
self._shutting_down = False
self._mode: str = config.get("mode", "socket") if config else "socket"
self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60, channel_id="slack")
self._signing_secret = (
config.get("signing_secret", "") or os.getenv("SLACK_SIGNING_SECRET", "")
if config
else os.getenv("SLACK_SIGNING_SECRET", "")
)
self._send_username: str = config.get("username", "") if config else ""
self._send_icon_url: str = config.get("icon_url", "") if config else ""
self._send_icon_emoji: str = config.get("icon_emoji", "") if config else ""
self._bot_token_source: str = "none"
self._app_token_source: str = "none"
self._user_token_source: str = "none"
self._user_token: str = ""
self._security_config = SecurityConfig.from_config(config)
self._allowlist_mgr = AllowlistManager(lambda: self._client)
self._pairing_mgr = PairingManager(ttl_seconds=self.PAIRING_TTL_SECONDS)
self._sent_cache = SentMessageCache()
self._poll_mgr = PollManager()
self._cmd_registry = SlackCommandRegistry()
self._cmd_registry.register_defaults()
self._approval_mgr = ApprovalManager()
self._reaction_notify_cfg = ReactionNotifyConfig.from_config(config)
self._tts_cfg = SlackTTSConfig.from_config(config)
self._vision_cfg = SlackVisionConfig.from_config(config)
self._prompt_hints = SlackPromptHints.from_config(config)
self._streaming_config = StreamingConfig.from_config(config)
self._dm_scope: str = config.get("dm_scope", "per-user") if config else "per-user"
self._native_stream_mgr: Any = None
self._inbound_queue: asyncio.Queue[ChannelMessage] = asyncio.Queue(maxsize=256)
self._shutdown_event = asyncio.Event()
async def pre_connect(self) -> dict:
bot_token = self._resolve_bot_token()
app_token = self._resolve_app_token()
if not bot_token:
return {"status": "error", "message": "Missing bot_token (SLACK_BOT_TOKEN)"}
if not app_token:
return {"status": "error", "message": "Missing app_token (SLACK_APP_TOKEN)"}
if not bot_token.startswith("xoxb-"):
return {"status": "error", "message": "bot_token must start with 'xoxb-'"}
if not app_token.startswith("xapp-"):
return {"status": "error", "message": "app_token must start with 'xapp-'"}
try:
client = AsyncWebClient(token=bot_token)
auth = await client.auth_test()
if not auth.get("ok"):
return {"status": "error", "message": f"auth.test failed: {auth.get('error')}"}
return {
"status": "ok",
"bot_id": auth.get("bot_id", ""),
"bot_user_id": auth.get("user_id", ""),
"team": auth.get("team", ""),
"team_id": auth.get("team_id", ""),
}
except SlackApiError as e:
return {"status": "error", "message": str(e)}
except Exception as e:
return {"status": "error", "message": str(e)}
async def connect(self) -> None:
bot_token = self._resolve_bot_token()
app_token = self._resolve_app_token()
if self._mode == "http":
await self._connect_http(bot_token)
return
if not bot_token or not app_token:
raise ChannelAuthenticationError(
"Slack bot_token and app_token must be configured.\n"
" bot_token: SLACK_BOT_TOKEN env or config (xoxb-...)\n"
" app_token: SLACK_APP_TOKEN env or config (xapp-...)"
)
self._status = ChannelStatus.CONNECTING
self._connected_event.clear()
self._client = AsyncWebClient(token=bot_token)
self._client.retry_handlers.append(AsyncRateLimitErrorRetryHandler(max_retry_count=self.MAX_RETRY_COUNT))
audit_result = audit_security_config(self._security_config)
if audit_result.has_critical:
logger.error(f"Slack security audit failed: {audit_result.to_dict()}")
self._security_config = auto_fix_security(self._security_config)
logger.warning(
f"Security config auto-fixed. Review allowlist settings. New config: {self._security_config.to_dict()}"
)
try:
auth = await self._client.auth_test()
if not auth.get("ok"):
raise ChannelAuthenticationError(f"auth.test failed: {auth.get('error')}")
except SlackApiError as e:
raise ChannelAuthenticationError(str(e)) from e
self._verify_token_app_id(bot_token, app_token, auth)
self._bot_user_id = auth["user_id"]
self._bot_id = auth.get("bot_id", "")
self._team = auth["team"]
self._team_id = auth.get("team_id", "")
logger.info(f"Slack bot @{auth['user']} authenticated, workspace: '{auth['team']}'")
self._socket_handler = SocketModeClient(
app_token=app_token,
web_client=self._client,
auto_reconnect_enabled=True,
)
self._register_socket_events()
self._socket_task = asyncio.create_task(self._socket_handler.connect_async())
self._connected_at = datetime.now(UTC)
try:
await asyncio.wait_for(
self._connected_event.wait(),
timeout=self.SOCKET_CONNECT_TIMEOUT_S,
)
except TimeoutError:
self._status = ChannelStatus.ERROR
raise ChannelException(
"Slack Socket Mode connection timed out",
retryable=True,
retry_after_ms=5000,
)
self._status = ChannelStatus.CONNECTED
logger.info(f"Slack Socket Mode connected, bot: @{auth['user']}, team: {auth['team']}")
self._stream_cleanup_task = asyncio.create_task(self._stream_cleanup_loop())
self._last_channel_event_at = time.monotonic()
self._channel_heartbeat_task = asyncio.create_task(self._channel_heartbeat_loop())
await self._restore_sent_cache_from_state()
async def disconnect(self) -> None:
self._shutting_down = True
self._shutdown_event.set()
self._status = ChannelStatus.DISCONNECTED
self._streaming_messages.clear()
self._connected_event.clear()
await self._persist_sent_cache_to_state()
if self._channel_heartbeat_task and not self._channel_heartbeat_task.done():
self._channel_heartbeat_task.cancel()
try:
await self._channel_heartbeat_task
except (asyncio.CancelledError, Exception):
pass
if self._stream_cleanup_task and not self._stream_cleanup_task.done():
self._stream_cleanup_task.cancel()
try:
await self._stream_cleanup_task
except (asyncio.CancelledError, Exception):
pass
if self._socket_handler:
try:
self._socket_handler.disconnect()
except Exception:
pass
if self._socket_task and not self._socket_task.done():
self._socket_task.cancel()
try:
await self._socket_task
except (asyncio.CancelledError, Exception):
pass
async with self._stream_lock:
self._streaming_messages.clear()
self._client = None
self._socket_handler = None
self._socket_task = None
self._connected_at = None
logger.info("Slack adapter disconnected")
async def send(self, response: ChannelResponse) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
async def _do_send() -> DeliveryResult:
payload = self.format_outbound(response)
chat_id = response.identity.channel_chat_id
thread_ts = response.metadata.get("thread_ts")
cached_ts = await self._sent_cache.get(chat_id)
is_dm = chat_id.startswith("D")
if cached_ts and not thread_ts and not response.reply_to_message_id and is_dm:
thread_ts = cached_ts
params: dict[str, Any] = {"channel": chat_id}
text = payload.get("text", "")
blocks = payload.get("blocks")
if response.reply_to_message_id:
params["thread_ts"] = response.reply_to_message_id
elif thread_ts:
params["thread_ts"] = thread_ts
if blocks:
params["blocks"] = blocks
for key in ("username", "icon_url", "icon_emoji"):
if payload.get(key):
params[key] = payload[key]
for key in ("reply_broadcast", "metadata", "unfurl_links", "unfurl_media", "link_names", "markdown_text"):
if key in payload and payload[key] is not None:
params[key] = payload[key]
if len(text) <= self.text_chunk_limit:
params["text"] = text or "(empty message)"
params["mrkdwn"] = payload.get("mrkdwn", True)
result = await self._client.chat_postMessage(**params)
if result.get("ok"):
ts = result.get("ts")
if ts:
await self._sent_cache.put(chat_id, ts)
return DeliveryResult(success=True, message_id=ts)
raise SlackApiError("chat_postMessage failed", response={"error": result.get("error", "Unknown error")})
chunks = resolve_text_chunks(text, self.text_chunk_limit, mode=ChunkMode.NEWLINE)
first_result = None
base_thread_ts = params.get("thread_ts")
failed_chunks: list[dict[str, Any]] = []
for chunk in chunks:
chunk_params: dict[str, Any] = {"channel": chat_id, "text": chunk.text, "mrkdwn": True}
if base_thread_ts:
chunk_params["thread_ts"] = base_thread_ts
if blocks and chunk.index == 0:
chunk_params["blocks"] = blocks
if chunk.index > 0 and first_result and first_result.message_id:
chunk_params["thread_ts"] = first_result.message_id
result = await self._client.chat_postMessage(**chunk_params)
if result.get("ok"):
ts = result.get("ts")
if chunk.index == 0:
first_result = DeliveryResult(success=True, message_id=ts)
if ts:
await self._sent_cache.put(chat_id, ts)
elif chunk.index == 0:
error_msg = result.get("error", "Unknown error")
raise SlackApiError("chat_postMessage chunk failed", response={"error": error_msg})
else:
failed_chunks.append({"index": chunk.index, "error": result.get("error", "Unknown error")})
metadata: dict[str, Any] = {}
if failed_chunks:
metadata["failed_chunks"] = failed_chunks
metadata["partial_success"] = True
logger.warning(f"Slack send: {len(failed_chunks)}/{len(chunks)} chunks failed: {failed_chunks}")
if first_result:
if metadata:
first_result.metadata = {**first_result.metadata, **metadata}
return first_result
return DeliveryResult(success=False, error="No chunks to send")
try:
return await self._circuit_breaker.call(_do_send)
except CircuitBreakerOpenError:
return DeliveryResult(success=False, error="Circuit breaker open")
except SlackApiError as e:
err = e.response.get("error", str(e))
return DeliveryResult(success=False, error=err, metadata={"error_type": "slack_api_error"})
except (TypeError, ValueError, AttributeError):
logger.exception("Programming error in send()")
raise
except Exception as e:
return DeliveryResult(success=False, error=str(e), metadata={"error_type": "unexpected_error"})
async def receive(self) -> AsyncIterator[ChannelMessage]:
while not self._shutdown_event.is_set():
try:
msg = await asyncio.wait_for(self._inbound_queue.get(), timeout=1.0)
yield msg
except TimeoutError:
continue
except asyncio.CancelledError:
break
except Exception:
logger.debug("receive() iteration error", exc_info=True)
await asyncio.sleep(0.1)
def normalize_inbound(self, raw: dict) -> ChannelMessage:
event = raw.get("event", {})
channel = event.get("channel", "")
user = event.get("user", "")
ts = event.get("ts", "")
text = event.get("text", "")
thread_ts = event.get("thread_ts")
stripped_text = normalize_slack_text(text) if text else ""
chat_type = resolve_chat_type(channel, event)
channel_chat_id = resolve_chat_id(channel, event)
is_at_bot = self._check_bot_mention(text)
mentioned_ids = self._extract_mention_ids(text) if text else []
msg_type = MessageType.TEXT
attachments: list[Attachment] = []
if event.get("files"):
files = event["files"]
msg_type = self._resolve_file_message_type(files)
attachments = self._extract_attachments(files)
evt = EventType.MESSAGE_RECEIVED
metadata: dict[str, Any] = {}
if event.get("type") == "interactive" or raw.get("interaction"):
evt = EventType.CARD_ACTION
interaction = raw.get("interaction", {})
metadata["interaction_type"] = interaction.get("type", "")
metadata["interaction_payload"] = interaction
reply_to = None
if thread_ts and thread_ts != ts:
reply_to = thread_ts
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=user,
channel_chat_id=channel_chat_id,
channel_message_id=f"{channel}:{ts}",
),
event_type=evt,
message_type=msg_type,
chat_type=chat_type,
content=stripped_text or text,
attachments=attachments,
mentions=MentionsInfo(
mentioned_user_ids=mentioned_ids,
is_bot_mentioned=is_at_bot,
),
reply_to_message_id=reply_to,
metadata={
**metadata,
"channel": channel,
"ts": ts,
"thread_ts": thread_ts,
"team_id": raw.get("team_id", self._team_id),
},
)
def format_outbound(self, response: ChannelResponse) -> dict[str, Any]:
content = response.content
blocks = response.metadata.get("blocks")
if not blocks and has_interactive_syntax(content):
clean_text, compiled_blocks = compile_interactive_replies(content)
if compiled_blocks:
content = clean_text or "(interactive message)"
blocks = compiled_blocks
payload: dict[str, Any] = {
"text": content,
"mrkdwn": True,
}
if blocks:
payload["blocks"] = blocks
if response.attachments:
payload["attachments"] = [
{
"attachment_type": att.type,
"url": att.url,
"filename": att.filename,
"size": att.size_bytes,
}
for att in response.attachments
]
if response.reply_to_message_id:
payload["thread_ts"] = response.reply_to_message_id
if response.metadata.get("thread_ts"):
payload["thread_ts"] = response.metadata["thread_ts"]
identity = response.metadata.get("identity", {})
username = identity.get("username") or self._send_username
if username:
payload["username"] = username
icon_url = identity.get("icon_url") or self._send_icon_url
if icon_url:
payload["icon_url"] = icon_url
icon_emoji = identity.get("icon_emoji") or self._send_icon_emoji
if icon_emoji:
payload["icon_emoji"] = icon_emoji
reply_broadcast = response.metadata.get("reply_broadcast")
if reply_broadcast is not None:
payload["reply_broadcast"] = bool(reply_broadcast)
msg_metadata = response.metadata.get("metadata")
if msg_metadata:
payload["metadata"] = msg_metadata
unfurl_links = response.metadata.get("unfurl_links")
if unfurl_links is not None:
payload["unfurl_links"] = bool(unfurl_links)
unfurl_media = response.metadata.get("unfurl_media")
if unfurl_media is not None:
payload["unfurl_media"] = bool(unfurl_media)
link_names = response.metadata.get("link_names")
if link_names:
payload["link_names"] = True
markdown_text = response.metadata.get("markdown_text")
if markdown_text:
payload["markdown_text"] = markdown_text
return payload
async def health_check(self) -> HealthStatus:
if self._status != ChannelStatus.CONNECTED or not self._client:
return HealthStatus(
status="unhealthy",
last_error="Socket Mode not connected",
)
start = time.monotonic()
try:
auth = await self._client.auth_test()
latency_ms = (time.monotonic() - start) * 1000
if not auth.get("ok"):
return HealthStatus(
status="degraded",
last_error=f"auth.test: {auth.get('error')}",
)
return HealthStatus(
status="healthy",
latency_ms=latency_ms,
last_connected_at=self._connected_at,
metadata={
"bot_id": self._bot_user_id,
"team": self._team,
"team_id": self._team_id,
},
)
except SlackApiError as e:
return HealthStatus(status="unhealthy", last_error=str(e))
except Exception as e:
return HealthStatus(status="unhealthy", last_error=str(e))
async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
now = time.monotonic()
full_text: str = ""
async with self._stream_lock:
last = self._streaming_messages.get(chat_id, {}).get("_last_update", 0)
elapsed_ms = (now - last) * 1000
if elapsed_ms < self.STREAM_UPDATE_MIN_INTERVAL_MS and not finished:
if chat_id in self._streaming_messages:
self._streaming_messages[chat_id]["_pending"] = (
self._streaming_messages[chat_id].get("_pending", "") + chunk
)
return DeliveryResult(success=True, message_id=msg_id)
pending = self._streaming_messages.get(chat_id, {}).pop("_pending", "")
full_text = pending + chunk if pending else chunk
try:
if not msg_id:
result = await self._client.chat_postMessage(
channel=chat_id,
text=full_text + (" ..." if not finished else ""),
mrkdwn=True,
)
ts = result.get("ts")
if ts:
async with self._stream_lock:
self._streaming_messages[chat_id] = {"ts": ts, "text": full_text, "_last_update": now}
return DeliveryResult(
success=result.get("ok", False),
message_id=ts,
error=result.get("error"),
)
else:
result = await self._client.chat_update(
channel=chat_id,
ts=msg_id,
text=full_text + (" ..." if not finished else ""),
mrkdwn=True,
)
async with self._stream_lock:
if finished:
self._streaming_messages.pop(chat_id, None)
else:
self._streaming_messages[chat_id] = {"ts": msg_id, "text": full_text, "_last_update": now}
return DeliveryResult(
success=result.get("ok", False),
message_id=result.get("ts", msg_id),
error=result.get("error"),
)
except SlackApiError as e:
err = e.response.get("error", str(e))
if err == "message_not_found":
resp = ChannelResponse(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id="",
channel_chat_id=chat_id,
),
content=full_text,
)
return await self.send(resp)
return DeliveryResult(success=False, error=err)
except (TypeError, ValueError, AttributeError):
logger.exception("Programming error in send_stream_chunk()")
raise
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def send_media(
self, chat_id: str, media_type: str, data: Any, *, thread_ts: str | None = None
) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
filename = f"upload.{media_type}" if media_type else "upload"
kwargs: dict[str, Any] = {
"channel": chat_id,
"file": io.BytesIO(data) if isinstance(data, bytes) else data,
"filename": filename,
}
if thread_ts:
kwargs["thread_ts"] = thread_ts
result = await self._client.files_upload_v2(**kwargs)
if result.get("ok"):
file_info = result.get("file", {})
return DeliveryResult(
success=True,
message_id=file_info.get("id"),
)
return DeliveryResult(
success=False,
error=result.get("error", "files.upload failed"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def send_tts(self, chat_id: str, text: str, *, thread_ts: str | None = None) -> DeliveryResult:
if not self._tts_cfg.enabled:
return DeliveryResult(success=False, error="TTS not enabled")
audio_data = await synthesize_slack_tts(text, self._tts_cfg)
if not audio_data:
return DeliveryResult(success=False, error="TTS synthesis failed")
suffix = "mp3" if self._tts_cfg.response_format == "mp3" else "ogg"
filename = f"tts_{chat_id}.{suffix}"
return await self.send_media(chat_id, filename, audio_data, thread_ts=thread_ts)
async def send_approval(self, chat_id: str, title: str, detail: str, command: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
from yuxi.channels.adapters.slack.blocks import build_approval_blocks
try:
blocks = build_approval_blocks(title, detail)
result = await self._client.chat_postMessage(
channel=chat_id,
text=title,
blocks=blocks,
)
if not result.get("ok"):
return DeliveryResult(success=False, error=result.get("error", "Unknown error"))
ts = result.get("ts")
approval = self._approval_mgr.create_approval(
title=title,
detail=detail,
command=command,
chat_id=chat_id,
message_ts=ts or "",
)
return DeliveryResult(
success=True,
message_id=ts,
metadata={"approval_id": approval.approval_id, "status": approval.status.value},
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def handle_approval_callback(
self, action_id: str, user_id: str, message_ts: str, chat_id: str
) -> DeliveryResult:
pending = self._approval_mgr.list_pending(chat_id)
target = None
for p in pending:
req = self._approval_mgr.get_approval(p["approval_id"])
if req and req.message_ts == message_ts:
target = req
break
if not target:
return DeliveryResult(success=False, error="No matching pending approval found")
if not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
status_text = ""
if action_id == "exec_approve":
self._approval_mgr.approve(target.approval_id, user_id)
exec_result = await self._approval_mgr.execute_approval(target.approval_id)
if exec_result.get("success"):
status_text = f"已执行 ✅\n```{str(exec_result.get('result', ''))[:500]}```"
else:
status_text = f"执行失败 ❌\n`{exec_result.get('error', '')}`"
elif action_id == "exec_reject":
self._approval_mgr.reject(target.approval_id)
status_text = "已取消 ❌"
await self._client.chat_update(
channel=chat_id,
ts=message_ts,
text=f"*{target.title}*\n{target.detail}\n\n{status_text}",
blocks=None,
)
return DeliveryResult(success=True, metadata={"status": status_text})
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def send_poll(
self, chat_id: str, question: str, options: list[str], *, thread_ts: str | None = None
) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
if len(options) < 2:
return DeliveryResult(success=False, error="Poll requires at least 2 options")
if len(options) > 10:
return DeliveryResult(success=False, error="Poll supports at most 10 options")
from yuxi.channels.adapters.slack.blocks import build_poll_blocks
poll_msg_params: dict[str, Any] = {"channel": chat_id, "text": question}
if thread_ts:
poll_msg_params["thread_ts"] = thread_ts
try:
poll = self._poll_mgr.create_poll(question, options, chat_id, "", "")
blocks = build_poll_blocks(question, options, poll.poll_id)
poll_msg_params["blocks"] = blocks
result = await self._client.chat_postMessage(**poll_msg_params)
if result.get("ok"):
ts = result.get("ts")
poll.message_ts = ts or ""
return DeliveryResult(
success=True,
message_id=ts,
metadata={"poll_id": poll.poll_id, "results": poll.results()},
)
return DeliveryResult(success=False, error=result.get("error", "Unknown error"))
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
def _build_media_filename(self, media_type: str) -> str:
return f"upload.{media_type}" if media_type else "upload"
async def send_ephemeral(
self,
chat_id: str,
user_id: str,
text: str,
*,
blocks: list | None = None,
thread_ts: str | None = None,
) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
params: dict[str, Any] = {
"channel": chat_id,
"user": user_id,
"text": text,
}
if blocks:
params["blocks"] = blocks
if thread_ts:
params["thread_ts"] = thread_ts
result = await self._client.chat_postEphemeral(**params)
ok = result.get("ok", False)
return DeliveryResult(
success=ok,
message_id=result.get("message_ts"),
error=result.get("error") if not ok else None,
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
except (TypeError, ValueError, AttributeError):
logger.exception("Programming error in send_ephemeral()")
raise
except Exception as e:
return DeliveryResult(success=False, error=str(e))
async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.chat_update(
channel=chat_id,
ts=msg_id,
text=content,
mrkdwn=True,
)
return DeliveryResult(
success=result.get("ok", False),
message_id=result.get("ts"),
error=result.get("error"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.chat_delete(
channel=chat_id,
ts=msg_id,
)
return DeliveryResult(
success=result.get("ok", False),
error=result.get("error"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
name = emoji.replace(":", "")
result = await self._client.reactions_add(
channel=chat_id,
name=name,
timestamp=msg_id,
)
return DeliveryResult(
success=result.get("ok", False),
error=result.get("error"),
)
except SlackApiError as e:
err = e.response.get("error", str(e))
if err == "already_reacted":
return DeliveryResult(success=True, error="already_reacted")
return DeliveryResult(success=False, error=err)
async def get_user_info(self, channel_user_id: str) -> dict[str, Any]:
if self._status != ChannelStatus.CONNECTED or not self._client:
raise ChannelNotConnectedError()
try:
result = await self._client.users_info(user=channel_user_id)
user = result.get("user", {})
return {
"id": user.get("id"),
"username": user.get("name"),
"display_name": user.get("profile", {}).get("display_name", ""),
"real_name": user.get("real_name", ""),
"email": user.get("profile", {}).get("email", ""),
"tz": user.get("tz", ""),
"is_admin": user.get("is_admin", False),
"is_bot": user.get("is_bot", False),
}
except SlackApiError as e:
raise ChannelException(f"Failed to get user info: {e}") from e
async def download_media(self, file_id: str) -> bytes:
if self._status != ChannelStatus.CONNECTED or not self._client:
raise ChannelNotConnectedError()
try:
import aiohttp
file_info = await self._client.files_info(file=file_id)
if not file_info.get("ok"):
raise ChannelException(f"files.info failed: {file_info.get('error')}")
url_private = file_info.get("file", {}).get("url_private", "")
if not url_private:
raise ChannelException(f"File {file_id} has no downloadable URL")
async with aiohttp.ClientSession() as session:
async with session.get(
url_private,
headers={"Authorization": f"Bearer {self._resolve_bot_token()}"},
) as resp:
return await resp.read()
except SlackApiError as e:
raise ChannelException(f"Failed to download file {file_id}: {e}") from e
except Exception as e:
raise ChannelException(f"Failed to download file {file_id}: {e}") from e
# ========== 内部辅助方法 ==========
def _resolve_bot_token(self) -> str:
token = self.config.get("bot_token", "")
if token:
self._bot_token_source = "config"
else:
token = os.getenv("SLACK_BOT_TOKEN", "")
if token:
self._bot_token_source = "env"
else:
self._bot_token_source = "none"
if token and not token.startswith("xoxb-"):
logger.warning(f"SLACK_BOT_TOKEN does not start with 'xoxb-', got prefix: {token[:5]}...")
return token
def _resolve_app_token(self) -> str:
token = self.config.get("app_token", "")
if token:
self._app_token_source = "config"
else:
token = os.getenv("SLACK_APP_TOKEN", "")
if token:
self._app_token_source = "env"
else:
self._app_token_source = "none"
if token and not token.startswith("xapp-"):
logger.warning(f"SLACK_APP_TOKEN does not start with 'xapp-', got prefix: {token[:5]}...")
if token and token.startswith("xapp-") and not self._APP_TOKEN_PATTERN.match(token):
logger.warning(f"SLACK_APP_TOKEN does not match expected format xapp-\\d-[a-z0-9]+-, got: {token[:12]}...")
return token
def _resolve_user_token(self) -> str:
token = self.config.get("user_token", "")
if token:
self._user_token_source = "config"
else:
token = os.getenv("SLACK_USER_TOKEN", "")
if token:
self._user_token_source = "env"
else:
self._user_token_source = "none"
if token and not token.startswith("xoxp-"):
logger.warning(f"SLACK_USER_TOKEN does not start with 'xoxp-', got prefix: {token[:5]}...")
return token
async def _restore_sent_cache_from_state(self) -> None:
cache_data = await self.state_get("sent_cache", namespace="cache")
if cache_data and isinstance(cache_data, list):
for entry in cache_data:
if isinstance(entry, dict):
chat_id = entry.get("chat_id")
thread_ts = entry.get("thread_ts")
if chat_id and thread_ts:
await self._sent_cache.put(chat_id, thread_ts)
logger.info(f"[Slack] Restored {len(cache_data)} sent cache entries from state_store")
async def _persist_sent_cache_to_state(self) -> None:
entries: list[dict[str, str]] = []
async with self._sent_cache._lock:
for chat_id, (thread_ts, stored_at) in self._sent_cache._cache.items():
if time.monotonic() - stored_at <= self._sent_cache._ttl:
entries.append({"chat_id": chat_id, "thread_ts": thread_ts})
if entries:
await self.state_set("sent_cache", entries, namespace="cache", ttl_seconds=3600)
logger.debug(f"[Slack] Persisted {len(entries)} sent cache entries to state_store")
async def _connect_http(self, bot_token: str) -> None:
if not bot_token:
raise ChannelAuthenticationError(
"Slack bot_token must be configured for HTTP mode.\n"
" bot_token: SLACK_BOT_TOKEN env or config (xoxb-...)"
)
if not self._signing_secret:
raise ChannelAuthenticationError(
"Slack signing_secret must be configured for HTTP mode.\n"
" signing_secret: SLACK_SIGNING_SECRET env or config"
)
self._status = ChannelStatus.CONNECTING
self._client = AsyncWebClient(token=bot_token)
self._client.retry_handlers.append(AsyncRateLimitErrorRetryHandler(max_retry_count=self.MAX_RETRY_COUNT))
try:
auth = await self._client.auth_test()
if not auth.get("ok"):
raise ChannelAuthenticationError(f"auth.test failed: {auth.get('error')}")
except SlackApiError as e:
raise ChannelAuthenticationError(str(e)) from e
self._bot_user_id = auth["user_id"]
self._bot_id = auth.get("bot_id", "")
self._team = auth["team"]
self._team_id = auth.get("team_id", "")
logger.info(f"Slack bot @{auth['user']} authenticated (HTTP mode), workspace: '{auth['team']}'")
self._status = ChannelStatus.CONNECTED
self._connected_at = datetime.now(UTC)
self._connected_event.set()
def _verify_token_app_id(self, bot_token: str, app_token: str, auth: dict) -> None:
bot_app_id = self._extract_app_id_from_token(bot_token, "xoxb-")
app_app_id = self._extract_app_id_from_token(app_token, "xapp-")
if bot_app_id and app_app_id and bot_app_id != app_app_id:
logger.warning(
f"Slack bot_token app_id ({bot_app_id}) does not match "
f"app_token app_id ({app_app_id}), ensure tokens belong to the same app"
)
@staticmethod
def _extract_app_id_from_token(token: str, prefix: str) -> str:
if not token.startswith(prefix):
return ""
parts = token[len(prefix) :].split("-", 1)
return parts[0] if parts else ""
async def _handle_http_event(self, payload: dict) -> None:
event = payload.get("event", {})
event_type = payload.get("event", {}).get("type", "")
subtype = event.get("subtype", "")
if subtype == "message_changed":
await self._handle_http_message_changed(event, payload)
return
if subtype == "message_deleted":
await self._handle_http_message_deleted(event, payload)
return
if subtype and subtype not in ("file_share", "thread_broadcast"):
return
if event.get("bot_id") and not subtype:
return
msg = self.normalize_inbound(
{
"event": event,
"event_type": event_type,
"team_id": payload.get("team_id", ""),
}
)
await self._handle_message_with_security(msg)
async def _handle_http_message_changed(self, event: dict, payload: dict) -> None:
msg_data = event.get("message", {})
previous = event.get("previous_message", {})
msg = self.normalize_inbound(
{
"event": {**previous, "channel": event.get("channel", ""), "ts": previous.get("ts", "")},
"event_type": "message",
"team_id": payload.get("team_id", ""),
}
)
msg.event_type = EventType.MESSAGE_UPDATED
msg.content = msg_data.get("text", "")
msg.metadata["edited_message"] = msg_data
msg.metadata["previous_message"] = previous
await self._handle_message_with_security(msg)
async def _handle_http_message_deleted(self, event: dict, payload: dict) -> None:
ts = event.get("deleted_ts", "")
channel = event.get("channel", "")
msg = ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id="",
channel_chat_id=channel,
channel_message_id=ts,
),
event_type=EventType.MESSAGE_DELETED,
chat_type="group" if not channel.startswith("D") else "direct",
content="",
metadata={"deleted_ts": ts, "team_id": payload.get("team_id", self._team_id)},
)
await self._handle_message_with_security(msg)
async def pin_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.pins_add(channel=chat_id, timestamp=msg_id)
return DeliveryResult(
success=result.get("ok", False),
error=result.get("error"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def unpin_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.pins_remove(channel=chat_id, timestamp=msg_id)
return DeliveryResult(
success=result.get("ok", False),
error=result.get("error"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def list_pins(self, chat_id: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.pins_list(channel=chat_id)
items = result.get("items", [])
return DeliveryResult(
success=result.get("ok", False),
metadata={"pins": items, "count": len(items)},
error=result.get("error"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def send_typing(self, chat_id: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.api_call(
api_method="chat.sendTyping",
http_verb="POST",
params={"channel": chat_id},
)
return DeliveryResult(
success=result.get("ok", False),
error=result.get("error"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def remove_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
name = emoji.replace(":", "")
result = await self._client.reactions_remove(
channel=chat_id,
name=name,
timestamp=msg_id,
)
return DeliveryResult(
success=result.get("ok", False),
error=result.get("error"),
)
except SlackApiError as e:
err = e.response.get("error", str(e))
if err == "no_reaction":
return DeliveryResult(success=True, error="no_reaction")
return DeliveryResult(success=False, error=err)
async def list_reactions(self, chat_id: str, msg_id: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.reactions_get(
channel=chat_id,
timestamp=msg_id,
full=True,
)
if not result.get("ok"):
return DeliveryResult(success=False, error=result.get("error"))
message = result.get("message", {})
reactions = message.get("reactions", [])
return DeliveryResult(
success=True,
metadata={"reactions": reactions},
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def remove_own_reactions(self, chat_id: str, msg_id: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.reactions_get(
channel=chat_id,
timestamp=msg_id,
full=True,
)
if not result.get("ok"):
return DeliveryResult(success=False, error=result.get("error"))
message = result.get("message", {})
reactions = message.get("reactions", [])
removed = 0
for reaction in reactions:
users = reaction.get("users", [])
if self._bot_user_id in users:
name = reaction.get("name", "")
try:
await self._client.reactions_remove(
channel=chat_id,
name=name,
timestamp=msg_id,
)
removed += 1
except SlackApiError:
pass
return DeliveryResult(success=True, metadata={"removed_count": removed})
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def read_messages(
self,
chat_id: str,
*,
limit: int = 100,
cursor: str = "",
thread_ts: str | None = None,
) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
if thread_ts:
result = await self._client.conversations_replies(
channel=chat_id,
ts=thread_ts,
limit=min(limit, 1000),
cursor=cursor or None,
)
messages = result.get("messages", [])
else:
result = await self._client.conversations_history(
channel=chat_id,
limit=min(limit, 1000),
cursor=cursor or None,
)
messages = result.get("messages", [])
return DeliveryResult(
success=result.get("ok", False),
metadata={
"messages": messages,
"count": len(messages),
"has_more": result.get("has_more", False),
"next_cursor": result.get("response_metadata", {}).get("next_cursor", ""),
},
error=result.get("error"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def list_emojis(self) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.emoji_list()
emoji_data = result.get("emoji", {})
return DeliveryResult(
success=result.get("ok", False),
metadata={"emojis": emoji_data, "count": len(emoji_data)},
error=result.get("error"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def get_message_permalink(self, channel_id: str, message_ts: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.chat_getPermalink(channel=channel_id, message_ts=message_ts)
ok = result.get("ok", False)
return DeliveryResult(
success=ok,
metadata={"permalink": result.get("permalink", "")},
error=result.get("error") if not ok else None,
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def schedule_message(
self,
channel_id: str,
*,
text: str | None = None,
blocks: list | None = None,
post_at: int | None = None,
thread_ts: str | None = None,
) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
params: dict[str, Any] = {"channel": channel_id}
if text:
params["text"] = text
if blocks:
params["blocks"] = blocks
if post_at:
params["post_at"] = post_at
if thread_ts:
params["thread_ts"] = thread_ts
result = await self._client.chat_scheduleMessage(**params)
ok = result.get("ok", False)
return DeliveryResult(
success=ok,
message_id=result.get("scheduled_message_id"),
metadata={"channel": result.get("channel"), "post_at": result.get("post_at")},
error=result.get("error") if not ok else None,
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def delete_scheduled_message(self, channel_id: str, scheduled_message_id: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.chat_deleteScheduledMessage(
channel=channel_id, scheduled_message_id=scheduled_message_id
)
ok = result.get("ok", False)
return DeliveryResult(
success=ok,
error=result.get("error") if not ok else None,
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def send_me_message(self, channel_id: str, text: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.chat_meMessage(channel=channel_id, text=text)
ok = result.get("ok", False)
return DeliveryResult(
success=ok,
message_id=result.get("ts"),
metadata={"channel": result.get("channel")},
error=result.get("error") if not ok else None,
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def delete_file(self, file_id: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.files_delete(file=file_id)
ok = result.get("ok", False)
return DeliveryResult(
success=ok,
error=result.get("error") if not ok else None,
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def share_file_public(self, file_id: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.files_sharedPublicURL(file=file_id)
ok = result.get("ok", False)
file_info = result.get("file", {})
return DeliveryResult(
success=ok,
metadata={
"permalink_public": file_info.get("permalink_public", ""),
"file": file_info,
},
error=result.get("error") if not ok else None,
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def get_user_profile(self, user_id: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.users_profile_get(user=user_id)
ok = result.get("ok", False)
return DeliveryResult(
success=ok,
metadata={"profile": result.get("profile", {})},
error=result.get("error") if not ok else None,
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def lookup_user_by_email(self, email: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.users_lookupByEmail(email=email)
ok = result.get("ok", False)
user = result.get("user", {})
return DeliveryResult(
success=ok,
metadata={"user": user},
error=result.get("error") if not ok else None,
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def verify_webhook_signature(self, headers: dict, body: bytes) -> bool:
if not self._signing_secret:
return False
timestamp = headers.get("x-slack-request-timestamp", "")
signature = headers.get("x-slack-signature", "")
if not timestamp or not signature:
return False
try:
ts = int(timestamp)
except (ValueError, TypeError):
return False
now = int(time.time())
if abs(now - ts) > 300:
logger.warning("Slack webhook: request timestamp too old")
return False
sig_basestring = f"v0:{timestamp}:{body.decode('utf-8')}"
computed = (
"v0="
+ hmac.new(
self._signing_secret.encode("utf-8"),
sig_basestring.encode("utf-8"),
hashlib.sha256,
).hexdigest()
)
return hmac.compare_digest(computed, signature)
async def _check_security(self, msg: ChannelMessage) -> SecurityDecision:
if msg.chat_type == "direct":
if not self._security_config.is_allowed_dm(msg.identity.channel_user_id):
if self._security_config.dm_policy == DmPolicy.PAIRING:
decision = await self._pairing_mgr.generate_pairing(msg.identity.channel_user_id)
if decision.requires_pairing:
return decision
return SecurityDecision(
allowed=False,
reason=f"dm_policy={self._security_config.dm_policy.value}",
)
return SecurityDecision(allowed=True)
if msg.chat_type in ("group", "channel"):
if not self._security_config.is_allowed_group(msg.identity.channel_chat_id):
return SecurityDecision(
allowed=False,
reason=f"group_policy={self._security_config.group_policy.value}",
)
if self._security_config.require_mention and not msg.mentions.is_bot_mentioned:
return SecurityDecision(
allowed=False,
reason="require_mention: bot not mentioned in channel message",
)
return SecurityDecision(allowed=True)
return SecurityDecision(allowed=True)
async def _enqueue_message(self, msg: ChannelMessage) -> None:
try:
self._inbound_queue.put_nowait(msg)
except asyncio.QueueFull:
logger.warning("Slack inbound queue full, dropping oldest message")
try:
self._inbound_queue.get_nowait()
self._inbound_queue.put_nowait(msg)
except (asyncio.QueueEmpty, asyncio.QueueFull):
pass
async def _handle_message_with_security(self, msg: ChannelMessage) -> None:
decision = await self._check_security(msg)
if not decision.allowed:
if decision.requires_pairing and decision.pairing_code:
logger.info(
f"Slack pairing required for user {msg.identity.channel_user_id}, code: {decision.pairing_code}"
)
await self._send_pairing_message(msg, decision.pairing_code)
else:
logger.debug(f"Slack security blocked message from {msg.identity.channel_user_id}: {decision.reason}")
return
await self._enqueue_message(msg)
await self._handle_message(msg)
async def _send_pairing_message(self, msg: ChannelMessage, code: str) -> None:
if not self._client:
return
try:
await self._client.chat_postMessage(
channel=msg.identity.channel_chat_id,
text=(
f"要通过此 Bot 进行对话,请先完成配对验证。\n配对码:`{code}`\n请将此配对码发送给管理员以完成配对。"
),
mrkdwn=True,
)
except Exception as e:
logger.error(f"Failed to send pairing message: {e}")
async def create_channel(self, name: str, *, is_private: bool = False) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.conversations_create(name=name, is_private=is_private)
channel = result.get("channel", {})
return DeliveryResult(
success=result.get("ok", False),
message_id=channel.get("id"),
error=result.get("error"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def archive_channel(self, chat_id: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.conversations_archive(channel=chat_id)
return DeliveryResult(
success=result.get("ok", False),
error=result.get("error"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def unarchive_channel(self, chat_id: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.conversations_unarchive(channel=chat_id)
return DeliveryResult(
success=result.get("ok", False),
error=result.get("error"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def rename_channel(self, chat_id: str, name: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.conversations_rename(channel=chat_id, name=name)
return DeliveryResult(
success=result.get("ok", False),
error=result.get("error"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def invite_users(self, chat_id: str, users: list[str]) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.conversations_invite(channel=chat_id, users=users)
return DeliveryResult(
success=result.get("ok", False),
error=result.get("error"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def kick_user(self, chat_id: str, user_id: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.conversations_kick(channel=chat_id, user=user_id)
return DeliveryResult(
success=result.get("ok", False),
error=result.get("error"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def join_channel(self, chat_id: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.conversations_join(channel=chat_id)
return DeliveryResult(
success=result.get("ok", False),
error=result.get("error"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def leave_channel(self, chat_id: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.conversations_leave(channel=chat_id)
return DeliveryResult(
success=result.get("ok", False),
error=result.get("error"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def get_channel_info(self, chat_id: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.conversations_info(channel=chat_id)
channel = result.get("channel", {})
return DeliveryResult(
success=result.get("ok", False),
metadata={"channel": channel},
error=result.get("error"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def list_channel_members(self, chat_id: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.conversations_members(channel=chat_id)
members = result.get("members", [])
return DeliveryResult(
success=result.get("ok", False),
metadata={"members": members, "count": len(members)},
error=result.get("error"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def set_channel_topic(self, chat_id: str, topic: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.conversations_setTopic(channel=chat_id, topic=topic)
return DeliveryResult(
success=result.get("ok", False),
error=result.get("error"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def set_channel_purpose(self, chat_id: str, purpose: str) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.conversations_setPurpose(channel=chat_id, purpose=purpose)
return DeliveryResult(
success=result.get("ok", False),
error=result.get("error"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def unfurl_link(self, chat_id: str, ts: str, unfurls: dict[str, Any]) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.chat_unfurl(channel=chat_id, ts=ts, unfurls=unfurls)
return DeliveryResult(
success=result.get("ok", False),
error=result.get("error"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def list_users(self, *, limit: int = 200, cursor: str = "") -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.users_list(limit=limit, cursor=cursor or None)
members = result.get("members", [])
return DeliveryResult(
success=result.get("ok", False),
metadata={
"members": members,
"count": len(members),
"next_cursor": result.get("response_metadata", {}).get("next_cursor", ""),
},
error=result.get("error"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def list_channels(self, *, limit: int = 200, cursor: str = "") -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.conversations_list(
limit=limit,
cursor=cursor or None,
types="public_channel,private_channel",
)
channels = result.get("channels", [])
return DeliveryResult(
success=result.get("ok", False),
metadata={
"channels": channels,
"count": len(channels),
"next_cursor": result.get("response_metadata", {}).get("next_cursor", ""),
},
error=result.get("error"),
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def close_poll(self, poll_id: str) -> DeliveryResult:
poll = self._poll_mgr.get_poll(poll_id)
if not poll:
return DeliveryResult(success=False, error=f"Poll not found: {poll_id}")
if poll.is_closed:
return DeliveryResult(success=False, error="Poll already closed")
self._poll_mgr.close_poll(poll_id)
if self._status == ChannelStatus.CONNECTED and self._client and poll.message_ts:
try:
results = poll.results()
result_lines = [f"*📊 投票已关闭: {poll.question}*", ""]
for opt in results["options"]:
bar = "" * opt["count"] if opt["count"] > 0 else ""
result_lines.append(f"{opt['text']}: {opt['count']}{bar}")
result_lines.append(f"\n_总计 {results['total_votes']} 票_")
await self._client.chat_update(
channel=poll.chat_id,
ts=poll.message_ts,
text="\n".join(result_lines),
blocks=None,
)
except SlackApiError as e:
logger.warning(f"Failed to update closed poll message: {e}")
return DeliveryResult(success=True, metadata={"results": poll.results()})
async def list_polls(self, chat_id: str | None = None) -> DeliveryResult:
polls = self._poll_mgr.list_polls(chat_id)
return DeliveryResult(success=True, metadata={"polls": polls, "count": len(polls)})
async def open_modal(self, trigger_id: str, view: dict) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.views_open(trigger_id=trigger_id, view=view)
ok = result.get("ok", False)
return DeliveryResult(
success=ok,
error=result.get("error") if not ok else None,
metadata={"view": result.get("view", {}) if ok else None},
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def update_modal(self, view_id: str, view: dict, *, hash_val: str = "") -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
params: dict[str, Any] = {"view_id": view_id, "view": view}
if hash_val:
params["hash"] = hash_val
result = await self._client.views_update(**params)
ok = result.get("ok", False)
return DeliveryResult(
success=ok,
error=result.get("error") if not ok else None,
metadata={"view": result.get("view", {}) if ok else None},
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def push_modal(self, trigger_id: str, view: dict) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.views_push(trigger_id=trigger_id, view=view)
ok = result.get("ok", False)
return DeliveryResult(
success=ok,
error=result.get("error") if not ok else None,
metadata={"view": result.get("view", {}) if ok else None},
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
async def publish_home_tab(self, user_id: str, view: dict) -> DeliveryResult:
if self._status != ChannelStatus.CONNECTED or not self._client:
return DeliveryResult(success=False, error="Slack not connected")
try:
result = await self._client.views_publish(user_id=user_id, view=view)
ok = result.get("ok", False)
return DeliveryResult(
success=ok,
error=result.get("error") if not ok else None,
metadata={"view": result.get("view", {}) if ok else None},
)
except SlackApiError as e:
return DeliveryResult(success=False, error=str(e))
def _check_bot_mention(self, text: str) -> bool:
if not text or not self._bot_user_id:
return False
return f"<@{self._bot_user_id}>" in text
def _extract_mention_ids(self, text: str) -> list[str]:
if not text:
return []
return re.findall(r"<@([A-Z0-9]+)>", text)
def _resolve_file_message_type(self, files: list) -> MessageType:
if not files:
return MessageType.TEXT
f = files[0]
mimetype = f.get("mimetype", "")
if mimetype.startswith("image/"):
return MessageType.IMAGE
if mimetype.startswith("video/"):
return MessageType.VIDEO
if mimetype.startswith("audio/"):
return MessageType.AUDIO
return MessageType.FILE
def _extract_attachments(self, files: list) -> list[Attachment]:
result = []
for f in files:
mt = f.get("mimetype", "application/octet-stream")
att_type = "file"
if mt.startswith("image/"):
att_type = "image"
elif mt.startswith("video/"):
att_type = "video"
elif mt.startswith("audio/"):
att_type = "audio"
result.append(
Attachment(
type=att_type,
url=f.get("url_private", ""),
mime_type=mt,
filename=f.get("name", ""),
size_bytes=f.get("size", 0),
)
)
return result
def _register_socket_events(self) -> None:
if not self._socket_handler:
return
handler = self._socket_handler
@handler.on("message")
async def _on_message(client: SocketModeClient, req: SocketModeRequest):
event = req.payload.get("event", {})
subtype = event.get("subtype")
if subtype == "message_changed":
await self._handle_message_changed(event, req)
return
if subtype == "message_deleted":
await self._handle_message_deleted(event, req)
return
if subtype in ("channel_join", "group_join"):
await self._handle_member_event(event, req, EventType.MEMBER_JOINED)
return
if subtype in ("channel_leave", "group_leave"):
await self._handle_member_event(event, req, EventType.MEMBER_LEFT)
return
if subtype and subtype not in ("file_share", "thread_broadcast"):
return
if event.get("bot_id") and not subtype:
return
try:
event_id = event.get("event_ts", event.get("ts", ""))
if event_id and self._is_duplicate_event(event_id):
return
channel = event.get("channel", "")
if channel and not channel.startswith("D"):
self._last_channel_event_at = time.monotonic()
msg = self.normalize_inbound(
{
"event": event,
"event_type": req.payload.get("event", {}).get("type", "unknown"),
"team_id": req.payload.get("team_id", ""),
}
)
await self._handle_message_with_security(msg)
except Exception as e:
logger.error(f"Slack message event error: {e}", exc_info=True)
@handler.on("reaction_added")
async def _on_reaction_added(client: SocketModeClient, req: SocketModeRequest):
try:
event_data = {
"reaction": req.payload.get("reaction", ""),
"user": req.payload.get("user", ""),
"item": req.payload.get("item", {}),
"event_ts": req.payload.get("event_ts", ""),
}
item = req.payload.get("item", {})
msg = self._build_system_event(
req,
event_type=EventType.MESSAGE_UPDATED,
content=f"reaction_added:{event_data['reaction']}",
channel=item.get("channel", ""),
ts=item.get("ts", ""),
user=req.payload.get("user", ""),
metadata={"reaction_event": event_data},
)
await self._handle_message_with_security(msg)
if self._reaction_notify_cfg.should_notify(event_data["reaction"]):
await self._notify_bot_owner_of_reaction(event_data["reaction"], req)
except Exception as e:
logger.error(f"Slack reaction_added error: {e}", exc_info=True)
@handler.on("reaction_removed")
async def _on_reaction_removed(client: SocketModeClient, req: SocketModeRequest):
try:
item = req.payload.get("item", {})
event_data = {
"reaction": req.payload.get("reaction", ""),
"user": req.payload.get("user", ""),
"item": item,
"event_ts": req.payload.get("event_ts", ""),
}
msg = self._build_system_event(
req,
event_type=EventType.MESSAGE_UPDATED,
content=f"reaction_removed:{event_data['reaction']}",
channel=item.get("channel", ""),
ts=item.get("ts", ""),
user=req.payload.get("user", ""),
metadata={"reaction_event": event_data},
)
await self._handle_message_with_security(msg)
except Exception as e:
logger.error(f"Slack reaction_removed error: {e}", exc_info=True)
@handler.on("interactive")
async def _on_interactive(client: SocketModeClient, req: SocketModeRequest):
payload = req.payload
try:
actions = payload.get("actions", [])
action_id = actions[0].get("action_id", "") if actions else ""
if action_id.startswith("poll_"):
await self._handle_poll_vote(payload, action_id, req)
return
if action_id in ("exec_approve", "exec_reject"):
await self._handle_approval_interaction(payload, action_id)
return
msg = self.normalize_inbound(
{
"event": {
"type": "interactive",
"user": payload.get("user", {}).get("id", ""),
"channel": payload.get("channel", {}).get("id", ""),
"ts": payload.get("message", {}).get("ts", ""),
},
"event_type": "interactive",
"team_id": payload.get("team", {}).get("id", ""),
"interaction": payload,
}
)
msg.event_type = EventType.CARD_ACTION
msg.metadata["interaction_type"] = payload.get("type", "")
await self._handle_message_with_security(msg)
except Exception as e:
logger.error(f"Slack interactive event error: {e}", exc_info=True)
@handler.on("view_submission")
async def _on_view_submission(client: SocketModeClient, req: SocketModeRequest):
payload = req.payload
try:
view = payload.get("view", {})
user = payload.get("user", {})
view_state = view.get("state", {}).get("values", {})
msg = self.normalize_inbound(
{
"event": {
"type": "view_submission",
"user": user.get("id", ""),
"channel": user.get("id", ""),
"ts": view.get("id", ""),
},
"event_type": "view_submission",
"team_id": payload.get("team", {}).get("id", ""),
"interaction": payload,
}
)
msg.event_type = EventType.CARD_ACTION
msg.metadata["interaction_type"] = "view_submission"
msg.metadata["view"] = view
msg.metadata["view_state"] = view_state
msg.metadata["callback_id"] = view.get("callback_id", "")
await self._handle_message_with_security(msg)
except Exception as e:
logger.error(f"Slack view_submission error: {e}", exc_info=True)
@handler.on("error")
async def _on_error(client: SocketModeClient, req: SocketModeRequest):
err_msg = str(req.payload)
logger.error(f"Slack Socket Mode error: {err_msg}")
if "too_many_websockets" in err_msg:
logger.warning(
f"too_many_websockets detected, "
f"waiting for Slack server-side GC (reconnect #{self._reconnect_count})"
)
self._status = ChannelStatus.RECONNECTING
return
if self._status == ChannelStatus.CONNECTED:
self._status = ChannelStatus.RECONNECTING
@handler.on("connected")
async def _on_connected(client: SocketModeClient, req: SocketModeRequest):
logger.info("Slack Socket Mode WebSocket connected")
self._status = ChannelStatus.CONNECTED
self._connected_at = datetime.now(UTC)
self._connected_event.set()
self._last_channel_event_at = time.monotonic()
self._reconnect_count = 0
@handler.on("disconnect")
async def _on_disconnect(client: SocketModeClient, req: SocketModeRequest):
logger.warning("Slack Socket Mode WebSocket disconnected")
if self._shutting_down:
return
if self._status == ChannelStatus.CONNECTED:
self._status = ChannelStatus.RECONNECTING
self._reconnect_count += 1
@handler.on("slash_commands")
async def _on_slash_command(client: SocketModeClient, req: SocketModeRequest):
try:
command = req.payload.get("command", "")
text = req.payload.get("text", "")
user_id = req.payload.get("user_id", "")
channel_id = req.payload.get("channel_id", "")
team_id = req.payload.get("team_id", "")
trigger_id = req.payload.get("trigger_id", "")
if not command:
return
result = await self._cmd_registry.handle_command(command, text, user_id, channel_id)
response_url = req.payload.get("response_url", "")
if response_url and self._client:
try:
import aiohttp
async with aiohttp.ClientSession() as session:
await session.post(response_url, json=result)
except Exception as e:
logger.error(f"Failed to send slash command response: {e}")
msg = self._build_system_event(
req,
event_type=EventType.SYSTEM_EVENT,
content=f"slash_command:{command} {text}",
channel=channel_id,
ts=req.payload.get("event_ts", ""),
user=user_id,
metadata={
"slash_command": command,
"text": text,
"trigger_id": trigger_id,
"team_id": team_id,
},
)
await self._handle_message_with_security(msg)
except Exception as e:
logger.error(f"Slack slash_command error: {e}", exc_info=True)
@handler.on("pin_added")
async def _on_pin_added(client: SocketModeClient, req: SocketModeRequest):
try:
item = req.payload.get("item", {})
msg = self._build_system_event(
req,
event_type=EventType.MESSAGE_UPDATED,
content="pin_added",
channel=item.get("channel", ""),
ts=item.get("message", {}).get("ts", ""),
user=req.payload.get("user", ""),
metadata={
"pin_event": "pin_added",
"item": item,
"event_ts": req.payload.get("event_ts", ""),
},
)
await self._handle_message_with_security(msg)
except Exception as e:
logger.error(f"Slack pin_added error: {e}", exc_info=True)
@handler.on("pin_removed")
async def _on_pin_removed(client: SocketModeClient, req: SocketModeRequest):
try:
item = req.payload.get("item", {})
msg = self._build_system_event(
req,
event_type=EventType.MESSAGE_UPDATED,
content="pin_removed",
channel=item.get("channel", ""),
ts=item.get("message", {}).get("ts", ""),
user=req.payload.get("user", ""),
metadata={
"pin_event": "pin_removed",
"item": item,
"event_ts": req.payload.get("event_ts", ""),
},
)
await self._handle_message_with_security(msg)
except Exception as e:
logger.error(f"Slack pin_removed error: {e}", exc_info=True)
@handler.on("channel_created")
async def _on_channel_created(client: SocketModeClient, req: SocketModeRequest):
try:
channel_data = req.payload.get("channel", {})
msg = self._build_system_event(
req,
event_type=EventType.SYSTEM_EVENT,
content=f"channel_created:{channel_data.get('name', '')}",
channel=channel_data.get("id", ""),
ts=req.payload.get("event_ts", ""),
user=channel_data.get("creator", ""),
metadata={
"channel_event": "channel_created",
"channel_data": channel_data,
},
)
await self._handle_message_with_security(msg)
except Exception as e:
logger.error(f"Slack channel_created error: {e}", exc_info=True)
@handler.on("channel_rename")
async def _on_channel_rename(client: SocketModeClient, req: SocketModeRequest):
try:
channel_data = req.payload.get("channel", {})
msg = self._build_system_event(
req,
event_type=EventType.SYSTEM_EVENT,
content=f"channel_rename:{channel_data.get('name', '')}",
channel=channel_data.get("id", ""),
ts=req.payload.get("event_ts", ""),
metadata={
"channel_event": "channel_rename",
"channel_data": channel_data,
},
)
await self._handle_message_with_security(msg)
except Exception as e:
logger.error(f"Slack channel_rename error: {e}", exc_info=True)
@handler.on("channel_archive")
async def _on_channel_archive(client: SocketModeClient, req: SocketModeRequest):
try:
channel = req.payload.get("channel", "")
msg = self._build_system_event(
req,
event_type=EventType.SYSTEM_EVENT,
content=f"channel_archive:{channel}",
channel=channel,
ts=req.payload.get("event_ts", ""),
user=req.payload.get("user", ""),
metadata={
"channel_event": "channel_archive",
"channel_id": channel,
},
)
await self._handle_message_with_security(msg)
except Exception as e:
logger.error(f"Slack channel_archive error: {e}", exc_info=True)
@handler.on("channel_unarchive")
async def _on_channel_unarchive(client: SocketModeClient, req: SocketModeRequest):
try:
channel = req.payload.get("channel", "")
msg = self._build_system_event(
req,
event_type=EventType.SYSTEM_EVENT,
content=f"channel_unarchive:{channel}",
channel=channel,
ts=req.payload.get("event_ts", ""),
user=req.payload.get("user", ""),
metadata={
"channel_event": "channel_unarchive",
"channel_id": channel,
},
)
await self._handle_message_with_security(msg)
except Exception as e:
logger.error(f"Slack channel_unarchive error: {e}", exc_info=True)
@handler.on("app_home_opened")
async def _on_app_home_opened(client: SocketModeClient, req: SocketModeRequest):
try:
user = req.payload.get("user", "")
msg = self._build_system_event(
req,
event_type=EventType.SYSTEM_EVENT,
content="app_home_opened",
channel=user,
ts=req.payload.get("event_ts", ""),
user=user,
metadata={
"forum_event": "app_home_opened",
"tab": req.payload.get("tab", "home"),
"view": req.payload.get("view", {}),
},
)
await self._handle_message_with_security(msg)
except Exception as e:
logger.error(f"Slack app_home_opened error: {e}", exc_info=True)
@handler.on("link_shared")
async def _on_link_shared(client: SocketModeClient, req: SocketModeRequest):
try:
links = req.payload.get("links", [])
user = req.payload.get("user", "")
channel = req.payload.get("channel", "")
message_ts = req.payload.get("message_ts", "")
link_urls = [link.get("url", "") for link in links]
msg = self._build_system_event(
req,
event_type=EventType.MESSAGE_RECEIVED,
content=" ".join(link_urls),
channel=channel,
ts=message_ts,
user=user,
metadata={
"forum_event": "link_shared",
"links": links,
"unfurl_id": req.payload.get("unfurl_id", ""),
},
)
await self._handle_message_with_security(msg)
except Exception as e:
logger.error(f"Slack link_shared error: {e}", exc_info=True)
@handler.on("thread_broadcast")
async def _on_thread_broadcast(client: SocketModeClient, req: SocketModeRequest):
try:
channel = req.payload.get("channel", "")
thread_ts = req.payload.get("thread_ts", req.payload.get("message", {}).get("thread_ts", ""))
msg = self._build_system_event(
req,
event_type=EventType.MESSAGE_UPDATED,
content=f"thread_broadcast:{thread_ts}",
channel=channel,
ts=req.payload.get("ts", req.payload.get("event_ts", "")),
user=req.payload.get("user", ""),
metadata={
"thread_event": "thread_broadcast",
"thread_ts": thread_ts,
"root_message": req.payload.get("root_message", req.payload.get("message", {})),
},
)
await self._handle_message_with_security(msg)
except Exception as e:
logger.error(f"Slack thread_broadcast error: {e}", exc_info=True)
@handler.on("app_uninstalled")
async def _on_app_uninstalled(client: SocketModeClient, req: SocketModeRequest):
logger.warning(f"Slack app uninstalled by team {req.payload.get('team_id', '')}, cleaning up connections")
try:
self._status = ChannelStatus.DISCONNECTED
self._connected_event.clear()
if self._socket_task and not self._socket_task.done():
self._socket_task.cancel()
try:
await self._socket_task
except (asyncio.CancelledError, Exception):
pass
except Exception as e:
logger.error(f"Slack app_uninstalled cleanup error: {e}", exc_info=True)
@handler.on("tokens_revoked")
async def _on_tokens_revoked(client: SocketModeClient, req: SocketModeRequest):
tokens = req.payload.get("tokens", {})
logger.warning(f"Slack tokens revoked: {list(tokens.keys())}")
try:
self._status = ChannelStatus.DISCONNECTED
self._connected_event.clear()
except Exception as e:
logger.error(f"Slack tokens_revoked cleanup error: {e}", exc_info=True)
@handler.on("team_join")
async def _on_team_join(client: SocketModeClient, req: SocketModeRequest):
try:
user_data = req.payload.get("user", {})
user_id = user_data.get("id", "")
msg = self._build_system_event(
req,
event_type=EventType.SYSTEM_EVENT,
content=f"team_join:{user_id}",
channel=user_id,
ts=req.payload.get("event_ts", ""),
user=user_id,
metadata={
"team_event": "team_join",
"user": user_data,
},
)
await self._handle_message_with_security(msg)
except Exception as e:
logger.error(f"Slack team_join error: {e}", exc_info=True)
@handler.on("emoji_changed")
async def _on_emoji_changed(client: SocketModeClient, req: SocketModeRequest):
try:
subtype = req.payload.get("subtype", "")
msg = self._build_system_event(
req,
event_type=EventType.SYSTEM_EVENT,
content=f"emoji_changed:{subtype}",
ts=req.payload.get("event_ts", ""),
metadata={
"emoji_event": "emoji_changed",
"subtype": subtype,
"names": req.payload.get("names", req.payload.get("name", [])),
},
)
await self._handle_message_with_security(msg)
except Exception as e:
logger.error(f"Slack emoji_changed error: {e}", exc_info=True)
@handler.on("file_public")
async def _on_file_public(client: SocketModeClient, req: SocketModeRequest):
try:
file_id = req.payload.get("file_id", req.payload.get("file", {}).get("id", ""))
msg = self._build_system_event(
req,
event_type=EventType.SYSTEM_EVENT,
content=f"file_public:{file_id}",
ts=req.payload.get("event_ts", ""),
metadata={
"file_event": "file_public",
"file_id": file_id,
},
)
await self._handle_message_with_security(msg)
except Exception as e:
logger.error(f"Slack file_public error: {e}", exc_info=True)
@handler.on("file_shared")
async def _on_file_shared(client: SocketModeClient, req: SocketModeRequest):
try:
file_data = req.payload.get("file", {})
file_id = file_data.get("id", "")
channel_id = req.payload.get("channel_id", "")
msg = self._build_system_event(
req,
event_type=EventType.SYSTEM_EVENT,
content=f"file_shared:{file_id}",
channel=channel_id,
ts=req.payload.get("event_ts", ""),
user=req.payload.get("user_id", ""),
metadata={
"file_event": "file_shared",
"file": file_data,
"file_id": file_id,
},
)
await self._handle_message_with_security(msg)
except Exception as e:
logger.error(f"Slack file_shared error: {e}", exc_info=True)
@handler.on("user_change")
async def _on_user_change(client: SocketModeClient, req: SocketModeRequest):
try:
user_data = req.payload.get("user", {})
user_id = user_data.get("id", "")
msg = self._build_system_event(
req,
event_type=EventType.SYSTEM_EVENT,
content=f"user_change:{user_id}",
channel=user_id,
ts=req.payload.get("event_ts", ""),
user=user_id,
metadata={
"user_event": "user_change",
"user": user_data,
},
)
await self._handle_message_with_security(msg)
except Exception as e:
logger.error(f"Slack user_change error: {e}", exc_info=True)
async def _handle_message_changed(self, event: dict, req: SocketModeRequest) -> None:
msg_data = event.get("message", {})
previous = event.get("previous_message", {})
original_msg = self.normalize_inbound(
{
"event": {**previous, "channel": event.get("channel", ""), "ts": previous.get("ts", "")},
"event_type": "message",
"team_id": req.payload.get("team_id", ""),
}
)
original_msg.event_type = EventType.MESSAGE_UPDATED
original_msg.content = msg_data.get("text", "")
original_msg.metadata["edited_message"] = msg_data
original_msg.metadata["previous_message"] = previous
await self._handle_message_with_security(original_msg)
async def _handle_message_deleted(self, event: dict, req: SocketModeRequest) -> None:
ts = event.get("deleted_ts", "")
channel = event.get("channel", "")
msg = self._build_system_event(
req,
event_type=EventType.MESSAGE_DELETED,
content="",
channel=channel,
ts=ts,
)
msg.metadata["deleted_ts"] = ts
await self._handle_message_with_security(msg)
async def _handle_member_event(self, event: dict, req: SocketModeRequest, event_type: EventType) -> None:
joined_user = event.get("user", event.get("inviter", ""))
channel = event.get("channel", "")
msg = self._build_system_event(
req,
event_type=event_type,
content=joined_user,
channel=channel,
ts=event.get("ts", ""),
user=joined_user,
)
await self._handle_message_with_security(msg)
async def _handle_poll_vote(self, payload: dict, action_id: str, req: SocketModeRequest) -> None:
parts = action_id.split("_", 2)
if len(parts) < 3:
return
poll_id = parts[1]
try:
option_index = int(parts[2])
except ValueError:
return
user_id = payload.get("user", {}).get("id", "")
channel_id = payload.get("channel", {}).get("id", "")
success = self._poll_mgr.vote_poll(poll_id, option_index, user_id)
if not success:
return
poll = self._poll_mgr.get_poll(poll_id)
if not poll:
return
results = poll.results()
result_lines = [f"*📊 投票: {poll.question}*", ""]
for opt in results["options"]:
bar = "" * opt["count"] if opt["count"] > 0 else ""
result_lines.append(f"{opt['text']}: {opt['count']}{bar}")
result_lines.append(f"\n_总计 {results['total_votes']} 票_")
if self._client and channel_id and poll.message_ts:
try:
await self._client.chat_update(
channel=channel_id,
ts=poll.message_ts,
text="\n".join(result_lines),
blocks=None,
)
except SlackApiError as e:
logger.warning(f"Failed to update poll results: {e}")
response_url = payload.get("response_url", "")
if response_url and self._client:
try:
import aiohttp
async with aiohttp.ClientSession() as session:
await session.post(
response_url,
json={"text": f"已投票: {poll.options[option_index].text}", "replace_original": False},
)
except Exception:
pass
async def _handle_approval_interaction(self, payload: dict, action_id: str) -> None:
user_id = payload.get("user", {}).get("id", "")
channel_id = payload.get("channel", {}).get("id", "")
message_ts = payload.get("message", {}).get("ts", "")
result = await self.handle_approval_callback(action_id, user_id, message_ts, channel_id)
if not result.success:
logger.warning(f"Approval callback failed: {result.error}")
async def _notify_bot_owner_of_reaction(self, emoji: str, req: SocketModeRequest) -> None:
if not self._client or not self._bot_user_id:
return
try:
user = req.payload.get("user", "")
item = req.payload.get("item", {})
channel = item.get("channel", "unknown")
await self._client.chat_postMessage(
channel=self._bot_user_id,
text=f":{emoji}: 你的消息在 <#{channel}> 中收到了一条 {emoji} 反应,来自 <@{user}>",
)
except Exception as e:
logger.debug(f"Failed to notify bot owner of reaction: {e}")
def _build_system_event(
self,
req: SocketModeRequest,
*,
event_type: EventType,
content: str,
channel: str = "",
ts: str = "",
user: str = "",
metadata: dict | None = None,
) -> ChannelMessage:
event = req.payload.get("event", {})
channel = channel or event.get("channel", "")
user = user or event.get("user", "")
ts = ts or event.get("ts", "")
chat_type = resolve_chat_type(channel, event)
channel_chat_id = resolve_chat_id(channel, event)
return ChannelMessage(
identity=ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id=user,
channel_chat_id=channel_chat_id,
channel_message_id=f"{channel}:{ts}" if ts else None,
),
event_type=event_type,
chat_type=chat_type,
content=content,
metadata={
**(metadata or {}),
"channel": channel,
"ts": ts,
"team_id": req.payload.get("team_id", self._team_id),
},
)
def _cleanup_stale_streams(self, now: float) -> None:
stale_keys = [
k for k, v in self._streaming_messages.items() if now - v.get("_last_update", 0) > self.STREAM_TTL_SECONDS
]
for k in stale_keys:
self._streaming_messages.pop(k, None)
logger.debug(f"Cleaned up stale streaming entry: {k}")
async def _stream_cleanup_loop(self) -> None:
while self._status in (ChannelStatus.CONNECTED, ChannelStatus.CONNECTING):
try:
await asyncio.sleep(self._stream_cleanup_interval_s)
async with self._stream_lock:
self._cleanup_stale_streams(time.monotonic())
except asyncio.CancelledError:
break
except Exception:
logger.debug("Stream cleanup iteration failed", exc_info=True)
async def _channel_heartbeat_loop(self) -> None:
while self._status in (ChannelStatus.CONNECTED, ChannelStatus.RECONNECTING):
try:
await asyncio.sleep(60)
now = time.monotonic()
elapsed = now - self._last_channel_event_at
if (
self._status == ChannelStatus.CONNECTED
and elapsed > self.CHANNEL_EVENT_HEARTBEAT_TIMEOUT_S
and self._last_channel_event_at > 0
):
logger.warning(
f"No channel events for {elapsed:.0f}s, connection may be degraded — triggering reconnect"
)
self._status = ChannelStatus.RECONNECTING
if self._socket_handler:
try:
self._socket_handler.disconnect()
except Exception:
pass
self._connected_event.clear()
if self._socket_task and not self._socket_task.done():
self._socket_task.cancel()
try:
await self._socket_task
except (asyncio.CancelledError, Exception):
pass
self._socket_task = asyncio.create_task(self._socket_handler.connect_async())
except asyncio.CancelledError:
break
except Exception:
logger.debug("Channel heartbeat iteration failed", exc_info=True)
def _is_duplicate_event(self, event_id: str) -> bool:
if event_id in self._processed_event_ids:
self._processed_event_ids.move_to_end(event_id)
logger.debug(f"Skipping duplicate event: {event_id}")
return True
self._processed_event_ids[event_id] = None
if len(self._processed_event_ids) > self.MAX_PROCESSED_EVENT_IDS:
self._processed_event_ids.popitem(last=False)
return False
def should_recover_thread(self, peer_kind: str = "direct") -> bool:
if self._dm_scope == "main" and peer_kind == "direct":
return False
return True