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:
Kris 2026-05-21 11:37:49 +08:00
parent 2ab65f153f
commit 7a9031ea53
26 changed files with 2582 additions and 0 deletions

View 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: @![DisplayName](extensionId). "
"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)

View File

@ -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}",
)

View 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"},
},
},
},
},
}

View File

@ -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]

View File

@ -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

View File

@ -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}")

View File

@ -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

View 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, {})

View 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}"

View File

@ -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()

View 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"

View 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}")

View 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

View File

@ -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]

View File

@ -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"
}

View File

@ -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", [])

View 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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}")

View 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}")

View 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")

View 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)

View 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