napcat-cli 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. napcat_cli/__init__.py +3 -0
  2. napcat_cli/cli.py +1834 -0
  3. napcat_cli/daemon/__init__.py +1 -0
  4. napcat_cli/daemon/schemas.py +470 -0
  5. napcat_cli/daemon/watch.py +1994 -0
  6. napcat_cli/data/SKILL.md +328 -0
  7. napcat_cli/data/__init__.py +0 -0
  8. napcat_cli/data/persona.md +155 -0
  9. napcat_cli/data/references/mounting.md +90 -0
  10. napcat_cli/data/skills-fs-config.json +12 -0
  11. napcat_cli/data/skills-fs-fragment.json +473 -0
  12. napcat_cli/data/skills-fs.d/agents-friend-time.md +7 -0
  13. napcat_cli/data/skills-fs.d/agents-friend.md +7 -0
  14. napcat_cli/data/skills-fs.d/agents-friends.md +4 -0
  15. napcat_cli/data/skills-fs.d/agents-group-time.md +7 -0
  16. napcat_cli/data/skills-fs.d/agents-group.md +6 -0
  17. napcat_cli/data/skills-fs.d/agents-groups.md +5 -0
  18. napcat_cli/data/skills-fs.d/agents-napcat.md +9 -0
  19. napcat_cli/data/skills-fs.d/persona.md +155 -0
  20. napcat_cli/data/skills-fs.d/skill-agents.md +42 -0
  21. napcat_cli/data/skills-fs.d/skill-body.md +102 -0
  22. napcat_cli/lib/__init__.py +1 -0
  23. napcat_cli/lib/api.py +258 -0
  24. napcat_cli/lib/config.py +105 -0
  25. napcat_cli/lib/events.py +127 -0
  26. napcat_cli/lib/events_sqlite.py +242 -0
  27. napcat_cli/lib/message.py +156 -0
  28. napcat_cli/setup_wizard.py +380 -0
  29. napcat_cli/tui/__init__.py +1 -0
  30. napcat_cli/tui/__main__.py +13 -0
  31. napcat_cli/tui/api.py +158 -0
  32. napcat_cli/tui/app.py +127 -0
  33. napcat_cli/tui/chat_list.py +168 -0
  34. napcat_cli/tui/chat_view.py +456 -0
  35. napcat_cli/tui/styles.tcss +9 -0
  36. napcat_cli/wake.py +51 -0
  37. napcat_cli/wake_backend.py +390 -0
  38. napcat_cli/wake_orchestrator.py +302 -0
  39. napcat_cli/wake_presets.py +77 -0
  40. napcat_cli-2.0.0.dist-info/METADATA +219 -0
  41. napcat_cli-2.0.0.dist-info/RECORD +43 -0
  42. napcat_cli-2.0.0.dist-info/WHEEL +4 -0
  43. napcat_cli-2.0.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,1994 @@
