新增 Slack 渠道扩展,支持在 Yuxi 平台中集成 Slack 团队协作平台。 包含以下功能模块: - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - monitor: 渠道状态监控 - status: 会话状态管理 - actions: 交互动作处理 - interactive: 交互式消息 - commands: 斜杠指令 - threading: 线程管理 - mentions: @提及 - constants: 常量定义 - types: 类型定义
212 lines
6.6 KiB
Python
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)
|