新增 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: 类型定义
325 lines
9.7 KiB
Python
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
|