ForcePilot/backend/package/yuxi/channel/extensions/slack/monitor.py
Kris bfc7755137 feat(channel): 添加 Slack 渠道扩展
新增 Slack 渠道扩展,支持在 Yuxi 平台中集成 Slack 团队协作平台。

包含以下功能模块:
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- monitor: 渠道状态监控
- status: 会话状态管理
- actions: 交互动作处理
- interactive: 交互式消息
- commands: 斜杠指令
- threading: 线程管理
- mentions: @提及
- constants: 常量定义
- types: 类型定义
2026-05-21 11:43:23 +08:00

212 lines
6.6 KiB
Python

from __future__ import annotations
import logging
import re
import time
from datetime import datetime, UTC
from yuxi.channel.extensions.slack.constants import (
SLACK_DEDUPE_MAX_SIZE,
SLACK_DEDUPE_TTL_SECONDS,
SLACK_MENTION_PATTERN,
SLACK_MESSAGE_SUBTYPES_SYSTEM,
)
from yuxi.channel.extensions.slack.types import SlackBotIdentity, SlackInboundEvent
from yuxi.channel.message.models import (
GroupContext,
MentionSource,
MessageType,
PeerInfo,
UnifiedMessage,
)
from yuxi.channel.routing.models import PeerKind
logger = logging.getLogger(__name__)
class SlackMonitor:
def __init__(self):
self._dedupe: dict[str, float] = {}
def _dedupe_key(self, event: SlackInboundEvent) -> str:
return f"{event.channel}:{event.ts}"
def _is_duplicate(self, event: SlackInboundEvent) -> bool:
key = self._dedupe_key(event)
now = time.monotonic()
if key in self._dedupe:
if now - self._dedupe[key] < SLACK_DEDUPE_TTL_SECONDS:
return True
self._dedupe[key] = now
if len(self._dedupe) > SLACK_DEDUPE_MAX_SIZE:
expired = [k for k, v in self._dedupe.items() if now - v >= SLACK_DEDUPE_TTL_SECONDS]
for k in expired:
del self._dedupe[k]
return False
async def convert_event(
self,
event: SlackInboundEvent,
bot_identity: SlackBotIdentity,
account_id: str,
) -> UnifiedMessage | None:
if self.should_drop_mismatched_event(event, bot_identity):
return None
if self.is_system_event(event):
return None
if self.is_echo(event, bot_identity):
return None
if self._is_duplicate(event):
logger.debug("Dropping duplicate event: channel=%s ts=%s", event.channel, event.ts)
return None
return self._build_unified_message(event, bot_identity, account_id)
def should_drop_mismatched_event(self, event: SlackInboundEvent, bot_identity: SlackBotIdentity) -> bool:
if event.api_app_id and bot_identity.api_app_id and event.api_app_id != bot_identity.api_app_id:
logger.debug(
"Dropping event: api_app_id mismatch %s != %s",
event.api_app_id,
bot_identity.api_app_id,
)
return True
if event.team_id and bot_identity.team_id and event.team_id != bot_identity.team_id:
logger.debug(
"Dropping event: team_id mismatch %s != %s",
event.team_id,
bot_identity.team_id,
)
return True
return False
def is_system_event(self, event: SlackInboundEvent) -> bool:
return event.subtype in SLACK_MESSAGE_SUBTYPES_SYSTEM
def is_echo(self, event: SlackInboundEvent, bot_identity: SlackBotIdentity) -> bool:
if not event.bot_id or not bot_identity.bot_id:
return False
return event.bot_id == bot_identity.bot_id
def _build_unified_message(
self,
event: SlackInboundEvent,
bot_identity: SlackBotIdentity,
account_id: str,
) -> UnifiedMessage:
peer_kind = self._determine_peer_kind(event.channel_type)
media_urls, media_types = self._extract_media(event)
message_type = MessageType.TEXT
if media_urls:
message_type = self._detect_message_type(media_types)
display_name = event.user_real_name or event.user_name or event.user
sender = PeerInfo(
kind=peer_kind,
id=event.user,
display_name=display_name,
username=event.user_name or event.user,
)
text = event.text or ""
mentioned_user_ids = self._extract_mentioned_user_ids(text)
cleaned_text = self._strip_slack_mentions(text)
was_mentioned = len(mentioned_user_ids) > 0
bot_user_id = bot_identity.bot_user_id
explicitly_mentioned_bot = bot_user_id and bot_user_id in mentioned_user_ids
mention_source = MentionSource.NONE
if explicitly_mentioned_bot:
mention_source = MentionSource.EXPLICIT_BOT
elif was_mentioned:
mention_source = MentionSource.MENTION_PATTERN
thread_id = None
if self.is_thread_message(event):
thread_id = event.thread_ts
group = GroupContext(
id=event.channel,
thread_id=thread_id,
)
timestamp = None
if event.event_ts:
try:
timestamp = datetime.fromtimestamp(float(event.event_ts), tz=UTC)
except (ValueError, TypeError):
pass
return UnifiedMessage(
msg_id=event.ts,
channel_type="slack",
account_id=account_id,
content=cleaned_text,
sender=sender,
message_type=message_type,
media_urls=media_urls,
media_types=media_types,
group=group,
timestamp=timestamp,
was_mentioned=was_mentioned,
explicitly_mentioned_bot=explicitly_mentioned_bot,
mentioned_user_ids=mentioned_user_ids,
mention_source=mention_source,
raw_payload=event.raw,
)
@staticmethod
def _determine_peer_kind(channel_type: str) -> PeerKind:
if channel_type in ("im", "mpim"):
return PeerKind.DIRECT
return PeerKind.GROUP
@staticmethod
def _extract_media(event: SlackInboundEvent) -> tuple[list[str], list[str]]:
urls: list[str] = []
types: list[str] = []
for f in event.files:
url = f.get("url_private_download") or f.get("url_private") or f.get("permalink") or ""
if url:
urls.append(url)
mimetype = f.get("mimetype", "") or f.get("filetype", "")
types.append(mimetype)
return urls, types
@staticmethod
def _detect_message_type(media_types: list[str]) -> MessageType:
for mt in media_types:
lower = mt.lower()
if lower.startswith("image/"):
return MessageType.IMAGE
if lower.startswith("audio/") or lower.startswith("voice/"):
return MessageType.VOICE
return MessageType.FILE
@staticmethod
def _strip_slack_mentions(text: str) -> str:
return re.sub(SLACK_MENTION_PATTERN, "", text).strip()
@staticmethod
def _extract_mentioned_user_ids(text: str) -> list[str]:
return re.findall(SLACK_MENTION_PATTERN, text)
@staticmethod
def is_thread_message(event: SlackInboundEvent) -> bool:
return bool(event.thread_ts and event.thread_ts != event.ts)