1
+ #!/usr/bin/env python3
2
+ """NapCat Watch Daemon — NapCat WS listener + skills-fs HTTP provider.
3
+
4
+ Connects to NapCat's WebSocket server to receive real-time events,
5
+ writes them to the filesystem bridge, and generates alert files.
6
+
7
+ Also runs an HTTP server implementing the skills-fs HTTP provider contract,
8
+ so skills-fs can query events and proxy NapCat API calls.
9
+
10
+ Alert files generated:
11
+ - NAPCAT_CLI_NEW_MESSAGE: Any new message received
12
+ - NAPCAT_CLI_AT_ME: Bot was @mentioned
13
+ - NAPCAT_CLI_REPLY_TO_ME: Reply to bot's message
14
+ - NAPCAT_CLI_NEW_POKE: Poke received
15
+ - NAPCAT_CLI_NEW_REQUEST: Friend/group join request
16
+ - NAPCAT_CLI_NEED_WAKE_UP: Composite alert - agent should check
17
+
18
+ The daemon connects via WebSocket to the NapCat server.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import json
24
+ import logging
25
+ import os
26
+ import signal
27
+ import sys
28
+ import time
29
+ import threading
30
+ from http.server import HTTPServer, BaseHTTPRequestHandler
31
+ from logging.handlers import RotatingFileHandler
32
+ from pathlib import Path
33
+ from typing import Any
34
+ from urllib.parse import parse_qs, urlparse
35
+
36
+ # Allow running from any directory
37
+ ROOT = Path(__file__).resolve().parent.parent
38
+ sys.path.insert(0, str(ROOT))
39
+
40
+ from napcat_cli.lib.config import DATA_DIR, get_config
41
+ from napcat_cli.lib.events import EventsWriter, EventsReader
42
+
43
+ from napcat_cli.daemon.schemas import ACTION_SCHEMAS
44
+ from napcat_cli.wake_presets import build_waker
45
+ from napcat_cli.wake_orchestrator import WakeOrchestrator
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Event Processor (alert generation)
50
+ # ---------------------------------------------------------------------------
51
+
52
+ def _make_rotating_logger(path: Path, max_bytes: int = 2_000_000, backup_count: int = 5) -> logging.Logger:
53
+ """A per-data-dir logger writing to ``path`` with size-based rotation.
54
+
55
+ Keeps daemon.log bounded: at ~2 MB it rolls to daemon.log.1 .. daemon.log.5
56
+ (olest deleted), so it can never fill the disk. Idempotent — re-creating it
57
+ for the same path does not stack duplicate handlers.
58
+ """
59
+ logger = logging.getLogger(f"napcat.daemon.{path.parent.name}")
60
+ logger.setLevel(logging.INFO)
61
+ logger.propagate = False
62
+ if not any(getattr(h, "_napcat_marker", False) for h in logger.handlers):
63
+ try:
64
+ path.parent.mkdir(parents=True, exist_ok=True)
65
+ h = RotatingFileHandler(path, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8")
66
+ h._napcat_marker = True # type: ignore[attr-defined]
67
+ h.setFormatter(logging.Formatter("[%(asctime)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S"))
68
+ logger.addHandler(h)
69
+ except Exception:
70
+ pass
71
+ return logger
72
+
73
+
74
+ _TIMEOUT = object() # sentinel returned by _run_with_timeout on timeout
75
+
76
+
77
+ def _run_with_timeout(fn, timeout: float, *args):
78
+ """Run ``fn(*args)`` in a daemon thread, returning its result.
79
+
80
+ If it doesn't finish within ``timeout`` seconds, returns ``_TIMEOUT`` and
81
+ abandons the thread. This is the D-state prevention primitive: a hung FUSE
82
+ syscall (status read / stat) only blocks the abandoned thread, never the
83
+ daemon's main thread or asyncio loop, so the process can't get wedged.
84
+ """
85
+ box: dict = {"done": False, "result": None}
86
+
87
+ def _runner():
88
+ try:
89
+ box["result"] = fn(*args)
90
+ except Exception as e: # a raised exception => not healthy
91
+ box["result"] = e
92
+ box["done"] = True
93
+
94
+ t = threading.Thread(target=_runner, daemon=True)
95
+ t.start()
96
+ t.join(timeout)
97
+ return box["result"] if box["done"] else _TIMEOUT
98
+
99
+
100
+ class EventProcessor:
101
+ """Process events and generate alerts."""
102
+
103
+ def __init__(self, data_dir: Path, self_id: str = "", wake_command: str = "", wake_on_event: bool = True, *, group_trigger_word: str = "", private_trigger: str = "",
104
+ waker=None, orchestrator=None,
105
+ debounce_seconds: float = 3.0, cooldown_seconds: float = 30.0,
106
+ new_message_idle_seconds: int = 600):
107
+ self.writer = EventsWriter(data_dir)
108
+ self.self_id = self_id
109
+ self.wake_command = wake_command
110
+ self.wake_on_event = wake_on_event
111
+ self.group_trigger_word = group_trigger_word
112
+ self.private_trigger = private_trigger
113
+ self.log_file = data_dir / "daemon.log"
114
+ self._logger = _make_rotating_logger(self.log_file)
115
+ # Wake orchestrator (built by run_daemon from config). When present it
116
+ # owns debounce/cooldown/backlog + the Waker (http->cli auto-fallback).
117
+ # When absent, _wake falls back to the legacy wake_command shell string.
118
+ if orchestrator is not None:
119
+ self.orchestrator = orchestrator
120
+ elif waker is not None:
121
+ self.orchestrator = WakeOrchestrator(
122
+ waker, log=self.log,
123
+ debounce_seconds=debounce_seconds, cooldown_seconds=cooldown_seconds,
124
+ new_message_idle_seconds=new_message_idle_seconds,
125
+ legacy_command=wake_command)
126
+ else:
127
+ self.orchestrator = None
128
+
129
+ def log(self, msg: str) -> None:
130
+ try:
131
+ self._logger.info(msg)
132
+ except Exception:
133
+ pass
134
+
135
+ def process(self, event: dict) -> None:
136
+ row_id = self.writer.write_event(event)
137
+ self.log(f"Event: row_id={row_id}")
138
+ post_type = event.get("post_type", "")
139
+ if post_type == "message":
140
+ self._handle_message(event)
141
+ elif post_type == "notice":
142
+ self._handle_notice(event)
143
+ elif post_type == "request":
144
+ self._handle_request(event)
145
+ elif post_type == "meta_event":
146
+ self._handle_meta(event)
147
+
148
+ def _handle_message(self, event: dict) -> None:
149
+ msg_type = event.get("message_type", "")
150
+ sender = event.get("sender", {})
151
+ sender_id = str(sender.get("user_id", ""))
152
+ nickname = sender.get("nickname", "")
153
+ raw_msg = event.get("raw_message", event.get("message", ""))
154
+ msg_id = str(event.get("message_id", ""))
155
+ group_id = event.get("group_id", "")
156
+
157
+ self.writer.write_alert("NAPCAT_CLI_NEW_MESSAGE", {
158
+ "summary": f"{nickname}({sender_id}): {str(raw_msg)[:50]}",
159
+ "sender_id": sender_id,
160
+ "nickname": nickname,
161
+ "message_type": msg_type,
162
+ "group_id": str(group_id) if group_id else "",
163
+ "message_id": msg_id,
164
+ "raw_message": str(raw_msg),
165
+ "time": event.get("time", 0),
166
+ })
167
+ # Track unread messages for the backlog sweep (NEW_MESSAGE itself does
168
+ # not wake; the sweep wakes the agent if they pile up unread).
169
+ if self.orchestrator is not None:
170
+ self.orchestrator.note_new_message(event.get("time") or time.time())
171
+
172
+ if self.self_id:
173
+ raw_str = str(raw_msg)
174
+ if f"[CQ:at,qq={self.self_id}]" in raw_str:
175
+ self.writer.write_alert("NAPCAT_CLI_AT_ME", {
176
+ "summary": f"@mentioned by {nickname} in {'group ' + str(group_id) if group_id else 'DM'}",
177
+ "sender_id": sender_id,
178
+ "group_id": str(group_id) if group_id else "",
179
+ "message_id": msg_id,
180
+ })
181
+ self._wake("AT_ME", event)
182
+
183
+ msg_segments = event.get("message", [])
184
+ if isinstance(msg_segments, list):
185
+ for seg in msg_segments:
186
+ if isinstance(seg, dict) and seg.get("type") == "reply":
187
+ data = seg.get("data", {})
188
+ if isinstance(data, str):
189
+ try:
190
+ import urllib.parse
191
+ data = json.loads(urllib.parse.unquote(data))
192
+ except Exception:
193
+ pass
194
+ if isinstance(data, dict) and str(data.get("id", "")) == msg_id:
195
+ self.writer.write_alert("NAPCAT_CLI_REPLY_TO_ME", {
196
+ "summary": f"Reply from {nickname}",
197
+ "sender_id": sender_id,
198
+ "group_id": str(group_id) if group_id else "",
199
+ "message_id": msg_id,
200
+ })
201
+ self._wake("REPLY_TO_ME", event)
202
+ break
203
+
204
+ # --- Trigger word detection on plain text (no CQ codes) ---
205
+ segs = event.get("message", [])
206
+ plain_text = "".join(
207
+ (seg.get("data") or {}).get("text", "")
208
+ for seg in (segs if isinstance(segs, list) else [])
209
+ if isinstance(seg, dict) and seg.get("type") == "text"
210
+ )
211
+ if msg_type == "group" and self.group_trigger_word and self.group_trigger_word in plain_text:
212
+ self._wake("GROUP_TRIGGER", event)
213
+ elif msg_type == "private":
214
+ if self.private_trigger == "*" or (self.private_trigger and self.private_trigger in plain_text):
215
+ self._wake("PRIVATE_TRIGGER", event)
216
+
217
+ def _handle_notice(self, event: dict) -> None:
218
+ notice_type = event.get("notice_type", "")
219
+ sub_type = event.get("sub_type", "")
220
+
221
+ # --- notify events (poke, profile_like, lucky_king) ---
222
+ if notice_type == "notify":
223
+ self._handle_notify(event, sub_type)
224
+ return
225
+
226
+ # --- group events ---
227
+ if notice_type == "group_admin":
228
+ self._handle_group_admin(event)
229
+ return
230
+ if notice_type == "group_ban":
231
+ self._handle_group_ban(event)
232
+ return
233
+ if notice_type == "group_decrease":
234
+ self._handle_group_decrease(event, sub_type)
235
+ return
236
+ if notice_type == "group_increase":
237
+ self._handle_group_increase(event, sub_type)
238
+ return
239
+ if notice_type == "group_upload":
240
+ self._handle_group_upload(event)
241
+ return
242
+ if notice_type == "group_recall":
243
+ self._handle_group_recall(event)
244
+ return
245
+ if notice_type == "group_card":
246
+ self._handle_group_card(event)
247
+ return
248
+ if notice_type == "group_msg_emoji_like":
249
+ self._handle_group_emoji_like(event)
250
+ return
251
+
252
+ # --- friend events ---
253
+ if notice_type == "friend_add":
254
+ self._handle_friend_add(event)
255
+ return
256
+ if notice_type == "friend_recall":
257
+ self._handle_friend_recall(event)
258
+ return
259
+
260
+ # --- catch-all for unknown notice types ---
261
+ self.writer.write_alert("NAPCAT_CLI_NOTICE", {
262
+ "summary": f"Notice: {notice_type}/{sub_type}",
263
+ "notice_type": notice_type,
264
+ "sub_type": sub_type,
265
+ })
266
+
267
+ # ---- notify sub-handlers ----
268
+
269
+ def _handle_notify(self, event: dict, sub_type: str) -> None:
270
+ if sub_type == "poke":
271
+ sender_id = str(event.get("user_id", ""))
272
+ group_id = event.get("group_id", "")
273
+ target_id = str(event.get("target_id", ""))
274
+ self.writer.write_alert("NAPCAT_CLI_NEW_POKE", {
275
+ "summary": f"Poke from {sender_id}{' in group ' + str(group_id) if group_id else ''}",
276
+ "sender_id": sender_id,
277
+ "target_id": target_id,
278
+ "group_id": str(group_id) if group_id else "",
279
+ })
280
+ # Wake if bot was poked
281
+ if self.self_id and target_id == self.self_id:
282
+ self._wake("NEW_POKE", event)
283
+ elif sub_type == "lucky_king":
284
+ self.writer.write_alert("NAPCAT_CLI_NEW_POKE", {
285
+ "summary": "Lucky king (red packet)",
286
+ "group_id": str(event.get("group_id", "")),
287
+ "user_id": str(event.get("user_id", "")),
288
+ })
289
+ elif sub_type == "profile_like":
290
+ operator_id = str(event.get("operator_id", ""))
291
+ operator_nick = event.get("operator_nick", "")
292
+ times = event.get("times", 0)
293
+ self.writer.write_alert("NAPCAT_CLI_NEW_POKE", {
294
+ "summary": f"{operator_nick}({operator_id}) liked profile {times} times",
295
+ "operator_id": operator_id,
296
+ "operator_nick": operator_nick,
297
+ "times": times,
298
+ "sub_type": "profile_like",
299
+ })
300
+ self._wake("PROFILE_LIKE", event)
301
+
302
+ # ---- group sub-handlers ----
303
+
304
+ def _handle_group_admin(self, event: dict) -> None:
305
+ sub = event.get("sub_type", "")
306
+ user_id = str(event.get("user_id", ""))
307
+ group_id = str(event.get("group_id", ""))
308
+ action = "promoted" if sub == "set" else "demoted"
309
+ self.writer.write_alert("NAPCAT_CLI_NOTICE", {
310
+ "summary": f"Admin {action}: {user_id} in group {group_id}",
311
+ "notice_type": "group_admin",
312
+ "sub_type": sub,
313
+ "user_id": user_id,
314
+ "group_id": group_id,
315
+ })
316
+ # Wake if bot's admin status changed
317
+ if self.self_id and user_id == self.self_id:
318
+ self._wake("GROUP_ADMIN_CHANGE", event)
319
+
320
+ def _handle_group_ban(self, event: dict) -> None:
321
+ sub = event.get("sub_type", "")
322
+ user_id = str(event.get("user_id", ""))
323
+ group_id = str(event.get("group_id", ""))
324
+ operator_id = str(event.get("operator_id", ""))
325
+ duration = event.get("duration", 0)
326
+ action = "banned" if sub == "ban" else "unbanned"
327
+ self.writer.write_alert("NAPCAT_CLI_NOTICE", {
328
+ "summary": f"{user_id} {action} in group {group_id} by {operator_id} ({duration}s)",
329
+ "notice_type": "group_ban",
330
+ "sub_type": sub,
331
+ "user_id": user_id,
332
+ "group_id": group_id,
333
+ "operator_id": operator_id,
334
+ "duration": duration,
335
+ })
336
+ # Wake if bot was banned
337
+ if self.self_id and user_id == self.self_id and sub == "ban":
338
+ self._wake("BOT_BANNED", event)
339
+
340
+ def _handle_group_decrease(self, event: dict, sub_type: str) -> None:
341
+ user_id = str(event.get("user_id", ""))
342
+ group_id = str(event.get("group_id", ""))
343
+ operator_id = str(event.get("operator_id", ""))
344
+ self.writer.write_alert("NAPCAT_CLI_NOTICE", {
345
+ "summary": f"Member {user_id} left group {group_id} ({sub_type}) by {operator_id}",
346
+ "notice_type": "group_decrease",
347
+ "sub_type": sub_type,
348
+ "user_id": user_id,
349
+ "group_id": group_id,
350
+ "operator_id": operator_id,
351
+ })
352
+ # Wake if bot was kicked
353
+ if sub_type == "kick_me" and self.self_id:
354
+ self._wake("BOT_KICKED_FROM_GROUP", event)
355
+ elif sub_type == "disband":
356
+ self._wake("GROUP_DISBANDED", event)
357
+
358
+ def _handle_group_increase(self, event: dict, sub_type: str) -> None:
359
+ user_id = str(event.get("user_id", ""))
360
+ group_id = str(event.get("group_id", ""))
361
+ operator_id = str(event.get("operator_id", ""))
362
+ self.writer.write_alert("NAPCAT_CLI_NOTICE", {
363
+ "summary": f"New member {user_id} joined group {group_id} ({sub_type}) by {operator_id}",
364
+ "notice_type": "group_increase",
365
+ "sub_type": sub_type,
366
+ "user_id": user_id,
367
+ "group_id": group_id,
368
+ "operator_id": operator_id,
369
+ })
370
+ self._wake("NEW_GROUP_MEMBER", event)
371
+
372
+ def _handle_group_upload(self, event: dict) -> None:
373
+ file_info = event.get("file", {})
374
+ self.writer.write_alert("NAPCAT_CLI_NOTICE", {
375
+ "summary": f"File uploaded by {event.get('user_id')} in group {event.get('group_id')}: {file_info.get('name', '?')}",
376
+ "notice_type": "group_upload",
377
+ "group_id": str(event.get("group_id", "")),
378
+ "user_id": str(event.get("user_id", "")),
379
+ "file_name": file_info.get("name", ""),
380
+ "file_id": file_info.get("id", ""),
381
+ "file_size": file_info.get("size", 0),
382
+ })
383
+
384
+ def _handle_group_recall(self, event: dict) -> None:
385
+ user_id = str(event.get("user_id", ""))
386
+ operator_id = str(event.get("operator_id", ""))
387
+ group_id = str(event.get("group_id", ""))
388
+ msg_id = str(event.get("message_id", ""))
389
+ self.writer.write_alert("NAPCAT_CLI_NOTICE", {
390
+ "summary": f"Message {msg_id} recalled in group {group_id} by {operator_id} (sender: {user_id})",
391
+ "notice_type": "group_recall",
392
+ "group_id": group_id,
393
+ "user_id": user_id,
394
+ "operator_id": operator_id,
395
+ "message_id": msg_id,
396
+ })
397
+ # Wake if bot's own message was recalled
398
+ if self.self_id and user_id == self.self_id:
399
+ self._wake("MY_MESSAGE_RECALLED", event)
400
+
401
+ def _handle_group_card(self, event: dict) -> None:
402
+ user_id = str(event.get("user_id", ""))
403
+ group_id = str(event.get("group_id", ""))
404
+ card_new = event.get("card_new", "")
405
+ card_old = event.get("card_old", "")
406
+ self.writer.write_alert("NAPCAT_CLI_NOTICE", {
407
+ "summary": f"Card changed for {user_id} in group {group_id}: '{card_old}' → '{card_new}'",
408
+ "notice_type": "group_card",
409
+ "group_id": group_id,
410
+ "user_id": user_id,
411
+ "card_old": card_old,
412
+ "card_new": card_new,
413
+ })
414
+
415
+ def _handle_group_emoji_like(self, event: dict) -> None:
416
+ user_id = str(event.get("user_id", ""))
417
+ group_id = str(event.get("group_id", ""))
418
+ msg_id = str(event.get("message_id", ""))
419
+ likes = event.get("likes", [])
420
+ emoji_ids = [l.get("emoji_id", "") for l in likes]
421
+ is_add = event.get("is_add", True)
422
+ action = "reacted" if is_add else "removed reaction from"
423
+ self.writer.write_alert("NAPCAT_CLI_NOTICE", {
424
+ "summary": f"{user_id} {action} message {msg_id} with {emoji_ids}",
425
+ "notice_type": "group_msg_emoji_like",
426
+ "group_id": group_id,
427
+ "user_id": user_id,
428
+ "message_id": msg_id,
429
+ "likes": likes,
430
+ "is_add": is_add,
431
+ })
432
+
433
+ # ---- friend sub-handlers ----
434
+
435
+ def _handle_friend_add(self, event: dict) -> None:
436
+ user_id = str(event.get("user_id", ""))
437
+ self.writer.write_alert("NAPCAT_CLI_NEW_REQUEST", {
438
+ "summary": f"New friend added: {user_id}",
439
+ "notice_type": "friend_add",
440
+ "user_id": user_id,
441
+ })
442
+ self._wake("NEW_FRIEND", event)
443
+
444
+ def _handle_friend_recall(self, event: dict) -> None:
445
+ user_id = str(event.get("user_id", ""))
446
+ msg_id = str(event.get("message_id", ""))
447
+ self.writer.write_alert("NAPCAT_CLI_NOTICE", {
448
+ "summary": f"Friend {user_id} recalled message {msg_id}",
449
+ "notice_type": "friend_recall",
450
+ "user_id": user_id,
451
+ "message_id": msg_id,
452
+ })
453
+
454
+ def _handle_request(self, event: dict) -> None:
455
+ req_type = event.get("request_type", "")
456
+ sub_type = event.get("sub_type", "")
457
+ flag = event.get("flag", "")
458
+ user_id = str(event.get("user_id", ""))
459
+ comment = event.get("comment", "")
460
+ alert_data: dict[str, Any] = {
461
+ "summary": f"{req_type} request from {user_id}: {comment[:30]}",
462
+ "request_type": req_type,
463
+ "sub_type": sub_type,
464
+ "user_id": user_id,
465
+ "flag": flag,
466
+ "comment": comment,
467
+ }
468
+ if req_type == "group":
469
+ alert_data["group_id"] = str(event.get("group_id", ""))
470
+ alert_data["summary"] = f"{req_type} {sub_type} request from {user_id} to group {event.get('group_id', '?')}: {comment[:30]}"
471
+ self.writer.write_alert("NAPCAT_CLI_NEW_REQUEST", alert_data)
472
+ self._wake("NEW_REQUEST", event)
473
+
474
+ def _handle_meta(self, event: dict) -> None:
475
+ sub_type = event.get("sub_type", "")
476
+ if sub_type == "lifespan":
477
+ status = event.get("status", "")
478
+ self.log(f"Connection status: {status}")
479
+ if status in ("down", "offline"):
480
+ self.writer.write_alert("NAPCAT_CLI_NOTICE", {
481
+ "summary": "Bot connection lost",
482
+ "meta_type": "lifespan",
483
+ "status": status,
484
+ })
485
+ self._wake("BOT_OFFLINE", event)
486
+ elif sub_type == "heartbeat":
487
+ interval = event.get("interval", 0)
488
+ self.log(f"Heartbeat ({interval}s)")
489
+
490
+ @staticmethod
491
+ def _event_brief(event: dict | None) -> str:
492
+ """One-line, grep-friendly summary of the triggering event for logs."""
493
+ if not event:
494
+ return "(no event)"
495
+ sender = event.get("sender") if isinstance(event.get("sender"), dict) else {}
496
+ who = sender.get("nickname") or event.get("user_id") or "?"
497
+ g = event.get("group_id")
498
+ where = f"group{g}" if g else "dm"
499
+ msg = event.get("message") if event.get("message") is not None else event.get("raw_message", "")
500
+ if isinstance(msg, list):
501
+ text = "".join(
502
+ (s.get("data") or {}).get("text", "")
503
+ for s in msg if isinstance(s, dict) and s.get("type") == "text"
504
+ )
505
+ else:
506
+ text = str(msg)
507
+ return f"who={who} where={where} text={text[:40]!r}"
508
+
509
+ def _wake(self, reason: str, event: dict | None = None) -> None:
510
+ if not self.wake_on_event:
511
+ self.log(f"[WAKE] disabled, skip reason={reason}")
512
+ return
513
+
514
+ self.log(f"[WAKE] trigger reason={reason} {self._event_brief(event)}")
515
+ self.writer.write_alert("NAPCAT_CLI_NEED_WAKE_UP", {
516
+ "summary": f"Wake up needed: {reason}",
517
+ "reason": reason,
518
+ "timestamp": int(time.time()),
519
+ })
520
+
521
+ # Preferred path: hand to the orchestrator (debounce/cooldown/backlog +
522
+ # Waker with http->cli auto-fallback; also owns the legacy_command escape
523
+ # hatch when no backend is configured).
524
+ if self.orchestrator is not None:
525
+ self.orchestrator.submit(reason, event)
526
+ return
527
+
528
+ # Legacy path: run wake_command as a shell string (back-compat).
529
+ if self.wake_command:
530
+ self.log(f"Executing wake command: {self.wake_command}")
531
+ try:
532
+ import subprocess
533
+ from napcat_cli.wake import build_wake_command
534
+ cmd = build_wake_command(self.wake_command, reason)
535
+ subprocess.run(cmd, shell=True, check=True, timeout=30,
536
+ capture_output=True, text=True)
537
+ except Exception as e:
538
+ self.log(f"Wake command failed: {e}")
539
+
540
+
541
+ # ---------------------------------------------------------------------------
542
+ # In-memory event cache (for HTTP provider reads)
543
+ # ---------------------------------------------------------------------------
544
+
545
+ class EventCache:
546
+ """Thread-safe in-memory cache of recent events."""
547
+
548
+ def __init__(self, data_dir: Path, max_events: int = 500):
549
+ self.data_dir = data_dir
550
+ self.max_events = max_events
551
+ self._lock = threading.Lock()
552
+ self._events: list[dict[str, Any]] = []
553
+
554
+ def add(self, event: dict[str, Any]) -> None:
555
+ with self._lock:
556
+ self._events.insert(0, event)
557
+ if len(self._events) > self.max_events:
558
+ self._events = self._events[: self.max_events]
559
+
560
+ def get(self, limit: int = 50, since: float = 0.0) -> list[dict[str, Any]]:
561
+ with self._lock:
562
+ events = list(self._events)
563
+ if since:
564
+ events = [e for e in events if e.get("time", 0) >= since]
565
+ return events[:limit]
566
+
567
+
568
+ # ---------------------------------------------------------------------------
569
+ # WebSocket listener
570
+ # ---------------------------------------------------------------------------
571
+
572
+ async def ws_daemon(ws_url: str, processor: EventProcessor, cache: EventCache) -> None:
573
+ """WebSocket daemon - connects to NapCat WS server."""
574
+ retry_delay = 5
575
+
576
+ while True:
577
+ try:
578
+ processor.log(f"Connecting to WebSocket: {ws_url}")
579
+
580
+ try:
581
+ import aiohttp
582
+ session = aiohttp.ClientSession()
583
+
584
+ async with session.ws_connect(ws_url, heartbeat=30) as ws:
585
+ processor.log(f"Connected to {ws_url}")
586
+
587
+ token = os.environ.get("NAPCAT_TOKEN", "")
588
+ if token:
589
+ await ws.send_json({
590
+ "time": int(time.time()),
591
+ "post_type": "connect",
592
+ "token": token,
593
+ })
594
+
595
+ async for msg in ws:
596
+ if msg.type == aiohttp.WSMsgType.TEXT:
597
+ try:
598
+ event = json.loads(msg.data)
599
+ processor.process(event)
600
+ cache.add(event)
601
+ except json.JSONDecodeError:
602
+ processor.log(f"Invalid JSON: {msg.data[:100]}")
603
+ elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
604
+ break
605
+
606
+ await session.close()
607
+ retry_delay = 5
608
+
609
+ except ImportError:
610
+ from websockets.connect import connect
611
+ async with connect(ws_url, ping_interval=30) as ws:
612
+ processor.log(f"Connected to {ws_url}")
613
+ retry_delay = 5
614
+
615
+ async for message in ws:
616
+ try:
617
+ event = json.loads(message)
618
+ processor.process(event)
619
+ cache.add(event)
620
+ except json.JSONDecodeError:
621
+ processor.log(f"Invalid JSON: {str(message)[:100]}")
622
+
623
+ except Exception as e:
624
+ processor.log(f"Connection error: {e}")
625
+
626
+ await asyncio.sleep(retry_delay)
627
+ retry_delay = min(retry_delay * 2, 60)
628
+
629
+
630
+ # ---------------------------------------------------------------------------
631
+ # Skills-fs FUSE Manager — spawn, monitor, restart on crash
632
+ # ---------------------------------------------------------------------------
633
+
634
+ # Default mountpoint is the napcat-cli skill directory itself, so skills-fs
635
+ # FUSE-overlays it: mounted => generated SKILL.md/AGENTS.md + the /napcat/ tree
636
+ # take over; unmounted => the static skill files (pre-mount) reappear.
637
+ _DEFAULT_MOUNTPOINT = str(Path.home() / ".hermes" / "skills" / "napcat-cli")
638
+ _DEFAULT_SKILLSFS_CONFIG = str(Path.home() / ".napcat-data" / "skills-fs.json")
639
+ def _resolve_shipped_binary() -> str:
640
+ """Return the shipped skills-fs binary path for source-tree dev mode, or '' if absent."""
641
+ shipped = Path(__file__).resolve().parents[2] / "skills-fs" / "skills-fs"
642
+ return str(shipped) if shipped.exists() and shipped.is_file() else ""
643
+
644
+
645
+ class SkillsFsManager:
646
+ """Spawn skills-fs FUSE daemon and monitor its health.
647
+
648
+ Lifecycle:
649
+ 1. After HTTP provider starts, spawn skills-fs with --daemon --pidfile.
650
+ 2. Read PID from pidfile and monitor with os.kill(pid, 0).
651
+ 3. If process dies, write degraded status file, restart with backoff.
652
+ 4. On shutdown, kill the child and unmount.
653
+ """
654
+
655
+ def __init__(
656
+ self,
657
+ processor: EventProcessor,
658
+ mountpoint: str = _DEFAULT_MOUNTPOINT,
659
+ binary: str = "",
660
+ config: str = _DEFAULT_SKILLSFS_CONFIG,
661
+ pidfile: str = "",
662
+ ):
663
+ self.processor = processor
664
+ self.mountpoint = mountpoint
665
+ self.pidfile = pidfile or (DATA_DIR / "skills-fs.pid").as_posix()
666
+ self.config = config
667
+
668
+ # Resolve binary: config > shipped > PATH
669
+ self.binary = binary
670
+ if not self.binary:
671
+ # Try shipped binary next to this repo
672
+ shipped = Path(_resolve_shipped_binary())
673
+ if shipped.exists() and shipped.is_file():
674
+ self.binary = str(shipped)
675
+ else:
676
+ # Search PATH
677
+ import shutil
678
+ found = shutil.which("skills-fs")
679
+ if found:
680
+ self.binary = found
681
+ self._pid: int | None = None
682
+ self._status: str = "stopped" # healthy | degraded | stopped
683
+ self._max_restarts = 3
684
+ self._restart_count = 0
685
+ self._stale_cleaned = False
686
+
687
+ @property
688
+ def status(self) -> str:
689
+ return self._status
690
+
691
+ def start(self) -> bool:
692
+ """Spawn skills-fs FUSE daemon. Returns True if mount succeeded."""
693
+ if not self.binary:
694
+ self.processor.log("skills-fs: no binary found, skipping mount")
695
+ self._status = "degraded"
696
+ self._write_degraded("skills-fs binary not found")
697
+ return False
698
+
699
+ # Reuse an already-healthy mount instead of stacking a second FUSE daemon
700
+ # (multiple skills-fs on one mountpoint is what deadlocks into D-state).
701
+ if self._existing_mount_healthy():
702
+ self._status = "healthy"
703
+ self._restart_count = 0
704
+ self.processor.log(f"skills-fs: reusing existing healthy mount at {self.mountpoint}")
705
+ return True
706
+
707
+ # Clean stale mount before starting
708
+ if not self._stale_cleaned:
709
+ self._clean_stale_mount()
710
+ self._stale_cleaned = True
711
+
712
+ args = [
713
+ self.binary, "fuse",
714
+ "--config", self.config,
715
+ "--mountpoint", self.mountpoint,
716
+ "--allow-other",
717
+ "--pidfile", self.pidfile,
718
+ "--log-file", str(DATA_DIR / "skills-fs.log"),
719
+ "--log-level", "info",
720
+ "--daemon",
721
+ ]
722
+ try:
723
+ self.processor.log(f"skills-fs: spawning {args}")
724
+ import subprocess
725
+ subprocess.run(args, check=True, timeout=10)
726
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e:
727
+ self.processor.log(f"skills-fs: spawn failed: {e}")
728
+ self._status = "degraded"
729
+ self._write_degraded(f"spawn failed: {e}")
730
+ return False
731
+
732
+ # Read PID from pidfile (daemon mode writes it)
733
+ self._wait_for_pid(max_wait=5)
734
+ if self._pid is None:
735
+ self.processor.log("skills-fs: pidfile not written in time")
736
+ self._status = "degraded"
737
+ self._write_degraded("pid not available")
738
+ return False
739
+
740
+ # Verify mount is accessible (timeout-guarded). If the mount isn't healthy
741
+ # within the deadline, kill the child so we don't leave a half-dead FUSE
742
+ # daemon that would deadlock readers into D-state.
743
+ if not self._verify_mount():
744
+ self.processor.log("skills-fs: mount not healthy after spawn — killing child, going degraded")
745
+ self._kill_child()
746
+ self._status = "degraded"
747
+ self._write_degraded("mount verification failed or timed out")
748
+ return False
749
+
750
+ self._status = "healthy"
751
+ self._restart_count = 0
752
+ self.processor.log(f"skills-fs: mounted at {self.mountpoint} (PID {self._pid})")
753
+ return True
754
+
755
+ def check(self) -> bool:
756
+ """Check if the FUSE process is still alive. Returns True if healthy."""
757
+ if self._pid is None:
758
+ self._status = "stopped"
759
+ return False
760
+
761
+ try:
762
+ os.kill(self._pid, 0)
763
+ except OSError:
764
+ # Process is dead
765
+ self._status = "degraded"
766
+ self._write_degraded(f"skills-fs process {self._pid} died")
767
+ self._pid = None
768
+ self._stale_cleaned = False # allow re-clean on restart
769
+ return False
770
+
771
+ # Also verify the mount is still accessible
772
+ if not self._verify_mount():
773
+ self._status = "degraded"
774
+ self._write_degraded("mount no longer accessible")
775
+ return False
776
+
777
+ self._status = "healthy"
778
+ return True
779
+
780
+ def restart(self) -> bool:
781
+ """Try to restart skills-fs after a crash."""
782
+ self._restart_count += 1
783
+ if self._restart_count > self._max_restarts:
784
+ self._status = "degraded"
785
+ self._write_degraded(
786
+ f"skills-fs crashed {self._restart_count} times, giving up"
787
+ )
788
+ self.processor.log(
789
+ f"skills-fs: exceeded max restarts ({self._max_restarts}), degraded"
790
+ )
791
+ return False
792
+
793
+ # Exponential backoff before restart
794
+ delay = min(2 ** self._restart_count, 30)
795
+ self.processor.log(f"skills-fs: restarting in {delay}s (attempt {self._restart_count})")
796
+ import time as _time
797
+ _time.sleep(delay)
798
+
799
+ return self.start()
800
+
801
+ def stop(self) -> None:
802
+ """Kill the child process and unmount."""
803
+ if self._pid is not None:
804
+ try:
805
+ os.kill(self._pid, signal.SIGTERM)
806
+ self.processor.log(f"skills-fs: sent SIGTERM to PID {self._pid}")
807
+ except OSError:
808
+ pass
809
+ # Lazy unmount to release any blocked readers
810
+ self._unmount()
811
+ # Clean up pidfile
812
+ try:
813
+ Path(self.pidfile).unlink(missing_ok=True)
814
+ except Exception:
815
+ pass
816
+ self._pid = None
817
+ self._status = "stopped"
818
+
819
+ # --- internal helpers ---
820
+
821
+ def _wait_for_pid(self, max_wait: int = 5) -> None:
822
+ """Wait for the pidfile to appear and read the PID."""
823
+ for _ in range(max_wait * 10):
824
+ try:
825
+ data = Path(self.pidfile).read_text().strip()
826
+ self._pid = int(data)
827
+ return
828
+ except (FileNotFoundError, ValueError):
829
+ import time as _time
830
+ _time.sleep(0.1)
831
+
832
+ def _verify_mount(self, timeout: float = 8.0) -> bool:
833
+ """Check that the mountpoint is accessible.
834
+
835
+ Timeout-guarded: a hung FUSE daemon can never put this process into
836
+ uninterruptible (D) sleep — the blocking probe runs in a daemon thread
837
+ that is abandoned if it doesn't return within ``timeout``.
838
+ """
839
+ def _probe() -> bool:
840
+ status_file = Path(self.mountpoint) / "status"
841
+ if status_file.exists():
842
+ status_file.read_text()
843
+ return True
844
+ os.stat(self.mountpoint)
845
+ import subprocess
846
+ result = subprocess.run(["mount"], capture_output=True, text=True, timeout=3)
847
+ return self.mountpoint in result.stdout
848
+
849
+ res = _run_with_timeout(_probe, timeout)
850
+ if res is _TIMEOUT:
851
+ self.processor.log(f"skills-fs: mount verify timed out ({timeout}s) — hung FUSE?")
852
+ return False
853
+ return res is True
854
+
855
+ def _existing_mount_healthy(self) -> bool:
856
+ """True if a skillsfs FUSE mount at our mountpoint is already up and
857
+ responsive. Used to REUSE an existing mount instead of stacking a second
858
+ FUSE daemon on the same point (stacking is what deadlocks into D-state).
859
+ """
860
+ try:
861
+ import subprocess
862
+ r = subprocess.run(["mount"], capture_output=True, text=True, timeout=3)
863
+ if self.mountpoint not in r.stdout:
864
+ return False
865
+ except Exception:
866
+ return False
867
+ if not self._verify_mount(timeout=6.0):
868
+ return False
869
+ # adopt the existing pidfile so we can manage/stop it later
870
+ self._wait_for_pid(max_wait=1)
871
+ return True
872
+
873
+ def _kill_child(self) -> None:
874
+ """SIGTERM then SIGKILL the skills-fs child; don't leave a half-dead FUSE."""
875
+ if self._pid is None:
876
+ return
877
+ for sig in (signal.SIGTERM, signal.SIGKILL):
878
+ try:
879
+ os.kill(self._pid, sig)
880
+ except OSError:
881
+ break
882
+ import time as _time
883
+ _time.sleep(0.5)
884
+ try:
885
+ os.kill(self._pid, 0)
886
+ except OSError:
887
+ break
888
+ self._unmount()
889
+ try:
890
+ Path(self.pidfile).unlink(missing_ok=True)
891
+ except Exception:
892
+ pass
893
+ self._pid = None
894
+
895
+ def _clean_stale_mount(self) -> None:
896
+ """Lazy-unmount any stale FUSE mount at our mountpoint."""
897
+ try:
898
+ import subprocess
899
+ result = subprocess.run(
900
+ ["umount", "-l", self.mountpoint],
901
+ capture_output=True, text=True, timeout=5,
902
+ )
903
+ if result.returncode == 0:
904
+ self.processor.log(f"skills-fs: cleaned stale mount at {self.mountpoint}")
905
+ except Exception as e:
906
+ self.processor.log(f"skills-fs: stale mount cleanup error: {e}")
907
+
908
+ def _unmount(self) -> None:
909
+ """Unmount the FUSE filesystem."""
910
+ try:
911
+ import subprocess
912
+ subprocess.run(
913
+ ["umount", self.mountpoint],
914
+ capture_output=True, text=True, timeout=5,
915
+ )
916
+ except Exception:
917
+ try:
918
+ subprocess.run(
919
+ ["umount", "-l", self.mountpoint],
920
+ capture_output=True, text=True, timeout=5,
921
+ )
922
+ except Exception:
923
+ pass
924
+
925
+ def _write_degraded(self, reason: str) -> None:
926
+ """Write a degraded status file so skills-fs consumers know the mount is unavailable."""
927
+ degraded_file = Path(self.mountpoint) / "SKILLS_FS_DEGRADED"
928
+ try:
929
+ info = {
930
+ "status": "degraded",
931
+ "reason": reason,
932
+ "timestamp": time.time(),
933
+ "message": "skills-fs FUSE mount is unavailable. Static files (SKILL.md, AGENTS.md) are still accessible but API endpoints will fail.",
934
+ }
935
+ Path(self.mountpoint).mkdir(parents=True, exist_ok=True)
936
+ degraded_file.write_text(json.dumps(info, indent=2, ensure_ascii=False))
937
+ except Exception:
938
+ pass
939
+
940
+
941
+ async def skillsfs_monitor_task(manager: SkillsFsManager, interval: int = 10) -> None:
942
+ """Background task that periodically checks skills-fs health and restarts on failure."""
943
+ while True:
944
+ await asyncio.sleep(interval)
945
+ if manager.status == "stopped":
946
+ continue
947
+ if not manager.check():
948
+ manager.restart()
949
+ # ---------------------------------------------------------------------------
950
+ # HTTP Provider (skills-fs compatible endpoint)
951
+ # ---------------------------------------------------------------------------
952
+
953
+ class NapCatHandler(BaseHTTPRequestHandler):
954
+ """HTTP handler implementing skills-fs HTTP provider contract."""
955
+
956
+ processor: EventProcessor = None
957
+ cache: EventCache = None
958
+ events_reader: EventsReader = None
959
+ skillsfs_manager: SkillsFsManager = None
960
+
961
+ def log_message(self, format: str, *args: Any) -> None:
962
+ pass # suppress default logging
963
+
964
+ def _send_json(self, data: Any, status: int = 200) -> None:
965
+ self.send_response(status)
966
+ self.send_header("Content-Type", "application/json")
967
+ self.end_headers()
968
+ self.wfile.write(json.dumps(data, ensure_ascii=False).encode("utf-8"))
969
+
970
+ def _send_error(self, status: int, message: str) -> None:
971
+ self.send_response(status)
972
+ self.send_header("Content-Type", "text/plain")
973
+ self.end_headers()
974
+ self.wfile.write(message.encode("utf-8"))
975
+
976
+
977
+ def do_GET(self) -> None:
978
+ """Handle GET requests with parameterized query support.
979
+
980
+ Supports:
981
+ - GET /events?since=&type=&limit=&post_type=
982
+ - GET /alerts?name=&limit=
983
+ - GET /invoke?action=...&param=value (read-only API actions proxied via GET)
984
+ - GET /status (bot online status)
985
+ """
986
+ parsed = urlparse(self.path)
987
+ path = parsed.path
988
+ qs = parse_qs(parsed.query)
989
+
990
+ def _first(k: str, default: str = "") -> str:
991
+ return qs.get(k, [default])[0]
992
+
993
+ def _int(k: str, default: int = 0) -> int:
994
+ try:
995
+ return int(_first(k, str(default)))
996
+ except ValueError:
997
+ return default
998
+
999
+ if path == "/events":
1000
+ try:
1001
+ result = self._dispatch("get_events", {
1002
+ "limit": _int("limit", 50),
1003
+ "since": _first("since", "") or None,
1004
+ "post_type": _first("post_type") or None,
1005
+ "event_type": _first("event_type") or None,
1006
+ })
1007
+ self._send_json(result)
1008
+ except Exception as e:
1009
+ self._send_error(500, str(e))
1010
+ return
1011
+
1012
+ if path == "/alerts":
1013
+ try:
1014
+ result = self._dispatch("get_alerts", {
1015
+ "name": _first("name") or None,
1016
+ "limit": _int("limit", 50),
1017
+ })
1018
+ self._send_json(result)
1019
+ except Exception as e:
1020
+ self._send_error(500, str(e))
1021
+ return
1022
+
1023
+ if path == "/status":
1024
+ recent = self.cache.get(limit=10)
1025
+ online = len(recent) > 0
1026
+ result: dict[str, Any] = {
1027
+ "online": online,
1028
+ "last_event": recent[-1].get("time", None) if recent else None,
1029
+ }
1030
+ if self.skillsfs_manager is not None:
1031
+ result["skills_fs"] = {
1032
+ "status": self.skillsfs_manager.status,
1033
+ "mountpoint": self.skillsfs_manager.mountpoint,
1034
+ "pid": self.skillsfs_manager._pid,
1035
+ }
1036
+ self._send_json(result)
1037
+ return
1038
+
1039
+ if path == "/invoke":
1040
+ # GET /invoke only allows read-only actions actually supported by _dispatch.
1041
+ _readable = {
1042
+ "get_events", "get_event", "get_alerts",
1043
+ "list_groups", "list_friends", "list_time_ranges",
1044
+ "list_messages", "get_message", "get_stats",
1045
+ "list_message_content", "get_message_content", "describe_action",
1046
+ }
1047
+ action = _first("action")
1048
+ if not action:
1049
+ self._send_error(400, "Missing action parameter")
1050
+ return
1051
+ if action not in _readable:
1052
+ self._send_error(403, f"Action '{action}' not allowed via GET (use POST for write actions)")
1053
+ return
1054
+ params = {k: v[0] if len(v) == 1 else v for k, v in qs.items() if k != "action"}
1055
+ try:
1056
+ result = self._dispatch(action, params)
1057
+ self._send_json(result)
1058
+ except Exception as e:
1059
+ self._send_error(500, str(e))
1060
+ return
1061
+
1062
+ self._send_error(404, f"Not found: {path}")
1063
+ def do_POST(self) -> None:
1064
+ if self.path not in ("/", "/invoke"):
1065
+ self._send_error(404, "Not found")
1066
+ return
1067
+
1068
+ length = int(self.headers.get("Content-Length", 0))
1069
+ if not length:
1070
+ self._send_error(400, "Missing request body")
1071
+ return
1072
+
1073
+ try:
1074
+ body = self.rfile.read(length)
1075
+ req = json.loads(body)
1076
+ except json.JSONDecodeError as e:
1077
+ self._send_error(400, f"Invalid JSON: {e}")
1078
+ return
1079
+
1080
+ action = req.get("action", "")
1081
+ params = req.get("params", {})
1082
+
1083
+ if not action:
1084
+ self._send_error(400, "Missing action")
1085
+ return
1086
+
1087
+ try:
1088
+ result = self._dispatch(action, params)
1089
+ self._send_json(result)
1090
+ except Exception as e:
1091
+ self.processor.log(f"Action {action!r} failed: {e}")
1092
+ self._send_error(500, str(e))
1093
+ # ------------------------------------------------------------------
1094
+ # Multi-type send/reply helper functions
1095
+ # ------------------------------------------------------------------
1096
+
1097
+ def _read_file_b64(self, path: str) -> str:
1098
+ """Read a file and return base64-encoded string."""
1099
+ import base64
1100
+ try:
1101
+ with open(path, 'rb') as f:
1102
+ data = f.read()
1103
+ except FileNotFoundError:
1104
+ raise ValueError(f"file not found: {path}")
1105
+ if len(data) > 20 * 1024 * 1024:
1106
+ raise ValueError("file too large (max 20MB)")
1107
+ return base64.b64encode(data).decode()
1108
+
1109
+ def _compose_message(self, kind: str, payload: str, qq: str = None, text: str = None) -> list | str | None:
1110
+ """Compose message segments by kind.
1111
+ Returns a list of segments, a CQ code string, or None (for file kind).
1112
+ """
1113
+ if kind == "text":
1114
+ return [{"type": "text", "data": {"text": payload}}]
1115
+ elif kind == "image":
1116
+ b64 = self._read_file_b64(payload)
1117
+ return [{"type": "image", "data": {"file": f"base64://{b64}"}}]
1118
+ elif kind == "file":
1119
+ return None # file upload handled separately via upload API
1120
+ elif kind == "cqcode":
1121
+ return payload # return raw string, NapCat parses CQ codes
1122
+ elif kind == "at":
1123
+ segs = [{"type": "at", "data": {"qq": str(qq)}}]
1124
+ if text:
1125
+ segs.append({"type": "text", "data": {"text": text}})
1126
+ return segs
1127
+ elif kind == "json":
1128
+ import json
1129
+ return json.loads(payload)
1130
+ else:
1131
+ raise ValueError(f"unknown message kind: {kind}")
1132
+
1133
+ def _format_send_result(self, api_result: dict) -> dict:
1134
+ """Format API send result for writeback read."""
1135
+ if api_result.get("retcode", -1) == 0:
1136
+ data = api_result.get("data", {})
1137
+ mid = data.get("message_id", 0)
1138
+ return {"status": "ok", "message_id": mid}
1139
+ return {"error": api_result.get("message", "unknown"), "retcode": api_result.get("retcode", 0)}
1140
+
1141
+ def _upload_file(self, scope: str, target_id: str, path: str) -> dict:
1142
+ """Upload a file via base64 to group or private chat."""
1143
+ from napcat_cli.lib.api import NapCatAPI
1144
+ api = NapCatAPI()
1145
+ b64 = self._read_file_b64(path)
1146
+ import os
1147
+ name = os.path.basename(path)
1148
+ if scope == "group":
1149
+ result = api.call("upload_group_file", group_id=target_id, file=f"base64://{b64}", name=name)
1150
+ else:
1151
+ result = api.call("upload_private_file", user_id=target_id, file=f"base64://{b64}", name=name)
1152
+ return self._format_send_result(result)
1153
+
1154
+ def _compose_reply(self, base_segments: list, message_id: str) -> list:
1155
+ """Prepend a reply segment to base segments."""
1156
+ return [{"type": "reply", "data": {"id": str(message_id)}}] + base_segments
1157
+
1158
+ def _resolve_payload(self, params: dict, fallback_key: str) -> str:
1159
+ """Resolve payload from _payload (skills-fs raw mode) or a named fallback key (HTTP POST)."""
1160
+ v = params.get("_payload")
1161
+ if v:
1162
+ return v
1163
+ v = params.get(fallback_key)
1164
+ return "" if v is None else str(v)
1165
+
1166
+
1167
+ def _dispatch(self, action: str, params: dict) -> Any:
1168
+ """Dispatch action to handler. Returns data for skills-fs."""
1169
+ from napcat_cli.lib.events_sqlite import get_connection, read_events as db_read_events, read_alerts as db_read_alerts, get_event_count
1170
+ db = get_connection(self.cache.data_dir)
1171
+
1172
+ if action == "get_events":
1173
+ events = db_read_events(
1174
+ db,
1175
+ limit=params.get("limit", 50),
1176
+ since=params.get("since", None),
1177
+ post_type=params.get("post_type", None),
1178
+ event_type=params.get("event_type", None),
1179
+ )
1180
+ return {"events": events, "count": len(events)}
1181
+
1182
+ if action == "get_event":
1183
+ event_id = params.get("id", "")
1184
+ from napcat_cli.lib.events_sqlite import read_events as db_read
1185
+ events = db_read(db, limit=1000)
1186
+ for e in events:
1187
+ if str(e.get("message_id", "")) == event_id:
1188
+ return e
1189
+ return {"error": f"Event {event_id} not found"}
1190
+
1191
+ if action == "get_alerts":
1192
+ alerts = db_read_alerts(db, name=params.get("name"), limit=params.get("limit", 50))
1193
+ return {"alerts": alerts, "count": len(alerts)}
1194
+
1195
+ if action == "clear_alert":
1196
+ name = params.get("name") or params.get("alert_name", "")
1197
+ from napcat_cli.lib.events_sqlite import clear_alerts
1198
+ count = clear_alerts(self.processor.writer.conn, name)
1199
+ return {"cleared": count}
1200
+
1201
+ if action == "clear_all_alerts":
1202
+ from napcat_cli.lib.events_sqlite import clear_alerts
1203
+ count = clear_alerts(self.processor.writer.conn)
1204
+ return {"cleared": count}
1205
+
1206
+ # Group / message browsing via SQLite indexes
1207
+ if action == "list_groups":
1208
+ groups = set()
1209
+ rows = db.execute("SELECT DISTINCT group_id FROM events WHERE group_id IS NOT NULL ORDER BY group_id").fetchall()
1210
+ for r in rows:
1211
+ groups.add(str(r["group_id"]))
1212
+ return {"entries": [{"name": g, "kind": "dynamic_dir"} for g in sorted(groups)]}
1213
+
1214
+ if action == "list_friends":
1215
+ rows = db.execute(
1216
+ "SELECT DISTINCT user_id FROM events WHERE post_type='message' AND message_type='private' AND user_id IS NOT NULL ORDER BY user_id"
1217
+ ).fetchall()
1218
+ users = [str(r["user_id"]) for r in rows if r["user_id"]]
1219
+ return {"entries": [{"name": u, "kind": "dynamic_dir"} for u in sorted(users)]}
1220
+
1221
+ if action == "list_time_ranges":
1222
+ ranges = ["recent", "1days", "7days", "30days", "90days"]
1223
+ entries = [{"name": r, "kind": "dynamic_dir"} for r in ranges]
1224
+ group_id = str(params.get("group_id", ""))
1225
+ user_id = str(params.get("user_id", ""))
1226
+ if group_id:
1227
+ entries.extend([
1228
+ {"name": "kick", "kind": "api"},
1229
+ {"name": "ban", "kind": "api"},
1230
+ {"name": "admin", "kind": "api"},
1231
+ {"name": "card", "kind": "api"},
1232
+ {"name": "name", "kind": "api"},
1233
+ {"name": "leave", "kind": "api"},
1234
+ {"name": "info", "kind": "api"},
1235
+ {"name": "members", "kind": "api"},
1236
+ {"name": "essence_list", "kind": "api"},
1237
+ {"name": "poke", "kind": "api"},
1238
+ {"name": "honor", "kind": "api"},
1239
+ {"name": "announce", "kind": "api"},
1240
+ {"name": "send", "kind": "dir"},
1241
+ {"name": "kick.schema", "kind": "blob"},
1242
+ {"name": "ban.schema", "kind": "blob"},
1243
+ {"name": "admin.schema", "kind": "blob"},
1244
+ {"name": "card.schema", "kind": "blob"},
1245
+ {"name": "name.schema", "kind": "blob"},
1246
+ {"name": "leave.schema", "kind": "blob"},
1247
+ {"name": "poke.schema", "kind": "blob"},
1248
+ {"name": "announce.schema", "kind": "blob"},
1249
+ ])
1250
+ elif user_id:
1251
+ entries.extend([
1252
+ {"name": "info", "kind": "api"},
1253
+ {"name": "remark", "kind": "api"},
1254
+ {"name": "send", "kind": "dir"},
1255
+ {"name": "remark.schema", "kind": "blob"},
1256
+ ])
1257
+ return {"entries": entries}
1258
+ if action == "list_messages":
1259
+ group_id = str(params.get("group_id", ""))
1260
+ user_id = str(params.get("user_id", ""))
1261
+ time_range = params.get("time_range", "recent")
1262
+ now = time.time()
1263
+ cutoff = now
1264
+ if time_range == "recent":
1265
+ cutoff = now - 3600
1266
+ elif time_range == "1days":
1267
+ cutoff = now - 86400
1268
+ elif time_range == "7days":
1269
+ cutoff = now - 7 * 86400
1270
+ elif time_range == "30days":
1271
+ cutoff = now - 30 * 86400
1272
+ elif time_range == "90days":
1273
+ cutoff = now - 90 * 86400
1274
+
1275
+ query = "SELECT message_id, timestamp, post_type, event_type, raw_json FROM events WHERE post_type='message' AND timestamp >= ?"
1276
+ qparams = [cutoff]
1277
+ if group_id:
1278
+ query += " AND group_id = ?"
1279
+ qparams.append(int(group_id))
1280
+ if user_id:
1281
+ query += " AND user_id = ?"
1282
+ qparams.append(int(user_id))
1283
+ query += " ORDER BY timestamp DESC LIMIT 500"
1284
+ rows = db.execute(query, qparams).fetchall()
1285
+ messages = []
1286
+ for r in rows:
1287
+ mid = str(r["message_id"]) if r["message_id"] else ""
1288
+ if mid:
1289
+ messages.append({"name": mid, "kind": "dynamic_dir", "time": r["timestamp"]})
1290
+ return {"entries": messages}
1291
+
1292
+ if action == "get_message":
1293
+ group_id = str(params.get("group_id", ""))
1294
+ user_id = str(params.get("user_id", ""))
1295
+ message_id = str(params.get("message_id", ""))
1296
+ query = "SELECT raw_json FROM events WHERE post_type='message' AND message_id = ?"
1297
+ qparams = [message_id]
1298
+ if group_id:
1299
+ query += " AND group_id = ?"
1300
+ qparams.append(int(group_id))
1301
+ if user_id:
1302
+ query += " AND user_id = ?"
1303
+ qparams.append(int(user_id))
1304
+ row = db.execute(query, qparams).fetchone()
1305
+ if row:
1306
+ import json as json_mod
1307
+ from napcat_cli.lib.message import format_message, extract_file_paths
1308
+ event = json_mod.loads(row["raw_json"])
1309
+ msg = event.get("message", [])
1310
+ event["formatted_text"] = format_message(msg)
1311
+ event["files"] = extract_file_paths(msg)
1312
+ return event
1313
+ scope = f"group {group_id}" if group_id else f"friend {user_id}"
1314
+ return {"error": f"Message {message_id} not found in {scope}"}
1315
+
1316
+ if action == "get_stats":
1317
+ event_count = get_event_count(db)
1318
+ from napcat_cli.lib.events_sqlite import get_alert_count
1319
+ alert_count = get_alert_count(db)
1320
+ return {"event_count": event_count, "alert_count": alert_count}
1321
+
1322
+ if action == "send_group_message":
1323
+ group_id = str(params.get("group_id", ""))
1324
+ message = params.get("message", "")
1325
+ if not group_id or not message:
1326
+ return {"error": "group_id and message are required", "expected_schema": ACTION_SCHEMAS["send_group_message"]}
1327
+ from napcat_cli.lib.api import NapCatAPI
1328
+ api = NapCatAPI()
1329
+ return api.call("send_msg", message_type="group", group_id=int(group_id) if group_id.isdigit() else group_id, message=message)
1330
+
1331
+ if action == "send_private_message":
1332
+ user_id = str(params.get("user_id", ""))
1333
+ message = params.get("message", "")
1334
+ if not user_id or not message:
1335
+ return {"error": "user_id and message are required", "expected_schema": ACTION_SCHEMAS["send_private_message"]}
1336
+ from napcat_cli.lib.api import NapCatAPI
1337
+ api = NapCatAPI()
1338
+ return api.call("send_msg", message_type="private", user_id=int(user_id) if user_id.isdigit() else user_id, message=message)
1339
+
1340
+ # ---- send_group_* ----
1341
+ if action == "send_group_text":
1342
+ group_id = str(params.get("group_id", ""))
1343
+ payload = self._resolve_payload(params, "text")
1344
+ if not group_id or not payload:
1345
+ return {"error": "group_id and payload are required", "expected_schema": ACTION_SCHEMAS.get("send_group_text", {})}
1346
+ from napcat_cli.lib.api import NapCatAPI
1347
+ api = NapCatAPI()
1348
+ msg = self._compose_message("text", payload)
1349
+ r = api.call("send_msg", message_type="group", group_id=int(group_id) if group_id.isdigit() else group_id, message=msg)
1350
+ return self._format_send_result(r)
1351
+
1352
+ if action == "send_group_image":
1353
+ group_id = str(params.get("group_id", ""))
1354
+ payload = self._resolve_payload(params, "path")
1355
+ if not group_id or not payload:
1356
+ return {"error": "group_id and payload (file path) are required", "expected_schema": ACTION_SCHEMAS.get("send_group_image", {})}
1357
+ from napcat_cli.lib.api import NapCatAPI
1358
+ api = NapCatAPI()
1359
+ try:
1360
+ msg = self._compose_message("image", payload)
1361
+ except ValueError as e:
1362
+ return {"error": str(e)}
1363
+ r = api.call("send_msg", message_type="group", group_id=int(group_id) if group_id.isdigit() else group_id, message=msg)
1364
+ return self._format_send_result(r)
1365
+
1366
+ if action == "send_group_file":
1367
+ group_id = str(params.get("group_id", ""))
1368
+ payload = self._resolve_payload(params, "path")
1369
+ if not group_id or not payload:
1370
+ return {"error": "group_id and payload (file path) are required", "expected_schema": ACTION_SCHEMAS.get("send_group_file", {})}
1371
+ try:
1372
+ return self._upload_file("group", group_id, payload)
1373
+ except ValueError as e:
1374
+ return {"error": str(e)}
1375
+
1376
+ if action == "send_group_cqcode":
1377
+ group_id = str(params.get("group_id", ""))
1378
+ payload = self._resolve_payload(params, "cqcode")
1379
+ if not group_id or not payload:
1380
+ return {"error": "group_id and payload (CQ code) are required", "expected_schema": ACTION_SCHEMAS.get("send_group_cqcode", {})}
1381
+ from napcat_cli.lib.api import NapCatAPI
1382
+ api = NapCatAPI()
1383
+ msg = self._compose_message("cqcode", payload)
1384
+ r = api.call("send_msg", message_type="group", group_id=int(group_id) if group_id.isdigit() else group_id, message=msg)
1385
+ return self._format_send_result(r)
1386
+
1387
+ if action == "send_group_at":
1388
+ group_id = str(params.get("group_id", ""))
1389
+ qq = str(params.get("qq", ""))
1390
+ text = str(params.get("text", ""))
1391
+ if not group_id or not qq:
1392
+ return {"error": "group_id and qq are required", "expected_schema": ACTION_SCHEMAS.get("send_group_at", {})}
1393
+ from napcat_cli.lib.api import NapCatAPI
1394
+ api = NapCatAPI()
1395
+ msg = self._compose_message("at", None, qq=qq, text=text)
1396
+ r = api.call("send_msg", message_type="group", group_id=int(group_id) if group_id.isdigit() else group_id, message=msg)
1397
+ return self._format_send_result(r)
1398
+
1399
+ if action == "send_group_json":
1400
+ group_id = str(params.get("group_id", ""))
1401
+ message = params.get("message", "")
1402
+ if not group_id or not message:
1403
+ return {"error": "group_id and message are required", "expected_schema": ACTION_SCHEMAS.get("send_group_json", {})}
1404
+ from napcat_cli.lib.api import NapCatAPI
1405
+ api = NapCatAPI()
1406
+ r = api.call("send_msg", message_type="group", group_id=int(group_id) if group_id.isdigit() else group_id, message=message)
1407
+ return self._format_send_result(r)
1408
+
1409
+ # ---- send_private_* ----
1410
+ if action == "send_private_text":
1411
+ user_id = str(params.get("user_id", ""))
1412
+ payload = self._resolve_payload(params, "text")
1413
+ if not user_id or not payload:
1414
+ return {"error": "user_id and payload are required", "expected_schema": ACTION_SCHEMAS.get("send_private_text", {})}
1415
+ from napcat_cli.lib.api import NapCatAPI
1416
+ api = NapCatAPI()
1417
+ msg = self._compose_message("text", payload)
1418
+ r = api.call("send_msg", message_type="private", user_id=int(user_id) if user_id.isdigit() else user_id, message=msg)
1419
+ return self._format_send_result(r)
1420
+
1421
+ if action == "send_private_image":
1422
+ user_id = str(params.get("user_id", ""))
1423
+ payload = self._resolve_payload(params, "path")
1424
+ if not user_id or not payload:
1425
+ return {"error": "user_id and payload (file path) are required", "expected_schema": ACTION_SCHEMAS.get("send_private_image", {})}
1426
+ from napcat_cli.lib.api import NapCatAPI
1427
+ api = NapCatAPI()
1428
+ try:
1429
+ msg = self._compose_message("image", payload)
1430
+ except ValueError as e:
1431
+ return {"error": str(e)}
1432
+ r = api.call("send_msg", message_type="private", user_id=int(user_id) if user_id.isdigit() else user_id, message=msg)
1433
+ return self._format_send_result(r)
1434
+
1435
+ if action == "send_private_file":
1436
+ user_id = str(params.get("user_id", ""))
1437
+ payload = self._resolve_payload(params, "path")
1438
+ if not user_id or not payload:
1439
+ return {"error": "user_id and payload (file path) are required", "expected_schema": ACTION_SCHEMAS.get("send_private_file", {})}
1440
+ try:
1441
+ return self._upload_file("private", user_id, payload)
1442
+ except ValueError as e:
1443
+ return {"error": str(e)}
1444
+
1445
+ if action == "send_private_cqcode":
1446
+ user_id = str(params.get("user_id", ""))
1447
+ payload = self._resolve_payload(params, "cqcode")
1448
+ if not user_id or not payload:
1449
+ return {"error": "user_id and payload (CQ code) are required", "expected_schema": ACTION_SCHEMAS.get("send_private_cqcode", {})}
1450
+ from napcat_cli.lib.api import NapCatAPI
1451
+ api = NapCatAPI()
1452
+ msg = self._compose_message("cqcode", payload)
1453
+ r = api.call("send_msg", message_type="private", user_id=int(user_id) if user_id.isdigit() else user_id, message=msg)
1454
+ return self._format_send_result(r)
1455
+
1456
+ if action == "send_private_at":
1457
+ user_id = str(params.get("user_id", ""))
1458
+ qq = str(params.get("qq", ""))
1459
+ text = str(params.get("text", ""))
1460
+ if not user_id or not qq:
1461
+ return {"error": "user_id and qq are required", "expected_schema": ACTION_SCHEMAS.get("send_private_at", {})}
1462
+ from napcat_cli.lib.api import NapCatAPI
1463
+ api = NapCatAPI()
1464
+ msg = self._compose_message("at", None, qq=qq, text=text)
1465
+ r = api.call("send_msg", message_type="private", user_id=int(user_id) if user_id.isdigit() else user_id, message=msg)
1466
+ return self._format_send_result(r)
1467
+
1468
+ if action == "send_private_json":
1469
+ user_id = str(params.get("user_id", ""))
1470
+ message = params.get("message", "")
1471
+ if not user_id or not message:
1472
+ return {"error": "user_id and message are required", "expected_schema": ACTION_SCHEMAS.get("send_private_json", {})}
1473
+ from napcat_cli.lib.api import NapCatAPI
1474
+ api = NapCatAPI()
1475
+ r = api.call("send_msg", message_type="private", user_id=int(user_id) if user_id.isdigit() else user_id, message=message)
1476
+ return self._format_send_result(r)
1477
+
1478
+ # ---- reply_group_* ----
1479
+ if action == "reply_group_text":
1480
+ group_id = str(params.get("group_id", ""))
1481
+ message_id = str(params.get("message_id", ""))
1482
+ payload = self._resolve_payload(params, "text")
1483
+ if not group_id or not message_id or not payload:
1484
+ return {"error": "group_id, message_id, and payload are required", "expected_schema": ACTION_SCHEMAS.get("reply_group_text", {})}
1485
+ from napcat_cli.lib.api import NapCatAPI
1486
+ api = NapCatAPI()
1487
+ base = self._compose_message("text", payload)
1488
+ msg = self._compose_reply(base, message_id)
1489
+ r = api.call("send_msg", message_type="group", group_id=int(group_id) if group_id.isdigit() else group_id, message=msg)
1490
+ return self._format_send_result(r)
1491
+
1492
+ if action == "reply_group_image":
1493
+ group_id = str(params.get("group_id", ""))
1494
+ message_id = str(params.get("message_id", ""))
1495
+ payload = self._resolve_payload(params, "path")
1496
+ if not group_id or not message_id or not payload:
1497
+ return {"error": "group_id, message_id, and payload are required", "expected_schema": ACTION_SCHEMAS.get("reply_group_image", {})}
1498
+ from napcat_cli.lib.api import NapCatAPI
1499
+ api = NapCatAPI()
1500
+ try:
1501
+ base = self._compose_message("image", payload)
1502
+ except ValueError as e:
1503
+ return {"error": str(e)}
1504
+ msg = self._compose_reply(base, message_id)
1505
+ r = api.call("send_msg", message_type="group", group_id=int(group_id) if group_id.isdigit() else group_id, message=msg)
1506
+ return self._format_send_result(r)
1507
+
1508
+ if action == "reply_group_file":
1509
+ group_id = str(params.get("group_id", ""))
1510
+ payload = self._resolve_payload(params, "path")
1511
+ if not group_id or not payload:
1512
+ return {"error": "group_id and payload (file path) are required", "expected_schema": ACTION_SCHEMAS.get("reply_group_file", {})}
1513
+ try:
1514
+ return self._upload_file("group", group_id, payload)
1515
+ except ValueError as e:
1516
+ return {"error": str(e)}
1517
+
1518
+ if action == "reply_group_cqcode":
1519
+ group_id = str(params.get("group_id", ""))
1520
+ message_id = str(params.get("message_id", ""))
1521
+ payload = self._resolve_payload(params, "cqcode")
1522
+ if not group_id or not message_id or not payload:
1523
+ return {"error": "group_id, message_id, and payload are required", "expected_schema": ACTION_SCHEMAS.get("reply_group_cqcode", {})}
1524
+ from napcat_cli.lib.api import NapCatAPI
1525
+ api = NapCatAPI()
1526
+ cq_str = self._compose_message("cqcode", payload)
1527
+ msg = [{"type": "reply", "data": {"id": str(message_id)}}, {"type": "text", "data": {"text": cq_str}}]
1528
+ r = api.call("send_msg", message_type="group", group_id=int(group_id) if group_id.isdigit() else group_id, message=msg)
1529
+ return self._format_send_result(r)
1530
+
1531
+ if action == "reply_group_at":
1532
+ group_id = str(params.get("group_id", ""))
1533
+ message_id = str(params.get("message_id", ""))
1534
+ qq = str(params.get("qq", ""))
1535
+ text = str(params.get("text", ""))
1536
+ if not group_id or not message_id or not qq:
1537
+ return {"error": "group_id, message_id, and qq are required", "expected_schema": ACTION_SCHEMAS.get("reply_group_at", {})}
1538
+ from napcat_cli.lib.api import NapCatAPI
1539
+ api = NapCatAPI()
1540
+ base = self._compose_message("at", None, qq=qq, text=text)
1541
+ msg = self._compose_reply(base, message_id)
1542
+ r = api.call("send_msg", message_type="group", group_id=int(group_id) if group_id.isdigit() else group_id, message=msg)
1543
+ return self._format_send_result(r)
1544
+
1545
+ if action == "reply_group_json":
1546
+ group_id = str(params.get("group_id", ""))
1547
+ message_id = str(params.get("message_id", ""))
1548
+ message = params.get("message", "")
1549
+ if not group_id or not message_id or not message:
1550
+ return {"error": "group_id, message_id, and message are required", "expected_schema": ACTION_SCHEMAS.get("reply_group_json", {})}
1551
+ from napcat_cli.lib.api import NapCatAPI
1552
+ api = NapCatAPI()
1553
+ msg = [{"type": "reply", "data": {"id": str(message_id)}}] + message
1554
+ r = api.call("send_msg", message_type="group", group_id=int(group_id) if group_id.isdigit() else group_id, message=msg)
1555
+ return self._format_send_result(r)
1556
+
1557
+ # ---- reply_private_* ----
1558
+ if action == "reply_private_text":
1559
+ user_id = str(params.get("user_id", ""))
1560
+ message_id = str(params.get("message_id", ""))
1561
+ payload = self._resolve_payload(params, "text")
1562
+ if not user_id or not message_id or not payload:
1563
+ return {"error": "user_id, message_id, and payload are required", "expected_schema": ACTION_SCHEMAS.get("reply_private_text", {})}
1564
+ from napcat_cli.lib.api import NapCatAPI
1565
+ api = NapCatAPI()
1566
+ base = self._compose_message("text", payload)
1567
+ msg = self._compose_reply(base, message_id)
1568
+ r = api.call("send_msg", message_type="private", user_id=int(user_id) if user_id.isdigit() else user_id, message=msg)
1569
+ return self._format_send_result(r)
1570
+
1571
+ if action == "reply_private_image":
1572
+ user_id = str(params.get("user_id", ""))
1573
+ message_id = str(params.get("message_id", ""))
1574
+ payload = self._resolve_payload(params, "path")
1575
+ if not user_id or not message_id or not payload:
1576
+ return {"error": "user_id, message_id, and payload are required", "expected_schema": ACTION_SCHEMAS.get("reply_private_image", {})}
1577
+ from napcat_cli.lib.api import NapCatAPI
1578
+ api = NapCatAPI()
1579
+ try:
1580
+ base = self._compose_message("image", payload)
1581
+ except ValueError as e:
1582
+ return {"error": str(e)}
1583
+ msg = self._compose_reply(base, message_id)
1584
+ r = api.call("send_msg", message_type="private", user_id=int(user_id) if user_id.isdigit() else user_id, message=msg)
1585
+ return self._format_send_result(r)
1586
+
1587
+ if action == "reply_private_file":
1588
+ user_id = str(params.get("user_id", ""))
1589
+ payload = self._resolve_payload(params, "path")
1590
+ if not user_id or not payload:
1591
+ return {"error": "user_id and payload (file path) are required", "expected_schema": ACTION_SCHEMAS.get("reply_private_file", {})}
1592
+ try:
1593
+ return self._upload_file("private", user_id, payload)
1594
+ except ValueError as e:
1595
+ return {"error": str(e)}
1596
+
1597
+ if action == "reply_private_cqcode":
1598
+ user_id = str(params.get("user_id", ""))
1599
+ message_id = str(params.get("message_id", ""))
1600
+ payload = self._resolve_payload(params, "cqcode")
1601
+ if not user_id or not message_id or not payload:
1602
+ return {"error": "user_id, message_id, and payload are required", "expected_schema": ACTION_SCHEMAS.get("reply_private_cqcode", {})}
1603
+ from napcat_cli.lib.api import NapCatAPI
1604
+ api = NapCatAPI()
1605
+ cq_str = self._compose_message("cqcode", payload)
1606
+ msg = [{"type": "reply", "data": {"id": str(message_id)}}, {"type": "text", "data": {"text": cq_str}}]
1607
+ r = api.call("send_msg", message_type="private", user_id=int(user_id) if user_id.isdigit() else user_id, message=msg)
1608
+ return self._format_send_result(r)
1609
+
1610
+ if action == "reply_private_at":
1611
+ user_id = str(params.get("user_id", ""))
1612
+ message_id = str(params.get("message_id", ""))
1613
+ qq = str(params.get("qq", ""))
1614
+ text = str(params.get("text", ""))
1615
+ if not user_id or not message_id or not qq:
1616
+ return {"error": "user_id, message_id, and qq are required", "expected_schema": ACTION_SCHEMAS.get("reply_private_at", {})}
1617
+ from napcat_cli.lib.api import NapCatAPI
1618
+ api = NapCatAPI()
1619
+ base = self._compose_message("at", None, qq=qq, text=text)
1620
+ msg = self._compose_reply(base, message_id)
1621
+ r = api.call("send_msg", message_type="private", user_id=int(user_id) if user_id.isdigit() else user_id, message=msg)
1622
+ return self._format_send_result(r)
1623
+
1624
+ if action == "reply_private_json":
1625
+ user_id = str(params.get("user_id", ""))
1626
+ message_id = str(params.get("message_id", ""))
1627
+ message = params.get("message", "")
1628
+ if not user_id or not message_id or not message:
1629
+ return {"error": "user_id, message_id, and message are required", "expected_schema": ACTION_SCHEMAS.get("reply_private_json", {})}
1630
+ from napcat_cli.lib.api import NapCatAPI
1631
+ api = NapCatAPI()
1632
+ msg = [{"type": "reply", "data": {"id": str(message_id)}}] + message
1633
+ r = api.call("send_msg", message_type="private", user_id=int(user_id) if user_id.isdigit() else user_id, message=msg)
1634
+ return self._format_send_result(r)
1635
+
1636
+ if action == "list_message_content":
1637
+ message_id = str(params.get("message_id", ""))
1638
+ group_id = str(params.get("group_id", ""))
1639
+ user_id = str(params.get("user_id", ""))
1640
+ query = "SELECT raw_json FROM events WHERE post_type='message' AND message_id = ?"
1641
+ qparams = [message_id]
1642
+ if group_id:
1643
+ query += " AND group_id = ?"
1644
+ qparams.append(int(group_id))
1645
+ if user_id:
1646
+ query += " AND user_id = ?"
1647
+ qparams.append(int(user_id))
1648
+ row = db.execute(query, qparams).fetchone()
1649
+ if not row:
1650
+ return {"error": f"Message {message_id} not found"}
1651
+ event = json.loads(row["raw_json"])
1652
+ segments = event.get("message", [])
1653
+ entries = [{"name": "metadata", "kind": "api"}, {"name": "text", "kind": "api"}, {"name": "reply", "kind": "dir"}]
1654
+ image_count = 0
1655
+ for seg in segments:
1656
+ stype = seg.get("type", "")
1657
+ if stype == "image":
1658
+ image_count += 1
1659
+ entries.append({"name": f"image" if image_count == 1 else f"image_{image_count}", "kind": "api"})
1660
+ elif stype == "file":
1661
+ file_count += 1
1662
+ entries.append({"name": f"file" if file_count == 1 else f"file_{file_count}", "kind": "api"})
1663
+ elif stype == "video":
1664
+ entries.append({"name": "video", "kind": "api"})
1665
+ elif stype == "record":
1666
+ entries.append({"name": "record", "kind": "api"})
1667
+ elif stype == "forward":
1668
+ entries.append({"name": "forward", "kind": "api"})
1669
+ return {"entries": entries}
1670
+
1671
+ if action == "get_message_content":
1672
+ message_id = str(params.get("message_id", ""))
1673
+ content = str(params.get("content", ""))
1674
+ group_id = str(params.get("group_id", ""))
1675
+ user_id = str(params.get("user_id", ""))
1676
+ query = "SELECT raw_json FROM events WHERE post_type='message' AND message_id = ?"
1677
+ qparams = [message_id]
1678
+ if group_id:
1679
+ query += " AND group_id = ?"
1680
+ qparams.append(int(group_id))
1681
+ if user_id:
1682
+ query += " AND user_id = ?"
1683
+ qparams.append(int(user_id))
1684
+ row = db.execute(query, qparams).fetchone()
1685
+ if not row:
1686
+ return {"error": f"Message {message_id} not found"}
1687
+ event = json.loads(row["raw_json"])
1688
+ segments = event.get("message", [])
1689
+ if not content:
1690
+ # No selector: return all available content
1691
+ result = {
1692
+ "metadata": {
1693
+ "message_id": event.get("message_id"),
1694
+ "sender": event.get("sender", {}).get("nickname", ""),
1695
+ "time": event.get("time"),
1696
+ "group_id": event.get("group_id"),
1697
+ "user_id": event.get("user_id"),
1698
+ "message_type": event.get("message_type"),
1699
+ },
1700
+ }
1701
+ texts = [seg.get("data", {}).get("text", "") for seg in segments if seg.get("type") == "text"]
1702
+ if texts:
1703
+ result["text"] = "\n".join(texts)
1704
+ for seg in segments:
1705
+ stype = seg.get("type", "")
1706
+ if stype == "image":
1707
+ result.setdefault("image", []).append(seg.get("data", {}))
1708
+ elif stype == "video":
1709
+ result.setdefault("video", []).append(seg.get("data", {}))
1710
+ elif stype == "record":
1711
+ result.setdefault("record", []).append(seg.get("data", {}))
1712
+ elif stype == "file":
1713
+ result.setdefault("file", []).append(seg.get("data", {}))
1714
+ elif stype == "forward":
1715
+ result.setdefault("forward", []).append(seg.get("data", {}))
1716
+ return result
1717
+
1718
+ if content == "metadata":
1719
+ return {
1720
+ "message_id": event.get("message_id"),
1721
+ "sender": event.get("sender", {}).get("nickname", ""),
1722
+ "time": event.get("time"),
1723
+ "group_id": event.get("group_id"),
1724
+ "user_id": event.get("user_id"),
1725
+ "message_type": event.get("message_type"),
1726
+ }
1727
+
1728
+ if content == "text":
1729
+ texts = [seg.get("data", {}).get("text", "") for seg in segments if seg.get("type") == "text"]
1730
+ return {"text": "\n".join(texts)}
1731
+
1732
+ # Parse image/file selectors like "image", "image_2", "file_3"
1733
+ if content.startswith("image") or content.startswith("file"):
1734
+ base = content.rsplit("_", 1)[0] if "_" in content else content
1735
+ if "_" in content:
1736
+ try:
1737
+ idx = int(content.rsplit("_", 1)[1])
1738
+ except ValueError:
1739
+ return {"error": f"Invalid content selector: {content}"}
1740
+ else:
1741
+ idx = 1
1742
+ collected = []
1743
+ for seg in segments:
1744
+ if seg.get("type") == base:
1745
+ collected.append(seg.get("data", {}))
1746
+ if idx <= len(collected):
1747
+ return collected[idx - 1]
1748
+ return {"error": f"Content {content} not found in message"}
1749
+
1750
+ if content == "video":
1751
+ for seg in segments:
1752
+ if seg.get("type") == "video":
1753
+ return seg.get("data", {})
1754
+ return {"error": "No video in message"}
1755
+
1756
+ if content == "record":
1757
+ for seg in segments:
1758
+ if seg.get("type") == "record":
1759
+ return seg.get("data", {})
1760
+ return {"error": "No record in message"}
1761
+
1762
+ if content == "forward":
1763
+ for seg in segments:
1764
+ if seg.get("type") == "forward":
1765
+ return seg.get("data", {})
1766
+ return {"error": "No forward in message"}
1767
+
1768
+ return {"error": f"Unknown content selector: {content}"}
1769
+
1770
+ if action == "describe_action":
1771
+ action_name = params.get("action", "")
1772
+ schema = ACTION_SCHEMAS.get(action_name)
1773
+ if schema:
1774
+ return schema
1775
+ return {"error": f"Unknown action: {action_name}"}
1776
+
1777
+ # ---- napcat_delete_msg with recall rule hints ----
1778
+ if action == "napcat_delete_msg":
1779
+ from napcat_cli.lib.api import NapCatAPI
1780
+ api = NapCatAPI()
1781
+ r = api.call("delete_msg", **params)
1782
+ if isinstance(r, dict) and "error" in r:
1783
+ r["hint"] = "Recall rules: bot recalling its own message must act within 2 minutes. Admin can recall any member message. Group owner can recall any message. No time limit for admin/owner recalling others' messages."
1784
+ return r
1785
+ # Proxy NapCat API calls through napcat_ prefix
1786
+ if action.startswith("napcat_"):
1787
+ from napcat_cli.lib.api import NapCatAPI
1788
+ api = NapCatAPI()
1789
+ napcat_action = action.replace("napcat_", "", 1)
1790
+ return api.call(napcat_action, **params)
1791
+
1792
+ return {"error": f"Unknown action: {action}"}
1793
+
1794
+
1795
+
1796
+ def run_http_server(
1797
+ processor: EventProcessor,
1798
+ cache: EventCache,
1799
+ port: int = 18820,
1800
+ host: str = "0.0.0.0",
1801
+ skillsfs_manager: SkillsFsManager | None = None,
1802
+ ) -> HTTPServer:
1803
+ """Start HTTP provider in a background thread. Returns the server."""
1804
+ NapCatHandler.processor = processor
1805
+ NapCatHandler.cache = cache
1806
+ NapCatHandler.events_reader = EventsReader(cache.data_dir)
1807
+ NapCatHandler.skillsfs_manager = skillsfs_manager
1808
+
1809
+ server = HTTPServer((host, port), NapCatHandler)
1810
+ t = threading.Thread(target=server.serve_forever, daemon=True)
1811
+ t.start()
1812
+ processor.log(f"HTTP provider listening on {host}:{port}")
1813
+ return server
1814
+
1815
+
1816
+ # ---------------------------------------------------------------------------
1817
+ # Main
1818
+ # ---------------------------------------------------------------------------
1819
+
1820
+ async def health_check_task(processor: EventProcessor, api_url: str, token: str, interval: int = 60, cooldown: int = 300) -> None:
1821
+ """Periodically check if NapCat bot is online via HTTP API.
1822
+
1823
+ Runs as a separate task alongside ws_daemon so a hung WS connection
1824
+ doesn't block health checks. Uses HTTP API (not WS) to detect
1825
+ 'WS connected but bot offline' cases.
1826
+
1827
+ Args:
1828
+ processor: EventProcessor for logging and wake commands.
1829
+ api_url: NapCat HTTP API URL.
1830
+ token: API authentication token.
1831
+ interval: Check interval in seconds (default: 60).
1832
+ cooldown: Minimum seconds between wake attempts (default: 300).
1833
+ """
1834
+ import urllib.request
1835
+ last_wake = 0.0
1836
+ loop = asyncio.get_running_loop()
1837
+
1838
+ while True:
1839
+ try:
1840
+ def probe():
1841
+ url = f"{api_url}/get_status"
1842
+ req = urllib.request.Request(url, method="POST")
1843
+ req.add_header("Content-Type", "application/json")
1844
+ if token:
1845
+ req.add_header("Authorization", f"Bearer {token}")
1846
+ with urllib.request.urlopen(req, timeout=5) as resp:
1847
+ return json.loads(resp.read())
1848
+
1849
+ data = await loop.run_in_executor(None, probe)
1850
+ online = bool(data.get("data", {}).get("online", False))
1851
+
1852
+ if not online:
1853
+ now = time.time()
1854
+ if now - last_wake > cooldown:
1855
+ processor.log(f"Health check: bot is offline. Attempting wake...")
1856
+ try:
1857
+ processor._wake("HEALTH_CHECK_OFFLINE")
1858
+ last_wake = now
1859
+ processor.log("Wake command sent.")
1860
+ except Exception as e:
1861
+ processor.log(f"Wake command failed: {e}")
1862
+ else:
1863
+ processor.log(f"Health check: bot is offline (cooldown: {int(cooldown - (now - last_wake))}s remaining)")
1864
+ else:
1865
+ processor.log("Health check: bot is online.")
1866
+ except Exception as e:
1867
+ processor.log(f"Health check failed: {e}")
1868
+
1869
+ await asyncio.sleep(interval)
1870
+
1871
+
1872
+ async def backlog_sweep_task(processor: "EventProcessor", interval: int = 45) -> None:
1873
+ """Periodically nudge the agent to read unread messages that piled up.
1874
+
1875
+ NEW_MESSAGE events are tracked (not woken) by the orchestrator; if they sit
1876
+ unread longer than ``new_message_idle_seconds`` this fires a single
1877
+ ``NEW_MESSAGE_BACKLOG`` wake so the agent scans the inbox.
1878
+ """
1879
+ await asyncio.sleep(interval) # let the daemon warm up first
1880
+ while True:
1881
+ try:
1882
+ if processor.orchestrator is not None:
1883
+ processor.orchestrator.maybe_backlog_sweep()
1884
+ except Exception as e:
1885
+ processor.log(f"backlog sweep error: {e}")
1886
+ await asyncio.sleep(interval)
1887
+
1888
+
1889
+ def run_daemon(config_path: str) -> None:
1890
+ """Start the WebSocket daemon with HTTP provider."""
1891
+ cfg_data = json.loads(Path(config_path).read_text())
1892
+ self_id = cfg_data.get("self_id", "")
1893
+ wake_command = cfg_data.get("wake_command", "")
1894
+ wake_on_event = bool(cfg_data.get("wake_enabled", cfg_data.get("wake_on_event", True)))
1895
+ group_trigger = cfg_data.get("group_trigger_word", "")
1896
+ private_trigger = cfg_data.get("private_trigger", "")
1897
+
1898
+ # Build the wake Waker (http->cli auto-fallback) from the wake_* config.
1899
+ from napcat_cli.lib.config import NapCatConfig
1900
+ wake_cfg = NapCatConfig()
1901
+ for _k in ("wake_preset", "wake_primary", "wake_session", "wake_http_url",
1902
+ "wake_http_key", "wake_http_session_id", "wake_cli_command"):
1903
+ if _k in cfg_data:
1904
+ setattr(wake_cfg, _k, cfg_data[_k])
1905
+ waker = build_waker(wake_cfg)
1906
+ debounce = float(cfg_data.get("wake_debounce_seconds", 3.0))
1907
+ cooldown = float(cfg_data.get("wake_cooldown_seconds", 30.0))
1908
+ nm_idle = int(cfg_data.get("wake_new_message_idle_seconds", 600))
1909
+
1910
+ processor = EventProcessor(DATA_DIR, self_id, wake_command, wake_on_event,
1911
+ group_trigger_word=group_trigger,
1912
+ private_trigger=private_trigger,
1913
+ waker=waker, debounce_seconds=debounce, cooldown_seconds=cooldown,
1914
+ new_message_idle_seconds=nm_idle,
1915
+ )
1916
+ cache = EventCache(DATA_DIR)
1917
+
1918
+ # PID file
1919
+ pid_file = DATA_DIR / "daemon.pid"
1920
+ pid_file.write_text(str(os.getpid()))
1921
+
1922
+ # Determine ports
1923
+ http_port = int(cfg_data.get("http_port", os.environ.get("NAPCAT_HTTP_PORT", "18821")))
1924
+ ws_port = int(cfg_data.get("ws_port", os.environ.get("NAPCAT_WS_PORT", "18800")))
1925
+ ws_url = cfg_data.get("ws_url", f"ws://127.0.0.1:{ws_port}")
1926
+
1927
+ # API URL for health checks (HTTP, not WS)
1928
+ api_url = cfg_data.get("api_url", os.environ.get("NAPCAT_API_URL", "http://127.0.0.1:18801"))
1929
+ token = cfg_data.get("token", os.environ.get("NAPCAT_TOKEN", ""))
1930
+
1931
+ # --- Start skills-fs FUSE mount ---
1932
+ # Treat empty strings as unset so an old config (which wrote "" for these)
1933
+ # still falls back to the defaults instead of spawning with blank args.
1934
+ skills_fs_enabled = cfg_data.get("skills_fs_enabled", True)
1935
+ skills_fs_mountpoint = cfg_data.get("skills_fs_mountpoint") or _DEFAULT_MOUNTPOINT
1936
+ skills_fs_binary = cfg_data.get("skills_fs_binary") or ""
1937
+ skills_fs_config = cfg_data.get("skills_fs_config") or _DEFAULT_SKILLSFS_CONFIG
1938
+
1939
+ skillsfs_mgr: SkillsFsManager | None = None
1940
+ if skills_fs_enabled:
1941
+ skillsfs_mgr = SkillsFsManager(
1942
+ processor,
1943
+ mountpoint=skills_fs_mountpoint,
1944
+ binary=skills_fs_binary,
1945
+ config=skills_fs_config,
1946
+ )
1947
+ skillsfs_mgr.start()
1948
+
1949
+ # Start HTTP provider (after skills-fs so handler can reference manager)
1950
+ server = run_http_server(processor, cache, http_port, skillsfs_manager=skillsfs_mgr)
1951
+ # Signal handling
1952
+ loop = asyncio.new_event_loop()
1953
+
1954
+ def shutdown_handler(signum: int, frame: Any) -> None:
1955
+ processor.log("Shutting down...")
1956
+ pid_file.unlink(missing_ok=True)
1957
+ if skillsfs_mgr is not None:
1958
+ skillsfs_mgr.stop()
1959
+ server.shutdown()
1960
+ loop.call_soon_threadsafe(loop.stop)
1961
+
1962
+ signal.signal(signal.SIGINT, shutdown_handler)
1963
+ signal.signal(signal.SIGTERM, shutdown_handler)
1964
+
1965
+ processor.log(f"NapCat daemon started (PID: {os.getpid()})")
1966
+ processor.log(f"WebSocket URL: {ws_url}")
1967
+ processor.log(f"HTTP provider port: {http_port}")
1968
+
1969
+ # Run WS daemon, health check, and skills-fs monitor as parallel tasks
1970
+ tasks = [
1971
+ loop.create_task(ws_daemon(ws_url, processor, cache)),
1972
+ loop.create_task(health_check_task(processor, api_url, token)),
1973
+ ]
1974
+ if skillsfs_mgr is not None:
1975
+ tasks.append(loop.create_task(skillsfs_monitor_task(skillsfs_mgr)))
1976
+ if processor.orchestrator is not None:
1977
+ tasks.append(loop.create_task(backlog_sweep_task(processor)))
1978
+
1979
+ # Wait for either to finish (shutdown or WS crash)
1980
+ done, _ = loop.run_until_complete(asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED))
1981
+ for t in done:
1982
+ if t.exception():
1983
+ processor.log(f"Task exception: {t.exception()}")
1984
+
1985
+
1986
+ def main() -> None:
1987
+ if len(sys.argv) < 2:
1988
+ print("Usage: watch.py <config_path>", file=sys.stderr)
1989
+ sys.exit(1)
1990
+ run_daemon(sys.argv[1])
1991
+
1992
+
1993
+ if __name__ == "__main__":
1994
+ main()