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
napcat_cli/cli.py ADDED
@@ -0,0 +1,1834 @@
1
+ #!/usr/bin/env python3
2
+ """napcat-cli - Standalone CLI for NapCat QQ bot management.
3
+
4
+ Usage:
5
+ napcat api <endpoint> [--data JSON] [--method POST|GET]
6
+ napcat send <group|private> <id> -m "<message>" [--file FILE] [--at USERS]
7
+ napcat recall <message_id> [--group GROUP]
8
+ napcat group <command> [args...]
9
+ napcat friend <command> [args...]
10
+ napcat file <command> [args...]
11
+ napcat daemon [start|stop|status]
12
+ napcat config [get|set] <key> [value]
13
+ napcat alerts [--clear]
14
+
15
+ Environment:
16
+ NAPCAT_API_URL HTTP API URL (default: http://127.0.0.1:18801)
17
+ NAPCAT_DATA_DIR Data directory (default: ~/.napcat-data)
18
+ NAPCAT_TOKEN API authentication token
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from napcat_cli.__init__ import __version__
23
+ import argparse
24
+ import json
25
+ import os
26
+ import sys
27
+ from pathlib import Path
28
+ import base64
29
+
30
+ # Project root
31
+ ROOT = Path(__file__).resolve().parent
32
+ sys.path.insert(0, str(ROOT))
33
+
34
+ from napcat_cli.lib.api import NapCatAPI
35
+ from napcat_cli.lib.config import get_config, DATA_DIR
36
+ from napcat_cli.lib.events import EventsReader
37
+
38
+
39
+
40
+ def require_online(api: NapCatAPI) -> bool:
41
+ """Check bot is online; print error and return False if offline."""
42
+ if not api.is_online():
43
+ print("Error: bot is offline. Check 'napcat status' or restart NapCat.", file=sys.stderr)
44
+ return False
45
+ return True
46
+
47
+
48
+ def _port_in_use(port: int) -> bool:
49
+ """True if something is already listening on 127.0.0.1:<port>.
50
+
51
+ Guards against stacking a second daemon (or starting one while a D-state
52
+ zombie still holds the port)."""
53
+ import socket
54
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
55
+ s.settimeout(0.5)
56
+ try:
57
+ return s.connect_ex(("127.0.0.1", int(port))) == 0
58
+ finally:
59
+ s.close()
60
+
61
+
62
+ def _normalize_file_path(path: str) -> str:
63
+ """Convert local file path to file:// URL if needed."""
64
+ if path.startswith(("http://", "https://", "file://", "base64://")):
65
+ return path
66
+ p = Path(path)
67
+ if p.exists():
68
+ return "file://" + str(p.resolve())
69
+ print(f"Error: file not found: {path}", file=sys.stderr)
70
+ return path
71
+
72
+ def cmd_api(args: argparse.Namespace, api: NapCatAPI) -> int:
73
+ """napcat api - Raw API access like 'gh api'."""
74
+ endpoint = args.endpoint
75
+ method = args.method or ("POST" if args.data else "GET")
76
+
77
+ data: dict | None = None
78
+ if args.data:
79
+ try:
80
+ data = json.loads(args.data)
81
+ except json.JSONDecodeError as e:
82
+ print(f"Error: Invalid JSON: {e}", file=sys.stderr)
83
+ return 1
84
+
85
+ result = api.request(endpoint, method=method, json_body=data)
86
+
87
+ if args.output == "json":
88
+ print(json.dumps(result, indent=2, ensure_ascii=False))
89
+ elif args.output == "value" and "data" in result:
90
+ val = result["data"]
91
+ if isinstance(val, (dict, list)):
92
+ print(json.dumps(val, indent=2, ensure_ascii=False))
93
+ else:
94
+ print(val)
95
+ else:
96
+ print(json.dumps(result, indent=2, ensure_ascii=False))
97
+
98
+ return 0 if result.get("retcode") == 0 or result.get("status") == "ok" else 1
99
+
100
+
101
+ def cmd_send(args: argparse.Namespace, api: NapCatAPI) -> int:
102
+ """napcat send - Send a message."""
103
+ if not require_online(api):
104
+ return 1
105
+
106
+ msg_type = "group" if args.target_type == "group" else "private"
107
+ target_id = args.target_id
108
+
109
+ # Build message segments
110
+ segments = []
111
+ message_text = args.message or args.message_text
112
+ if not message_text:
113
+ print("Error: message is required (positional or --message)", file=sys.stderr)
114
+ return 1
115
+ if args.at:
116
+ for uid in args.at:
117
+ segments.append({"type": "at", "data": {"qq": uid}})
118
+
119
+ if args.file:
120
+ file_path = args.file
121
+ orig_name = Path(file_path).name
122
+ if file_path.startswith(("http://", "https://", "file://", "base64://")):
123
+ pass
124
+ elif Path(file_path).exists():
125
+ file_path = "file://" + str(Path(file_path).resolve())
126
+ else:
127
+ print(f"Warning: file not found '{args.file}', sending as-is (NapCat may reject)", file=sys.stderr)
128
+ segments.append({"type": "file", "data": {"file": file_path, "name": orig_name}})
129
+ elif args.image:
130
+ image_path = args.image
131
+ if image_path.startswith(("http://", "https://", "file://", "base64://")):
132
+ pass
133
+ elif Path(image_path).exists():
134
+ try:
135
+ data = Path(image_path).read_bytes()
136
+ b64 = base64.b64encode(data).decode()
137
+ segments.append({"type": "image", "data": {"file": f"base64://{b64}"}})
138
+ except Exception:
139
+ image_path = "file://" + str(Path(image_path).resolve())
140
+ segments.append({"type": "image", "data": {"file": image_path}})
141
+ else:
142
+ print(f"Warning: image not found '{args.image}', sending as-is", file=sys.stderr)
143
+ segments.append({"type": "image", "data": {"file": image_path}})
144
+
145
+ # message is now required positional arg
146
+ segments.append({"type": "text", "data": {"text": message_text}})
147
+
148
+ kwargs: dict = {"message_type": msg_type, "message": segments}
149
+ if msg_type == "group":
150
+ kwargs["group_id"] = target_id
151
+ else:
152
+ kwargs["user_id"] = target_id
153
+
154
+ result = api.call("send_msg", **kwargs)
155
+
156
+ if getattr(args, "json", False):
157
+ print(json.dumps(result, indent=2, ensure_ascii=False))
158
+ return 0 if result.get("retcode") == 0 else 1
159
+
160
+ if result.get("retcode") == 0:
161
+ data = result.get("data", {})
162
+ msg_id = data.get("message_id", "unknown")
163
+ print(f"Sent message_id={msg_id}", file=sys.stderr)
164
+ return 0
165
+ else:
166
+ print(f"Error: {result.get('message', 'Unknown error')}", file=sys.stderr)
167
+ return 1
168
+
169
+
170
+ def cmd_reply(args: argparse.Namespace, api: NapCatAPI) -> int:
171
+ """napcat reply - Reply to a message."""
172
+ if not require_online(api):
173
+ return 1
174
+
175
+ msg_type = "group" if args.target_type == "group" else "private"
176
+ target_id = args.target_id
177
+
178
+ # Build message segments
179
+ segments = [{"type": "reply", "data": {"id": args.message_id}}]
180
+ message_text = args.message or args.message_text
181
+ if not message_text:
182
+ print("Error: reply message is required (positional or --message)", file=sys.stderr)
183
+ return 1
184
+ if args.at:
185
+ for uid in args.at:
186
+ segments.append({"type": "at", "data": {"qq": uid}})
187
+
188
+ if args.file:
189
+ file_path = args.file
190
+ orig_name = Path(file_path).name
191
+ if file_path.startswith(("http://", "https://", "file://", "base64://")):
192
+ pass
193
+ elif Path(file_path).exists():
194
+ file_path = "file://" + str(Path(file_path).resolve())
195
+ else:
196
+ print(f"Warning: file not found '{args.file}', sending as-is (NapCat may reject)", file=sys.stderr)
197
+ segments.append({"type": "file", "data": {"file": file_path, "name": orig_name}})
198
+ elif args.image:
199
+ image_path = args.image
200
+ if image_path.startswith(("http://", "https://", "file://", "base64://")):
201
+ pass
202
+ elif Path(image_path).exists():
203
+ try:
204
+ data = Path(image_path).read_bytes()
205
+ b64 = base64.b64encode(data).decode()
206
+ segments.append({"type": "image", "data": {"file": f"base64://{b64}"}})
207
+ except Exception:
208
+ image_path = "file://" + str(Path(image_path).resolve())
209
+ segments.append({"type": "image", "data": {"file": image_path}})
210
+ else:
211
+ print(f"Warning: image not found '{args.image}', sending as-is", file=sys.stderr)
212
+ segments.append({"type": "image", "data": {"file": image_path}})
213
+
214
+ segments.append({"type": "text", "data": {"text": message_text}})
215
+
216
+ kwargs: dict = {"message_type": msg_type, "message": segments}
217
+ if msg_type == "group":
218
+ kwargs["group_id"] = target_id
219
+ else:
220
+ kwargs["user_id"] = target_id
221
+
222
+ result = api.call("send_msg", **kwargs)
223
+
224
+ if getattr(args, "json", False):
225
+ print(json.dumps(result, indent=2, ensure_ascii=False))
226
+ return 0 if result.get("retcode") == 0 else 1
227
+
228
+ if result.get("retcode") == 0:
229
+ data = result.get("data", {})
230
+ msg_id = data.get("message_id", "unknown")
231
+ print(f"Replied with message_id={msg_id}", file=sys.stderr)
232
+ return 0
233
+ else:
234
+ print(f"Error: {result.get('message', 'Unknown error')}", file=sys.stderr)
235
+ return 1
236
+
237
+
238
+ def cmd_recall(args: argparse.Namespace, api: NapCatAPI) -> int:
239
+ """napcat recall - Recall a message."""
240
+ if not require_online(api):
241
+ return 1
242
+ kwargs: dict = {"message_id": args.message_id}
243
+ if args.group:
244
+ kwargs["group_id"] = args.group
245
+
246
+ result = api.call("delete_msg", **kwargs)
247
+
248
+ if result.get("retcode") == 0:
249
+ print("Message recalled successfully", file=sys.stderr)
250
+ return 0
251
+ else:
252
+ print(f"Error: {result.get('message', result)}", file=sys.stderr)
253
+ return 1
254
+
255
+
256
+ def cmd_group(args: argparse.Namespace, api: NapCatAPI) -> int:
257
+ """napcat group - Group management."""
258
+ sub = args.subcommand
259
+ if not require_online(api):
260
+ return 1
261
+
262
+ if sub == "info":
263
+ result = api.call("get_group_info", group_id=args.group_id, no_cache=args.no_cache)
264
+ elif sub == "members":
265
+ result = api.call("get_group_member_list", group_id=args.group_id, no_cache=args.no_cache)
266
+ elif sub == "member":
267
+ result = api.call("get_group_member_info", group_id=args.group_id, user_id=args.user_id, no_cache=args.no_cache)
268
+ elif sub == "mute":
269
+ secs = args.duration or 30 * 60
270
+ result = api.call("set_group_ban", group_id=args.group_id, user_id=args.user_id, duration=secs)
271
+ elif sub == "unmute":
272
+ result = api.call("set_group_ban", group_id=args.group_id, user_id=args.user_id, duration=0)
273
+ elif sub == "kick":
274
+ kwargs: dict = {"group_id": args.group_id, "user_id": args.user_id}
275
+ if hasattr(args, 'reject'):
276
+ kwargs["reject_add_request"] = True
277
+ result = api.call("set_group_kick", **kwargs)
278
+ elif sub == "admin":
279
+ result = api.call("set_group_admin", group_id=args.group_id, user_id=args.user_id, enable=args.enable)
280
+ elif sub == "rename":
281
+ result = api.call("set_group_card", group_id=args.group_id, user_id=args.user_id, card=args.card)
282
+ elif sub == "remark":
283
+ result = api.call("set_group_remark", group_id=args.group_id, remark=args.remark)
284
+ elif sub == "announce":
285
+ result = api.call("send_group_notice", group_id=args.group_id, content=args.content)
286
+ if result.get("retcode") == 200 and result.get("data") is None:
287
+ print("Group announcements are not supported by this NapCat instance.", file=sys.stderr)
288
+ elif sub == "list":
289
+ kwargs = {}
290
+ if args.limit is not None:
291
+ kwargs["limit"] = args.limit
292
+ result = api.call("get_group_list", **kwargs)
293
+ if args.limit is not None and result.get("retcode") == 0 and result.get("data"):
294
+ result["data"] = result["data"][:args.limit]
295
+ elif sub == "essence":
296
+ result = api.call("get_essence_list", group_id=args.group_id)
297
+ if result.get("retcode") == 200 and result.get("data") is None:
298
+ print("Essence messages are not supported by this NapCat instance.", file=sys.stderr)
299
+ elif sub == "set_essence":
300
+ result = api.call("set_essence", group_id=args.group_id, message_id=args.message_id)
301
+ elif sub == "delete_essence":
302
+ result = api.call("delete_essence", group_id=args.group_id, message_id=args.message_id)
303
+ elif sub == "poke":
304
+ result = api.call("send_poke", group_id=args.group_id, user_id=args.user_id)
305
+ if result.get("retcode") == 200 and result.get("data") is None:
306
+ print("Group poke is not supported by this NapCat instance.", file=sys.stderr)
307
+ else:
308
+ print(f"Unknown group command: {sub}", file=sys.stderr)
309
+ return 1
310
+
311
+ print(json.dumps(result, indent=2, ensure_ascii=False))
312
+ return 0 if result.get("retcode") == 0 else 1
313
+
314
+
315
+ def cmd_friend(args: argparse.Namespace, api: NapCatAPI) -> int:
316
+ """napcat friend - Friend management."""
317
+ sub = args.subcommand
318
+ if not require_online(api):
319
+ return 1
320
+
321
+ if sub == "list":
322
+ result = api.call("get_friend_list", no_cache=args.no_cache)
323
+ elif sub == "remark":
324
+ result = api.call("set_friend_remark", user_id=args.user_id, remark=args.remark)
325
+ elif sub == "add":
326
+ print("Error: QQ protocol does not support proactive friend requests. "
327
+ "Friend requests can only be approved/rejected via 'napcat api' using "
328
+ "set_friend_add_request with the flag from an incoming request event.", file=sys.stderr)
329
+ return 1
330
+ elif sub == "delete":
331
+ result = api.call("delete_friend", user_id=args.user_id)
332
+ else:
333
+ print(f"Unknown friend command: {sub}", file=sys.stderr)
334
+ return 1
335
+
336
+ print(json.dumps(result, indent=2, ensure_ascii=False))
337
+ return 0 if result.get("retcode") == 0 else 1
338
+
339
+
340
+ def cmd_file(args: argparse.Namespace, api: NapCatAPI) -> int:
341
+ """napcat file - File operations."""
342
+ if not require_online(api):
343
+ return 1
344
+ sub = args.subcommand
345
+
346
+ if sub == "upload-group":
347
+ file_path = _normalize_file_path(args.file)
348
+ kwargs: dict = {"group_id": args.group_id, "file": file_path, "name": args.name or Path(args.file).name}
349
+ if args.folder:
350
+ kwargs["folder"] = args.folder
351
+ result = api.call("upload_group_file", **kwargs)
352
+ elif sub == "upload-private":
353
+ file_path = _normalize_file_path(args.file)
354
+ result = api.call("upload_private_file", user_id=args.user_id, file=file_path, name=args.name or Path(args.file).name)
355
+ elif sub == "list-group":
356
+ result = api.call("get_group_root_files", group_id=args.group_id)
357
+ elif sub == "list-folder":
358
+ result = api.call("get_group_files_by_folder", group_id=args.group_id, folder_id=args.folder_id)
359
+ elif sub == "info":
360
+ result = api.call("get_file", group_id=args.group_id, file_id=args.file_id)
361
+ elif sub == "download":
362
+ result = api.call("get_file", group_id=args.group_id, file_id=args.file_id)
363
+ if result.get("retcode") == 0:
364
+ local_path = result["data"]["file"]
365
+ print(f"File available at: {local_path}", file=sys.stderr)
366
+ if args.output_dir:
367
+ import shutil
368
+ dst = Path(args.output_dir) / Path(local_path).name
369
+ shutil.copy2(local_path, dst)
370
+ print(f"Copied to: {dst}", file=sys.stderr)
371
+ else:
372
+ print(f"Unknown file command: {sub}", file=sys.stderr)
373
+ return 1
374
+
375
+ print(json.dumps(result, indent=2, ensure_ascii=False))
376
+ return 0 if result.get("retcode") == 0 else 1
377
+
378
+
379
+ def cmd_daemon(args: argparse.Namespace, api: NapCatAPI) -> int:
380
+ """napcat daemon - Manage the watch daemon."""
381
+ import subprocess
382
+ # daemon launched via -m
383
+
384
+ if args.subcommand == "start":
385
+ # Refuse to stack a second daemon on the same port — multiple daemons
386
+ # each spawning their own skills-fs on one mountpoint is what deadlocks.
387
+ cfg = get_config()
388
+ if _port_in_use(cfg.http_port):
389
+ print(f"Error: http_port {cfg.http_port} already in use (another daemon running, "
390
+ f"or a stale/D-state process holding it). Stop it first or change http_port.",
391
+ file=sys.stderr)
392
+ return 1
393
+ # Check for existing daemon before starting a new one
394
+ pid_file = DATA_DIR / "daemon.pid"
395
+ if pid_file.exists():
396
+ existing_pid = int(pid_file.read_text().strip())
397
+ try:
398
+ os.kill(existing_pid, 0) # Check if alive without killing
399
+ print(f"Daemon already running (PID {existing_pid}). Use 'napcat daemon stop' first.", file=sys.stderr)
400
+ return 1
401
+ except ProcessLookupError:
402
+ pid_file.unlink() # Stale PID file, clean up
403
+ # Write config for daemon
404
+ cfg = get_config()
405
+ cfg_path = DATA_DIR / "daemon.json"
406
+ cfg_dict = {
407
+ "self_id": cfg.self_id or "",
408
+ "wake_command": cfg.wake_command,
409
+ "wake_on_event": cfg.wake_on_event,
410
+ "ws_port": cfg.ws_port,
411
+ "http_port": cfg.http_port,
412
+ "group_trigger_word": cfg.group_trigger_word,
413
+ "private_trigger": cfg.private_trigger,
414
+ "skills_fs_enabled": cfg.skills_fs_enabled,
415
+ "skills_fs_mountpoint": cfg.skills_fs_mountpoint,
416
+ "skills_fs_binary": cfg.skills_fs_binary,
417
+ "skills_fs_config": cfg.skills_fs_config,
418
+ "wake_enabled": cfg.wake_enabled,
419
+ "wake_preset": cfg.wake_preset,
420
+ "wake_primary": cfg.wake_primary,
421
+ "wake_session": cfg.wake_session,
422
+ "wake_http_url": cfg.wake_http_url,
423
+ "wake_http_session_id": cfg.wake_http_session_id,
424
+ "wake_cli_command": cfg.wake_cli_command,
425
+ "wake_debounce_seconds": cfg.wake_debounce_seconds,
426
+ "wake_cooldown_seconds": cfg.wake_cooldown_seconds,
427
+ "wake_new_message_idle_seconds": cfg.wake_new_message_idle_seconds,
428
+ }
429
+ cfg_path.write_text(json.dumps(cfg_dict, indent=2))
430
+
431
+ # Launch daemon as background process. Put the source tree on PYTHONPATH
432
+ # so `-m napcat_cli.daemon.watch` resolves regardless of the caller's cwd
433
+ # (no system-pip install required).
434
+ repo_root = str(Path(__file__).resolve().parents[1])
435
+ env = os.environ.copy()
436
+ env["PYTHONPATH"] = repo_root + (os.pathsep + env["PYTHONPATH"]) if env.get("PYTHONPATH") else repo_root
437
+ cmd = [sys.executable, "-m", "napcat_cli.daemon.watch", str(cfg_path)]
438
+ proc = subprocess.Popen(cmd, stdout=open(DATA_DIR / "daemon.log", "a"), stderr=subprocess.STDOUT, env=env)
439
+ print(f"Daemon started (PID: {proc.pid})", file=sys.stderr)
440
+ print(f"Log: {DATA_DIR / 'daemon.log'}")
441
+ return 0
442
+
443
+ elif args.subcommand == "stop":
444
+ pid_file = DATA_DIR / "daemon.pid"
445
+ if pid_file.exists():
446
+ pid = int(pid_file.read_text().strip())
447
+ try:
448
+ os.kill(pid, 15)
449
+ pid_file.unlink()
450
+ print(f"Daemon (PID {pid}) stopped", file=sys.stderr)
451
+ except ProcessLookupError:
452
+ print("Daemon not running", file=sys.stderr)
453
+ pid_file.unlink()
454
+ return 0
455
+ return 0
456
+ else:
457
+ print("No daemon PID found", file=sys.stderr)
458
+ return 1
459
+
460
+ elif args.subcommand == "status":
461
+ pid_file = DATA_DIR / "daemon.pid"
462
+ if pid_file.exists():
463
+ pid = int(pid_file.read_text().strip())
464
+ try:
465
+ os.kill(pid, 0)
466
+ print(f"Daemon running (PID: {pid})", file=sys.stderr)
467
+ log = DATA_DIR / "daemon.log"
468
+ if log.exists():
469
+ lines = log.read_text().splitlines()[-10:]
470
+ print("Recent log:", file=sys.stderr)
471
+ for line in lines:
472
+ print(f" {line}", file=sys.stderr)
473
+ return 0
474
+ except ProcessLookupError:
475
+ print("Daemon not running (stale PID file)", file=sys.stderr)
476
+ return 1
477
+ else:
478
+ print("Daemon not running", file=sys.stderr)
479
+ return 1
480
+
481
+ else:
482
+ print(f"Unknown daemon command: {args.subcommand}", file=sys.stderr)
483
+ return 1
484
+
485
+
486
+ def cmd_events(args: argparse.Namespace, api: NapCatAPI) -> int:
487
+ """napcat events - Read events from the filesystem bridge."""
488
+ reader = EventsReader(DATA_DIR)
489
+ events = reader.read(limit=args.limit, event_type=args.type, since=args.since)
490
+ if getattr(args, "no_heartbeat", False):
491
+ events = [e for e in events if e.get("meta_event_type") != "heartbeat"]
492
+
493
+ if args.output == "json":
494
+ print(json.dumps(events, indent=2, ensure_ascii=False))
495
+ else:
496
+ for ev in events:
497
+ ts = ev.get("time", 0)
498
+ ptype = ev.get("post_type", "?")
499
+ ntype = ev.get("notice_type", ev.get("request_type", "?"))
500
+ msg = ev.get("message", ev.get("raw_message", ""))
501
+ sender = ev.get("sender", {})
502
+ nickname = sender.get("nickname", "?") if isinstance(sender, dict) else "?"
503
+ print(f"[{ts}] {ptype}/{ntype} from {nickname}: {msg[:100]}")
504
+
505
+ return 0
506
+
507
+
508
+ def cmd_alerts(args: argparse.Namespace, api: NapCatAPI) -> int:
509
+ """napcat alerts - Check or clear alerts from SQLite database."""
510
+ from napcat_cli.lib.events import EventsReader
511
+ reader = EventsReader(DATA_DIR)
512
+
513
+ if getattr(args, "subcommand", None) == "clear":
514
+ reader._get_conn()
515
+ from napcat_cli.lib.events_sqlite import clear_alerts
516
+ count = clear_alerts(reader._conn)
517
+ print(f"All alerts cleared ({count} removed)", file=sys.stderr)
518
+ return 0
519
+
520
+ alerts = reader.read_alerts()
521
+ if not alerts:
522
+ print("No pending alerts", file=sys.stderr)
523
+ return 0
524
+
525
+ print(f"Pending alerts ({len(alerts)}):", file=sys.stderr)
526
+ for a in alerts:
527
+ name = a.get("name", "?")
528
+ summary = a.get("summary", a.get("text", "?"))
529
+ print(f" [{name}] {summary[:80]}", file=sys.stderr)
530
+
531
+ return 0 if alerts else 1
532
+ def cmd_batch(args: argparse.Namespace, api: NapCatAPI) -> int:
533
+ """napcat batch - Batch operations (kick, mute, unmute)."""
534
+ if not require_online(api):
535
+ return 1
536
+
537
+ batch_cmd = getattr(args, "batch_command", None)
538
+ group_id = str(args.group_id)
539
+ user_ids = [str(uid) for uid in args.user_ids]
540
+ results = []
541
+
542
+ if batch_cmd == "kick":
543
+ reject = getattr(args, "reject", False)
544
+ for uid in user_ids:
545
+ kwargs = {"group_id": group_id, "user_id": uid}
546
+ if reject:
547
+ kwargs["reject_add_request"] = True
548
+ result = api.call("set_group_kick", **kwargs)
549
+ results.append({"user_id": uid, "action": "kick", "result": result})
550
+
551
+ elif batch_cmd == "mute":
552
+ duration = getattr(args, "duration", 1800)
553
+ for uid in user_ids:
554
+ result = api.call("set_group_ban", group_id=group_id, user_id=uid, duration=duration)
555
+ results.append({"user_id": uid, "action": "mute", "result": result})
556
+
557
+ elif batch_cmd == "unmute":
558
+ for uid in user_ids:
559
+ result = api.call("set_group_ban", group_id=group_id, user_id=uid, duration=0)
560
+ results.append({"user_id": uid, "action": "unmute", "result": result})
561
+
562
+ else:
563
+ print("Unknown batch command. Use: kick, mute, unmute", file=sys.stderr)
564
+ return 1
565
+
566
+ # Summary
567
+ success = sum(1 for r in results if r["result"].get("retcode") == 0 or r["result"].get("status") == "ok")
568
+ failed = len(results) - success
569
+ print(f"Batch {batch_cmd}: {success}/{len(results)} succeeded", file=sys.stderr)
570
+ if failed:
571
+ print(f" Failed: {failed} operations", file=sys.stderr)
572
+
573
+ print(json.dumps(results, indent=2, ensure_ascii=False))
574
+ return 0 if failed == 0 else 1
575
+
576
+
577
+ def cmd_config(args: argparse.Namespace, api: NapCatAPI) -> int:
578
+ """napcat config - Manage configuration."""
579
+ cfg = get_config()
580
+
581
+ if args.subcommand == "get":
582
+ key = args.key
583
+ val = getattr(cfg, key, None)
584
+ if val is not None:
585
+ print(val)
586
+ else:
587
+ print(f"Unknown key: {key}", file=sys.stderr)
588
+ return 1
589
+ return 0
590
+
591
+ elif args.subcommand == "set":
592
+ cfg.set(args.key, args.value)
593
+ cfg.save()
594
+ print(f"Set {args.key} = {args.value}", file=sys.stderr)
595
+ return 0
596
+
597
+ elif args.subcommand == "show":
598
+ items = [
599
+ ("api_url", cfg.api_url),
600
+ ("token", cfg.token),
601
+ ("self_id", cfg.self_id),
602
+ ("data_dir", str(DATA_DIR)),
603
+ ("webhook_port", cfg.webhook_port),
604
+ ("ws_port", cfg.ws_port),
605
+ ("http_port", cfg.http_port),
606
+ ("wake_on_event", cfg.wake_on_event),
607
+ ("wake_command", cfg.wake_command),
608
+ ("wake_enabled", cfg.wake_enabled),
609
+ ("wake_preset", cfg.wake_preset),
610
+ ("wake_primary", cfg.wake_primary),
611
+ ("wake_session", cfg.wake_session),
612
+ ("wake_http_url", cfg.wake_http_url),
613
+ ("wake_http_key", "(set)" if cfg.wake_http_key else ""),
614
+ ("wake_http_session_id", cfg.wake_http_session_id),
615
+ ("wake_cli_command", cfg.wake_cli_command),
616
+ ("wake_debounce_seconds", cfg.wake_debounce_seconds),
617
+ ("wake_cooldown_seconds", cfg.wake_cooldown_seconds),
618
+ ("wake_new_message_idle_seconds", cfg.wake_new_message_idle_seconds),
619
+ ]
620
+ for key, val in items:
621
+ print(f"{key}: {val}")
622
+ return 0
623
+
624
+ else:
625
+ print(f"Unknown config command: {args.subcommand}", file=sys.stderr)
626
+ return 1
627
+
628
+
629
+ def cmd_status(args: argparse.Namespace, api: NapCatAPI) -> int:
630
+ """napcat status - Check bot status."""
631
+ # Check login info
632
+ login = api.call("get_login_info")
633
+ if login.get("retcode") != 0:
634
+ print("Not logged in", file=sys.stderr)
635
+ return 1
636
+
637
+ # Check online status
638
+ status = api.call("get_status")
639
+ data = status.get("data", {})
640
+ online = data.get("online", False)
641
+ good = data.get("good", False)
642
+
643
+ nickname = login["data"].get("nickname", "Unknown")
644
+ user_id = login["data"].get("user_id", "Unknown")
645
+
646
+ status_text = "在线" if online else "离线"
647
+ health_text = "状态良好" if good else "状态异常"
648
+ print(f"Logged in as: {nickname} ({user_id})", file=sys.stderr)
649
+ print(f"Status: {status_text} ({health_text})", file=sys.stderr)
650
+
651
+ # Update config with self_id
652
+ cfg = get_config()
653
+ if cfg.self_id is None:
654
+ cfg.self_id = str(login["data"].get("user_id", ""))
655
+ cfg.save()
656
+
657
+ # Print full JSON
658
+ print(json.dumps({"login": login["data"], "status": data}, indent=2, ensure_ascii=False))
659
+
660
+ if not online:
661
+ print("Bot is offline. Try 'napcat daemon start' to restart, or check WebUI at http://127.0.0.1:6099/webui", file=sys.stderr)
662
+ return 1
663
+ return 0
664
+
665
+
666
+ def cmd_ocr(args: argparse.Namespace, api: NapCatAPI) -> int:
667
+ """napcat ocr - OCR an image."""
668
+ if not require_online(api):
669
+ return 1
670
+ image_path = args.image
671
+ if not image_path.startswith(("http://", "https://", "file://", "base64://")):
672
+ if Path(image_path).exists():
673
+ image_path = "file://" + str(Path(image_path).resolve())
674
+ else:
675
+ print(f"Error: image file not found: {args.image}", file=sys.stderr)
676
+ return 1
677
+ result = api.call("ocr_image", image=image_path)
678
+ if result.get("retcode") != 0:
679
+ msg = result.get("message", "")
680
+ if "not supported" in str(msg).lower() or "not exist" in str(msg).lower():
681
+ print(f"Error: OCR is not supported by this NapCat instance: {msg}", file=sys.stderr)
682
+ else:
683
+ print(f"Error: OCR failed: {msg}", file=sys.stderr)
684
+ print(json.dumps(result, indent=2, ensure_ascii=False))
685
+ return 0 if result.get("retcode") == 0 else 1
686
+
687
+
688
+ def cmd_translate(args: argparse.Namespace, api: NapCatAPI) -> int:
689
+ """napcat translate - QQ translation (may not be available in all NapCat versions)."""
690
+ if not require_online(api):
691
+ return 1
692
+ result = api.call("qq_translate", text=args.text, from_lang=args.from_lang, to_lang=args.to_lang)
693
+ if result.get("retcode") != 0:
694
+ msg = result.get("message", "")
695
+ if "not supported" in str(msg).lower() or "not exist" in str(msg).lower():
696
+ print(f"Error: QQ translation is not supported by this NapCat instance: {msg}", file=sys.stderr)
697
+ else:
698
+ print(f"Translation failed: {msg}", file=sys.stderr)
699
+ print(json.dumps(result, indent=2, ensure_ascii=False))
700
+ return 0 if result.get("retcode") == 0 else 1
701
+
702
+
703
+ def cmd_like(args: argparse.Namespace, api: NapCatAPI) -> int:
704
+ """napcat like - Send profile like (thumbs up)."""
705
+ if not require_online(api):
706
+ return 1
707
+ times = max(1, min(10, args.times or 1))
708
+ result = api.call("send_like", user_id=args.user_id, times=times)
709
+ if result.get("retcode") == 0:
710
+ print(f"Liked user {args.user_id} ({times} time(s))", file=sys.stderr)
711
+ return 0
712
+ else:
713
+ print(f"Error: {result.get('message', result)}", file=sys.stderr)
714
+ return 1
715
+
716
+
717
+
718
+ def cmd_send_forward(args: argparse.Namespace, api: NapCatAPI) -> int:
719
+ """napcat send_forward - Forward a message to a group."""
720
+ if api.is_api_supported("napcat_forward_msg") is False:
721
+ print("Error: API 'napcat_forward_msg' is not supported by this NapCat instance.", file=sys.stderr)
722
+ return 1
723
+ if not require_online(api):
724
+ return 1
725
+ result = api.call("napcat_forward_msg", group_id=args.group_id, message_id=args.message_id)
726
+ print(json.dumps(result, indent=2, ensure_ascii=False))
727
+ return 0 if result.get("retcode") == 0 else 1
728
+
729
+
730
+ def cmd_send_poke(args: argparse.Namespace, api: NapCatAPI) -> int:
731
+ """napcat send_poke - Poke a member in a group."""
732
+ if not require_online(api):
733
+ return 1
734
+ if args.target_type == "group":
735
+ result = api.call("send_poke", group_id=args.target_id, user_id=args.user_id)
736
+ else:
737
+ result = api.call("send_poke", user_id=args.target_id)
738
+ if result.get("retcode") != 0:
739
+ msg = result.get("message", result)
740
+ if "private" in str(msg).lower() or args.target_type == "private":
741
+ print(f"Error: Private poke is not supported by this NapCat instance: {msg}", file=sys.stderr)
742
+ else:
743
+ print(f"Error: {msg}", file=sys.stderr)
744
+ print(json.dumps(result, indent=2, ensure_ascii=False))
745
+ return 0 if result.get("retcode") == 0 else 1
746
+
747
+
748
+ def cmd_create_schedule(args: argparse.Namespace, api: NapCatAPI) -> int:
749
+ """napcat create_schedule - Create a scheduled message."""
750
+ if api.is_api_supported("create_schedule") is False:
751
+ print("Error: API 'create_schedule' is not supported by this NapCat instance.", file=sys.stderr)
752
+ return 1
753
+ if not require_online(api):
754
+ return 1
755
+ msg_type = args.target_type
756
+ target_id = args.target_id
757
+ message = args.message
758
+ if args.message_type == "array":
759
+ try:
760
+ message = json.loads(message)
761
+ except json.JSONDecodeError:
762
+ print(f"Error: message must be valid JSON when --message-type=array", file=sys.stderr)
763
+ return 1
764
+ kwargs: dict = {
765
+ "message": message,
766
+ "repeat": args.repeat or args.times,
767
+ "interval": args.repeat_interval,
768
+ }
769
+ if msg_type == "group":
770
+ kwargs["group_id"] = target_id
771
+ else:
772
+ kwargs["user_id"] = target_id
773
+ result = api.call("create_schedule", **kwargs)
774
+ if result.get("retcode") != 0:
775
+ msg = result.get("message", "")
776
+ if "not supported" in str(msg).lower() or "not exist" in str(msg).lower() or "no such" in str(msg).lower():
777
+ print(f"Error: Scheduled messages are not supported by this NapCat instance: {msg}", file=sys.stderr)
778
+ else:
779
+ print(f"Error: {msg}", file=sys.stderr)
780
+ print(json.dumps(result, indent=2, ensure_ascii=False))
781
+ return 0 if result.get("retcode") == 0 else 1
782
+
783
+
784
+ def cmd_schedule_list(args: argparse.Namespace, api: NapCatAPI) -> int:
785
+ """napcat schedule list - List scheduled messages."""
786
+ if api.is_api_supported("get_schedule_list") is False:
787
+ print("Error: API 'get_schedule_list' is not supported by this NapCat instance.", file=sys.stderr)
788
+ return 1
789
+ if not require_online(api):
790
+ return 1
791
+ kwargs: dict = {}
792
+ if args.group:
793
+ kwargs["group_id"] = args.group
794
+ if args.user:
795
+ kwargs["user_id"] = args.user
796
+ result = api.call("get_schedule_list", **kwargs)
797
+ if result.get("retcode") != 0:
798
+ msg = result.get("message", "")
799
+ if "not supported" in str(msg).lower() or "not exist" in str(msg).lower() or "no such" in str(msg).lower():
800
+ print(f"Error: Scheduled messages are not supported by this NapCat instance: {msg}", file=sys.stderr)
801
+ else:
802
+ print(f"Error: {msg}", file=sys.stderr)
803
+ print(json.dumps(result, indent=2, ensure_ascii=False))
804
+ return 0 if result.get("retcode") == 0 else 1
805
+
806
+
807
+ def cmd_schedule_cancel(args: argparse.Namespace, api: NapCatAPI) -> int:
808
+ """napcat schedule cancel - Cancel a scheduled message."""
809
+ if not require_online(api):
810
+ return 1
811
+ kwargs: dict = {"schedule_id": args.schedule_id}
812
+ if args.group:
813
+ kwargs["group_id"] = args.group
814
+ if args.user:
815
+ kwargs["user_id"] = args.user
816
+ result = api.call("delete_schedule", **kwargs)
817
+ if result.get("retcode") != 0:
818
+ msg = result.get("message", "")
819
+ if "not supported" in str(msg).lower() or "not exist" in str(msg).lower() or "no such" in str(msg).lower():
820
+ print(f"Error: Scheduled messages are not supported by this NapCat instance: {msg}", file=sys.stderr)
821
+ else:
822
+ print(f"Error: {msg}", file=sys.stderr)
823
+ print(json.dumps(result, indent=2, ensure_ascii=False))
824
+ return 0 if result.get("retcode") == 0 else 1
825
+
826
+
827
+ def cmd_schedule(args: argparse.Namespace, api: NapCatAPI) -> int:
828
+ """napcat schedule - Manage scheduled messages."""
829
+ sub = args.subcommand
830
+ if sub == "list":
831
+ return cmd_schedule_list(args, api)
832
+ elif sub == "cancel":
833
+ return cmd_schedule_cancel(args, api)
834
+ else:
835
+ print("Error: unknown schedule subcommand", file=sys.stderr)
836
+ return 1
837
+
838
+
839
+ def cmd_get_cookies(args: argparse.Namespace, api: NapCatAPI) -> int:
840
+ """napcat get_cookies - Get QQ web cookies."""
841
+ if not require_online(api):
842
+ return 1
843
+ result = api.call("napcat_get_cookies", domain=args.domain)
844
+ print(json.dumps(result, indent=2, ensure_ascii=False))
845
+ return 0 if result.get("retcode") == 0 else 1
846
+
847
+
848
+ def cmd_get_stranger_info(args: argparse.Namespace, api: NapCatAPI) -> int:
849
+ """napcat get_stranger_info - Get stranger info by QQ number."""
850
+ if not require_online(api):
851
+ return 1
852
+ kwargs: dict = {"user_id": str(args.user_id)}
853
+ if hasattr(args, "no_cache") and args.no_cache:
854
+ kwargs["no_cache"] = True
855
+ result = api.call("get_stranger_info", **kwargs)
856
+ print(json.dumps(result, indent=2, ensure_ascii=False))
857
+ return 0 if result.get("retcode") == 0 else 1
858
+
859
+
860
+ def cmd_react(args: argparse.Namespace, api: NapCatAPI) -> int:
861
+ """napcat react - Send emoji reaction on a group message."""
862
+ if not require_online(api):
863
+ return 1
864
+ result = api.call("set_msg_emoji_like", group_id=args.group_id, message_id=args.message_id, emoji_id=args.emoji_id)
865
+ if result.get("retcode") == 0:
866
+ print(f"Emoji reaction sent (emoji_id={args.emoji_id})", file=sys.stderr)
867
+ return 0
868
+ else:
869
+ print(f"Error: {result.get('message', result)}", file=sys.stderr)
870
+ return 1
871
+
872
+ def cmd_search(args: argparse.Namespace, api: NapCatAPI) -> int:
873
+ """napcat search - Search messages by keyword in event history."""
874
+ reader = EventsReader(DATA_DIR)
875
+ keyword = args.keyword or getattr(args, "keyword_flag", None)
876
+ if not keyword:
877
+ print("Error: keyword is required (use positional or --keyword)", file=sys.stderr)
878
+ return 1
879
+ events = reader.read(
880
+ limit=args.limit,
881
+ since=args.since,
882
+ event_type=args.event_type,
883
+ keyword=keyword,
884
+ )
885
+ results = events
886
+ print(f"Found {len(results)} matching messages", file=sys.stderr)
887
+ return 0
888
+
889
+
890
+ def cmd_msg(args: argparse.Namespace, api: NapCatAPI) -> int:
891
+ """napcat msg - Browse recent messages or query a specific message ID."""
892
+
893
+ # Direct message ID lookup
894
+ if args.message_id is not None:
895
+ reader = EventsReader(DATA_DIR)
896
+ msg_id = int(args.message_id)
897
+ events = reader.read(limit=500, event_type="message")
898
+ for e in events:
899
+ if e.get("message_id") == msg_id:
900
+ sender = e.get("sender", {})
901
+ msg = e.get("message", "")
902
+ text = ""
903
+ if isinstance(msg, list):
904
+ for seg in msg:
905
+ if seg.get("type") == "text":
906
+ text += seg.get("data", {}).get("text", "")
907
+ elif isinstance(msg, str):
908
+ text = msg
909
+ result = {
910
+ "message_id": msg_id,
911
+ "time": e.get("time"),
912
+ "sender": f"{sender.get('nickname', '?')} ({sender.get('user_id', '?')})",
913
+ "group_id": e.get("group_id", ""),
914
+ "message_type": e.get("message_type", ""),
915
+ "text": text[:500],
916
+ "raw_message": e.get("raw_message", ""),
917
+ }
918
+ print(json.dumps(result, indent=2, ensure_ascii=False))
919
+ return 0
920
+ print(f"Message ID {msg_id} not found in event history", file=sys.stderr)
921
+ return 1
922
+
923
+ # Browse mode
924
+ reader = EventsReader(DATA_DIR)
925
+ group_id: int | None = int(args.group_id) if args.group_id else None
926
+ user_id: int | None = int(args.user_id) if args.user_id else None
927
+ events = reader.read(
928
+ limit=args.limit,
929
+ since=args.since,
930
+ event_type="message",
931
+ group_id=group_id,
932
+ user_id=user_id,
933
+ )
934
+ results = []
935
+ for e in events:
936
+ sender = e.get("sender", {})
937
+ text = ""
938
+ msg = e.get("message", "")
939
+ if isinstance(msg, list):
940
+ for seg in msg:
941
+ if seg.get("type") == "text":
942
+ text += seg.get("data", {}).get("text", "")
943
+ elif isinstance(msg, str):
944
+ text = msg
945
+ results.append({
946
+ "time": e.get("time"),
947
+ "sender": f"{sender.get('nickname', '?')} ({sender.get('user_id', '?')})",
948
+ "group_id": e.get("group_id", ""),
949
+ "message_type": e.get("message_type", ""),
950
+ "message_id": e.get("message_id", ""),
951
+ "text": text[:200],
952
+ })
953
+ print(json.dumps(results, indent=2, ensure_ascii=False))
954
+ return 0
955
+
956
+ def cmd_phone(args: argparse.Namespace, api: NapCatAPI) -> int:
957
+ """napcat phone — Launch mobile-style TUI interface or run subcommand."""
958
+ import asyncio
959
+ import os as _os
960
+
961
+ if args.port:
962
+ _os.environ["NAPCAT_HTTP_PORT"] = str(args.port)
963
+
964
+ # Handle subcommands for scriptable CLI access
965
+ if hasattr(args, "phone_subcommand") and args.phone_subcommand:
966
+ subcmd = args.phone_subcommand
967
+ if subcmd == "status":
968
+ return cmd_status(args)
969
+ elif subcmd == "config":
970
+ return cmd_config_show(args)
971
+ elif subcmd == "alerts":
972
+ return cmd_alerts_check(args)
973
+ elif subcmd == "events":
974
+ limit = getattr(args, "limit", 50)
975
+ no_hb = getattr(args, "no_heartbeat", False)
976
+ fake_args = argparse.Namespace(limit=limit, no_heartbeat=no_hb, output=None, output_file=None, event_type=None, since=None, json=False, text=False, array=False)
977
+ return cmd_events(fake_args, api)
978
+ elif subcmd == "msg":
979
+ target_type = getattr(args, "target_type", None)
980
+ target_id = getattr(args, "target_id", None)
981
+ message = getattr(args, "message", None)
982
+ if not all([target_type, target_id, message]):
983
+ print("Error: target_type, target_id, and message are required", file=sys.stderr)
984
+ return 1
985
+ fake_args = argparse.Namespace(message_type=target_type, target_id=str(target_id), message=message, at=[], file=None, image=None, json=False)
986
+ return cmd_send(fake_args, api)
987
+ else:
988
+ print(f"Unknown phone subcommand: {subcmd}", file=sys.stderr)
989
+ return 1
990
+
991
+ if args.non_interactive:
992
+ # Quick status check mode
993
+ from napcat_cli.tui.api import get_client
994
+ client = get_client()
995
+ try:
996
+ events = asyncio.run(client.get_events(limit=5))
997
+ alerts = asyncio.run(client.get_alerts())
998
+ print(f"Events: {len(events)}, Alerts: {len(alerts)}")
999
+ for e in events[:3]:
1000
+ print(f" - {e.get('post_type', '?')}: {e.get('message_type', '')} {e.get('sender', {}).get('nickname', '')}")
1001
+ return 0
1002
+ except Exception as e:
1003
+ print(f"Error connecting to daemon: {e}", file=sys.stderr)
1004
+ return 1
1005
+
1006
+ # Full TUI mode
1007
+ from napcat_cli.tui.app import NapCatApp
1008
+ app = NapCatApp()
1009
+ app.run()
1010
+ return 0
1011
+
1012
+ def _call_provider(action: str, params: dict, port: int) -> dict:
1013
+ """Call the daemon HTTP provider at http://127.0.0.1:<port>/invoke."""
1014
+ import urllib.request
1015
+ import urllib.error
1016
+
1017
+ url = f"http://127.0.0.1:{port}/invoke"
1018
+ body = json.dumps({"action": action, "params": params}).encode("utf-8")
1019
+ req = urllib.request.Request(url, data=body, method="POST")
1020
+ req.add_header("Content-Type", "application/json")
1021
+
1022
+ try:
1023
+ with urllib.request.urlopen(req, timeout=10) as resp:
1024
+ raw = resp.read().decode("utf-8")
1025
+ return json.loads(raw)
1026
+ except urllib.error.HTTPError as e:
1027
+ raw = e.read().decode("utf-8", errors="replace")
1028
+ try:
1029
+ return json.loads(raw)
1030
+ except json.JSONDecodeError:
1031
+ return {"status": "error", "message": raw, "code": e.code}
1032
+ except urllib.error.URLError as e:
1033
+ return {"status": "error", "message": f"Connection failed: {e.reason}"}
1034
+ except Exception as e:
1035
+ return {"status": "error", "message": str(e)}
1036
+
1037
+
1038
+ def cmd_message(args: argparse.Namespace, api: NapCatAPI) -> int:
1039
+ """napcat message - Query message content via daemon provider."""
1040
+ cfg = get_config()
1041
+ port = cfg.http_port
1042
+
1043
+ params: dict = {"message_id": str(args.message_id)}
1044
+ if args.group:
1045
+ params["group_id"] = str(args.group)
1046
+ if args.user:
1047
+ params["user_id"] = str(args.user)
1048
+ if args.content:
1049
+ params["content"] = args.content
1050
+
1051
+ result = _call_provider("get_message_content", params, port)
1052
+
1053
+ if result.get("status") == "error":
1054
+ print(f"Error: {result.get('message', 'Unknown error')}", file=sys.stderr)
1055
+ return 1
1056
+
1057
+ if args.content:
1058
+ # Specific content requested — print that field
1059
+ key = args.content
1060
+ val = result.get(key)
1061
+ if val is not None:
1062
+ if isinstance(val, (dict, list)):
1063
+ print(json.dumps(val, indent=2, ensure_ascii=False))
1064
+ else:
1065
+ print(val)
1066
+ else:
1067
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1068
+ else:
1069
+ # No --content: print metadata + content summary
1070
+ meta = result.get("metadata", {})
1071
+ if meta:
1072
+ print("=== Message Metadata ===")
1073
+ for k, v in meta.items():
1074
+ print(f" {k}: {v}")
1075
+ print()
1076
+
1077
+ # Show content segments
1078
+ for key in ("text", "image", "file", "video", "record", "forward"):
1079
+ val = result.get(key)
1080
+ if val is not None:
1081
+ print(f"=== {key.upper()} ===")
1082
+ if isinstance(val, (dict, list)):
1083
+ print(json.dumps(val, indent=2, ensure_ascii=False))
1084
+ else:
1085
+ print(val)
1086
+ print()
1087
+
1088
+ return 0
1089
+
1090
+
1091
+ def _load_local_schemas() -> dict:
1092
+ """Load ACTION_SCHEMAS from daemon/schemas.py without importing."""
1093
+ schemas_file = ROOT / "daemon" / "schemas.py"
1094
+ if not schemas_file.exists():
1095
+ return {}
1096
+ raw = schemas_file.read_text()
1097
+ # Extract the ACTION_SCHEMAS dict with a simple AST-free approach:
1098
+ # Find the dict literal between ACTION_SCHEMAS = { and the closing }
1099
+ start = raw.find('ACTION_SCHEMAS')
1100
+ if start == -1:
1101
+ return {}
1102
+ brace_start = raw.find('{', start)
1103
+ if brace_start == -1:
1104
+ return {}
1105
+ # Count braces to find the matching close
1106
+ depth = 0
1107
+ for i, ch in enumerate(raw[brace_start:], start=brace_start):
1108
+ if ch == '{':
1109
+ depth += 1
1110
+ elif ch == '}':
1111
+ depth -= 1
1112
+ if depth == 0:
1113
+ expr = raw[brace_start:i + 1]
1114
+ break
1115
+ else:
1116
+ return {}
1117
+ try:
1118
+ return eval(expr, {"__builtins__": {}}, {})
1119
+ except Exception:
1120
+ return {}
1121
+
1122
+
1123
+ def cmd_schema(args: argparse.Namespace, api: NapCatAPI) -> int:
1124
+ """napcat schema - Describe an action schema."""
1125
+ cfg = get_config()
1126
+ port = cfg.http_port
1127
+
1128
+ local_schemas = _load_local_schemas()
1129
+
1130
+ # --list mode: print all available actions
1131
+ if args.list:
1132
+ # Also try to get from HTTP
1133
+ try:
1134
+ result = _call_provider("describe_action", {"action": "__list__"}, port)
1135
+ if isinstance(result, list) and result:
1136
+ actions = sorted(result)
1137
+ else:
1138
+ actions = sorted(local_schemas.keys())
1139
+ except Exception:
1140
+ actions = sorted(local_schemas.keys())
1141
+
1142
+ if not actions:
1143
+ print("No actions found.", file=sys.stderr)
1144
+ return 1
1145
+
1146
+ print(f"Available actions ({len(actions)}):")
1147
+ for a in actions:
1148
+ desc = local_schemas.get(a, {}).get("description", "")
1149
+ req = local_schemas.get(a, {}).get("required", [])
1150
+ req_str = f" [{', '.join(req)}]" if req else ""
1151
+ print(f" {a}{req_str}")
1152
+ if desc:
1153
+ print(f" {desc}")
1154
+ return 0
1155
+
1156
+ action = args.action
1157
+ if action is None:
1158
+ print("Error: action name required. Use 'napcat schema --list' to see available actions.", file=sys.stderr)
1159
+ return 1
1160
+
1161
+ # Try HTTP provider first
1162
+ result = _call_provider("describe_action", {"action": action}, port)
1163
+
1164
+ # If HTTP fails or action not found, fall back to local schemas.py
1165
+ if result.get("status") == "error" or not result.get("params"):
1166
+ if action in local_schemas:
1167
+ result = local_schemas[action]
1168
+ else:
1169
+ print(f"Error: unknown action '{action}'", file=sys.stderr)
1170
+ actions = sorted(local_schemas.keys()) if local_schemas else []
1171
+ if actions:
1172
+ print(f"Available actions: {', '.join(actions)}", file=sys.stderr)
1173
+ return 1
1174
+
1175
+ print(f"Action: {action}")
1176
+ print(f"Description: {result.get('description', 'N/A')}")
1177
+ print(f"Required params: {', '.join(result.get('required', [])) or 'none'}")
1178
+ print(f"All params: {', '.join(result.get('params', [])) or 'none'}")
1179
+ example = result.get("example", {})
1180
+ if example:
1181
+ print(f"Example:")
1182
+ print(json.dumps(example, indent=2, ensure_ascii=False))
1183
+
1184
+ return 0
1185
+
1186
+
1187
+ FS_TREE = """\
1188
+ /napcat/
1189
+ ├── AGENTS.md # Full guide
1190
+
1191
+ ├── Legacy write files (root level):
1192
+ │ ├── send_group # JSON: {"message": [...]}
1193
+ │ ├── send_private # JSON: {"user_id": N, "message": [...]}
1194
+ │ ├── delete # JSON: {"message_id": N}
1195
+ │ ├── clear_alert # JSON: {"alert_name": "..."}
1196
+ │ ├── clear_all_alerts # {}
1197
+ │ ├── group_kick # JSON: {"user_id": N}
1198
+ │ ├── group_ban # JSON: {"user_id": N, "duration": N}
1199
+ │ ├── group_admin # JSON: {"user_id": N, "enable": true/false}
1200
+ │ ├── group_card # JSON: {"user_id": N, "card": "..."}
1201
+ │ ├── group_name # JSON: {"name": "..."}
1202
+ │ ├── group_leave # {}
1203
+ │ ├── group_announce # JSON: {"content": "..."}
1204
+ │ ├── group_essence # JSON: {"message_id": N}
1205
+ │ ├── delete_essence # JSON: {"message_id": N}
1206
+ │ ├── friend_add # JSON: {"user_id": N, "remark": "..."}
1207
+ │ ├── friend_delete # JSON: {"user_id": N}
1208
+ │ ├── friend_remark # JSON: {"user_id": N, "remark": "..."}
1209
+ │ └── send_poke # JSON: {"user_id": N}
1210
+
1211
+ ├── groups/:group_id/ # Per-group operations (path param: group_id)
1212
+ │ ├── send # JSON: {"message": [...]}
1213
+ │ ├── kick # JSON: {"user_id": N}
1214
+ │ ├── ban # JSON: {"user_id": N, "duration": N}
1215
+ │ ├── admin # JSON: {"user_id": N, "enable": true/false}
1216
+ │ ├── card # JSON: {"user_id": N, "card": "..."}
1217
+ │ ├── name # JSON: {"name": "..."}
1218
+ │ ├── leave # {}
1219
+ │ ├── info # (read-only)
1220
+ │ ├── members # (read-only)
1221
+ │ ├── essence_list # (read-only)
1222
+ │ ├── poke # JSON: {"user_id": N}
1223
+ │ ├── honor # (read-only)
1224
+ │ ├── announce # JSON: {"content": "..."}
1225
+ │ │
1226
+ │ └── :time_range/:message_id/ # Per-message content directory
1227
+ │ ├── metadata # sender, time, message_type, etc.
1228
+ │ ├── text # Plaintext of all text segments
1229
+ │ ├── image # URL, file, file_size, summary
1230
+ │ ├── image_2, image_3, ... # Additional images
1231
+ │ ├── file # File segment info
1232
+ │ ├── video # Video segment info
1233
+ │ ├── record # Voice record info
1234
+ │ └── forward # Forward message info
1235
+
1236
+ ├── friends/:user_id/ # Per-friend operations (path param: user_id)
1237
+ │ ├── send # JSON: {"message": [...]}
1238
+ │ ├── info # (read-only)
1239
+ │ ├── remark # JSON: {"remark": "..."}
1240
+ │ │
1241
+ │ └── :time_range/:message_id/ # Same structure as groups/ above
1242
+
1243
+ └── get_image # Legacy: get image by message_id
1244
+ """
1245
+
1246
+
1247
+ def _default_wake_prompt(reason: str) -> str:
1248
+ return (f"[napcat-cli 手动唤醒] reason={reason}。请用 `napcat events` / `napcat alerts` "
1249
+ f"查看最新动态,结合上下文酌情处理与回复。")
1250
+
1251
+
1252
+ def _build_waker_for_cli(cfg, transport: str | None = None):
1253
+ """Build a Waker from config, optionally overriding the primary transport."""
1254
+ from napcat_cli.wake_presets import build_waker
1255
+ if transport and transport in ("http", "cli"):
1256
+ import copy
1257
+ cfg = copy.copy(cfg)
1258
+ cfg.wake_primary = transport
1259
+ return build_waker(cfg)
1260
+
1261
+
1262
+ def cmd_wake(args: argparse.Namespace, api: NapCatAPI) -> int:
1263
+ """napcat wake - Deliver a wake to the configured agent (HTTP/CLI, auto-fallback)."""
1264
+ cfg = get_config()
1265
+ sub = getattr(args, "wake_subcommand", None)
1266
+
1267
+ if sub == "test":
1268
+ return _wake_test(cfg)
1269
+ if sub == "sessions":
1270
+ return _wake_sessions(cfg)
1271
+
1272
+ reason = getattr(args, "reason", "manual")
1273
+ prompt = getattr(args, "prompt", None) or _default_wake_prompt(reason)
1274
+ transport = getattr(args, "transport", None)
1275
+ dry_run = getattr(args, "dry_run", False)
1276
+ timeout = getattr(args, "wake_timeout", None) or 120.0
1277
+
1278
+ waker = _build_waker_for_cli(cfg, transport)
1279
+
1280
+ # No backend configured → legacy wake_command escape hatch, else guidance.
1281
+ if waker.empty:
1282
+ if cfg.wake_command:
1283
+ from napcat_cli.wake import build_wake_command
1284
+ cmd = build_wake_command(cfg.wake_command, reason)
1285
+ if dry_run:
1286
+ print(cmd)
1287
+ return 0
1288
+ import subprocess
1289
+ try:
1290
+ r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30)
1291
+ if r.stdout:
1292
+ print(r.stdout, end="")
1293
+ print(f"Legacy wake executed ({reason})", file=sys.stderr)
1294
+ return r.returncode
1295
+ except Exception as e:
1296
+ print(f"Legacy wake failed: {e}", file=sys.stderr)
1297
+ return 1
1298
+ print("No wake backend configured. Run `napcat setup` (Hermes preset) or set "
1299
+ "wake_preset/wake_session/wake_http_*/wake_cli_command.", file=sys.stderr)
1300
+ return 1
1301
+
1302
+ if dry_run:
1303
+ # Render every configured transport so the user sees what would fire.
1304
+ print(f"# dry-run — reason={reason}, primary={waker.primary}")
1305
+ any_shown = False
1306
+ for b in waker.backends:
1307
+ res = b.wake(prompt, reason, {}, "dry-run", dry_run=True)
1308
+ print(f"[{b.name}] {res.detail}")
1309
+ any_shown = True
1310
+ if not any_shown:
1311
+ print("(no transport configured)")
1312
+ return 0
1313
+
1314
+ res = waker.wake(prompt, reason, timeout=timeout)
1315
+ if res.ok:
1316
+ print(f"Wake delivered via {res.transport} ({reason}) — {res.detail}", file=sys.stderr)
1317
+ return 0
1318
+ print(f"Wake failed via {res.transport}: {res.detail}", file=sys.stderr)
1319
+ return 1
1320
+
1321
+
1322
+ def _wake_test(cfg) -> int:
1323
+ """Probe each configured wake transport."""
1324
+ waker = _build_waker_for_cli(cfg)
1325
+ print(f"preset={cfg.wake_preset} primary={cfg.wake_primary} session={cfg.wake_session}")
1326
+ if waker.empty:
1327
+ if cfg.wake_command:
1328
+ print(f" [legacy] wake_command is set: {cfg.wake_command[:80]}")
1329
+ else:
1330
+ print(" No wake backend configured. Run `napcat setup`.")
1331
+ return 1
1332
+ rc = 0
1333
+ for t in waker.test():
1334
+ tag = "OK" if (t["configured"] and t["reachable"]) else "--"
1335
+ print(f" [{tag}] {t['transport']:5} configured={t['configured']} reachable={t['reachable']} ({t['label']})")
1336
+ if t["configured"] and not t["reachable"]:
1337
+ rc = 1
1338
+ # CLI: also note whether the hermes binary is on PATH
1339
+ if cfg.wake_preset == "hermes":
1340
+ import shutil
1341
+ print(f" hermes on PATH: {bool(shutil.which('hermes'))}")
1342
+ return rc
1343
+
1344
+
1345
+ def _wake_sessions(cfg) -> int:
1346
+ """List agent sessions (Hermes /api/sessions when the HTTP backend is configured)."""
1347
+ waker = _build_waker_for_cli(cfg)
1348
+ sessions = waker.list_sessions()
1349
+ if sessions is None:
1350
+ print("Sessions listing requires the HTTP backend (configure wake_http_url + "
1351
+ "wake_http_key, e.g. via `napcat setup` opt-in).", file=sys.stderr)
1352
+ return 1
1353
+ if not sessions:
1354
+ print("No sessions found.")
1355
+ return 0
1356
+ print(f"Sessions ({len(sessions)}):")
1357
+ for s in sessions[:30]:
1358
+ print(f" {s.get('id','?'):40} {s.get('title','')[:50]} {s.get('last_active','')}")
1359
+ return 0
1360
+
1361
+
1362
+ def cmd_fs(args: argparse.Namespace, api: NapCatAPI) -> int:
1363
+ """napcat fs - Show skills-fs directory tree and workflow."""
1364
+ cfg = get_config()
1365
+ port = cfg.http_port
1366
+
1367
+ # Query /status for skills-fs health
1368
+ skills_fs_info = None
1369
+ try:
1370
+ import urllib.request
1371
+ import urllib.error
1372
+ import urllib.parse
1373
+ host = urllib.parse.urlparse(cfg.api_url).hostname or "127.0.0.1"
1374
+ url = f"http://{host}:{port}/status"
1375
+ req = urllib.request.Request(url)
1376
+ with urllib.request.urlopen(req, timeout=5) as resp:
1377
+ status_data = json.loads(resp.read().decode("utf-8"))
1378
+ skills_fs_info = status_data.get("skills_fs")
1379
+ except Exception:
1380
+ pass
1381
+
1382
+ if skills_fs_info:
1383
+ print("=== Skills-FS Status ===")
1384
+ st = skills_fs_info.get("status", "unknown")
1385
+ print(f" Status: {st}")
1386
+ mp = skills_fs_info.get("mountpoint", "")
1387
+ if mp:
1388
+ print(f" Mountpoint: {mp}")
1389
+ pid = skills_fs_info.get("pid")
1390
+ if pid:
1391
+ print(f" PID: {pid}")
1392
+ print()
1393
+ else:
1394
+ print("=== Skills-FS Status ===")
1395
+ print(" Not available (daemon not running or skills-fs disabled)")
1396
+ print()
1397
+
1398
+ # Do not traverse the FUSE mountpoint — it may block (D-state).
1399
+ print("Mountpoint contents: use `napcat events` / `napcat alerts`, or `ls <mountpoint>` manually.")
1400
+ print(" (CLI does not traverse the FUSE mount to avoid blocking.)")
1401
+ print()
1402
+
1403
+ # Show static tree & workflow
1404
+ print(FS_TREE)
1405
+ print("Workflow:")
1406
+ print(" 1. Read <file>.schema to see the expected JSON format.")
1407
+ print(" 2. Write JSON matching the schema to <file>.")
1408
+ print(" 3. Read <file> back to see the result (or error with schema hint).")
1409
+ print()
1410
+ print("Path parameters (:group_id, :user_id, :time_range, :message_id, :content)")
1411
+ print("are resolved by skills-fs before calling the provider.")
1412
+ print()
1413
+ print("Writeback: write errors return {\"error\": ..., \"expected_schema\": ...} on read.")
1414
+ return 0
1415
+ def main() -> int:
1416
+ parser = argparse.ArgumentParser(
1417
+ prog="napcat",
1418
+ description="NapCat QQ CLI - Standalone CLI and daemon for QQ bot management",
1419
+ )
1420
+ parser.add_argument("--api-url", default=None, help="Override NAPCAT_API_URL")
1421
+ parser.add_argument("--token", default=None, help="Override NAPCAT_TOKEN")
1422
+ parser.add_argument("--data-dir", default=None, help="Override NAPCAT_DATA_DIR")
1423
+ parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
1424
+ parser.add_argument("--timeout", type=int, default=None, help="API request timeout in seconds (default: 30)")
1425
+ parser.add_argument("--json", action="store_true", help="Output JSON only (suppress stderr)")
1426
+ parser.add_argument("--quiet", action="store_true", help="Suppress stderr output")
1427
+ parser.add_argument("--version", action="version", version=f"napcat-cli {__version__}")
1428
+
1429
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
1430
+
1431
+ # --- api ---
1432
+ api_p = subparsers.add_parser("api", help="Raw API access (like gh api)")
1433
+ api_p.add_argument("endpoint", help="API endpoint (e.g., get_login_info)")
1434
+ api_p.add_argument("--method", choices=["GET", "POST", "PUT", "DELETE"], default=None)
1435
+ api_p.add_argument("--data", "-d", help="JSON data for POST/PUT requests")
1436
+ api_p.add_argument("--output", "-o", choices=["json", "value", "raw"], default="json")
1437
+
1438
+ # --- send ---
1439
+ send_p = subparsers.add_parser("send", help="Send a message")
1440
+ send_p.add_argument("target_type", choices=["group", "private"])
1441
+ send_p.add_argument("target_id", help="Group ID or user ID")
1442
+ send_p.add_argument("message", nargs="?", help="Message text (or use --message)")
1443
+ send_p.add_argument("--message", dest="message_text", help="Message text")
1444
+ send_p.add_argument("--at", action="append", help="QQ number to @ (repeatable)")
1445
+ send_p.add_argument("--file", help="File to send")
1446
+ send_p.add_argument("--image", help="Image to send")
1447
+ send_p.add_argument("--json", action="store_true", help="Output JSON only")
1448
+
1449
+ # --- reply ---
1450
+ reply_p = subparsers.add_parser("reply", help="Reply to a message")
1451
+ reply_p.add_argument("target_type", choices=["group", "private"])
1452
+ reply_p.add_argument("target_id", help="Group ID or user ID")
1453
+ reply_p.add_argument("message_id", help="Message ID to reply to")
1454
+ reply_p.add_argument("message", nargs="?", help="Reply text (or use --message)")
1455
+ reply_p.add_argument("--message", dest="message_text", help="Reply text")
1456
+ reply_p.add_argument("--at", action="append", help="QQ number to @ (repeatable)")
1457
+ reply_p.add_argument("--file", help="File to attach")
1458
+ reply_p.add_argument("--image", help="Image to attach")
1459
+ reply_p.add_argument("--json", action="store_true", help="Output JSON only")
1460
+
1461
+ # --- recall ---
1462
+ recall_p = subparsers.add_parser("recall", help="Recall a message")
1463
+ recall_p.add_argument("message_id", help="Message ID to recall")
1464
+ recall_p.add_argument("--group", help="Group ID (for group messages)")
1465
+
1466
+ # --- group ---
1467
+ group_p = subparsers.add_parser("group", help="Group management")
1468
+ group_sub = group_p.add_subparsers(dest="subcommand")
1469
+
1470
+ gi = group_sub.add_parser("info", help="Get group info")
1471
+ gi.add_argument("group_id")
1472
+ gi.add_argument("--no-cache", action="store_true")
1473
+
1474
+ gm = group_sub.add_parser("members", help="List group members")
1475
+ gm.add_argument("group_id")
1476
+ gm.add_argument("--no-cache", action="store_true")
1477
+ gm.add_argument("--limit", type=int, default=None, help="Max events to return")
1478
+
1479
+ gmi = group_sub.add_parser("member", help="Get member info")
1480
+ gmi.add_argument("group_id")
1481
+ gmi.add_argument("user_id")
1482
+ gmi.add_argument("--no-cache", action="store_true")
1483
+
1484
+ gmute = group_sub.add_parser("mute", help="Mute a member")
1485
+ gmute.add_argument("group_id")
1486
+ gmute.add_argument("user_id")
1487
+ gmute.add_argument("--duration", "-d", type=int, default=None)
1488
+
1489
+ gu = group_sub.add_parser("unmute", help="Unmute a member")
1490
+ gu.add_argument("group_id")
1491
+ gu.add_argument("user_id")
1492
+
1493
+ gk = group_sub.add_parser("kick", help="Kick a member")
1494
+ gk.add_argument("group_id")
1495
+ gk.add_argument("user_id")
1496
+ gk.add_argument("--reject", action="store_true", help="Prevent rejoin")
1497
+
1498
+ ga = group_sub.add_parser("admin", help="Set/remove admin")
1499
+ ga.add_argument("group_id")
1500
+ ga.add_argument("user_id")
1501
+ ga.add_argument("--enable", action="store_true", default=True)
1502
+ ga.add_argument("--disable", action="store_true", default=False)
1503
+
1504
+ gr = group_sub.add_parser(
1505
+ "rename",
1506
+ help="Set group card name",
1507
+ usage="napcat group rename <group_id> <user_id> <card>",
1508
+ epilog="Example: napcat group rename 1050866499 3914024488 '测试名片'",
1509
+ )
1510
+ gr.add_argument("group_id")
1511
+ gr.add_argument("user_id")
1512
+ gr.add_argument("card", help="Card name")
1513
+
1514
+ gre = group_sub.add_parser("remark", help="Set group remark")
1515
+ gre.add_argument("group_id")
1516
+ gre.add_argument("remark")
1517
+
1518
+ gan = group_sub.add_parser("announce", help="Send group announcement")
1519
+ gan.add_argument("group_id")
1520
+ gan.add_argument("content")
1521
+
1522
+ gl = group_sub.add_parser("list", help="List groups")
1523
+ gl.add_argument("--limit", "-n", type=int, default=None, help="Max groups to show")
1524
+
1525
+ gp = group_sub.add_parser("poke", help="Poke a member")
1526
+ gp.add_argument("group_id")
1527
+ gp.add_argument("user_id")
1528
+
1529
+ ge = group_sub.add_parser("essence", help="List essence messages")
1530
+ ge.add_argument("group_id")
1531
+
1532
+ ges = group_sub.add_parser("set_essence", help="Set a message as essence")
1533
+ ges.add_argument("group_id")
1534
+ ges.add_argument("message_id")
1535
+
1536
+ gdes = group_sub.add_parser("delete_essence", help="Delete an essence message")
1537
+ gdes.add_argument("group_id")
1538
+ gdes.add_argument("message_id")
1539
+
1540
+
1541
+ # --- friend ---
1542
+ friend_p = subparsers.add_parser("friend", help="Friend management")
1543
+ friend_sub = friend_p.add_subparsers(dest="subcommand")
1544
+
1545
+ fl = friend_sub.add_parser("list", help="List friends")
1546
+ fl.add_argument("--no-cache", action="store_true", default=False)
1547
+ fi = friend_sub.add_parser("info", help="Get user info")
1548
+ fi.add_argument("user_id")
1549
+ fi.add_argument("--no-cache", action="store_true")
1550
+ fr = friend_sub.add_parser("remark", help="Set friend remark")
1551
+ fr.add_argument("user_id")
1552
+ fr.add_argument("remark")
1553
+ fa = friend_sub.add_parser("add", help="Send friend request")
1554
+ fa.add_argument("user_id")
1555
+ fa.add_argument("--remark", default=None)
1556
+ fd = friend_sub.add_parser("delete", help="Delete friend")
1557
+ fd.add_argument("user_id")
1558
+
1559
+ # --- file ---
1560
+ file_p = subparsers.add_parser("file", help="File operations")
1561
+ file_sub = file_p.add_subparsers(dest="subcommand")
1562
+
1563
+ fg = file_sub.add_parser("upload-group", help="Upload file to group")
1564
+ fg.add_argument("group_id")
1565
+ fg.add_argument("file")
1566
+ fg.add_argument("--name", default=None)
1567
+ fg.add_argument("--folder", default=None)
1568
+
1569
+ fp = file_sub.add_parser("upload-private", help="Upload file privately")
1570
+ fp.add_argument("user_id")
1571
+ fp.add_argument("file")
1572
+ fp.add_argument("--name", default=None)
1573
+
1574
+ flg = file_sub.add_parser("list-group", help="List group files")
1575
+ flg.add_argument("group_id")
1576
+
1577
+ flf = file_sub.add_parser("list-folder", help="List folder contents")
1578
+ flf.add_argument("group_id")
1579
+ flf.add_argument("folder_id")
1580
+
1581
+ finfo = file_sub.add_parser("info", help="Get file info")
1582
+ finfo.add_argument("group_id", help="Group ID")
1583
+ finfo.add_argument("file_id", help="File ID")
1584
+
1585
+ fd = file_sub.add_parser("download", help="Download a file")
1586
+ fd.add_argument("group_id", help="Group ID")
1587
+ fd.add_argument("file_id", help="File ID")
1588
+ fd.add_argument("--output-dir", "-o", default=None)
1589
+
1590
+ # --- daemon ---
1591
+ daemon_p = subparsers.add_parser("daemon", help="Manage watch daemon")
1592
+ daemon_p.add_argument("subcommand", choices=["start", "stop", "status"])
1593
+
1594
+ # --- events ---
1595
+ events_p = subparsers.add_parser("events", help="Read events from SQLite database")
1596
+ events_p.add_argument("--type", default=None, help="Filter by event type")
1597
+ events_p.add_argument("--since", type=int, default=None, help="Events after timestamp")
1598
+ events_p.add_argument("--limit", "-n", type=int, default=50, help="Max events to read")
1599
+ events_p.add_argument("--output", "-o", choices=["json", "text"], default="json")
1600
+ events_p.add_argument("--no-heartbeat", action="store_true", help="Skip heartbeat events")
1601
+
1602
+ # --- alerts ---
1603
+ alerts_p = subparsers.add_parser("alerts", help="Check or clear alerts")
1604
+ alerts_sub = alerts_p.add_subparsers(dest="subcommand")
1605
+ alerts_sub.add_parser("check", help="Show pending alerts")
1606
+ ac = alerts_sub.add_parser("clear", help="Clear all alerts")
1607
+
1608
+ # --- config ---
1609
+ config_p = subparsers.add_parser("config", help="Manage configuration")
1610
+ config_sub = config_p.add_subparsers(dest="subcommand")
1611
+ cget = config_sub.add_parser("get", help="Get a config value")
1612
+ cget.add_argument("key")
1613
+ cset = config_sub.add_parser("set", help="Set a config value")
1614
+ cset.add_argument("key")
1615
+ cset.add_argument("value")
1616
+ config_sub.add_parser("show", help="Show all config")
1617
+
1618
+ # --- status ---
1619
+ subparsers.add_parser("status", help="Check bot login status")
1620
+
1621
+ # --- ocr ---
1622
+ ocr_p = subparsers.add_parser("ocr", help="OCR an image")
1623
+ ocr_p.add_argument("image", help="Image file path or URL")
1624
+
1625
+ # --- translate ---
1626
+ trans_p = subparsers.add_parser("translate", help="QQ translation")
1627
+ trans_p.add_argument("text", help="Text to translate")
1628
+ trans_p.add_argument("--from", dest="from_lang", default="auto")
1629
+ trans_p.add_argument("--to", dest="to_lang", default="zh")
1630
+
1631
+
1632
+ # --- like ---
1633
+ like_p = subparsers.add_parser("like", help="Send profile like")
1634
+ like_p.add_argument("user_id", help="User QQ number to like")
1635
+ like_p.add_argument("times", type=int, default=1, nargs="?", help="Like times (1-10, default: 1)")
1636
+
1637
+ # --- react ---
1638
+ react_p = subparsers.add_parser("react", help="Send emoji reaction on group message")
1639
+ react_p.add_argument("group_id", help="Group ID")
1640
+ react_p.add_argument("message_id", help="Message ID to react to")
1641
+ react_p.add_argument("emoji_id", help="Emoji ID (e.g. 166, 386)")
1642
+
1643
+ # --- search ---
1644
+ search_p = subparsers.add_parser("search", help="Search messages by keyword in event history")
1645
+ search_p.add_argument("keyword", nargs="?", default=None, help="Keyword to search for (positional)")
1646
+ search_p.add_argument("--keyword", dest="keyword_flag", default=None, help="Keyword to search for (flag)")
1647
+ search_p.add_argument("--limit", "-n", type=int, default=50, help="Max events to scan (default: 50)")
1648
+ search_p.add_argument("--since", type=int, default=None, help="Events after timestamp")
1649
+ search_p.add_argument("--event-type", "-t", default=None, help="Filter by event type")
1650
+
1651
+ # --- msg ---
1652
+ msg_p = subparsers.add_parser("msg", help="Browse recent messages or query a specific message ID")
1653
+ msg_p.add_argument("message_id", nargs="?", default=None, help="Message ID to look up directly (positional)")
1654
+ msg_p.add_argument("--group", "-g", dest="group_id", default=None, help="Filter by group ID")
1655
+ msg_p.add_argument("--user", "-u", dest="user_id", default=None, help="Filter by user ID")
1656
+ msg_p.add_argument("--limit", "-n", type=int, default=50, help="Max messages to show (default: 50)")
1657
+ msg_p.add_argument("--since", type=int, default=None, help="Messages after timestamp")
1658
+ # --- phone ---
1659
+ # --- batch ---
1660
+ batch_p = subparsers.add_parser("batch", help="Batch operations (kick, mute, unmute)")
1661
+ batch_sub = batch_p.add_subparsers(dest="batch_command")
1662
+
1663
+ # batch kick
1664
+ batch_kick = batch_sub.add_parser("kick", help="Kick multiple members from a group")
1665
+ batch_kick.add_argument("group_id", help="Group ID")
1666
+ batch_kick.add_argument("user_ids", nargs="+", help="User IDs to kick")
1667
+ batch_kick.add_argument("--reject", action="store_true", default=False, help="Prevent rejoin")
1668
+
1669
+ # batch mute
1670
+ batch_mute = batch_sub.add_parser("mute", help="Mute multiple members in a group")
1671
+ batch_mute.add_argument("group_id", help="Group ID")
1672
+ batch_mute.add_argument("user_ids", nargs="+", help="User IDs to mute")
1673
+ batch_mute.add_argument("--duration", "-d", type=int, default=1800, help="Duration in seconds (default: 1800)")
1674
+
1675
+ # batch unmute
1676
+ batch_unmute = batch_sub.add_parser("unmute", help="Unmute multiple members in a group")
1677
+ batch_unmute.add_argument("group_id", help="Group ID")
1678
+ batch_unmute.add_argument("user_ids", nargs="+", help="User IDs to unmute")
1679
+
1680
+ phone_p = subparsers.add_parser("phone", help="Launch mobile-style TUI interface")
1681
+ phone_p.add_argument("--port", type=int, default=None, help="Daemon HTTP port (default: NAPCAT_HTTP_PORT or 18821)")
1682
+ phone_p.add_argument("--non-interactive", action="store_true", help="Run once and exit (status check)")
1683
+
1684
+ # Phone subcommands for scriptable CLI access to TUI features
1685
+ phone_sub = phone_p.add_subparsers(dest="phone_subcommand")
1686
+ phone_msg = phone_sub.add_parser("msg", help="Send message via TUI")
1687
+ phone_msg.add_argument("target_type", choices=["group", "private"])
1688
+ phone_msg.add_argument("target_id")
1689
+ phone_msg.add_argument("message", help="Message text")
1690
+ phone_status = phone_sub.add_parser("status", help="Check bot status")
1691
+ phone_events = phone_sub.add_parser("events", help="View events")
1692
+ phone_events.add_argument("--limit", "-n", type=int, default=50)
1693
+ phone_events.add_argument("--no-heartbeat", action="store_true")
1694
+ phone_alerts = phone_sub.add_parser("alerts", help="Check alerts")
1695
+ phone_config = phone_sub.add_parser("config", help="View config")
1696
+
1697
+ # --- message ---
1698
+ message_p = subparsers.add_parser("message", help="Query message content via daemon provider")
1699
+ message_p.add_argument("message_id", help="Message ID")
1700
+ message_p.add_argument("--content", choices=["metadata", "text", "image", "file", "video", "record", "forward"], default=None, help="Specific content to retrieve")
1701
+ message_p.add_argument("--group", "-g", default=None, help="Filter by group ID")
1702
+ message_p.add_argument("--user", "-u", default=None, help="Filter by user ID")
1703
+
1704
+ # --- schema ---
1705
+ schema_p = subparsers.add_parser("schema", help="Describe an action schema")
1706
+ schema_p.add_argument("--list", action="store_true", help="List all available actions")
1707
+ schema_p.add_argument("action", nargs="?", default=None, help="Action name (e.g., send_group_message)")
1708
+
1709
+ # --- send_forward ---
1710
+ sf = subparsers.add_parser("send_forward", help="Forward a message to a group")
1711
+ sf.add_argument("message_id", help="Message ID to forward")
1712
+ sf.add_argument("group_id", help="Target group ID")
1713
+
1714
+ # --- send_poke ---
1715
+ sp = subparsers.add_parser("send_poke", help="Poke a member in a group or private user")
1716
+ sp.add_argument("target_type", choices=["group", "private"])
1717
+ sp.add_argument("target_id", help="Group ID or user ID")
1718
+ sp.add_argument("user_id", help="User ID to poke (group only; ignored for private)")
1719
+
1720
+ # --- create_schedule ---
1721
+ cs = subparsers.add_parser("create_schedule", help="Create a scheduled message")
1722
+ cs.add_argument("target_type", choices=["group", "private"])
1723
+ cs.add_argument("target_id", help="Group ID or user ID")
1724
+ cs.add_argument("message", help="Message text to schedule")
1725
+ cs.add_argument("--repeat", type=int, default=0, help="Number of repeats (0 = once)")
1726
+ cs.add_argument("--times", type=int, default=0, help="Times to send (deprecated, use --repeat)")
1727
+ cs.add_argument("--repeat-interval", type=int, default=60, help="Interval between repeats in seconds")
1728
+ cs.add_argument("--message-type", choices=["text", "array"], default="text", help="Message type")
1729
+
1730
+ # --- schedule ---
1731
+ schedule_p = subparsers.add_parser("schedule", help="Manage scheduled messages")
1732
+ schedule_sub = schedule_p.add_subparsers(dest="subcommand")
1733
+
1734
+ schedule_list_p = schedule_sub.add_parser("list", help="List scheduled messages")
1735
+ schedule_list_p.add_argument("--group", help="Group ID to filter")
1736
+ schedule_list_p.add_argument("--user", help="User ID to filter")
1737
+
1738
+ schedule_cancel_p = schedule_sub.add_parser("cancel", help="Cancel a scheduled message")
1739
+ schedule_cancel_p.add_argument("schedule_id", help="Schedule ID to cancel")
1740
+ schedule_cancel_p.add_argument("--group", help="Group ID")
1741
+ schedule_cancel_p.add_argument("--user", help="User ID")
1742
+
1743
+ # --- get_cookies ---
1744
+ gc = subparsers.add_parser("get_cookies", help="Get QQ web cookies")
1745
+ gc.add_argument("domain", nargs="?", default="qq.com", help="Cookie domain (default: qq.com)")
1746
+
1747
+ gsi = subparsers.add_parser("get_stranger_info", help="Get stranger info by QQ number")
1748
+ gsi.add_argument("user_id")
1749
+ gsi.add_argument("--no-cache", action="store_true", default=False, help="Force fresh query")
1750
+
1751
+ # --- fs ---
1752
+ subparsers.add_parser("fs", help="Show skills-fs directory tree and workflow")
1753
+
1754
+ # --- wake ---
1755
+ wake_p = subparsers.add_parser("wake", help="Wake the configured agent (HTTP/CLI, auto-fallback)")
1756
+ wake_p.add_argument("--reason", "-r", default="manual", help="Wake reason (default: manual)")
1757
+ wake_p.add_argument("--prompt", "-p", default=None, help="Prompt text to send (default: a contextual prompt)")
1758
+ wake_p.add_argument("--transport", choices=["auto", "http", "cli"], default=None,
1759
+ help="Force a transport for this wake (default: from config / auto)")
1760
+ wake_p.add_argument("--timeout", dest="wake_timeout", type=float, default=None,
1761
+ help="Per-wake timeout in seconds (default: 120)")
1762
+ wake_p.add_argument("--dry-run", action="store_true", help="Render what would fire without executing")
1763
+ wake_sub = wake_p.add_subparsers(dest="wake_subcommand")
1764
+ wake_sub.add_parser("test", help="Probe each configured transport (configured + reachable)")
1765
+ wake_sub.add_parser("sessions", help="List agent sessions (requires HTTP backend)")
1766
+
1767
+ # --- setup ---
1768
+ setup_p = subparsers.add_parser("setup", help="Interactive setup wizard")
1769
+ setup_p.add_argument("--non-interactive", action="store_true")
1770
+ setup_p.add_argument("--yes", "-y", action="store_true")
1771
+ setup_p.add_argument("--force", action="store_true")
1772
+
1773
+ args = parser.parse_args()
1774
+
1775
+ if not args.command:
1776
+ parser.print_help()
1777
+ return 1
1778
+
1779
+ # Apply overrides
1780
+ if args.api_url:
1781
+ os.environ["NAPCAT_API_URL"] = args.api_url
1782
+ if args.token:
1783
+ os.environ["NAPCAT_TOKEN"] = args.token
1784
+ if args.data_dir:
1785
+ os.environ["NAPCAT_DATA_DIR"] = args.data_dir
1786
+
1787
+ # Init API client
1788
+ api = NapCatAPI(timeout=args.timeout)
1789
+
1790
+ # Dispatch
1791
+ commands = {
1792
+ "api": cmd_api,
1793
+ "send": cmd_send,
1794
+ "recall": cmd_recall,
1795
+ "group": cmd_group,
1796
+ "friend": cmd_friend,
1797
+ "file": cmd_file,
1798
+ "daemon": cmd_daemon,
1799
+ "events": cmd_events,
1800
+ "alerts": cmd_alerts,
1801
+ "config": cmd_config,
1802
+ "status": cmd_status,
1803
+ "ocr": cmd_ocr,
1804
+ "translate": cmd_translate,
1805
+ "phone": cmd_phone,
1806
+ "like": cmd_like,
1807
+ "react": cmd_react,
1808
+ "search": cmd_search,
1809
+ "msg": cmd_msg,
1810
+ "batch": cmd_batch,
1811
+ "message": cmd_message,
1812
+ "schema": cmd_schema,
1813
+ "fs": cmd_fs,
1814
+ "send_forward": cmd_send_forward,
1815
+ "send_poke": cmd_send_poke,
1816
+ "reply": cmd_reply,
1817
+ "create_schedule": cmd_create_schedule,
1818
+ "schedule": cmd_schedule,
1819
+ "get_cookies": cmd_get_cookies,
1820
+ "get_stranger_info": cmd_get_stranger_info,
1821
+ "wake": cmd_wake,
1822
+ "setup": lambda a, api: __import__("napcat_cli.setup_wizard", fromlist=["run_setup"]).run_setup(a.non_interactive, a.yes, a.force),
1823
+ }
1824
+
1825
+ handler = commands.get(args.command)
1826
+ if handler:
1827
+ return handler(args, api)
1828
+ else:
1829
+ parser.print_help()
1830
+ return 1
1831
+
1832
+
1833
+ if __name__ == "__main__":
1834
+ sys.exit(main())