这是一个批量整理提交,包含以下主要改动: 1. 删除多处冗余的空行和未使用的导入 2. 修复文件末尾缺少换行符的问题 3. 调整部分模块的导入顺序与代码排版 4. 修复部分配置默认值与策略逻辑 5. 新增多个功能模块与辅助工具 6. 完善异常处理与日志记录 7. 修复速率限制、消息缓存、权限校验等逻辑bug 8. 废弃部分旧有API与配置项并添加警告提示
1037 lines
41 KiB
Python
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,
|
|
}
|