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,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"
|
napcat_cli/lib/config.py
ADDED
|
@@ -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)
|
napcat_cli/lib/events.py
ADDED
|
@@ -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
|