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.
- napcat_cli/__init__.py +3 -0
- napcat_cli/cli.py +1834 -0
- napcat_cli/daemon/__init__.py +1 -0
- napcat_cli/daemon/schemas.py +470 -0
- napcat_cli/daemon/watch.py +1994 -0
- napcat_cli/data/SKILL.md +328 -0
- napcat_cli/data/__init__.py +0 -0
- napcat_cli/data/persona.md +155 -0
- napcat_cli/data/references/mounting.md +90 -0
- napcat_cli/data/skills-fs-config.json +12 -0
- napcat_cli/data/skills-fs-fragment.json +473 -0
- napcat_cli/data/skills-fs.d/agents-friend-time.md +7 -0
- napcat_cli/data/skills-fs.d/agents-friend.md +7 -0
- napcat_cli/data/skills-fs.d/agents-friends.md +4 -0
- napcat_cli/data/skills-fs.d/agents-group-time.md +7 -0
- napcat_cli/data/skills-fs.d/agents-group.md +6 -0
- napcat_cli/data/skills-fs.d/agents-groups.md +5 -0
- napcat_cli/data/skills-fs.d/agents-napcat.md +9 -0
- napcat_cli/data/skills-fs.d/persona.md +155 -0
- napcat_cli/data/skills-fs.d/skill-agents.md +42 -0
- napcat_cli/data/skills-fs.d/skill-body.md +102 -0
- napcat_cli/lib/__init__.py +1 -0
- napcat_cli/lib/api.py +258 -0
- napcat_cli/lib/config.py +105 -0
- napcat_cli/lib/events.py +127 -0
- napcat_cli/lib/events_sqlite.py +242 -0
- napcat_cli/lib/message.py +156 -0
- napcat_cli/setup_wizard.py +380 -0
- napcat_cli/tui/__init__.py +1 -0
- napcat_cli/tui/__main__.py +13 -0
- napcat_cli/tui/api.py +158 -0
- napcat_cli/tui/app.py +127 -0
- napcat_cli/tui/chat_list.py +168 -0
- napcat_cli/tui/chat_view.py +456 -0
- napcat_cli/tui/styles.tcss +9 -0
- napcat_cli/wake.py +51 -0
- napcat_cli/wake_backend.py +390 -0
- napcat_cli/wake_orchestrator.py +302 -0
- napcat_cli/wake_presets.py +77 -0
- napcat_cli-2.0.0.dist-info/METADATA +219 -0
- napcat_cli-2.0.0.dist-info/RECORD +43 -0
- napcat_cli-2.0.0.dist-info/WHEEL +4 -0
- 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,,
|