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

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

325 lines
9.7 KiB
Python

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