from __future__ import annotations import asyncio import logging import os import re from collections.abc import AsyncIterator, Callable from dataclasses import dataclass, field from typing import Any, Literal from urllib.parse import urlparse from yuxi.channels.base import BaseChannelAdapter from yuxi.channels.capabilities import ChannelCapabilities from yuxi.channels.meta import ChannelMeta from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError from yuxi.channels.exceptions import ( ChannelAuthenticationError, ) from yuxi.channels.models import ( ChannelMessage, ChannelResponse, ChannelStatus, ChannelType, ChatType, DeliveryResult, HealthStatus, ) from yuxi.channels.registry import register_builtin_adapter from yuxi.utils.datetime_utils import utc_now_naive from .client import NextcloudTalkClient, verify_hmac_signature from .config import ( resolve_room_enabled, resolve_room_system_prompt, resolve_room_skills, resolve_dm_system_prompt, resolve_dm_enabled, resolve_dm_skills, ) from .dedup import NextcloudTalkDedupGuard from .formatter import format_outbound from .metrics import NextcloudTalkMetrics from .monitor_polling import PollingMonitor from .monitor_ws import WebSocketMonitor, try_connect_websocket from .normalizer import normalize_inbound from .pairing import send_pairing_challenge from .probe import probe_capabilities, resolve_api_base, resolve_api_version from .security import check_dm_policy, check_group_policy, check_mention_gate, collect_security_warnings from .send import ( send_media, send_reaction, send_text, send_reaction_delete, get_reactions as _get_reactions, edit_message as _send_edit, delete_message as _send_delete, ) from .session import resolve_session_route, make_thread_key logger = logging.getLogger(__name__) _DEBUG_ACCOUNTS = os.getenv("OPENCLAW_DEBUG_NEXTCLOUD_TALK_ACCOUNTS", "").strip().lower() in ( "1", "true", "yes", "on", ) @dataclass class _AccountSession: account_id: str label: str = "" server_url: str = "" bot_user: str = "" app_password: str = "" client: NextcloudTalkClient | None = None api_base: str = "" monitor_mode: Literal["websocket", "polling"] = "polling" polling_monitor: PollingMonitor | None = None ws_monitor: WebSocketMonitor | None = None polling_task: asyncio.Task | None = None ws_task: asyncio.Task | None = None config: dict[str, Any] = field(default_factory=dict) @register_builtin_adapter(aliases=["nc-talk", "nc", "nextcloud-talk"]) class NextcloudTalkAdapter(BaseChannelAdapter): channel_id = "nextcloud-talk" channel_type = ChannelType.NEXTCLOUDTALK text_chunk_limit = 4000 supports_markdown = True supports_streaming = True streaming_modes = ["off", "block"] max_media_size_mb = 100 capabilities = ChannelCapabilities( chat_types=["direct", "group"], supports_markdown=True, supports_streaming=True, streaming_modes=["off", "block"], text_chunk_limit=4000, max_media_size_mb=100, reactions=True, edit=True, unsend=True, reply=True, media=True, block_streaming=True, polls=True, ) meta = ChannelMeta( id="nextcloud-talk", label="Nextcloud Talk", aliases=["nextcloudtalk", "nc-talk", "nc", "nextcloud-talk"], ) def __init__(self, config: dict[str, Any] | None = None): super().__init__(config) self._status = ChannelStatus.DISCONNECTED self._client: NextcloudTalkClient | None = None self._monitor_mode: Literal["websocket", "polling"] = "polling" self._api_base: str = "" self._bot_display_name: str = "ForcePilot Bot" self._conversations: dict[str, dict[str, Any]] = {} self._circuit_breaker = CircuitBreaker(failure_threshold=5) self._polling_monitor: PollingMonitor | None = None self._ws_monitor: WebSocketMonitor | None = None self._polling_task: asyncio.Task | None = None self._ws_task: asyncio.Task | None = None self._stream_msg_ids: dict[str, str] = {} self._dedup_guard = NextcloudTalkDedupGuard() self._sent_cache: dict[str, str] = {} self._on_send_callback: Callable[[DeliveryResult], None] | None = None self._stream_edit_times: dict[str, float] = {} self._stream_msg_ttls: dict[str, float] = {} self._stream_max_edits_per_sec = 3 self._reaction_level: str = "minimal" self._stream_coalesce: dict[str, Any] = {} self._sessions: dict[str, _AccountSession] = {} self._primary_account_id: str | None = None self._is_multi_account: bool = False self._inbound_count: int = 0 self._outbound_count: int = 0 self._last_inbound_at: float | None = None self._last_outbound_at: float | None = None self._metrics = NextcloudTalkMetrics() self._resolve_accounts() @property def status(self) -> str: return self._status.value async def connect(self) -> None: if self._status == ChannelStatus.CONNECTED: return self._status = ChannelStatus.CONNECTING logger.info(f"[NextcloudTalk] Starting channel '{self.channel_id}'") server_url = self.config.get("server_url", "").rstrip("/") if not server_url: raise ChannelAuthenticationError() parsed = urlparse(server_url) if parsed.scheme not in ("https", "http") or not parsed.hostname: raise ChannelAuthenticationError(f"Invalid server_url format: {server_url}") bot_user = self.config.get("bot_user", self.config.get("botUser", "")) or os.getenv( "NEXTCLOUD_TALK_BOT_USER", "" ) app_password = self.config.get("app_password", self.config.get("appPassword", "")) or os.getenv( "NEXTCLOUD_TALK_APP_PASSWORD", os.getenv("NEXTCLOUD_TALK_API_PASSWORD", os.getenv("NEXTCLOUD_TALK_BOT_SECRET", "")), ) if not bot_user or not app_password: raise ChannelAuthenticationError() warnings = collect_security_warnings(self.config) for w in warnings: logger.warning(f"[NextcloudTalk] Security warning: {w}") network_cfg = self.config.get("network", {}) proxy_url = network_cfg.get("proxy", "") self._client = NextcloudTalkClient( server_url, bot_user, app_password, proxy=proxy_url if proxy_url else None, bot_secret=app_password, ) await self._client.start() try: caps = await probe_capabilities(self._client.session, server_url) api_version = resolve_api_version(caps) self._api_base = resolve_api_base(api_version) talk_version = caps.get("version", "unknown") logger.info(f"[NextcloudTalk] Talk version: {talk_version}, API: {api_version}") except Exception as e: logger.warning(f"[NextcloudTalk] Probe failed: {e}, using default v1 API") self._api_base = resolve_api_base("v1") self._bot_display_name = self.config.get("display_name", "ForcePilot Bot") self._reaction_level = self.config.get("reaction_level", self.config.get("reactionLevel", "minimal")) coalesce_key = "block_streaming_coalesce" self._stream_coalesce = self.config.get(coalesce_key, self.config.get("blockStreamingCoalesce", {})) ws = None if self.config.get("try_websocket", True): ws = await try_connect_websocket( server_url, self._client.basic_auth, bot_user, app_password, self._client.session, ) if ws: self._monitor_mode = "websocket" logger.info("[NextcloudTalk] Mode: WebSocket") else: self._monitor_mode = "polling" logger.info("[NextcloudTalk] Mode: Polling (WebSocket unavailable)") await self._load_conversations() logger.info(f"[NextcloudTalk] Loaded {len(self._conversations)} conversations") if self._monitor_mode == "websocket": self._ws_monitor = WebSocketMonitor( ws=ws, channel_id=self.channel_id, basic_auth=self._client.basic_auth, server_url=server_url, bot_user=bot_user, app_password=app_password, ) self._ws_monitor.set_on_message(self._handle_message) self._ws_monitor.set_on_fallback(self._fallback_to_polling) self._ws_task = asyncio.create_task(self._ws_monitor.run()) else: await self._start_polling() self._status = ChannelStatus.CONNECTED logger.info(f"[NextcloudTalk] Channel '{self.channel_id}' started in {self._monitor_mode} mode") async def disconnect(self) -> None: if self._status == ChannelStatus.DISCONNECTED: return logger.info(f"[NextcloudTalk] Stopping channel '{self.channel_id}'") if self._ws_monitor: await self._ws_monitor.stop() self._ws_monitor = None if self._ws_task and not self._ws_task.done(): self._ws_task.cancel() try: await self._ws_task except asyncio.CancelledError: pass self._ws_task = None if self._polling_monitor: await self._polling_monitor.stop() self._polling_monitor = None if self._polling_task and not self._polling_task.done(): self._polling_task.cancel() try: await self._polling_task except asyncio.CancelledError: pass self._polling_task = None if self._client: await self._client.stop() self._client = None self._conversations.clear() self._status = ChannelStatus.DISCONNECTED logger.info(f"[NextcloudTalk] Channel '{self.channel_id}' stopped") async def send(self, response: ChannelResponse) -> DeliveryResult: if not self._client or self._status != ChannelStatus.CONNECTED: return DeliveryResult(success=False, error="Channel not connected") _send_start = asyncio.get_event_loop().time() payloads = self.format_outbound(response) token = response.identity.channel_chat_id last_result = DeliveryResult(success=False, error="No payloads") silent = self.config.get("silent", False) for payload in payloads: async def _do_send(p=payload): return await send_text( self._client, self._api_base, token, p, self.config, self._sent_cache, silent=silent, ) try: last_result = await self._circuit_breaker.call(_do_send) except CircuitBreakerOpenError: self._metrics.record_circuit_breaker_trip() return DeliveryResult(success=False, error="Circuit breaker open") except Exception as e: self._metrics.record_send_error() return DeliveryResult(success=False, error=str(e)) _send_elapsed = asyncio.get_event_loop().time() - _send_start self._metrics.record_send_success(_send_elapsed) import time as _time self._outbound_count += 1 self._last_outbound_at = _time.time() if self._on_send_callback: try: self._on_send_callback(last_result) except Exception: pass return last_result def set_on_send_callback(self, callback: Callable[[DeliveryResult], None] | None) -> None: self._on_send_callback = callback async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Channel not connected") if isinstance(data, bytes): media_data = data else: return DeliveryResult(success=False, error="Media data must be bytes") async def _do_send(): return await send_media( self._client, self._api_base, chat_id, media_type, media_data, config=self.config, ) try: return await self._circuit_breaker.call(_do_send) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Channel not connected") async def _do_send(): return await send_reaction( self._client, self._api_base, chat_id, msg_id, emoji, config=self.config, ) try: return await self._circuit_breaker.call(_do_send) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def remove_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Channel not connected") async def _do_send(): return await send_reaction_delete( self._client, self._api_base, chat_id, msg_id, emoji, config=self.config, ) try: return await self._circuit_breaker.call(_do_send) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def get_reactions(self, chat_id: str, msg_id: str) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Channel not connected") async def _do_send(): return await _get_reactions( self._client, self._api_base, chat_id, msg_id, config=self.config, ) try: return await self._circuit_breaker.call(_do_send) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Channel not connected") coalesce = self._stream_coalesce if coalesce and not finished: min_chars = coalesce.get("min_chars", 0) max_delay_ms = coalesce.get("max_delay_ms", 0) if min_chars > 0 and len(chunk) < min_chars: self._stream_pending = getattr(self, "_stream_pending", {}) pending = self._stream_pending.get(chat_id, "") self._stream_pending[chat_id] = pending + chunk if max_delay_ms > 0: await asyncio.sleep(max_delay_ms / 1000) if finished: chunk = self._stream_pending.pop(chat_id, pending + chunk) elif len(self._stream_pending.get(chat_id, "")) < min_chars: return DeliveryResult(success=True) else: chunk = self._stream_pending.pop(chat_id, "") else: pending = getattr(self, "_stream_pending", {}).pop(chat_id, "") if pending: chunk = pending + chunk now = asyncio.get_event_loop().time() last_edit = self._stream_edit_times.get(chat_id, 0) if last_edit > 0: min_interval = 1.0 / self._stream_max_edits_per_sec elapsed = now - last_edit if elapsed < min_interval: await asyncio.sleep(min_interval - elapsed) self._stream_edit_times[chat_id] = now self._stream_msg_ttls[chat_id] = now if not msg_id: identity = self._build_stream_identity(chat_id, msg_id) response = ChannelResponse( identity=identity, content=chunk, metadata={"reference_id": f"stream-{chat_id}"}, ) result = await self.send(response) if result.success and result.message_id: self._stream_msg_ids[chat_id] = result.message_id return result async def _do_edit(): return await _send_edit( self._client, self._api_base, chat_id, msg_id, chunk, self.config, ) try: return await self._circuit_breaker.call(_do_edit) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def receive(self) -> AsyncIterator[ChannelMessage]: return yield # type: ignore[misc] def normalize_inbound(self, raw: dict[str, Any]) -> ChannelMessage: token = raw.get("token", "") conv = self._conversations.get(token) room_type = conv.get("type") if conv else None message = normalize_inbound(raw, self.channel_id, room_type) message.metadata["provider"] = "nextcloud-talk" message.metadata["surface"] = "nextcloud-talk" sender_name = message.metadata.get("nc_actor_display_name", "") if sender_name: message.metadata["sender_name"] = sender_name return message def format_outbound(self, response: ChannelResponse) -> list[dict[str, Any]]: markdown_cfg = self.config.get("markdown", {}) response_prefix = self.config.get("response_prefix", self.config.get("responsePrefix", "")) return format_outbound(response, self._bot_display_name, self.text_chunk_limit, markdown_cfg, response_prefix) async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Channel not connected") async def _do_edit(): return await _send_edit(self._client, self._api_base, chat_id, msg_id, content, self.config) try: return await self._circuit_breaker.call(_do_edit) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Channel not connected") async def _do_delete(): return await _send_delete(self._client, self._api_base, chat_id, msg_id, self.config) try: return await self._circuit_breaker.call(_do_delete) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def get_user_info(self, channel_user_id: str) -> dict[str, Any]: if not self._client: return {} try: result = await self._client.get(f"/ocs/v2.php/cloud/users/{channel_user_id}") ocs = result.get("ocs", {}) return ocs.get("data", {}) except Exception: return {"id": channel_user_id} async def download_media(self, file_id: str) -> bytes: if not self._client: raise RuntimeError("Client not initialized") if not file_id.startswith(self._client.server_url): raise ValueError("Invalid media URL: must be served by the same Nextcloud instance") if re.search(r"/\.{1,2}/", file_id): raise ValueError("Invalid media URL: path traversal detected") async with self._client.session.get(file_id, headers=self._client.auth_headers()) as resp: resp.raise_for_status() return await resp.read() def resolve_session_route(self, token: str, default_agent_id: str) -> dict[str, str]: conv = self._conversations.get(token) return resolve_session_route(token, conv, default_agent_id) async def _handle_message(self, message: ChannelMessage) -> None: user_id = message.identity.channel_user_id token = message.identity.channel_chat_id msg_id = message.identity.channel_message_id import time as _time self._inbound_count += 1 self._last_inbound_at = _time.time() if msg_id: if self._dedup_guard.is_invalid(token, msg_id): pass elif not self._dedup_guard.claim(token, msg_id): return try: if message.chat_type == ChatType.DIRECT: if not check_dm_policy(user_id, self.config): dm_policy = self.config.get("dm_policy", "pairing") if dm_policy == "pairing" and self._client: await send_pairing_challenge( self._client, self._api_base, token, user_id, self.config, ) logger.debug(f"[NextcloudTalk] DM blocked by policy: {user_id}") self._dedup_guard.release(token, msg_id) if msg_id else None return if not resolve_dm_enabled(user_id, self.config): logger.debug(f"[NextcloudTalk] DM disabled by dms config: {user_id}") self._dedup_guard.release(token, msg_id) if msg_id else None return dm_system_prompt = resolve_dm_system_prompt(user_id, self.config) if dm_system_prompt: message.metadata["system_prompt"] = dm_system_prompt dm_skills = resolve_dm_skills(user_id, self.config) if dm_skills: message.metadata["skills_filter"] = dm_skills else: if not check_group_policy(token, user_id, self.config): logger.debug(f"[NextcloudTalk] Group blocked: {user_id} in {token}") self._dedup_guard.release(token, msg_id) if msg_id else None return if not resolve_room_enabled(token, self.config): logger.debug(f"[NextcloudTalk] Room disabled: {token}") self._dedup_guard.release(token, msg_id) if msg_id else None return raw_params = message.metadata.get("nc_message_parameters", {}) bot_name = self._bot_display_name if not check_mention_gate( token, message.content, bot_name, self.config, raw_params, ): logger.debug(f"[NextcloudTalk] Mention gate blocked: {user_id} in {token}") self._dedup_guard.release(token, msg_id) if msg_id else None return room_system_prompt = resolve_room_system_prompt(token, self.config) if room_system_prompt: message.metadata["system_prompt"] = room_system_prompt room_skills = resolve_room_skills(token, self.config) if room_skills: message.metadata["skills_filter"] = room_skills conv = self._conversations.get(token) room_type = conv.get("type") if conv else None message.metadata["thread_key"] = make_thread_key(self.config.get("agent_id", "default"), token, room_type) await super()._handle_message(message) if msg_id: self._dedup_guard.commit(token, msg_id) except Exception: if msg_id: self._dedup_guard.release(token, msg_id) raise async def health_check(self) -> HealthStatus: if not self._client: return HealthStatus(status="unhealthy", last_error="Client not initialized") self._cleanup_stream_ids() try: caps = await probe_capabilities(self._client.session, self._client.server_url) spreed_version = caps.get("version", "unknown") if caps else "not detected" return HealthStatus( status="healthy", metadata={ "server_url": self._client.server_url, "talk_version": spreed_version, "monitor_mode": self._monitor_mode, "conversations": len(self._conversations), "dedup_stats": self._dedup_guard.stats(), "active_stream_count": len(self._stream_msg_ids), "total_active_streams": len(self._stream_msg_ids), "activity": self.get_activity_stats(), "metrics": self._metrics.snapshot(), }, last_connected_at=utc_now_naive(), ) except Exception as e: return HealthStatus(status="degraded", last_error=str(e)) async def verify_webhook_signature(self, headers: dict, body: bytes) -> bool: bot_secret = self.config.get("app_password", self.config.get("appPassword", "")) or os.getenv( "NEXTCLOUD_TALK_APP_PASSWORD", os.getenv("NEXTCLOUD_TALK_BOT_SECRET", "") ) if not bot_secret: logger.warning("[NextcloudTalk] Cannot verify webhook: no bot_secret configured") return False signature = headers.get("X-Nextcloud-Talk-SHA256", headers.get("x-nextcloud-talk-sha256", "")) if not signature: return False return verify_hmac_signature(body, signature, bot_secret) def _cleanup_stream_ids(self) -> None: now = asyncio.get_event_loop().time() ttl = 300 stale = [cid for cid, ts in self._stream_msg_ttls.items() if now - ts > ttl] for cid in stale: self._stream_msg_ids.pop(cid, None) self._stream_msg_ttls.pop(cid, None) self._stream_edit_times.pop(cid, None) if stale: logger.debug(f"[NextcloudTalk] Cleaned {len(stale)} stale stream IDs") async def pre_connect(self) -> dict: server_url = self.config.get("server_url", "").rstrip("/") if not server_url: return {"status": "error", "message": "Missing server_url"} bot_user = self.config.get("bot_user", "") app_password = self.config.get("app_password", "") if not bot_user or not app_password: return {"status": "error", "message": "Missing bot_user or app_password"} client = NextcloudTalkClient(server_url, bot_user, app_password, bot_secret=app_password) await client.start() try: caps = await probe_capabilities(client.session, server_url) version = caps.get("version", "unknown") if caps else "not detected" return { "status": "ok", "server_url": server_url, "bot_user": bot_user, "talk_version": version, } except Exception as e: return {"status": "error", "message": str(e)} finally: await client.stop() async def _load_conversations(self) -> None: if not self._client: return path = f"{self._api_base}/conversation" try: result = await self._client.get(path) ocs = result.get("ocs", {}) data = ocs.get("data", []) if isinstance(data, list): for conv in data: token = conv.get("token", "") if token: self._conversations[token] = { "token": token, "type": conv.get("type", 2), "name": conv.get("name", ""), "display_name": conv.get("displayName", conv.get("name", "")), } except Exception as e: logger.warning(f"[NextcloudTalk] Failed to load conversations: {e}") async def create_poll(self, chat_id: str, question: str, options: list[str]) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Channel not connected") payload = {"question": question, "options": options} async def _do_create(): path = f"{self._api_base}/poll/{chat_id}" try: result = await self._client.post(path, json=payload) ocs = result.get("ocs", {}) if ocs.get("meta", {}).get("status") == "ok": poll_id = ocs.get("data", {}).get("id", "") return DeliveryResult(success=True, message_id=poll_id) return DeliveryResult(success=False, error=ocs.get("meta", {}).get("message", "Unknown error")) except Exception as e: return DeliveryResult(success=False, error=str(e)) try: return await self._circuit_breaker.call(_do_create) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def list_conversations(self) -> list[dict[str, Any]]: if not self._client: return [] path = f"{self._api_base}/conversation" try: result = await self._client.get(path) ocs = result.get("ocs", {}) data = ocs.get("data", []) if isinstance(data, list): return [ { "token": conv.get("token", ""), "type": conv.get("type", 2), "name": conv.get("name", ""), "display_name": conv.get("displayName", conv.get("name", "")), } for conv in data if conv.get("token") ] return [] except Exception as e: logger.warning(f"[NextcloudTalk] Failed to list conversations: {e}") return [] async def _refresh_conversations(self) -> None: prev_count = len(self._conversations) await self._load_conversations() new_count = len(self._conversations) if prev_count != new_count: logger.info(f"[NextcloudTalk] Conversations refreshed: {prev_count} 鈫?{new_count}") if self._polling_monitor: self._polling_monitor.update_conversations(self._conversations) async def _start_polling(self) -> None: if not self._client: return poll_interval = self.config.get("poll_interval", 0.5) poll_timeout = self.config.get("poll_timeout", 30) self._polling_monitor = PollingMonitor( client=self._client, api_base=self._api_base, channel_id=self.channel_id, poll_interval=float(poll_interval), poll_timeout=int(poll_timeout), config=self.config, ) self._polling_monitor.set_on_message(self._handle_message) self._polling_monitor.set_on_refresh(self._refresh_conversations) self._polling_task = asyncio.create_task(self._polling_monitor.start(self._conversations)) async def _fallback_to_polling(self) -> None: logger.info("[NextcloudTalk] Falling back to polling mode") self._monitor_mode = "polling" self._status = ChannelStatus.CONNECTED await self._start_polling() async def get_account(self, account_id: str) -> dict[str, Any] | None: session = self._sessions.get(account_id) if not session: return None return { "account_id": session.account_id, "label": session.label, "server_url": session.server_url, "bot_user": session.bot_user, "is_primary": account_id == self._primary_account_id, } async def list_accounts(self) -> list[dict[str, Any]]: return [ { "account_id": s.account_id, "label": s.label, "server_url": s.server_url, "bot_user": s.bot_user, "is_primary": s.account_id == self._primary_account_id, } for s in self._sessions.values() ] @property def primary_account_id(self) -> str | None: return self._primary_account_id @property def is_multi_account(self) -> bool: return self._is_multi_account def _resolve_accounts(self) -> None: accounts_cfg = self.config.get("accounts", []) if isinstance(accounts_cfg, list) and accounts_cfg: self._is_multi_account = True for acct in accounts_cfg: if not isinstance(acct, dict): continue aid = acct.get("id", str(len(self._sessions) + 1)) session = _AccountSession( account_id=aid, label=acct.get("label", f"account-{aid}"), server_url=acct.get("server_url", self.config.get("server_url", "")).rstrip("/"), bot_user=acct.get("bot_user", self.config.get("bot_user", "")), app_password=acct.get( "app_password", acct.get( "appPassword", self.config.get("app_password", self.config.get("appPassword", "")), ), ), config={**self.config, **acct}, ) self._sessions[aid] = session if self._primary_account_id is None: self._primary_account_id = aid if not self._sessions: self._is_multi_account = False session = _AccountSession( account_id="default", label="default", server_url=self.config.get("server_url", "").rstrip("/"), bot_user=self.config.get("bot_user", self.config.get("botUser", "")), app_password=self.config.get("app_password", self.config.get("appPassword", "")), config=self.config, ) self._sessions["default"] = session self._primary_account_id = "default" if _DEBUG_ACCOUNTS: for aid, s in self._sessions.items(): logger.debug( f"[NextcloudTalk] Account '{aid}': label={s.label}, " f"server={s.server_url}, bot_user={s.bot_user}, " f"has_password={'***' if s.app_password else 'no'}" ) logger.debug( f"[NextcloudTalk] Multi-account: {self._is_multi_account}, " f"primary: {self._primary_account_id}, total: {len(self._sessions)}" ) async def send_voice(self, chat_id: str, audio_data: bytes, mime_type: str = "audio/mp4") -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Channel not connected") return await self.send_media(chat_id, "voice", audio_data) async def transcribe_voice(self, chat_id: str, msg_id: str) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Channel not connected") async def _do_transcribe(): path = f"{self._api_base}/transcribe/{chat_id}/{msg_id}" try: result = await self._client.get(path) ocs = result.get("ocs", {}) if ocs.get("meta", {}).get("status") == "ok": transcription = ocs.get("data", {}).get("transcription", ocs.get("data", {}).get("text", "")) return DeliveryResult(success=True, metadata={"transcription": transcription}) return DeliveryResult(success=False, error="Transcription not available") except Exception as e: return DeliveryResult(success=False, error=str(e)) try: return await self._circuit_breaker.call(_do_transcribe) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def analyze_image(self, chat_id: str, msg_id: str, prompt: str = "") -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Channel not connected") async def _do_analyze(): base_path = f"{self._api_base}/analyze/{chat_id}/{msg_id}" path = base_path try: if prompt: result = await self._client.post(path, json={"prompt": prompt}) else: result = await self._client.get(path) ocs = result.get("ocs", {}) if ocs.get("meta", {}).get("status") == "ok": analysis = ocs.get("data", {}).get("analysis", ocs.get("data", {}).get("result", "")) return DeliveryResult(success=True, metadata={"analysis": analysis}) return DeliveryResult(success=False, error="Image analysis not available") except Exception as e: return DeliveryResult(success=False, error=str(e)) try: return await self._circuit_breaker.call(_do_analyze) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def request_approval( self, chat_id: str, action: str, details: dict[str, Any] | None = None, ) -> DeliveryResult: if not self._client: return DeliveryResult(success=False, error="Channel not connected") payload = {"action": action, "details": details or {}} async def _do_approve(): path = f"{self._api_base}/approval/{chat_id}" try: result = await self._client.post(path, json=payload) ocs = result.get("ocs", {}) if ocs.get("meta", {}).get("status") == "ok": approval_id = ocs.get("data", {}).get("id", "") return DeliveryResult(success=True, message_id=approval_id) return DeliveryResult( success=False, error=ocs.get("meta", {}).get("message", "Approval request failed"), ) except Exception as e: return DeliveryResult(success=False, error=str(e)) try: return await self._circuit_breaker.call(_do_approve) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") except Exception as e: return DeliveryResult(success=False, error=str(e)) async def reload_config(self, new_config: dict[str, Any]) -> None: logger.info("[NextcloudTalk] Reloading config, will disconnect and reconnect") await self.disconnect() self.config = new_config await self.connect() def get_activity_stats(self) -> dict[str, Any]: import time as _time now = _time.time() return { "inbound_count": self._inbound_count, "outbound_count": self._outbound_count, "last_inbound_seconds_ago": round(now - self._last_inbound_at, 1) if self._last_inbound_at else None, "last_outbound_seconds_ago": round(now - self._last_outbound_at, 1) if self._last_outbound_at else None, }