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
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())
|