napcat-cli 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. napcat_cli/__init__.py +3 -0
  2. napcat_cli/cli.py +1834 -0
  3. napcat_cli/daemon/__init__.py +1 -0
  4. napcat_cli/daemon/schemas.py +470 -0
  5. napcat_cli/daemon/watch.py +1994 -0
  6. napcat_cli/data/SKILL.md +328 -0
  7. napcat_cli/data/__init__.py +0 -0
  8. napcat_cli/data/persona.md +155 -0
  9. napcat_cli/data/references/mounting.md +90 -0
  10. napcat_cli/data/skills-fs-config.json +12 -0
  11. napcat_cli/data/skills-fs-fragment.json +473 -0
  12. napcat_cli/data/skills-fs.d/agents-friend-time.md +7 -0
  13. napcat_cli/data/skills-fs.d/agents-friend.md +7 -0
  14. napcat_cli/data/skills-fs.d/agents-friends.md +4 -0
  15. napcat_cli/data/skills-fs.d/agents-group-time.md +7 -0
  16. napcat_cli/data/skills-fs.d/agents-group.md +6 -0
  17. napcat_cli/data/skills-fs.d/agents-groups.md +5 -0
  18. napcat_cli/data/skills-fs.d/agents-napcat.md +9 -0
  19. napcat_cli/data/skills-fs.d/persona.md +155 -0
  20. napcat_cli/data/skills-fs.d/skill-agents.md +42 -0
  21. napcat_cli/data/skills-fs.d/skill-body.md +102 -0
  22. napcat_cli/lib/__init__.py +1 -0
  23. napcat_cli/lib/api.py +258 -0
  24. napcat_cli/lib/config.py +105 -0
  25. napcat_cli/lib/events.py +127 -0
  26. napcat_cli/lib/events_sqlite.py +242 -0
  27. napcat_cli/lib/message.py +156 -0
  28. napcat_cli/setup_wizard.py +380 -0
  29. napcat_cli/tui/__init__.py +1 -0
  30. napcat_cli/tui/__main__.py +13 -0
  31. napcat_cli/tui/api.py +158 -0
  32. napcat_cli/tui/app.py +127 -0
  33. napcat_cli/tui/chat_list.py +168 -0
  34. napcat_cli/tui/chat_view.py +456 -0
  35. napcat_cli/tui/styles.tcss +9 -0
  36. napcat_cli/wake.py +51 -0
  37. napcat_cli/wake_backend.py +390 -0
  38. napcat_cli/wake_orchestrator.py +302 -0
  39. napcat_cli/wake_presets.py +77 -0
  40. napcat_cli-2.0.0.dist-info/METADATA +219 -0
  41. napcat_cli-2.0.0.dist-info/RECORD +43 -0
  42. napcat_cli-2.0.0.dist-info/WHEEL +4 -0
  43. napcat_cli-2.0.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,42 @@
