新增 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: 类型定义
209 lines
7.1 KiB
Python
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"),
|
|
)
|