ForcePilot/backend/package/yuxi/channels/adapters/nextcloudtalk/adapter.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

1037 lines
41 KiB
Python

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._sent_cache_times: dict[str, float] = {}
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._stream_pending: dict[str, str] = {}
self._stream_coalesce_lock = asyncio.Lock()
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,
sent_cache_times=self._sent_cache_times,
)
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()
self._cleanup_sent_cache()
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:
async with self._stream_coalesce_lock:
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)
async with self._stream_coalesce_lock:
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:
async with self._stream_coalesce_lock:
pending = 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-Bot-Signature", headers.get("x-nextcloud-talk-bot-signature", ""))
random_header = headers.get("X-Nextcloud-Talk-Bot-Random", headers.get("x-nextcloud-talk-bot-random", ""))
if not signature or not random_header:
return False
return verify_hmac_signature(body, random_header, 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")
def _cleanup_sent_cache(self) -> None:
now = asyncio.get_event_loop().time()
ttl = 3600
stale = [ref for ref, ts in self._sent_cache_times.items() if now - ts > ttl]
for ref in stale:
self._sent_cache.pop(ref, None)
self._sent_cache_times.pop(ref, None)
if stale:
logger.debug(f"[NextcloudTalk] Cleaned {len(stale)} stale sent cache entries")
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,
}