1
+ # NapCat CLI Skill — Agent Guide
2
+
3
+ This skill exposes a NapCat QQ bot through `skills-fs`, a virtual filesystem engine.
4
+ Before the filesystem is available, an external daemon must be started.
5
+
6
+ ## Daemon requirements
7
+
8
+ - `skills-fs` Go binary (can be shipped with this skill)
9
+ - `watch.py` NapCat WebSocket listener / HTTP provider (Python daemon)
10
+ - NapCat server reachable via HTTP and WebSocket
11
+
12
+ ## How to start
13
+
14
+ ```bash
15
+ # 1. Start NapCat (e.g. via Docker)
16
+ docker compose -f NapcatDocker/docker-compose.yml up -d
17
+
18
+ # 2. Start the NapCat HTTP/WebSocket provider daemon
19
+ python3 napcat-cli/daemon/watch.py ~/.napcat-data/daemon.json
20
+
21
+ # 3. Start skills-fs FUSE
22
+ skills-fs fuse --config ~/.napcat-cli/skills-fs.json --mountpoint ~/.napcat-cli/skills/napcat-cli --allow-other --log-file ~/.napcat-cli/skills-fuse.log
23
+ ```
24
+
25
+ ## Filesystem layout
26
+
27
+ - `/SKILL.md` — skill documentation
28
+ - `/AGENTS.md` — this guide
29
+ - `/persona.md` — bot persona / system prompt
30
+ - `/napcat/` — API endpoints and data directories
31
+ - `/napcat/send_group`, `/napcat/send_private` — write JSON to send messages
32
+ - `/napcat/events/`, `/napcat/alerts/` — real-time events and notifications
33
+ - `/napcat/groups/{group_id}/{recent,1days,7days,30days,90days}/{message_id}` — browse group messages; write to `/napcat/groups/{group_id}/send` to reply
34
+ - `/napcat/friends/{user_id}/{recent,1days,7days,30days,90days}/{message_id}` — browse private messages; write to `/napcat/friends/{user_id}/send` to reply
35
+
36
+ Each directory contains an `AGENTS.md` explaining its purpose and parameters.
37
+
38
+ ## Shipped-with-skills-fs option
39
+
40
+ This skill can be bundled with a pre-built `skills-fs` binary. Set the binary path in
41
+ `~/.napcat-data/daemon.json` under `skillsFs.binary` and start the daemon with
42
+ `napcat daemon start`.
@@ -0,0 +1,102 @@
1
+ # NapCat CLI Skill (skills-fs mounted)
2
+
3
+ > ⚙️ **Mount state: skills-fs IS mounted.** This is the *generated* post-mount SKILL.md
4
+ > (skills-fs overwrote the static one when it mounted the `/napcat/` tree). You now have
5
+ > **two equivalent interfaces**: the `napcat` CLI below, and the `/napcat/` virtual
6
+ > filesystem. Either works; pick whichever fits. If the mount ever disappears, the CLI
7
+ > keeps working — see `references/mounting.md` for the FUSE D-state hazard & recovery.
8
+
9
+ Access QQ bot capabilities through the `napcat` CLI **or** the `/napcat/` FUSE tree.
10
+
11
+ ## When to Use
12
+ - Send private or group messages, read events/alerts, manage groups/friends/message lifecycle.
13
+ - Query group/friend metadata; poke members; call raw API endpoints.
14
+
15
+ ## Prerequisites
16
+ - NapCat daemon running, HTTP `/invoke` at `http://127.0.0.1:18821`, connected to NapCat WS (`:18800`).
17
+ - Bot online (`napcat status`).
18
+ - `skills-fs` FUSE mounted at the skill dir (this state) for the filesystem interface.
19
+
20
+ ---
21
+
22
+ # Interface A — the `napcat` CLI (no mount required)
23
+
24
+ All operations are CLI commands. Most useful when you don't want to touch the FUSE tree.
25
+
26
+ ```bash
27
+ napcat status # bot online?
28
+ napcat events # recent events (JSON)
29
+ napcat alerts # pending alerts
30
+ napcat send private <uid> -m "hi"
31
+ napcat send group <gid> -m "hi" --at <uid>
32
+ napcat reply <msg_id> -m "回复"
33
+ napcat group list | members <gid> | info <gid>
34
+ napcat friend list
35
+ napcat daemon status # daemon + recent log
36
+ ```
37
+
38
+ ### Read-only
39
+ `napcat status` · `napcat events [--since T] [--type X]` · `napcat alerts [--clear]` · `napcat msg <id>` · `napcat group list/members/info/essence <gid>` · `napcat friend list` · `napcat file list-group <gid>`
40
+
41
+ ### Sending
42
+ `napcat send group|private <id> -m "..." [--at <uid>] [--image <path>] [--file <path>]` · `napcat reply <id> -m "..."` · `napcat recall <id> [--group <gid>]`
43
+
44
+ ### Group ops
45
+ `napcat group {poke|mute|unmute|kick|admin|rename|remark|announce|set_essence} <gid> ...`
46
+
47
+ ### Friend ops / lifecycle / raw
48
+ `napcat friend {list|remark|info|delete} ...` · `napcat send_poke` · `napcat send_forward` · `napcat api <endpoint> [-d JSON]`
49
+
50
+ ### Wake / debug
51
+ `napcat wake test` · `napcat wake --dry-run` · `napcat wake --reason AT_ME --prompt "..."`
52
+
53
+ ### Persona
54
+ On QQ, reply in persona — load `~/.napcat-data/skills/persona.md` first. Do **not** say "我是AI".
55
+
56
+ ---
57
+
58
+ # Interface B — the `/napcat/` FUSE tree (this mount)
59
+
60
+ Read an API file to fetch data; write JSON to a write-enabled file to act. Written JSON is
61
+ forwarded as provider params.
62
+
63
+ ```bash
64
+ cat napcat/events # read recent events
65
+ echo '{"group_id":123456,"message":"hi"}' > napcat/send_group # send group msg
66
+ echo '{"alert_name":"NEW_MESSAGE"}' > napcat/clear_alert # clear an alert
67
+ ```
68
+
69
+ ### Send / message mgmt
70
+ `send_group`=send_group_msg · `send_private`=send_private_msg · `delete`=delete_msg · `get_events` · `get_alerts` · `clear_alert` · `clear_all_alerts`
71
+
72
+ ### Group management (write JSON params)
73
+ `group_kick` · `group_ban` · `group_admin` · `group_name` · `group_leave` · `group_essence` · `group_members`(read) · `group_info`(read) · `group_honor`(read) · `group_forward`
74
+
75
+ ### Friend / system
76
+ `friend_list` · `friend_add` · `friend_info` · `status` · `cookie` · `bkn` · `get_image` · `ocr` · `approve_request` · `reject_request`
77
+
78
+ ### Per-conversation directories
79
+ `napcat/groups/{group_id}/{recent|1days|7days|30days|90days}/{message_id}/` — browse messages;
80
+ write to `napcat/groups/{group_id}/send` to reply. Same shape under `napcat/friends/{user_id}/`.
81
+
82
+ ## Procedure (FUSE)
83
+ 1. Pick the action file (e.g. `napcat/send_group`).
84
+ 2. Read for data; write JSON params for actions. Read back to verify.
85
+ 3. For group/friend message browse, use the per-conversation directories above.
86
+
87
+ ## Pitfalls
88
+ - **CLI vs FUSE poke**: `napcat group poke` works; the HTTP `send_group_poke` often doesn't. Prefer the CLI for poke.
89
+ - **Offline**: check `napcat status` / read `napcat/status` before API calls. If offline, `napcat daemon start`.
90
+ - **Rate limits**: `send_like` → error 1400 = rate-limited; wait ≥30s. >~10 msg/min to one target may throttle.
91
+ - **Recall**: own messages ≤2 min; others' ≤20 s (admin/owner exempt).
92
+ - **events file**: only recent ~50 in memory; `alerts` keeps fuller history. Newest-first; use `?limit=N` (e.g. `cat napcat/events?limit=20`) — note the daemon defaults to a small result set.
93
+ - **Files/images**: local paths need `file:///` prefix; `http(s)://` and `base64://` also OK.
94
+ - **Write payload**: write JSON params only (`{"group_id":"123","message":"hi"}`), NOT the full OneBot envelope — the provider wraps it. Non-JSON writes fail.
95
+ - **Unsupported APIs**: `retcode:200` + "不支持的Api" → feature unavailable on this NapCat build (e.g. `send_group_notice`, `get_essence_list`).
96
+ - **Kernel errors**: `NodeIKernel...` = kernel timeout (bot may be offline); `ERR_NEED_MAKEUP` = QQ risk control; `ERR_NOT_GROUP_ADMIN` / `ERR_NOT_IN_GROUP` / `ERR_SEND_MSG_FREQ_LIMIT` are self-explanatory.
97
+ - **Mount lost (ENOTCONN)**: fall back to the CLI; remount via `napcat daemon restart` (skills-fs remounts).
98
+
99
+ ## Verification
100
+ - `napcat status` / `cat napcat/status` → online.
101
+ - `cat napcat/events` → events returned.
102
+ - Write `napcat/send_group` → message appears in the target group.
@@ -0,0 +1 @@
1
+ """napcat-cli library."""
napcat_cli/lib/api.py ADDED
@@ -0,0 +1,258 @@
1
+ """NapCat HTTP API client."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import sys
7
+ import urllib.request
8
+ import urllib.error
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .config import get_config, DATA_DIR
13
+
14
+
15
+ class NapCatAPI:
16
+ """HTTP client for NapCat OneBot 11 API."""
17
+
18
+ def __init__(self, api_url: str | None = None, token: str | None = None, timeout: int | None = None):
19
+ cfg = get_config()
20
+ self.api_url = (api_url or os.environ.get("NAPCAT_API_URL") or cfg.api_url).rstrip("/")
21
+ self.token = token or os.environ.get("NAPCAT_TOKEN") or cfg.token
22
+ self.timeout = timeout if timeout is not None else 30
23
+ self.echo_counter = 0
24
+
25
+ # Load or create API availability cache
26
+ self._load_api_cache()
27
+
28
+ def _load_api_cache(self) -> None:
29
+ """Load API availability cache from disk. No probe — cache is built lazily."""
30
+ self._unsupported_apis: set[str] = set()
31
+ cache_file = DATA_DIR / "napcat_api_cache.json"
32
+ try:
33
+ if cache_file.exists():
34
+ data = json.loads(cache_file.read_text())
35
+ ts = data.get("timestamp", 0)
36
+ import time
37
+ if time.time() - ts < 3600: # 1 hour TTL
38
+ self._unsupported_apis = set(data.get("unsupported", []))
39
+ except Exception:
40
+ pass
41
+
42
+ def _save_api_cache(self) -> None:
43
+ """Persist API availability cache to disk."""
44
+ try:
45
+ import time
46
+ cache_file = DATA_DIR / "napcat_api_cache.json"
47
+ cache_file.parent.mkdir(parents=True, exist_ok=True)
48
+ cache_file.write_text(json.dumps({
49
+ "timestamp": int(time.time()),
50
+ "unsupported": list(self._unsupported_apis),
51
+ }))
52
+ except Exception:
53
+ pass
54
+
55
+ def _next_echo(self) -> str:
56
+ """Generate unique echo ID for request tracking."""
57
+ import secrets
58
+ return secrets.token_hex(6)
59
+
60
+ def request(self, endpoint: str, method: str = "POST", json_body: dict | None = None, timeout: int | None = None) -> dict:
61
+ """Make a raw API request.
62
+
63
+ Args:
64
+ endpoint: API endpoint name (e.g., "get_login_info", ".send_poke")
65
+ method: HTTP method
66
+ json_body: JSON body for POST/PUT requests
67
+ timeout: Per-call timeout in seconds. Falls back to self.timeout (default 30).
68
+
69
+ Returns:
70
+ Parsed JSON response as dict.
71
+ """
72
+ url = f"{self.api_url}/{endpoint}"
73
+ call_timeout = timeout if timeout is not None else self.timeout
74
+
75
+ body = b""
76
+ if method.upper() in ("POST", "PUT", "PATCH") and json_body is not None:
77
+ body = json.dumps(json_body).encode("utf-8")
78
+
79
+ req = urllib.request.Request(url, data=body, method=method)
80
+ req.add_header("Content-Type", "application/json")
81
+ if self.token:
82
+ req.add_header("Authorization", f"Bearer {self.token}")
83
+
84
+ try:
85
+ with urllib.request.urlopen(req, timeout=call_timeout) as resp:
86
+ raw = resp.read().decode("utf-8")
87
+ return json.loads(raw)
88
+ except urllib.error.HTTPError as e:
89
+ raw = e.read().decode("utf-8", errors="replace")
90
+ try:
91
+ err = json.loads(raw)
92
+ except json.JSONDecodeError:
93
+ err = {"status": "error", "message": raw, "retcode": e.code}
94
+ print(f"API error {e.code}: {err}", file=sys.stderr)
95
+ return err
96
+ except urllib.error.URLError as e:
97
+ err = {
98
+ "status": "error",
99
+ "message": f"Connection failed: {e.reason}",
100
+ "retcode": -1,
101
+ "hint": self._connection_hint(),
102
+ }
103
+ print(f"Connection error: {err['message']}", file=sys.stderr)
104
+ return err
105
+ except Exception as e:
106
+ err = {
107
+ "status": "error",
108
+ "message": str(e),
109
+ "retcode": -1,
110
+ "hint": self._connection_hint(),
111
+ }
112
+ print(f"Error: {err['message']}", file=sys.stderr)
113
+ return err
114
+ def _normalize(self, params: dict) -> dict:
115
+ """Normalize params for NapCat 4.x - convert string IDs to int."""
116
+ numeric_fields = {
117
+ "group_id", "user_id", "message_id", "flag",
118
+ "duration", "count", "status", "ext_status", "battery_status",
119
+ "font", "busid", "page", "page_size", "role",
120
+ }
121
+ bool_fields = {
122
+ "no_cache", "enable", "approve", "reject_add_request",
123
+ "auto_escape", "enable_force_push", "upload_file",
124
+ }
125
+ result: dict = {}
126
+ for k, v in params.items():
127
+ if k in numeric_fields and isinstance(v, str):
128
+ try:
129
+ result[k] = int(v)
130
+ except ValueError:
131
+ result[k] = v
132
+ elif k in bool_fields and isinstance(v, str):
133
+ result[k] = v.lower() in ("true", "1", "yes")
134
+ else:
135
+ result[k] = v
136
+ return result
137
+
138
+ def is_online(self, _cache_ttl: int = 30) -> bool:
139
+ """Check if NapCat bot is currently online.
140
+
141
+ Caches result for _cache_ttl seconds to avoid excessive polling.
142
+ """
143
+ import time
144
+ now = time.time()
145
+ if hasattr(self, "_online_cache") and (now - self._online_cache["ts"]) < _cache_ttl:
146
+ return self._online_cache["online"]
147
+
148
+ result = self.request("get_status", method="POST", json_body={}, timeout=5)
149
+ data = result.get("data", {})
150
+ online = bool(data.get("online", False))
151
+ self._online_cache = {"ts": now, "online": online}
152
+ return online
153
+
154
+ @staticmethod
155
+ def friendly_error(raw_message: str) -> str:
156
+ """Map NapCat kernel error messages to user-friendly descriptions."""
157
+ mapping: dict[str, str] = {
158
+ "NTEvent serviceAndMethod:NodeIKernelMsgService/sendMsgInfoListener/onMsgInfoListener": "NapCat 内核超时,bot 可能离线",
159
+ "ERR_NEED_MAKEUP": "QQ 风控限制,操作被拒绝",
160
+ "ERR_NOT_GROUP_ADMIN": "不是群管理员",
161
+ "ERR_NOT_IN_GROUP": "不在该群中",
162
+ "ERR_REQUEST_COOLDOWN": "请求冷却中,请稍后再试",
163
+ "ERR_SEND_MSG_FREQ_LIMIT": "发送频率限制,请稍后再试",
164
+ "ERR_GROUP_NOT_FOUND": "群不存在或已退群",
165
+ }
166
+ for pattern, friendly in mapping.items():
167
+ if pattern in raw_message:
168
+ return friendly
169
+ # If message contains kernel internal path, simplify it
170
+ if "NodeIKernel" in raw_message:
171
+ return "NapCat 内核响应超时"
172
+ return raw_message
173
+
174
+ # Cache of known unsupported APIs (populated on first use)
175
+ _unsupported_apis: set[str] | None = None
176
+
177
+ def is_api_supported(self, action: str) -> bool | None:
178
+ """Check if a NapCat API action is supported.
179
+
180
+ Returns True if known supported, False if known unsupported,
181
+ None if unknown (hasn't been tested yet).
182
+ """
183
+ if self._unsupported_apis is None:
184
+ self._unsupported_apis = set()
185
+ if action in self._unsupported_apis:
186
+ return False
187
+ return None # unknown
188
+
189
+ def mark_api_unsupported(self, action: str) -> None:
190
+ """Record that an API action is not supported by this NapCat instance."""
191
+ if self._unsupported_apis is None:
192
+ self._unsupported_apis = set()
193
+ self._unsupported_apis.add(action)
194
+ self._save_api_cache()
195
+
196
+
197
+ def call(self, action: str, timeout: int | None = None, **params: Any) -> dict:
198
+ """Call an API action with parameters.
199
+
200
+ Args:
201
+ action: Action name (e.g., "send_msg", "get_group_info")
202
+ timeout: Per-call timeout in seconds. Falls back to self.timeout.
203
+ **params: Action parameters
204
+
205
+ Returns:
206
+ Parsed JSON response.
207
+ """
208
+ if self.is_api_supported(action) is False:
209
+ return {
210
+ "status": "failed",
211
+ "retcode": 200,
212
+ "data": None,
213
+ "message": f"API '{action}' is not supported by this NapCat instance. Check OneBot 11 spec or NapCat extensions.",
214
+ "wording": f"API '{action}' unsupported",
215
+ }
216
+
217
+ normalized = self._normalize(params)
218
+ result = self.request(action, json_body=normalized, timeout=timeout)
219
+
220
+ # Detect unsupported API responses and cache them
221
+ if result.get("message", "") == "不支持的Api" or "不支持的Api" in str(result.get("wording", "")):
222
+ self.mark_api_unsupported(action)
223
+ result["message"] = f"API '{action}' is not supported by this NapCat instance. Check OneBot 11 spec or NapCat extensions."
224
+ result["wording"] = f"API '{action}' unsupported"
225
+ # Map kernel errors to friendly messages
226
+ raw_msg = result.get("message", "")
227
+ if raw_msg and result.get("retcode", 0) != 0:
228
+ friendly = self.friendly_error(raw_msg)
229
+ if friendly != raw_msg:
230
+ result["message"] = friendly
231
+
232
+ return result
233
+
234
+ def _connection_hint(self) -> str:
235
+ """Generate helpful hint when connection fails."""
236
+ hints = []
237
+ # Check if container is running
238
+ try:
239
+ import subprocess
240
+ result = subprocess.run(
241
+ ["docker", "ps", "--filter", "name=napcat", "--format", "{{.Status}}"],
242
+ capture_output=True, text=True, timeout=5,
243
+ )
244
+ if not result.stdout.strip():
245
+ hints.append("NapCat Docker container is not running. Start with: docker compose up -d")
246
+ elif "Exited" in result.stdout:
247
+ hints.append("NapCat container has exited. Check logs: docker logs napcat")
248
+ except Exception:
249
+ pass
250
+
251
+ # Check URL
252
+ if not self.api_url:
253
+ hints.append("NAPCAT_API_URL not set. Default: http://127.0.0.1:18801")
254
+
255
+ # Check if logged in
256
+ hints.append("Make sure you have logged in via WebUI (http://127.0.0.1:6099/webui)")
257
+
258
+ return " | ".join(hints) if hints else "Check NapCat container and login status"
@@ -0,0 +1,105 @@
1
+ """Configuration management for napcat-cli."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from dataclasses import dataclass, fields, asdict
7
+ from pathlib import Path
8
+
9
+
10
+ def _get_data_dir() -> Path:
11
+ """Return the data directory, re-computed from env each call."""
12
+ return Path(os.environ.get("NAPCAT_DATA_DIR", str(Path.home() / ".napcat-data")))
13
+
14
+
15
+ class _LazyPath:
16
+ """Lazy proxy: re-reads NAPCAT_DATA_DIR on each access."""
17
+ def __truediv__(self, other):
18
+ return _get_data_dir() / other
19
+ def __str__(self):
20
+ return str(_get_data_dir())
21
+ def __repr__(self):
22
+ return f"DATA_DIR({_get_data_dir()!s})"
23
+
24
+
25
+ DATA_DIR = _LazyPath()
26
+
27
+
28
+ @dataclass
29
+ class NapCatConfig:
30
+ """napcat-cli configuration."""
31
+ api_url: str = "http://127.0.0.1:18801"
32
+ token: str = ""
33
+ self_id: str | None = None
34
+ webhook_port: int = 18820
35
+ ws_port: int = 18800
36
+ http_port: int = 18821
37
+ wake_on_event: bool = True # deprecated alias of wake_enabled (back-compat)
38
+ wake_command: str = "" # legacy shell escape hatch (used only if no backend configured)
39
+
40
+ # --- generic, pluggable agent wake (Hermes is the default preset, not required) ---
41
+ wake_enabled: bool = True
42
+ wake_preset: str = "hermes" # hermes | custom | none
43
+ wake_primary: str = "auto" # auto | http | cli (auto = http if configured+reachable, else cli)
44
+ wake_session: str = "napcat-qq" # session name (cli --continue / http session lookup)
45
+ wake_http_url: str = "" # e.g. http://127.0.0.1:8642 (hermes preset default)
46
+ wake_http_key: str = "" # bearer token (env: NAPCAT_WAKE_HTTP_KEY)
47
+ wake_http_session_id: str = "" # explicit session id; else resolved by name via GET /api/sessions
48
+ wake_cli_command: str = "" # rendered template; hermes preset fills it
49
+ wake_debounce_seconds: float = 3.0
50
+ wake_cooldown_seconds: float = 30.0
51
+ wake_new_message_idle_seconds: int = 600
52
+
53
+ event_dir: str = "events"
54
+ alert_dir: str = "alerts"
55
+ log_file: str = "daemon.log"
56
+ group_trigger_word: str = ""
57
+ private_trigger: str = "*"
58
+
59
+ # skills-fs integration
60
+ skills_fs_enabled: bool = True
61
+ skills_fs_binary: str = ""
62
+ skills_fs_mountpoint: str = ""
63
+ skills_fs_config: str = ""
64
+
65
+ def save(self) -> None:
66
+ """Save config to file atomically via temp file then rename."""
67
+ import os
68
+ data_dir = _get_data_dir()
69
+ config_file = data_dir / "config.json"
70
+ tmp_path = config_file.with_suffix(".tmp")
71
+ tmp_path.write_text(json.dumps(asdict(self), indent=2, ensure_ascii=False))
72
+ os.replace(str(tmp_path), str(config_file))
73
+
74
+ def set(self, key: str, value: str) -> None:
75
+ """Set a config value by key name."""
76
+ if hasattr(self, key):
77
+ attr = getattr(self, key)
78
+ if isinstance(attr, bool):
79
+ value = value.lower() in ("true", "1", "yes")
80
+ elif isinstance(attr, int):
81
+ value = int(value)
82
+ setattr(self, key, value)
83
+ else:
84
+ raise ValueError(f"Unknown config key: {key}. Available: {', '.join(f.name for f in fields(NapCatConfig))}")
85
+
86
+
87
+ def get_config() -> NapCatConfig:
88
+ """Load config from file or create default."""
89
+ data_dir = _get_data_dir()
90
+ config_file = data_dir / "config.json"
91
+ if config_file.exists():
92
+ try:
93
+ data = json.loads(config_file.read_text())
94
+ return NapCatConfig(**data)
95
+ except Exception:
96
+ pass
97
+ return NapCatConfig()
98
+
99
+
100
+ def ensure_dirs() -> None:
101
+ """Ensure events and alerts directories exist."""
102
+ cfg = get_config()
103
+ data_dir = _get_data_dir()
104
+ (data_dir / cfg.event_dir).mkdir(exist_ok=True)
105
+ (data_dir / cfg.alert_dir).mkdir(exist_ok=True)
@@ -0,0 +1,127 @@
1
+ """Event storage for NapCat CLI — SQLite backend.
2
+
3
+ Events and alerts are stored in a SQLite database (napcat_events.db) for
4
+ fast querying, atomic writes, and proper indexing.
5
+
6
+ The SQLite database is exposed through the skills-fs HTTP provider so agents
7
+ can query it via HTTP.
8
+
9
+ Usage:
10
+ reader = EventsReader(data_dir)
11
+ events = reader.read(limit=20, since=1700000000, event_type="message")
12
+
13
+ writer = EventsWriter(data_dir)
14
+ writer.write_event({"post_type": "message", ...})
15
+ writer.write_alert("NAPCAT_CLI_NEW_MESSAGE", {"sender_id": 123})
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import sqlite3
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from .events_sqlite import (
24
+ clear_alerts,
25
+ get_connection,
26
+ get_db_path,
27
+ read_alerts,
28
+ read_events,
29
+ write_alert,
30
+ write_event,
31
+ )
32
+
33
+
34
+ class EventsWriter:
35
+ """Write events and alerts to SQLite database."""
36
+
37
+ def __init__(self, data_dir: Path):
38
+ self.data_dir = data_dir
39
+ self.conn = get_connection(data_dir)
40
+
41
+ def write_event(self, event: dict[str, Any], event_type: str = "") -> int:
42
+ """Write an event to the database. Returns the row ID."""
43
+ return write_event(self.conn, event)
44
+
45
+ def write_alert(self, alert_name: str, data: dict[str, Any]) -> int:
46
+ """Write an alert to the database. Returns the row ID."""
47
+ return write_alert(self.conn, alert_name, data)
48
+
49
+ def clear_alert(self, name: str) -> bool:
50
+ """Clear a specific alert."""
51
+ count = clear_alerts(self.conn, name)
52
+ return count > 0
53
+
54
+ def clear_all_alerts(self) -> int:
55
+ """Clear all alerts. Returns count cleared."""
56
+ return clear_alerts(self.conn)
57
+
58
+ def close(self) -> None:
59
+ """Close the database connection."""
60
+ self.conn.close()
61
+
62
+
63
+ class EventsReader:
64
+ """Read events from SQLite database."""
65
+
66
+ def __init__(self, data_dir: Path):
67
+ self.data_dir = data_dir
68
+ self.db_path = get_db_path(data_dir)
69
+ self._conn: sqlite3.Connection | None = None
70
+
71
+ def _get_conn(self) -> sqlite3.Connection:
72
+ if self._conn is None:
73
+ self._conn = get_connection(self.data_dir)
74
+ return self._conn
75
+
76
+ def read(
77
+ self,
78
+ limit: int = 50,
79
+ event_type: str | None = None,
80
+ since: int | None = None,
81
+ post_type: str | None = None,
82
+ group_id: int | None = None,
83
+ user_id: int | None = None,
84
+ keyword: str | None = None,
85
+ ) -> list[dict[str, Any]]:
86
+ """Read events from SQLite, newest first.
87
+
88
+ Args:
89
+ limit: Maximum number of events to return.
90
+ event_type: Filter by event type (e.g., "message", "heartbeat").
91
+ since: Only include events with timestamp >= since.
92
+ post_type: Filter by post_type (e.g., "message", "notice").
93
+ group_id: Filter by group_id.
94
+ user_id: Filter by user_id or sender_id.
95
+ keyword: Filter by keyword in raw_json (LIKE query).
96
+ """
97
+ conn = self._get_conn()
98
+ return read_events(
99
+ conn,
100
+ limit=limit,
101
+ event_type=event_type,
102
+ since=since,
103
+ post_type=post_type,
104
+ group_id=group_id,
105
+ user_id=user_id,
106
+ keyword=keyword,
107
+ )
108
+
109
+ def read_alerts(
110
+ self,
111
+ name: str | None = None,
112
+ limit: int = 50,
113
+ ) -> list[dict[str, Any]]:
114
+ """Read alerts from the database."""
115
+ conn = self._get_conn()
116
+ return read_alerts(conn, name=name, limit=limit)
117
+
118
+ def get_count(self) -> int:
119
+ """Get total event count."""
120
+ from .events_sqlite import get_event_count
121
+ conn = self._get_conn()
122
+ return get_event_count(conn)
123
+
124
+ def close(self) -> None:
125
+ if self._conn is not None:
126
+ self._conn.close()
127
+ self._conn = None