ForcePilot/backend/package/yuxi/channel/extensions/ringcentral/monitor.py
Kris 7a9031ea53 feat(channel): 添加 RingCentral 渠道扩展
新增 RingCentral 渠道扩展,支持在 Yuxi 平台中集成 RingCentral 统一通信平台。

包含以下功能模块:
- sdk: RingCentral SDK 封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- subscription: 事件订阅
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- events: 事件处理
- adaptive_cards: 自适应卡片
- formatting: 格式化
- media: 媒体资源处理
- mentions: @提及
- notes: 笔记功能
- reactions: 表情反应
- tasks: 任务管理
- teams: 团队管理
- types: 类型定义
2026-05-21 11:37:49 +08:00

149 lines
4.6 KiB
Python

from __future__ import annotations
import asyncio
import logging
from datetime import UTC, datetime
from yuxi.channel.extensions.ringcentral.formatting import sanitize_text
from yuxi.channel.extensions.ringcentral.mentions import strip_mentions
from yuxi.channel.extensions.ringcentral.types import RingCentralEvent, ResolvedRingCentralAccount
from yuxi.channel.message.models import (
GroupContext,
MessageType,
PeerInfo,
UnifiedMessage,
)
from yuxi.channel.routing.models import PeerKind
logger = logging.getLogger(__name__)
_PERSON_FETCH_TIMEOUT = 2.0
def _infer_attachment_msg_type(attachment: dict) -> MessageType:
mime = attachment.get("contentType", "").lower()
if mime.startswith("image/"):
return MessageType.IMAGE
if mime.startswith("audio/"):
return MessageType.VOICE
return MessageType.FILE
def _build_attachment_text(attachments: list[dict]) -> str:
names = [a.get("name", "unknown") for a in attachments]
return f"[{', '.join(names)}]"
class RingCentralMonitor:
async def event_to_unified_message(
self,
raw_payload: dict,
event: RingCentralEvent,
account: ResolvedRingCentralAccount | None = None,
) -> UnifiedMessage | None:
post = event.body
if not post:
return None
text = strip_mentions(sanitize_text(post.text or ""))
attachments = post.attachments or []
if not text and not attachments:
return None
if attachments and not text:
msg_type = _infer_attachment_msg_type(attachments[0])
display_text = _build_attachment_text(attachments)
else:
msg_type = MessageType.TEXT
display_text = text
chat_type = _infer_chat_type(post.group_id, raw_payload)
is_dm = chat_type == "direct"
sender_kind = PeerKind.DIRECT if is_dm else PeerKind.GROUP
display_name = None
avatar_url = None
if post.creator_id:
try:
person = await self._fetch_person_safe(post.creator_id, account)
if person:
display_name = (
person.get("firstName", "") + " " + person.get("lastName", "")
).strip() or person.get("name")
avatar_url = person.get("avatar")
except Exception:
pass
sender_info = PeerInfo(
kind=sender_kind,
id=post.creator_id,
display_name=display_name,
avatar_url=avatar_url,
is_bot=False,
)
group = None
if not is_dm:
group = GroupContext(
id=post.group_id,
kind=chat_type,
)
metadata = {
"ChatType": chat_type,
"WasMentioned": "@!" in (post.text or ""),
"event_type": event.event,
"subscription_id": event.subscription_id,
}
return UnifiedMessage(
msg_id=post.id or f"rc:{int(datetime.now(tz=UTC).timestamp())}",
channel_type="ringcentral",
account_id=account.account_id if account else "default",
content=display_text,
sender=sender_info,
message_type=msg_type,
group=group,
raw_payload=raw_payload,
metadata=metadata,
was_mentioned="@!" in (post.text or ""),
)
async def _fetch_person_safe(
self,
person_id: str,
account: ResolvedRingCentralAccount | None,
) -> dict | None:
try:
from yuxi.channel.plugins.registry import ChannelPluginRegistry
plugin = ChannelPluginRegistry.get("ringcentral")
if plugin and hasattr(plugin, "_outbound"):
return await asyncio.wait_for(
plugin._outbound.fetch_person(
person_id,
account_id=account.account_id if account else None,
),
timeout=_PERSON_FETCH_TIMEOUT,
)
except Exception:
pass
return None
def _infer_chat_type(group_id: str, raw_payload: dict) -> str:
body = raw_payload.get("body", raw_payload)
mentions = body.get("mentions", {})
if isinstance(mentions, dict):
for mid, mdata in mentions.items():
mtype = mdata.get("type", "") if isinstance(mdata, dict) else ""
if mtype == "Team":
return "team"
if mtype == "Group":
return "group"
if mtype == "PersonalChat":
return "direct"
return "direct"