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,380 @@
|
|
|
1
|
+
"""Interactive setup wizard for napcat-cli configuration."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from napcat_cli.lib.config import NapCatConfig, DATA_DIR
|
|
12
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _write_daemon_json(cfg: NapCatConfig, data_dir: str | Path) -> None:
|
|
16
|
+
"""Write a complete daemon.json with all fields consumed by watch.py."""
|
|
17
|
+
cfg_dict = {
|
|
18
|
+
"self_id": cfg.self_id or "",
|
|
19
|
+
"wake_command": cfg.wake_command,
|
|
20
|
+
"wake_on_event": cfg.wake_on_event,
|
|
21
|
+
"ws_port": cfg.ws_port,
|
|
22
|
+
"http_port": cfg.http_port,
|
|
23
|
+
"group_trigger_word": cfg.group_trigger_word,
|
|
24
|
+
"private_trigger": cfg.private_trigger,
|
|
25
|
+
"skills_fs_enabled": cfg.skills_fs_enabled,
|
|
26
|
+
"skills_fs_mountpoint": cfg.skills_fs_mountpoint,
|
|
27
|
+
"skills_fs_binary": cfg.skills_fs_binary,
|
|
28
|
+
"skills_fs_config": cfg.skills_fs_config,
|
|
29
|
+
"wake_enabled": cfg.wake_enabled,
|
|
30
|
+
"wake_preset": cfg.wake_preset,
|
|
31
|
+
"wake_primary": cfg.wake_primary,
|
|
32
|
+
"wake_session": cfg.wake_session,
|
|
33
|
+
"wake_http_url": cfg.wake_http_url,
|
|
34
|
+
"wake_http_session_id": cfg.wake_http_session_id,
|
|
35
|
+
"wake_cli_command": cfg.wake_cli_command,
|
|
36
|
+
"wake_debounce_seconds": cfg.wake_debounce_seconds,
|
|
37
|
+
"wake_cooldown_seconds": cfg.wake_cooldown_seconds,
|
|
38
|
+
"wake_new_message_idle_seconds": cfg.wake_new_message_idle_seconds,
|
|
39
|
+
}
|
|
40
|
+
daemon_path = Path(data_dir) / "daemon.json"
|
|
41
|
+
daemon_path.write_text(json.dumps(cfg_dict, indent=2))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _prompt_token_with_validation(api_url: str, non_interactive: bool = False) -> str:
|
|
45
|
+
"""Validate a token against NapCat; re-prompt on failure."""
|
|
46
|
+
try:
|
|
47
|
+
token = os.environ.get("NAPCAT_TOKEN", "")
|
|
48
|
+
except Exception:
|
|
49
|
+
token = ""
|
|
50
|
+
|
|
51
|
+
if non_interactive:
|
|
52
|
+
# Try once, never block
|
|
53
|
+
try:
|
|
54
|
+
api = NapCatAPI(api_url=api_url, token=token, timeout=5)
|
|
55
|
+
r = api.call("get_login_info")
|
|
56
|
+
if r.get("retcode") == 0:
|
|
57
|
+
print(f" Token validated OK")
|
|
58
|
+
else:
|
|
59
|
+
print(f" Token validation skipped in non-interactive mode")
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
return token
|
|
63
|
+
|
|
64
|
+
# Interactive: validate, re-prompt on failure
|
|
65
|
+
try:
|
|
66
|
+
api = NapCatAPI(api_url=api_url, token=token, timeout=5)
|
|
67
|
+
r = api.call("get_login_info")
|
|
68
|
+
if r.get("retcode") == 0:
|
|
69
|
+
print(f" Token validated OK")
|
|
70
|
+
return token
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
# Validation failed — re-prompt
|
|
75
|
+
print(f" Token 校验失败。重新输入 token(直接回车则忽略校验继续):", file=sys.stderr)
|
|
76
|
+
while True:
|
|
77
|
+
try:
|
|
78
|
+
new_token = input(" > ")
|
|
79
|
+
except (EOFError, KeyboardInterrupt):
|
|
80
|
+
print(file=sys.stderr)
|
|
81
|
+
return token
|
|
82
|
+
if not new_token:
|
|
83
|
+
print(f" Keeping current token, skipping validation.")
|
|
84
|
+
return token
|
|
85
|
+
token = new_token
|
|
86
|
+
try:
|
|
87
|
+
api = NapCatAPI(api_url=api_url, token=token, timeout=5)
|
|
88
|
+
r = api.call("get_login_info")
|
|
89
|
+
if r.get("retcode") == 0:
|
|
90
|
+
print(f" Token validated OK")
|
|
91
|
+
return token
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
print(f" Token 校验失败。重新输入 token(直接回车则忽略校验继续):", file=sys.stderr)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _check_skills_fs_binary(cfg: NapCatConfig) -> tuple[str, str]:
|
|
98
|
+
"""Check for skills-fs binary. Returns (path_or_empty, how)."""
|
|
99
|
+
binary = cfg.skills_fs_binary
|
|
100
|
+
if binary:
|
|
101
|
+
p = Path(binary)
|
|
102
|
+
if p.exists() and os.access(p, os.X_OK):
|
|
103
|
+
return binary, "configured"
|
|
104
|
+
print(f" Configured binary '{binary}' not found or not executable.", file=sys.stderr)
|
|
105
|
+
|
|
106
|
+
# Try shipped binary next to repo
|
|
107
|
+
from napcat_cli.daemon.watch import _resolve_shipped_binary
|
|
108
|
+
shipped = _resolve_shipped_binary()
|
|
109
|
+
if shipped and Path(shipped).exists():
|
|
110
|
+
return shipped, "shipped"
|
|
111
|
+
|
|
112
|
+
# Search PATH
|
|
113
|
+
import shutil
|
|
114
|
+
found = shutil.which("skills-fs")
|
|
115
|
+
if found:
|
|
116
|
+
return found, "PATH"
|
|
117
|
+
|
|
118
|
+
return "", "missing"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _install_hermes_skill(cfg: NapCatConfig, force: bool = False) -> None:
|
|
122
|
+
"""Copy SKILL.md, persona.md, and references/ into ~/.hermes/skills/napcat-cli/."""
|
|
123
|
+
hermes_dir = Path.home() / ".hermes" / "skills" / "napcat-cli"
|
|
124
|
+
|
|
125
|
+
if hermes_dir.exists() and not force:
|
|
126
|
+
print(f" Hermes skill already installed at {hermes_dir}. Use --force to overwrite.")
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
import importlib.resources as pkg_resources
|
|
131
|
+
skill_data = pkg_resources.files("napcat_cli.data")
|
|
132
|
+
skill_md = skill_data.joinpath("SKILL.md").read_text()
|
|
133
|
+
persona_md = skill_data.joinpath("persona.md").read_text()
|
|
134
|
+
ref_files: dict[str, str] = {}
|
|
135
|
+
try:
|
|
136
|
+
for child in skill_data.joinpath("references").iterdir():
|
|
137
|
+
if str(child).endswith(".md"):
|
|
138
|
+
ref_files[child.name] = child.read_text()
|
|
139
|
+
except (FileNotFoundError, NotADirectoryError, OSError):
|
|
140
|
+
pass
|
|
141
|
+
except Exception:
|
|
142
|
+
print(" Could not read bundled skill files, skipping Hermes skill install.", file=sys.stderr)
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
hermes_dir.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
(hermes_dir / "SKILL.md").write_text(skill_md)
|
|
147
|
+
(hermes_dir / "persona.md").write_text(persona_md)
|
|
148
|
+
if ref_files:
|
|
149
|
+
ref_dir = hermes_dir / "references"
|
|
150
|
+
ref_dir.mkdir(exist_ok=True)
|
|
151
|
+
for name, content in ref_files.items():
|
|
152
|
+
(ref_dir / name).write_text(content)
|
|
153
|
+
print(f" Hermes skill installed at {hermes_dir}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
HERMES_GATEWAY_UNIT = "hermes-gateway.service"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _enable_hermes_api_server(cfg: NapCatConfig) -> bool:
|
|
160
|
+
"""Opt-in: enable the Hermes API server and restart the gateway.
|
|
161
|
+
|
|
162
|
+
Appends ``API_SERVER_ENABLED=true`` + a generated key to ``~/.hermes/.env``
|
|
163
|
+
(append-only, never rewrites), restarts the systemd unit (passwordless sudo),
|
|
164
|
+
and wires ``cfg.wake_http_url`` / ``cfg.wake_http_key``. Returns True on success.
|
|
165
|
+
"""
|
|
166
|
+
import secrets as _secrets
|
|
167
|
+
import subprocess
|
|
168
|
+
hermes_env = Path.home() / ".hermes" / ".env"
|
|
169
|
+
key = _secrets.token_hex(32)
|
|
170
|
+
try:
|
|
171
|
+
with open(hermes_env, "a") as f:
|
|
172
|
+
f.write("\n# Added by napcat-cli setup (agent wake)\n")
|
|
173
|
+
f.write("API_SERVER_ENABLED=true\n")
|
|
174
|
+
f.write(f"API_SERVER_KEY={key}\n")
|
|
175
|
+
except Exception as e:
|
|
176
|
+
print(f" Could not write {hermes_env}: {e}", file=sys.stderr)
|
|
177
|
+
return False
|
|
178
|
+
print(f" Appended API_SERVER_ENABLED=true + key to {hermes_env}")
|
|
179
|
+
try:
|
|
180
|
+
subprocess.run(["sudo", "-n", "systemctl", "restart", HERMES_GATEWAY_UNIT],
|
|
181
|
+
check=True, timeout=60)
|
|
182
|
+
print(f" Restarted {HERMES_GATEWAY_UNIT}")
|
|
183
|
+
except Exception as e:
|
|
184
|
+
print(f" WARNING: could not restart {HERMES_GATEWAY_UNIT}: {e}", file=sys.stderr)
|
|
185
|
+
print(f" Restart manually: sudo systemctl restart {HERMES_GATEWAY_UNIT}", file=sys.stderr)
|
|
186
|
+
cfg.wake_http_url = "http://127.0.0.1:8642"
|
|
187
|
+
cfg.wake_http_key = key
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _check_cli_symlink(yes: bool = False) -> None:
|
|
192
|
+
"""Check if napcat is available on PATH; offer symlink if not."""
|
|
193
|
+
import shutil
|
|
194
|
+
if shutil.which("napcat"):
|
|
195
|
+
print(" 'napcat' is already on PATH.")
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
local_bin = Path.home() / ".local" / "bin"
|
|
199
|
+
target = local_bin / "napcat"
|
|
200
|
+
|
|
201
|
+
if target.exists():
|
|
202
|
+
print(f" Symlink exists at {target}")
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
# Check if installed via pip/uv (entry point)
|
|
206
|
+
try:
|
|
207
|
+
import importlib.metadata
|
|
208
|
+
info = importlib.metadata.distribution("napcat-cli")
|
|
209
|
+
if info:
|
|
210
|
+
print(" 'napcat' is installed via pip/uv — should be on PATH.")
|
|
211
|
+
return
|
|
212
|
+
except Exception:
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
# Source tree — offer symlink
|
|
216
|
+
print(f" 'napcat' not found on PATH.")
|
|
217
|
+
print(f" Create symlink: ln -sf <path-to-napcat> {target}")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _prompt_str(label: str, default: str, current: str = "") -> str:
|
|
221
|
+
"""Interactive prompt showing current value; bare Enter keeps current."""
|
|
222
|
+
display = current if current else default
|
|
223
|
+
if not current:
|
|
224
|
+
print(f" {label} [{display}]: ", end="", file=sys.stderr)
|
|
225
|
+
else:
|
|
226
|
+
print(f" {label} (current: {current}) [{default}]: ", end="", file=sys.stderr)
|
|
227
|
+
try:
|
|
228
|
+
val = input()
|
|
229
|
+
except (EOFError, KeyboardInterrupt):
|
|
230
|
+
print(file=sys.stderr)
|
|
231
|
+
val = ""
|
|
232
|
+
return val if val else current if current else default
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _prompt_str_ni(default: str) -> str:
|
|
236
|
+
"""Non-interactive: return env var or default."""
|
|
237
|
+
return default
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def run_setup(non_interactive: bool = False, yes: bool = False, force: bool = False) -> int:
|
|
241
|
+
"""Run the interactive setup wizard."""
|
|
242
|
+
print("=== napcat-cli Setup ===")
|
|
243
|
+
print()
|
|
244
|
+
|
|
245
|
+
cfg = NapCatConfig()
|
|
246
|
+
|
|
247
|
+
# --- 1. NapCat connection ---
|
|
248
|
+
print("[1] NapCat connection")
|
|
249
|
+
if non_interactive:
|
|
250
|
+
api_url = os.environ.get("NAPCAT_API_URL", "http://127.0.0.1:18801")
|
|
251
|
+
token = _prompt_token_with_validation(api_url, non_interactive=True)
|
|
252
|
+
else:
|
|
253
|
+
api_url = _prompt_str("API URL", "http://127.0.0.1:18801")
|
|
254
|
+
token = _prompt_token_with_validation(api_url)
|
|
255
|
+
|
|
256
|
+
cfg.api_url = api_url
|
|
257
|
+
cfg.token = token
|
|
258
|
+
print()
|
|
259
|
+
|
|
260
|
+
# --- 2. Data dir ---
|
|
261
|
+
print("[2] Data directory")
|
|
262
|
+
if non_interactive:
|
|
263
|
+
data_dir = os.environ.get("NAPCAT_DATA_DIR", str(Path.home() / ".napcat-data"))
|
|
264
|
+
else:
|
|
265
|
+
data_dir = _prompt_str("Data directory", str(Path.home() / ".napcat-data"))
|
|
266
|
+
print(f" Data dir: {data_dir}")
|
|
267
|
+
print()
|
|
268
|
+
|
|
269
|
+
# --- 3. skills-fs ---
|
|
270
|
+
print("[3] skills-fs configuration")
|
|
271
|
+
_skill_dir = str(Path.home() / ".hermes" / "skills" / "napcat-cli")
|
|
272
|
+
if non_interactive:
|
|
273
|
+
cfg.skills_fs_mountpoint = os.environ.get("NAPCAT_SKILLSFS_MOUNTPOINT", _skill_dir)
|
|
274
|
+
cfg.skills_fs_config = os.environ.get("NAPCAT_SKILLSFS_CONFIG",
|
|
275
|
+
str(Path.home() / ".napcat-data" / "skills-fs.json"))
|
|
276
|
+
else:
|
|
277
|
+
cfg.skills_fs_mountpoint = _prompt_str(
|
|
278
|
+
"skills-fs mountpoint (overlay target = the skill dir)", _skill_dir)
|
|
279
|
+
cfg.skills_fs_config = _prompt_str(
|
|
280
|
+
"skills-fs config path", str(Path.home() / ".napcat-data" / "skills-fs.json"))
|
|
281
|
+
cfg.skills_fs_enabled = True
|
|
282
|
+
|
|
283
|
+
# Check binary
|
|
284
|
+
binary_path, how = _check_skills_fs_binary(cfg)
|
|
285
|
+
if how == "missing":
|
|
286
|
+
print(" Go binary not found. Build it: cd skills-fs && make build (needs Go).", file=sys.stderr)
|
|
287
|
+
else:
|
|
288
|
+
print(f" skills-fs binary found: {binary_path} ({how})")
|
|
289
|
+
if binary_path:
|
|
290
|
+
cfg.skills_fs_binary = binary_path
|
|
291
|
+
print()
|
|
292
|
+
|
|
293
|
+
# --- 4. Wake agent preset ---
|
|
294
|
+
print("[4] Wake agent configuration")
|
|
295
|
+
print(" Wake is pluggable: Hermes is the default preset, but any HTTP endpoint or")
|
|
296
|
+
print(" shell command works. Default transport = CLI one-shot (zero infra); HTTP is opt-in.")
|
|
297
|
+
if non_interactive:
|
|
298
|
+
preset = "hermes"
|
|
299
|
+
else:
|
|
300
|
+
print(" Choose wake agent: [H]ermes (default) / [C]ustom / [N]one", file=sys.stderr)
|
|
301
|
+
try:
|
|
302
|
+
choice = input(" > ").lower().strip()
|
|
303
|
+
except (EOFError, KeyboardInterrupt):
|
|
304
|
+
print(file=sys.stderr)
|
|
305
|
+
choice = "h"
|
|
306
|
+
preset = "custom" if choice in ("c", "custom") else "none" if choice in ("n", "none") else "hermes"
|
|
307
|
+
|
|
308
|
+
cfg.wake_enabled = preset != "none"
|
|
309
|
+
cfg.wake_preset = preset
|
|
310
|
+
cfg.wake_primary = "auto"
|
|
311
|
+
|
|
312
|
+
if preset == "hermes":
|
|
313
|
+
session = "napcat-qq" if non_interactive else _prompt_str("Agent session name", "napcat-qq")
|
|
314
|
+
cfg.wake_session = session
|
|
315
|
+
cfg.wake_cli_command = "" # hermes preset fills the default CLI command at wake time
|
|
316
|
+
cfg.wake_command = "" # clear any legacy (broken) wake command
|
|
317
|
+
cfg.wake_on_event = True
|
|
318
|
+
print(f" preset=hermes session={session} primary=auto (CLI one-shot by default)")
|
|
319
|
+
enable_http = False
|
|
320
|
+
if not non_interactive:
|
|
321
|
+
print(f" Enable the Hermes HTTP API server now? Appends to ~/.hermes/.env and runs\n"
|
|
322
|
+
f" sudo systemctl restart {HERMES_GATEWAY_UNIT}\n"
|
|
323
|
+
f" (briefly interrupts your messaging platforms). [y/N]", file=sys.stderr)
|
|
324
|
+
try:
|
|
325
|
+
enable_http = input(" > ").lower().strip() in ("y", "yes")
|
|
326
|
+
except (EOFError, KeyboardInterrupt):
|
|
327
|
+
print(file=sys.stderr)
|
|
328
|
+
if enable_http:
|
|
329
|
+
_enable_hermes_api_server(cfg)
|
|
330
|
+
else:
|
|
331
|
+
print(" HTTP API server not enabled — CLI one-shot transport will be used.\n"
|
|
332
|
+
" (Re-run setup or set wake_http_* later to switch to HTTP.)")
|
|
333
|
+
elif preset == "custom":
|
|
334
|
+
cfg.wake_session = "napcat-qq" if non_interactive else _prompt_str("Session name", "napcat-qq")
|
|
335
|
+
if not non_interactive:
|
|
336
|
+
cfg.wake_http_url = _prompt_str("HTTP base URL (blank = skip http)", "")
|
|
337
|
+
if cfg.wake_http_url:
|
|
338
|
+
cfg.wake_http_key = _prompt_str("HTTP bearer key", "")
|
|
339
|
+
cfg.wake_cli_command = _prompt_str("CLI command template (blank = skip cli)", "")
|
|
340
|
+
cfg.wake_command = ""
|
|
341
|
+
cfg.wake_on_event = True
|
|
342
|
+
else:
|
|
343
|
+
cfg.wake_command = ""
|
|
344
|
+
cfg.wake_on_event = False
|
|
345
|
+
print(" Wake disabled.")
|
|
346
|
+
print()
|
|
347
|
+
|
|
348
|
+
# --- 5. Install skill into Hermes ---
|
|
349
|
+
print("[5] Hermes skill installation")
|
|
350
|
+
if non_interactive or yes:
|
|
351
|
+
_install_hermes_skill(cfg, force=force)
|
|
352
|
+
else:
|
|
353
|
+
print(f" Install skill to ~/.hermes/skills/napcat-cli/? [Y/n]", file=sys.stderr)
|
|
354
|
+
try:
|
|
355
|
+
choice = input(" > ").lower().strip()
|
|
356
|
+
except (EOFError, KeyboardInterrupt):
|
|
357
|
+
print(file=sys.stderr)
|
|
358
|
+
choice = "y"
|
|
359
|
+
if choice in ("", "y", "yes"):
|
|
360
|
+
_install_hermes_skill(cfg, force=force)
|
|
361
|
+
print()
|
|
362
|
+
|
|
363
|
+
# --- 6. Check CLI symlink ---
|
|
364
|
+
print("[6] CLI availability")
|
|
365
|
+
_check_cli_symlink(yes=yes)
|
|
366
|
+
print()
|
|
367
|
+
|
|
368
|
+
# --- Persist ---
|
|
369
|
+
print("Writing configuration...")
|
|
370
|
+
os.environ["NAPCAT_DATA_DIR"] = data_dir
|
|
371
|
+
cfg.save()
|
|
372
|
+
_write_daemon_json(cfg, data_dir)
|
|
373
|
+
print(f" config.json written to {data_dir}/config.json")
|
|
374
|
+
print(f" daemon.json written to {data_dir}/daemon.json")
|
|
375
|
+
print()
|
|
376
|
+
print("=== Setup complete ===")
|
|
377
|
+
print("Start the daemon with: napcat daemon start")
|
|
378
|
+
print("Test wake with: napcat wake --dry-run")
|
|
379
|
+
|
|
380
|
+
return 0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""napcat TUI — Textual-based mobile QQ-style interface."""
|
napcat_cli/tui/api.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""HTTP client for the napcat daemon provider (port 18821)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import urllib.request
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from napcat_cli.lib.message import format_message, extract_file_paths
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ChatItem:
|
|
18
|
+
"""A conversation entry (group or friend)."""
|
|
19
|
+
id: str # group_id or user_id
|
|
20
|
+
name: str # display name
|
|
21
|
+
kind: str # "group" or "private"
|
|
22
|
+
remark: str = "" # remark set by user for friends
|
|
23
|
+
last_message: str = ""
|
|
24
|
+
last_sender: str = ""
|
|
25
|
+
last_time: int = 0
|
|
26
|
+
unread: int = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Message:
|
|
31
|
+
"""A single message."""
|
|
32
|
+
id: str
|
|
33
|
+
sender_id: str
|
|
34
|
+
sender_name: str
|
|
35
|
+
content: str
|
|
36
|
+
time: int
|
|
37
|
+
is_self: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _http_post(url: str, body: dict, timeout: int = 10) -> dict:
|
|
41
|
+
"""Synchronous HTTP POST helper using urllib."""
|
|
42
|
+
data = json.dumps(body).encode("utf-8")
|
|
43
|
+
req = urllib.request.Request(
|
|
44
|
+
url, data=data,
|
|
45
|
+
headers={"Content-Type": "application/json"},
|
|
46
|
+
method="POST",
|
|
47
|
+
)
|
|
48
|
+
try:
|
|
49
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
50
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
51
|
+
except Exception as e:
|
|
52
|
+
return {"error": str(e)}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Repo root (the directory containing the ``napcat_cli`` package). Put it on
|
|
56
|
+
# PYTHONPATH for the slash-command subprocess so ``-m napcat_cli.cli`` resolves
|
|
57
|
+
# regardless of the caller's cwd or whether the package is pip-installed.
|
|
58
|
+
_REPO_ROOT = str(Path(__file__).resolve().parents[2])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DaemonClient:
|
|
62
|
+
"""Client for the napcat daemon HTTP provider on port 18821."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, port: int | None = None) -> None:
|
|
65
|
+
self.url = f"http://127.0.0.1:{port or int(os.environ.get('NAPCAT_HTTP_PORT', '18821'))}/invoke"
|
|
66
|
+
|
|
67
|
+
async def call(self, action: str, params: dict | None = None) -> dict:
|
|
68
|
+
"""Call a daemon action via POST JSON (async, non-blocking)."""
|
|
69
|
+
body = {"action": action, "params": params or {}}
|
|
70
|
+
return await asyncio.to_thread(_http_post, self.url, body)
|
|
71
|
+
|
|
72
|
+
async def get_events(self, limit: int = 100) -> list[dict]:
|
|
73
|
+
"""Get recent events from daemon."""
|
|
74
|
+
result = await self.call("get_events", {"limit": limit})
|
|
75
|
+
return result.get("events", [])
|
|
76
|
+
|
|
77
|
+
async def get_alerts(self) -> list[dict]:
|
|
78
|
+
"""Get pending alerts."""
|
|
79
|
+
result = await self.call("get_alerts", {})
|
|
80
|
+
return result.get("alerts", [])
|
|
81
|
+
|
|
82
|
+
async def get_friends(self) -> list[dict]:
|
|
83
|
+
"""Get friend list."""
|
|
84
|
+
result = await self.call("napcat_get_friend_list", {})
|
|
85
|
+
if result.get("retcode") == 0:
|
|
86
|
+
return result.get("data", [])
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
async def get_groups(self) -> list[dict]:
|
|
90
|
+
"""Get group list."""
|
|
91
|
+
result = await self.call("napcat_get_group_list", {})
|
|
92
|
+
if result.get("retcode") == 0:
|
|
93
|
+
return result.get("data", [])
|
|
94
|
+
return []
|
|
95
|
+
|
|
96
|
+
async def get_message_history(self, message_type: str, target_id: str, count: int = 50, start_id: int = 0) -> list[dict]:
|
|
97
|
+
"""Get message history for a group or friend."""
|
|
98
|
+
params: dict = {"count": count}
|
|
99
|
+
if message_type == "group":
|
|
100
|
+
params["group_id"] = str(target_id)
|
|
101
|
+
action = "napcat_get_group_msg_history"
|
|
102
|
+
else:
|
|
103
|
+
params["user_id"] = str(target_id)
|
|
104
|
+
action = "napcat_get_friend_msg_history"
|
|
105
|
+
if start_id:
|
|
106
|
+
params["message_seq"] = start_id
|
|
107
|
+
result = await self.call(action, params)
|
|
108
|
+
if result.get("retcode") == 0:
|
|
109
|
+
data = result.get("data") or {}
|
|
110
|
+
messages = data.get("messages", []) if isinstance(data, dict) else (data or [])
|
|
111
|
+
# Add formatted_text and files to each message
|
|
112
|
+
for msg in messages:
|
|
113
|
+
msg_segments = msg.get("message", [])
|
|
114
|
+
if isinstance(msg_segments, list):
|
|
115
|
+
msg["formatted_text"] = format_message(msg_segments)
|
|
116
|
+
msg["files"] = extract_file_paths(msg_segments)
|
|
117
|
+
else:
|
|
118
|
+
msg["formatted_text"] = str(msg_segments)
|
|
119
|
+
msg["files"] = []
|
|
120
|
+
return messages
|
|
121
|
+
return []
|
|
122
|
+
|
|
123
|
+
async def send_message(self, message_type: str, target_id: str, message: str) -> dict:
|
|
124
|
+
"""Send a message."""
|
|
125
|
+
segments = [{"type": "text", "data": {"text": message}}]
|
|
126
|
+
params: dict = {"message_type": message_type, "message": segments}
|
|
127
|
+
if message_type == "group":
|
|
128
|
+
params["group_id"] = str(target_id)
|
|
129
|
+
else:
|
|
130
|
+
params["user_id"] = str(target_id)
|
|
131
|
+
return await self.call("napcat_send_msg", params)
|
|
132
|
+
|
|
133
|
+
async def run_napcat_cli(self, args: list[str]) -> tuple[str, str, int]:
|
|
134
|
+
"""Run a napcat CLI command via the package entrypoint, returning
|
|
135
|
+
``(stdout, stderr, code)``. This is the reuse path for ``/commands``."""
|
|
136
|
+
def _run() -> tuple[str, str, int]:
|
|
137
|
+
env = os.environ.copy()
|
|
138
|
+
pp = env.get("PYTHONPATH", "")
|
|
139
|
+
env["PYTHONPATH"] = _REPO_ROOT + (os.pathsep + pp if pp else "")
|
|
140
|
+
try:
|
|
141
|
+
result = subprocess.run(
|
|
142
|
+
[sys.executable, "-m", "napcat_cli.cli"] + list(args),
|
|
143
|
+
capture_output=True, text=True, timeout=30, env=env,
|
|
144
|
+
)
|
|
145
|
+
return result.stdout, result.stderr, result.returncode
|
|
146
|
+
except Exception as e:
|
|
147
|
+
return "", str(e), 1
|
|
148
|
+
return await asyncio.to_thread(_run)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# Cached instance
|
|
152
|
+
_client: DaemonClient | None = None
|
|
153
|
+
|
|
154
|
+
def get_client() -> DaemonClient:
|
|
155
|
+
global _client
|
|
156
|
+
if _client is None:
|
|
157
|
+
_client = DaemonClient()
|
|
158
|
+
return _client
|
napcat_cli/tui/app.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""napcat TUI — root application."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from textual.app import App, ComposeResult
|
|
7
|
+
from textual.widgets import Footer, Header
|
|
8
|
+
|
|
9
|
+
from napcat_cli.lib.message import format_message
|
|
10
|
+
from .api import ChatItem, DaemonClient, get_client
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class NapCatApp(App):
|
|
14
|
+
"""Root application for the napcat TUI."""
|
|
15
|
+
SCREENS: dict[str, type] = {}
|
|
16
|
+
|
|
17
|
+
CSS_PATH = "styles.tcss"
|
|
18
|
+
|
|
19
|
+
BINDINGS = [
|
|
20
|
+
("escape", "quit", "Quit"),
|
|
21
|
+
("f5", "refresh", "Refresh"),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
super().__init__()
|
|
26
|
+
self.client: DaemonClient = get_client()
|
|
27
|
+
self.chats: dict[str, ChatItem] = {}
|
|
28
|
+
self.alerts: list[dict[str, Any]] = []
|
|
29
|
+
self._seen_alerts: set[str] = set()
|
|
30
|
+
self._seen_message_ids: set[str] = set()
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def _alert_signature(alert: dict[str, Any]) -> str:
|
|
34
|
+
import json
|
|
35
|
+
return json.dumps(alert, sort_keys=True)
|
|
36
|
+
|
|
37
|
+
def compose(self) -> ComposeResult:
|
|
38
|
+
yield Header()
|
|
39
|
+
yield Footer()
|
|
40
|
+
|
|
41
|
+
def on_mount(self) -> None:
|
|
42
|
+
from .chat_list import ChatListScreen
|
|
43
|
+
self.set_interval(2, self._poll_loop)
|
|
44
|
+
self.push_screen(ChatListScreen())
|
|
45
|
+
|
|
46
|
+
async def _poll_loop(self) -> None:
|
|
47
|
+
await self.refresh_data()
|
|
48
|
+
screen = self.screen
|
|
49
|
+
if hasattr(screen, "_refresh_list"):
|
|
50
|
+
screen._refresh_list()
|
|
51
|
+
if hasattr(screen, "_refresh_messages"):
|
|
52
|
+
screen._refresh_messages()
|
|
53
|
+
|
|
54
|
+
async def refresh_data(self) -> None:
|
|
55
|
+
"""Fetch fresh data from the daemon and merge into app state."""
|
|
56
|
+
try:
|
|
57
|
+
events = await self.client.get_events(limit=200)
|
|
58
|
+
friends = await self.client.get_friends()
|
|
59
|
+
groups = await self.client.get_groups()
|
|
60
|
+
self.alerts = await self.client.get_alerts()
|
|
61
|
+
|
|
62
|
+
# Build/update chat items from friends and groups
|
|
63
|
+
for f in friends:
|
|
64
|
+
fid = str(f.get("user_id", ""))
|
|
65
|
+
self.chats.setdefault(fid, ChatItem(
|
|
66
|
+
id=fid,
|
|
67
|
+
name=f.get("nickname", fid),
|
|
68
|
+
kind="private",
|
|
69
|
+
remark=f.get("remark", ""),
|
|
70
|
+
))
|
|
71
|
+
self.chats[fid].name = f.get("nickname", fid)
|
|
72
|
+
self.chats[fid].remark = f.get("remark", "")
|
|
73
|
+
|
|
74
|
+
for g in groups:
|
|
75
|
+
gid = str(g.get("group_id", ""))
|
|
76
|
+
self.chats.setdefault(gid, ChatItem(
|
|
77
|
+
id=gid,
|
|
78
|
+
name=g.get("group_name", gid),
|
|
79
|
+
kind="group",
|
|
80
|
+
))
|
|
81
|
+
self.chats[gid].name = g.get("group_name", gid)
|
|
82
|
+
|
|
83
|
+
# Update last message / unread from events
|
|
84
|
+
for ev in events:
|
|
85
|
+
post_type = ev.get("post_type", "")
|
|
86
|
+
if post_type == "message":
|
|
87
|
+
target_id = str(ev.get("group_id") or ev.get("user_id", ""))
|
|
88
|
+
sender_name = ""
|
|
89
|
+
sender_info = ev.get("sender", {})
|
|
90
|
+
if sender_info:
|
|
91
|
+
sender_name = sender_info.get("nickname") or sender_info.get("card") or ""
|
|
92
|
+
content = ""
|
|
93
|
+
raw = ev.get("raw_message", "") or ev.get("message", [])
|
|
94
|
+
if isinstance(raw, str):
|
|
95
|
+
content = raw
|
|
96
|
+
elif isinstance(raw, list):
|
|
97
|
+
content = format_message(raw)
|
|
98
|
+
if not target_id:
|
|
99
|
+
continue
|
|
100
|
+
if target_id not in self.chats:
|
|
101
|
+
is_group = bool(ev.get("group_id"))
|
|
102
|
+
self.chats.setdefault(target_id, ChatItem(
|
|
103
|
+
id=target_id,
|
|
104
|
+
name=(sender_name or target_id) if not is_group else target_id,
|
|
105
|
+
kind="group" if is_group else "private",
|
|
106
|
+
))
|
|
107
|
+
item = self.chats[target_id]
|
|
108
|
+
item.last_message = content
|
|
109
|
+
item.last_sender = sender_name
|
|
110
|
+
item.last_time = ev.get("time", 0)
|
|
111
|
+
mid = str(ev.get("message_id", "")) or str(ev.get("message_seq", ""))
|
|
112
|
+
if mid and mid not in self._seen_message_ids:
|
|
113
|
+
self._seen_message_ids.add(mid)
|
|
114
|
+
item.unread += 1
|
|
115
|
+
elif post_type == "notice":
|
|
116
|
+
notice_type = ev.get("notice_type", "")
|
|
117
|
+
if notice_type == "friend_increase":
|
|
118
|
+
fid = str(ev.get("user_id", ""))
|
|
119
|
+
self.chats.setdefault(fid, ChatItem(
|
|
120
|
+
id=fid, name=fid, kind="private",
|
|
121
|
+
))
|
|
122
|
+
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
async def action_refresh(self) -> None:
|
|
127
|
+
await self.refresh_data()
|