napcat-cli 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- napcat_cli/__init__.py +3 -0
- napcat_cli/cli.py +1834 -0
- napcat_cli/daemon/__init__.py +1 -0
- napcat_cli/daemon/schemas.py +470 -0
- napcat_cli/daemon/watch.py +1994 -0
- napcat_cli/data/SKILL.md +328 -0
- napcat_cli/data/__init__.py +0 -0
- napcat_cli/data/persona.md +155 -0
- napcat_cli/data/references/mounting.md +90 -0
- napcat_cli/data/skills-fs-config.json +12 -0
- napcat_cli/data/skills-fs-fragment.json +473 -0
- napcat_cli/data/skills-fs.d/agents-friend-time.md +7 -0
- napcat_cli/data/skills-fs.d/agents-friend.md +7 -0
- napcat_cli/data/skills-fs.d/agents-friends.md +4 -0
- napcat_cli/data/skills-fs.d/agents-group-time.md +7 -0
- napcat_cli/data/skills-fs.d/agents-group.md +6 -0
- napcat_cli/data/skills-fs.d/agents-groups.md +5 -0
- napcat_cli/data/skills-fs.d/agents-napcat.md +9 -0
- napcat_cli/data/skills-fs.d/persona.md +155 -0
- napcat_cli/data/skills-fs.d/skill-agents.md +42 -0
- napcat_cli/data/skills-fs.d/skill-body.md +102 -0
- napcat_cli/lib/__init__.py +1 -0
- napcat_cli/lib/api.py +258 -0
- napcat_cli/lib/config.py +105 -0
- napcat_cli/lib/events.py +127 -0
- napcat_cli/lib/events_sqlite.py +242 -0
- napcat_cli/lib/message.py +156 -0
- napcat_cli/setup_wizard.py +380 -0
- napcat_cli/tui/__init__.py +1 -0
- napcat_cli/tui/__main__.py +13 -0
- napcat_cli/tui/api.py +158 -0
- napcat_cli/tui/app.py +127 -0
- napcat_cli/tui/chat_list.py +168 -0
- napcat_cli/tui/chat_view.py +456 -0
- napcat_cli/tui/styles.tcss +9 -0
- napcat_cli/wake.py +51 -0
- napcat_cli/wake_backend.py +390 -0
- napcat_cli/wake_orchestrator.py +302 -0
- napcat_cli/wake_presets.py +77 -0
- napcat_cli-2.0.0.dist-info/METADATA +219 -0
- napcat_cli-2.0.0.dist-info/RECORD +43 -0
- napcat_cli-2.0.0.dist-info/WHEEL +4 -0
- napcat_cli-2.0.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,1994 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""NapCat Watch Daemon — NapCat WS listener + skills-fs HTTP provider.
|
|
3
|
+
|
|
4
|
+
Connects to NapCat's WebSocket server to receive real-time events,
|
|
5
|
+
writes them to the filesystem bridge, and generates alert files.
|
|
6
|
+
|
|
7
|
+
Also runs an HTTP server implementing the skills-fs HTTP provider contract,
|
|
8
|
+
so skills-fs can query events and proxy NapCat API calls.
|
|
9
|
+
|
|
10
|
+
Alert files generated:
|
|
11
|
+
- NAPCAT_CLI_NEW_MESSAGE: Any new message received
|
|
12
|
+
- NAPCAT_CLI_AT_ME: Bot was @mentioned
|
|
13
|
+
- NAPCAT_CLI_REPLY_TO_ME: Reply to bot's message
|
|
14
|
+
- NAPCAT_CLI_NEW_POKE: Poke received
|
|
15
|
+
- NAPCAT_CLI_NEW_REQUEST: Friend/group join request
|
|
16
|
+
- NAPCAT_CLI_NEED_WAKE_UP: Composite alert - agent should check
|
|
17
|
+
|
|
18
|
+
The daemon connects via WebSocket to the NapCat server.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import os
|
|
26
|
+
import signal
|
|
27
|
+
import sys
|
|
28
|
+
import time
|
|
29
|
+
import threading
|
|
30
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
31
|
+
from logging.handlers import RotatingFileHandler
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
from urllib.parse import parse_qs, urlparse
|
|
35
|
+
|
|
36
|
+
# Allow running from any directory
|
|
37
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
38
|
+
sys.path.insert(0, str(ROOT))
|
|
39
|
+
|
|
40
|
+
from napcat_cli.lib.config import DATA_DIR, get_config
|
|
41
|
+
from napcat_cli.lib.events import EventsWriter, EventsReader
|
|
42
|
+
|
|
43
|
+
from napcat_cli.daemon.schemas import ACTION_SCHEMAS
|
|
44
|
+
from napcat_cli.wake_presets import build_waker
|
|
45
|
+
from napcat_cli.wake_orchestrator import WakeOrchestrator
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Event Processor (alert generation)
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def _make_rotating_logger(path: Path, max_bytes: int = 2_000_000, backup_count: int = 5) -> logging.Logger:
|
|
53
|
+
"""A per-data-dir logger writing to ``path`` with size-based rotation.
|
|
54
|
+
|
|
55
|
+
Keeps daemon.log bounded: at ~2 MB it rolls to daemon.log.1 .. daemon.log.5
|
|
56
|
+
(olest deleted), so it can never fill the disk. Idempotent — re-creating it
|
|
57
|
+
for the same path does not stack duplicate handlers.
|
|
58
|
+
"""
|
|
59
|
+
logger = logging.getLogger(f"napcat.daemon.{path.parent.name}")
|
|
60
|
+
logger.setLevel(logging.INFO)
|
|
61
|
+
logger.propagate = False
|
|
62
|
+
if not any(getattr(h, "_napcat_marker", False) for h in logger.handlers):
|
|
63
|
+
try:
|
|
64
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
h = RotatingFileHandler(path, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8")
|
|
66
|
+
h._napcat_marker = True # type: ignore[attr-defined]
|
|
67
|
+
h.setFormatter(logging.Formatter("[%(asctime)s] %(message)s", datefmt="%Y-%m-%d %H:%M:%S"))
|
|
68
|
+
logger.addHandler(h)
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
return logger
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_TIMEOUT = object() # sentinel returned by _run_with_timeout on timeout
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _run_with_timeout(fn, timeout: float, *args):
|
|
78
|
+
"""Run ``fn(*args)`` in a daemon thread, returning its result.
|
|
79
|
+
|
|
80
|
+
If it doesn't finish within ``timeout`` seconds, returns ``_TIMEOUT`` and
|
|
81
|
+
abandons the thread. This is the D-state prevention primitive: a hung FUSE
|
|
82
|
+
syscall (status read / stat) only blocks the abandoned thread, never the
|
|
83
|
+
daemon's main thread or asyncio loop, so the process can't get wedged.
|
|
84
|
+
"""
|
|
85
|
+
box: dict = {"done": False, "result": None}
|
|
86
|
+
|
|
87
|
+
def _runner():
|
|
88
|
+
try:
|
|
89
|
+
box["result"] = fn(*args)
|
|
90
|
+
except Exception as e: # a raised exception => not healthy
|
|
91
|
+
box["result"] = e
|
|
92
|
+
box["done"] = True
|
|
93
|
+
|
|
94
|
+
t = threading.Thread(target=_runner, daemon=True)
|
|
95
|
+
t.start()
|
|
96
|
+
t.join(timeout)
|
|
97
|
+
return box["result"] if box["done"] else _TIMEOUT
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class EventProcessor:
|
|
101
|
+
"""Process events and generate alerts."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, data_dir: Path, self_id: str = "", wake_command: str = "", wake_on_event: bool = True, *, group_trigger_word: str = "", private_trigger: str = "",
|
|
104
|
+
waker=None, orchestrator=None,
|
|
105
|
+
debounce_seconds: float = 3.0, cooldown_seconds: float = 30.0,
|
|
106
|
+
new_message_idle_seconds: int = 600):
|
|
107
|
+
self.writer = EventsWriter(data_dir)
|
|
108
|
+
self.self_id = self_id
|
|
109
|
+
self.wake_command = wake_command
|
|
110
|
+
self.wake_on_event = wake_on_event
|
|
111
|
+
self.group_trigger_word = group_trigger_word
|
|
112
|
+
self.private_trigger = private_trigger
|
|
113
|
+
self.log_file = data_dir / "daemon.log"
|
|
114
|
+
self._logger = _make_rotating_logger(self.log_file)
|
|
115
|
+
# Wake orchestrator (built by run_daemon from config). When present it
|
|
116
|
+
# owns debounce/cooldown/backlog + the Waker (http->cli auto-fallback).
|
|
117
|
+
# When absent, _wake falls back to the legacy wake_command shell string.
|
|
118
|
+
if orchestrator is not None:
|
|
119
|
+
self.orchestrator = orchestrator
|
|
120
|
+
elif waker is not None:
|
|
121
|
+
self.orchestrator = WakeOrchestrator(
|
|
122
|
+
waker, log=self.log,
|
|
123
|
+
debounce_seconds=debounce_seconds, cooldown_seconds=cooldown_seconds,
|
|
124
|
+
new_message_idle_seconds=new_message_idle_seconds,
|
|
125
|
+
legacy_command=wake_command)
|
|
126
|
+
else:
|
|
127
|
+
self.orchestrator = None
|
|
128
|
+
|
|
129
|
+
def log(self, msg: str) -> None:
|
|
130
|
+
try:
|
|
131
|
+
self._logger.info(msg)
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
def process(self, event: dict) -> None:
|
|
136
|
+
row_id = self.writer.write_event(event)
|
|
137
|
+
self.log(f"Event: row_id={row_id}")
|
|
138
|
+
post_type = event.get("post_type", "")
|
|
139
|
+
if post_type == "message":
|
|
140
|
+
self._handle_message(event)
|
|
141
|
+
elif post_type == "notice":
|
|
142
|
+
self._handle_notice(event)
|
|
143
|
+
elif post_type == "request":
|
|
144
|
+
self._handle_request(event)
|
|
145
|
+
elif post_type == "meta_event":
|
|
146
|
+
self._handle_meta(event)
|
|
147
|
+
|
|
148
|
+
def _handle_message(self, event: dict) -> None:
|
|
149
|
+
msg_type = event.get("message_type", "")
|
|
150
|
+
sender = event.get("sender", {})
|
|
151
|
+
sender_id = str(sender.get("user_id", ""))
|
|
152
|
+
nickname = sender.get("nickname", "")
|
|
153
|
+
raw_msg = event.get("raw_message", event.get("message", ""))
|
|
154
|
+
msg_id = str(event.get("message_id", ""))
|
|
155
|
+
group_id = event.get("group_id", "")
|
|
156
|
+
|
|
157
|
+
self.writer.write_alert("NAPCAT_CLI_NEW_MESSAGE", {
|
|
158
|
+
"summary": f"{nickname}({sender_id}): {str(raw_msg)[:50]}",
|
|
159
|
+
"sender_id": sender_id,
|
|
160
|
+
"nickname": nickname,
|
|
161
|
+
"message_type": msg_type,
|
|
162
|
+
"group_id": str(group_id) if group_id else "",
|
|
163
|
+
"message_id": msg_id,
|
|
164
|
+
"raw_message": str(raw_msg),
|
|
165
|
+
"time": event.get("time", 0),
|
|
166
|
+
})
|
|
167
|
+
# Track unread messages for the backlog sweep (NEW_MESSAGE itself does
|
|
168
|
+
# not wake; the sweep wakes the agent if they pile up unread).
|
|
169
|
+
if self.orchestrator is not None:
|
|
170
|
+
self.orchestrator.note_new_message(event.get("time") or time.time())
|
|
171
|
+
|
|
172
|
+
if self.self_id:
|
|
173
|
+
raw_str = str(raw_msg)
|
|
174
|
+
if f"[CQ:at,qq={self.self_id}]" in raw_str:
|
|
175
|
+
self.writer.write_alert("NAPCAT_CLI_AT_ME", {
|
|
176
|
+
"summary": f"@mentioned by {nickname} in {'group ' + str(group_id) if group_id else 'DM'}",
|
|
177
|
+
"sender_id": sender_id,
|
|
178
|
+
"group_id": str(group_id) if group_id else "",
|
|
179
|
+
"message_id": msg_id,
|
|
180
|
+
})
|
|
181
|
+
self._wake("AT_ME", event)
|
|
182
|
+
|
|
183
|
+
msg_segments = event.get("message", [])
|
|
184
|
+
if isinstance(msg_segments, list):
|
|
185
|
+
for seg in msg_segments:
|
|
186
|
+
if isinstance(seg, dict) and seg.get("type") == "reply":
|
|
187
|
+
data = seg.get("data", {})
|
|
188
|
+
if isinstance(data, str):
|
|
189
|
+
try:
|
|
190
|
+
import urllib.parse
|
|
191
|
+
data = json.loads(urllib.parse.unquote(data))
|
|
192
|
+
except Exception:
|
|
193
|
+
pass
|
|
194
|
+
if isinstance(data, dict) and str(data.get("id", "")) == msg_id:
|
|
195
|
+
self.writer.write_alert("NAPCAT_CLI_REPLY_TO_ME", {
|
|
196
|
+
"summary": f"Reply from {nickname}",
|
|
197
|
+
"sender_id": sender_id,
|
|
198
|
+
"group_id": str(group_id) if group_id else "",
|
|
199
|
+
"message_id": msg_id,
|
|
200
|
+
})
|
|
201
|
+
self._wake("REPLY_TO_ME", event)
|
|
202
|
+
break
|
|
203
|
+
|
|
204
|
+
# --- Trigger word detection on plain text (no CQ codes) ---
|
|
205
|
+
segs = event.get("message", [])
|
|
206
|
+
plain_text = "".join(
|
|
207
|
+
(seg.get("data") or {}).get("text", "")
|
|
208
|
+
for seg in (segs if isinstance(segs, list) else [])
|
|
209
|
+
if isinstance(seg, dict) and seg.get("type") == "text"
|
|
210
|
+
)
|
|
211
|
+
if msg_type == "group" and self.group_trigger_word and self.group_trigger_word in plain_text:
|
|
212
|
+
self._wake("GROUP_TRIGGER", event)
|
|
213
|
+
elif msg_type == "private":
|
|
214
|
+
if self.private_trigger == "*" or (self.private_trigger and self.private_trigger in plain_text):
|
|
215
|
+
self._wake("PRIVATE_TRIGGER", event)
|
|
216
|
+
|
|
217
|
+
def _handle_notice(self, event: dict) -> None:
|
|
218
|
+
notice_type = event.get("notice_type", "")
|
|
219
|
+
sub_type = event.get("sub_type", "")
|
|
220
|
+
|
|
221
|
+
# --- notify events (poke, profile_like, lucky_king) ---
|
|
222
|
+
if notice_type == "notify":
|
|
223
|
+
self._handle_notify(event, sub_type)
|
|
224
|
+
return
|
|
225
|
+
|
|
226
|
+
# --- group events ---
|
|
227
|
+
if notice_type == "group_admin":
|
|
228
|
+
self._handle_group_admin(event)
|
|
229
|
+
return
|
|
230
|
+
if notice_type == "group_ban":
|
|
231
|
+
self._handle_group_ban(event)
|
|
232
|
+
return
|
|
233
|
+
if notice_type == "group_decrease":
|
|
234
|
+
self._handle_group_decrease(event, sub_type)
|
|
235
|
+
return
|
|
236
|
+
if notice_type == "group_increase":
|
|
237
|
+
self._handle_group_increase(event, sub_type)
|
|
238
|
+
return
|
|
239
|
+
if notice_type == "group_upload":
|
|
240
|
+
self._handle_group_upload(event)
|
|
241
|
+
return
|
|
242
|
+
if notice_type == "group_recall":
|
|
243
|
+
self._handle_group_recall(event)
|
|
244
|
+
return
|
|
245
|
+
if notice_type == "group_card":
|
|
246
|
+
self._handle_group_card(event)
|
|
247
|
+
return
|
|
248
|
+
if notice_type == "group_msg_emoji_like":
|
|
249
|
+
self._handle_group_emoji_like(event)
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
# --- friend events ---
|
|
253
|
+
if notice_type == "friend_add":
|
|
254
|
+
self._handle_friend_add(event)
|
|
255
|
+
return
|
|
256
|
+
if notice_type == "friend_recall":
|
|
257
|
+
self._handle_friend_recall(event)
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
# --- catch-all for unknown notice types ---
|
|
261
|
+
self.writer.write_alert("NAPCAT_CLI_NOTICE", {
|
|
262
|
+
"summary": f"Notice: {notice_type}/{sub_type}",
|
|
263
|
+
"notice_type": notice_type,
|
|
264
|
+
"sub_type": sub_type,
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
# ---- notify sub-handlers ----
|
|
268
|
+
|
|
269
|
+
def _handle_notify(self, event: dict, sub_type: str) -> None:
|
|
270
|
+
if sub_type == "poke":
|
|
271
|
+
sender_id = str(event.get("user_id", ""))
|
|
272
|
+
group_id = event.get("group_id", "")
|
|
273
|
+
target_id = str(event.get("target_id", ""))
|
|
274
|
+
self.writer.write_alert("NAPCAT_CLI_NEW_POKE", {
|
|
275
|
+
"summary": f"Poke from {sender_id}{' in group ' + str(group_id) if group_id else ''}",
|
|
276
|
+
"sender_id": sender_id,
|
|
277
|
+
"target_id": target_id,
|
|
278
|
+
"group_id": str(group_id) if group_id else "",
|
|
279
|
+
})
|
|
280
|
+
# Wake if bot was poked
|
|
281
|
+
if self.self_id and target_id == self.self_id:
|
|
282
|
+
self._wake("NEW_POKE", event)
|
|
283
|
+
elif sub_type == "lucky_king":
|
|
284
|
+
self.writer.write_alert("NAPCAT_CLI_NEW_POKE", {
|
|
285
|
+
"summary": "Lucky king (red packet)",
|
|
286
|
+
"group_id": str(event.get("group_id", "")),
|
|
287
|
+
"user_id": str(event.get("user_id", "")),
|
|
288
|
+
})
|
|
289
|
+
elif sub_type == "profile_like":
|
|
290
|
+
operator_id = str(event.get("operator_id", ""))
|
|
291
|
+
operator_nick = event.get("operator_nick", "")
|
|
292
|
+
times = event.get("times", 0)
|
|
293
|
+
self.writer.write_alert("NAPCAT_CLI_NEW_POKE", {
|
|
294
|
+
"summary": f"{operator_nick}({operator_id}) liked profile {times} times",
|
|
295
|
+
"operator_id": operator_id,
|
|
296
|
+
"operator_nick": operator_nick,
|
|
297
|
+
"times": times,
|
|
298
|
+
"sub_type": "profile_like",
|
|
299
|
+
})
|
|
300
|
+
self._wake("PROFILE_LIKE", event)
|
|
301
|
+
|
|
302
|
+
# ---- group sub-handlers ----
|
|
303
|
+
|
|
304
|
+
def _handle_group_admin(self, event: dict) -> None:
|
|
305
|
+
sub = event.get("sub_type", "")
|
|
306
|
+
user_id = str(event.get("user_id", ""))
|
|
307
|
+
group_id = str(event.get("group_id", ""))
|
|
308
|
+
action = "promoted" if sub == "set" else "demoted"
|
|
309
|
+
self.writer.write_alert("NAPCAT_CLI_NOTICE", {
|
|
310
|
+
"summary": f"Admin {action}: {user_id} in group {group_id}",
|
|
311
|
+
"notice_type": "group_admin",
|
|
312
|
+
"sub_type": sub,
|
|
313
|
+
"user_id": user_id,
|
|
314
|
+
"group_id": group_id,
|
|
315
|
+
})
|
|
316
|
+
# Wake if bot's admin status changed
|
|
317
|
+
if self.self_id and user_id == self.self_id:
|
|
318
|
+
self._wake("GROUP_ADMIN_CHANGE", event)
|
|
319
|
+
|
|
320
|
+
def _handle_group_ban(self, event: dict) -> None:
|
|
321
|
+
sub = event.get("sub_type", "")
|
|
322
|
+
user_id = str(event.get("user_id", ""))
|
|
323
|
+
group_id = str(event.get("group_id", ""))
|
|
324
|
+
operator_id = str(event.get("operator_id", ""))
|
|
325
|
+
duration = event.get("duration", 0)
|
|
326
|
+
action = "banned" if sub == "ban" else "unbanned"
|
|
327
|
+
self.writer.write_alert("NAPCAT_CLI_NOTICE", {
|
|
328
|
+
"summary": f"{user_id} {action} in group {group_id} by {operator_id} ({duration}s)",
|
|
329
|
+
"notice_type": "group_ban",
|
|
330
|
+
"sub_type": sub,
|
|
331
|
+
"user_id": user_id,
|
|
332
|
+
"group_id": group_id,
|
|
333
|
+
"operator_id": operator_id,
|
|
334
|
+
"duration": duration,
|
|
335
|
+
})
|
|
336
|
+
# Wake if bot was banned
|
|
337
|
+
if self.self_id and user_id == self.self_id and sub == "ban":
|
|
338
|
+
self._wake("BOT_BANNED", event)
|
|
339
|
+
|
|
340
|
+
def _handle_group_decrease(self, event: dict, sub_type: str) -> None:
|
|
341
|
+
user_id = str(event.get("user_id", ""))
|
|
342
|
+
group_id = str(event.get("group_id", ""))
|
|
343
|
+
operator_id = str(event.get("operator_id", ""))
|
|
344
|
+
self.writer.write_alert("NAPCAT_CLI_NOTICE", {
|
|
345
|
+
"summary": f"Member {user_id} left group {group_id} ({sub_type}) by {operator_id}",
|
|
346
|
+
"notice_type": "group_decrease",
|
|
347
|
+
"sub_type": sub_type,
|
|
348
|
+
"user_id": user_id,
|
|
349
|
+
"group_id": group_id,
|
|
350
|
+
"operator_id": operator_id,
|
|
351
|
+
})
|
|
352
|
+
# Wake if bot was kicked
|
|
353
|
+
if sub_type == "kick_me" and self.self_id:
|
|
354
|
+
self._wake("BOT_KICKED_FROM_GROUP", event)
|
|
355
|
+
elif sub_type == "disband":
|
|
356
|
+
self._wake("GROUP_DISBANDED", event)
|
|
357
|
+
|
|
358
|
+
def _handle_group_increase(self, event: dict, sub_type: str) -> None:
|
|
359
|
+
user_id = str(event.get("user_id", ""))
|
|
360
|
+
group_id = str(event.get("group_id", ""))
|
|
361
|
+
operator_id = str(event.get("operator_id", ""))
|
|
362
|
+
self.writer.write_alert("NAPCAT_CLI_NOTICE", {
|
|
363
|
+
"summary": f"New member {user_id} joined group {group_id} ({sub_type}) by {operator_id}",
|
|
364
|
+
"notice_type": "group_increase",
|
|
365
|
+
"sub_type": sub_type,
|
|
366
|
+
"user_id": user_id,
|
|
367
|
+
"group_id": group_id,
|
|
368
|
+
"operator_id": operator_id,
|
|
369
|
+
})
|
|
370
|
+
self._wake("NEW_GROUP_MEMBER", event)
|
|
371
|
+
|
|
372
|
+
def _handle_group_upload(self, event: dict) -> None:
|
|
373
|
+
file_info = event.get("file", {})
|
|
374
|
+
self.writer.write_alert("NAPCAT_CLI_NOTICE", {
|
|
375
|
+
"summary": f"File uploaded by {event.get('user_id')} in group {event.get('group_id')}: {file_info.get('name', '?')}",
|
|
376
|
+
"notice_type": "group_upload",
|
|
377
|
+
"group_id": str(event.get("group_id", "")),
|
|
378
|
+
"user_id": str(event.get("user_id", "")),
|
|
379
|
+
"file_name": file_info.get("name", ""),
|
|
380
|
+
"file_id": file_info.get("id", ""),
|
|
381
|
+
"file_size": file_info.get("size", 0),
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
def _handle_group_recall(self, event: dict) -> None:
|
|
385
|
+
user_id = str(event.get("user_id", ""))
|
|
386
|
+
operator_id = str(event.get("operator_id", ""))
|
|
387
|
+
group_id = str(event.get("group_id", ""))
|
|
388
|
+
msg_id = str(event.get("message_id", ""))
|
|
389
|
+
self.writer.write_alert("NAPCAT_CLI_NOTICE", {
|
|
390
|
+
"summary": f"Message {msg_id} recalled in group {group_id} by {operator_id} (sender: {user_id})",
|
|
391
|
+
"notice_type": "group_recall",
|
|
392
|
+
"group_id": group_id,
|
|
393
|
+
"user_id": user_id,
|
|
394
|
+
"operator_id": operator_id,
|
|
395
|
+
"message_id": msg_id,
|
|
396
|
+
})
|
|
397
|
+
# Wake if bot's own message was recalled
|
|
398
|
+
if self.self_id and user_id == self.self_id:
|
|
399
|
+
self._wake("MY_MESSAGE_RECALLED", event)
|
|
400
|
+
|
|
401
|
+
def _handle_group_card(self, event: dict) -> None:
|
|
402
|
+
user_id = str(event.get("user_id", ""))
|
|
403
|
+
group_id = str(event.get("group_id", ""))
|
|
404
|
+
card_new = event.get("card_new", "")
|
|
405
|
+
card_old = event.get("card_old", "")
|
|
406
|
+
self.writer.write_alert("NAPCAT_CLI_NOTICE", {
|
|
407
|
+
"summary": f"Card changed for {user_id} in group {group_id}: '{card_old}' → '{card_new}'",
|
|
408
|
+
"notice_type": "group_card",
|
|
409
|
+
"group_id": group_id,
|
|
410
|
+
"user_id": user_id,
|
|
411
|
+
"card_old": card_old,
|
|
412
|
+
"card_new": card_new,
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
def _handle_group_emoji_like(self, event: dict) -> None:
|
|
416
|
+
user_id = str(event.get("user_id", ""))
|
|
417
|
+
group_id = str(event.get("group_id", ""))
|
|
418
|
+
msg_id = str(event.get("message_id", ""))
|
|
419
|
+
likes = event.get("likes", [])
|
|
420
|
+
emoji_ids = [l.get("emoji_id", "") for l in likes]
|
|
421
|
+
is_add = event.get("is_add", True)
|
|
422
|
+
action = "reacted" if is_add else "removed reaction from"
|
|
423
|
+
self.writer.write_alert("NAPCAT_CLI_NOTICE", {
|
|
424
|
+
"summary": f"{user_id} {action} message {msg_id} with {emoji_ids}",
|
|
425
|
+
"notice_type": "group_msg_emoji_like",
|
|
426
|
+
"group_id": group_id,
|
|
427
|
+
"user_id": user_id,
|
|
428
|
+
"message_id": msg_id,
|
|
429
|
+
"likes": likes,
|
|
430
|
+
"is_add": is_add,
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
# ---- friend sub-handlers ----
|
|
434
|
+
|
|
435
|
+
def _handle_friend_add(self, event: dict) -> None:
|
|
436
|
+
user_id = str(event.get("user_id", ""))
|
|
437
|
+
self.writer.write_alert("NAPCAT_CLI_NEW_REQUEST", {
|
|
438
|
+
"summary": f"New friend added: {user_id}",
|
|
439
|
+
"notice_type": "friend_add",
|
|
440
|
+
"user_id": user_id,
|
|
441
|
+
})
|
|
442
|
+
self._wake("NEW_FRIEND", event)
|
|
443
|
+
|
|
444
|
+
def _handle_friend_recall(self, event: dict) -> None:
|
|
445
|
+
user_id = str(event.get("user_id", ""))
|
|
446
|
+
msg_id = str(event.get("message_id", ""))
|
|
447
|
+
self.writer.write_alert("NAPCAT_CLI_NOTICE", {
|
|
448
|
+
"summary": f"Friend {user_id} recalled message {msg_id}",
|
|
449
|
+
"notice_type": "friend_recall",
|
|
450
|
+
"user_id": user_id,
|
|
451
|
+
"message_id": msg_id,
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
def _handle_request(self, event: dict) -> None:
|
|
455
|
+
req_type = event.get("request_type", "")
|
|
456
|
+
sub_type = event.get("sub_type", "")
|
|
457
|
+
flag = event.get("flag", "")
|
|
458
|
+
user_id = str(event.get("user_id", ""))
|
|
459
|
+
comment = event.get("comment", "")
|
|
460
|
+
alert_data: dict[str, Any] = {
|
|
461
|
+
"summary": f"{req_type} request from {user_id}: {comment[:30]}",
|
|
462
|
+
"request_type": req_type,
|
|
463
|
+
"sub_type": sub_type,
|
|
464
|
+
"user_id": user_id,
|
|
465
|
+
"flag": flag,
|
|
466
|
+
"comment": comment,
|
|
467
|
+
}
|
|
468
|
+
if req_type == "group":
|
|
469
|
+
alert_data["group_id"] = str(event.get("group_id", ""))
|
|
470
|
+
alert_data["summary"] = f"{req_type} {sub_type} request from {user_id} to group {event.get('group_id', '?')}: {comment[:30]}"
|
|
471
|
+
self.writer.write_alert("NAPCAT_CLI_NEW_REQUEST", alert_data)
|
|
472
|
+
self._wake("NEW_REQUEST", event)
|
|
473
|
+
|
|
474
|
+
def _handle_meta(self, event: dict) -> None:
|
|
475
|
+
sub_type = event.get("sub_type", "")
|
|
476
|
+
if sub_type == "lifespan":
|
|
477
|
+
status = event.get("status", "")
|
|
478
|
+
self.log(f"Connection status: {status}")
|
|
479
|
+
if status in ("down", "offline"):
|
|
480
|
+
self.writer.write_alert("NAPCAT_CLI_NOTICE", {
|
|
481
|
+
"summary": "Bot connection lost",
|
|
482
|
+
"meta_type": "lifespan",
|
|
483
|
+
"status": status,
|
|
484
|
+
})
|
|
485
|
+
self._wake("BOT_OFFLINE", event)
|
|
486
|
+
elif sub_type == "heartbeat":
|
|
487
|
+
interval = event.get("interval", 0)
|
|
488
|
+
self.log(f"Heartbeat ({interval}s)")
|
|
489
|
+
|
|
490
|
+
@staticmethod
|
|
491
|
+
def _event_brief(event: dict | None) -> str:
|
|
492
|
+
"""One-line, grep-friendly summary of the triggering event for logs."""
|
|
493
|
+
if not event:
|
|
494
|
+
return "(no event)"
|
|
495
|
+
sender = event.get("sender") if isinstance(event.get("sender"), dict) else {}
|
|
496
|
+
who = sender.get("nickname") or event.get("user_id") or "?"
|
|
497
|
+
g = event.get("group_id")
|
|
498
|
+
where = f"group{g}" if g else "dm"
|
|
499
|
+
msg = event.get("message") if event.get("message") is not None else event.get("raw_message", "")
|
|
500
|
+
if isinstance(msg, list):
|
|
501
|
+
text = "".join(
|
|
502
|
+
(s.get("data") or {}).get("text", "")
|
|
503
|
+
for s in msg if isinstance(s, dict) and s.get("type") == "text"
|
|
504
|
+
)
|
|
505
|
+
else:
|
|
506
|
+
text = str(msg)
|
|
507
|
+
return f"who={who} where={where} text={text[:40]!r}"
|
|
508
|
+
|
|
509
|
+
def _wake(self, reason: str, event: dict | None = None) -> None:
|
|
510
|
+
if not self.wake_on_event:
|
|
511
|
+
self.log(f"[WAKE] disabled, skip reason={reason}")
|
|
512
|
+
return
|
|
513
|
+
|
|
514
|
+
self.log(f"[WAKE] trigger reason={reason} {self._event_brief(event)}")
|
|
515
|
+
self.writer.write_alert("NAPCAT_CLI_NEED_WAKE_UP", {
|
|
516
|
+
"summary": f"Wake up needed: {reason}",
|
|
517
|
+
"reason": reason,
|
|
518
|
+
"timestamp": int(time.time()),
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
# Preferred path: hand to the orchestrator (debounce/cooldown/backlog +
|
|
522
|
+
# Waker with http->cli auto-fallback; also owns the legacy_command escape
|
|
523
|
+
# hatch when no backend is configured).
|
|
524
|
+
if self.orchestrator is not None:
|
|
525
|
+
self.orchestrator.submit(reason, event)
|
|
526
|
+
return
|
|
527
|
+
|
|
528
|
+
# Legacy path: run wake_command as a shell string (back-compat).
|
|
529
|
+
if self.wake_command:
|
|
530
|
+
self.log(f"Executing wake command: {self.wake_command}")
|
|
531
|
+
try:
|
|
532
|
+
import subprocess
|
|
533
|
+
from napcat_cli.wake import build_wake_command
|
|
534
|
+
cmd = build_wake_command(self.wake_command, reason)
|
|
535
|
+
subprocess.run(cmd, shell=True, check=True, timeout=30,
|
|
536
|
+
capture_output=True, text=True)
|
|
537
|
+
except Exception as e:
|
|
538
|
+
self.log(f"Wake command failed: {e}")
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
# ---------------------------------------------------------------------------
|
|
542
|
+
# In-memory event cache (for HTTP provider reads)
|
|
543
|
+
# ---------------------------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
class EventCache:
|
|
546
|
+
"""Thread-safe in-memory cache of recent events."""
|
|
547
|
+
|
|
548
|
+
def __init__(self, data_dir: Path, max_events: int = 500):
|
|
549
|
+
self.data_dir = data_dir
|
|
550
|
+
self.max_events = max_events
|
|
551
|
+
self._lock = threading.Lock()
|
|
552
|
+
self._events: list[dict[str, Any]] = []
|
|
553
|
+
|
|
554
|
+
def add(self, event: dict[str, Any]) -> None:
|
|
555
|
+
with self._lock:
|
|
556
|
+
self._events.insert(0, event)
|
|
557
|
+
if len(self._events) > self.max_events:
|
|
558
|
+
self._events = self._events[: self.max_events]
|
|
559
|
+
|
|
560
|
+
def get(self, limit: int = 50, since: float = 0.0) -> list[dict[str, Any]]:
|
|
561
|
+
with self._lock:
|
|
562
|
+
events = list(self._events)
|
|
563
|
+
if since:
|
|
564
|
+
events = [e for e in events if e.get("time", 0) >= since]
|
|
565
|
+
return events[:limit]
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
# ---------------------------------------------------------------------------
|
|
569
|
+
# WebSocket listener
|
|
570
|
+
# ---------------------------------------------------------------------------
|
|
571
|
+
|
|
572
|
+
async def ws_daemon(ws_url: str, processor: EventProcessor, cache: EventCache) -> None:
|
|
573
|
+
"""WebSocket daemon - connects to NapCat WS server."""
|
|
574
|
+
retry_delay = 5
|
|
575
|
+
|
|
576
|
+
while True:
|
|
577
|
+
try:
|
|
578
|
+
processor.log(f"Connecting to WebSocket: {ws_url}")
|
|
579
|
+
|
|
580
|
+
try:
|
|
581
|
+
import aiohttp
|
|
582
|
+
session = aiohttp.ClientSession()
|
|
583
|
+
|
|
584
|
+
async with session.ws_connect(ws_url, heartbeat=30) as ws:
|
|
585
|
+
processor.log(f"Connected to {ws_url}")
|
|
586
|
+
|
|
587
|
+
token = os.environ.get("NAPCAT_TOKEN", "")
|
|
588
|
+
if token:
|
|
589
|
+
await ws.send_json({
|
|
590
|
+
"time": int(time.time()),
|
|
591
|
+
"post_type": "connect",
|
|
592
|
+
"token": token,
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
async for msg in ws:
|
|
596
|
+
if msg.type == aiohttp.WSMsgType.TEXT:
|
|
597
|
+
try:
|
|
598
|
+
event = json.loads(msg.data)
|
|
599
|
+
processor.process(event)
|
|
600
|
+
cache.add(event)
|
|
601
|
+
except json.JSONDecodeError:
|
|
602
|
+
processor.log(f"Invalid JSON: {msg.data[:100]}")
|
|
603
|
+
elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR):
|
|
604
|
+
break
|
|
605
|
+
|
|
606
|
+
await session.close()
|
|
607
|
+
retry_delay = 5
|
|
608
|
+
|
|
609
|
+
except ImportError:
|
|
610
|
+
from websockets.connect import connect
|
|
611
|
+
async with connect(ws_url, ping_interval=30) as ws:
|
|
612
|
+
processor.log(f"Connected to {ws_url}")
|
|
613
|
+
retry_delay = 5
|
|
614
|
+
|
|
615
|
+
async for message in ws:
|
|
616
|
+
try:
|
|
617
|
+
event = json.loads(message)
|
|
618
|
+
processor.process(event)
|
|
619
|
+
cache.add(event)
|
|
620
|
+
except json.JSONDecodeError:
|
|
621
|
+
processor.log(f"Invalid JSON: {str(message)[:100]}")
|
|
622
|
+
|
|
623
|
+
except Exception as e:
|
|
624
|
+
processor.log(f"Connection error: {e}")
|
|
625
|
+
|
|
626
|
+
await asyncio.sleep(retry_delay)
|
|
627
|
+
retry_delay = min(retry_delay * 2, 60)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
# ---------------------------------------------------------------------------
|
|
631
|
+
# Skills-fs FUSE Manager — spawn, monitor, restart on crash
|
|
632
|
+
# ---------------------------------------------------------------------------
|
|
633
|
+
|
|
634
|
+
# Default mountpoint is the napcat-cli skill directory itself, so skills-fs
|
|
635
|
+
# FUSE-overlays it: mounted => generated SKILL.md/AGENTS.md + the /napcat/ tree
|
|
636
|
+
# take over; unmounted => the static skill files (pre-mount) reappear.
|
|
637
|
+
_DEFAULT_MOUNTPOINT = str(Path.home() / ".hermes" / "skills" / "napcat-cli")
|
|
638
|
+
_DEFAULT_SKILLSFS_CONFIG = str(Path.home() / ".napcat-data" / "skills-fs.json")
|
|
639
|
+
def _resolve_shipped_binary() -> str:
|
|
640
|
+
"""Return the shipped skills-fs binary path for source-tree dev mode, or '' if absent."""
|
|
641
|
+
shipped = Path(__file__).resolve().parents[2] / "skills-fs" / "skills-fs"
|
|
642
|
+
return str(shipped) if shipped.exists() and shipped.is_file() else ""
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
class SkillsFsManager:
|
|
646
|
+
"""Spawn skills-fs FUSE daemon and monitor its health.
|
|
647
|
+
|
|
648
|
+
Lifecycle:
|
|
649
|
+
1. After HTTP provider starts, spawn skills-fs with --daemon --pidfile.
|
|
650
|
+
2. Read PID from pidfile and monitor with os.kill(pid, 0).
|
|
651
|
+
3. If process dies, write degraded status file, restart with backoff.
|
|
652
|
+
4. On shutdown, kill the child and unmount.
|
|
653
|
+
"""
|
|
654
|
+
|
|
655
|
+
def __init__(
|
|
656
|
+
self,
|
|
657
|
+
processor: EventProcessor,
|
|
658
|
+
mountpoint: str = _DEFAULT_MOUNTPOINT,
|
|
659
|
+
binary: str = "",
|
|
660
|
+
config: str = _DEFAULT_SKILLSFS_CONFIG,
|
|
661
|
+
pidfile: str = "",
|
|
662
|
+
):
|
|
663
|
+
self.processor = processor
|
|
664
|
+
self.mountpoint = mountpoint
|
|
665
|
+
self.pidfile = pidfile or (DATA_DIR / "skills-fs.pid").as_posix()
|
|
666
|
+
self.config = config
|
|
667
|
+
|
|
668
|
+
# Resolve binary: config > shipped > PATH
|
|
669
|
+
self.binary = binary
|
|
670
|
+
if not self.binary:
|
|
671
|
+
# Try shipped binary next to this repo
|
|
672
|
+
shipped = Path(_resolve_shipped_binary())
|
|
673
|
+
if shipped.exists() and shipped.is_file():
|
|
674
|
+
self.binary = str(shipped)
|
|
675
|
+
else:
|
|
676
|
+
# Search PATH
|
|
677
|
+
import shutil
|
|
678
|
+
found = shutil.which("skills-fs")
|
|
679
|
+
if found:
|
|
680
|
+
self.binary = found
|
|
681
|
+
self._pid: int | None = None
|
|
682
|
+
self._status: str = "stopped" # healthy | degraded | stopped
|
|
683
|
+
self._max_restarts = 3
|
|
684
|
+
self._restart_count = 0
|
|
685
|
+
self._stale_cleaned = False
|
|
686
|
+
|
|
687
|
+
@property
|
|
688
|
+
def status(self) -> str:
|
|
689
|
+
return self._status
|
|
690
|
+
|
|
691
|
+
def start(self) -> bool:
|
|
692
|
+
"""Spawn skills-fs FUSE daemon. Returns True if mount succeeded."""
|
|
693
|
+
if not self.binary:
|
|
694
|
+
self.processor.log("skills-fs: no binary found, skipping mount")
|
|
695
|
+
self._status = "degraded"
|
|
696
|
+
self._write_degraded("skills-fs binary not found")
|
|
697
|
+
return False
|
|
698
|
+
|
|
699
|
+
# Reuse an already-healthy mount instead of stacking a second FUSE daemon
|
|
700
|
+
# (multiple skills-fs on one mountpoint is what deadlocks into D-state).
|
|
701
|
+
if self._existing_mount_healthy():
|
|
702
|
+
self._status = "healthy"
|
|
703
|
+
self._restart_count = 0
|
|
704
|
+
self.processor.log(f"skills-fs: reusing existing healthy mount at {self.mountpoint}")
|
|
705
|
+
return True
|
|
706
|
+
|
|
707
|
+
# Clean stale mount before starting
|
|
708
|
+
if not self._stale_cleaned:
|
|
709
|
+
self._clean_stale_mount()
|
|
710
|
+
self._stale_cleaned = True
|
|
711
|
+
|
|
712
|
+
args = [
|
|
713
|
+
self.binary, "fuse",
|
|
714
|
+
"--config", self.config,
|
|
715
|
+
"--mountpoint", self.mountpoint,
|
|
716
|
+
"--allow-other",
|
|
717
|
+
"--pidfile", self.pidfile,
|
|
718
|
+
"--log-file", str(DATA_DIR / "skills-fs.log"),
|
|
719
|
+
"--log-level", "info",
|
|
720
|
+
"--daemon",
|
|
721
|
+
]
|
|
722
|
+
try:
|
|
723
|
+
self.processor.log(f"skills-fs: spawning {args}")
|
|
724
|
+
import subprocess
|
|
725
|
+
subprocess.run(args, check=True, timeout=10)
|
|
726
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e:
|
|
727
|
+
self.processor.log(f"skills-fs: spawn failed: {e}")
|
|
728
|
+
self._status = "degraded"
|
|
729
|
+
self._write_degraded(f"spawn failed: {e}")
|
|
730
|
+
return False
|
|
731
|
+
|
|
732
|
+
# Read PID from pidfile (daemon mode writes it)
|
|
733
|
+
self._wait_for_pid(max_wait=5)
|
|
734
|
+
if self._pid is None:
|
|
735
|
+
self.processor.log("skills-fs: pidfile not written in time")
|
|
736
|
+
self._status = "degraded"
|
|
737
|
+
self._write_degraded("pid not available")
|
|
738
|
+
return False
|
|
739
|
+
|
|
740
|
+
# Verify mount is accessible (timeout-guarded). If the mount isn't healthy
|
|
741
|
+
# within the deadline, kill the child so we don't leave a half-dead FUSE
|
|
742
|
+
# daemon that would deadlock readers into D-state.
|
|
743
|
+
if not self._verify_mount():
|
|
744
|
+
self.processor.log("skills-fs: mount not healthy after spawn — killing child, going degraded")
|
|
745
|
+
self._kill_child()
|
|
746
|
+
self._status = "degraded"
|
|
747
|
+
self._write_degraded("mount verification failed or timed out")
|
|
748
|
+
return False
|
|
749
|
+
|
|
750
|
+
self._status = "healthy"
|
|
751
|
+
self._restart_count = 0
|
|
752
|
+
self.processor.log(f"skills-fs: mounted at {self.mountpoint} (PID {self._pid})")
|
|
753
|
+
return True
|
|
754
|
+
|
|
755
|
+
def check(self) -> bool:
|
|
756
|
+
"""Check if the FUSE process is still alive. Returns True if healthy."""
|
|
757
|
+
if self._pid is None:
|
|
758
|
+
self._status = "stopped"
|
|
759
|
+
return False
|
|
760
|
+
|
|
761
|
+
try:
|
|
762
|
+
os.kill(self._pid, 0)
|
|
763
|
+
except OSError:
|
|
764
|
+
# Process is dead
|
|
765
|
+
self._status = "degraded"
|
|
766
|
+
self._write_degraded(f"skills-fs process {self._pid} died")
|
|
767
|
+
self._pid = None
|
|
768
|
+
self._stale_cleaned = False # allow re-clean on restart
|
|
769
|
+
return False
|
|
770
|
+
|
|
771
|
+
# Also verify the mount is still accessible
|
|
772
|
+
if not self._verify_mount():
|
|
773
|
+
self._status = "degraded"
|
|
774
|
+
self._write_degraded("mount no longer accessible")
|
|
775
|
+
return False
|
|
776
|
+
|
|
777
|
+
self._status = "healthy"
|
|
778
|
+
return True
|
|
779
|
+
|
|
780
|
+
def restart(self) -> bool:
|
|
781
|
+
"""Try to restart skills-fs after a crash."""
|
|
782
|
+
self._restart_count += 1
|
|
783
|
+
if self._restart_count > self._max_restarts:
|
|
784
|
+
self._status = "degraded"
|
|
785
|
+
self._write_degraded(
|
|
786
|
+
f"skills-fs crashed {self._restart_count} times, giving up"
|
|
787
|
+
)
|
|
788
|
+
self.processor.log(
|
|
789
|
+
f"skills-fs: exceeded max restarts ({self._max_restarts}), degraded"
|
|
790
|
+
)
|
|
791
|
+
return False
|
|
792
|
+
|
|
793
|
+
# Exponential backoff before restart
|
|
794
|
+
delay = min(2 ** self._restart_count, 30)
|
|
795
|
+
self.processor.log(f"skills-fs: restarting in {delay}s (attempt {self._restart_count})")
|
|
796
|
+
import time as _time
|
|
797
|
+
_time.sleep(delay)
|
|
798
|
+
|
|
799
|
+
return self.start()
|
|
800
|
+
|
|
801
|
+
def stop(self) -> None:
|
|
802
|
+
"""Kill the child process and unmount."""
|
|
803
|
+
if self._pid is not None:
|
|
804
|
+
try:
|
|
805
|
+
os.kill(self._pid, signal.SIGTERM)
|
|
806
|
+
self.processor.log(f"skills-fs: sent SIGTERM to PID {self._pid}")
|
|
807
|
+
except OSError:
|
|
808
|
+
pass
|
|
809
|
+
# Lazy unmount to release any blocked readers
|
|
810
|
+
self._unmount()
|
|
811
|
+
# Clean up pidfile
|
|
812
|
+
try:
|
|
813
|
+
Path(self.pidfile).unlink(missing_ok=True)
|
|
814
|
+
except Exception:
|
|
815
|
+
pass
|
|
816
|
+
self._pid = None
|
|
817
|
+
self._status = "stopped"
|
|
818
|
+
|
|
819
|
+
# --- internal helpers ---
|
|
820
|
+
|
|
821
|
+
def _wait_for_pid(self, max_wait: int = 5) -> None:
|
|
822
|
+
"""Wait for the pidfile to appear and read the PID."""
|
|
823
|
+
for _ in range(max_wait * 10):
|
|
824
|
+
try:
|
|
825
|
+
data = Path(self.pidfile).read_text().strip()
|
|
826
|
+
self._pid = int(data)
|
|
827
|
+
return
|
|
828
|
+
except (FileNotFoundError, ValueError):
|
|
829
|
+
import time as _time
|
|
830
|
+
_time.sleep(0.1)
|
|
831
|
+
|
|
832
|
+
def _verify_mount(self, timeout: float = 8.0) -> bool:
|
|
833
|
+
"""Check that the mountpoint is accessible.
|
|
834
|
+
|
|
835
|
+
Timeout-guarded: a hung FUSE daemon can never put this process into
|
|
836
|
+
uninterruptible (D) sleep — the blocking probe runs in a daemon thread
|
|
837
|
+
that is abandoned if it doesn't return within ``timeout``.
|
|
838
|
+
"""
|
|
839
|
+
def _probe() -> bool:
|
|
840
|
+
status_file = Path(self.mountpoint) / "status"
|
|
841
|
+
if status_file.exists():
|
|
842
|
+
status_file.read_text()
|
|
843
|
+
return True
|
|
844
|
+
os.stat(self.mountpoint)
|
|
845
|
+
import subprocess
|
|
846
|
+
result = subprocess.run(["mount"], capture_output=True, text=True, timeout=3)
|
|
847
|
+
return self.mountpoint in result.stdout
|
|
848
|
+
|
|
849
|
+
res = _run_with_timeout(_probe, timeout)
|
|
850
|
+
if res is _TIMEOUT:
|
|
851
|
+
self.processor.log(f"skills-fs: mount verify timed out ({timeout}s) — hung FUSE?")
|
|
852
|
+
return False
|
|
853
|
+
return res is True
|
|
854
|
+
|
|
855
|
+
def _existing_mount_healthy(self) -> bool:
|
|
856
|
+
"""True if a skillsfs FUSE mount at our mountpoint is already up and
|
|
857
|
+
responsive. Used to REUSE an existing mount instead of stacking a second
|
|
858
|
+
FUSE daemon on the same point (stacking is what deadlocks into D-state).
|
|
859
|
+
"""
|
|
860
|
+
try:
|
|
861
|
+
import subprocess
|
|
862
|
+
r = subprocess.run(["mount"], capture_output=True, text=True, timeout=3)
|
|
863
|
+
if self.mountpoint not in r.stdout:
|
|
864
|
+
return False
|
|
865
|
+
except Exception:
|
|
866
|
+
return False
|
|
867
|
+
if not self._verify_mount(timeout=6.0):
|
|
868
|
+
return False
|
|
869
|
+
# adopt the existing pidfile so we can manage/stop it later
|
|
870
|
+
self._wait_for_pid(max_wait=1)
|
|
871
|
+
return True
|
|
872
|
+
|
|
873
|
+
def _kill_child(self) -> None:
|
|
874
|
+
"""SIGTERM then SIGKILL the skills-fs child; don't leave a half-dead FUSE."""
|
|
875
|
+
if self._pid is None:
|
|
876
|
+
return
|
|
877
|
+
for sig in (signal.SIGTERM, signal.SIGKILL):
|
|
878
|
+
try:
|
|
879
|
+
os.kill(self._pid, sig)
|
|
880
|
+
except OSError:
|
|
881
|
+
break
|
|
882
|
+
import time as _time
|
|
883
|
+
_time.sleep(0.5)
|
|
884
|
+
try:
|
|
885
|
+
os.kill(self._pid, 0)
|
|
886
|
+
except OSError:
|
|
887
|
+
break
|
|
888
|
+
self._unmount()
|
|
889
|
+
try:
|
|
890
|
+
Path(self.pidfile).unlink(missing_ok=True)
|
|
891
|
+
except Exception:
|
|
892
|
+
pass
|
|
893
|
+
self._pid = None
|
|
894
|
+
|
|
895
|
+
def _clean_stale_mount(self) -> None:
|
|
896
|
+
"""Lazy-unmount any stale FUSE mount at our mountpoint."""
|
|
897
|
+
try:
|
|
898
|
+
import subprocess
|
|
899
|
+
result = subprocess.run(
|
|
900
|
+
["umount", "-l", self.mountpoint],
|
|
901
|
+
capture_output=True, text=True, timeout=5,
|
|
902
|
+
)
|
|
903
|
+
if result.returncode == 0:
|
|
904
|
+
self.processor.log(f"skills-fs: cleaned stale mount at {self.mountpoint}")
|
|
905
|
+
except Exception as e:
|
|
906
|
+
self.processor.log(f"skills-fs: stale mount cleanup error: {e}")
|
|
907
|
+
|
|
908
|
+
def _unmount(self) -> None:
|
|
909
|
+
"""Unmount the FUSE filesystem."""
|
|
910
|
+
try:
|
|
911
|
+
import subprocess
|
|
912
|
+
subprocess.run(
|
|
913
|
+
["umount", self.mountpoint],
|
|
914
|
+
capture_output=True, text=True, timeout=5,
|
|
915
|
+
)
|
|
916
|
+
except Exception:
|
|
917
|
+
try:
|
|
918
|
+
subprocess.run(
|
|
919
|
+
["umount", "-l", self.mountpoint],
|
|
920
|
+
capture_output=True, text=True, timeout=5,
|
|
921
|
+
)
|
|
922
|
+
except Exception:
|
|
923
|
+
pass
|
|
924
|
+
|
|
925
|
+
def _write_degraded(self, reason: str) -> None:
|
|
926
|
+
"""Write a degraded status file so skills-fs consumers know the mount is unavailable."""
|
|
927
|
+
degraded_file = Path(self.mountpoint) / "SKILLS_FS_DEGRADED"
|
|
928
|
+
try:
|
|
929
|
+
info = {
|
|
930
|
+
"status": "degraded",
|
|
931
|
+
"reason": reason,
|
|
932
|
+
"timestamp": time.time(),
|
|
933
|
+
"message": "skills-fs FUSE mount is unavailable. Static files (SKILL.md, AGENTS.md) are still accessible but API endpoints will fail.",
|
|
934
|
+
}
|
|
935
|
+
Path(self.mountpoint).mkdir(parents=True, exist_ok=True)
|
|
936
|
+
degraded_file.write_text(json.dumps(info, indent=2, ensure_ascii=False))
|
|
937
|
+
except Exception:
|
|
938
|
+
pass
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
async def skillsfs_monitor_task(manager: SkillsFsManager, interval: int = 10) -> None:
|
|
942
|
+
"""Background task that periodically checks skills-fs health and restarts on failure."""
|
|
943
|
+
while True:
|
|
944
|
+
await asyncio.sleep(interval)
|
|
945
|
+
if manager.status == "stopped":
|
|
946
|
+
continue
|
|
947
|
+
if not manager.check():
|
|
948
|
+
manager.restart()
|
|
949
|
+
# ---------------------------------------------------------------------------
|
|
950
|
+
# HTTP Provider (skills-fs compatible endpoint)
|
|
951
|
+
# ---------------------------------------------------------------------------
|
|
952
|
+
|
|
953
|
+
class NapCatHandler(BaseHTTPRequestHandler):
|
|
954
|
+
"""HTTP handler implementing skills-fs HTTP provider contract."""
|
|
955
|
+
|
|
956
|
+
processor: EventProcessor = None
|
|
957
|
+
cache: EventCache = None
|
|
958
|
+
events_reader: EventsReader = None
|
|
959
|
+
skillsfs_manager: SkillsFsManager = None
|
|
960
|
+
|
|
961
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
962
|
+
pass # suppress default logging
|
|
963
|
+
|
|
964
|
+
def _send_json(self, data: Any, status: int = 200) -> None:
|
|
965
|
+
self.send_response(status)
|
|
966
|
+
self.send_header("Content-Type", "application/json")
|
|
967
|
+
self.end_headers()
|
|
968
|
+
self.wfile.write(json.dumps(data, ensure_ascii=False).encode("utf-8"))
|
|
969
|
+
|
|
970
|
+
def _send_error(self, status: int, message: str) -> None:
|
|
971
|
+
self.send_response(status)
|
|
972
|
+
self.send_header("Content-Type", "text/plain")
|
|
973
|
+
self.end_headers()
|
|
974
|
+
self.wfile.write(message.encode("utf-8"))
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def do_GET(self) -> None:
|
|
978
|
+
"""Handle GET requests with parameterized query support.
|
|
979
|
+
|
|
980
|
+
Supports:
|
|
981
|
+
- GET /events?since=&type=&limit=&post_type=
|
|
982
|
+
- GET /alerts?name=&limit=
|
|
983
|
+
- GET /invoke?action=...¶m=value (read-only API actions proxied via GET)
|
|
984
|
+
- GET /status (bot online status)
|
|
985
|
+
"""
|
|
986
|
+
parsed = urlparse(self.path)
|
|
987
|
+
path = parsed.path
|
|
988
|
+
qs = parse_qs(parsed.query)
|
|
989
|
+
|
|
990
|
+
def _first(k: str, default: str = "") -> str:
|
|
991
|
+
return qs.get(k, [default])[0]
|
|
992
|
+
|
|
993
|
+
def _int(k: str, default: int = 0) -> int:
|
|
994
|
+
try:
|
|
995
|
+
return int(_first(k, str(default)))
|
|
996
|
+
except ValueError:
|
|
997
|
+
return default
|
|
998
|
+
|
|
999
|
+
if path == "/events":
|
|
1000
|
+
try:
|
|
1001
|
+
result = self._dispatch("get_events", {
|
|
1002
|
+
"limit": _int("limit", 50),
|
|
1003
|
+
"since": _first("since", "") or None,
|
|
1004
|
+
"post_type": _first("post_type") or None,
|
|
1005
|
+
"event_type": _first("event_type") or None,
|
|
1006
|
+
})
|
|
1007
|
+
self._send_json(result)
|
|
1008
|
+
except Exception as e:
|
|
1009
|
+
self._send_error(500, str(e))
|
|
1010
|
+
return
|
|
1011
|
+
|
|
1012
|
+
if path == "/alerts":
|
|
1013
|
+
try:
|
|
1014
|
+
result = self._dispatch("get_alerts", {
|
|
1015
|
+
"name": _first("name") or None,
|
|
1016
|
+
"limit": _int("limit", 50),
|
|
1017
|
+
})
|
|
1018
|
+
self._send_json(result)
|
|
1019
|
+
except Exception as e:
|
|
1020
|
+
self._send_error(500, str(e))
|
|
1021
|
+
return
|
|
1022
|
+
|
|
1023
|
+
if path == "/status":
|
|
1024
|
+
recent = self.cache.get(limit=10)
|
|
1025
|
+
online = len(recent) > 0
|
|
1026
|
+
result: dict[str, Any] = {
|
|
1027
|
+
"online": online,
|
|
1028
|
+
"last_event": recent[-1].get("time", None) if recent else None,
|
|
1029
|
+
}
|
|
1030
|
+
if self.skillsfs_manager is not None:
|
|
1031
|
+
result["skills_fs"] = {
|
|
1032
|
+
"status": self.skillsfs_manager.status,
|
|
1033
|
+
"mountpoint": self.skillsfs_manager.mountpoint,
|
|
1034
|
+
"pid": self.skillsfs_manager._pid,
|
|
1035
|
+
}
|
|
1036
|
+
self._send_json(result)
|
|
1037
|
+
return
|
|
1038
|
+
|
|
1039
|
+
if path == "/invoke":
|
|
1040
|
+
# GET /invoke only allows read-only actions actually supported by _dispatch.
|
|
1041
|
+
_readable = {
|
|
1042
|
+
"get_events", "get_event", "get_alerts",
|
|
1043
|
+
"list_groups", "list_friends", "list_time_ranges",
|
|
1044
|
+
"list_messages", "get_message", "get_stats",
|
|
1045
|
+
"list_message_content", "get_message_content", "describe_action",
|
|
1046
|
+
}
|
|
1047
|
+
action = _first("action")
|
|
1048
|
+
if not action:
|
|
1049
|
+
self._send_error(400, "Missing action parameter")
|
|
1050
|
+
return
|
|
1051
|
+
if action not in _readable:
|
|
1052
|
+
self._send_error(403, f"Action '{action}' not allowed via GET (use POST for write actions)")
|
|
1053
|
+
return
|
|
1054
|
+
params = {k: v[0] if len(v) == 1 else v for k, v in qs.items() if k != "action"}
|
|
1055
|
+
try:
|
|
1056
|
+
result = self._dispatch(action, params)
|
|
1057
|
+
self._send_json(result)
|
|
1058
|
+
except Exception as e:
|
|
1059
|
+
self._send_error(500, str(e))
|
|
1060
|
+
return
|
|
1061
|
+
|
|
1062
|
+
self._send_error(404, f"Not found: {path}")
|
|
1063
|
+
def do_POST(self) -> None:
|
|
1064
|
+
if self.path not in ("/", "/invoke"):
|
|
1065
|
+
self._send_error(404, "Not found")
|
|
1066
|
+
return
|
|
1067
|
+
|
|
1068
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
1069
|
+
if not length:
|
|
1070
|
+
self._send_error(400, "Missing request body")
|
|
1071
|
+
return
|
|
1072
|
+
|
|
1073
|
+
try:
|
|
1074
|
+
body = self.rfile.read(length)
|
|
1075
|
+
req = json.loads(body)
|
|
1076
|
+
except json.JSONDecodeError as e:
|
|
1077
|
+
self._send_error(400, f"Invalid JSON: {e}")
|
|
1078
|
+
return
|
|
1079
|
+
|
|
1080
|
+
action = req.get("action", "")
|
|
1081
|
+
params = req.get("params", {})
|
|
1082
|
+
|
|
1083
|
+
if not action:
|
|
1084
|
+
self._send_error(400, "Missing action")
|
|
1085
|
+
return
|
|
1086
|
+
|
|
1087
|
+
try:
|
|
1088
|
+
result = self._dispatch(action, params)
|
|
1089
|
+
self._send_json(result)
|
|
1090
|
+
except Exception as e:
|
|
1091
|
+
self.processor.log(f"Action {action!r} failed: {e}")
|
|
1092
|
+
self._send_error(500, str(e))
|
|
1093
|
+
# ------------------------------------------------------------------
|
|
1094
|
+
# Multi-type send/reply helper functions
|
|
1095
|
+
# ------------------------------------------------------------------
|
|
1096
|
+
|
|
1097
|
+
def _read_file_b64(self, path: str) -> str:
|
|
1098
|
+
"""Read a file and return base64-encoded string."""
|
|
1099
|
+
import base64
|
|
1100
|
+
try:
|
|
1101
|
+
with open(path, 'rb') as f:
|
|
1102
|
+
data = f.read()
|
|
1103
|
+
except FileNotFoundError:
|
|
1104
|
+
raise ValueError(f"file not found: {path}")
|
|
1105
|
+
if len(data) > 20 * 1024 * 1024:
|
|
1106
|
+
raise ValueError("file too large (max 20MB)")
|
|
1107
|
+
return base64.b64encode(data).decode()
|
|
1108
|
+
|
|
1109
|
+
def _compose_message(self, kind: str, payload: str, qq: str = None, text: str = None) -> list | str | None:
|
|
1110
|
+
"""Compose message segments by kind.
|
|
1111
|
+
Returns a list of segments, a CQ code string, or None (for file kind).
|
|
1112
|
+
"""
|
|
1113
|
+
if kind == "text":
|
|
1114
|
+
return [{"type": "text", "data": {"text": payload}}]
|
|
1115
|
+
elif kind == "image":
|
|
1116
|
+
b64 = self._read_file_b64(payload)
|
|
1117
|
+
return [{"type": "image", "data": {"file": f"base64://{b64}"}}]
|
|
1118
|
+
elif kind == "file":
|
|
1119
|
+
return None # file upload handled separately via upload API
|
|
1120
|
+
elif kind == "cqcode":
|
|
1121
|
+
return payload # return raw string, NapCat parses CQ codes
|
|
1122
|
+
elif kind == "at":
|
|
1123
|
+
segs = [{"type": "at", "data": {"qq": str(qq)}}]
|
|
1124
|
+
if text:
|
|
1125
|
+
segs.append({"type": "text", "data": {"text": text}})
|
|
1126
|
+
return segs
|
|
1127
|
+
elif kind == "json":
|
|
1128
|
+
import json
|
|
1129
|
+
return json.loads(payload)
|
|
1130
|
+
else:
|
|
1131
|
+
raise ValueError(f"unknown message kind: {kind}")
|
|
1132
|
+
|
|
1133
|
+
def _format_send_result(self, api_result: dict) -> dict:
|
|
1134
|
+
"""Format API send result for writeback read."""
|
|
1135
|
+
if api_result.get("retcode", -1) == 0:
|
|
1136
|
+
data = api_result.get("data", {})
|
|
1137
|
+
mid = data.get("message_id", 0)
|
|
1138
|
+
return {"status": "ok", "message_id": mid}
|
|
1139
|
+
return {"error": api_result.get("message", "unknown"), "retcode": api_result.get("retcode", 0)}
|
|
1140
|
+
|
|
1141
|
+
def _upload_file(self, scope: str, target_id: str, path: str) -> dict:
|
|
1142
|
+
"""Upload a file via base64 to group or private chat."""
|
|
1143
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1144
|
+
api = NapCatAPI()
|
|
1145
|
+
b64 = self._read_file_b64(path)
|
|
1146
|
+
import os
|
|
1147
|
+
name = os.path.basename(path)
|
|
1148
|
+
if scope == "group":
|
|
1149
|
+
result = api.call("upload_group_file", group_id=target_id, file=f"base64://{b64}", name=name)
|
|
1150
|
+
else:
|
|
1151
|
+
result = api.call("upload_private_file", user_id=target_id, file=f"base64://{b64}", name=name)
|
|
1152
|
+
return self._format_send_result(result)
|
|
1153
|
+
|
|
1154
|
+
def _compose_reply(self, base_segments: list, message_id: str) -> list:
|
|
1155
|
+
"""Prepend a reply segment to base segments."""
|
|
1156
|
+
return [{"type": "reply", "data": {"id": str(message_id)}}] + base_segments
|
|
1157
|
+
|
|
1158
|
+
def _resolve_payload(self, params: dict, fallback_key: str) -> str:
|
|
1159
|
+
"""Resolve payload from _payload (skills-fs raw mode) or a named fallback key (HTTP POST)."""
|
|
1160
|
+
v = params.get("_payload")
|
|
1161
|
+
if v:
|
|
1162
|
+
return v
|
|
1163
|
+
v = params.get(fallback_key)
|
|
1164
|
+
return "" if v is None else str(v)
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def _dispatch(self, action: str, params: dict) -> Any:
|
|
1168
|
+
"""Dispatch action to handler. Returns data for skills-fs."""
|
|
1169
|
+
from napcat_cli.lib.events_sqlite import get_connection, read_events as db_read_events, read_alerts as db_read_alerts, get_event_count
|
|
1170
|
+
db = get_connection(self.cache.data_dir)
|
|
1171
|
+
|
|
1172
|
+
if action == "get_events":
|
|
1173
|
+
events = db_read_events(
|
|
1174
|
+
db,
|
|
1175
|
+
limit=params.get("limit", 50),
|
|
1176
|
+
since=params.get("since", None),
|
|
1177
|
+
post_type=params.get("post_type", None),
|
|
1178
|
+
event_type=params.get("event_type", None),
|
|
1179
|
+
)
|
|
1180
|
+
return {"events": events, "count": len(events)}
|
|
1181
|
+
|
|
1182
|
+
if action == "get_event":
|
|
1183
|
+
event_id = params.get("id", "")
|
|
1184
|
+
from napcat_cli.lib.events_sqlite import read_events as db_read
|
|
1185
|
+
events = db_read(db, limit=1000)
|
|
1186
|
+
for e in events:
|
|
1187
|
+
if str(e.get("message_id", "")) == event_id:
|
|
1188
|
+
return e
|
|
1189
|
+
return {"error": f"Event {event_id} not found"}
|
|
1190
|
+
|
|
1191
|
+
if action == "get_alerts":
|
|
1192
|
+
alerts = db_read_alerts(db, name=params.get("name"), limit=params.get("limit", 50))
|
|
1193
|
+
return {"alerts": alerts, "count": len(alerts)}
|
|
1194
|
+
|
|
1195
|
+
if action == "clear_alert":
|
|
1196
|
+
name = params.get("name") or params.get("alert_name", "")
|
|
1197
|
+
from napcat_cli.lib.events_sqlite import clear_alerts
|
|
1198
|
+
count = clear_alerts(self.processor.writer.conn, name)
|
|
1199
|
+
return {"cleared": count}
|
|
1200
|
+
|
|
1201
|
+
if action == "clear_all_alerts":
|
|
1202
|
+
from napcat_cli.lib.events_sqlite import clear_alerts
|
|
1203
|
+
count = clear_alerts(self.processor.writer.conn)
|
|
1204
|
+
return {"cleared": count}
|
|
1205
|
+
|
|
1206
|
+
# Group / message browsing via SQLite indexes
|
|
1207
|
+
if action == "list_groups":
|
|
1208
|
+
groups = set()
|
|
1209
|
+
rows = db.execute("SELECT DISTINCT group_id FROM events WHERE group_id IS NOT NULL ORDER BY group_id").fetchall()
|
|
1210
|
+
for r in rows:
|
|
1211
|
+
groups.add(str(r["group_id"]))
|
|
1212
|
+
return {"entries": [{"name": g, "kind": "dynamic_dir"} for g in sorted(groups)]}
|
|
1213
|
+
|
|
1214
|
+
if action == "list_friends":
|
|
1215
|
+
rows = db.execute(
|
|
1216
|
+
"SELECT DISTINCT user_id FROM events WHERE post_type='message' AND message_type='private' AND user_id IS NOT NULL ORDER BY user_id"
|
|
1217
|
+
).fetchall()
|
|
1218
|
+
users = [str(r["user_id"]) for r in rows if r["user_id"]]
|
|
1219
|
+
return {"entries": [{"name": u, "kind": "dynamic_dir"} for u in sorted(users)]}
|
|
1220
|
+
|
|
1221
|
+
if action == "list_time_ranges":
|
|
1222
|
+
ranges = ["recent", "1days", "7days", "30days", "90days"]
|
|
1223
|
+
entries = [{"name": r, "kind": "dynamic_dir"} for r in ranges]
|
|
1224
|
+
group_id = str(params.get("group_id", ""))
|
|
1225
|
+
user_id = str(params.get("user_id", ""))
|
|
1226
|
+
if group_id:
|
|
1227
|
+
entries.extend([
|
|
1228
|
+
{"name": "kick", "kind": "api"},
|
|
1229
|
+
{"name": "ban", "kind": "api"},
|
|
1230
|
+
{"name": "admin", "kind": "api"},
|
|
1231
|
+
{"name": "card", "kind": "api"},
|
|
1232
|
+
{"name": "name", "kind": "api"},
|
|
1233
|
+
{"name": "leave", "kind": "api"},
|
|
1234
|
+
{"name": "info", "kind": "api"},
|
|
1235
|
+
{"name": "members", "kind": "api"},
|
|
1236
|
+
{"name": "essence_list", "kind": "api"},
|
|
1237
|
+
{"name": "poke", "kind": "api"},
|
|
1238
|
+
{"name": "honor", "kind": "api"},
|
|
1239
|
+
{"name": "announce", "kind": "api"},
|
|
1240
|
+
{"name": "send", "kind": "dir"},
|
|
1241
|
+
{"name": "kick.schema", "kind": "blob"},
|
|
1242
|
+
{"name": "ban.schema", "kind": "blob"},
|
|
1243
|
+
{"name": "admin.schema", "kind": "blob"},
|
|
1244
|
+
{"name": "card.schema", "kind": "blob"},
|
|
1245
|
+
{"name": "name.schema", "kind": "blob"},
|
|
1246
|
+
{"name": "leave.schema", "kind": "blob"},
|
|
1247
|
+
{"name": "poke.schema", "kind": "blob"},
|
|
1248
|
+
{"name": "announce.schema", "kind": "blob"},
|
|
1249
|
+
])
|
|
1250
|
+
elif user_id:
|
|
1251
|
+
entries.extend([
|
|
1252
|
+
{"name": "info", "kind": "api"},
|
|
1253
|
+
{"name": "remark", "kind": "api"},
|
|
1254
|
+
{"name": "send", "kind": "dir"},
|
|
1255
|
+
{"name": "remark.schema", "kind": "blob"},
|
|
1256
|
+
])
|
|
1257
|
+
return {"entries": entries}
|
|
1258
|
+
if action == "list_messages":
|
|
1259
|
+
group_id = str(params.get("group_id", ""))
|
|
1260
|
+
user_id = str(params.get("user_id", ""))
|
|
1261
|
+
time_range = params.get("time_range", "recent")
|
|
1262
|
+
now = time.time()
|
|
1263
|
+
cutoff = now
|
|
1264
|
+
if time_range == "recent":
|
|
1265
|
+
cutoff = now - 3600
|
|
1266
|
+
elif time_range == "1days":
|
|
1267
|
+
cutoff = now - 86400
|
|
1268
|
+
elif time_range == "7days":
|
|
1269
|
+
cutoff = now - 7 * 86400
|
|
1270
|
+
elif time_range == "30days":
|
|
1271
|
+
cutoff = now - 30 * 86400
|
|
1272
|
+
elif time_range == "90days":
|
|
1273
|
+
cutoff = now - 90 * 86400
|
|
1274
|
+
|
|
1275
|
+
query = "SELECT message_id, timestamp, post_type, event_type, raw_json FROM events WHERE post_type='message' AND timestamp >= ?"
|
|
1276
|
+
qparams = [cutoff]
|
|
1277
|
+
if group_id:
|
|
1278
|
+
query += " AND group_id = ?"
|
|
1279
|
+
qparams.append(int(group_id))
|
|
1280
|
+
if user_id:
|
|
1281
|
+
query += " AND user_id = ?"
|
|
1282
|
+
qparams.append(int(user_id))
|
|
1283
|
+
query += " ORDER BY timestamp DESC LIMIT 500"
|
|
1284
|
+
rows = db.execute(query, qparams).fetchall()
|
|
1285
|
+
messages = []
|
|
1286
|
+
for r in rows:
|
|
1287
|
+
mid = str(r["message_id"]) if r["message_id"] else ""
|
|
1288
|
+
if mid:
|
|
1289
|
+
messages.append({"name": mid, "kind": "dynamic_dir", "time": r["timestamp"]})
|
|
1290
|
+
return {"entries": messages}
|
|
1291
|
+
|
|
1292
|
+
if action == "get_message":
|
|
1293
|
+
group_id = str(params.get("group_id", ""))
|
|
1294
|
+
user_id = str(params.get("user_id", ""))
|
|
1295
|
+
message_id = str(params.get("message_id", ""))
|
|
1296
|
+
query = "SELECT raw_json FROM events WHERE post_type='message' AND message_id = ?"
|
|
1297
|
+
qparams = [message_id]
|
|
1298
|
+
if group_id:
|
|
1299
|
+
query += " AND group_id = ?"
|
|
1300
|
+
qparams.append(int(group_id))
|
|
1301
|
+
if user_id:
|
|
1302
|
+
query += " AND user_id = ?"
|
|
1303
|
+
qparams.append(int(user_id))
|
|
1304
|
+
row = db.execute(query, qparams).fetchone()
|
|
1305
|
+
if row:
|
|
1306
|
+
import json as json_mod
|
|
1307
|
+
from napcat_cli.lib.message import format_message, extract_file_paths
|
|
1308
|
+
event = json_mod.loads(row["raw_json"])
|
|
1309
|
+
msg = event.get("message", [])
|
|
1310
|
+
event["formatted_text"] = format_message(msg)
|
|
1311
|
+
event["files"] = extract_file_paths(msg)
|
|
1312
|
+
return event
|
|
1313
|
+
scope = f"group {group_id}" if group_id else f"friend {user_id}"
|
|
1314
|
+
return {"error": f"Message {message_id} not found in {scope}"}
|
|
1315
|
+
|
|
1316
|
+
if action == "get_stats":
|
|
1317
|
+
event_count = get_event_count(db)
|
|
1318
|
+
from napcat_cli.lib.events_sqlite import get_alert_count
|
|
1319
|
+
alert_count = get_alert_count(db)
|
|
1320
|
+
return {"event_count": event_count, "alert_count": alert_count}
|
|
1321
|
+
|
|
1322
|
+
if action == "send_group_message":
|
|
1323
|
+
group_id = str(params.get("group_id", ""))
|
|
1324
|
+
message = params.get("message", "")
|
|
1325
|
+
if not group_id or not message:
|
|
1326
|
+
return {"error": "group_id and message are required", "expected_schema": ACTION_SCHEMAS["send_group_message"]}
|
|
1327
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1328
|
+
api = NapCatAPI()
|
|
1329
|
+
return api.call("send_msg", message_type="group", group_id=int(group_id) if group_id.isdigit() else group_id, message=message)
|
|
1330
|
+
|
|
1331
|
+
if action == "send_private_message":
|
|
1332
|
+
user_id = str(params.get("user_id", ""))
|
|
1333
|
+
message = params.get("message", "")
|
|
1334
|
+
if not user_id or not message:
|
|
1335
|
+
return {"error": "user_id and message are required", "expected_schema": ACTION_SCHEMAS["send_private_message"]}
|
|
1336
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1337
|
+
api = NapCatAPI()
|
|
1338
|
+
return api.call("send_msg", message_type="private", user_id=int(user_id) if user_id.isdigit() else user_id, message=message)
|
|
1339
|
+
|
|
1340
|
+
# ---- send_group_* ----
|
|
1341
|
+
if action == "send_group_text":
|
|
1342
|
+
group_id = str(params.get("group_id", ""))
|
|
1343
|
+
payload = self._resolve_payload(params, "text")
|
|
1344
|
+
if not group_id or not payload:
|
|
1345
|
+
return {"error": "group_id and payload are required", "expected_schema": ACTION_SCHEMAS.get("send_group_text", {})}
|
|
1346
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1347
|
+
api = NapCatAPI()
|
|
1348
|
+
msg = self._compose_message("text", payload)
|
|
1349
|
+
r = api.call("send_msg", message_type="group", group_id=int(group_id) if group_id.isdigit() else group_id, message=msg)
|
|
1350
|
+
return self._format_send_result(r)
|
|
1351
|
+
|
|
1352
|
+
if action == "send_group_image":
|
|
1353
|
+
group_id = str(params.get("group_id", ""))
|
|
1354
|
+
payload = self._resolve_payload(params, "path")
|
|
1355
|
+
if not group_id or not payload:
|
|
1356
|
+
return {"error": "group_id and payload (file path) are required", "expected_schema": ACTION_SCHEMAS.get("send_group_image", {})}
|
|
1357
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1358
|
+
api = NapCatAPI()
|
|
1359
|
+
try:
|
|
1360
|
+
msg = self._compose_message("image", payload)
|
|
1361
|
+
except ValueError as e:
|
|
1362
|
+
return {"error": str(e)}
|
|
1363
|
+
r = api.call("send_msg", message_type="group", group_id=int(group_id) if group_id.isdigit() else group_id, message=msg)
|
|
1364
|
+
return self._format_send_result(r)
|
|
1365
|
+
|
|
1366
|
+
if action == "send_group_file":
|
|
1367
|
+
group_id = str(params.get("group_id", ""))
|
|
1368
|
+
payload = self._resolve_payload(params, "path")
|
|
1369
|
+
if not group_id or not payload:
|
|
1370
|
+
return {"error": "group_id and payload (file path) are required", "expected_schema": ACTION_SCHEMAS.get("send_group_file", {})}
|
|
1371
|
+
try:
|
|
1372
|
+
return self._upload_file("group", group_id, payload)
|
|
1373
|
+
except ValueError as e:
|
|
1374
|
+
return {"error": str(e)}
|
|
1375
|
+
|
|
1376
|
+
if action == "send_group_cqcode":
|
|
1377
|
+
group_id = str(params.get("group_id", ""))
|
|
1378
|
+
payload = self._resolve_payload(params, "cqcode")
|
|
1379
|
+
if not group_id or not payload:
|
|
1380
|
+
return {"error": "group_id and payload (CQ code) are required", "expected_schema": ACTION_SCHEMAS.get("send_group_cqcode", {})}
|
|
1381
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1382
|
+
api = NapCatAPI()
|
|
1383
|
+
msg = self._compose_message("cqcode", payload)
|
|
1384
|
+
r = api.call("send_msg", message_type="group", group_id=int(group_id) if group_id.isdigit() else group_id, message=msg)
|
|
1385
|
+
return self._format_send_result(r)
|
|
1386
|
+
|
|
1387
|
+
if action == "send_group_at":
|
|
1388
|
+
group_id = str(params.get("group_id", ""))
|
|
1389
|
+
qq = str(params.get("qq", ""))
|
|
1390
|
+
text = str(params.get("text", ""))
|
|
1391
|
+
if not group_id or not qq:
|
|
1392
|
+
return {"error": "group_id and qq are required", "expected_schema": ACTION_SCHEMAS.get("send_group_at", {})}
|
|
1393
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1394
|
+
api = NapCatAPI()
|
|
1395
|
+
msg = self._compose_message("at", None, qq=qq, text=text)
|
|
1396
|
+
r = api.call("send_msg", message_type="group", group_id=int(group_id) if group_id.isdigit() else group_id, message=msg)
|
|
1397
|
+
return self._format_send_result(r)
|
|
1398
|
+
|
|
1399
|
+
if action == "send_group_json":
|
|
1400
|
+
group_id = str(params.get("group_id", ""))
|
|
1401
|
+
message = params.get("message", "")
|
|
1402
|
+
if not group_id or not message:
|
|
1403
|
+
return {"error": "group_id and message are required", "expected_schema": ACTION_SCHEMAS.get("send_group_json", {})}
|
|
1404
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1405
|
+
api = NapCatAPI()
|
|
1406
|
+
r = api.call("send_msg", message_type="group", group_id=int(group_id) if group_id.isdigit() else group_id, message=message)
|
|
1407
|
+
return self._format_send_result(r)
|
|
1408
|
+
|
|
1409
|
+
# ---- send_private_* ----
|
|
1410
|
+
if action == "send_private_text":
|
|
1411
|
+
user_id = str(params.get("user_id", ""))
|
|
1412
|
+
payload = self._resolve_payload(params, "text")
|
|
1413
|
+
if not user_id or not payload:
|
|
1414
|
+
return {"error": "user_id and payload are required", "expected_schema": ACTION_SCHEMAS.get("send_private_text", {})}
|
|
1415
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1416
|
+
api = NapCatAPI()
|
|
1417
|
+
msg = self._compose_message("text", payload)
|
|
1418
|
+
r = api.call("send_msg", message_type="private", user_id=int(user_id) if user_id.isdigit() else user_id, message=msg)
|
|
1419
|
+
return self._format_send_result(r)
|
|
1420
|
+
|
|
1421
|
+
if action == "send_private_image":
|
|
1422
|
+
user_id = str(params.get("user_id", ""))
|
|
1423
|
+
payload = self._resolve_payload(params, "path")
|
|
1424
|
+
if not user_id or not payload:
|
|
1425
|
+
return {"error": "user_id and payload (file path) are required", "expected_schema": ACTION_SCHEMAS.get("send_private_image", {})}
|
|
1426
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1427
|
+
api = NapCatAPI()
|
|
1428
|
+
try:
|
|
1429
|
+
msg = self._compose_message("image", payload)
|
|
1430
|
+
except ValueError as e:
|
|
1431
|
+
return {"error": str(e)}
|
|
1432
|
+
r = api.call("send_msg", message_type="private", user_id=int(user_id) if user_id.isdigit() else user_id, message=msg)
|
|
1433
|
+
return self._format_send_result(r)
|
|
1434
|
+
|
|
1435
|
+
if action == "send_private_file":
|
|
1436
|
+
user_id = str(params.get("user_id", ""))
|
|
1437
|
+
payload = self._resolve_payload(params, "path")
|
|
1438
|
+
if not user_id or not payload:
|
|
1439
|
+
return {"error": "user_id and payload (file path) are required", "expected_schema": ACTION_SCHEMAS.get("send_private_file", {})}
|
|
1440
|
+
try:
|
|
1441
|
+
return self._upload_file("private", user_id, payload)
|
|
1442
|
+
except ValueError as e:
|
|
1443
|
+
return {"error": str(e)}
|
|
1444
|
+
|
|
1445
|
+
if action == "send_private_cqcode":
|
|
1446
|
+
user_id = str(params.get("user_id", ""))
|
|
1447
|
+
payload = self._resolve_payload(params, "cqcode")
|
|
1448
|
+
if not user_id or not payload:
|
|
1449
|
+
return {"error": "user_id and payload (CQ code) are required", "expected_schema": ACTION_SCHEMAS.get("send_private_cqcode", {})}
|
|
1450
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1451
|
+
api = NapCatAPI()
|
|
1452
|
+
msg = self._compose_message("cqcode", payload)
|
|
1453
|
+
r = api.call("send_msg", message_type="private", user_id=int(user_id) if user_id.isdigit() else user_id, message=msg)
|
|
1454
|
+
return self._format_send_result(r)
|
|
1455
|
+
|
|
1456
|
+
if action == "send_private_at":
|
|
1457
|
+
user_id = str(params.get("user_id", ""))
|
|
1458
|
+
qq = str(params.get("qq", ""))
|
|
1459
|
+
text = str(params.get("text", ""))
|
|
1460
|
+
if not user_id or not qq:
|
|
1461
|
+
return {"error": "user_id and qq are required", "expected_schema": ACTION_SCHEMAS.get("send_private_at", {})}
|
|
1462
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1463
|
+
api = NapCatAPI()
|
|
1464
|
+
msg = self._compose_message("at", None, qq=qq, text=text)
|
|
1465
|
+
r = api.call("send_msg", message_type="private", user_id=int(user_id) if user_id.isdigit() else user_id, message=msg)
|
|
1466
|
+
return self._format_send_result(r)
|
|
1467
|
+
|
|
1468
|
+
if action == "send_private_json":
|
|
1469
|
+
user_id = str(params.get("user_id", ""))
|
|
1470
|
+
message = params.get("message", "")
|
|
1471
|
+
if not user_id or not message:
|
|
1472
|
+
return {"error": "user_id and message are required", "expected_schema": ACTION_SCHEMAS.get("send_private_json", {})}
|
|
1473
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1474
|
+
api = NapCatAPI()
|
|
1475
|
+
r = api.call("send_msg", message_type="private", user_id=int(user_id) if user_id.isdigit() else user_id, message=message)
|
|
1476
|
+
return self._format_send_result(r)
|
|
1477
|
+
|
|
1478
|
+
# ---- reply_group_* ----
|
|
1479
|
+
if action == "reply_group_text":
|
|
1480
|
+
group_id = str(params.get("group_id", ""))
|
|
1481
|
+
message_id = str(params.get("message_id", ""))
|
|
1482
|
+
payload = self._resolve_payload(params, "text")
|
|
1483
|
+
if not group_id or not message_id or not payload:
|
|
1484
|
+
return {"error": "group_id, message_id, and payload are required", "expected_schema": ACTION_SCHEMAS.get("reply_group_text", {})}
|
|
1485
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1486
|
+
api = NapCatAPI()
|
|
1487
|
+
base = self._compose_message("text", payload)
|
|
1488
|
+
msg = self._compose_reply(base, message_id)
|
|
1489
|
+
r = api.call("send_msg", message_type="group", group_id=int(group_id) if group_id.isdigit() else group_id, message=msg)
|
|
1490
|
+
return self._format_send_result(r)
|
|
1491
|
+
|
|
1492
|
+
if action == "reply_group_image":
|
|
1493
|
+
group_id = str(params.get("group_id", ""))
|
|
1494
|
+
message_id = str(params.get("message_id", ""))
|
|
1495
|
+
payload = self._resolve_payload(params, "path")
|
|
1496
|
+
if not group_id or not message_id or not payload:
|
|
1497
|
+
return {"error": "group_id, message_id, and payload are required", "expected_schema": ACTION_SCHEMAS.get("reply_group_image", {})}
|
|
1498
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1499
|
+
api = NapCatAPI()
|
|
1500
|
+
try:
|
|
1501
|
+
base = self._compose_message("image", payload)
|
|
1502
|
+
except ValueError as e:
|
|
1503
|
+
return {"error": str(e)}
|
|
1504
|
+
msg = self._compose_reply(base, message_id)
|
|
1505
|
+
r = api.call("send_msg", message_type="group", group_id=int(group_id) if group_id.isdigit() else group_id, message=msg)
|
|
1506
|
+
return self._format_send_result(r)
|
|
1507
|
+
|
|
1508
|
+
if action == "reply_group_file":
|
|
1509
|
+
group_id = str(params.get("group_id", ""))
|
|
1510
|
+
payload = self._resolve_payload(params, "path")
|
|
1511
|
+
if not group_id or not payload:
|
|
1512
|
+
return {"error": "group_id and payload (file path) are required", "expected_schema": ACTION_SCHEMAS.get("reply_group_file", {})}
|
|
1513
|
+
try:
|
|
1514
|
+
return self._upload_file("group", group_id, payload)
|
|
1515
|
+
except ValueError as e:
|
|
1516
|
+
return {"error": str(e)}
|
|
1517
|
+
|
|
1518
|
+
if action == "reply_group_cqcode":
|
|
1519
|
+
group_id = str(params.get("group_id", ""))
|
|
1520
|
+
message_id = str(params.get("message_id", ""))
|
|
1521
|
+
payload = self._resolve_payload(params, "cqcode")
|
|
1522
|
+
if not group_id or not message_id or not payload:
|
|
1523
|
+
return {"error": "group_id, message_id, and payload are required", "expected_schema": ACTION_SCHEMAS.get("reply_group_cqcode", {})}
|
|
1524
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1525
|
+
api = NapCatAPI()
|
|
1526
|
+
cq_str = self._compose_message("cqcode", payload)
|
|
1527
|
+
msg = [{"type": "reply", "data": {"id": str(message_id)}}, {"type": "text", "data": {"text": cq_str}}]
|
|
1528
|
+
r = api.call("send_msg", message_type="group", group_id=int(group_id) if group_id.isdigit() else group_id, message=msg)
|
|
1529
|
+
return self._format_send_result(r)
|
|
1530
|
+
|
|
1531
|
+
if action == "reply_group_at":
|
|
1532
|
+
group_id = str(params.get("group_id", ""))
|
|
1533
|
+
message_id = str(params.get("message_id", ""))
|
|
1534
|
+
qq = str(params.get("qq", ""))
|
|
1535
|
+
text = str(params.get("text", ""))
|
|
1536
|
+
if not group_id or not message_id or not qq:
|
|
1537
|
+
return {"error": "group_id, message_id, and qq are required", "expected_schema": ACTION_SCHEMAS.get("reply_group_at", {})}
|
|
1538
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1539
|
+
api = NapCatAPI()
|
|
1540
|
+
base = self._compose_message("at", None, qq=qq, text=text)
|
|
1541
|
+
msg = self._compose_reply(base, message_id)
|
|
1542
|
+
r = api.call("send_msg", message_type="group", group_id=int(group_id) if group_id.isdigit() else group_id, message=msg)
|
|
1543
|
+
return self._format_send_result(r)
|
|
1544
|
+
|
|
1545
|
+
if action == "reply_group_json":
|
|
1546
|
+
group_id = str(params.get("group_id", ""))
|
|
1547
|
+
message_id = str(params.get("message_id", ""))
|
|
1548
|
+
message = params.get("message", "")
|
|
1549
|
+
if not group_id or not message_id or not message:
|
|
1550
|
+
return {"error": "group_id, message_id, and message are required", "expected_schema": ACTION_SCHEMAS.get("reply_group_json", {})}
|
|
1551
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1552
|
+
api = NapCatAPI()
|
|
1553
|
+
msg = [{"type": "reply", "data": {"id": str(message_id)}}] + message
|
|
1554
|
+
r = api.call("send_msg", message_type="group", group_id=int(group_id) if group_id.isdigit() else group_id, message=msg)
|
|
1555
|
+
return self._format_send_result(r)
|
|
1556
|
+
|
|
1557
|
+
# ---- reply_private_* ----
|
|
1558
|
+
if action == "reply_private_text":
|
|
1559
|
+
user_id = str(params.get("user_id", ""))
|
|
1560
|
+
message_id = str(params.get("message_id", ""))
|
|
1561
|
+
payload = self._resolve_payload(params, "text")
|
|
1562
|
+
if not user_id or not message_id or not payload:
|
|
1563
|
+
return {"error": "user_id, message_id, and payload are required", "expected_schema": ACTION_SCHEMAS.get("reply_private_text", {})}
|
|
1564
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1565
|
+
api = NapCatAPI()
|
|
1566
|
+
base = self._compose_message("text", payload)
|
|
1567
|
+
msg = self._compose_reply(base, message_id)
|
|
1568
|
+
r = api.call("send_msg", message_type="private", user_id=int(user_id) if user_id.isdigit() else user_id, message=msg)
|
|
1569
|
+
return self._format_send_result(r)
|
|
1570
|
+
|
|
1571
|
+
if action == "reply_private_image":
|
|
1572
|
+
user_id = str(params.get("user_id", ""))
|
|
1573
|
+
message_id = str(params.get("message_id", ""))
|
|
1574
|
+
payload = self._resolve_payload(params, "path")
|
|
1575
|
+
if not user_id or not message_id or not payload:
|
|
1576
|
+
return {"error": "user_id, message_id, and payload are required", "expected_schema": ACTION_SCHEMAS.get("reply_private_image", {})}
|
|
1577
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1578
|
+
api = NapCatAPI()
|
|
1579
|
+
try:
|
|
1580
|
+
base = self._compose_message("image", payload)
|
|
1581
|
+
except ValueError as e:
|
|
1582
|
+
return {"error": str(e)}
|
|
1583
|
+
msg = self._compose_reply(base, message_id)
|
|
1584
|
+
r = api.call("send_msg", message_type="private", user_id=int(user_id) if user_id.isdigit() else user_id, message=msg)
|
|
1585
|
+
return self._format_send_result(r)
|
|
1586
|
+
|
|
1587
|
+
if action == "reply_private_file":
|
|
1588
|
+
user_id = str(params.get("user_id", ""))
|
|
1589
|
+
payload = self._resolve_payload(params, "path")
|
|
1590
|
+
if not user_id or not payload:
|
|
1591
|
+
return {"error": "user_id and payload (file path) are required", "expected_schema": ACTION_SCHEMAS.get("reply_private_file", {})}
|
|
1592
|
+
try:
|
|
1593
|
+
return self._upload_file("private", user_id, payload)
|
|
1594
|
+
except ValueError as e:
|
|
1595
|
+
return {"error": str(e)}
|
|
1596
|
+
|
|
1597
|
+
if action == "reply_private_cqcode":
|
|
1598
|
+
user_id = str(params.get("user_id", ""))
|
|
1599
|
+
message_id = str(params.get("message_id", ""))
|
|
1600
|
+
payload = self._resolve_payload(params, "cqcode")
|
|
1601
|
+
if not user_id or not message_id or not payload:
|
|
1602
|
+
return {"error": "user_id, message_id, and payload are required", "expected_schema": ACTION_SCHEMAS.get("reply_private_cqcode", {})}
|
|
1603
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1604
|
+
api = NapCatAPI()
|
|
1605
|
+
cq_str = self._compose_message("cqcode", payload)
|
|
1606
|
+
msg = [{"type": "reply", "data": {"id": str(message_id)}}, {"type": "text", "data": {"text": cq_str}}]
|
|
1607
|
+
r = api.call("send_msg", message_type="private", user_id=int(user_id) if user_id.isdigit() else user_id, message=msg)
|
|
1608
|
+
return self._format_send_result(r)
|
|
1609
|
+
|
|
1610
|
+
if action == "reply_private_at":
|
|
1611
|
+
user_id = str(params.get("user_id", ""))
|
|
1612
|
+
message_id = str(params.get("message_id", ""))
|
|
1613
|
+
qq = str(params.get("qq", ""))
|
|
1614
|
+
text = str(params.get("text", ""))
|
|
1615
|
+
if not user_id or not message_id or not qq:
|
|
1616
|
+
return {"error": "user_id, message_id, and qq are required", "expected_schema": ACTION_SCHEMAS.get("reply_private_at", {})}
|
|
1617
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1618
|
+
api = NapCatAPI()
|
|
1619
|
+
base = self._compose_message("at", None, qq=qq, text=text)
|
|
1620
|
+
msg = self._compose_reply(base, message_id)
|
|
1621
|
+
r = api.call("send_msg", message_type="private", user_id=int(user_id) if user_id.isdigit() else user_id, message=msg)
|
|
1622
|
+
return self._format_send_result(r)
|
|
1623
|
+
|
|
1624
|
+
if action == "reply_private_json":
|
|
1625
|
+
user_id = str(params.get("user_id", ""))
|
|
1626
|
+
message_id = str(params.get("message_id", ""))
|
|
1627
|
+
message = params.get("message", "")
|
|
1628
|
+
if not user_id or not message_id or not message:
|
|
1629
|
+
return {"error": "user_id, message_id, and message are required", "expected_schema": ACTION_SCHEMAS.get("reply_private_json", {})}
|
|
1630
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1631
|
+
api = NapCatAPI()
|
|
1632
|
+
msg = [{"type": "reply", "data": {"id": str(message_id)}}] + message
|
|
1633
|
+
r = api.call("send_msg", message_type="private", user_id=int(user_id) if user_id.isdigit() else user_id, message=msg)
|
|
1634
|
+
return self._format_send_result(r)
|
|
1635
|
+
|
|
1636
|
+
if action == "list_message_content":
|
|
1637
|
+
message_id = str(params.get("message_id", ""))
|
|
1638
|
+
group_id = str(params.get("group_id", ""))
|
|
1639
|
+
user_id = str(params.get("user_id", ""))
|
|
1640
|
+
query = "SELECT raw_json FROM events WHERE post_type='message' AND message_id = ?"
|
|
1641
|
+
qparams = [message_id]
|
|
1642
|
+
if group_id:
|
|
1643
|
+
query += " AND group_id = ?"
|
|
1644
|
+
qparams.append(int(group_id))
|
|
1645
|
+
if user_id:
|
|
1646
|
+
query += " AND user_id = ?"
|
|
1647
|
+
qparams.append(int(user_id))
|
|
1648
|
+
row = db.execute(query, qparams).fetchone()
|
|
1649
|
+
if not row:
|
|
1650
|
+
return {"error": f"Message {message_id} not found"}
|
|
1651
|
+
event = json.loads(row["raw_json"])
|
|
1652
|
+
segments = event.get("message", [])
|
|
1653
|
+
entries = [{"name": "metadata", "kind": "api"}, {"name": "text", "kind": "api"}, {"name": "reply", "kind": "dir"}]
|
|
1654
|
+
image_count = 0
|
|
1655
|
+
for seg in segments:
|
|
1656
|
+
stype = seg.get("type", "")
|
|
1657
|
+
if stype == "image":
|
|
1658
|
+
image_count += 1
|
|
1659
|
+
entries.append({"name": f"image" if image_count == 1 else f"image_{image_count}", "kind": "api"})
|
|
1660
|
+
elif stype == "file":
|
|
1661
|
+
file_count += 1
|
|
1662
|
+
entries.append({"name": f"file" if file_count == 1 else f"file_{file_count}", "kind": "api"})
|
|
1663
|
+
elif stype == "video":
|
|
1664
|
+
entries.append({"name": "video", "kind": "api"})
|
|
1665
|
+
elif stype == "record":
|
|
1666
|
+
entries.append({"name": "record", "kind": "api"})
|
|
1667
|
+
elif stype == "forward":
|
|
1668
|
+
entries.append({"name": "forward", "kind": "api"})
|
|
1669
|
+
return {"entries": entries}
|
|
1670
|
+
|
|
1671
|
+
if action == "get_message_content":
|
|
1672
|
+
message_id = str(params.get("message_id", ""))
|
|
1673
|
+
content = str(params.get("content", ""))
|
|
1674
|
+
group_id = str(params.get("group_id", ""))
|
|
1675
|
+
user_id = str(params.get("user_id", ""))
|
|
1676
|
+
query = "SELECT raw_json FROM events WHERE post_type='message' AND message_id = ?"
|
|
1677
|
+
qparams = [message_id]
|
|
1678
|
+
if group_id:
|
|
1679
|
+
query += " AND group_id = ?"
|
|
1680
|
+
qparams.append(int(group_id))
|
|
1681
|
+
if user_id:
|
|
1682
|
+
query += " AND user_id = ?"
|
|
1683
|
+
qparams.append(int(user_id))
|
|
1684
|
+
row = db.execute(query, qparams).fetchone()
|
|
1685
|
+
if not row:
|
|
1686
|
+
return {"error": f"Message {message_id} not found"}
|
|
1687
|
+
event = json.loads(row["raw_json"])
|
|
1688
|
+
segments = event.get("message", [])
|
|
1689
|
+
if not content:
|
|
1690
|
+
# No selector: return all available content
|
|
1691
|
+
result = {
|
|
1692
|
+
"metadata": {
|
|
1693
|
+
"message_id": event.get("message_id"),
|
|
1694
|
+
"sender": event.get("sender", {}).get("nickname", ""),
|
|
1695
|
+
"time": event.get("time"),
|
|
1696
|
+
"group_id": event.get("group_id"),
|
|
1697
|
+
"user_id": event.get("user_id"),
|
|
1698
|
+
"message_type": event.get("message_type"),
|
|
1699
|
+
},
|
|
1700
|
+
}
|
|
1701
|
+
texts = [seg.get("data", {}).get("text", "") for seg in segments if seg.get("type") == "text"]
|
|
1702
|
+
if texts:
|
|
1703
|
+
result["text"] = "\n".join(texts)
|
|
1704
|
+
for seg in segments:
|
|
1705
|
+
stype = seg.get("type", "")
|
|
1706
|
+
if stype == "image":
|
|
1707
|
+
result.setdefault("image", []).append(seg.get("data", {}))
|
|
1708
|
+
elif stype == "video":
|
|
1709
|
+
result.setdefault("video", []).append(seg.get("data", {}))
|
|
1710
|
+
elif stype == "record":
|
|
1711
|
+
result.setdefault("record", []).append(seg.get("data", {}))
|
|
1712
|
+
elif stype == "file":
|
|
1713
|
+
result.setdefault("file", []).append(seg.get("data", {}))
|
|
1714
|
+
elif stype == "forward":
|
|
1715
|
+
result.setdefault("forward", []).append(seg.get("data", {}))
|
|
1716
|
+
return result
|
|
1717
|
+
|
|
1718
|
+
if content == "metadata":
|
|
1719
|
+
return {
|
|
1720
|
+
"message_id": event.get("message_id"),
|
|
1721
|
+
"sender": event.get("sender", {}).get("nickname", ""),
|
|
1722
|
+
"time": event.get("time"),
|
|
1723
|
+
"group_id": event.get("group_id"),
|
|
1724
|
+
"user_id": event.get("user_id"),
|
|
1725
|
+
"message_type": event.get("message_type"),
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
if content == "text":
|
|
1729
|
+
texts = [seg.get("data", {}).get("text", "") for seg in segments if seg.get("type") == "text"]
|
|
1730
|
+
return {"text": "\n".join(texts)}
|
|
1731
|
+
|
|
1732
|
+
# Parse image/file selectors like "image", "image_2", "file_3"
|
|
1733
|
+
if content.startswith("image") or content.startswith("file"):
|
|
1734
|
+
base = content.rsplit("_", 1)[0] if "_" in content else content
|
|
1735
|
+
if "_" in content:
|
|
1736
|
+
try:
|
|
1737
|
+
idx = int(content.rsplit("_", 1)[1])
|
|
1738
|
+
except ValueError:
|
|
1739
|
+
return {"error": f"Invalid content selector: {content}"}
|
|
1740
|
+
else:
|
|
1741
|
+
idx = 1
|
|
1742
|
+
collected = []
|
|
1743
|
+
for seg in segments:
|
|
1744
|
+
if seg.get("type") == base:
|
|
1745
|
+
collected.append(seg.get("data", {}))
|
|
1746
|
+
if idx <= len(collected):
|
|
1747
|
+
return collected[idx - 1]
|
|
1748
|
+
return {"error": f"Content {content} not found in message"}
|
|
1749
|
+
|
|
1750
|
+
if content == "video":
|
|
1751
|
+
for seg in segments:
|
|
1752
|
+
if seg.get("type") == "video":
|
|
1753
|
+
return seg.get("data", {})
|
|
1754
|
+
return {"error": "No video in message"}
|
|
1755
|
+
|
|
1756
|
+
if content == "record":
|
|
1757
|
+
for seg in segments:
|
|
1758
|
+
if seg.get("type") == "record":
|
|
1759
|
+
return seg.get("data", {})
|
|
1760
|
+
return {"error": "No record in message"}
|
|
1761
|
+
|
|
1762
|
+
if content == "forward":
|
|
1763
|
+
for seg in segments:
|
|
1764
|
+
if seg.get("type") == "forward":
|
|
1765
|
+
return seg.get("data", {})
|
|
1766
|
+
return {"error": "No forward in message"}
|
|
1767
|
+
|
|
1768
|
+
return {"error": f"Unknown content selector: {content}"}
|
|
1769
|
+
|
|
1770
|
+
if action == "describe_action":
|
|
1771
|
+
action_name = params.get("action", "")
|
|
1772
|
+
schema = ACTION_SCHEMAS.get(action_name)
|
|
1773
|
+
if schema:
|
|
1774
|
+
return schema
|
|
1775
|
+
return {"error": f"Unknown action: {action_name}"}
|
|
1776
|
+
|
|
1777
|
+
# ---- napcat_delete_msg with recall rule hints ----
|
|
1778
|
+
if action == "napcat_delete_msg":
|
|
1779
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1780
|
+
api = NapCatAPI()
|
|
1781
|
+
r = api.call("delete_msg", **params)
|
|
1782
|
+
if isinstance(r, dict) and "error" in r:
|
|
1783
|
+
r["hint"] = "Recall rules: bot recalling its own message must act within 2 minutes. Admin can recall any member message. Group owner can recall any message. No time limit for admin/owner recalling others' messages."
|
|
1784
|
+
return r
|
|
1785
|
+
# Proxy NapCat API calls through napcat_ prefix
|
|
1786
|
+
if action.startswith("napcat_"):
|
|
1787
|
+
from napcat_cli.lib.api import NapCatAPI
|
|
1788
|
+
api = NapCatAPI()
|
|
1789
|
+
napcat_action = action.replace("napcat_", "", 1)
|
|
1790
|
+
return api.call(napcat_action, **params)
|
|
1791
|
+
|
|
1792
|
+
return {"error": f"Unknown action: {action}"}
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
|
|
1796
|
+
def run_http_server(
|
|
1797
|
+
processor: EventProcessor,
|
|
1798
|
+
cache: EventCache,
|
|
1799
|
+
port: int = 18820,
|
|
1800
|
+
host: str = "0.0.0.0",
|
|
1801
|
+
skillsfs_manager: SkillsFsManager | None = None,
|
|
1802
|
+
) -> HTTPServer:
|
|
1803
|
+
"""Start HTTP provider in a background thread. Returns the server."""
|
|
1804
|
+
NapCatHandler.processor = processor
|
|
1805
|
+
NapCatHandler.cache = cache
|
|
1806
|
+
NapCatHandler.events_reader = EventsReader(cache.data_dir)
|
|
1807
|
+
NapCatHandler.skillsfs_manager = skillsfs_manager
|
|
1808
|
+
|
|
1809
|
+
server = HTTPServer((host, port), NapCatHandler)
|
|
1810
|
+
t = threading.Thread(target=server.serve_forever, daemon=True)
|
|
1811
|
+
t.start()
|
|
1812
|
+
processor.log(f"HTTP provider listening on {host}:{port}")
|
|
1813
|
+
return server
|
|
1814
|
+
|
|
1815
|
+
|
|
1816
|
+
# ---------------------------------------------------------------------------
|
|
1817
|
+
# Main
|
|
1818
|
+
# ---------------------------------------------------------------------------
|
|
1819
|
+
|
|
1820
|
+
async def health_check_task(processor: EventProcessor, api_url: str, token: str, interval: int = 60, cooldown: int = 300) -> None:
|
|
1821
|
+
"""Periodically check if NapCat bot is online via HTTP API.
|
|
1822
|
+
|
|
1823
|
+
Runs as a separate task alongside ws_daemon so a hung WS connection
|
|
1824
|
+
doesn't block health checks. Uses HTTP API (not WS) to detect
|
|
1825
|
+
'WS connected but bot offline' cases.
|
|
1826
|
+
|
|
1827
|
+
Args:
|
|
1828
|
+
processor: EventProcessor for logging and wake commands.
|
|
1829
|
+
api_url: NapCat HTTP API URL.
|
|
1830
|
+
token: API authentication token.
|
|
1831
|
+
interval: Check interval in seconds (default: 60).
|
|
1832
|
+
cooldown: Minimum seconds between wake attempts (default: 300).
|
|
1833
|
+
"""
|
|
1834
|
+
import urllib.request
|
|
1835
|
+
last_wake = 0.0
|
|
1836
|
+
loop = asyncio.get_running_loop()
|
|
1837
|
+
|
|
1838
|
+
while True:
|
|
1839
|
+
try:
|
|
1840
|
+
def probe():
|
|
1841
|
+
url = f"{api_url}/get_status"
|
|
1842
|
+
req = urllib.request.Request(url, method="POST")
|
|
1843
|
+
req.add_header("Content-Type", "application/json")
|
|
1844
|
+
if token:
|
|
1845
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
1846
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
1847
|
+
return json.loads(resp.read())
|
|
1848
|
+
|
|
1849
|
+
data = await loop.run_in_executor(None, probe)
|
|
1850
|
+
online = bool(data.get("data", {}).get("online", False))
|
|
1851
|
+
|
|
1852
|
+
if not online:
|
|
1853
|
+
now = time.time()
|
|
1854
|
+
if now - last_wake > cooldown:
|
|
1855
|
+
processor.log(f"Health check: bot is offline. Attempting wake...")
|
|
1856
|
+
try:
|
|
1857
|
+
processor._wake("HEALTH_CHECK_OFFLINE")
|
|
1858
|
+
last_wake = now
|
|
1859
|
+
processor.log("Wake command sent.")
|
|
1860
|
+
except Exception as e:
|
|
1861
|
+
processor.log(f"Wake command failed: {e}")
|
|
1862
|
+
else:
|
|
1863
|
+
processor.log(f"Health check: bot is offline (cooldown: {int(cooldown - (now - last_wake))}s remaining)")
|
|
1864
|
+
else:
|
|
1865
|
+
processor.log("Health check: bot is online.")
|
|
1866
|
+
except Exception as e:
|
|
1867
|
+
processor.log(f"Health check failed: {e}")
|
|
1868
|
+
|
|
1869
|
+
await asyncio.sleep(interval)
|
|
1870
|
+
|
|
1871
|
+
|
|
1872
|
+
async def backlog_sweep_task(processor: "EventProcessor", interval: int = 45) -> None:
|
|
1873
|
+
"""Periodically nudge the agent to read unread messages that piled up.
|
|
1874
|
+
|
|
1875
|
+
NEW_MESSAGE events are tracked (not woken) by the orchestrator; if they sit
|
|
1876
|
+
unread longer than ``new_message_idle_seconds`` this fires a single
|
|
1877
|
+
``NEW_MESSAGE_BACKLOG`` wake so the agent scans the inbox.
|
|
1878
|
+
"""
|
|
1879
|
+
await asyncio.sleep(interval) # let the daemon warm up first
|
|
1880
|
+
while True:
|
|
1881
|
+
try:
|
|
1882
|
+
if processor.orchestrator is not None:
|
|
1883
|
+
processor.orchestrator.maybe_backlog_sweep()
|
|
1884
|
+
except Exception as e:
|
|
1885
|
+
processor.log(f"backlog sweep error: {e}")
|
|
1886
|
+
await asyncio.sleep(interval)
|
|
1887
|
+
|
|
1888
|
+
|
|
1889
|
+
def run_daemon(config_path: str) -> None:
|
|
1890
|
+
"""Start the WebSocket daemon with HTTP provider."""
|
|
1891
|
+
cfg_data = json.loads(Path(config_path).read_text())
|
|
1892
|
+
self_id = cfg_data.get("self_id", "")
|
|
1893
|
+
wake_command = cfg_data.get("wake_command", "")
|
|
1894
|
+
wake_on_event = bool(cfg_data.get("wake_enabled", cfg_data.get("wake_on_event", True)))
|
|
1895
|
+
group_trigger = cfg_data.get("group_trigger_word", "")
|
|
1896
|
+
private_trigger = cfg_data.get("private_trigger", "")
|
|
1897
|
+
|
|
1898
|
+
# Build the wake Waker (http->cli auto-fallback) from the wake_* config.
|
|
1899
|
+
from napcat_cli.lib.config import NapCatConfig
|
|
1900
|
+
wake_cfg = NapCatConfig()
|
|
1901
|
+
for _k in ("wake_preset", "wake_primary", "wake_session", "wake_http_url",
|
|
1902
|
+
"wake_http_key", "wake_http_session_id", "wake_cli_command"):
|
|
1903
|
+
if _k in cfg_data:
|
|
1904
|
+
setattr(wake_cfg, _k, cfg_data[_k])
|
|
1905
|
+
waker = build_waker(wake_cfg)
|
|
1906
|
+
debounce = float(cfg_data.get("wake_debounce_seconds", 3.0))
|
|
1907
|
+
cooldown = float(cfg_data.get("wake_cooldown_seconds", 30.0))
|
|
1908
|
+
nm_idle = int(cfg_data.get("wake_new_message_idle_seconds", 600))
|
|
1909
|
+
|
|
1910
|
+
processor = EventProcessor(DATA_DIR, self_id, wake_command, wake_on_event,
|
|
1911
|
+
group_trigger_word=group_trigger,
|
|
1912
|
+
private_trigger=private_trigger,
|
|
1913
|
+
waker=waker, debounce_seconds=debounce, cooldown_seconds=cooldown,
|
|
1914
|
+
new_message_idle_seconds=nm_idle,
|
|
1915
|
+
)
|
|
1916
|
+
cache = EventCache(DATA_DIR)
|
|
1917
|
+
|
|
1918
|
+
# PID file
|
|
1919
|
+
pid_file = DATA_DIR / "daemon.pid"
|
|
1920
|
+
pid_file.write_text(str(os.getpid()))
|
|
1921
|
+
|
|
1922
|
+
# Determine ports
|
|
1923
|
+
http_port = int(cfg_data.get("http_port", os.environ.get("NAPCAT_HTTP_PORT", "18821")))
|
|
1924
|
+
ws_port = int(cfg_data.get("ws_port", os.environ.get("NAPCAT_WS_PORT", "18800")))
|
|
1925
|
+
ws_url = cfg_data.get("ws_url", f"ws://127.0.0.1:{ws_port}")
|
|
1926
|
+
|
|
1927
|
+
# API URL for health checks (HTTP, not WS)
|
|
1928
|
+
api_url = cfg_data.get("api_url", os.environ.get("NAPCAT_API_URL", "http://127.0.0.1:18801"))
|
|
1929
|
+
token = cfg_data.get("token", os.environ.get("NAPCAT_TOKEN", ""))
|
|
1930
|
+
|
|
1931
|
+
# --- Start skills-fs FUSE mount ---
|
|
1932
|
+
# Treat empty strings as unset so an old config (which wrote "" for these)
|
|
1933
|
+
# still falls back to the defaults instead of spawning with blank args.
|
|
1934
|
+
skills_fs_enabled = cfg_data.get("skills_fs_enabled", True)
|
|
1935
|
+
skills_fs_mountpoint = cfg_data.get("skills_fs_mountpoint") or _DEFAULT_MOUNTPOINT
|
|
1936
|
+
skills_fs_binary = cfg_data.get("skills_fs_binary") or ""
|
|
1937
|
+
skills_fs_config = cfg_data.get("skills_fs_config") or _DEFAULT_SKILLSFS_CONFIG
|
|
1938
|
+
|
|
1939
|
+
skillsfs_mgr: SkillsFsManager | None = None
|
|
1940
|
+
if skills_fs_enabled:
|
|
1941
|
+
skillsfs_mgr = SkillsFsManager(
|
|
1942
|
+
processor,
|
|
1943
|
+
mountpoint=skills_fs_mountpoint,
|
|
1944
|
+
binary=skills_fs_binary,
|
|
1945
|
+
config=skills_fs_config,
|
|
1946
|
+
)
|
|
1947
|
+
skillsfs_mgr.start()
|
|
1948
|
+
|
|
1949
|
+
# Start HTTP provider (after skills-fs so handler can reference manager)
|
|
1950
|
+
server = run_http_server(processor, cache, http_port, skillsfs_manager=skillsfs_mgr)
|
|
1951
|
+
# Signal handling
|
|
1952
|
+
loop = asyncio.new_event_loop()
|
|
1953
|
+
|
|
1954
|
+
def shutdown_handler(signum: int, frame: Any) -> None:
|
|
1955
|
+
processor.log("Shutting down...")
|
|
1956
|
+
pid_file.unlink(missing_ok=True)
|
|
1957
|
+
if skillsfs_mgr is not None:
|
|
1958
|
+
skillsfs_mgr.stop()
|
|
1959
|
+
server.shutdown()
|
|
1960
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
1961
|
+
|
|
1962
|
+
signal.signal(signal.SIGINT, shutdown_handler)
|
|
1963
|
+
signal.signal(signal.SIGTERM, shutdown_handler)
|
|
1964
|
+
|
|
1965
|
+
processor.log(f"NapCat daemon started (PID: {os.getpid()})")
|
|
1966
|
+
processor.log(f"WebSocket URL: {ws_url}")
|
|
1967
|
+
processor.log(f"HTTP provider port: {http_port}")
|
|
1968
|
+
|
|
1969
|
+
# Run WS daemon, health check, and skills-fs monitor as parallel tasks
|
|
1970
|
+
tasks = [
|
|
1971
|
+
loop.create_task(ws_daemon(ws_url, processor, cache)),
|
|
1972
|
+
loop.create_task(health_check_task(processor, api_url, token)),
|
|
1973
|
+
]
|
|
1974
|
+
if skillsfs_mgr is not None:
|
|
1975
|
+
tasks.append(loop.create_task(skillsfs_monitor_task(skillsfs_mgr)))
|
|
1976
|
+
if processor.orchestrator is not None:
|
|
1977
|
+
tasks.append(loop.create_task(backlog_sweep_task(processor)))
|
|
1978
|
+
|
|
1979
|
+
# Wait for either to finish (shutdown or WS crash)
|
|
1980
|
+
done, _ = loop.run_until_complete(asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED))
|
|
1981
|
+
for t in done:
|
|
1982
|
+
if t.exception():
|
|
1983
|
+
processor.log(f"Task exception: {t.exception()}")
|
|
1984
|
+
|
|
1985
|
+
|
|
1986
|
+
def main() -> None:
|
|
1987
|
+
if len(sys.argv) < 2:
|
|
1988
|
+
print("Usage: watch.py <config_path>", file=sys.stderr)
|
|
1989
|
+
sys.exit(1)
|
|
1990
|
+
run_daemon(sys.argv[1])
|
|
1991
|
+
|
|
1992
|
+
|
|
1993
|
+
if __name__ == "__main__":
|
|
1994
|
+
main()
|