ForcePilot/backend/package/yuxi/channel/extensions/rocketchat/outbound.py
Kris 043e75d787 feat(channel): 添加 RocketChat 渠道扩展
新增 RocketChat 渠道扩展,支持在 Yuxi 平台中集成 RocketChat 团队协作平台。

包含以下功能模块:
- client: RocketChat API 客户端封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- websocket: WebSocket 实时连接
- webhook: Webhook 事件处理
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- dedup: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- gating: 门控管理
- threading: 线程管理
- reactions: 表情反应
- types: 类型定义
2026-05-21 11:39:24 +08:00

216 lines
6.6 KiB
Python

from __future__ import annotations
import logging
from yuxi.channel.extensions.rocketchat.client import RocketChatClient
from yuxi.channel.extensions.rocketchat.errors import RocketChatError
from yuxi.channel.extensions.rocketchat.format import (
safe_split_markdown,
truncate_markdown,
)
from yuxi.channel.extensions.rocketchat.types import DeliveryTarget, SendContext
logger = logging.getLogger(__name__)
class RocketChatOutbound:
def __init__(self, client: RocketChatClient, account_id: str = ""):
self.client = client
self.account_id = account_id
self._dm_cache: dict[str, str] = {}
async def send_text(
self,
room_id: str,
content: str,
*,
thread_id: str | None = None,
file_ids: list[str] | None = None,
text_chunk_limit: int = 4000,
) -> dict | None:
chunks = safe_split_markdown(content, text_chunk_limit)
last_post: dict | None = None
for chunk in chunks:
try:
resp = await self.client.post_message(
room_id,
chunk,
thread_id=thread_id,
)
last_post = resp
except RocketChatError as e:
logger.error("Failed to send text to room %s: %s", room_id, e)
raise
return last_post
async def send_rich_text(
self,
room_id: str,
content: str = "",
*,
thread_id: str | None = None,
alias: str | None = None,
avatar: str | None = None,
emoji: str | None = None,
attachments: list[dict] | None = None,
) -> dict | None:
try:
return await self.client.send_message(
room_id,
text=content,
thread_id=thread_id,
alias=alias,
avatar=avatar,
emoji=emoji,
attachments=attachments,
)
except RocketChatError as e:
logger.error("Failed to send rich message to room %s: %s", room_id, e)
return None
async def edit_message(self, room_id: str, msg_id: str, content: str) -> dict | None:
try:
return await self.client.update_message(
room_id,
msg_id,
truncate_markdown(content),
)
except RocketChatError as e:
logger.error("Failed to edit message %s: %s", msg_id, e)
return None
async def delete_message(self, room_id: str, msg_id: str) -> bool:
try:
await self.client.delete_message(room_id, msg_id)
return True
except RocketChatError:
return False
async def send_media(
self,
room_id: str,
file_data: bytes,
filename: str,
*,
thread_id: str | None = None,
caption: str | None = None,
) -> dict | None:
try:
upload = await self.client.upload_file(
room_id,
file_data,
filename,
thread_id=thread_id,
)
if caption:
try:
return await self.client.post_message(
room_id,
caption,
thread_id=thread_id,
)
except RocketChatError:
pass
return upload
except RocketChatError as e:
logger.error("Failed to send media to room %s: %s", room_id, e)
return None
async def send_reaction(self, msg_id: str, emoji: str) -> dict | None:
try:
return await self.client.add_reaction(msg_id, emoji)
except RocketChatError as e:
logger.error("Failed to add reaction: %s", e)
return None
async def start_typing(self, room_id: str, username: str | None = None) -> None:
try:
await self.client.send_typing(room_id, username)
except RocketChatError as e:
logger.debug("Failed to send typing indicator: %s", e)
async def stop_typing(self, room_id: str) -> None:
pass
async def create_dm_room(self, username: str) -> str | None:
if username in self._dm_cache:
return self._dm_cache[username]
try:
result = await self.client.create_direct_message(username)
room_id = result.get("room", {}).get("_id", "")
if room_id:
self._dm_cache[username] = room_id
return room_id
except RocketChatError as e:
logger.error("Failed to create DM room: %s", e)
return None
def parse_rocketchat_target(raw: str) -> DeliveryTarget:
raw = raw.strip()
if raw.startswith("room:"):
return DeliveryTarget(kind="room", id=raw[len("room:") :])
if raw.startswith("user:"):
return DeliveryTarget(kind="user", id=raw[len("user:") :])
if raw.startswith("@"):
return DeliveryTarget(kind="user", name=raw[1:])
if raw.startswith("#"):
return DeliveryTarget(kind="room", name=raw[1:])
return DeliveryTarget(kind="room", id=raw)
async def resolve_send_context(
target_str: str,
client: RocketChatClient,
account_id: str,
) -> SendContext:
target = parse_rocketchat_target(target_str)
if target.kind == "room" and target.id:
return SendContext(
room_id=target.id,
auth_token=client.auth_token,
user_id=client.user_id,
server_url=client.server_url,
account_id=account_id,
)
if target.kind == "user" and target.name:
try:
room_id = await _resolve_dm_room(client, target.name)
return SendContext(
room_id=room_id or "",
auth_token=client.auth_token,
user_id=client.user_id,
server_url=client.server_url,
account_id=account_id,
)
except RocketChatError:
pass
return SendContext(
room_id="",
auth_token=client.auth_token,
user_id=client.user_id,
server_url=client.server_url,
account_id=account_id,
)
async def _resolve_dm_room(client: RocketChatClient, username: str) -> str | None:
try:
result = await client.create_direct_message(username)
return result.get("room", {}).get("_id", "")
except RocketChatError:
return None
def map_room_type_to_chat_type(room_type: str) -> str:
mapping = {"c": "channel", "p": "group", "d": "direct"}
return mapping.get(room_type, "channel")