新增 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: 类型定义
149 lines
4.6 KiB
Python
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"
|