返回 JoyAI-Echo
mochat.py
1 """Mochat channel implementation using Socket.IO with HTTP polling fallback."""
2
3 from __future__ import annotations
4
5 import asyncio
6 import json
7 from collections import deque
8 from dataclasses import dataclass, field
9 from datetime import datetime
10 from typing import Any
11
12 import httpx
13 from loguru import logger
14
15 from nanobot.bus.events import OutboundMessage
16 from nanobot.bus.queue import MessageBus
17 from nanobot.channels.base import BaseChannel
18 from nanobot.config.paths import get_runtime_subdir
19 from nanobot.config.schema import Base
20 from pydantic import Field
21
22 try:
23 import socketio
24 SOCKETIO_AVAILABLE = True
25 except ImportError:
26 socketio = None
27 SOCKETIO_AVAILABLE = False
28
29 try:
30 import msgpack # noqa: F401
31 MSGPACK_AVAILABLE = True
32 except ImportError:
33 MSGPACK_AVAILABLE = False
34
35 MAX_SEEN_MESSAGE_IDS = 2000
36 CURSOR_SAVE_DEBOUNCE_S = 0.5
37
38
39 # ---------------------------------------------------------------------------
40 # Data classes
41 # ---------------------------------------------------------------------------
42
43 @dataclass
44 class MochatBufferedEntry:
45 """Buffered inbound entry for delayed dispatch."""
46 raw_body: str
47 author: str
48 sender_name: str = ""
49 sender_username: str = ""
50 timestamp: int | None = None
51 message_id: str = ""
52 group_id: str = ""
53
54
55 @dataclass
56 class DelayState:
57 """Per-target delayed message state."""
58 entries: list[MochatBufferedEntry] = field(default_factory=list)
59 lock: asyncio.Lock = field(default_factory=asyncio.Lock)
60 timer: asyncio.Task | None = None
61
62
63 @dataclass
64 class MochatTarget:
65 """Outbound target resolution result."""
66 id: str
67 is_panel: bool
68
69
70 # ---------------------------------------------------------------------------
71 # Pure helpers
72 # ---------------------------------------------------------------------------
73
74 def _safe_dict(value: Any) -> dict:
75 """Return *value* if it's a dict, else empty dict."""
76 return value if isinstance(value, dict) else {}
77
78
79 def _str_field(src: dict, *keys: str) -> str:
80 """Return the first non-empty str value found for *keys*, stripped."""
81 for k in keys:
82 v = src.get(k)
83 if isinstance(v, str) and v.strip():
84 return v.strip()
85 return ""
86
87
88 def _make_synthetic_event(
89 message_id: str, author: str, content: Any,
90 meta: Any, group_id: str, converse_id: str,
91 timestamp: Any = None, *, author_info: Any = None,
92 ) -> dict[str, Any]:
93 """Build a synthetic ``message.add`` event dict."""
94 payload: dict[str, Any] = {
95 "messageId": message_id, "author": author,
96 "content": content, "meta": _safe_dict(meta),
97 "groupId": group_id, "converseId": converse_id,
98 }
99 if author_info is not None:
100 payload["authorInfo"] = _safe_dict(author_info)
101 return {
102 "type": "message.add",
103 "timestamp": timestamp or datetime.utcnow().isoformat(),
104 "payload": payload,
105 }
106
107
108 def normalize_mochat_content(content: Any) -> str:
109 """Normalize content payload to text."""
110 if isinstance(content, str):
111 return content.strip()
112 if content is None:
113 return ""
114 try:
115 return json.dumps(content, ensure_ascii=False)
116 except TypeError:
117 return str(content)
118
119
120 def resolve_mochat_target(raw: str) -> MochatTarget:
121 """Resolve id and target kind from user-provided target string."""
122 trimmed = (raw or "").strip()
123 if not trimmed:
124 return MochatTarget(id="", is_panel=False)
125
126 lowered = trimmed.lower()
127 cleaned, forced_panel = trimmed, False
128 for prefix in ("mochat:", "group:", "channel:", "panel:"):
129 if lowered.startswith(prefix):
130 cleaned = trimmed[len(prefix):].strip()
131 forced_panel = prefix in {"group:", "channel:", "panel:"}
132 break
133
134 if not cleaned:
135 return MochatTarget(id="", is_panel=False)
136 return MochatTarget(id=cleaned, is_panel=forced_panel or not cleaned.startswith("session_"))
137
138
139 def extract_mention_ids(value: Any) -> list[str]:
140 """Extract mention ids from heterogeneous mention payload."""
141 if not isinstance(value, list):
142 return []
143 ids: list[str] = []
144 for item in value:
145 if isinstance(item, str):
146 if item.strip():
147 ids.append(item.strip())
148 elif isinstance(item, dict):
149 for key in ("id", "userId", "_id"):
150 candidate = item.get(key)
151 if isinstance(candidate, str) and candidate.strip():
152 ids.append(candidate.strip())
153 break
154 return ids
155
156
157 def resolve_was_mentioned(payload: dict[str, Any], agent_user_id: str) -> bool:
158 """Resolve mention state from payload metadata and text fallback."""
159 meta = payload.get("meta")
160 if isinstance(meta, dict):
161 if meta.get("mentioned") is True or meta.get("wasMentioned") is True:
162 return True
163 for f in ("mentions", "mentionIds", "mentionedUserIds", "mentionedUsers"):
164 if agent_user_id and agent_user_id in extract_mention_ids(meta.get(f)):
165 return True
166 if not agent_user_id:
167 return False
168 content = payload.get("content")
169 if not isinstance(content, str) or not content:
170 return False
171 return f"<@{agent_user_id}>" in content or f"@{agent_user_id}" in content
172
173
174 def resolve_require_mention(config: MochatConfig, session_id: str, group_id: str) -> bool:
175 """Resolve mention requirement for group/panel conversations."""
176 groups = config.groups or {}
177 for key in (group_id, session_id, "*"):
178 if key and key in groups:
179 return bool(groups[key].require_mention)
180 return bool(config.mention.require_in_groups)
181
182
183 def build_buffered_body(entries: list[MochatBufferedEntry], is_group: bool) -> str:
184 """Build text body from one or more buffered entries."""
185 if not entries:
186 return ""
187 if len(entries) == 1:
188 return entries[0].raw_body
189 lines: list[str] = []
190 for entry in entries:
191 if not entry.raw_body:
192 continue
193 if is_group:
194 label = entry.sender_name.strip() or entry.sender_username.strip() or entry.author
195 if label:
196 lines.append(f"{label}: {entry.raw_body}")
197 continue
198 lines.append(entry.raw_body)
199 return "\n".join(lines).strip()
200
201
202 def parse_timestamp(value: Any) -> int | None:
203 """Parse event timestamp to epoch milliseconds."""
204 if not isinstance(value, str) or not value.strip():
205 return None
206 try:
207 return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1000)
208 except ValueError:
209 return None
210
211
212 # ---------------------------------------------------------------------------
213 # Config classes
214 # ---------------------------------------------------------------------------
215
216 class MochatMentionConfig(Base):
217 """Mochat mention behavior configuration."""
218
219 require_in_groups: bool = False
220
221
222 class MochatGroupRule(Base):
223 """Mochat per-group mention requirement."""
224
225 require_mention: bool = False
226
227
228 class MochatConfig(Base):
229 """Mochat channel configuration."""
230
231 enabled: bool = False
232 base_url: str = "https://mochat.io"
233 socket_url: str = ""
234 socket_path: str = "/socket.io"
235 socket_disable_msgpack: bool = False
236 socket_reconnect_delay_ms: int = 1000
237 socket_max_reconnect_delay_ms: int = 10000
238 socket_connect_timeout_ms: int = 10000
239 refresh_interval_ms: int = 30000
240 watch_timeout_ms: int = 25000
241 watch_limit: int = 100
242 retry_delay_ms: int = 500
243 max_retry_attempts: int = 0
244 claw_token: str = ""
245 agent_user_id: str = ""
246 sessions: list[str] = Field(default_factory=list)
247 panels: list[str] = Field(default_factory=list)
248 allow_from: list[str] = Field(default_factory=list)
249 mention: MochatMentionConfig = Field(default_factory=MochatMentionConfig)
250 groups: dict[str, MochatGroupRule] = Field(default_factory=dict)
251 reply_delay_mode: str = "non-mention"
252 reply_delay_ms: int = 120000
253
254
255 # ---------------------------------------------------------------------------
256 # Channel
257 # ---------------------------------------------------------------------------
258
259 class MochatChannel(BaseChannel):
260 """Mochat channel using socket.io with fallback polling workers."""
261
262 name = "mochat"
263 display_name = "Mochat"
264
265 @classmethod
266 def default_config(cls) -> dict[str, Any]:
267 return MochatConfig().model_dump(by_alias=True)
268
269 def __init__(self, config: Any, bus: MessageBus):
270 if isinstance(config, dict):
271 config = MochatConfig.model_validate(config)
272 super().__init__(config, bus)
273 self.config: MochatConfig = config
274 self._http: httpx.AsyncClient | None = None
275 self._socket: Any = None
276 self._ws_connected = self._ws_ready = False
277
278 self._state_dir = get_runtime_subdir("mochat")
279 self._cursor_path = self._state_dir / "session_cursors.json"
280 self._session_cursor: dict[str, int] = {}
281 self._cursor_save_task: asyncio.Task | None = None
282
283 self._session_set: set[str] = set()
284 self._panel_set: set[str] = set()
285 self._auto_discover_sessions = self._auto_discover_panels = False
286
287 self._cold_sessions: set[str] = set()
288 self._session_by_converse: dict[str, str] = {}
289
290 self._seen_set: dict[str, set[str]] = {}
291 self._seen_queue: dict[str, deque[str]] = {}
292 self._delay_states: dict[str, DelayState] = {}
293
294 self._fallback_mode = False
295 self._session_fallback_tasks: dict[str, asyncio.Task] = {}
296 self._panel_fallback_tasks: dict[str, asyncio.Task] = {}
297 self._refresh_task: asyncio.Task | None = None
298 self._target_locks: dict[str, asyncio.Lock] = {}
299
300 # ---- lifecycle ---------------------------------------------------------
301
302 async def start(self) -> None:
303 """Start Mochat channel workers and websocket connection."""
304 if not self.config.claw_token:
305 logger.error("Mochat claw_token not configured")
306 return
307
308 self._running = True
309 self._http = httpx.AsyncClient(timeout=30.0)
310 self._state_dir.mkdir(parents=True, exist_ok=True)
311 await self._load_session_cursors()
312 self._seed_targets_from_config()
313 await self._refresh_targets(subscribe_new=False)
314
315 if not await self._start_socket_client():
316 await self._ensure_fallback_workers()
317
318 self._refresh_task = asyncio.create_task(self._refresh_loop())
319 while self._running:
320 await asyncio.sleep(1)
321
322 async def stop(self) -> None:
323 """Stop all workers and clean up resources."""
324 self._running = False
325 if self._refresh_task:
326 self._refresh_task.cancel()
327 self._refresh_task = None
328
329 await self._stop_fallback_workers()
330 await self._cancel_delay_timers()
331
332 if self._socket:
333 try:
334 await self._socket.disconnect()
335 except Exception:
336 pass
337 self._socket = None
338
339 if self._cursor_save_task:
340 self._cursor_save_task.cancel()
341 self._cursor_save_task = None
342 await self._save_session_cursors()
343
344 if self._http:
345 await self._http.aclose()
346 self._http = None
347 self._ws_connected = self._ws_ready = False
348
349 async def send(self, msg: OutboundMessage) -> None:
350 """Send outbound message to session or panel."""
351 if not self.config.claw_token:
352 logger.warning("Mochat claw_token missing, skip send")
353 return
354
355 parts = ([msg.content.strip()] if msg.content and msg.content.strip() else [])
356 if msg.media:
357 parts.extend(m for m in msg.media if isinstance(m, str) and m.strip())
358 content = "\n".join(parts).strip()
359 if not content:
360 return
361
362 target = resolve_mochat_target(msg.chat_id)
363 if not target.id:
364 logger.warning("Mochat outbound target is empty")
365 return
366
367 is_panel = (target.is_panel or target.id in self._panel_set) and not target.id.startswith("session_")
368 try:
369 if is_panel:
370 await self._api_send("/api/claw/groups/panels/send", "panelId", target.id,
371 content, msg.reply_to, self._read_group_id(msg.metadata))
372 else:
373 await self._api_send("/api/claw/sessions/send", "sessionId", target.id,
374 content, msg.reply_to)
375 except Exception as e:
376 logger.error("Failed to send Mochat message: {}", e)
377 raise
378
379 # ---- config / init helpers ---------------------------------------------
380
381 def _seed_targets_from_config(self) -> None:
382 sessions, self._auto_discover_sessions = self._normalize_id_list(self.config.sessions)
383 panels, self._auto_discover_panels = self._normalize_id_list(self.config.panels)
384 self._session_set.update(sessions)
385 self._panel_set.update(panels)
386 for sid in sessions:
387 if sid not in self._session_cursor:
388 self._cold_sessions.add(sid)
389
390 @staticmethod
391 def _normalize_id_list(values: list[str]) -> tuple[list[str], bool]:
392 cleaned = [str(v).strip() for v in values if str(v).strip()]
393 return sorted({v for v in cleaned if v != "*"}), "*" in cleaned
394
395 # ---- websocket ---------------------------------------------------------
396
397 async def _start_socket_client(self) -> bool:
398 if not SOCKETIO_AVAILABLE:
399 logger.warning("python-socketio not installed, Mochat using polling fallback")
400 return False
401
402 serializer = "default"
403 if not self.config.socket_disable_msgpack:
404 if MSGPACK_AVAILABLE:
405 serializer = "msgpack"
406 else:
407 logger.warning("msgpack not installed but socket_disable_msgpack=false; using JSON")
408
409 client = socketio.AsyncClient(
410 reconnection=True,
411 reconnection_attempts=self.config.max_retry_attempts or None,
412 reconnection_delay=max(0.1, self.config.socket_reconnect_delay_ms / 1000.0),
413 reconnection_delay_max=max(0.1, self.config.socket_max_reconnect_delay_ms / 1000.0),
414 logger=False, engineio_logger=False, serializer=serializer,
415 )
416
417 @client.event
418 async def connect() -> None:
419 self._ws_connected, self._ws_ready = True, False
420 logger.info("Mochat websocket connected")
421 subscribed = await self._subscribe_all()
422 self._ws_ready = subscribed
423 await (self._stop_fallback_workers() if subscribed else self._ensure_fallback_workers())
424
425 @client.event
426 async def disconnect() -> None:
427 if not self._running:
428 return
429 self._ws_connected = self._ws_ready = False
430 logger.warning("Mochat websocket disconnected")
431 await self._ensure_fallback_workers()
432
433 @client.event
434 async def connect_error(data: Any) -> None:
435 logger.error("Mochat websocket connect error: {}", data)
436
437 @client.on("claw.session.events")
438 async def on_session_events(payload: dict[str, Any]) -> None:
439 await self._handle_watch_payload(payload, "session")
440
441 @client.on("claw.panel.events")
442 async def on_panel_events(payload: dict[str, Any]) -> None:
443 await self._handle_watch_payload(payload, "panel")
444
445 for ev in ("notify:chat.inbox.append", "notify:chat.message.add",
446 "notify:chat.message.update", "notify:chat.message.recall",
447 "notify:chat.message.delete"):
448 client.on(ev, self._build_notify_handler(ev))
449
450 socket_url = (self.config.socket_url or self.config.base_url).strip().rstrip("/")
451 socket_path = (self.config.socket_path or "/socket.io").strip().lstrip("/")
452
453 try:
454 self._socket = client
455 await client.connect(
456 socket_url, transports=["websocket"], socketio_path=socket_path,
457 auth={"token": self.config.claw_token},
458 wait_timeout=max(1.0, self.config.socket_connect_timeout_ms / 1000.0),
459 )
460 return True
461 except Exception as e:
462 logger.error("Failed to connect Mochat websocket: {}", e)
463 try:
464 await client.disconnect()
465 except Exception:
466 pass
467 self._socket = None
468 return False
469
470 def _build_notify_handler(self, event_name: str):
471 async def handler(payload: Any) -> None:
472 if event_name == "notify:chat.inbox.append":
473 await self._handle_notify_inbox_append(payload)
474 elif event_name.startswith("notify:chat.message."):
475 await self._handle_notify_chat_message(payload)
476 return handler
477
478 # ---- subscribe ---------------------------------------------------------
479
480 async def _subscribe_all(self) -> bool:
481 ok = await self._subscribe_sessions(sorted(self._session_set))
482 ok = await self._subscribe_panels(sorted(self._panel_set)) and ok
483 if self._auto_discover_sessions or self._auto_discover_panels:
484 await self._refresh_targets(subscribe_new=True)
485 return ok
486
487 async def _subscribe_sessions(self, session_ids: list[str]) -> bool:
488 if not session_ids:
489 return True
490 for sid in session_ids:
491 if sid not in self._session_cursor:
492 self._cold_sessions.add(sid)
493
494 ack = await self._socket_call("com.claw.im.subscribeSessions", {
495 "sessionIds": session_ids, "cursors": self._session_cursor,
496 "limit": self.config.watch_limit,
497 })
498 if not ack.get("result"):
499 logger.error("Mochat subscribeSessions failed: {}", ack.get('message', 'unknown error'))
500 return False
501
502 data = ack.get("data")
503 items: list[dict[str, Any]] = []
504 if isinstance(data, list):
505 items = [i for i in data if isinstance(i, dict)]
506 elif isinstance(data, dict):
507 sessions = data.get("sessions")
508 if isinstance(sessions, list):
509 items = [i for i in sessions if isinstance(i, dict)]
510 elif "sessionId" in data:
511 items = [data]
512 for p in items:
513 await self._handle_watch_payload(p, "session")
514 return True
515
516 async def _subscribe_panels(self, panel_ids: list[str]) -> bool:
517 if not self._auto_discover_panels and not panel_ids:
518 return True
519 ack = await self._socket_call("com.claw.im.subscribePanels", {"panelIds": panel_ids})
520 if not ack.get("result"):
521 logger.error("Mochat subscribePanels failed: {}", ack.get('message', 'unknown error'))
522 return False
523 return True
524
525 async def _socket_call(self, event_name: str, payload: dict[str, Any]) -> dict[str, Any]:
526 if not self._socket:
527 return {"result": False, "message": "socket not connected"}
528 try:
529 raw = await self._socket.call(event_name, payload, timeout=10)
530 except Exception as e:
531 return {"result": False, "message": str(e)}
532 return raw if isinstance(raw, dict) else {"result": True, "data": raw}
533
534 # ---- refresh / discovery -----------------------------------------------
535
536 async def _refresh_loop(self) -> None:
537 interval_s = max(1.0, self.config.refresh_interval_ms / 1000.0)
538 while self._running:
539 await asyncio.sleep(interval_s)
540 try:
541 await self._refresh_targets(subscribe_new=self._ws_ready)
542 except Exception as e:
543 logger.warning("Mochat refresh failed: {}", e)
544 if self._fallback_mode:
545 await self._ensure_fallback_workers()
546
547 async def _refresh_targets(self, subscribe_new: bool) -> None:
548 if self._auto_discover_sessions:
549 await self._refresh_sessions_directory(subscribe_new)
550 if self._auto_discover_panels:
551 await self._refresh_panels(subscribe_new)
552
553 async def _refresh_sessions_directory(self, subscribe_new: bool) -> None:
554 try:
555 response = await self._post_json("/api/claw/sessions/list", {})
556 except Exception as e:
557 logger.warning("Mochat listSessions failed: {}", e)
558 return
559
560 sessions = response.get("sessions")
561 if not isinstance(sessions, list):
562 return
563
564 new_ids: list[str] = []
565 for s in sessions:
566 if not isinstance(s, dict):
567 continue
568 sid = _str_field(s, "sessionId")
569 if not sid:
570 continue
571 if sid not in self._session_set:
572 self._session_set.add(sid)
573 new_ids.append(sid)
574 if sid not in self._session_cursor:
575 self._cold_sessions.add(sid)
576 cid = _str_field(s, "converseId")
577 if cid:
578 self._session_by_converse[cid] = sid
579
580 if not new_ids:
581 return
582 if self._ws_ready and subscribe_new:
583 await self._subscribe_sessions(new_ids)
584 if self._fallback_mode:
585 await self._ensure_fallback_workers()
586
587 async def _refresh_panels(self, subscribe_new: bool) -> None:
588 try:
589 response = await self._post_json("/api/claw/groups/get", {})
590 except Exception as e:
591 logger.warning("Mochat getWorkspaceGroup failed: {}", e)
592 return
593
594 raw_panels = response.get("panels")
595 if not isinstance(raw_panels, list):
596 return
597
598 new_ids: list[str] = []
599 for p in raw_panels:
600 if not isinstance(p, dict):
601 continue
602 pt = p.get("type")
603 if isinstance(pt, int) and pt != 0:
604 continue
605 pid = _str_field(p, "id", "_id")
606 if pid and pid not in self._panel_set:
607 self._panel_set.add(pid)
608 new_ids.append(pid)
609
610 if not new_ids:
611 return
612 if self._ws_ready and subscribe_new:
613 await self._subscribe_panels(new_ids)
614 if self._fallback_mode:
615 await self._ensure_fallback_workers()
616
617 # ---- fallback workers --------------------------------------------------
618
619 async def _ensure_fallback_workers(self) -> None:
620 if not self._running:
621 return
622 self._fallback_mode = True
623 for sid in sorted(self._session_set):
624 t = self._session_fallback_tasks.get(sid)
625 if not t or t.done():
626 self._session_fallback_tasks[sid] = asyncio.create_task(self._session_watch_worker(sid))
627 for pid in sorted(self._panel_set):
628 t = self._panel_fallback_tasks.get(pid)
629 if not t or t.done():
630 self._panel_fallback_tasks[pid] = asyncio.create_task(self._panel_poll_worker(pid))
631
632 async def _stop_fallback_workers(self) -> None:
633 self._fallback_mode = False
634 tasks = [*self._session_fallback_tasks.values(), *self._panel_fallback_tasks.values()]
635 for t in tasks:
636 t.cancel()
637 if tasks:
638 await asyncio.gather(*tasks, return_exceptions=True)
639 self._session_fallback_tasks.clear()
640 self._panel_fallback_tasks.clear()
641
642 async def _session_watch_worker(self, session_id: str) -> None:
643 while self._running and self._fallback_mode:
644 try:
645 payload = await self._post_json("/api/claw/sessions/watch", {
646 "sessionId": session_id, "cursor": self._session_cursor.get(session_id, 0),
647 "timeoutMs": self.config.watch_timeout_ms, "limit": self.config.watch_limit,
648 })
649 await self._handle_watch_payload(payload, "session")
650 except asyncio.CancelledError:
651 break
652 except Exception as e:
653 logger.warning("Mochat watch fallback error ({}): {}", session_id, e)
654 await asyncio.sleep(max(0.1, self.config.retry_delay_ms / 1000.0))
655
656 async def _panel_poll_worker(self, panel_id: str) -> None:
657 sleep_s = max(1.0, self.config.refresh_interval_ms / 1000.0)
658 while self._running and self._fallback_mode:
659 try:
660 resp = await self._post_json("/api/claw/groups/panels/messages", {
661 "panelId": panel_id, "limit": min(100, max(1, self.config.watch_limit)),
662 })
663 msgs = resp.get("messages")
664 if isinstance(msgs, list):
665 for m in reversed(msgs):
666 if not isinstance(m, dict):
667 continue
668 evt = _make_synthetic_event(
669 message_id=str(m.get("messageId") or ""),
670 author=str(m.get("author") or ""),
671 content=m.get("content"),
672 meta=m.get("meta"), group_id=str(resp.get("groupId") or ""),
673 converse_id=panel_id, timestamp=m.get("createdAt"),
674 author_info=m.get("authorInfo"),
675 )
676 await self._process_inbound_event(panel_id, evt, "panel")
677 except asyncio.CancelledError:
678 break
679 except Exception as e:
680 logger.warning("Mochat panel polling error ({}): {}", panel_id, e)
681 await asyncio.sleep(sleep_s)
682
683 # ---- inbound event processing ------------------------------------------
684
685 async def _handle_watch_payload(self, payload: dict[str, Any], target_kind: str) -> None:
686 if not isinstance(payload, dict):
687 return
688 target_id = _str_field(payload, "sessionId")
689 if not target_id:
690 return
691
692 lock = self._target_locks.setdefault(f"{target_kind}:{target_id}", asyncio.Lock())
693 async with lock:
694 prev = self._session_cursor.get(target_id, 0) if target_kind == "session" else 0
695 pc = payload.get("cursor")
696 if target_kind == "session" and isinstance(pc, int) and pc >= 0:
697 self._mark_session_cursor(target_id, pc)
698
699 raw_events = payload.get("events")
700 if not isinstance(raw_events, list):
701 return
702 if target_kind == "session" and target_id in self._cold_sessions:
703 self._cold_sessions.discard(target_id)
704 return
705
706 for event in raw_events:
707 if not isinstance(event, dict):
708 continue
709 seq = event.get("seq")
710 if target_kind == "session" and isinstance(seq, int) and seq > self._session_cursor.get(target_id, prev):
711 self._mark_session_cursor(target_id, seq)
712 if event.get("type") == "message.add":
713 await self._process_inbound_event(target_id, event, target_kind)
714
715 async def _process_inbound_event(self, target_id: str, event: dict[str, Any], target_kind: str) -> None:
716 payload = event.get("payload")
717 if not isinstance(payload, dict):
718 return
719
720 author = _str_field(payload, "author")
721 if not author or (self.config.agent_user_id and author == self.config.agent_user_id):
722 return
723 if not self.is_allowed(author):
724 return
725
726 message_id = _str_field(payload, "messageId")
727 seen_key = f"{target_kind}:{target_id}"
728 if message_id and self._remember_message_id(seen_key, message_id):
729 return
730
731 raw_body = normalize_mochat_content(payload.get("content")) or "[empty message]"
732 ai = _safe_dict(payload.get("authorInfo"))
733 sender_name = _str_field(ai, "nickname", "email")
734 sender_username = _str_field(ai, "agentId")
735
736 group_id = _str_field(payload, "groupId")
737 is_group = bool(group_id)
738 was_mentioned = resolve_was_mentioned(payload, self.config.agent_user_id)
739 require_mention = target_kind == "panel" and is_group and resolve_require_mention(self.config, target_id, group_id)
740 use_delay = target_kind == "panel" and self.config.reply_delay_mode == "non-mention"
741
742 if require_mention and not was_mentioned and not use_delay:
743 return
744
745 entry = MochatBufferedEntry(
746 raw_body=raw_body, author=author, sender_name=sender_name,
747 sender_username=sender_username, timestamp=parse_timestamp(event.get("timestamp")),
748 message_id=message_id, group_id=group_id,
749 )
750
751 if use_delay:
752 delay_key = seen_key
753 if was_mentioned:
754 await self._flush_delayed_entries(delay_key, target_id, target_kind, "mention", entry)
755 else:
756 await self._enqueue_delayed_entry(delay_key, target_id, target_kind, entry)
757 return
758
759 await self._dispatch_entries(target_id, target_kind, [entry], was_mentioned)
760
761 # ---- dedup / buffering -------------------------------------------------
762
763 def _remember_message_id(self, key: str, message_id: str) -> bool:
764 seen_set = self._seen_set.setdefault(key, set())
765 seen_queue = self._seen_queue.setdefault(key, deque())
766 if message_id in seen_set:
767 return True
768 seen_set.add(message_id)
769 seen_queue.append(message_id)
770 while len(seen_queue) > MAX_SEEN_MESSAGE_IDS:
771 seen_set.discard(seen_queue.popleft())
772 return False
773
774 async def _enqueue_delayed_entry(self, key: str, target_id: str, target_kind: str, entry: MochatBufferedEntry) -> None:
775 state = self._delay_states.setdefault(key, DelayState())
776 async with state.lock:
777 state.entries.append(entry)
778 if state.timer:
779 state.timer.cancel()
780 state.timer = asyncio.create_task(self._delay_flush_after(key, target_id, target_kind))
781
782 async def _delay_flush_after(self, key: str, target_id: str, target_kind: str) -> None:
783 await asyncio.sleep(max(0, self.config.reply_delay_ms) / 1000.0)
784 await self._flush_delayed_entries(key, target_id, target_kind, "timer", None)
785
786 async def _flush_delayed_entries(self, key: str, target_id: str, target_kind: str, reason: str, entry: MochatBufferedEntry | None) -> None:
787 state = self._delay_states.setdefault(key, DelayState())
788 async with state.lock:
789 if entry:
790 state.entries.append(entry)
791 current = asyncio.current_task()
792 if state.timer and state.timer is not current:
793 state.timer.cancel()
794 state.timer = None
795 entries = state.entries[:]
796 state.entries.clear()
797 if entries:
798 await self._dispatch_entries(target_id, target_kind, entries, reason == "mention")
799
800 async def _dispatch_entries(self, target_id: str, target_kind: str, entries: list[MochatBufferedEntry], was_mentioned: bool) -> None:
801 if not entries:
802 return
803 last = entries[-1]
804 is_group = bool(last.group_id)
805 body = build_buffered_body(entries, is_group) or "[empty message]"
806 await self._handle_message(
807 sender_id=last.author, chat_id=target_id, content=body,
808 metadata={
809 "message_id": last.message_id, "timestamp": last.timestamp,
810 "is_group": is_group, "group_id": last.group_id,
811 "sender_name": last.sender_name, "sender_username": last.sender_username,
812 "target_kind": target_kind, "was_mentioned": was_mentioned,
813 "buffered_count": len(entries),
814 },
815 )
816
817 async def _cancel_delay_timers(self) -> None:
818 for state in self._delay_states.values():
819 if state.timer:
820 state.timer.cancel()
821 self._delay_states.clear()
822
823 # ---- notify handlers ---------------------------------------------------
824
825 async def _handle_notify_chat_message(self, payload: Any) -> None:
826 if not isinstance(payload, dict):
827 return
828 group_id = _str_field(payload, "groupId")
829 panel_id = _str_field(payload, "converseId", "panelId")
830 if not group_id or not panel_id:
831 return
832 if self._panel_set and panel_id not in self._panel_set:
833 return
834
835 evt = _make_synthetic_event(
836 message_id=str(payload.get("_id") or payload.get("messageId") or ""),
837 author=str(payload.get("author") or ""),
838 content=payload.get("content"), meta=payload.get("meta"),
839 group_id=group_id, converse_id=panel_id,
840 timestamp=payload.get("createdAt"), author_info=payload.get("authorInfo"),
841 )
842 await self._process_inbound_event(panel_id, evt, "panel")
843
844 async def _handle_notify_inbox_append(self, payload: Any) -> None:
845 if not isinstance(payload, dict) or payload.get("type") != "message":
846 return
847 detail = payload.get("payload")
848 if not isinstance(detail, dict):
849 return
850 if _str_field(detail, "groupId"):
851 return
852 converse_id = _str_field(detail, "converseId")
853 if not converse_id:
854 return
855
856 session_id = self._session_by_converse.get(converse_id)
857 if not session_id:
858 await self._refresh_sessions_directory(self._ws_ready)
859 session_id = self._session_by_converse.get(converse_id)
860 if not session_id:
861 return
862
863 evt = _make_synthetic_event(
864 message_id=str(detail.get("messageId") or payload.get("_id") or ""),
865 author=str(detail.get("messageAuthor") or ""),
866 content=str(detail.get("messagePlainContent") or detail.get("messageSnippet") or ""),
867 meta={"source": "notify:chat.inbox.append", "converseId": converse_id},
868 group_id="", converse_id=converse_id, timestamp=payload.get("createdAt"),
869 )
870 await self._process_inbound_event(session_id, evt, "session")
871
872 # ---- cursor persistence ------------------------------------------------
873
874 def _mark_session_cursor(self, session_id: str, cursor: int) -> None:
875 if cursor < 0 or cursor < self._session_cursor.get(session_id, 0):
876 return
877 self._session_cursor[session_id] = cursor
878 if not self._cursor_save_task or self._cursor_save_task.done():
879 self._cursor_save_task = asyncio.create_task(self._save_cursor_debounced())
880
881 async def _save_cursor_debounced(self) -> None:
882 await asyncio.sleep(CURSOR_SAVE_DEBOUNCE_S)
883 await self._save_session_cursors()
884
885 async def _load_session_cursors(self) -> None:
886 if not self._cursor_path.exists():
887 return
888 try:
889 data = json.loads(self._cursor_path.read_text("utf-8"))
890 except Exception as e:
891 logger.warning("Failed to read Mochat cursor file: {}", e)
892 return
893 cursors = data.get("cursors") if isinstance(data, dict) else None
894 if isinstance(cursors, dict):
895 for sid, cur in cursors.items():
896 if isinstance(sid, str) and isinstance(cur, int) and cur >= 0:
897 self._session_cursor[sid] = cur
898
899 async def _save_session_cursors(self) -> None:
900 try:
901 self._state_dir.mkdir(parents=True, exist_ok=True)
902 self._cursor_path.write_text(json.dumps({
903 "schemaVersion": 1, "updatedAt": datetime.utcnow().isoformat(),
904 "cursors": self._session_cursor,
905 }, ensure_ascii=False, indent=2) + "\n", "utf-8")
906 except Exception as e:
907 logger.warning("Failed to save Mochat cursor file: {}", e)
908
909 # ---- HTTP helpers ------------------------------------------------------
910
911 async def _post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
912 if not self._http:
913 raise RuntimeError("Mochat HTTP client not initialized")
914 url = f"{self.config.base_url.strip().rstrip('/')}{path}"
915 response = await self._http.post(url, headers={
916 "Content-Type": "application/json", "X-Claw-Token": self.config.claw_token,
917 }, json=payload)
918 if not response.is_success:
919 raise RuntimeError(f"Mochat HTTP {response.status_code}: {response.text[:200]}")
920 try:
921 parsed = response.json()
922 except Exception:
923 parsed = response.text
924 if isinstance(parsed, dict) and isinstance(parsed.get("code"), int):
925 if parsed["code"] != 200:
926 msg = str(parsed.get("message") or parsed.get("name") or "request failed")
927 raise RuntimeError(f"Mochat API error: {msg} (code={parsed['code']})")
928 data = parsed.get("data")
929 return data if isinstance(data, dict) else {}
930 return parsed if isinstance(parsed, dict) else {}
931
932 async def _api_send(self, path: str, id_key: str, id_val: str,
933 content: str, reply_to: str | None, group_id: str | None = None) -> dict[str, Any]:
934 """Unified send helper for session and panel messages."""
935 body: dict[str, Any] = {id_key: id_val, "content": content}
936 if reply_to:
937 body["replyTo"] = reply_to
938 if group_id:
939 body["groupId"] = group_id
940 return await self._post_json(path, body)
941
942 @staticmethod
943 def _read_group_id(metadata: dict[str, Any]) -> str | None:
944 if not isinstance(metadata, dict):
945 return None
946 value = metadata.get("group_id") or metadata.get("groupId")
947 return value.strip() if isinstance(value, str) and value.strip() else None
948
948 lines PYTHON