ForcePilot/backend/package/yuxi/channel/extensions/nextcloud_talk/gateway.py
Kris 6fc9a4a94c feat(channel): 添加 Nextcloud Talk 渠道扩展
新增 Nextcloud Talk 渠道扩展,支持在 Yuxi 平台中集成 Nextcloud Talk 即时通讯渠道。

包含以下功能模块:
- accounts: 账户管理
- bot_admin: Bot 管理
- chat_api: 聊天 API 封装
- config_schema: 配置模式
- conversation: 会话管理
- gateway: SSE/WebSocket 网关接入
- inbound: 入站消息处理
- send: 消息发送
- webhook_server: Webhook 服务
- format: 消息格式转换
- normalize: 消息规范化
- poll_api: 投票 API
- reaction_api: 表情反应 API
- policy: 安全策略
- probe: 健康探测
- replay_guard: 重放防护
- signature: 签名验证
- room_info: 房间信息
- types: 类型定义
2026-05-21 11:29:56 +08:00

209 lines
7.1 KiB
Python

from __future__ import annotations
import logging
from datetime import datetime, UTC
from aiohttp import web
from yuxi.channel.context import ChannelContext
from yuxi.channel.extensions.nextcloud_talk.accounts import resolve_nextcloud_talk_account
from yuxi.channel.extensions.nextcloud_talk.config_schema import NextcloudTalkConfig
from yuxi.channel.extensions.nextcloud_talk.inbound import handle_nextcloud_talk_inbound
from yuxi.channel.extensions.nextcloud_talk.replay_guard import ReplayGuard
from yuxi.channel.extensions.nextcloud_talk.room_info import RoomInfoResolver
from yuxi.channel.extensions.nextcloud_talk.send import (
NextcloudTalkSendError,
send_message_nextcloud_talk,
)
from yuxi.channel.extensions.nextcloud_talk.types import NextcloudTalkInboundMessage
from yuxi.channel.extensions.nextcloud_talk.webhook_server import (
NextcloudTalkWebhookHandler,
create_nextcloud_talk_webhook_app,
)
from yuxi.channel.message.models import GroupContext, MessageType, PeerInfo, UnifiedMessage
from yuxi.channel.routing.models import PeerKind
logger = logging.getLogger(__name__)
class NextcloudTalkGateway:
def __init__(self):
self._runner: web.AppRunner | None = None
self._site: web.TCPSite | None = None
self._account: object | None = None
self._cfg: NextcloudTalkConfig | None = None
self._ctx: ChannelContext | None = None
async def start(self, ctx: ChannelContext) -> object:
self._ctx = ctx
channel_cfg = ctx.config.get("channels", {}).get("nextcloud-talk", {})
self._cfg = NextcloudTalkConfig(**channel_cfg)
self._account = resolve_nextcloud_talk_account(self._cfg)
if not self._account.configured:
raise RuntimeError("Nextcloud Talk gateway not configured")
replay_guard = ReplayGuard()
room_resolver = RoomInfoResolver(
base_url=self._account.base_url,
api_user=self._account.api_user,
api_password=self._account.api_password,
)
async def _send_callback(room_token: str, text: str) -> dict:
return await send_message_nextcloud_talk(
to=room_token,
text=text,
base_url=self._account.base_url,
secret=self._account.bot_secret,
dangerously_allow_private_network=self._cfg.dangerously_allow_private_network,
)
async def _inbound_handler(message: NextcloudTalkInboundMessage):
result = await handle_nextcloud_talk_inbound(
message=message,
cfg=self._cfg,
room_resolver=room_resolver,
)
if result is None:
return None
if result.get("action") == "pairing_challenge":
sender_id = result["sender_id"]
sender_name = result.get("sender_name", sender_id)
room_token = result["room_token"]
logger.info(
"Nextcloud Talk: pairing challenge for sender '%s'",
sender_id,
)
try:
await _send_callback(
room_token,
f"Hi {sender_name}! Your ForcePilot access requires approval. "
f"Your Nextcloud user id: {sender_id}. "
f"Please ask an admin to add you to the allowlist.",
)
except NextcloudTalkSendError as e:
logger.warning("Nextcloud Talk: failed to send pairing message: %s", e)
return None
unified_msg = _to_unified_message(message, result, ctx)
if ctx.queue is not None:
await ctx.queue.put(unified_msg)
return result
handler = NextcloudTalkWebhookHandler(
secret=self._account.bot_secret,
replay_guard=replay_guard,
base_url=self._account.base_url,
inbound_handler=_inbound_handler,
send_callback=_send_callback,
auto_join_message=self._cfg.auto_join_message,
)
app = create_nextcloud_talk_webhook_app(handler, webhook_path=self._cfg.webhook_path)
self._runner = web.AppRunner(app)
await self._runner.setup()
self._site = web.TCPSite(
self._runner,
host=self._cfg.webhook_host,
port=self._cfg.webhook_port,
)
await self._site.start()
public_url = (
self._cfg.webhook_public_url
or f"http://{self._cfg.webhook_host}:{self._cfg.webhook_port}{self._cfg.webhook_path}"
)
logger.info(
"Nextcloud Talk webhook listening on %s:%d (public: %s)",
self._cfg.webhook_host,
self._cfg.webhook_port,
public_url,
)
await ctx.cancel_event.wait()
await self._cleanup()
async def stop(self, ctx: ChannelContext) -> None:
ctx.cancel_event.set()
await self._cleanup()
async def _cleanup(self) -> None:
if self._site:
await self._site.stop()
self._site = None
if self._runner:
await self._runner.cleanup()
self._runner = None
logger.info("Nextcloud Talk webhook stopped")
async def check_health(self) -> bool:
return self._site is not None
@property
def account(self):
return self._account
@property
def cfg(self) -> NextcloudTalkConfig | None:
return self._cfg
def _to_unified_message(
message: NextcloudTalkInboundMessage,
inbound_result: dict,
ctx: ChannelContext,
) -> UnifiedMessage:
chat_type = inbound_result.get("ChatType", "group")
peer_kind = PeerKind.DIRECT if chat_type == "direct" else PeerKind.GROUP
sender = PeerInfo(
kind=peer_kind,
id=message.sender_id,
display_name=message.sender_name,
)
group = None
if chat_type == "group":
group = GroupContext(
id=message.room_token,
name=message.room_name,
)
metadata = {
"room_token": message.room_token,
"room_name": message.room_name,
"sender_name": message.sender_name,
"media_type": message.media_type,
"content_format": "markdown" if message.media_type == "text/markdown" else "plain",
"was_mentioned": inbound_result.get("WasMentioned", False),
}
if message.reply_to_id:
metadata["reply_to_id"] = message.reply_to_id
if message.participant_type is not None:
metadata["participant_type"] = message.participant_type
group_system_prompt = inbound_result.get("GroupSystemPrompt")
if group_system_prompt:
metadata["group_system_prompt"] = group_system_prompt
return UnifiedMessage(
msg_id=message.message_id,
channel_type="nextcloud-talk",
account_id=ctx.account_id,
content=message.text,
sender=sender,
message_type=MessageType.TEXT,
group=group,
timestamp=datetime.now(UTC),
metadata=metadata,
conversation_label=inbound_result.get("ConversationLabel"),
)