2026-02-09 08:50:17 +00:00
|
|
|
"""Mochat channel implementation using Socket.IO with HTTP polling fallback."""
|
2026-02-09 08:46:47 +00:00
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import json
|
|
|
|
|
from collections import deque
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
from loguru import logger
|
|
|
|
|
|
|
|
|
|
from nanobot.bus.events import OutboundMessage
|
|
|
|
|
from nanobot.bus.queue import MessageBus
|
|
|
|
|
from nanobot.channels.base import BaseChannel
|
2026-02-09 08:50:17 +00:00
|
|
|
from nanobot.config.schema import MochatConfig
|
2026-02-09 08:46:47 +00:00
|
|
|
from nanobot.utils.helpers import get_data_path
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
import socketio
|
|
|
|
|
|
|
|
|
|
SOCKETIO_AVAILABLE = True
|
|
|
|
|
except ImportError:
|
|
|
|
|
socketio = None
|
|
|
|
|
SOCKETIO_AVAILABLE = False
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
import msgpack # noqa: F401
|
|
|
|
|
|
|
|
|
|
MSGPACK_AVAILABLE = True
|
|
|
|
|
except ImportError:
|
|
|
|
|
MSGPACK_AVAILABLE = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
MAX_SEEN_MESSAGE_IDS = 2000
|
|
|
|
|
CURSOR_SAVE_DEBOUNCE_S = 0.5
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
2026-02-09 08:50:17 +00:00
|
|
|
class MochatBufferedEntry:
|
2026-02-09 08:46:47 +00:00
|
|
|
"""Buffered inbound entry for delayed dispatch."""
|
|
|
|
|
|
|
|
|
|
raw_body: str
|
|
|
|
|
author: str
|
|
|
|
|
sender_name: str = ""
|
|
|
|
|
sender_username: str = ""
|
|
|
|
|
timestamp: int | None = None
|
|
|
|
|
message_id: str = ""
|
|
|
|
|
group_id: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class DelayState:
|
|
|
|
|
"""Per-target delayed message state."""
|
|
|
|
|
|
2026-02-09 08:50:17 +00:00
|
|
|
entries: list[MochatBufferedEntry] = field(default_factory=list)
|
2026-02-09 08:46:47 +00:00
|
|
|
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
|
|
|
|
timer: asyncio.Task | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
2026-02-09 08:50:17 +00:00
|
|
|
class MochatTarget:
|
2026-02-09 08:46:47 +00:00
|
|
|
"""Outbound target resolution result."""
|
|
|
|
|
|
|
|
|
|
id: str
|
|
|
|
|
is_panel: bool
|
|
|
|
|
|
|
|
|
|
|
2026-02-09 08:50:17 +00:00
|
|
|
def normalize_mochat_content(content: Any) -> str:
|
2026-02-09 08:46:47 +00:00
|
|
|
"""Normalize content payload to text."""
|
|
|
|
|
if isinstance(content, str):
|
|
|
|
|
return content.strip()
|
|
|
|
|
if content is None:
|
|
|
|
|
return ""
|
|
|
|
|
try:
|
|
|
|
|
return json.dumps(content, ensure_ascii=False)
|
|
|
|
|
except TypeError:
|
|
|
|
|
return str(content)
|
|
|
|
|
|
|
|
|
|
|
2026-02-09 08:50:17 +00:00
|
|
|
def resolve_mochat_target(raw: str) -> MochatTarget:
|
2026-02-09 08:46:47 +00:00
|
|
|
"""Resolve id and target kind from user-provided target string."""
|
|
|
|
|
trimmed = (raw or "").strip()
|
|
|
|
|
if not trimmed:
|
2026-02-09 08:50:17 +00:00
|
|
|
return MochatTarget(id="", is_panel=False)
|
2026-02-09 08:46:47 +00:00
|
|
|
|
|
|
|
|
lowered = trimmed.lower()
|
|
|
|
|
cleaned = trimmed
|
|
|
|
|
forced_panel = False
|
|
|
|
|
|
2026-02-09 08:50:17 +00:00
|
|
|
prefixes = ["mochat:", "group:", "channel:", "panel:"]
|
2026-02-09 08:46:47 +00:00
|
|
|
for prefix in prefixes:
|
|
|
|
|
if lowered.startswith(prefix):
|
|
|
|
|
cleaned = trimmed[len(prefix) :].strip()
|
|
|
|
|
if prefix in {"group:", "channel:", "panel:"}:
|
|
|
|
|
forced_panel = True
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if not cleaned:
|
2026-02-09 08:50:17 +00:00
|
|
|
return MochatTarget(id="", is_panel=False)
|
2026-02-09 08:46:47 +00:00
|
|
|
|
2026-02-09 08:50:17 +00:00
|
|
|
return MochatTarget(id=cleaned, is_panel=forced_panel or not cleaned.startswith("session_"))
|
2026-02-09 08:46:47 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def extract_mention_ids(value: Any) -> list[str]:
|
|
|
|
|
"""Extract mention ids from heterogeneous mention payload."""
|
|
|
|
|
if not isinstance(value, list):
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
ids: list[str] = []
|
|
|
|
|
for item in value:
|
|
|
|
|
if isinstance(item, str):
|
|
|
|
|
text = item.strip()
|
|
|
|
|
if text:
|
|
|
|
|
ids.append(text)
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if not isinstance(item, dict):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
for key in ("id", "userId", "_id"):
|
|
|
|
|
candidate = item.get(key)
|
|
|
|
|
if isinstance(candidate, str) and candidate.strip():
|
|
|
|
|
ids.append(candidate.strip())
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
return ids
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_was_mentioned(payload: dict[str, Any], agent_user_id: str) -> bool:
|
|
|
|
|
"""Resolve mention state from payload metadata and text fallback."""
|
|
|
|
|
meta = payload.get("meta")
|
|
|
|
|
if isinstance(meta, dict):
|
|
|
|
|
if meta.get("mentioned") is True or meta.get("wasMentioned") is True:
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
for field in ("mentions", "mentionIds", "mentionedUserIds", "mentionedUsers"):
|
|
|
|
|
ids = extract_mention_ids(meta.get(field))
|
|
|
|
|
if agent_user_id and agent_user_id in ids:
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
if not agent_user_id:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
content = payload.get("content")
|
|
|
|
|
if not isinstance(content, str) or not content:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
return f"<@{agent_user_id}>" in content or f"@{agent_user_id}" in content
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_require_mention(
|
2026-02-09 08:50:17 +00:00
|
|
|
config: MochatConfig,
|
2026-02-09 08:46:47 +00:00
|
|
|
session_id: str,
|
|
|
|
|
group_id: str,
|
|
|
|
|
) -> bool:
|
|
|
|
|
"""Resolve mention requirement for group/panel conversations."""
|
|
|
|
|
groups = config.groups or {}
|
|
|
|
|
if group_id and group_id in groups:
|
|
|
|
|
return bool(groups[group_id].require_mention)
|
|
|
|
|
if session_id in groups:
|
|
|
|
|
return bool(groups[session_id].require_mention)
|
|
|
|
|
if "*" in groups:
|
|
|
|
|
return bool(groups["*"].require_mention)
|
|
|
|
|
return bool(config.mention.require_in_groups)
|
|
|
|
|
|
|
|
|
|
|
2026-02-09 08:50:17 +00:00
|
|
|
def build_buffered_body(entries: list[MochatBufferedEntry], is_group: bool) -> str:
|
2026-02-09 08:46:47 +00:00
|
|
|
"""Build text body from one or more buffered entries."""
|
|
|
|
|
if not entries:
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
if len(entries) == 1:
|
|
|
|
|
return entries[0].raw_body
|
|
|
|
|
|
|
|
|
|
lines: list[str] = []
|
|
|
|
|
for entry in entries:
|
|
|
|
|
body = entry.raw_body
|
|
|
|
|
if not body:
|
|
|
|
|
continue
|
|
|
|
|
if is_group:
|
|
|
|
|
label = entry.sender_name.strip() or entry.sender_username.strip() or entry.author
|
|
|
|
|
if label:
|
|
|
|
|
lines.append(f"{label}: {body}")
|
|
|
|
|
continue
|
|
|
|
|
lines.append(body)
|
|
|
|
|
|
|
|
|
|
return "\n".join(lines).strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_timestamp(value: Any) -> int | None:
|
|
|
|
|
"""Parse event timestamp to epoch milliseconds."""
|
|
|
|
|
if not isinstance(value, str) or not value.strip():
|
|
|
|
|
return None
|
|
|
|
|
try:
|
|
|
|
|
return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1000)
|
|
|
|
|
except ValueError:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
2026-02-09 08:50:17 +00:00
|
|
|
class MochatChannel(BaseChannel):
|
|
|
|
|
"""Mochat channel using socket.io with fallback polling workers."""
|
2026-02-09 08:46:47 +00:00
|
|
|
|
2026-02-09 08:50:17 +00:00
|
|
|
name = "mochat"
|
2026-02-09 08:46:47 +00:00
|
|
|
|
2026-02-09 08:50:17 +00:00
|
|
|
def __init__(self, config: MochatConfig, bus: MessageBus):
|
2026-02-09 08:46:47 +00:00
|
|
|
super().__init__(config, bus)
|
2026-02-09 08:50:17 +00:00
|
|
|
self.config: MochatConfig = config
|
2026-02-09 08:46:47 +00:00
|
|
|
self._http: httpx.AsyncClient | None = None
|
|
|
|
|
self._socket: Any = None
|
|
|
|
|
self._ws_connected = False
|
|
|
|
|
self._ws_ready = False
|
|
|
|
|
|
2026-02-09 08:50:17 +00:00
|
|
|
self._state_dir = get_data_path() / "mochat"
|
2026-02-09 08:46:47 +00:00
|
|
|
self._cursor_path = self._state_dir / "session_cursors.json"
|
|
|
|
|
self._session_cursor: dict[str, int] = {}
|
|
|
|
|
self._cursor_save_task: asyncio.Task | None = None
|
|
|
|
|
|
|
|
|
|
self._session_set: set[str] = set()
|
|
|
|
|
self._panel_set: set[str] = set()
|
|
|
|
|
self._auto_discover_sessions = False
|
|
|
|
|
self._auto_discover_panels = False
|
|
|
|
|
|
|
|
|
|
self._cold_sessions: set[str] = set()
|
|
|
|
|
self._session_by_converse: dict[str, str] = {}
|
|
|
|
|
|
|
|
|
|
self._seen_set: dict[str, set[str]] = {}
|
|
|
|
|
self._seen_queue: dict[str, deque[str]] = {}
|
|
|
|
|
|
|
|
|
|
self._delay_states: dict[str, DelayState] = {}
|
|
|
|
|
|
|
|
|
|
self._fallback_mode = False
|
|
|
|
|
self._session_fallback_tasks: dict[str, asyncio.Task] = {}
|
|
|
|
|
self._panel_fallback_tasks: dict[str, asyncio.Task] = {}
|
|
|
|
|
self._refresh_task: asyncio.Task | None = None
|
|
|
|
|
|
|
|
|
|
self._target_locks: dict[str, asyncio.Lock] = {}
|
|
|
|
|
|
|
|
|
|
async def start(self) -> None:
|
2026-02-09 08:50:17 +00:00
|
|
|
"""Start Mochat channel workers and websocket connection."""
|
2026-02-09 08:46:47 +00:00
|
|
|
if not self.config.claw_token:
|
2026-02-09 08:50:17 +00:00
|
|
|
logger.error("Mochat claw_token not configured")
|
2026-02-09 08:46:47 +00:00
|
|
|
return
|
|
|
|
|
|
|
|
|
|
self._running = True
|
|
|
|
|
self._http = httpx.AsyncClient(timeout=30.0)
|
|
|
|
|
|
|
|
|
|
self._state_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
await self._load_session_cursors()
|
|
|
|
|
self._seed_targets_from_config()
|
|
|
|
|
|
|
|
|
|
await self._refresh_targets(subscribe_new=False)
|
|
|
|
|
|
|
|
|
|
websocket_started = await self._start_socket_client()
|
|
|
|
|
if not websocket_started:
|
|
|
|
|
await self._ensure_fallback_workers()
|
|
|
|
|
|
|
|
|
|
self._refresh_task = asyncio.create_task(self._refresh_loop())
|
|
|
|
|
|
|
|
|
|
while self._running:
|
|
|
|
|
await asyncio.sleep(1)
|
|
|
|
|
|
|
|
|
|
async def stop(self) -> None:
|
|
|
|
|
"""Stop all workers and clean up resources."""
|
|
|
|
|
self._running = False
|
|
|
|
|
|
|
|
|
|
if self._refresh_task:
|
|
|
|
|
self._refresh_task.cancel()
|
|
|
|
|
self._refresh_task = None
|
|
|
|
|
|
|
|
|
|
await self._stop_fallback_workers()
|
|
|
|
|
await self._cancel_delay_timers()
|
|
|
|
|
|
|
|
|
|
if self._socket:
|
|
|
|
|
try:
|
|
|
|
|
await self._socket.disconnect()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
self._socket = None
|
|
|
|
|
|
|
|
|
|
if self._cursor_save_task:
|
|
|
|
|
self._cursor_save_task.cancel()
|
|
|
|
|
self._cursor_save_task = None
|
|
|
|
|
|
|
|
|
|
await self._save_session_cursors()
|
|
|
|
|
|
|
|
|
|
if self._http:
|
|
|
|
|
await self._http.aclose()
|
|
|
|
|
self._http = None
|
|
|
|
|
|
|
|
|
|
self._ws_connected = False
|
|
|
|
|
self._ws_ready = False
|
|
|
|
|
|
|
|
|
|
async def send(self, msg: OutboundMessage) -> None:
|
|
|
|
|
"""Send outbound message to session or panel."""
|
|
|
|
|
if not self.config.claw_token:
|
2026-02-09 08:50:17 +00:00
|
|
|
logger.warning("Mochat claw_token missing, skip send")
|
2026-02-09 08:46:47 +00:00
|
|
|
return
|
|
|
|
|
|
|
|
|
|
content_parts = [msg.content.strip()] if msg.content and msg.content.strip() else []
|
|
|
|
|
if msg.media:
|
|
|
|
|
content_parts.extend([m for m in msg.media if isinstance(m, str) and m.strip()])
|
|
|
|
|
content = "\n".join(content_parts).strip()
|
|
|
|
|
if not content:
|
|
|
|
|
return
|
|
|
|
|
|
2026-02-09 08:50:17 +00:00
|
|
|
target = resolve_mochat_target(msg.chat_id)
|
2026-02-09 08:46:47 +00:00
|
|
|
if not target.id:
|
2026-02-09 08:50:17 +00:00
|
|
|
logger.warning("Mochat outbound target is empty")
|
2026-02-09 08:46:47 +00:00
|
|
|
return
|
|
|
|
|
|
|
|
|
|
is_panel = target.is_panel or target.id in self._panel_set
|
|
|
|
|
if target.id.startswith("session_"):
|
|
|
|
|
is_panel = False
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
if is_panel:
|
|
|
|
|
await self._send_panel_message(
|
|
|
|
|
panel_id=target.id,
|
|
|
|
|
content=content,
|
|
|
|
|
reply_to=msg.reply_to,
|
|
|
|
|
group_id=self._read_group_id(msg.metadata),
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
await self._send_session_message(
|
|
|
|
|
session_id=target.id,
|
|
|
|
|
content=content,
|
|
|
|
|
reply_to=msg.reply_to,
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
2026-02-09 08:50:17 +00:00
|
|
|
logger.error(f"Failed to send Mochat message: {e}")
|
2026-02-09 08:46:47 +00:00
|
|
|
|
|
|
|
|
def _seed_targets_from_config(self) -> None:
|
|
|
|
|
sessions, self._auto_discover_sessions = self._normalize_id_list(self.config.sessions)
|
|
|
|
|
panels, self._auto_discover_panels = self._normalize_id_list(self.config.panels)
|
|
|
|
|
|
|
|
|
|
self._session_set.update(sessions)
|
|
|
|
|
self._panel_set.update(panels)
|
|
|
|
|
|
|
|
|
|
for session_id in sessions:
|
|
|
|
|
if session_id not in self._session_cursor:
|
|
|
|
|
self._cold_sessions.add(session_id)
|
|
|
|
|
|
|
|
|
|
def _normalize_id_list(self, values: list[str]) -> tuple[list[str], bool]:
|
|
|
|
|
cleaned = [str(v).strip() for v in values if str(v).strip()]
|
|
|
|
|
has_wildcard = "*" in cleaned
|
|
|
|
|
ids = sorted({v for v in cleaned if v != "*"})
|
|
|
|
|
return ids, has_wildcard
|
|
|
|
|
|
|
|
|
|
async def _start_socket_client(self) -> bool:
|
|
|
|
|
if not SOCKETIO_AVAILABLE:
|
2026-02-09 08:50:17 +00:00
|
|
|
logger.warning("python-socketio not installed, Mochat using polling fallback")
|
2026-02-09 08:46:47 +00:00
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
serializer = "default"
|
|
|
|
|
if not self.config.socket_disable_msgpack:
|
|
|
|
|
if MSGPACK_AVAILABLE:
|
|
|
|
|
serializer = "msgpack"
|
|
|
|
|
else:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"msgpack is not installed but socket_disable_msgpack=false; "
|
|
|
|
|
"trying JSON serializer"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
reconnect_attempts = None
|
|
|
|
|
if self.config.max_retry_attempts > 0:
|
|
|
|
|
reconnect_attempts = self.config.max_retry_attempts
|
|
|
|
|
|
|
|
|
|
client = socketio.AsyncClient(
|
|
|
|
|
reconnection=True,
|
|
|
|
|
reconnection_attempts=reconnect_attempts,
|
|
|
|
|
reconnection_delay=max(0.1, self.config.socket_reconnect_delay_ms / 1000.0),
|
|
|
|
|
reconnection_delay_max=max(
|
|
|
|
|
0.1,
|
|
|
|
|
self.config.socket_max_reconnect_delay_ms / 1000.0,
|
|
|
|
|
),
|
|
|
|
|
logger=False,
|
|
|
|
|
engineio_logger=False,
|
|
|
|
|
serializer=serializer,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@client.event
|
|
|
|
|
async def connect() -> None:
|
|
|
|
|
self._ws_connected = True
|
|
|
|
|
self._ws_ready = False
|
2026-02-09 08:50:17 +00:00
|
|
|
logger.info("Mochat websocket connected")
|
2026-02-09 08:46:47 +00:00
|
|
|
|
|
|
|
|
subscribed = await self._subscribe_all()
|
|
|
|
|
self._ws_ready = subscribed
|
|
|
|
|
if subscribed:
|
|
|
|
|
await self._stop_fallback_workers()
|
|
|
|
|
else:
|
|
|
|
|
await self._ensure_fallback_workers()
|
|
|
|
|
|
|
|
|
|
@client.event
|
|
|
|
|
async def disconnect() -> None:
|
|
|
|
|
if not self._running:
|
|
|
|
|
return
|
|
|
|
|
self._ws_connected = False
|
|
|
|
|
self._ws_ready = False
|
2026-02-09 08:50:17 +00:00
|
|
|
logger.warning("Mochat websocket disconnected")
|
2026-02-09 08:46:47 +00:00
|
|
|
await self._ensure_fallback_workers()
|
|
|
|
|
|
|
|
|
|
@client.event
|
|
|
|
|
async def connect_error(data: Any) -> None:
|
|
|
|
|
message = str(data)
|
2026-02-09 08:50:17 +00:00
|
|
|
logger.error(f"Mochat websocket connect error: {message}")
|
2026-02-09 08:46:47 +00:00
|
|
|
|
|
|
|
|
@client.on("claw.session.events")
|
|
|
|
|
async def on_session_events(payload: dict[str, Any]) -> None:
|
|
|
|
|
await self._handle_watch_payload(payload, target_kind="session")
|
|
|
|
|
|
|
|
|
|
@client.on("claw.panel.events")
|
|
|
|
|
async def on_panel_events(payload: dict[str, Any]) -> None:
|
|
|
|
|
await self._handle_watch_payload(payload, target_kind="panel")
|
|
|
|
|
|
|
|
|
|
for event_name in (
|
|
|
|
|
"notify:chat.inbox.append",
|
|
|
|
|
"notify:chat.message.add",
|
|
|
|
|
"notify:chat.message.update",
|
|
|
|
|
"notify:chat.message.recall",
|
|
|
|
|
"notify:chat.message.delete",
|
|
|
|
|
):
|
|
|
|
|
client.on(event_name, self._build_notify_handler(event_name))
|
|
|
|
|
|
|
|
|
|
socket_url = (self.config.socket_url or self.config.base_url).strip().rstrip("/")
|
|
|
|
|
socket_path = (self.config.socket_path or "/socket.io").strip()
|
|
|
|
|
if socket_path.startswith("/"):
|
|
|
|
|
socket_path = socket_path[1:]
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
self._socket = client
|
|
|
|
|
await client.connect(
|
|
|
|
|
socket_url,
|
|
|
|
|
transports=["websocket"],
|
|
|
|
|
socketio_path=socket_path,
|
|
|
|
|
auth={"token": self.config.claw_token},
|
|
|
|
|
wait_timeout=max(1.0, self.config.socket_connect_timeout_ms / 1000.0),
|
|
|
|
|
)
|
|
|
|
|
return True
|
|
|
|
|
except Exception as e:
|
2026-02-09 08:50:17 +00:00
|
|
|
logger.error(f"Failed to connect Mochat websocket: {e}")
|
2026-02-09 08:46:47 +00:00
|
|
|
try:
|
|
|
|
|
await client.disconnect()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
self._socket = None
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def _build_notify_handler(self, event_name: str):
|
|
|
|
|
async def handler(payload: Any) -> None:
|
|
|
|
|
if event_name == "notify:chat.inbox.append":
|
|
|
|
|
await self._handle_notify_inbox_append(payload)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if event_name.startswith("notify:chat.message."):
|
|
|
|
|
await self._handle_notify_chat_message(payload)
|
|
|
|
|
|
|
|
|
|
return handler
|
|
|
|
|
|
|
|
|
|
async def _subscribe_all(self) -> bool:
|
|
|
|
|
sessions_ok = await self._subscribe_sessions(sorted(self._session_set))
|
|
|
|
|
panels_ok = await self._subscribe_panels(sorted(self._panel_set))
|
|
|
|
|
|
|
|
|
|
if self._auto_discover_sessions or self._auto_discover_panels:
|
|
|
|
|
await self._refresh_targets(subscribe_new=True)
|
|
|
|
|
|
|
|
|
|
return sessions_ok and panels_ok
|
|
|
|
|
|
|
|
|
|
async def _subscribe_sessions(self, session_ids: list[str]) -> bool:
|
|
|
|
|
if not session_ids:
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
for session_id in session_ids:
|
|
|
|
|
if session_id not in self._session_cursor:
|
|
|
|
|
self._cold_sessions.add(session_id)
|
|
|
|
|
|
|
|
|
|
ack = await self._socket_call(
|
|
|
|
|
"com.claw.im.subscribeSessions",
|
|
|
|
|
{
|
|
|
|
|
"sessionIds": session_ids,
|
|
|
|
|
"cursors": self._session_cursor,
|
|
|
|
|
"limit": self.config.watch_limit,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if not ack.get("result"):
|
2026-02-09 08:50:17 +00:00
|
|
|
logger.error(f"Mochat subscribeSessions failed: {ack.get('message', 'unknown error')}")
|
2026-02-09 08:46:47 +00:00
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
data = ack.get("data")
|
|
|
|
|
items: list[dict[str, Any]] = []
|
|
|
|
|
if isinstance(data, list):
|
|
|
|
|
items = [item for item in data if isinstance(item, dict)]
|
|
|
|
|
elif isinstance(data, dict):
|
|
|
|
|
sessions = data.get("sessions")
|
|
|
|
|
if isinstance(sessions, list):
|
|
|
|
|
items = [item for item in sessions if isinstance(item, dict)]
|
|
|
|
|
elif "sessionId" in data:
|
|
|
|
|
items = [data]
|
|
|
|
|
|
|
|
|
|
for payload in items:
|
|
|
|
|
await self._handle_watch_payload(payload, target_kind="session")
|
|
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
async def _subscribe_panels(self, panel_ids: list[str]) -> bool:
|
|
|
|
|
if not self._auto_discover_panels and not panel_ids:
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
ack = await self._socket_call(
|
|
|
|
|
"com.claw.im.subscribePanels",
|
|
|
|
|
{
|
|
|
|
|
"panelIds": panel_ids,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
if not ack.get("result"):
|
2026-02-09 08:50:17 +00:00
|
|
|
logger.error(f"Mochat subscribePanels failed: {ack.get('message', 'unknown error')}")
|
2026-02-09 08:46:47 +00:00
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
async def _socket_call(self, event_name: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
if not self._socket:
|
|
|
|
|
return {"result": False, "message": "socket not connected"}
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
raw = await self._socket.call(event_name, payload, timeout=10)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return {"result": False, "message": str(e)}
|
|
|
|
|
|
|
|
|
|
if isinstance(raw, dict):
|
|
|
|
|
return raw
|
|
|
|
|
|
|
|
|
|
return {"result": True, "data": raw}
|
|
|
|
|
|
|
|
|
|
async def _refresh_loop(self) -> None:
|
|
|
|
|
interval_s = max(1.0, self.config.refresh_interval_ms / 1000.0)
|
|
|
|
|
|
|
|
|
|
while self._running:
|
|
|
|
|
await asyncio.sleep(interval_s)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
await self._refresh_targets(subscribe_new=self._ws_ready)
|
|
|
|
|
except Exception as e:
|
2026-02-09 08:50:17 +00:00
|
|
|
logger.warning(f"Mochat refresh failed: {e}")
|
2026-02-09 08:46:47 +00:00
|
|
|
|
|
|
|
|
if self._fallback_mode:
|
|
|
|
|
await self._ensure_fallback_workers()
|
|
|
|
|
|
|
|
|
|
async def _refresh_targets(self, subscribe_new: bool) -> None:
|
|
|
|
|
if self._auto_discover_sessions:
|
|
|
|
|
await self._refresh_sessions_directory(subscribe_new=subscribe_new)
|
|
|
|
|
|
|
|
|
|
if self._auto_discover_panels:
|
|
|
|
|
await self._refresh_panels(subscribe_new=subscribe_new)
|
|
|
|
|
|
|
|
|
|
async def _refresh_sessions_directory(self, subscribe_new: bool) -> None:
|
|
|
|
|
try:
|
|
|
|
|
response = await self._list_sessions()
|
|
|
|
|
except Exception as e:
|
2026-02-09 08:50:17 +00:00
|
|
|
logger.warning(f"Mochat listSessions failed: {e}")
|
2026-02-09 08:46:47 +00:00
|
|
|
return
|
|
|
|
|
|
|
|
|
|
sessions = response.get("sessions")
|
|
|
|
|
if not isinstance(sessions, list):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
new_sessions: list[str] = []
|
|
|
|
|
for session in sessions:
|
|
|
|
|
if not isinstance(session, dict):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
session_id = str(session.get("sessionId") or "").strip()
|
|
|
|
|
if not session_id:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if session_id not in self._session_set:
|
|
|
|
|
self._session_set.add(session_id)
|
|
|
|
|
new_sessions.append(session_id)
|
|
|
|
|
if session_id not in self._session_cursor:
|
|
|
|
|
self._cold_sessions.add(session_id)
|
|
|
|
|
|
|
|
|
|
converse_id = str(session.get("converseId") or "").strip()
|
|
|
|
|
if converse_id:
|
|
|
|
|
self._session_by_converse[converse_id] = session_id
|
|
|
|
|
|
|
|
|
|
if not new_sessions:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if self._ws_ready and subscribe_new:
|
|
|
|
|
await self._subscribe_sessions(new_sessions)
|
|
|
|
|
|
|
|
|
|
if self._fallback_mode:
|
|
|
|
|
await self._ensure_fallback_workers()
|
|
|
|
|
|
|
|
|
|
async def _refresh_panels(self, subscribe_new: bool) -> None:
|
|
|
|
|
try:
|
|
|
|
|
response = await self._get_workspace_group()
|
|
|
|
|
except Exception as e:
|
2026-02-09 08:50:17 +00:00
|
|
|
logger.warning(f"Mochat getWorkspaceGroup failed: {e}")
|
2026-02-09 08:46:47 +00:00
|
|
|
return
|
|
|
|
|
|
|
|
|
|
raw_panels = response.get("panels")
|
|
|
|
|
if not isinstance(raw_panels, list):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
new_panels: list[str] = []
|
|
|
|
|
for panel in raw_panels:
|
|
|
|
|
if not isinstance(panel, dict):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
panel_type = panel.get("type")
|
|
|
|
|
if isinstance(panel_type, int) and panel_type != 0:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
panel_id = str(panel.get("id") or panel.get("_id") or "").strip()
|
|
|
|
|
if not panel_id:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
if panel_id not in self._panel_set:
|
|
|
|
|
self._panel_set.add(panel_id)
|
|
|
|
|
new_panels.append(panel_id)
|
|
|
|
|
|
|
|
|
|
if not new_panels:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if self._ws_ready and subscribe_new:
|
|
|
|
|
await self._subscribe_panels(new_panels)
|
|
|
|
|
|
|
|
|
|
if self._fallback_mode:
|
|
|
|
|
await self._ensure_fallback_workers()
|
|
|
|
|
|
|
|
|
|
async def _ensure_fallback_workers(self) -> None:
|
|
|
|
|
if not self._running:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
self._fallback_mode = True
|
|
|
|
|
|
|
|
|
|
for session_id in sorted(self._session_set):
|
|
|
|
|
task = self._session_fallback_tasks.get(session_id)
|
|
|
|
|
if task and not task.done():
|
|
|
|
|
continue
|
|
|
|
|
self._session_fallback_tasks[session_id] = asyncio.create_task(
|
|
|
|
|
self._session_watch_worker(session_id)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
for panel_id in sorted(self._panel_set):
|
|
|
|
|
task = self._panel_fallback_tasks.get(panel_id)
|
|
|
|
|
if task and not task.done():
|
|
|
|
|
continue
|
|
|
|
|
self._panel_fallback_tasks[panel_id] = asyncio.create_task(
|
|
|
|
|
self._panel_poll_worker(panel_id)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _stop_fallback_workers(self) -> None:
|
|
|
|
|
self._fallback_mode = False
|
|
|
|
|
|
|
|
|
|
tasks = [
|
|
|
|
|
*self._session_fallback_tasks.values(),
|
|
|
|
|
*self._panel_fallback_tasks.values(),
|
|
|
|
|
]
|
|
|
|
|
for task in tasks:
|
|
|
|
|
task.cancel()
|
|
|
|
|
|
|
|
|
|
if tasks:
|
|
|
|
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
|
|
|
|
|
|
self._session_fallback_tasks.clear()
|
|
|
|
|
self._panel_fallback_tasks.clear()
|
|
|
|
|
|
|
|
|
|
async def _session_watch_worker(self, session_id: str) -> None:
|
|
|
|
|
while self._running and self._fallback_mode:
|
|
|
|
|
try:
|
|
|
|
|
payload = await self._watch_session(
|
|
|
|
|
session_id=session_id,
|
|
|
|
|
cursor=self._session_cursor.get(session_id, 0),
|
|
|
|
|
timeout_ms=self.config.watch_timeout_ms,
|
|
|
|
|
limit=self.config.watch_limit,
|
|
|
|
|
)
|
|
|
|
|
await self._handle_watch_payload(payload, target_kind="session")
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
break
|
|
|
|
|
except Exception as e:
|
2026-02-09 08:50:17 +00:00
|
|
|
logger.warning(f"Mochat watch fallback error ({session_id}): {e}")
|
2026-02-09 08:46:47 +00:00
|
|
|
await asyncio.sleep(max(0.1, self.config.retry_delay_ms / 1000.0))
|
|
|
|
|
|
|
|
|
|
async def _panel_poll_worker(self, panel_id: str) -> None:
|
|
|
|
|
sleep_s = max(1.0, self.config.refresh_interval_ms / 1000.0)
|
|
|
|
|
|
|
|
|
|
while self._running and self._fallback_mode:
|
|
|
|
|
try:
|
|
|
|
|
response = await self._list_panel_messages(
|
|
|
|
|
panel_id=panel_id,
|
|
|
|
|
limit=min(100, max(1, self.config.watch_limit)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
raw_messages = response.get("messages")
|
|
|
|
|
if isinstance(raw_messages, list):
|
|
|
|
|
for message in reversed(raw_messages):
|
|
|
|
|
if not isinstance(message, dict):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
synthetic_event = {
|
|
|
|
|
"type": "message.add",
|
|
|
|
|
"timestamp": message.get("createdAt") or datetime.utcnow().isoformat(),
|
|
|
|
|
"payload": {
|
|
|
|
|
"messageId": str(message.get("messageId") or ""),
|
|
|
|
|
"author": str(message.get("author") or ""),
|
|
|
|
|
"authorInfo": message.get("authorInfo") if isinstance(message.get("authorInfo"), dict) else {},
|
|
|
|
|
"content": message.get("content"),
|
|
|
|
|
"meta": message.get("meta") if isinstance(message.get("meta"), dict) else {},
|
|
|
|
|
"groupId": str(response.get("groupId") or ""),
|
|
|
|
|
"converseId": panel_id,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
await self._process_inbound_event(
|
|
|
|
|
target_id=panel_id,
|
|
|
|
|
event=synthetic_event,
|
|
|
|
|
target_kind="panel",
|
|
|
|
|
)
|
|
|
|
|
except asyncio.CancelledError:
|
|
|
|
|
break
|
|
|
|
|
except Exception as e:
|
2026-02-09 08:50:17 +00:00
|
|
|
logger.warning(f"Mochat panel polling error ({panel_id}): {e}")
|
2026-02-09 08:46:47 +00:00
|
|
|
|
|
|
|
|
await asyncio.sleep(sleep_s)
|
|
|
|
|
|
|
|
|
|
async def _handle_watch_payload(
|
|
|
|
|
self,
|
|
|
|
|
payload: dict[str, Any],
|
|
|
|
|
target_kind: str,
|
|
|
|
|
) -> None:
|
|
|
|
|
if not isinstance(payload, dict):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
target_id = str(payload.get("sessionId") or "").strip()
|
|
|
|
|
if not target_id:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
lock = self._target_locks.setdefault(f"{target_kind}:{target_id}", asyncio.Lock())
|
|
|
|
|
async with lock:
|
|
|
|
|
previous_cursor = self._session_cursor.get(target_id, 0) if target_kind == "session" else 0
|
|
|
|
|
payload_cursor = payload.get("cursor")
|
|
|
|
|
if (
|
|
|
|
|
target_kind == "session"
|
|
|
|
|
and isinstance(payload_cursor, int)
|
|
|
|
|
and payload_cursor >= 0
|
|
|
|
|
):
|
|
|
|
|
self._mark_session_cursor(target_id, payload_cursor)
|
|
|
|
|
|
|
|
|
|
raw_events = payload.get("events")
|
|
|
|
|
if not isinstance(raw_events, list):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if target_kind == "session" and target_id in self._cold_sessions:
|
|
|
|
|
self._cold_sessions.discard(target_id)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
for event in raw_events:
|
|
|
|
|
if not isinstance(event, dict):
|
|
|
|
|
continue
|
|
|
|
|
seq = event.get("seq")
|
|
|
|
|
if (
|
|
|
|
|
target_kind == "session"
|
|
|
|
|
and isinstance(seq, int)
|
|
|
|
|
and seq > self._session_cursor.get(target_id, previous_cursor)
|
|
|
|
|
):
|
|
|
|
|
self._mark_session_cursor(target_id, seq)
|
|
|
|
|
|
|
|
|
|
if event.get("type") != "message.add":
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
await self._process_inbound_event(
|
|
|
|
|
target_id=target_id,
|
|
|
|
|
event=event,
|
|
|
|
|
target_kind=target_kind,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _process_inbound_event(
|
|
|
|
|
self,
|
|
|
|
|
target_id: str,
|
|
|
|
|
event: dict[str, Any],
|
|
|
|
|
target_kind: str,
|
|
|
|
|
) -> None:
|
|
|
|
|
payload = event.get("payload")
|
|
|
|
|
if not isinstance(payload, dict):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
author = str(payload.get("author") or "").strip()
|
|
|
|
|
if not author:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if self.config.agent_user_id and author == self.config.agent_user_id:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if not self.is_allowed(author):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
message_id = str(payload.get("messageId") or "").strip()
|
|
|
|
|
seen_key = f"{target_kind}:{target_id}"
|
|
|
|
|
if message_id and self._remember_message_id(seen_key, message_id):
|
|
|
|
|
return
|
|
|
|
|
|
2026-02-09 08:50:17 +00:00
|
|
|
raw_body = normalize_mochat_content(payload.get("content"))
|
2026-02-09 08:46:47 +00:00
|
|
|
if not raw_body:
|
|
|
|
|
raw_body = "[empty message]"
|
|
|
|
|
|
|
|
|
|
author_info = payload.get("authorInfo") if isinstance(payload.get("authorInfo"), dict) else {}
|
|
|
|
|
sender_name = str(author_info.get("nickname") or author_info.get("email") or "").strip()
|
|
|
|
|
sender_username = str(author_info.get("agentId") or "").strip()
|
|
|
|
|
|
|
|
|
|
group_id = str(payload.get("groupId") or "").strip()
|
|
|
|
|
is_group = bool(group_id)
|
|
|
|
|
was_mentioned = resolve_was_mentioned(payload, self.config.agent_user_id)
|
|
|
|
|
|
|
|
|
|
require_mention = (
|
|
|
|
|
target_kind == "panel"
|
|
|
|
|
and is_group
|
|
|
|
|
and resolve_require_mention(self.config, target_id, group_id)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
use_delay = target_kind == "panel" and self.config.reply_delay_mode == "non-mention"
|
|
|
|
|
|
|
|
|
|
if require_mention and not was_mentioned and not use_delay:
|
|
|
|
|
return
|
|
|
|
|
|
2026-02-09 08:50:17 +00:00
|
|
|
entry = MochatBufferedEntry(
|
2026-02-09 08:46:47 +00:00
|
|
|
raw_body=raw_body,
|
|
|
|
|
author=author,
|
|
|
|
|
sender_name=sender_name,
|
|
|
|
|
sender_username=sender_username,
|
|
|
|
|
timestamp=parse_timestamp(event.get("timestamp")),
|
|
|
|
|
message_id=message_id,
|
|
|
|
|
group_id=group_id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if use_delay:
|
|
|
|
|
delay_key = f"{target_kind}:{target_id}"
|
|
|
|
|
if was_mentioned:
|
|
|
|
|
await self._flush_delayed_entries(
|
|
|
|
|
key=delay_key,
|
|
|
|
|
target_id=target_id,
|
|
|
|
|
target_kind=target_kind,
|
|
|
|
|
reason="mention",
|
|
|
|
|
entry=entry,
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
await self._enqueue_delayed_entry(
|
|
|
|
|
key=delay_key,
|
|
|
|
|
target_id=target_id,
|
|
|
|
|
target_kind=target_kind,
|
|
|
|
|
entry=entry,
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
await self._dispatch_entries(
|
|
|
|
|
target_id=target_id,
|
|
|
|
|
target_kind=target_kind,
|
|
|
|
|
entries=[entry],
|
|
|
|
|
was_mentioned=was_mentioned,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _remember_message_id(self, key: str, message_id: str) -> bool:
|
|
|
|
|
seen_set = self._seen_set.setdefault(key, set())
|
|
|
|
|
seen_queue = self._seen_queue.setdefault(key, deque())
|
|
|
|
|
|
|
|
|
|
if message_id in seen_set:
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
seen_set.add(message_id)
|
|
|
|
|
seen_queue.append(message_id)
|
|
|
|
|
|
|
|
|
|
while len(seen_queue) > MAX_SEEN_MESSAGE_IDS:
|
|
|
|
|
removed = seen_queue.popleft()
|
|
|
|
|
seen_set.discard(removed)
|
|
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
async def _enqueue_delayed_entry(
|
|
|
|
|
self,
|
|
|
|
|
key: str,
|
|
|
|
|
target_id: str,
|
|
|
|
|
target_kind: str,
|
2026-02-09 08:50:17 +00:00
|
|
|
entry: MochatBufferedEntry,
|
2026-02-09 08:46:47 +00:00
|
|
|
) -> None:
|
|
|
|
|
state = self._delay_states.setdefault(key, DelayState())
|
|
|
|
|
|
|
|
|
|
async with state.lock:
|
|
|
|
|
state.entries.append(entry)
|
|
|
|
|
if state.timer:
|
|
|
|
|
state.timer.cancel()
|
|
|
|
|
|
|
|
|
|
state.timer = asyncio.create_task(
|
|
|
|
|
self._delay_flush_after(key, target_id, target_kind)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _delay_flush_after(self, key: str, target_id: str, target_kind: str) -> None:
|
|
|
|
|
await asyncio.sleep(max(0, self.config.reply_delay_ms) / 1000.0)
|
|
|
|
|
await self._flush_delayed_entries(
|
|
|
|
|
key=key,
|
|
|
|
|
target_id=target_id,
|
|
|
|
|
target_kind=target_kind,
|
|
|
|
|
reason="timer",
|
|
|
|
|
entry=None,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _flush_delayed_entries(
|
|
|
|
|
self,
|
|
|
|
|
key: str,
|
|
|
|
|
target_id: str,
|
|
|
|
|
target_kind: str,
|
|
|
|
|
reason: str,
|
2026-02-09 08:50:17 +00:00
|
|
|
entry: MochatBufferedEntry | None,
|
2026-02-09 08:46:47 +00:00
|
|
|
) -> None:
|
|
|
|
|
state = self._delay_states.setdefault(key, DelayState())
|
|
|
|
|
|
|
|
|
|
async with state.lock:
|
|
|
|
|
if entry:
|
|
|
|
|
state.entries.append(entry)
|
|
|
|
|
|
|
|
|
|
current = asyncio.current_task()
|
|
|
|
|
if state.timer and state.timer is not current:
|
|
|
|
|
state.timer.cancel()
|
|
|
|
|
state.timer = None
|
|
|
|
|
elif state.timer is current:
|
|
|
|
|
state.timer = None
|
|
|
|
|
|
|
|
|
|
entries = state.entries[:]
|
|
|
|
|
state.entries.clear()
|
|
|
|
|
|
|
|
|
|
if not entries:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
await self._dispatch_entries(
|
|
|
|
|
target_id=target_id,
|
|
|
|
|
target_kind=target_kind,
|
|
|
|
|
entries=entries,
|
|
|
|
|
was_mentioned=(reason == "mention"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _dispatch_entries(
|
|
|
|
|
self,
|
|
|
|
|
target_id: str,
|
|
|
|
|
target_kind: str,
|
2026-02-09 08:50:17 +00:00
|
|
|
entries: list[MochatBufferedEntry],
|
2026-02-09 08:46:47 +00:00
|
|
|
was_mentioned: bool,
|
|
|
|
|
) -> None:
|
|
|
|
|
if not entries:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
is_group = bool(entries[-1].group_id)
|
|
|
|
|
body = build_buffered_body(entries, is_group)
|
|
|
|
|
if not body:
|
|
|
|
|
body = "[empty message]"
|
|
|
|
|
|
|
|
|
|
last = entries[-1]
|
|
|
|
|
metadata = {
|
|
|
|
|
"message_id": last.message_id,
|
|
|
|
|
"timestamp": last.timestamp,
|
|
|
|
|
"is_group": is_group,
|
|
|
|
|
"group_id": last.group_id,
|
|
|
|
|
"sender_name": last.sender_name,
|
|
|
|
|
"sender_username": last.sender_username,
|
|
|
|
|
"target_kind": target_kind,
|
|
|
|
|
"was_mentioned": was_mentioned,
|
|
|
|
|
"buffered_count": len(entries),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await self._handle_message(
|
|
|
|
|
sender_id=last.author,
|
|
|
|
|
chat_id=target_id,
|
|
|
|
|
content=body,
|
|
|
|
|
metadata=metadata,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _cancel_delay_timers(self) -> None:
|
|
|
|
|
for state in self._delay_states.values():
|
|
|
|
|
if state.timer:
|
|
|
|
|
state.timer.cancel()
|
|
|
|
|
state.timer = None
|
|
|
|
|
self._delay_states.clear()
|
|
|
|
|
|
|
|
|
|
async def _handle_notify_chat_message(self, payload: Any) -> None:
|
|
|
|
|
if not isinstance(payload, dict):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
group_id = str(payload.get("groupId") or "").strip()
|
|
|
|
|
panel_id = str(payload.get("converseId") or payload.get("panelId") or "").strip()
|
|
|
|
|
if not group_id or not panel_id:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if self._panel_set and panel_id not in self._panel_set:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
synthetic_event = {
|
|
|
|
|
"type": "message.add",
|
|
|
|
|
"timestamp": payload.get("createdAt") or datetime.utcnow().isoformat(),
|
|
|
|
|
"payload": {
|
|
|
|
|
"messageId": str(payload.get("_id") or payload.get("messageId") or ""),
|
|
|
|
|
"author": str(payload.get("author") or ""),
|
|
|
|
|
"authorInfo": payload.get("authorInfo") if isinstance(payload.get("authorInfo"), dict) else {},
|
|
|
|
|
"content": payload.get("content"),
|
|
|
|
|
"meta": payload.get("meta") if isinstance(payload.get("meta"), dict) else {},
|
|
|
|
|
"groupId": group_id,
|
|
|
|
|
"converseId": panel_id,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
await self._process_inbound_event(
|
|
|
|
|
target_id=panel_id,
|
|
|
|
|
event=synthetic_event,
|
|
|
|
|
target_kind="panel",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _handle_notify_inbox_append(self, payload: Any) -> None:
|
|
|
|
|
if not isinstance(payload, dict):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if payload.get("type") != "message":
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
detail = payload.get("payload")
|
|
|
|
|
if not isinstance(detail, dict):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
group_id = str(detail.get("groupId") or "").strip()
|
|
|
|
|
if group_id:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
converse_id = str(detail.get("converseId") or "").strip()
|
|
|
|
|
if not converse_id:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
session_id = self._session_by_converse.get(converse_id)
|
|
|
|
|
if not session_id:
|
|
|
|
|
await self._refresh_sessions_directory(subscribe_new=self._ws_ready)
|
|
|
|
|
session_id = self._session_by_converse.get(converse_id)
|
|
|
|
|
if not session_id:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
message_id = str(detail.get("messageId") or payload.get("_id") or "").strip()
|
|
|
|
|
author = str(detail.get("messageAuthor") or "").strip()
|
|
|
|
|
content = str(detail.get("messagePlainContent") or detail.get("messageSnippet") or "").strip()
|
|
|
|
|
|
|
|
|
|
synthetic_event = {
|
|
|
|
|
"type": "message.add",
|
|
|
|
|
"timestamp": payload.get("createdAt") or datetime.utcnow().isoformat(),
|
|
|
|
|
"payload": {
|
|
|
|
|
"messageId": message_id,
|
|
|
|
|
"author": author,
|
|
|
|
|
"content": content,
|
|
|
|
|
"meta": {
|
|
|
|
|
"source": "notify:chat.inbox.append",
|
|
|
|
|
"converseId": converse_id,
|
|
|
|
|
},
|
|
|
|
|
"converseId": converse_id,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await self._process_inbound_event(
|
|
|
|
|
target_id=session_id,
|
|
|
|
|
event=synthetic_event,
|
|
|
|
|
target_kind="session",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _mark_session_cursor(self, session_id: str, cursor: int) -> None:
|
|
|
|
|
if cursor < 0:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
previous = self._session_cursor.get(session_id, 0)
|
|
|
|
|
if cursor < previous:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
self._session_cursor[session_id] = cursor
|
|
|
|
|
self._schedule_cursor_save()
|
|
|
|
|
|
|
|
|
|
def _schedule_cursor_save(self) -> None:
|
|
|
|
|
if self._cursor_save_task and not self._cursor_save_task.done():
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
self._cursor_save_task = asyncio.create_task(self._save_cursor_debounced())
|
|
|
|
|
|
|
|
|
|
async def _save_cursor_debounced(self) -> None:
|
|
|
|
|
await asyncio.sleep(CURSOR_SAVE_DEBOUNCE_S)
|
|
|
|
|
await self._save_session_cursors()
|
|
|
|
|
|
|
|
|
|
async def _load_session_cursors(self) -> None:
|
|
|
|
|
if not self._cursor_path.exists():
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
data = json.loads(self._cursor_path.read_text("utf-8"))
|
|
|
|
|
except Exception as e:
|
2026-02-09 08:50:17 +00:00
|
|
|
logger.warning(f"Failed to read Mochat cursor file: {e}")
|
2026-02-09 08:46:47 +00:00
|
|
|
return
|
|
|
|
|
|
|
|
|
|
cursors = data.get("cursors") if isinstance(data, dict) else None
|
|
|
|
|
if not isinstance(cursors, dict):
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
for session_id, cursor in cursors.items():
|
|
|
|
|
if isinstance(session_id, str) and isinstance(cursor, int) and cursor >= 0:
|
|
|
|
|
self._session_cursor[session_id] = cursor
|
|
|
|
|
|
|
|
|
|
async def _save_session_cursors(self) -> None:
|
|
|
|
|
payload = {
|
|
|
|
|
"schemaVersion": 1,
|
|
|
|
|
"updatedAt": datetime.utcnow().isoformat(),
|
|
|
|
|
"cursors": self._session_cursor,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
self._state_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
self._cursor_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", "utf-8")
|
|
|
|
|
except Exception as e:
|
2026-02-09 08:50:17 +00:00
|
|
|
logger.warning(f"Failed to save Mochat cursor file: {e}")
|
2026-02-09 08:46:47 +00:00
|
|
|
|
|
|
|
|
def _base_url(self) -> str:
|
|
|
|
|
return self.config.base_url.strip().rstrip("/")
|
|
|
|
|
|
|
|
|
|
async def _post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
if not self._http:
|
2026-02-09 08:50:17 +00:00
|
|
|
raise RuntimeError("Mochat HTTP client not initialized")
|
2026-02-09 08:46:47 +00:00
|
|
|
|
|
|
|
|
url = f"{self._base_url()}{path}"
|
|
|
|
|
response = await self._http.post(
|
|
|
|
|
url,
|
|
|
|
|
headers={
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
"X-Claw-Token": self.config.claw_token,
|
|
|
|
|
},
|
|
|
|
|
json=payload,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
text = response.text
|
|
|
|
|
if not response.is_success:
|
2026-02-09 08:50:17 +00:00
|
|
|
raise RuntimeError(f"Mochat HTTP {response.status_code}: {text[:200]}")
|
2026-02-09 08:46:47 +00:00
|
|
|
|
|
|
|
|
parsed: Any
|
|
|
|
|
try:
|
|
|
|
|
parsed = response.json()
|
|
|
|
|
except Exception:
|
|
|
|
|
parsed = text
|
|
|
|
|
|
|
|
|
|
if isinstance(parsed, dict) and isinstance(parsed.get("code"), int):
|
|
|
|
|
if parsed["code"] != 200:
|
|
|
|
|
message = str(parsed.get("message") or parsed.get("name") or "request failed")
|
2026-02-09 08:50:17 +00:00
|
|
|
raise RuntimeError(f"Mochat API error: {message} (code={parsed['code']})")
|
2026-02-09 08:46:47 +00:00
|
|
|
data = parsed.get("data")
|
|
|
|
|
return data if isinstance(data, dict) else {}
|
|
|
|
|
|
|
|
|
|
if isinstance(parsed, dict):
|
|
|
|
|
return parsed
|
|
|
|
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
async def _watch_session(
|
|
|
|
|
self,
|
|
|
|
|
session_id: str,
|
|
|
|
|
cursor: int,
|
|
|
|
|
timeout_ms: int,
|
|
|
|
|
limit: int,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
return await self._post_json(
|
|
|
|
|
"/api/claw/sessions/watch",
|
|
|
|
|
{
|
|
|
|
|
"sessionId": session_id,
|
|
|
|
|
"cursor": cursor,
|
|
|
|
|
"timeoutMs": timeout_ms,
|
|
|
|
|
"limit": limit,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _send_session_message(
|
|
|
|
|
self,
|
|
|
|
|
session_id: str,
|
|
|
|
|
content: str,
|
|
|
|
|
reply_to: str | None,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
payload = {
|
|
|
|
|
"sessionId": session_id,
|
|
|
|
|
"content": content,
|
|
|
|
|
}
|
|
|
|
|
if reply_to:
|
|
|
|
|
payload["replyTo"] = reply_to
|
|
|
|
|
return await self._post_json("/api/claw/sessions/send", payload)
|
|
|
|
|
|
|
|
|
|
async def _send_panel_message(
|
|
|
|
|
self,
|
|
|
|
|
panel_id: str,
|
|
|
|
|
content: str,
|
|
|
|
|
reply_to: str | None,
|
|
|
|
|
group_id: str | None,
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
payload = {
|
|
|
|
|
"panelId": panel_id,
|
|
|
|
|
"content": content,
|
|
|
|
|
}
|
|
|
|
|
if reply_to:
|
|
|
|
|
payload["replyTo"] = reply_to
|
|
|
|
|
if group_id:
|
|
|
|
|
payload["groupId"] = group_id
|
|
|
|
|
return await self._post_json("/api/claw/groups/panels/send", payload)
|
|
|
|
|
|
|
|
|
|
async def _list_sessions(self) -> dict[str, Any]:
|
|
|
|
|
return await self._post_json("/api/claw/sessions/list", {})
|
|
|
|
|
|
|
|
|
|
async def _get_workspace_group(self) -> dict[str, Any]:
|
|
|
|
|
return await self._post_json("/api/claw/groups/get", {})
|
|
|
|
|
|
|
|
|
|
async def _list_panel_messages(self, panel_id: str, limit: int) -> dict[str, Any]:
|
|
|
|
|
return await self._post_json(
|
|
|
|
|
"/api/claw/groups/panels/messages",
|
|
|
|
|
{
|
|
|
|
|
"panelId": panel_id,
|
|
|
|
|
"limit": limit,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _read_group_id(self, metadata: dict[str, Any]) -> str | None:
|
|
|
|
|
if not isinstance(metadata, dict):
|
|
|
|
|
return None
|
|
|
|
|
value = metadata.get("group_id") or metadata.get("groupId")
|
|
|
|
|
if isinstance(value, str) and value.strip():
|
|
|
|
|
return value.strip()
|
|
|
|
|
return None
|