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: 类型定义
This commit is contained in:
parent
2ab65f153f
commit
7a9031ea53
409
backend/package/yuxi/channel/extensions/ringcentral/__init__.py
Normal file
409
backend/package/yuxi/channel/extensions/ringcentral/__init__.py
Normal file
@ -0,0 +1,409 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from yuxi.channel.capabilities import ChannelCapabilities
|
||||
from yuxi.channel.extensions.base import BaseChannelPlugin
|
||||
from yuxi.channel.extensions.ringcentral.config import RingCentralConfigAdapter
|
||||
from yuxi.channel.extensions.ringcentral.dedupe import RingCentralDeduplicator
|
||||
from yuxi.channel.extensions.ringcentral.gateway import RingCentralGatewayAdapter
|
||||
from yuxi.channel.extensions.ringcentral.mentions import extract_mentions, strip_mentions
|
||||
from yuxi.channel.extensions.ringcentral.monitor import RingCentralMonitor
|
||||
from yuxi.channel.extensions.ringcentral.outbound import RingCentralOutboundAdapter
|
||||
from yuxi.channel.extensions.ringcentral.security import RingCentralSecurityAdapter
|
||||
from yuxi.channel.extensions.ringcentral.session import RingCentralSessionAdapter
|
||||
from yuxi.channel.extensions.ringcentral.status import RingCentralStatusAdapter
|
||||
from yuxi.channel.plugins.registry import ChannelPluginRegistry
|
||||
from yuxi.channel.sdk.actions import MessageAction, MessageActionRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RingCentralPlugin(BaseChannelPlugin):
|
||||
id = "ringcentral"
|
||||
name = "RingCentral"
|
||||
order = 80
|
||||
label = "RingCentral (Team Messaging)"
|
||||
aliases = ["rc", "glip", "ringcentral-glip"]
|
||||
|
||||
def __init__(self):
|
||||
self._config = RingCentralConfigAdapter()
|
||||
self._gateway = RingCentralGatewayAdapter()
|
||||
self._outbound = RingCentralOutboundAdapter()
|
||||
self._monitor = RingCentralMonitor()
|
||||
self._status = RingCentralStatusAdapter()
|
||||
self._security = RingCentralSecurityAdapter()
|
||||
self._session = RingCentralSessionAdapter()
|
||||
self._dedup = RingCentralDeduplicator()
|
||||
self._actions = MessageActionRegistry()
|
||||
self._actions.register(
|
||||
MessageAction.SEND, self._handle_send, description="发送文本消息到 RingCentral"
|
||||
)
|
||||
self._actions.register(
|
||||
MessageAction.SEND_MEDIA, self._handle_send_media, description="发送图片/文件到 RingCentral"
|
||||
)
|
||||
self._actions.register(
|
||||
MessageAction.EDIT, self._handle_edit, description="编辑已发送的消息"
|
||||
)
|
||||
self._actions.register(
|
||||
MessageAction.REACT, self._handle_react, description="对消息添加表情反应"
|
||||
)
|
||||
|
||||
@property
|
||||
def capabilities(self) -> ChannelCapabilities:
|
||||
return ChannelCapabilities(
|
||||
chat_types=["direct", "group", "team"],
|
||||
message_types=["text", "image", "file"],
|
||||
reactions=True,
|
||||
typing_indicator=False,
|
||||
threads=False,
|
||||
edit=True,
|
||||
unsend=True,
|
||||
reply=False,
|
||||
media=True,
|
||||
streaming=True,
|
||||
streaming_mode="block",
|
||||
block_streaming=True,
|
||||
block_streaming_chunk_min_chars=500,
|
||||
block_streaming_chunk_max_chars=1000,
|
||||
block_streaming_coalesce_min_chars=200,
|
||||
adaptive_cards=True,
|
||||
tasks=False,
|
||||
events=False,
|
||||
notes=False,
|
||||
)
|
||||
|
||||
@property
|
||||
def actions(self):
|
||||
return self._actions
|
||||
|
||||
# ── ConfigProtocol ────────────────────────────────────
|
||||
|
||||
def list_account_ids(self, config: dict) -> list[str]:
|
||||
return self._config.list_account_ids(config)
|
||||
|
||||
async def resolve_account(self, account_id: str) -> dict:
|
||||
return await self._config.resolve_account(account_id)
|
||||
|
||||
def is_configured(self, account: dict) -> bool:
|
||||
return self._config.is_configured(account)
|
||||
|
||||
def is_enabled(self, account: dict) -> bool:
|
||||
return self._config.is_enabled(account)
|
||||
|
||||
def disabled_reason(self, account: dict) -> str:
|
||||
return self._config.disabled_reason(account)
|
||||
|
||||
async def resolve_allow_from(self, config: dict, account_id: str) -> list[str] | None:
|
||||
return await self._config.resolve_allow_from(config, account_id)
|
||||
|
||||
def describe_account(self, account: dict) -> dict:
|
||||
return self._config.describe_account(account)
|
||||
|
||||
# ── GatewayProtocol ───────────────────────────────────
|
||||
|
||||
async def start(self, ctx) -> object:
|
||||
return await self._gateway.start(ctx)
|
||||
|
||||
async def stop(self, ctx) -> None:
|
||||
await self._gateway.stop(ctx)
|
||||
|
||||
# ── OutboundProtocol ──────────────────────────────────
|
||||
|
||||
async def send_text(
|
||||
self,
|
||||
target_id: str,
|
||||
content: str,
|
||||
*,
|
||||
reply_to_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
account_id: str | None = None,
|
||||
) -> None:
|
||||
await self._outbound.send_text(
|
||||
target_id,
|
||||
content,
|
||||
reply_to_id=reply_to_id,
|
||||
thread_id=thread_id,
|
||||
account_id=account_id,
|
||||
)
|
||||
|
||||
async def send_media(
|
||||
self,
|
||||
target_id: str,
|
||||
media_url: str,
|
||||
media_type: str,
|
||||
reply_to_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
account_id: str | None = None,
|
||||
) -> None:
|
||||
await self._outbound.send_media(
|
||||
target_id,
|
||||
media_url,
|
||||
media_type,
|
||||
reply_to_id=reply_to_id,
|
||||
thread_id=thread_id,
|
||||
account_id=account_id,
|
||||
)
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
target_id: str,
|
||||
message_id: str,
|
||||
content: str,
|
||||
*,
|
||||
thread_id: str | None = None,
|
||||
account_id: str | None = None,
|
||||
) -> str | None:
|
||||
return await self._outbound.edit_message(
|
||||
target_id,
|
||||
message_id,
|
||||
content,
|
||||
thread_id=thread_id,
|
||||
account_id=account_id,
|
||||
)
|
||||
|
||||
def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]:
|
||||
return self._outbound.chunker(text, limit, ctx)
|
||||
|
||||
def sanitize_text(self, text: str, payload: object | None = None) -> str:
|
||||
return self._outbound.sanitize_text_out(text, payload)
|
||||
|
||||
# ── CardProtocol ──────────────────────────────────────
|
||||
|
||||
async def send_card(
|
||||
self,
|
||||
target_id: str,
|
||||
card_content: dict,
|
||||
*,
|
||||
reply_to_id: str | None = None,
|
||||
account_id: str | None = None,
|
||||
) -> str | None:
|
||||
from yuxi.channel.extensions.ringcentral.adaptive_cards import send_adaptive_card
|
||||
|
||||
client = self._gateway.get_client(account_id or "default")
|
||||
if not client:
|
||||
return None
|
||||
result = await send_adaptive_card(client, target_id, card_content)
|
||||
return result.get("id")
|
||||
|
||||
async def edit_card(
|
||||
self,
|
||||
target_id: str,
|
||||
message_id: str,
|
||||
card_content: dict,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
) -> str | None:
|
||||
from yuxi.channel.extensions.ringcentral.adaptive_cards import update_adaptive_card
|
||||
|
||||
client = self._gateway.get_client(account_id or "default")
|
||||
if not client:
|
||||
return None
|
||||
result = await update_adaptive_card(client, message_id, card_content)
|
||||
return message_id if result else None
|
||||
|
||||
# ── StatusProtocol ────────────────────────────────────
|
||||
|
||||
async def probe(self, account: dict) -> bool:
|
||||
return await self._status.probe(account)
|
||||
|
||||
def build_summary(self, snapshot: object) -> dict:
|
||||
return self._status.build_summary(snapshot)
|
||||
|
||||
# ── SecurityProtocol ──────────────────────────────────
|
||||
|
||||
async def check_allowlist(self, peer_id: str, channel_type: str) -> bool:
|
||||
try:
|
||||
accounts = self._config.list_account_ids({})
|
||||
for account_id in accounts:
|
||||
resolved = await self._config.resolve_account(account_id)
|
||||
account = resolved.get("resolved")
|
||||
if account and not self._security.check_allowlist(peer_id, account):
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
def resolve_dm_policy(self) -> dict:
|
||||
return self._security.resolve_dm_policy()
|
||||
|
||||
# ── PairingProtocol ───────────────────────────────────
|
||||
|
||||
async def generate_code(self, peer_id: str) -> str:
|
||||
from yuxi.channel.extensions.ringcentral.pairing import RingCentralPairingAdapter
|
||||
|
||||
adapter = RingCentralPairingAdapter()
|
||||
return await adapter.generate_code(peer_id)
|
||||
|
||||
async def verify_code(self, peer_id: str, code: str) -> bool:
|
||||
from yuxi.channel.extensions.ringcentral.pairing import RingCentralPairingAdapter
|
||||
|
||||
adapter = RingCentralPairingAdapter()
|
||||
return await adapter.verify_code(peer_id, code)
|
||||
|
||||
# ── MessagingProtocol ─────────────────────────────────
|
||||
|
||||
def resolve_session(self, msg):
|
||||
return self._session.resolve_session(msg)
|
||||
|
||||
def resolve_reply_to_mode(
|
||||
self,
|
||||
config: dict,
|
||||
account_id: str | None = None,
|
||||
chat_type: str | None = None,
|
||||
) -> str:
|
||||
return "none"
|
||||
|
||||
# ── ReactionProtocol ──────────────────────────────────
|
||||
|
||||
async def send_reaction(
|
||||
self,
|
||||
target_id: str,
|
||||
message_id: str,
|
||||
emoji: str,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
) -> None:
|
||||
client = self._gateway.get_client(account_id or "default")
|
||||
if not client:
|
||||
return
|
||||
await client.post(
|
||||
f"/restapi/v1.0/glip/posts/{message_id}/reactions",
|
||||
body={"reaction": emoji},
|
||||
)
|
||||
|
||||
async def remove_reaction(
|
||||
self,
|
||||
target_id: str,
|
||||
message_id: str,
|
||||
emoji: str,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
) -> None:
|
||||
client = self._gateway.get_client(account_id or "default")
|
||||
if not client:
|
||||
return
|
||||
await client.delete(f"/restapi/v1.0/glip/posts/{message_id}/reactions/{emoji}")
|
||||
|
||||
# ── WebhookProtocol ───────────────────────────────────
|
||||
|
||||
async def handle_webhook(self, request: object, account_id: str | None = None) -> object:
|
||||
return None
|
||||
|
||||
# ── InboundHandlerProtocol ────────────────────────────
|
||||
|
||||
async def handle_raw_event(self, event: dict, account: dict) -> object | None:
|
||||
from yuxi.channel.extensions.ringcentral.types import RingCentralEvent
|
||||
|
||||
rc_event = RingCentralEvent.from_payload(event)
|
||||
resolved = account.get("resolved")
|
||||
return await self._monitor.event_to_unified_message(event, rc_event, resolved)
|
||||
|
||||
# ── FormatProtocol ────────────────────────────────────
|
||||
|
||||
def markdown_to_native(self, md_text: str) -> dict | str:
|
||||
from yuxi.channel.extensions.ringcentral.formatting import sanitize_text
|
||||
|
||||
return sanitize_text(md_text)
|
||||
|
||||
def native_to_markdown(self, native_content: dict | str) -> str:
|
||||
if isinstance(native_content, dict):
|
||||
return native_content.get("text", "")
|
||||
return str(native_content)
|
||||
|
||||
# ── DedupeProtocol ────────────────────────────────────
|
||||
|
||||
def is_duplicate(self, key: str) -> bool:
|
||||
return self._dedup.is_duplicate(key)
|
||||
|
||||
def mark_seen(self, key: str) -> None:
|
||||
self._dedup.mark_seen(key)
|
||||
|
||||
def reset(self) -> None:
|
||||
self._dedup.reset()
|
||||
|
||||
@property
|
||||
def ttl_seconds(self) -> int:
|
||||
return self._dedup.ttl_seconds
|
||||
|
||||
@property
|
||||
def max_entries(self) -> int:
|
||||
return self._dedup.max_entries
|
||||
|
||||
# ── MentionsProtocol ──────────────────────────────────
|
||||
|
||||
def extract_mentions(self, raw_message: dict) -> list[str]:
|
||||
text = raw_message.get("body", {}).get("text", "")
|
||||
return [name for name, _ in extract_mentions(text)]
|
||||
|
||||
def strip_mentions(self, text: str, ctx: object | None = None) -> str:
|
||||
return strip_mentions(text)
|
||||
|
||||
# ── ConfigSchemaProtocol ──────────────────────────────
|
||||
|
||||
def config_schema(self) -> dict:
|
||||
return self._config.config_schema()
|
||||
|
||||
def collect_warnings(self, config: dict, account_id: str | None = None) -> list[str]:
|
||||
return self._security.collect_warnings(config, account_id)
|
||||
|
||||
# ── AgentPromptProtocol ───────────────────────────────
|
||||
|
||||
@property
|
||||
def channel_format_instructions(self) -> str | None:
|
||||
return (
|
||||
"You are responding via RingCentral Team Messaging. Messages support Markdown "
|
||||
"(bold, italic, strikethrough, code, code blocks, blockquotes, lists, headings). "
|
||||
"@mention format: @. "
|
||||
"Messages are limited to 1000 characters and will be automatically chunked if longer. "
|
||||
"Be concise and avoid excessive formatting."
|
||||
)
|
||||
|
||||
def build_system_prompt(self, context) -> str | None:
|
||||
return (
|
||||
"你正在通过 RingCentral Team Messaging (Glip) 与用户交互。"
|
||||
"RingCentral 支持 Markdown 格式、自适应卡片、表情反应等功能。"
|
||||
"请使用简洁清晰的格式回复,大字报式内容会自动分段发送。"
|
||||
)
|
||||
|
||||
# ── HeartbeatProtocol ─────────────────────────────────
|
||||
|
||||
async def send_typing(self, target_id: str, thread_id: str | None = None) -> None:
|
||||
pass
|
||||
|
||||
async def clear_typing(self, target_id: str, thread_id: str | None = None) -> None:
|
||||
pass
|
||||
|
||||
# ── MessageAction handlers ────────────────────────────
|
||||
|
||||
async def _handle_send(
|
||||
self, *, content: str, target_id: str,
|
||||
reply_to_id: str | None = None, thread_id: str | None = None, **kwargs,
|
||||
) -> dict:
|
||||
await self.send_text(
|
||||
target_id, content, reply_to_id=reply_to_id, thread_id=thread_id,
|
||||
)
|
||||
return {"success": True}
|
||||
|
||||
async def _handle_send_media(
|
||||
self, *, media_url: str, media_type: str, target_id: str, **kwargs,
|
||||
) -> dict:
|
||||
await self.send_media(target_id, media_url, media_type)
|
||||
return {"success": True}
|
||||
|
||||
async def _handle_edit(
|
||||
self, *, message_id: str, content: str,
|
||||
target_id: str, **kwargs,
|
||||
) -> dict:
|
||||
result = await self.edit_message(target_id, message_id, content)
|
||||
return {"success": result is not None, "msg_id": result}
|
||||
|
||||
async def _handle_react(
|
||||
self, *, message_id: str, emoji: str, **kwargs,
|
||||
) -> dict:
|
||||
await self.send_reaction("", message_id, emoji)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
ringcentral_plugin = RingCentralPlugin()
|
||||
ChannelPluginRegistry.register(ringcentral_plugin)
|
||||
@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from yuxi.channel.extensions.ringcentral.sdk import AsyncRingCentralClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TEAM_MESSAGING_BASE = "/team-messaging/v1"
|
||||
|
||||
|
||||
async def send_adaptive_card(
|
||||
client: AsyncRingCentralClient,
|
||||
chat_id: str,
|
||||
card: dict,
|
||||
) -> dict:
|
||||
return await client.post(
|
||||
f"{TEAM_MESSAGING_BASE}/chats/{chat_id}/adaptive-cards",
|
||||
body=card,
|
||||
)
|
||||
|
||||
|
||||
async def update_adaptive_card(
|
||||
client: AsyncRingCentralClient,
|
||||
card_id: str,
|
||||
card: dict,
|
||||
) -> dict:
|
||||
return await client.put(
|
||||
f"{TEAM_MESSAGING_BASE}/adaptive-cards/{card_id}",
|
||||
body=card,
|
||||
)
|
||||
|
||||
|
||||
async def get_adaptive_card(
|
||||
client: AsyncRingCentralClient,
|
||||
card_id: str,
|
||||
) -> dict:
|
||||
return await client.get(
|
||||
f"{TEAM_MESSAGING_BASE}/adaptive-cards/{card_id}",
|
||||
)
|
||||
167
backend/package/yuxi/channel/extensions/ringcentral/config.py
Normal file
167
backend/package/yuxi/channel/extensions/ringcentral/config.py
Normal file
@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from yuxi.channel.extensions.ringcentral.types import ResolvedRingCentralAccount
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_WEBHOOK_PATH = "/api/webhook/ringcentral"
|
||||
|
||||
|
||||
class RingCentralConfigAdapter:
|
||||
DEFAULT_ACCOUNT_ID = "default"
|
||||
|
||||
def list_account_ids(self, config: dict) -> list[str]:
|
||||
raw = config.get("channels", {}).get("ringcentral", {})
|
||||
accounts = raw.get("accounts", {})
|
||||
if accounts:
|
||||
return list(accounts.keys())
|
||||
if raw.get("client_id"):
|
||||
return [self.DEFAULT_ACCOUNT_ID]
|
||||
return []
|
||||
|
||||
async def resolve_account(self, account_id: str) -> dict:
|
||||
from yuxi.channel.runtime.manager import gateway
|
||||
|
||||
config = gateway.global_config if gateway is not None else {}
|
||||
rc_config = config.get("channels", {}).get("ringcentral", {}) if config else {}
|
||||
|
||||
defaults = rc_config.get("accounts", {}).get("default", {})
|
||||
account_cfg = rc_config.get("accounts", {}).get(account_id, {})
|
||||
merged = {**rc_config, **defaults, **account_cfg}
|
||||
|
||||
resolved = self._build_resolved(account_id, merged)
|
||||
|
||||
return {
|
||||
"account_id": account_id,
|
||||
"name": resolved.name,
|
||||
"client_id": resolved.client_id,
|
||||
"client_secret": resolved.client_secret,
|
||||
"server_url": resolved.server_url,
|
||||
"jwt_token": resolved.jwt_token,
|
||||
"webhook_path": resolved.webhook_path,
|
||||
"webhook_validation_token": resolved.webhook_validation_token,
|
||||
"bot_person_id": resolved.bot_person_id,
|
||||
"subscription_id": resolved.subscription_id,
|
||||
"dm_policy": resolved.dm_policy,
|
||||
"dm_enabled": resolved.dm_enabled,
|
||||
"dm_allow_from": resolved.dm_allow_from,
|
||||
"group_policy": resolved.group_policy,
|
||||
"require_mention": resolved.require_mention,
|
||||
"text_chunk_limit": resolved.text_chunk_limit,
|
||||
"media_max_mb": resolved.media_max_mb,
|
||||
"enabled": resolved.enabled,
|
||||
"config": resolved.config,
|
||||
"resolved": resolved,
|
||||
}
|
||||
|
||||
def _build_resolved(self, account_id: str, merged: dict) -> ResolvedRingCentralAccount:
|
||||
client_id = merged.get("client_id", "") or os.getenv("RINGCENTRAL_CLIENT_ID", "")
|
||||
client_secret = merged.get("client_secret", "") or os.getenv("RINGCENTRAL_CLIENT_SECRET", "")
|
||||
jwt_token = merged.get("jwt_token", "") or os.getenv("RINGCENTRAL_JWT_TOKEN", "")
|
||||
|
||||
return ResolvedRingCentralAccount(
|
||||
account_id=account_id,
|
||||
name=merged.get("name", account_id),
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
server_url=merged.get("server_url", "https://platform.ringcentral.com"),
|
||||
jwt_token=jwt_token,
|
||||
webhook_path=merged.get("webhook_path", _DEFAULT_WEBHOOK_PATH),
|
||||
webhook_validation_token=merged.get("webhook_validation_token", ""),
|
||||
bot_person_id=merged.get("bot_person_id", ""),
|
||||
subscription_id=merged.get("subscription_id", ""),
|
||||
dm_policy=merged.get("dm_policy", "pairing"),
|
||||
dm_enabled=merged.get("dm_enabled", True),
|
||||
dm_allow_from=merged.get("dm_allow_from", []),
|
||||
group_policy=merged.get("group_policy", "allowlist"),
|
||||
require_mention=merged.get("require_mention", True),
|
||||
text_chunk_limit=merged.get("text_chunk_limit", 1000),
|
||||
media_max_mb=merged.get("media_max_mb", 20),
|
||||
enabled=merged.get("enabled", True),
|
||||
config=merged,
|
||||
)
|
||||
|
||||
def is_configured(self, account: dict) -> bool:
|
||||
resolved = account.get("resolved")
|
||||
if isinstance(resolved, ResolvedRingCentralAccount):
|
||||
return resolved.is_configured
|
||||
return bool(account.get("client_id") and account.get("jwt_token"))
|
||||
|
||||
def is_enabled(self, account: dict) -> bool:
|
||||
return bool(account.get("enabled", True))
|
||||
|
||||
def disabled_reason(self, account: dict) -> str:
|
||||
if not self.is_configured(account):
|
||||
return "RingCentral client_id, client_secret and jwt_token are required"
|
||||
return ""
|
||||
|
||||
async def resolve_allow_from(self, config: dict, account_id: str) -> list[str] | None:
|
||||
account = await self.resolve_account(account_id)
|
||||
return account.get("dm_allow_from", [])
|
||||
|
||||
def describe_account(self, account: dict) -> dict:
|
||||
return {
|
||||
"account_id": account.get("account_id", ""),
|
||||
"name": account.get("name", ""),
|
||||
"configured": self.is_configured(account),
|
||||
}
|
||||
|
||||
def config_schema(self) -> dict:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": True},
|
||||
"name": {"type": "string"},
|
||||
"clientId": {"type": "string"},
|
||||
"clientSecret": {"type": "string"},
|
||||
"serverUrl": {
|
||||
"type": "string",
|
||||
"default": "https://platform.ringcentral.com",
|
||||
},
|
||||
"jwtToken": {"type": "string"},
|
||||
"webhookPath": {"type": "string", "default": _DEFAULT_WEBHOOK_PATH},
|
||||
"webhookValidationToken": {"type": "string"},
|
||||
"botPersonId": {"type": "string"},
|
||||
"subscriptionId": {"type": "string"},
|
||||
"dmPolicy": {
|
||||
"type": "string",
|
||||
"enum": ["open", "pairing", "allowlist", "disabled"],
|
||||
"default": "pairing",
|
||||
},
|
||||
"dmEnabled": {"type": "boolean", "default": True},
|
||||
"dmAllowFrom": {"type": "array", "items": {"type": "string"}},
|
||||
"groupPolicy": {
|
||||
"type": "string",
|
||||
"enum": ["open", "allowlist", "disabled"],
|
||||
"default": "allowlist",
|
||||
},
|
||||
"requireMention": {"type": "boolean", "default": True},
|
||||
"textChunkLimit": {"type": "integer", "default": 1000},
|
||||
"mediaMaxMb": {"type": "integer", "default": 20},
|
||||
"accounts": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"clientId": {"type": "string"},
|
||||
"clientSecret": {"type": "string"},
|
||||
"serverUrl": {"type": "string"},
|
||||
"jwtToken": {"type": "string"},
|
||||
"webhookPath": {"type": "string"},
|
||||
"webhookValidationToken": {"type": "string"},
|
||||
"botPersonId": {"type": "string"},
|
||||
"subscriptionId": {"type": "string"},
|
||||
"dmPolicy": {"type": "string"},
|
||||
"dmEnabled": {"type": "boolean"},
|
||||
"dmAllowFrom": {"type": "array", "items": {"type": "string"}},
|
||||
"groupPolicy": {"type": "string"},
|
||||
"requireMention": {"type": "boolean"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
|
||||
class RingCentralDeduplicator:
|
||||
def __init__(self, ttl_seconds: int = 300, max_entries: int = 10000):
|
||||
self._seen: dict[str, float] = {}
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self.max_entries = max_entries
|
||||
|
||||
def is_duplicate(self, key: str) -> bool:
|
||||
self._evict_expired()
|
||||
return key in self._seen
|
||||
|
||||
def mark_seen(self, key: str) -> None:
|
||||
self._seen[key] = time.time()
|
||||
if len(self._seen) > self.max_entries:
|
||||
self._evict_oldest()
|
||||
|
||||
def reset(self) -> None:
|
||||
self._seen.clear()
|
||||
|
||||
def _evict_expired(self) -> None:
|
||||
now = time.time()
|
||||
expired = [k for k, t in self._seen.items() if now - t > self.ttl_seconds]
|
||||
for k in expired:
|
||||
del self._seen[k]
|
||||
|
||||
def _evict_oldest(self) -> None:
|
||||
excess = len(self._seen) - self.max_entries
|
||||
if excess <= 0:
|
||||
return
|
||||
sorted_keys = sorted(self._seen, key=lambda k: self._seen[k])
|
||||
for k in sorted_keys[:excess]:
|
||||
del self._seen[k]
|
||||
@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class RingCentralError(Exception):
|
||||
def __init__(self, status_code: int, message: str):
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
super().__init__(f"[{status_code}] {message}")
|
||||
|
||||
|
||||
class RingCentralAuthError(RingCentralError):
|
||||
pass
|
||||
|
||||
|
||||
class RingCentralRateLimitError(RingCentralError):
|
||||
def __init__(self, status_code: int, message: str, retry_after: int = 60):
|
||||
super().__init__(status_code, message)
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
class RingCentralNotFoundError(RingCentralError):
|
||||
pass
|
||||
|
||||
|
||||
class RingCentralValidationError(RingCentralError):
|
||||
pass
|
||||
|
||||
|
||||
class RingCentralPermissionError(RingCentralError):
|
||||
pass
|
||||
|
||||
|
||||
def is_retryable_error(status_code: int) -> bool:
|
||||
return status_code == 429 or status_code >= 500
|
||||
@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from yuxi.channel.extensions.ringcentral.sdk import AsyncRingCentralClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def list_events(
|
||||
client: AsyncRingCentralClient,
|
||||
group_id: str,
|
||||
) -> dict:
|
||||
return await client.get(f"/restapi/v1.0/glip/groups/{group_id}/events")
|
||||
|
||||
|
||||
async def create_event(
|
||||
client: AsyncRingCentralClient,
|
||||
group_id: str,
|
||||
title: str,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
*,
|
||||
description: str = "",
|
||||
location: str = "",
|
||||
all_day: bool = False,
|
||||
recurrence: str | None = None,
|
||||
) -> dict:
|
||||
payload = {
|
||||
"title": title,
|
||||
"startTime": start_time,
|
||||
"endTime": end_time,
|
||||
}
|
||||
if description:
|
||||
payload["description"] = description
|
||||
if location:
|
||||
payload["location"] = location
|
||||
if all_day:
|
||||
payload["allDay"] = all_day
|
||||
if recurrence:
|
||||
payload["recurrence"] = recurrence
|
||||
return await client.post(
|
||||
f"/restapi/v1.0/glip/groups/{group_id}/events",
|
||||
body=payload,
|
||||
)
|
||||
|
||||
|
||||
async def get_event(
|
||||
client: AsyncRingCentralClient,
|
||||
event_id: str,
|
||||
) -> dict:
|
||||
return await client.get(f"/restapi/v1.0/glip/events/{event_id}")
|
||||
|
||||
|
||||
async def update_event(
|
||||
client: AsyncRingCentralClient,
|
||||
event_id: str,
|
||||
*,
|
||||
title: str | None = None,
|
||||
start_time: str | None = None,
|
||||
end_time: str | None = None,
|
||||
description: str | None = None,
|
||||
location: str | None = None,
|
||||
) -> dict:
|
||||
payload = {}
|
||||
if title is not None:
|
||||
payload["title"] = title
|
||||
if start_time is not None:
|
||||
payload["startTime"] = start_time
|
||||
if end_time is not None:
|
||||
payload["endTime"] = end_time
|
||||
if description is not None:
|
||||
payload["description"] = description
|
||||
if location is not None:
|
||||
payload["location"] = location
|
||||
return await client.put(
|
||||
f"/restapi/v1.0/glip/events/{event_id}",
|
||||
body=payload,
|
||||
)
|
||||
|
||||
|
||||
async def delete_event(client: AsyncRingCentralClient, event_id: str) -> None:
|
||||
await client.delete(f"/restapi/v1.0/glip/events/{event_id}")
|
||||
@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_CONTROL_CHAR_PATTERN = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
||||
_MULTI_NEWLINE_PATTERN = re.compile(r"\n{3,}")
|
||||
|
||||
|
||||
def sanitize_text(text: str) -> str:
|
||||
cleaned = _CONTROL_CHAR_PATTERN.sub("", text)
|
||||
cleaned = _MULTI_NEWLINE_PATTERN.sub("\n\n", cleaned)
|
||||
return cleaned.strip()
|
||||
|
||||
|
||||
def chunk_text(text: str, limit: int = 1000) -> list[str]:
|
||||
if len(text) <= limit:
|
||||
return [text]
|
||||
|
||||
chunks = []
|
||||
remaining = text
|
||||
while remaining:
|
||||
if len(remaining) <= limit:
|
||||
chunks.append(remaining)
|
||||
break
|
||||
|
||||
split_at = remaining.rfind("\n", 0, limit)
|
||||
if split_at == -1 or split_at < limit // 2:
|
||||
split_at = remaining.rfind(" ", 0, limit)
|
||||
if split_at == -1:
|
||||
split_at = limit
|
||||
|
||||
chunks.append(remaining[:split_at])
|
||||
remaining = remaining[split_at:].lstrip()
|
||||
return chunks
|
||||
160
backend/package/yuxi/channel/extensions/ringcentral/gateway.py
Normal file
160
backend/package/yuxi/channel/extensions/ringcentral/gateway.py
Normal file
@ -0,0 +1,160 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
from yuxi.channel.extensions.ringcentral.config import RingCentralConfigAdapter
|
||||
from yuxi.channel.extensions.ringcentral.errors import RingCentralAuthError
|
||||
from yuxi.channel.extensions.ringcentral.sdk import AsyncRingCentralClient
|
||||
from yuxi.channel.extensions.ringcentral.subscription import (
|
||||
create_subscription,
|
||||
delete_subscription,
|
||||
renew_subscription,
|
||||
)
|
||||
from yuxi.channel.extensions.ringcentral.types import ResolvedRingCentralAccount
|
||||
from yuxi.channel.extensions.ringcentral.webhook import RingCentralWebhookHandler
|
||||
from yuxi.channel.gateway.routes import webhook_registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUBSCRIPTION_RENEW_BEFORE_SEC = 3600
|
||||
RENEW_INTERVAL = 86400 - SUBSCRIPTION_RENEW_BEFORE_SEC
|
||||
|
||||
|
||||
class RingCentralGatewayAdapter:
|
||||
def __init__(self):
|
||||
self._config_adapter = RingCentralConfigAdapter()
|
||||
self._webhook_handler = RingCentralWebhookHandler()
|
||||
self._clients: dict[str, AsyncRingCentralClient] = {}
|
||||
self._accounts: dict[str, ResolvedRingCentralAccount] = {}
|
||||
self._tasks: dict[str, asyncio.Task] = {}
|
||||
self._abort_events: dict[str, asyncio.Event] = {}
|
||||
self._running: dict[str, bool] = {}
|
||||
self._status_snapshots: dict[str, dict] = {}
|
||||
|
||||
async def start(self, ctx) -> object:
|
||||
account_dict = getattr(ctx, "account", {}) or {}
|
||||
account = account_dict.get("resolved")
|
||||
if not isinstance(account, ResolvedRingCentralAccount):
|
||||
account_id = account_dict.get("account_id", "default")
|
||||
resolved_dict = await self._config_adapter.resolve_account(account_id)
|
||||
account = resolved_dict.get("resolved")
|
||||
|
||||
if not account or not account.is_configured:
|
||||
logger.warning("RingCentral account not configured: %s", account.account_id if account else "unknown")
|
||||
return asyncio.Queue()
|
||||
|
||||
account_id = account.account_id
|
||||
|
||||
client = AsyncRingCentralClient(
|
||||
client_id=account.client_id,
|
||||
client_secret=account.client_secret,
|
||||
server_url=account.server_url,
|
||||
)
|
||||
|
||||
try:
|
||||
await client.login_jwt(account.jwt_token)
|
||||
except RingCentralAuthError as e:
|
||||
logger.error("RingCentral auth failed for %s: %s", account_id, e)
|
||||
return asyncio.Queue()
|
||||
|
||||
self._clients[account_id] = client
|
||||
self._accounts[account_id] = account
|
||||
|
||||
self._webhook_handler.register_target(account.webhook_path, account)
|
||||
|
||||
webhook_registry.register(
|
||||
"ringcentral",
|
||||
self._webhook_handler.handle_webhook,
|
||||
guard_config=None,
|
||||
)
|
||||
|
||||
base_url = getattr(ctx, "base_url", "") if ctx else ""
|
||||
if base_url and not account.subscription_id:
|
||||
webhook_url = f"{base_url.rstrip('/')}{account.webhook_path}"
|
||||
try:
|
||||
sub_result = await create_subscription(client, account, webhook_url)
|
||||
account.subscription_id = sub_result.get("id", "")
|
||||
except Exception:
|
||||
logger.warning("Failed to create RingCentral subscription for %s", account_id)
|
||||
|
||||
abort_event = asyncio.Event()
|
||||
self._abort_events[account_id] = abort_event
|
||||
|
||||
task = asyncio.create_task(self._subscription_renew_loop(account_id, account, abort_event))
|
||||
self._tasks[account_id] = task
|
||||
|
||||
self._running[account_id] = True
|
||||
self._status_snapshots[account_id] = {
|
||||
"running": True,
|
||||
"webhook_path": account.webhook_path,
|
||||
"server_url": account.server_url,
|
||||
"subscription_id": account.subscription_id,
|
||||
"start_time": time.time(),
|
||||
}
|
||||
|
||||
logger.info("RingCentral gateway started for account: %s, webhook: %s", account_id, account.webhook_path)
|
||||
|
||||
queue = getattr(ctx, "queue", asyncio.Queue())
|
||||
return queue
|
||||
|
||||
async def stop(self, ctx) -> None:
|
||||
account_dict = getattr(ctx, "account", {}) or {}
|
||||
account_id = account_dict.get("account_id", "default")
|
||||
|
||||
account = account_dict.get("resolved")
|
||||
if isinstance(account, ResolvedRingCentralAccount):
|
||||
self._webhook_handler.unregister_target(account.webhook_path)
|
||||
|
||||
abort = self._abort_events.pop(account_id, None)
|
||||
if abort:
|
||||
abort.set()
|
||||
|
||||
task = self._tasks.pop(account_id, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
client = self._clients.pop(account_id, None)
|
||||
stored_account = self._accounts.pop(account_id, None)
|
||||
|
||||
if client and stored_account and stored_account.subscription_id:
|
||||
try:
|
||||
await delete_subscription(client, stored_account.subscription_id)
|
||||
except Exception:
|
||||
logger.warning("Failed to delete RingCentral subscription for %s", account_id)
|
||||
|
||||
self._running.pop(account_id, None)
|
||||
self._status_snapshots[account_id] = {"running": False, "stop_time": time.time()}
|
||||
|
||||
logger.info("RingCentral gateway stopped for account: %s", account_id)
|
||||
|
||||
async def _subscription_renew_loop(
|
||||
self,
|
||||
account_id: str,
|
||||
account: ResolvedRingCentralAccount,
|
||||
abort_event: asyncio.Event,
|
||||
) -> None:
|
||||
client = self._clients.get(account_id)
|
||||
if not client:
|
||||
return
|
||||
|
||||
while not abort_event.is_set():
|
||||
try:
|
||||
await asyncio.sleep(RENEW_INTERVAL)
|
||||
if abort_event.is_set():
|
||||
break
|
||||
if account.subscription_id:
|
||||
await renew_subscription(client, account.subscription_id)
|
||||
except Exception as e:
|
||||
logger.error("RingCentral subscription renew failed for %s: %s", account_id, e)
|
||||
|
||||
def get_client(self, account_id: str = "default") -> AsyncRingCentralClient | None:
|
||||
return self._clients.get(account_id)
|
||||
|
||||
def get_status(self, account_id: str) -> dict:
|
||||
return self._status_snapshots.get(account_id, {})
|
||||
91
backend/package/yuxi/channel/extensions/ringcentral/media.py
Normal file
91
backend/package/yuxi/channel/extensions/ringcentral/media.py
Normal file
@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from yuxi.channel.extensions.ringcentral.sdk import AsyncRingCentralClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def upload_file(
|
||||
client: AsyncRingCentralClient,
|
||||
group_id: str,
|
||||
file_data: bytes,
|
||||
filename: str,
|
||||
content_type: str = "application/octet-stream",
|
||||
) -> dict:
|
||||
builder = client.platform.create_multipart_builder()
|
||||
builder.set_body({"groupId": group_id})
|
||||
builder.add_file(None, content=file_data, content_type=content_type, file_name=filename)
|
||||
|
||||
def _send():
|
||||
request = builder.request("/restapi/v1.0/glip/files")
|
||||
return client.platform.send_request(request)
|
||||
|
||||
resp = await asyncio.to_thread(_send)
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def download_file_content(
|
||||
client: AsyncRingCentralClient,
|
||||
file_id: str,
|
||||
) -> bytes:
|
||||
def _download():
|
||||
resp = client.platform.get(f"/restapi/v1.0/glip/files/{file_id}/content")
|
||||
return resp.response.content
|
||||
|
||||
return await asyncio.to_thread(_download)
|
||||
|
||||
|
||||
async def send_media_message(
|
||||
client: AsyncRingCentralClient,
|
||||
group_id: str,
|
||||
text: str,
|
||||
file_info: dict,
|
||||
) -> dict:
|
||||
return await client.post(
|
||||
f"/restapi/v1.0/glip/chats/{group_id}/posts",
|
||||
body={
|
||||
"text": text,
|
||||
"attachments": [
|
||||
{
|
||||
"type": "File",
|
||||
"id": file_info.get("id"),
|
||||
"name": file_info.get("name", ""),
|
||||
"contentUri": file_info.get("contentUri", ""),
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def send_media_from_url(
|
||||
client: AsyncRingCentralClient,
|
||||
group_id: str,
|
||||
media_url: str,
|
||||
media_type: str,
|
||||
) -> None:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as http:
|
||||
resp = await http.get(media_url)
|
||||
resp.raise_for_status()
|
||||
content = resp.content
|
||||
|
||||
filename = _extract_filename(media_url, media_type)
|
||||
upload_result = await upload_file(client, group_id, content, filename, media_type)
|
||||
await send_media_message(client, group_id, "", upload_result)
|
||||
|
||||
|
||||
def _extract_filename(url: str, content_type: str) -> str:
|
||||
ext_map = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"video/mp4": ".mp4",
|
||||
"application/pdf": ".pdf",
|
||||
}
|
||||
ext = ext_map.get(content_type, ".bin")
|
||||
return f"attachment{ext}"
|
||||
@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
MENTION_PATTERN = re.compile(r"@!\[(.*?)\]\((.*?)\)")
|
||||
|
||||
|
||||
def extract_mentions(text: str) -> list[tuple[str, str]]:
|
||||
return MENTION_PATTERN.findall(text)
|
||||
|
||||
|
||||
def strip_mentions(text: str) -> str:
|
||||
return MENTION_PATTERN.sub("", text).strip()
|
||||
148
backend/package/yuxi/channel/extensions/ringcentral/monitor.py
Normal file
148
backend/package/yuxi/channel/extensions/ringcentral/monitor.py
Normal file
@ -0,0 +1,148 @@
|
||||
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"
|
||||
55
backend/package/yuxi/channel/extensions/ringcentral/notes.py
Normal file
55
backend/package/yuxi/channel/extensions/ringcentral/notes.py
Normal file
@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from yuxi.channel.extensions.ringcentral.sdk import AsyncRingCentralClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def list_notes(
|
||||
client: AsyncRingCentralClient,
|
||||
chat_id: str,
|
||||
) -> dict:
|
||||
return await client.get(f"/restapi/v1.0/glip/chats/{chat_id}/notes")
|
||||
|
||||
|
||||
async def create_note(
|
||||
client: AsyncRingCentralClient,
|
||||
chat_id: str,
|
||||
title: str,
|
||||
body: str,
|
||||
) -> dict:
|
||||
return await client.post(
|
||||
f"/restapi/v1.0/glip/chats/{chat_id}/notes",
|
||||
body={"title": title, "body": body},
|
||||
)
|
||||
|
||||
|
||||
async def get_note(
|
||||
client: AsyncRingCentralClient,
|
||||
note_id: str,
|
||||
) -> dict:
|
||||
return await client.get(f"/restapi/v1.0/glip/notes/{note_id}")
|
||||
|
||||
|
||||
async def update_note(
|
||||
client: AsyncRingCentralClient,
|
||||
note_id: str,
|
||||
*,
|
||||
title: str | None = None,
|
||||
body: str | None = None,
|
||||
) -> dict:
|
||||
payload = {}
|
||||
if title is not None:
|
||||
payload["title"] = title
|
||||
if body is not None:
|
||||
payload["body"] = body
|
||||
return await client.patch(
|
||||
f"/restapi/v1.0/glip/notes/{note_id}",
|
||||
body=payload,
|
||||
)
|
||||
|
||||
|
||||
async def delete_note(client: AsyncRingCentralClient, note_id: str) -> None:
|
||||
await client.delete(f"/restapi/v1.0/glip/notes/{note_id}")
|
||||
324
backend/package/yuxi/channel/extensions/ringcentral/outbound.py
Normal file
324
backend/package/yuxi/channel/extensions/ringcentral/outbound.py
Normal file
@ -0,0 +1,324 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from yuxi.channel.extensions.ringcentral.errors import (
|
||||
RingCentralRateLimitError,
|
||||
is_retryable_error,
|
||||
)
|
||||
from yuxi.channel.extensions.ringcentral.formatting import chunk_text, sanitize_text
|
||||
from yuxi.channel.extensions.ringcentral.sdk import AsyncRingCentralClient
|
||||
from yuxi.channel.extensions.ringcentral.types import ResolvedRingCentralAccount
|
||||
from yuxi.channel.protocols import (
|
||||
OutboundDeliveryCapabilities,
|
||||
OutboundDeliveryMode,
|
||||
OutboundPresentationCapabilities,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RingCentralOutboundAdapter:
|
||||
delivery_mode = OutboundDeliveryMode.DIRECT
|
||||
chunker_mode = "markdown"
|
||||
text_chunk_limit: int = 1000
|
||||
max_retries: int = 3
|
||||
retry_base_delay: float = 1.0
|
||||
presentation_capabilities = OutboundPresentationCapabilities(supported=False)
|
||||
delivery_capabilities = OutboundDeliveryCapabilities(
|
||||
durable_final_text=True,
|
||||
durable_final_media=False,
|
||||
)
|
||||
|
||||
def _get_account(self, account_id: str | None) -> ResolvedRingCentralAccount | None:
|
||||
if account_id:
|
||||
from yuxi.channel.plugins.registry import ChannelPluginRegistry
|
||||
|
||||
plugin = ChannelPluginRegistry.get("ringcentral")
|
||||
if plugin and hasattr(plugin, "_config"):
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
account_dict = loop.run_until_complete(plugin._config.resolve_account(account_id))
|
||||
return account_dict.get("resolved")
|
||||
return None
|
||||
|
||||
def _get_client(self, account_id: str | None = None) -> AsyncRingCentralClient | None:
|
||||
if account_id:
|
||||
from yuxi.channel.plugins.registry import ChannelPluginRegistry
|
||||
|
||||
plugin = ChannelPluginRegistry.get("ringcentral")
|
||||
if plugin and hasattr(plugin, "_gateway"):
|
||||
return plugin._gateway.get_client(account_id)
|
||||
return None
|
||||
|
||||
def _require_client(self, account_id: str | None = None) -> AsyncRingCentralClient:
|
||||
client = self._get_client(account_id)
|
||||
if not client:
|
||||
raise RuntimeError(f"RingCentral client not found for account: {account_id}")
|
||||
return client
|
||||
|
||||
async def send_text(
|
||||
self,
|
||||
target_id: str,
|
||||
content: str,
|
||||
*,
|
||||
reply_to_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
account_id: str | None = None,
|
||||
) -> None:
|
||||
client = self._get_client(account_id)
|
||||
if not client:
|
||||
raise RuntimeError(f"RingCentral client not found for account: {account_id}")
|
||||
|
||||
safe = sanitize_text(content)
|
||||
if not safe:
|
||||
return
|
||||
|
||||
account = self._get_account(account_id)
|
||||
limit = account.text_chunk_limit if account else self.text_chunk_limit
|
||||
|
||||
chunks = chunk_text(safe, limit)
|
||||
for chunk in chunks:
|
||||
await self._send_post_with_retry(client, target_id, chunk)
|
||||
|
||||
async def _send_post_with_retry(
|
||||
self,
|
||||
client: AsyncRingCentralClient,
|
||||
chat_id: str,
|
||||
text: str,
|
||||
) -> str | None:
|
||||
last_error = None
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
result = await client.post(
|
||||
f"/restapi/v1.0/glip/chats/{chat_id}/posts",
|
||||
body={"text": text},
|
||||
)
|
||||
return result.get("id", "")
|
||||
except RingCentralRateLimitError as e:
|
||||
delay = max(e.retry_after, self.retry_base_delay * (2**attempt))
|
||||
logger.warning("RingCentral rate limited, retrying in %ss (attempt %d)", delay, attempt + 1)
|
||||
await asyncio.sleep(delay)
|
||||
last_error = e
|
||||
except Exception as e:
|
||||
if not is_retryable_error(getattr(e, "status_code", 500)):
|
||||
raise
|
||||
delay = self.retry_base_delay * (2**attempt)
|
||||
await asyncio.sleep(delay)
|
||||
last_error = e
|
||||
if last_error:
|
||||
raise last_error
|
||||
return None
|
||||
|
||||
async def send_media(
|
||||
self,
|
||||
target_id: str,
|
||||
media_url: str,
|
||||
media_type: str,
|
||||
reply_to_id: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
account_id: str | None = None,
|
||||
) -> None:
|
||||
client = self._get_client(account_id)
|
||||
if not client:
|
||||
raise RuntimeError(f"RingCentral client not found for account: {account_id}")
|
||||
|
||||
from yuxi.channel.extensions.ringcentral.media import send_media_from_url
|
||||
|
||||
await send_media_from_url(client, target_id, media_url, media_type)
|
||||
|
||||
async def edit_message(
|
||||
self,
|
||||
target_id: str,
|
||||
message_id: str,
|
||||
content: str,
|
||||
*,
|
||||
thread_id: str | None = None,
|
||||
account_id: str | None = None,
|
||||
) -> str | None:
|
||||
client = self._get_client(account_id)
|
||||
if not client:
|
||||
return None
|
||||
|
||||
safe = sanitize_text(content)
|
||||
result = await client.patch(
|
||||
f"/restapi/v1.0/glip/chats/{target_id}/posts/{message_id}",
|
||||
body={"text": safe},
|
||||
)
|
||||
return result.get("id") if result else None
|
||||
|
||||
async def delete_message(
|
||||
self,
|
||||
chat_id: str,
|
||||
post_id: str,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
) -> None:
|
||||
client = self._get_client(account_id)
|
||||
if not client:
|
||||
return
|
||||
|
||||
await client.delete(f"/restapi/v1.0/glip/chats/{chat_id}/posts/{post_id}")
|
||||
|
||||
async def send_reaction(
|
||||
self,
|
||||
post_id: str,
|
||||
emoji: str,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
) -> dict | None:
|
||||
client = self._get_client(account_id)
|
||||
if not client:
|
||||
return None
|
||||
|
||||
return await client.post(
|
||||
f"/restapi/v1.0/glip/posts/{post_id}/reactions",
|
||||
body={"reaction": emoji},
|
||||
)
|
||||
|
||||
async def remove_reaction(
|
||||
self,
|
||||
post_id: str,
|
||||
emoji: str,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
) -> None:
|
||||
client = self._get_client(account_id)
|
||||
if not client:
|
||||
return
|
||||
|
||||
await client.delete(
|
||||
f"/restapi/v1.0/glip/posts/{post_id}/reactions/{emoji}",
|
||||
)
|
||||
|
||||
def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]:
|
||||
return chunk_text(text, limit)
|
||||
|
||||
async def fetch_posts(
|
||||
self,
|
||||
chat_id: str,
|
||||
*,
|
||||
page: int = 1,
|
||||
per_page: int = 20,
|
||||
account_id: str | None = None,
|
||||
) -> dict:
|
||||
client = self._require_client(account_id)
|
||||
return await client.get(
|
||||
f"/restapi/v1.0/glip/chats/{chat_id}/posts",
|
||||
params={"page": page, "perPage": per_page},
|
||||
)
|
||||
|
||||
async def get_post(
|
||||
self,
|
||||
chat_id: str,
|
||||
post_id: str,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
) -> dict:
|
||||
client = self._require_client(account_id)
|
||||
return await client.get(
|
||||
f"/restapi/v1.0/glip/chats/{chat_id}/posts/{post_id}",
|
||||
)
|
||||
|
||||
async def fetch_person(
|
||||
self,
|
||||
person_id: str,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
) -> dict:
|
||||
client = self._require_client(account_id)
|
||||
return await client.get(f"/restapi/v1.0/glip/persons/{person_id}")
|
||||
|
||||
async def fetch_me(
|
||||
self,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
) -> dict:
|
||||
return await self.fetch_person("~", account_id=account_id)
|
||||
|
||||
async def mark_chat_read(
|
||||
self,
|
||||
chat_id: str,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
) -> None:
|
||||
client = self._require_client(account_id)
|
||||
await client.post(f"/restapi/v1.0/glip/chats/{chat_id}/read")
|
||||
|
||||
async def favorite_post(
|
||||
self,
|
||||
post_id: str,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
) -> None:
|
||||
client = self._require_client(account_id)
|
||||
await client.post(f"/restapi/v1.0/glip/posts/{post_id}/favorite")
|
||||
|
||||
async def unfavorite_post(
|
||||
self,
|
||||
post_id: str,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
) -> None:
|
||||
client = self._require_client(account_id)
|
||||
await client.delete(f"/restapi/v1.0/glip/posts/{post_id}/favorite")
|
||||
|
||||
async def list_chats(
|
||||
self,
|
||||
*,
|
||||
page: int = 1,
|
||||
per_page: int = 20,
|
||||
account_id: str | None = None,
|
||||
) -> dict:
|
||||
client = self._require_client(account_id)
|
||||
return await client.get(
|
||||
"/restapi/v1.0/glip/chats",
|
||||
params={"page": page, "perPage": per_page},
|
||||
)
|
||||
|
||||
async def get_chat(
|
||||
self,
|
||||
chat_id: str,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
) -> dict:
|
||||
client = self._require_client(account_id)
|
||||
return await client.get(f"/restapi/v1.0/glip/chats/{chat_id}")
|
||||
|
||||
async def list_groups(
|
||||
self,
|
||||
*,
|
||||
page: int = 1,
|
||||
per_page: int = 20,
|
||||
account_id: str | None = None,
|
||||
) -> dict:
|
||||
client = self._require_client(account_id)
|
||||
return await client.get(
|
||||
"/restapi/v1.0/glip/groups",
|
||||
params={"page": page, "perPage": per_page},
|
||||
)
|
||||
|
||||
async def get_group(
|
||||
self,
|
||||
group_id: str,
|
||||
*,
|
||||
account_id: str | None = None,
|
||||
) -> dict:
|
||||
client = self._require_client(account_id)
|
||||
return await client.get(f"/restapi/v1.0/glip/groups/{group_id}")
|
||||
|
||||
def sanitize_text_out(self, text: str, payload: object) -> str:
|
||||
return sanitize_text(text)
|
||||
|
||||
def resolve_effective_text_chunk_limit(
|
||||
self,
|
||||
config: dict,
|
||||
account_id: str | None = None,
|
||||
fallback_limit: int | None = None,
|
||||
) -> int | None:
|
||||
return self.text_chunk_limit
|
||||
|
||||
def should_treat_delivered_text_as_visible(self, kind: str, text: str | None = None) -> bool:
|
||||
return True
|
||||
@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CODE_TTL_SEC = 300
|
||||
_CODE_LENGTH = 6
|
||||
|
||||
_pairing_store: dict[str, dict] = {}
|
||||
|
||||
|
||||
class RingCentralPairingAdapter:
|
||||
id_label = "RingCentral User ID"
|
||||
|
||||
async def generate_code(self, peer_id: str) -> str:
|
||||
code = secrets.token_hex(_CODE_LENGTH // 2)[:_CODE_LENGTH].upper()
|
||||
_pairing_store[peer_id] = {
|
||||
"code": code,
|
||||
"status": "pending",
|
||||
"created_at": time.time(),
|
||||
}
|
||||
return code
|
||||
|
||||
async def verify_code(self, peer_id: str, code: str) -> bool:
|
||||
stored = _pairing_store.get(peer_id)
|
||||
if not stored:
|
||||
return False
|
||||
if time.time() - stored.get("created_at", 0) > _CODE_TTL_SEC:
|
||||
del _pairing_store[peer_id]
|
||||
return False
|
||||
if stored.get("code") != code:
|
||||
return False
|
||||
stored["status"] = "verified"
|
||||
return True
|
||||
|
||||
def is_paired(self, peer_id: str) -> bool:
|
||||
stored = _pairing_store.get(peer_id)
|
||||
return stored is not None and stored.get("status") == "verified"
|
||||
|
||||
def cleanup_expired(self) -> None:
|
||||
now = time.time()
|
||||
expired = [k for k, v in _pairing_store.items() if now - v.get("created_at", 0) > _CODE_TTL_SEC]
|
||||
for k in expired:
|
||||
del _pairing_store[k]
|
||||
@ -0,0 +1,34 @@
|
||||
{
|
||||
"id": "ringcentral",
|
||||
"name": "RingCentral",
|
||||
"version": "0.1.0",
|
||||
"label": "RingCentral (Team Messaging)",
|
||||
"aliases": ["rc", "glip", "ringcentral-glip"],
|
||||
"description": "RingCentral channel plugin for ForcePilot — Team Messaging, Glip API, and RingCentral Bot integration",
|
||||
"author": "ForcePilot",
|
||||
"order": 80,
|
||||
"dependencies": ["ringcentral"],
|
||||
"capabilities": {
|
||||
"chat_types": ["direct", "group", "team"],
|
||||
"message_types": ["text", "image", "file"],
|
||||
"reactions": true,
|
||||
"typing_indicator": false,
|
||||
"threads": false,
|
||||
"edit": true,
|
||||
"unsend": true,
|
||||
"reply": false,
|
||||
"media": true,
|
||||
"streaming": true,
|
||||
"streaming_mode": "block",
|
||||
"block_streaming": true,
|
||||
"block_streaming_chunk_min_chars": 500,
|
||||
"block_streaming_chunk_max_chars": 1000,
|
||||
"block_streaming_coalesce_min_chars": 200,
|
||||
"adaptive_cards": true,
|
||||
"tasks": false,
|
||||
"events": false,
|
||||
"notes": false
|
||||
},
|
||||
"enabled": true,
|
||||
"python_requires": ">=3.12"
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from yuxi.channel.extensions.ringcentral.sdk import AsyncRingCentralClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def add_reaction(client: AsyncRingCentralClient, post_id: str, emoji: str) -> dict:
|
||||
return await client.post(
|
||||
f"/restapi/v1.0/glip/posts/{post_id}/reactions",
|
||||
body={"reaction": emoji},
|
||||
)
|
||||
|
||||
|
||||
async def remove_reaction(client: AsyncRingCentralClient, post_id: str, emoji: str) -> None:
|
||||
await client.delete(
|
||||
f"/restapi/v1.0/glip/posts/{post_id}/reactions/{emoji}",
|
||||
)
|
||||
|
||||
|
||||
async def list_reactions(client: AsyncRingCentralClient, post_id: str) -> list[dict]:
|
||||
resp = await client.get(f"/restapi/v1.0/glip/posts/{post_id}/reactions")
|
||||
return resp.get("records", [])
|
||||
128
backend/package/yuxi/channel/extensions/ringcentral/sdk.py
Normal file
128
backend/package/yuxi/channel/extensions/ringcentral/sdk.py
Normal file
@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from ringcentral import SDK
|
||||
from ringcentral.http.api_exception import ApiException
|
||||
|
||||
from yuxi.channel.extensions.ringcentral.errors import (
|
||||
RingCentralAuthError,
|
||||
RingCentralError,
|
||||
RingCentralNotFoundError,
|
||||
RingCentralPermissionError,
|
||||
RingCentralRateLimitError,
|
||||
RingCentralValidationError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AsyncRingCentralClient:
|
||||
def __init__(self, client_id: str, client_secret: str, server_url: str):
|
||||
self._sdk = SDK(client_id, client_secret, server_url)
|
||||
self._platform = self._sdk.platform()
|
||||
self.client_id = client_id
|
||||
self.server_url = server_url
|
||||
|
||||
async def login_jwt(self, jwt_token: str) -> dict:
|
||||
def _login():
|
||||
return self._platform.login(jwt=jwt_token)
|
||||
|
||||
try:
|
||||
resp = await asyncio.to_thread(_login)
|
||||
data = resp.json()
|
||||
logger.info("RingCentral JWT login succeeded, owner_id=%s", data.get("owner_id", ""))
|
||||
return data
|
||||
except ApiException as e:
|
||||
raise RingCentralAuthError(getattr(e, "status_code", 401), str(e)) from e
|
||||
|
||||
async def ensure_authenticated(self, jwt_token: str) -> None:
|
||||
auth = self._platform.auth()
|
||||
if auth and auth.access_token_valid():
|
||||
return
|
||||
logger.info("RingCentral access token expired, re-authenticating...")
|
||||
await self.login_jwt(jwt_token)
|
||||
|
||||
@property
|
||||
def is_logged_in(self) -> bool:
|
||||
return self._platform.logged_in()
|
||||
|
||||
@property
|
||||
def platform(self):
|
||||
return self._platform
|
||||
|
||||
async def _call(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
body: dict | None = None,
|
||||
params: dict | None = None,
|
||||
) -> dict:
|
||||
def _do():
|
||||
if method == "GET":
|
||||
return self._platform.get(path, params or {})
|
||||
elif method == "POST":
|
||||
return self._platform.post(path, body)
|
||||
elif method == "PATCH":
|
||||
return self._platform.patch(path, body)
|
||||
elif method == "PUT":
|
||||
return self._platform.put(path, body)
|
||||
elif method == "DELETE":
|
||||
return self._platform.delete(path)
|
||||
raise ValueError(f"Unknown method: {method}")
|
||||
|
||||
try:
|
||||
resp = await asyncio.to_thread(_do)
|
||||
if resp.ok():
|
||||
return resp.json()
|
||||
raise self._parse_error(resp)
|
||||
except ApiException as e:
|
||||
raise self._parse_api_exception(e)
|
||||
|
||||
def _parse_error(self, response) -> RingCentralError:
|
||||
status = response.status
|
||||
try:
|
||||
body = response.json()
|
||||
msg = body.get("message", response.text)
|
||||
except Exception:
|
||||
msg = response.text
|
||||
|
||||
if status == 429:
|
||||
retry_after = response.headers.get("Retry-After", "60")
|
||||
return RingCentralRateLimitError(status, msg, retry_after=int(retry_after))
|
||||
if status == 404:
|
||||
return RingCentralNotFoundError(status, msg)
|
||||
if status in (400, 422):
|
||||
return RingCentralValidationError(status, msg)
|
||||
if status == 403:
|
||||
return RingCentralPermissionError(status, msg)
|
||||
if status in (401,):
|
||||
return RingCentralAuthError(status, msg)
|
||||
return RingCentralError(status, msg)
|
||||
|
||||
def _parse_api_exception(self, e: ApiException) -> RingCentralError:
|
||||
msg = str(e)
|
||||
if "rate limit" in msg.lower() or "429" in msg:
|
||||
return RingCentralRateLimitError(429, msg, retry_after=60)
|
||||
if "unauthorized" in msg.lower() or "token" in msg.lower():
|
||||
return RingCentralAuthError(401, msg)
|
||||
return RingCentralError(500, msg)
|
||||
|
||||
async def get(self, path: str, params: dict | None = None) -> dict:
|
||||
return await self._call("GET", path, params=params)
|
||||
|
||||
async def post(self, path: str, body: dict | None = None) -> dict:
|
||||
return await self._call("POST", path, body=body)
|
||||
|
||||
async def patch(self, path: str, body: dict | None = None) -> dict:
|
||||
return await self._call("PATCH", path, body=body)
|
||||
|
||||
async def put(self, path: str, body: dict | None = None) -> dict:
|
||||
return await self._call("PUT", path, body=body)
|
||||
|
||||
async def delete(self, path: str) -> dict:
|
||||
return await self._call("DELETE", path)
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RingCentralSecurityAdapter:
|
||||
def resolve_dm_policy(self) -> dict:
|
||||
return {"mode": "pairing", "allow_from": []}
|
||||
|
||||
def check_allowlist(self, sender_id: str, account) -> bool:
|
||||
if not account:
|
||||
return True
|
||||
allow_from = account.dm_allow_from if hasattr(account, "dm_allow_from") else []
|
||||
if not allow_from:
|
||||
return True
|
||||
return sender_id in allow_from
|
||||
|
||||
def evaluate(self, chat_type: str, sender_id: str, text: str, account) -> tuple[bool, str]:
|
||||
if chat_type == "direct":
|
||||
if not account:
|
||||
return True, "ok"
|
||||
if not getattr(account, "dm_enabled", True):
|
||||
return False, "dm_disabled"
|
||||
dm_policy = getattr(account, "dm_policy", "pairing")
|
||||
if dm_policy == "disabled":
|
||||
return False, "dm_disabled"
|
||||
if dm_policy == "allowlist" and not self.check_allowlist(sender_id, account):
|
||||
return False, "sender_not_in_allowlist"
|
||||
if dm_policy == "pairing":
|
||||
return True, "pairing_check"
|
||||
|
||||
elif chat_type in ("group", "team"):
|
||||
if not account:
|
||||
return True, "ok"
|
||||
group_policy = getattr(account, "group_policy", "allowlist")
|
||||
if group_policy == "disabled":
|
||||
return False, "group_disabled"
|
||||
if getattr(account, "require_mention", True) and "@!" not in text:
|
||||
return False, "mention_required"
|
||||
|
||||
return True, "ok"
|
||||
|
||||
def collect_warnings(self, config: dict, account_id: str | None = None) -> list[str]:
|
||||
warnings = []
|
||||
rc_config = config.get("channels", {}).get("ringcentral", {})
|
||||
|
||||
group_policy = rc_config.get("group_policy", "allowlist")
|
||||
if group_policy == "open":
|
||||
warnings.append(
|
||||
"Group policy is 'open' - any RingCentral group can trigger the bot. "
|
||||
"Consider using 'allowlist' for production."
|
||||
)
|
||||
|
||||
dm_policy = rc_config.get("dm_policy", "pairing")
|
||||
if dm_policy == "open":
|
||||
warnings.append(
|
||||
"DM policy is 'open' - anyone can DM the bot. Consider using 'pairing' or 'allowlist' for production."
|
||||
)
|
||||
|
||||
return warnings
|
||||
@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.protocols import SessionResolution
|
||||
|
||||
|
||||
class RingCentralSessionAdapter:
|
||||
def resolve_session(self, msg) -> SessionResolution:
|
||||
from yuxi.channel.routing.models import PeerKind
|
||||
|
||||
if hasattr(msg, "sender") and hasattr(msg.sender, "kind"):
|
||||
if msg.sender.kind == PeerKind.DIRECT:
|
||||
chat_id = _extract_chat_id(msg)
|
||||
return SessionResolution(
|
||||
kind="direct",
|
||||
conversation_id=chat_id or msg.sender.id,
|
||||
)
|
||||
|
||||
chat_id = _extract_chat_id(msg)
|
||||
if chat_id:
|
||||
return SessionResolution(kind="group", conversation_id=chat_id)
|
||||
|
||||
if msg.group and msg.group.id:
|
||||
return SessionResolution(kind="group", conversation_id=msg.group.id)
|
||||
|
||||
return SessionResolution(kind="group", conversation_id="unknown")
|
||||
|
||||
@staticmethod
|
||||
def build_dm_session_key(agent_id: str, account_id: str, chat_id: str) -> str:
|
||||
return f"agent:{agent_id}:ringcentral:direct:{chat_id}"
|
||||
|
||||
@staticmethod
|
||||
def build_group_session_key(agent_id: str, account_id: str, chat_id: str) -> str:
|
||||
return f"agent:{agent_id}:ringcentral:group:{chat_id}"
|
||||
|
||||
|
||||
def _extract_chat_id(msg) -> str | None:
|
||||
metadata = getattr(msg, "metadata", {}) or {}
|
||||
if metadata.get("chat_id"):
|
||||
return metadata["chat_id"]
|
||||
|
||||
raw = getattr(msg, "raw_payload", {}) or {}
|
||||
body = raw.get("body", raw)
|
||||
group_id = body.get("groupId", "")
|
||||
if group_id:
|
||||
return group_id
|
||||
|
||||
return None
|
||||
@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from yuxi.channel.extensions.ringcentral.types import ResolvedRingCentralAccount
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RingCentralStatusAdapter:
|
||||
async def probe(self, account_dict: dict) -> bool:
|
||||
account = account_dict.get("resolved")
|
||||
if not isinstance(account, ResolvedRingCentralAccount):
|
||||
return False
|
||||
|
||||
from yuxi.channel.plugins.registry import ChannelPluginRegistry
|
||||
|
||||
plugin = ChannelPluginRegistry.get("ringcentral")
|
||||
if not plugin or not hasattr(plugin, "_gateway"):
|
||||
return False
|
||||
|
||||
client = plugin._gateway.get_client(account.account_id)
|
||||
if not client or not client.is_logged_in:
|
||||
return False
|
||||
|
||||
try:
|
||||
await client.get("/restapi/v1.0/account/~/extension/~")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def build_summary(self, snapshot) -> dict:
|
||||
return {
|
||||
"channel": "ringcentral",
|
||||
"mode": "webhook",
|
||||
}
|
||||
|
||||
def build_account_snapshot(self, account_dict: dict) -> dict:
|
||||
return {
|
||||
"account_id": account_dict.get("account_id", "default"),
|
||||
"name": account_dict.get("name", ""),
|
||||
"configured": bool(account_dict.get("client_id") and account_dict.get("jwt_token")),
|
||||
"enabled": account_dict.get("enabled", True),
|
||||
}
|
||||
|
||||
def collect_status_issues(self, accounts: list[dict]) -> list[dict]:
|
||||
issues = []
|
||||
for account in accounts:
|
||||
if not account:
|
||||
continue
|
||||
|
||||
if not account.get("client_id"):
|
||||
issues.append(
|
||||
{
|
||||
"channel": "ringcentral",
|
||||
"account_id": account.get("account_id", "default"),
|
||||
"kind": "client_id_missing",
|
||||
"message": "client_id is not configured.",
|
||||
"fix": "Set channels.ringcentral.client_id.",
|
||||
}
|
||||
)
|
||||
|
||||
if not account.get("jwt_token"):
|
||||
issues.append(
|
||||
{
|
||||
"channel": "ringcentral",
|
||||
"account_id": account.get("account_id", "default"),
|
||||
"kind": "jwt_token_missing",
|
||||
"message": "jwt_token is not configured.",
|
||||
"fix": "Set channels.ringcentral.jwt_token.",
|
||||
}
|
||||
)
|
||||
|
||||
return issues
|
||||
@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RingCentralBlockStreamer:
|
||||
def __init__(
|
||||
self,
|
||||
chunk_min: int = 500,
|
||||
chunk_max: int = 1000,
|
||||
coalesce_min: int = 200,
|
||||
coalesce_idle_ms: int = 200,
|
||||
):
|
||||
self.chunk_min = chunk_min
|
||||
self.chunk_max = chunk_max
|
||||
self.coalesce_min = coalesce_min
|
||||
self.coalesce_idle_ms = coalesce_idle_ms
|
||||
|
||||
async def stream(
|
||||
self,
|
||||
target_id: str,
|
||||
content_stream,
|
||||
outbound_adapter,
|
||||
account_id: str | None = None,
|
||||
) -> list[str]:
|
||||
message_ids = []
|
||||
buffer = ""
|
||||
|
||||
async for chunk in content_stream:
|
||||
buffer += chunk
|
||||
if len(buffer) >= self.chunk_min:
|
||||
to_send = buffer[: self.chunk_max]
|
||||
await outbound_adapter.send_text(
|
||||
target_id,
|
||||
to_send,
|
||||
account_id=account_id,
|
||||
)
|
||||
message_ids.append(to_send)
|
||||
buffer = buffer[self.chunk_max :]
|
||||
|
||||
if buffer.strip():
|
||||
await outbound_adapter.send_text(
|
||||
target_id,
|
||||
buffer,
|
||||
account_id=account_id,
|
||||
)
|
||||
message_ids.append(buffer)
|
||||
|
||||
return message_ids
|
||||
@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from yuxi.channel.extensions.ringcentral.sdk import AsyncRingCentralClient
|
||||
from yuxi.channel.extensions.ringcentral.types import ResolvedRingCentralAccount
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_SUBSCRIPTION_EXPIRES = 86400 * 7
|
||||
|
||||
|
||||
async def create_subscription(
|
||||
client: AsyncRingCentralClient,
|
||||
account: ResolvedRingCentralAccount,
|
||||
webhook_url: str,
|
||||
expires_in: int = DEFAULT_SUBSCRIPTION_EXPIRES,
|
||||
) -> dict:
|
||||
payload = {
|
||||
"eventFilters": [
|
||||
"/restapi/v1.0/glip/posts",
|
||||
"/restapi/v1.0/glip/groups",
|
||||
],
|
||||
"expiresIn": expires_in,
|
||||
"deliveryMode": {
|
||||
"transportType": "WebHook",
|
||||
"address": webhook_url,
|
||||
},
|
||||
}
|
||||
|
||||
if not any("adaptive-cards" in f for f in payload["eventFilters"]):
|
||||
payload["eventFilters"].append("/team-messaging/v1/adaptive-cards/action")
|
||||
|
||||
result = await client.post("/restapi/v1.0/subscription", body=payload)
|
||||
logger.info("RingCentral subscription created: id=%s", result.get("id"))
|
||||
return result
|
||||
|
||||
|
||||
async def renew_subscription(
|
||||
client: AsyncRingCentralClient,
|
||||
subscription_id: str,
|
||||
expires_in: int = DEFAULT_SUBSCRIPTION_EXPIRES,
|
||||
) -> dict:
|
||||
result = await client.put(
|
||||
f"/restapi/v1.0/subscription/{subscription_id}",
|
||||
body={"expiresIn": expires_in},
|
||||
)
|
||||
logger.info("RingCentral subscription renewed: id=%s", subscription_id)
|
||||
return result
|
||||
|
||||
|
||||
async def delete_subscription(client: AsyncRingCentralClient, subscription_id: str) -> None:
|
||||
await client.delete(f"/restapi/v1.0/subscription/{subscription_id}")
|
||||
logger.info("RingCentral subscription deleted: id=%s", subscription_id)
|
||||
|
||||
|
||||
async def get_subscription(client: AsyncRingCentralClient, subscription_id: str) -> dict:
|
||||
return await client.get(f"/restapi/v1.0/subscription/{subscription_id}")
|
||||
77
backend/package/yuxi/channel/extensions/ringcentral/tasks.py
Normal file
77
backend/package/yuxi/channel/extensions/ringcentral/tasks.py
Normal file
@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from yuxi.channel.extensions.ringcentral.sdk import AsyncRingCentralClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def list_tasks(
|
||||
client: AsyncRingCentralClient,
|
||||
chat_id: str,
|
||||
*,
|
||||
page: int = 1,
|
||||
per_page: int = 20,
|
||||
) -> dict:
|
||||
return await client.get(
|
||||
f"/restapi/v1.0/glip/chats/{chat_id}/tasks",
|
||||
params={"page": page, "perPage": per_page},
|
||||
)
|
||||
|
||||
|
||||
async def create_task(
|
||||
client: AsyncRingCentralClient,
|
||||
chat_id: str,
|
||||
subject: str,
|
||||
*,
|
||||
assignees: list[str] | None = None,
|
||||
body: str | None = None,
|
||||
due_date: str | None = None,
|
||||
) -> dict:
|
||||
payload = {"subject": subject}
|
||||
if assignees:
|
||||
payload["assignees"] = [{"id": a} for a in assignees]
|
||||
if body:
|
||||
payload["body"] = body
|
||||
if due_date:
|
||||
payload["dueDate"] = due_date
|
||||
return await client.post(
|
||||
f"/restapi/v1.0/glip/chats/{chat_id}/tasks",
|
||||
body=payload,
|
||||
)
|
||||
|
||||
|
||||
async def get_task(
|
||||
client: AsyncRingCentralClient,
|
||||
task_id: str,
|
||||
) -> dict:
|
||||
return await client.get(f"/restapi/v1.0/glip/tasks/{task_id}")
|
||||
|
||||
|
||||
async def update_task(
|
||||
client: AsyncRingCentralClient,
|
||||
task_id: str,
|
||||
*,
|
||||
subject: str | None = None,
|
||||
body: str | None = None,
|
||||
due_date: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> dict:
|
||||
payload = {}
|
||||
if subject is not None:
|
||||
payload["subject"] = subject
|
||||
if body is not None:
|
||||
payload["body"] = body
|
||||
if due_date is not None:
|
||||
payload["dueDate"] = due_date
|
||||
if status is not None:
|
||||
payload["status"] = status
|
||||
return await client.patch(
|
||||
f"/restapi/v1.0/glip/tasks/{task_id}",
|
||||
body=payload,
|
||||
)
|
||||
|
||||
|
||||
async def delete_task(client: AsyncRingCentralClient, task_id: str) -> None:
|
||||
await client.delete(f"/restapi/v1.0/glip/tasks/{task_id}")
|
||||
53
backend/package/yuxi/channel/extensions/ringcentral/teams.py
Normal file
53
backend/package/yuxi/channel/extensions/ringcentral/teams.py
Normal file
@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from yuxi.channel.extensions.ringcentral.sdk import AsyncRingCentralClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def create_team(
|
||||
client: AsyncRingCentralClient,
|
||||
name: str,
|
||||
description: str = "",
|
||||
members: list[str] | None = None,
|
||||
) -> dict:
|
||||
body = {"name": name}
|
||||
if description:
|
||||
body["description"] = description
|
||||
if members:
|
||||
body["members"] = [{"id": m} for m in members]
|
||||
return await client.post("/restapi/v1.0/glip/teams", body=body)
|
||||
|
||||
|
||||
async def update_team(
|
||||
client: AsyncRingCentralClient,
|
||||
team_id: str,
|
||||
*,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
) -> dict:
|
||||
body = {}
|
||||
if name is not None:
|
||||
body["name"] = name
|
||||
if description is not None:
|
||||
body["description"] = description
|
||||
return await client.patch(f"/restapi/v1.0/glip/teams/{team_id}", body=body)
|
||||
|
||||
|
||||
async def delete_team(client: AsyncRingCentralClient, team_id: str) -> None:
|
||||
await client.delete(f"/restapi/v1.0/glip/teams/{team_id}")
|
||||
|
||||
|
||||
async def add_team_members(
|
||||
client: AsyncRingCentralClient,
|
||||
chat_id: str,
|
||||
members: list[str],
|
||||
) -> dict:
|
||||
body = {"members": [{"id": m} for m in members]}
|
||||
return await client.post(f"/restapi/v1.0/glip/teams/{chat_id}/add", body=body)
|
||||
|
||||
|
||||
async def archive_team(client: AsyncRingCentralClient, chat_id: str) -> None:
|
||||
await client.post(f"/restapi/v1.0/glip/teams/{chat_id}/archive")
|
||||
93
backend/package/yuxi/channel/extensions/ringcentral/types.py
Normal file
93
backend/package/yuxi/channel/extensions/ringcentral/types.py
Normal file
@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class RingCentralUser:
|
||||
id: str = ""
|
||||
name: str = ""
|
||||
email: str | None = None
|
||||
avatar_url: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RingCentralChat:
|
||||
id: str = ""
|
||||
type: str = ""
|
||||
name: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RingCentralPost:
|
||||
id: str = ""
|
||||
group_id: str = ""
|
||||
creator_id: str = ""
|
||||
text: str = ""
|
||||
type: str = "TextMessage"
|
||||
attachments: list[dict] = field(default_factory=list)
|
||||
creation_time: str = ""
|
||||
last_modified_time: str = ""
|
||||
mentions: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RingCentralEvent:
|
||||
uuid: str = ""
|
||||
event: str = ""
|
||||
timestamp: str = ""
|
||||
subscription_id: str = ""
|
||||
body: RingCentralPost | None = None
|
||||
_raw: dict = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_payload(cls, payload: dict) -> RingCentralEvent:
|
||||
body = payload.get("body", {})
|
||||
post = None
|
||||
if body:
|
||||
post = RingCentralPost(
|
||||
id=body.get("id", ""),
|
||||
group_id=body.get("groupId", ""),
|
||||
creator_id=body.get("creatorId", ""),
|
||||
text=body.get("text", ""),
|
||||
type=body.get("type", "TextMessage"),
|
||||
attachments=body.get("attachments", []),
|
||||
creation_time=body.get("creationTime", ""),
|
||||
last_modified_time=body.get("lastModifiedTime", ""),
|
||||
mentions=body.get("mentions", []),
|
||||
)
|
||||
return cls(
|
||||
uuid=payload.get("uuid", ""),
|
||||
event=payload.get("event", ""),
|
||||
timestamp=payload.get("timestamp", ""),
|
||||
subscription_id=payload.get("subscriptionId", ""),
|
||||
body=post,
|
||||
_raw=payload,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedRingCentralAccount:
|
||||
account_id: str
|
||||
name: str = ""
|
||||
client_id: str = ""
|
||||
client_secret: str = ""
|
||||
server_url: str = "https://platform.ringcentral.com"
|
||||
jwt_token: str = ""
|
||||
webhook_path: str = "/api/webhook/ringcentral"
|
||||
webhook_validation_token: str = ""
|
||||
bot_person_id: str = ""
|
||||
subscription_id: str = ""
|
||||
dm_policy: str = "pairing"
|
||||
dm_enabled: bool = True
|
||||
dm_allow_from: list[str] = field(default_factory=list)
|
||||
group_policy: str = "allowlist"
|
||||
require_mention: bool = True
|
||||
text_chunk_limit: int = 1000
|
||||
media_max_mb: int = 20
|
||||
enabled: bool = True
|
||||
config: dict = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self.client_id and self.client_secret and self.jwt_token)
|
||||
239
backend/package/yuxi/channel/extensions/ringcentral/webhook.py
Normal file
239
backend/package/yuxi/channel/extensions/ringcentral/webhook.py
Normal file
@ -0,0 +1,239 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from yuxi.channel.extensions.ringcentral.types import RingCentralEvent, ResolvedRingCentralAccount
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WEBHOOK_TIMEOUT = 120.0
|
||||
|
||||
|
||||
def _needs_event_body_parsing(event_type: str) -> bool:
|
||||
return event_type in ("PostAdded", "PostChanged", "PostRemoved")
|
||||
|
||||
|
||||
def _is_card_action_event(event_type: str) -> bool:
|
||||
return event_type in ("adaptiveCard/action", "adaptiveCard/action")
|
||||
|
||||
|
||||
def _is_group_event(event_type: str) -> bool:
|
||||
return event_type in ("GroupAdded", "GroupChanged", "GroupRemoved")
|
||||
|
||||
|
||||
class RingCentralWebhookHandler:
|
||||
def __init__(self):
|
||||
self._targets: dict[str, ResolvedRingCentralAccount] = {}
|
||||
|
||||
def register_target(self, webhook_path: str, account: ResolvedRingCentralAccount) -> None:
|
||||
self._targets[webhook_path] = account
|
||||
|
||||
def unregister_target(self, webhook_path: str) -> None:
|
||||
self._targets.pop(webhook_path, None)
|
||||
|
||||
def resolve_target(self, webhook_path: str) -> ResolvedRingCentralAccount | None:
|
||||
return self._targets.get(webhook_path)
|
||||
|
||||
async def handle_webhook(
|
||||
self,
|
||||
body: dict,
|
||||
account: ResolvedRingCentralAccount | None = None,
|
||||
) -> dict:
|
||||
raw_event_type = body.get("event", "")
|
||||
logger.debug("RingCentral webhook event: %s", raw_event_type)
|
||||
|
||||
if _is_card_action_event(raw_event_type):
|
||||
return await self._handle_card_action(body, account)
|
||||
|
||||
if _is_group_event(raw_event_type):
|
||||
return await self._handle_group_event(body, raw_event_type, account)
|
||||
|
||||
if _needs_event_body_parsing(raw_event_type):
|
||||
return await self._handle_post_event(body, account)
|
||||
|
||||
return {"status": "ignored", "reason": f"unhandled event type: {raw_event_type}"}
|
||||
|
||||
async def _handle_card_action(
|
||||
self,
|
||||
payload: dict,
|
||||
account: ResolvedRingCentralAccount | None,
|
||||
) -> dict:
|
||||
action_data = payload.get("body", {})
|
||||
action = action_data.get("action", {})
|
||||
card = action_data.get("card", {})
|
||||
logger.info(
|
||||
"RingCentral adaptive card action: action_type=%s, card_id=%s",
|
||||
action.get("type"),
|
||||
card.get("id"),
|
||||
)
|
||||
|
||||
from yuxi.channel.message.models import MessageType, PeerInfo, UnifiedMessage
|
||||
from yuxi.channel.routing.models import PeerKind
|
||||
|
||||
sender_id = action_data.get("userId", "") or action_data.get("creatorId", "")
|
||||
unified_msg = UnifiedMessage(
|
||||
msg_id=payload.get("uuid", f"rc-ca:{payload.get('timestamp', '')}"),
|
||||
channel_type="ringcentral",
|
||||
account_id=account.account_id if account else "default",
|
||||
content=action.get("title", "") or action.get("data", {}).get("value", ""),
|
||||
sender=PeerInfo(kind=PeerKind.GROUP, id=sender_id, display_name=None),
|
||||
message_type=MessageType.EVENT,
|
||||
group=None,
|
||||
raw_payload=payload,
|
||||
metadata={
|
||||
"card_action": True,
|
||||
"card_id": card.get("id"),
|
||||
"action_type": action.get("type"),
|
||||
"action_data": action.get("data"),
|
||||
},
|
||||
)
|
||||
|
||||
from yuxi.channel.runtime.manager import gateway
|
||||
|
||||
processor = getattr(gateway, "_processor", None) or getattr(gateway, "processor", None)
|
||||
if processor:
|
||||
await asyncio.wait_for(processor.process(unified_msg), timeout=WEBHOOK_TIMEOUT)
|
||||
return {"status": "processed", "event_type": "card_action"}
|
||||
|
||||
async def _handle_group_event(
|
||||
self,
|
||||
payload: dict,
|
||||
event_type: str,
|
||||
account: ResolvedRingCentralAccount | None,
|
||||
) -> dict:
|
||||
group_data = payload.get("body", {})
|
||||
logger.info(
|
||||
"RingCentral group event: type=%s, group_id=%s, name=%s",
|
||||
event_type,
|
||||
group_data.get("id"),
|
||||
group_data.get("name"),
|
||||
)
|
||||
return {"status": "acknowledged", "event_type": event_type, "group_id": group_data.get("id")}
|
||||
|
||||
async def _handle_post_event(
|
||||
self,
|
||||
body: dict,
|
||||
account: ResolvedRingCentralAccount | None,
|
||||
) -> dict:
|
||||
event = RingCentralEvent.from_payload(body)
|
||||
if not event.body:
|
||||
return {"status": "ignored", "reason": "no event body"}
|
||||
if account and account.bot_person_id and event.body.creator_id == account.bot_person_id:
|
||||
return {"status": "ignored", "reason": "self message"}
|
||||
|
||||
text = event.body.text or ""
|
||||
attachments = getattr(event.body, "attachments", []) or []
|
||||
if not text and not attachments:
|
||||
return {"status": "ignored", "reason": "empty text and no attachments"}
|
||||
|
||||
return await self._process_post_event(body, event, account)
|
||||
|
||||
async def _process_post_event(
|
||||
self,
|
||||
raw_payload: dict,
|
||||
event: RingCentralEvent,
|
||||
account: ResolvedRingCentralAccount | None,
|
||||
) -> dict:
|
||||
event_type = event.event
|
||||
if event_type == "PostAdded":
|
||||
return await self._handle_post_added(raw_payload, event, account)
|
||||
if event_type == "PostChanged":
|
||||
return await self._handle_post_changed(raw_payload, event, account)
|
||||
if event_type == "PostRemoved":
|
||||
return await self._handle_post_removed(raw_payload, event, account)
|
||||
return {"status": "ignored", "reason": f"unhandled post event: {event_type}"}
|
||||
|
||||
async def _handle_post_added(
|
||||
self,
|
||||
raw_payload: dict,
|
||||
event: RingCentralEvent,
|
||||
account: ResolvedRingCentralAccount | None,
|
||||
) -> dict:
|
||||
try:
|
||||
from yuxi.channel.extensions.ringcentral.monitor import RingCentralMonitor
|
||||
|
||||
monitor = RingCentralMonitor()
|
||||
unified_msg = await monitor.event_to_unified_message(raw_payload, event, account=account)
|
||||
if unified_msg is None:
|
||||
return {"status": "filtered"}
|
||||
|
||||
from yuxi.channel.runtime.manager import gateway
|
||||
|
||||
processor = getattr(gateway, "_processor", None) or getattr(gateway, "processor", None)
|
||||
if processor:
|
||||
await asyncio.wait_for(processor.process(unified_msg), timeout=WEBHOOK_TIMEOUT)
|
||||
|
||||
return {"status": "processed"}
|
||||
except TimeoutError:
|
||||
logger.error("Agent response timeout for RingCentral message")
|
||||
return {"status": "timeout"}
|
||||
except Exception:
|
||||
logger.exception("Failed to process RingCentral message")
|
||||
return {"status": "error"}
|
||||
|
||||
async def _handle_post_changed(
|
||||
self,
|
||||
raw_payload: dict,
|
||||
event: RingCentralEvent,
|
||||
account: ResolvedRingCentralAccount | None,
|
||||
) -> dict:
|
||||
from yuxi.channel.extensions.ringcentral.monitor import RingCentralMonitor
|
||||
|
||||
monitor = RingCentralMonitor()
|
||||
unified_msg = await monitor.event_to_unified_message(raw_payload, event, account=account)
|
||||
if unified_msg is None:
|
||||
return {"status": "filtered"}
|
||||
|
||||
if _contains_reaction_change(event):
|
||||
unified_msg.metadata["reaction_change"] = True
|
||||
logger.debug("RingCentral reaction change detected in PostChanged")
|
||||
|
||||
unified_msg.metadata["edit_event"] = True
|
||||
from yuxi.channel.runtime.manager import gateway
|
||||
|
||||
processor = getattr(gateway, "_processor", None) or getattr(gateway, "processor", None)
|
||||
if processor:
|
||||
await asyncio.wait_for(processor.process(unified_msg), timeout=WEBHOOK_TIMEOUT)
|
||||
return {"status": "processed"}
|
||||
|
||||
async def _handle_post_removed(
|
||||
self,
|
||||
raw_payload: dict,
|
||||
event: RingCentralEvent,
|
||||
account: ResolvedRingCentralAccount | None,
|
||||
) -> dict:
|
||||
from yuxi.channel.message.models import MessageType, PeerInfo, UnifiedMessage
|
||||
from yuxi.channel.routing.models import PeerKind
|
||||
|
||||
body = event.body
|
||||
unified_msg = UnifiedMessage(
|
||||
msg_id=f"rc-del:{event.uuid}",
|
||||
channel_type="ringcentral",
|
||||
account_id=account.account_id if account else "default",
|
||||
content="",
|
||||
sender=PeerInfo(kind=PeerKind.GROUP, id="system", display_name="System"),
|
||||
message_type=MessageType.EVENT,
|
||||
group=None,
|
||||
raw_payload=raw_payload,
|
||||
metadata={
|
||||
"delete_event": True,
|
||||
"deleted_post_id": body.id if body else "",
|
||||
"event_type": event.event,
|
||||
},
|
||||
)
|
||||
from yuxi.channel.runtime.manager import gateway
|
||||
|
||||
processor = getattr(gateway, "_processor", None) or getattr(gateway, "processor", None)
|
||||
if processor:
|
||||
await asyncio.wait_for(processor.process(unified_msg), timeout=WEBHOOK_TIMEOUT)
|
||||
return {"status": "processed"}
|
||||
|
||||
|
||||
def _contains_reaction_change(event: RingCentralEvent) -> bool:
|
||||
if not event.body:
|
||||
return False
|
||||
reactions = getattr(event.body, "reactions", None)
|
||||
if reactions is None:
|
||||
return False
|
||||
return len(reactions) > 0
|
||||
Loading…
Reference in New Issue
Block a user