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