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,302 @@
1
+ """Wake orchestrator — debounce, cooldown, backlog sweep, contextual prompts.
2
+
3
+ Sits between :class:`napcat_cli.daemon.watch.EventProcessor` and the
4
+ :class:`napcat_cli.wake_backend.Waker`. Events arrive on the daemon's asyncio
5
+ loop; this module offloads the blocking wake (HTTP/subprocess) to a worker
6
+ thread so the loop never blocks, and adds:
7
+
8
+ - **Debounce**: a burst of same-reason events within ``debounce_seconds``
9
+ coalesces into one wake.
10
+ - **Cooldown**: per-reason ``cooldown_seconds`` suppresses repeats. ``AT_ME`` and
11
+ ``REPLY_TO_ME`` bypass cooldown (near-immediate wake) so direct mentions are
12
+ answered promptly.
13
+ - **NEW_MESSAGE backlog sweep**: if unread messages accumulate longer than
14
+ ``new_message_idle_seconds`` without a wake, fire a ``NEW_MESSAGE_BACKLOG``
15
+ wake so the agent scans the inbox.
16
+ - **Contextual prompts**: the wake prompt summarizes *what* happened (who, where,
17
+ text, counts) instead of a generic "new message".
18
+ - **Legacy fallback**: if no backend is configured but a ``wake_command`` is set,
19
+ it is run as-is (back-compat for ``echo … >> .agent-wake`` configs).
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import queue
24
+ import threading
25
+ import time
26
+ from typing import Any, Callable
27
+
28
+ from .wake_backend import Waker
29
+ from .wake import render_wake_command
30
+
31
+ # Reasons that should wake near-immediately and ignore cooldown.
32
+ _IMMEDIATE = {"AT_ME", "REPLY_TO_ME"}
33
+ # Message-class reasons — a wake for any of these counts as "the agent read the inbox".
34
+ _MESSAGE_REASONS = {"AT_ME", "REPLY_TO_ME", "NEW_MESSAGE", "NEW_MESSAGE_BACKLOG",
35
+ "GROUP_TRIGGER", "PRIVATE_TRIGGER"}
36
+
37
+ _PROMPT_FOOTER = (
38
+ "你可以用 `napcat events` / `napcat alerts` 查看详情,用 `napcat send`/`napcat reply` 回复。"
39
+ )
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Prompt construction
44
+ # ---------------------------------------------------------------------------
45
+
46
+ def _event_text(event: dict) -> str:
47
+ msg = event.get("message") if isinstance(event, dict) else None
48
+ if msg is None and isinstance(event, dict):
49
+ msg = event.get("raw_message", "")
50
+ if isinstance(msg, list):
51
+ return "".join(
52
+ (s.get("data") or {}).get("text", "")
53
+ for s in msg
54
+ if isinstance(s, dict) and s.get("type") == "text"
55
+ ).strip()
56
+ return str(msg or "").strip()
57
+
58
+
59
+ def _who(event: dict) -> str:
60
+ s = event.get("sender") if isinstance(event.get("sender"), dict) else {}
61
+ nick = s.get("nickname") or event.get("user_id") or "?"
62
+ return f"{nick}({event.get('user_id', '?')})"
63
+
64
+
65
+ def _where(event: dict) -> str:
66
+ g = event.get("group_id")
67
+ return f"群{g}" if g else "私聊"
68
+
69
+
70
+ def build_prompt(reason: str, events: list[dict]) -> str:
71
+ """Build a contextual wake prompt for a coalesced batch of events."""
72
+ events = [e for e in events if isinstance(e, dict)]
73
+ n = len(events)
74
+
75
+ if reason in ("AT_ME", "REPLY_TO_ME"):
76
+ who = _who(events[-1]) if events else "?"
77
+ where = _where(events[-1]) if events else "?"
78
+ text = _event_text(events[-1]) if events else ""
79
+ verb = "被 @" if reason == "AT_ME" else "被回复"
80
+ head = f"你在{where}{verb}了" + (f"{n}次" if n > 1 else "")
81
+ body = f"。最近一条来自 {who}:{text}" if text else ""
82
+ action = "请尽快查看并回复。" if reason == "AT_ME" else "请查看并酌情回复。"
83
+ return f"【QQ {reason}】{head}{body}。{action}\n{_PROMPT_FOOTER}"
84
+
85
+ if reason == "NEW_MESSAGE_BACKLOG":
86
+ return f"【QQ 未读积压】有约 {n} 条未读新消息积压了一段时间,请扫一眼收件箱,酌情回复需要回复的。\n{_PROMPT_FOOTER}"
87
+
88
+ if reason in ("NEW_MESSAGE", "GROUP_TRIGGER", "PRIVATE_TRIGGER"):
89
+ text = _event_text(events[-1]) if events else ""
90
+ return f"【QQ 新消息】收到 {n} 条新消息。最近:{_where(events[-1]) if events else ''} {_who(events[-1]) if events else ''}:{text}\n{_PROMPT_FOOTER}"
91
+
92
+ if reason == "NEW_FRIEND":
93
+ ids = sorted({str(e.get("user_id", "")) for e in events if e.get("user_id")})
94
+ return f"【QQ 新好友】新增好友 {n} 个:{', '.join(ids)}。可酌情打招呼或忽略。\n{_PROMPT_FOOTER}"
95
+
96
+ if reason == "NEW_REQUEST":
97
+ reqs = []
98
+ for e in events:
99
+ rt = e.get("request_type", "?")
100
+ sub = e.get("sub_type", "")
101
+ comment = str(e.get("comment", ""))[:40]
102
+ reqs.append(f"{rt}/{sub} from {e.get('user_id','?')}" + (f"「{comment}」" if comment else ""))
103
+ return f"【QQ 请求】收到 {n} 个加好友/加群请求:{'; '.join(reqs)}。请决定是否同意(用 napcat api set_friend_add_request/set_group_add_request)。\n{_PROMPT_FOOTER}"
104
+
105
+ if reason == "BOT_BANNED":
106
+ e = events[-1] if events else {}
107
+ return f"【QQ 被禁言】你在{e.get('group_id','?')}被禁言,操作者 {e.get('operator_id','?')},时长 {e.get('duration','?')}s。请知悉。"
108
+
109
+ if reason == "BOT_KICKED_FROM_GROUP":
110
+ return f"【QQ 被踢出群】你被踢出/移除了 {n} 个群。请知悉。"
111
+
112
+ if reason == "GROUP_ADMIN_CHANGE":
113
+ return f"【QQ 管理员变动】你的群管理员权限发生变动。请知悉。"
114
+
115
+ if reason in ("NEW_POKE", "PROFILE_LIKE"):
116
+ e = events[-1] if events else {}
117
+ return f"【QQ 戳一戳】{e.get('sender_id') or e.get('operator_id','?')} 戳了你/赞了你 {n} 次。可酌情互动。"
118
+
119
+ if reason == "NEW_GROUP_MEMBER":
120
+ ids = sorted({str(e.get("user_id", "")) for e in events if e.get("user_id")})
121
+ return f"【QQ 新群成员】{n} 个新成员加入:{', '.join(ids)}。可酌情欢迎。"
122
+
123
+ if reason == "BOT_OFFLINE":
124
+ return "【QQ 掉线】NapCat bot 连接丢失/离线。请检查容器与登录状态。"
125
+
126
+ # generic fallback
127
+ summaries = "; ".join(str(e.get("summary", ""))[:60] for e in events if e.get("summary"))
128
+ return f"【QQ 事件 {reason}】{summaries or f'{n} 个事件'}。请查看 napcat events。\n{_PROMPT_FOOTER}"
129
+
130
+
131
+ # ---------------------------------------------------------------------------
132
+ # Orchestrator
133
+ # ---------------------------------------------------------------------------
134
+
135
+ class WakeOrchestrator:
136
+ def __init__(
137
+ self,
138
+ waker: Waker,
139
+ *,
140
+ log: Callable[[str], None] = lambda _msg: None,
141
+ debounce_seconds: float = 3.0,
142
+ cooldown_seconds: float = 30.0,
143
+ new_message_idle_seconds: int = 600,
144
+ legacy_command: str = "",
145
+ legacy_session: str = "",
146
+ wake_timeout: float = 120.0,
147
+ ):
148
+ self.waker = waker
149
+ self.log = log
150
+ self.debounce_seconds = debounce_seconds
151
+ self.cooldown_seconds = cooldown_seconds
152
+ self.new_message_idle_seconds = new_message_idle_seconds
153
+ self.legacy_command = legacy_command
154
+ self.legacy_session = legacy_session
155
+ self.wake_timeout = wake_timeout
156
+
157
+ self._lock = threading.Lock()
158
+ self._pending: dict[str, list[dict]] = {}
159
+ self._timers: dict[str, threading.Timer] = {}
160
+ self._last_wake: dict[str, float] = {}
161
+
162
+ # unread-new-message tracking for backlog sweep (in-memory; best-effort)
163
+ self._unread_since: float | None = None
164
+ self._unread_count: int = 0
165
+ self._last_message_wake: float = 0.0
166
+
167
+ self._queue: "queue.Queue[tuple[str, str, list[dict]] | None]" = queue.Queue()
168
+ self._worker = threading.Thread(target=self._run, name="napcat-wake-worker", daemon=True)
169
+ self._worker.start()
170
+
171
+ # -- public API --------------------------------------------------------
172
+
173
+ def submit(self, reason: str, event: dict | None = None) -> None:
174
+ """Queue a wake for ``reason`` (debounced). Non-blocking."""
175
+ with self._lock:
176
+ self._pending.setdefault(reason, []).append(event or {})
177
+ n = len(self._pending[reason])
178
+ # (re)start debounce timer
179
+ old = self._timers.get(reason)
180
+ if old:
181
+ old.cancel()
182
+ # near-immediate for direct mentions (coalesces sub-second bursts), debounce otherwise
183
+ delay = min(self.debounce_seconds, 1.0) if reason in _IMMEDIATE else self.debounce_seconds
184
+ t = threading.Timer(delay, self._flush, args=(reason,))
185
+ t.daemon = True
186
+ self._timers[reason] = t
187
+ t.start()
188
+ self.log(f"[WAKE] queued reason={reason} pending={n} debounce={delay:.1f}s "
189
+ f"primary={getattr(self.waker, 'primary', '?')}")
190
+
191
+ def note_new_message(self, event_time: float) -> None:
192
+ """Track an incoming NEW_MESSAGE for backlog detection (not a wake)."""
193
+ with self._lock:
194
+ if self._unread_since is None:
195
+ self._unread_since = event_time or time.time()
196
+ self._unread_count += 1
197
+
198
+ def maybe_backlog_sweep(self, now: float | None = None) -> bool:
199
+ """Called periodically. Fire a backlog wake if unread messages are stale.
200
+
201
+ Returns True if a backlog wake was queued.
202
+ """
203
+ now = now or time.time()
204
+ with self._lock:
205
+ if self._unread_since is None or self._unread_count == 0:
206
+ return False
207
+ idle = now - self._unread_since
208
+ since_wake = now - self._last_message_wake
209
+ if idle < self.new_message_idle_seconds:
210
+ return False
211
+ if since_wake < self.new_message_idle_seconds:
212
+ return False
213
+ count = self._unread_count
214
+ # consume the unread batch — the agent will read them
215
+ self._unread_since = None
216
+ self._unread_count = 0
217
+ self._last_message_wake = now
218
+ self.log(f"[WAKE] backlog reason=NEW_MESSAGE_BACKLOG unread={count} "
219
+ f"idle={int(idle)}s>={self.new_message_idle_seconds}s")
220
+ # synthesize a backlog reason via submit/cooldown-bypass
221
+ with self._lock:
222
+ self._pending.setdefault("NEW_MESSAGE_BACKLOG", []).extend([{}] * count)
223
+ self._fire_now("NEW_MESSAGE_BACKLOG", count)
224
+ return True
225
+
226
+ def stop(self) -> None:
227
+ self._queue.put(None)
228
+ with self._lock:
229
+ for t in list(self._timers.values()):
230
+ t.cancel()
231
+ self._timers.clear()
232
+
233
+ # -- internals ---------------------------------------------------------
234
+
235
+ def _flush(self, reason: str) -> None:
236
+ """Timer callback: apply cooldown, then enqueue a coalesced wake."""
237
+ with self._lock:
238
+ self._timers.pop(reason, None)
239
+ events = self._pending.pop(reason, [])
240
+ if not events:
241
+ return
242
+ now = time.time()
243
+ if reason not in _IMMEDIATE:
244
+ last = self._last_wake.get(reason, 0.0)
245
+ if now - last < self.cooldown_seconds:
246
+ self.log(f"[WAKE] suppressed reason={reason} cooldown "
247
+ f"age={int(now - last)}s/{int(self.cooldown_seconds)}s buffered={len(events)}")
248
+ return
249
+ self._last_wake[reason] = now
250
+ if reason in _MESSAGE_REASONS:
251
+ self._last_message_wake = now
252
+ # a real message wake consumes unread backlog tracking
253
+ self._unread_since = None
254
+ self._unread_count = 0
255
+ self._enqueue(reason, events)
256
+
257
+ def _fire_now(self, reason: str, count: int) -> None:
258
+ """Bypass debounce/cooldown for synthesized backlog wakes."""
259
+ with self._lock:
260
+ events = self._pending.pop(reason, []) or [{"_synthesized": True} for _ in range(count)]
261
+ self._last_wake[reason] = time.time()
262
+ self._enqueue(reason, events)
263
+
264
+ def _enqueue(self, reason: str, events: list[dict]) -> None:
265
+ prompt = build_prompt(reason, events)
266
+ self._queue.put((reason, prompt, events))
267
+
268
+ def _run(self) -> None:
269
+ while True:
270
+ job = self._queue.get()
271
+ if job is None:
272
+ break
273
+ reason, prompt, events = job
274
+ try:
275
+ self._do_wake(reason, prompt, events)
276
+ except Exception as e: # never let the worker die
277
+ self.log(f"wake worker error ({reason}): {e}")
278
+
279
+ def _do_wake(self, reason: str, prompt: str, events: list[dict]) -> None:
280
+ idem = f"napcat-{reason}-{int(time.time())}"
281
+ ctx = {"reason": reason, "count": len(events)}
282
+ if self.waker.empty and self.legacy_command:
283
+ # legacy escape hatch (e.g. echo >> .agent-wake) — run as-is
284
+ cmd = render_wake_command(self.legacy_command, reason=reason, session=self.legacy_session)
285
+ self.log(f"[WAKE] deliver reason={reason} transport=legacy cmd={cmd}")
286
+ import subprocess
287
+ try:
288
+ r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
289
+ self.log(f"[WAKE] deliver reason={reason} transport=legacy ok={r.returncode == 0} exit={r.returncode}")
290
+ _out = (r.stdout or "").strip()
291
+ if _out:
292
+ self.log(f"[WAKE] reply reason={reason} transport=legacy: {' '.join(_out.split())[:500]}")
293
+ except Exception as e:
294
+ self.log(f"[WAKE] deliver reason={reason} transport=legacy ok=False error={e}")
295
+ return
296
+ res = self.waker.wake(prompt, reason, ctx, idem_key=idem, timeout=self.wake_timeout)
297
+ self.log(f"[WAKE] deliver reason={reason} transport={res.transport} ok={res.ok} "
298
+ f"elapsed={res.elapsed:.1f}s :: {res.detail}")
299
+ _reply = (res.extra or {}).get("reply") or ""
300
+ if _reply.strip():
301
+ self.log(f"[WAKE] reply reason={reason} transport={res.transport}: "
302
+ f"{' '.join(_reply.split())[:500]}")
@@ -0,0 +1,77 @@
1
+ """Wake presets — turn NapCatConfig into a :class:`Waker`.
2
+
3
+ Hermes is the default preset but is *not* required. ``custom`` lets you point the
4
+ HTTP/CLI backends at any agent; ``none`` disables wake. The HTTP key is read
5
+ from ``NAPCAT_WAKE_HTTP_KEY`` (or ``HERMES_API_KEY``) if not set in config, so it
6
+ does not have to live in plaintext ``config.json``.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+
12
+ from .lib.config import NapCatConfig
13
+ from .wake_backend import CliWakeBackend, HttpWakeBackend, Waker
14
+
15
+ HERMES_DEFAULT_HTTP_URL = "http://127.0.0.1:8642"
16
+ HERMES_DEFAULT_CLI = "hermes --continue {session} -z {prompt} --yolo --pass-session-id"
17
+ HERMES_PATH = "/api/sessions/{session}/chat"
18
+
19
+
20
+ def _http_key(cfg: NapCatConfig) -> str:
21
+ return (
22
+ cfg.wake_http_key
23
+ or os.environ.get("NAPCAT_WAKE_HTTP_KEY", "")
24
+ or os.environ.get("HERMES_API_KEY", "")
25
+ )
26
+
27
+
28
+ def _http_backend(cfg: NapCatConfig, *, fill_defaults: bool) -> HttpWakeBackend | None:
29
+ base = cfg.wake_http_url or (HERMES_DEFAULT_HTTP_URL if fill_defaults else "")
30
+ key = _http_key(cfg)
31
+ if not base or not key:
32
+ return None
33
+ return HttpWakeBackend(
34
+ base_url=base,
35
+ key=key,
36
+ session=cfg.wake_session,
37
+ session_id=cfg.wake_http_session_id,
38
+ path_template=HERMES_PATH,
39
+ body_field="input",
40
+ label="hermes-http" if fill_defaults else "http",
41
+ )
42
+
43
+
44
+ def _cli_backend(cfg: NapCatConfig, *, fill_defaults: bool) -> CliWakeBackend | None:
45
+ tmpl = cfg.wake_cli_command or (HERMES_DEFAULT_CLI if fill_defaults else "")
46
+ if not tmpl:
47
+ return None
48
+ return CliWakeBackend(tmpl, session=cfg.wake_session,
49
+ label="hermes-cli" if fill_defaults else "cli")
50
+
51
+
52
+ def build_waker(cfg: NapCatConfig) -> Waker:
53
+ """Build a Waker from ``cfg.wake_preset`` and the wake_* config fields."""
54
+ preset = (cfg.wake_preset or "hermes").lower()
55
+ backends = []
56
+
57
+ if preset == "hermes":
58
+ # Hermes defaults fill empty fields; http is optional (needs key), cli is on by default.
59
+ http = _http_backend(cfg, fill_defaults=True)
60
+ cli = _cli_backend(cfg, fill_defaults=True)
61
+ if http:
62
+ backends.append(http)
63
+ if cli:
64
+ backends.append(cli)
65
+ elif preset == "custom":
66
+ http = _http_backend(cfg, fill_defaults=False)
67
+ cli = _cli_backend(cfg, fill_defaults=False)
68
+ if http:
69
+ backends.append(http)
70
+ if cli:
71
+ backends.append(cli)
72
+ # preset == "none": no backends
73
+
74
+ primary = (cfg.wake_primary or "auto").lower()
75
+ if primary not in ("http", "cli", "auto"):
76
+ primary = "auto"
77
+ return Waker(backends, primary=primary)
@@ -0,0 +1,219 @@
1
+ Metadata-Version: 2.4
2
+ Name: napcat-cli
3
+ Version: 2.0.0
4
+ Summary: Standalone CLI and daemon for NapCat QQ bot management with skills-fs integration
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: textual>=0.40
8
+ Provides-Extra: dev
9
+ Requires-Dist: pytest>=7; extra == 'dev'
10
+ Description-Content-Type: text/markdown
11
+
12
+ # napcat-cli
13
+
14
+ Standalone CLI and daemon for NapCat QQ bot management with skills-fs integration.
15
+
16
+ > 📝 **介绍博文:** [napcat-cli 群配置与管理速查](https://yvxi.pages.dev/blog/napcat-cli-group-config/) — 从零配置、群消息收发、Agent Wake 的完整链路。
17
+
18
+ ---
19
+
20
+ napcat-cli provides a CLI for all NapCat API operations, a WebSocket daemon
21
+ that bridges to a skills-fs HTTP provider, and an agent wake-up mechanism for
22
+ integrating with Hermes or other AI agents.
23
+
24
+ ```bash
25
+ napcat send group 123456 -m "Hello"
26
+ napcat send private 987654 -m "Hi"
27
+ napcat recall 1001
28
+ napcat events --limit 10
29
+ napcat daemon start
30
+ napcat wake --reason NEW_MESSAGE
31
+ ```
32
+
33
+ ---
34
+
35
+ ## Commands
36
+
37
+ | Command | Description |
38
+ |---------|-------------|
39
+ | `napcat api <endpoint>` | Raw API access (like `gh api`) |
40
+ | `napcat send group <id> -m "msg"` | Send group message |
41
+ | `napcat send private <id> -m "msg"` | Send private message |
42
+ | `napcat reply <id> -m "msg"` | Reply to a message |
43
+ | `napcat recall <msg_id>` | Recall a message |
44
+ | `napcat group <sub>` | Group list, members, settings |
45
+ | `napcat friend <sub>` | Friend list, info |
46
+ | `napcat file <sub>` | File upload, download, list |
47
+ | `napcat events` | Read events from SQLite |
48
+ | `napcat alerts [--clear]` | Pending alerts |
49
+ | `napcat status` | Bot online status |
50
+ | `napcat config get/set` | Configuration management |
51
+ | `napcat daemon start/stop/status/restart` | Watch daemon |
52
+ | `napcat fs tree` | Skills-fs directory tree |
53
+ | `napcat wake [--reason R] [--prompt P] [--transport T] [--dry-run]` | Wake the configured agent (HTTP/CLI, auto-fallback) |
54
+ | `napcat setup` | Interactive setup wizard |
55
+ | `napcat phone` | Textual phone-style TUI |
56
+
57
+ ---
58
+
59
+ ## Setup
60
+
61
+ ```bash
62
+ uv tool install napcat-cli
63
+ napcat setup # interactive — guides token, data dir, skills-fs, wake
64
+ napcat daemon start
65
+ ```
66
+
67
+ The setup wizard writes two config files:
68
+
69
+ - `<data_dir>/config.json` — API URL, token, self_id, ports, triggers
70
+ - `<data_dir>/daemon.json` — All fields consumed by `watch.py` including `skills_fs_*` settings
71
+
72
+ Non-interactive mode uses defaults:
73
+
74
+ ```bash
75
+ napcat setup --non-interactive # no prompts, validates token
76
+ napcat setup --yes # skip token validation
77
+ napcat setup --force # overwrite existing config
78
+ ```
79
+
80
+ ---
81
+
82
+ ## Agent Wake
83
+
84
+ When a notable QQ event arrives, the daemon wakes an external agent (Hermes by
85
+ default) carrying a **contextual prompt** so it can read the inbox and reply.
86
+ The wake mechanism is **generic and pluggable** — Hermes is just the default
87
+ preset; any HTTP endpoint or shell command works.
88
+
89
+ ### Two transports, auto-fallback
90
+
91
+ | Transport | When it's used | Needs |
92
+ |-----------|----------------|-------|
93
+ | **CLI one-shot** (default) | Always available | `hermes` on PATH |
94
+ | **HTTP API server** | Opt-in (best latency, idempotent, in-session) | `wake_http_url` + `wake_http_key` |
95
+
96
+ `wake_primary=auto` tries HTTP first (if configured + reachable), else falls back
97
+ to the CLI one-shot. The Hermes CLI backend runs
98
+ `hermes --continue <session> -z "<prompt>" --yolo --pass-session-id`; the HTTP
99
+ backend POSTs to `POST /api/sessions/{id}/chat` (verified per the Hermes API
100
+ docs), with `Authorization: Bearer <key>` and an `Idempotency-Key` header.
101
+
102
+ ### Event routing
103
+
104
+ | Trigger | Behavior |
105
+ |---------|----------|
106
+ | `AT_ME`, `REPLY_TO_ME` | Near-immediate wake (cooldown bypassed), with who/where/text in the prompt |
107
+ | `GROUP_TRIGGER`, `PRIVATE_TRIGGER` | Debounced wake |
108
+ | `NEW_MESSAGE` (not @) | Tracked, not woken; if unread longer than `wake_new_message_idle_seconds` → a `NEW_MESSAGE_BACKLOG` wake |
109
+ | `NEW_FRIEND`, `NEW_REQUEST`, `BOT_BANNED`, `NEW_POKE`, `GROUP_ADMIN_CHANGE`, `NEW_GROUP_MEMBER`, `BOT_OFFLINE`, … | Debounced + cooldown-bounded wake so the agent perceives them within a reasonable window |
110
+
111
+ Debounce (`wake_debounce_seconds`, default 3) coalesces a burst into one wake;
112
+ cooldown (`wake_cooldown_seconds`, default 30) suppresses repeats. Every wake is
113
+ logged to `daemon.log` as `[WAKE] trigger / queued / deliver / reply` lines
114
+ (including transport, elapsed time, and the agent's reply), and `daemon.log` is
115
+ size-rotated (2 MB × 5) so it can't fill the disk.
116
+
117
+ ### Configure
118
+
119
+ The easiest path is the wizard:
120
+
121
+ ```bash
122
+ napcat setup # choose Hermes preset; CLI one-shot by default, HTTP opt-in
123
+ ```
124
+
125
+ Or set keys directly:
126
+
127
+ ```bash
128
+ napcat config set wake_enabled true
129
+ napcat config set wake_preset hermes # hermes | custom | none
130
+ napcat config set wake_session napcat-qq
131
+ napcat config set wake_primary auto # auto | http | cli
132
+ # HTTP (optional). Key also readable from NAPCAT_WAKE_HTTP_KEY / HERMES_API_KEY:
133
+ napcat config set wake_http_url http://127.0.0.1:8642
134
+ napcat config set wake_http_key <API_SERVER_KEY>
135
+ napcat config set wake_new_message_idle_seconds 600
136
+ ```
137
+
138
+ To enable the Hermes HTTP API server (appends to `~/.hermes/.env` and restarts
139
+ the `hermes-gateway.service` systemd unit), answer "y" during `napcat setup`, or
140
+ set `API_SERVER_ENABLED=true` + `API_SERVER_KEY` in `~/.hermes/.env` yourself and
141
+ `sudo systemctl restart hermes-gateway.service`.
142
+
143
+ ### Manual / debug
144
+
145
+ ```bash
146
+ napcat wake # reason: manual, contextual default prompt
147
+ napcat wake --reason AT_ME --prompt "hello"
148
+ napcat wake --transport cli # force a transport for this wake
149
+ napcat wake --dry-run # render the HTTP request + CLI command without executing
150
+ napcat wake test # per-transport configured + reachable probe
151
+ napcat wake sessions # list Hermes sessions (HTTP backend)
152
+ grep '\[WAKE\]' ~/.napcat-data/daemon.log # see when/why/how wakes fired
153
+ ```
154
+
155
+ The agent replies via `napcat send` / `napcat reply` (or the skills-fs write
156
+ path) — see `napcat_cli/data/SKILL.md`. Legacy `wake_command` shell strings still
157
+ run as a last-resort escape hatch when no backend is configured.
158
+
159
+ ---
160
+
161
+ ## Environment Variables
162
+
163
+ | Variable | Default | Description |
164
+ |----------|---------|-------------|
165
+ | `NAPCAT_API_URL` | `http://127.0.0.1:18801` | NapCat HTTP endpoint |
166
+ | `NAPCAT_TOKEN` | — | API auth token |
167
+ | `NAPCAT_DATA_DIR` | `~/.napcat-data` | Data directory |
168
+
169
+ ---
170
+
171
+ ## skills-fs Integration
172
+
173
+ The daemon runs an HTTP provider server (port 18820/18821) that skills-fs calls
174
+ to read/write NapCat API endpoints through the virtual filesystem.
175
+
176
+ When `skills_fs_enabled` is true in `daemon.json`, the daemon spawns skills-fs
177
+ automatically with the configured mountpoint and config file.
178
+
179
+ Manual start:
180
+
181
+ ```bash
182
+ skills-fs fuse --config ~/.napcat-data/skills-fs.json \
183
+ --mountpoint ~/.napcat-data/skills/napcat-cli --allow-other
184
+ ```
185
+
186
+ ---
187
+
188
+ ## Development
189
+
190
+ ```
191
+ napcat-cli/
192
+ ├── napcat_cli/ # Installable package
193
+ │ ├── cli.py # CLI entry point
194
+ │ ├── wake.py # Wake command-template renderer
195
+ │ ├── wake_backend.py # Generic HTTP/CLI wake transports + Waker (auto-fallback)
196
+ │ ├── wake_presets.py # Hermes/custom/none presets -> Waker
197
+ │ ├── wake_orchestrator.py # Debounce, cooldown, backlog sweep, contextual prompts
198
+ │ ├── setup_wizard.py # Setup wizard
199
+ │ ├── daemon/ # Watch daemon, schemas
200
+ │ ├── lib/ # API, config, events
201
+ │ ├── tui/ # Textual TUI
202
+ │ └── data/ # SKILL.md, persona.md
203
+ ├── pyproject.toml
204
+ ├── tests/
205
+ ├── skills-fs/ # Go submodule
206
+ └── tools/ # Dev utilities
207
+ ```
208
+
209
+ ```bash
210
+ python -m pytest tests/ -v
211
+ python -m build --wheel
212
+ uv build && uv publish
213
+ ```
214
+
215
+ ---
216
+
217
+ ## License
218
+
219
+ MIT
@@ -0,0 +1,43 @@
1
+ napcat_cli/__init__.py,sha256=uLnTthioRitJl0d-k4RSCjX3O_ZePv2fNzogDezNyiY,100
2
+ napcat_cli/cli.py,sha256=k-2YttNxkFITM1HJ81mp7PNL276suviY_TVDLSvHVY8,77544
3
+ napcat_cli/setup_wizard.py,sha256=MmOH5n3JKt81UDbC6JaIB3gaz5CdduAaS73VLM5gIuo,14498
4
+ napcat_cli/wake.py,sha256=bhU70CBYJZwBmMjr-pbO1lzdYY8Fz01uyNGkjTavu7c,1632
5
+ napcat_cli/wake_backend.py,sha256=ilOry4ReFWqLQFB3wiRs6Y5NO2IF66HSSpPeVjVbYcc,15762
6
+ napcat_cli/wake_orchestrator.py,sha256=MVqrPQHxnRRh8W_-JAbb3Y7NktrgIBBZ7MNMmTSJris,13895
7
+ napcat_cli/wake_presets.py,sha256=SxuZIacAX6uROgDg6PSy_DlNqvOZFBgaL4-4WAJL-J0,2707
8
+ napcat_cli/daemon/__init__.py,sha256=ZSASPpn16p4LLYBG82YDOZCRrE1xm5OeLxkf02XizF8,25
9
+ napcat_cli/daemon/schemas.py,sha256=BkCHLHmRCJr13UPsbAm8jUOem18Ejb3C_F4vF6hjAr4,20817
10
+ napcat_cli/daemon/watch.py,sha256=ayrvDPnDyxD8gFlbu64omxq-TsK6nRJ_1exCDAMkppc,88632
11
+ napcat_cli/data/SKILL.md,sha256=htWg2Ui4hC1Y2aC0BMnXk-EQPhnbA9Vvch7aj9IrNj0,13353
12
+ napcat_cli/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ napcat_cli/data/persona.md,sha256=NhpiQ-HUCxN_XHhleqWzMpOXCcK9o6nqtxjzDYNSdnE,5713
14
+ napcat_cli/data/skills-fs-config.json,sha256=jw8xFCHCfg16GeMP1tbazBpOmGAsVCSqAeUyxFafCGg,199
15
+ napcat_cli/data/skills-fs-fragment.json,sha256=fKRHltmtil41wsrkDLWmnud-UU00U4Y15GFwWV3KibA,11418
16
+ napcat_cli/data/references/mounting.md,sha256=n4Vn5yMH7OOJyO-dRSr0fJ46ZyeA__XONmLs8AocPM4,4395
17
+ napcat_cli/data/skills-fs.d/agents-friend-time.md,sha256=OXVFP-w6tPGPNi3AiDOz13EIWAaeUNjjjoCseFINEIk,160
18
+ napcat_cli/data/skills-fs.d/agents-friend.md,sha256=Lne5LA7Sqjm4I1nJPFK-54Vlzh5kmmb3DiEFgGbD6gI,190
19
+ napcat_cli/data/skills-fs.d/agents-friends.md,sha256=DtGv_lm9jXuFnQe9AhUASB941MRC5nvhLFv6Ep8XcRc,185
20
+ napcat_cli/data/skills-fs.d/agents-group-time.md,sha256=IFWWD_7pzjlBUWjTmtKPP6c4B9VTptsuafNJ0bLncx8,162
21
+ napcat_cli/data/skills-fs.d/agents-group.md,sha256=k3yyAtIzkWoQq6fKuEg5Ck5WS3RKNHyol8ZWIEwr31s,126
22
+ napcat_cli/data/skills-fs.d/agents-groups.md,sha256=iLEutqgxjD9LvqaTfKJZ6-HpUCjytCB3RzsRAZLOly8,233
23
+ napcat_cli/data/skills-fs.d/agents-napcat.md,sha256=hcdg1ksk8NFFyz15jnuZaNtuPdVJGTO8rkQ6S1RNVHI,496
24
+ napcat_cli/data/skills-fs.d/persona.md,sha256=NhpiQ-HUCxN_XHhleqWzMpOXCcK9o6nqtxjzDYNSdnE,5713
25
+ napcat_cli/data/skills-fs.d/skill-agents.md,sha256=Jiopoj1xBjsDE5OHrRDOHVBG8amEI-VToJCdzpRH0eI,1793
26
+ napcat_cli/data/skills-fs.d/skill-body.md,sha256=-yoCWvIqAjdFulQ0GJlBU_EaJTO43U-p9MoMZp5z7zw,5424
27
+ napcat_cli/lib/__init__.py,sha256=bOl0q7d8qqfglMmR928vy3xcxci0-HLPCKqpimu8yMQ,26
28
+ napcat_cli/lib/api.py,sha256=xHblnqVgjmEtKZNw6Y-HjNYZedw7h9WFZJEOdGUFd2Q,10329
29
+ napcat_cli/lib/config.py,sha256=ILNCUCwCD1S8Q8m6RBNh2AZZCg7_wL7P4akxmPqkyhk,3827
30
+ napcat_cli/lib/events.py,sha256=dLns-7g2SkjT-9jaxQP_leC5-UvfXoNZ6tXgVB1KFXI,3866
31
+ napcat_cli/lib/events_sqlite.py,sha256=QQyCbTIETfV45aJ61I7lMsF1-kMg8pZbXLSWkb2e7Zg,8185
32
+ napcat_cli/lib/message.py,sha256=S04KDbRGnu22xZzuZbxu02--3DeQ-7cxM3of6xYK9Rk,4581
33
+ napcat_cli/tui/__init__.py,sha256=3kSNNdR0JUY18jUDzMVGXwBPZnBq7WrKLwqB0uIcBz4,62
34
+ napcat_cli/tui/__main__.py,sha256=jR6ewycp8EiotB_V6rKwmF2uVS6eWj0jxSTueLFy330,191
35
+ napcat_cli/tui/api.py,sha256=dWz1kSCQP5tyoavmKiNr6zHZBF6TziPOTgeHYHR5zBk,5795
36
+ napcat_cli/tui/app.py,sha256=FxEaKqZNnGyNANAiHuGAi-lbagu37d8zlaqvFmYau4c,4875
37
+ napcat_cli/tui/chat_list.py,sha256=CfjbLfBmhnqbRQO-AKwt0Wdk9hhLMJhmRl7RNDPAHJA,5543
38
+ napcat_cli/tui/chat_view.py,sha256=EHh6RRa_IO0Ty6AV7uztkk0FPWjmVjYnQrxncvdn5PE,16191
39
+ napcat_cli/tui/styles.tcss,sha256=kJJaBuOMO8FmdigHxn9eOq-__rGAIjaOfiBJ_W2YttA,346
40
+ napcat_cli-2.0.0.dist-info/METADATA,sha256=MSZW6ctrJlksnjd7vem3ax2Xp4HQtNLgXlBqXhIAXxc,7944
41
+ napcat_cli-2.0.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
42
+ napcat_cli-2.0.0.dist-info/entry_points.txt,sha256=xISOTwC-m9F5ywJuwDakrMK03ZSdmDn_tIX-8cM1oRg,47
43
+ napcat_cli-2.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ napcat = napcat_cli.cli:main