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,242 @@
|
|
|
1
|
+
"""SQLite event store for NapCat CLI.
|
|
2
|
+
|
|
3
|
+
Replaces the filesystem JSON approach with a SQLite database for:
|
|
4
|
+
- Faster querying (time-based filters, type filters)
|
|
5
|
+
- No file naming conflicts
|
|
6
|
+
- Atomic writes
|
|
7
|
+
- Proper indexing for time-based lookups
|
|
8
|
+
|
|
9
|
+
The database is stored at DATA_DIR/napcat_events.db.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import sqlite3
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_db_path(data_dir: Path | None = None) -> Path:
|
|
21
|
+
"""Get the path to the events database."""
|
|
22
|
+
if data_dir is None:
|
|
23
|
+
from .config import DATA_DIR
|
|
24
|
+
data_dir = DATA_DIR
|
|
25
|
+
return data_dir / "napcat_events.db"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_connection(data_dir: Path | None = None) -> sqlite3.Connection:
|
|
29
|
+
"""Get a connection to the events database, creating it if needed."""
|
|
30
|
+
db_path = get_db_path(data_dir)
|
|
31
|
+
conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
|
32
|
+
conn.row_factory = sqlite3.Row
|
|
33
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
34
|
+
conn.execute("PRAGMA synchronous=NORMAL")
|
|
35
|
+
init_db(conn)
|
|
36
|
+
return conn
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def init_db(conn: sqlite3.Connection) -> None:
|
|
40
|
+
"""Initialize the database schema if tables don't exist."""
|
|
41
|
+
conn.executescript(
|
|
42
|
+
"CREATE TABLE IF NOT EXISTS events ("
|
|
43
|
+
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
|
44
|
+
"timestamp INTEGER NOT NULL, "
|
|
45
|
+
"post_type TEXT NOT NULL, "
|
|
46
|
+
"event_type TEXT NOT NULL, "
|
|
47
|
+
"raw_json TEXT NOT NULL, "
|
|
48
|
+
"self_id INTEGER, "
|
|
49
|
+
"group_id INTEGER, "
|
|
50
|
+
"user_id INTEGER, "
|
|
51
|
+
"message_id INTEGER, "
|
|
52
|
+
"message_type TEXT, "
|
|
53
|
+
"sender_id INTEGER, "
|
|
54
|
+
"created_at INTEGER NOT NULL"
|
|
55
|
+
"); "
|
|
56
|
+
|
|
57
|
+
"CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp DESC); "
|
|
58
|
+
"CREATE INDEX IF NOT EXISTS idx_events_post_type ON events(post_type); "
|
|
59
|
+
"CREATE INDEX IF NOT EXISTS idx_events_event_type ON events(event_type); "
|
|
60
|
+
"CREATE INDEX IF NOT EXISTS idx_events_group_id ON events(group_id); "
|
|
61
|
+
"CREATE INDEX IF NOT EXISTS idx_events_user_id ON events(user_id); "
|
|
62
|
+
"CREATE INDEX IF NOT EXISTS idx_events_sender_id ON events(sender_id); "
|
|
63
|
+
|
|
64
|
+
"CREATE TABLE IF NOT EXISTS alerts ("
|
|
65
|
+
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
|
66
|
+
"name TEXT NOT NULL, "
|
|
67
|
+
"timestamp INTEGER NOT NULL, "
|
|
68
|
+
"raw_json TEXT NOT NULL, "
|
|
69
|
+
"created_at INTEGER NOT NULL"
|
|
70
|
+
"); "
|
|
71
|
+
|
|
72
|
+
"CREATE INDEX IF NOT EXISTS idx_alerts_name ON alerts(name); "
|
|
73
|
+
"CREATE INDEX IF NOT EXISTS idx_alerts_timestamp ON alerts(timestamp DESC);"
|
|
74
|
+
)
|
|
75
|
+
conn.commit()
|
|
76
|
+
|
|
77
|
+
# Schema migration: add columns missing from older databases
|
|
78
|
+
for col, ctype in (("message_id", "INTEGER DEFAULT NULL"),):
|
|
79
|
+
try:
|
|
80
|
+
conn.execute(f"ALTER TABLE events ADD COLUMN {col} {ctype}")
|
|
81
|
+
except sqlite3.OperationalError:
|
|
82
|
+
pass
|
|
83
|
+
conn.commit()
|
|
84
|
+
|
|
85
|
+
# Ensure index exists (may fail if table was old but column was added manually)
|
|
86
|
+
try:
|
|
87
|
+
conn.execute(
|
|
88
|
+
"CREATE INDEX IF NOT EXISTS idx_events_message_id ON events(message_id)"
|
|
89
|
+
)
|
|
90
|
+
conn.commit()
|
|
91
|
+
except sqlite3.OperationalError:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Writer operations
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
def write_event(conn: sqlite3.Connection, event: dict[str, Any]) -> int:
|
|
100
|
+
"""Write an event to the database. Returns the new event ID."""
|
|
101
|
+
now = int(time.time())
|
|
102
|
+
ts = event.get("time", now)
|
|
103
|
+
post_type = event.get("post_type", "unknown")
|
|
104
|
+
event_type = event.get("notice_type", event.get("request_type", event.get("meta_event_type", post_type)))
|
|
105
|
+
raw_json = json.dumps(event, ensure_ascii=False)
|
|
106
|
+
|
|
107
|
+
self_id = event.get("self_id")
|
|
108
|
+
group_id = event.get("group_id")
|
|
109
|
+
user_id = event.get("user_id")
|
|
110
|
+
message_id = event.get("message_id")
|
|
111
|
+
message_type = event.get("message_type")
|
|
112
|
+
sender_id = event.get("sender", {}).get("user_id") if "sender" in event else None
|
|
113
|
+
|
|
114
|
+
cursor = conn.execute(
|
|
115
|
+
"INSERT INTO events "
|
|
116
|
+
"(timestamp, post_type, event_type, raw_json, self_id, group_id, user_id, "
|
|
117
|
+
"message_id, message_type, sender_id, created_at) "
|
|
118
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
119
|
+
(ts, post_type, event_type, raw_json, self_id, group_id, user_id,
|
|
120
|
+
message_id, message_type, sender_id, now),
|
|
121
|
+
)
|
|
122
|
+
conn.commit()
|
|
123
|
+
return cursor.lastrowid
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def write_alert(conn: sqlite3.Connection, name: str, data: dict[str, Any]) -> int:
|
|
127
|
+
"""Write an alert to the database. Returns the new alert ID."""
|
|
128
|
+
now = int(time.time())
|
|
129
|
+
raw_json = json.dumps(data, ensure_ascii=False)
|
|
130
|
+
alert_ts = data.get("timestamp", now)
|
|
131
|
+
|
|
132
|
+
cursor = conn.execute(
|
|
133
|
+
"INSERT INTO alerts (name, timestamp, raw_json, created_at) VALUES (?, ?, ?, ?)",
|
|
134
|
+
(name, alert_ts, raw_json, now),
|
|
135
|
+
)
|
|
136
|
+
conn.commit()
|
|
137
|
+
return cursor.lastrowid
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# Reader operations
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def read_events(
|
|
145
|
+
conn: sqlite3.Connection,
|
|
146
|
+
limit: int = 50,
|
|
147
|
+
event_type: str | None = None,
|
|
148
|
+
since: int | None = None,
|
|
149
|
+
post_type: str | None = None,
|
|
150
|
+
group_id: int | None = None,
|
|
151
|
+
user_id: int | None = None,
|
|
152
|
+
keyword: str | None = None,
|
|
153
|
+
) -> list[dict[str, Any]]:
|
|
154
|
+
"""Read events from the database, newest first."""
|
|
155
|
+
query = "SELECT raw_json FROM events WHERE 1=1"
|
|
156
|
+
params: list[Any] = []
|
|
157
|
+
|
|
158
|
+
if event_type:
|
|
159
|
+
query += " AND event_type = ?"
|
|
160
|
+
params.append(event_type)
|
|
161
|
+
if since:
|
|
162
|
+
query += " AND timestamp >= ?"
|
|
163
|
+
params.append(since)
|
|
164
|
+
if post_type:
|
|
165
|
+
query += " AND post_type = ?"
|
|
166
|
+
params.append(post_type)
|
|
167
|
+
if group_id:
|
|
168
|
+
query += " AND group_id = ?"
|
|
169
|
+
params.append(group_id)
|
|
170
|
+
if user_id:
|
|
171
|
+
query += " AND (user_id = ? OR sender_id = ?)"
|
|
172
|
+
params.append(user_id)
|
|
173
|
+
params.append(user_id)
|
|
174
|
+
if keyword:
|
|
175
|
+
query += " AND raw_json LIKE ?"
|
|
176
|
+
params.append(f"%{keyword}%")
|
|
177
|
+
|
|
178
|
+
query += " ORDER BY timestamp DESC LIMIT ?"
|
|
179
|
+
params.append(limit)
|
|
180
|
+
|
|
181
|
+
rows = conn.execute(query, params).fetchall()
|
|
182
|
+
events = []
|
|
183
|
+
for row in rows:
|
|
184
|
+
try:
|
|
185
|
+
events.append(json.loads(row["raw_json"]))
|
|
186
|
+
except json.JSONDecodeError:
|
|
187
|
+
continue
|
|
188
|
+
return events
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def read_alerts(
|
|
192
|
+
conn: sqlite3.Connection,
|
|
193
|
+
name: str | None = None,
|
|
194
|
+
limit: int = 50,
|
|
195
|
+
) -> list[dict[str, Any]]:
|
|
196
|
+
"""Read alerts from the database."""
|
|
197
|
+
query = "SELECT name, raw_json FROM alerts WHERE 1=1"
|
|
198
|
+
params: list[Any] = []
|
|
199
|
+
|
|
200
|
+
if name:
|
|
201
|
+
query += " AND name = ?"
|
|
202
|
+
params.append(name)
|
|
203
|
+
|
|
204
|
+
query += " ORDER BY timestamp DESC LIMIT ?"
|
|
205
|
+
params.append(limit)
|
|
206
|
+
|
|
207
|
+
rows = conn.execute(query, params).fetchall()
|
|
208
|
+
return [{"name": r["name"], **json.loads(r["raw_json"])} for r in rows]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def clear_alerts(conn: sqlite3.Connection, name: str | None = None) -> int:
|
|
212
|
+
"""Clear alerts. If name is given, clear only that alert type.
|
|
213
|
+
Returns count of deleted rows."""
|
|
214
|
+
cur = conn
|
|
215
|
+
if name:
|
|
216
|
+
cur = conn.execute("DELETE FROM alerts WHERE name = ?", (name,))
|
|
217
|
+
else:
|
|
218
|
+
cur = conn.execute("DELETE FROM alerts")
|
|
219
|
+
deleted = cur.rowcount
|
|
220
|
+
conn.commit()
|
|
221
|
+
return deleted
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def get_event_count(conn: sqlite3.Connection) -> int:
|
|
225
|
+
"""Get total event count."""
|
|
226
|
+
return conn.execute("SELECT COUNT(*) FROM events").fetchone()[0]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def get_alert_count(conn: sqlite3.Connection, name: str | None = None) -> int:
|
|
230
|
+
"""Get alert count, optionally filtered by name."""
|
|
231
|
+
if name:
|
|
232
|
+
return conn.execute("SELECT COUNT(*) FROM alerts WHERE name = ?", (name,)).fetchone()[0]
|
|
233
|
+
return conn.execute("SELECT COUNT(*) FROM alerts").fetchone()[0]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def prune_events(conn: sqlite3.Connection, older_than_days: int = 7) -> int:
|
|
237
|
+
"""Remove events older than specified days."""
|
|
238
|
+
cutoff = int(time.time()) - (older_than_days * 86400)
|
|
239
|
+
cur = conn.execute("DELETE FROM events WHERE timestamp < ?", (cutoff,))
|
|
240
|
+
deleted = cur.rowcount
|
|
241
|
+
conn.commit()
|
|
242
|
+
return deleted
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""NapCat/OneBot 11 message segment parsing and formatting.
|
|
2
|
+
|
|
3
|
+
Converts between NapCat segment format (array of {type, data}) and display format.
|
|
4
|
+
Extracts file paths from image/video/record segments for agent to read.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def format_message(msg: list[dict[str, Any]]) -> str:
|
|
15
|
+
"""Convert NapCat message segments to display string.
|
|
16
|
+
|
|
17
|
+
Handles all segment types: text, at, image, record (voice), reply, video, face.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
msg: List of segment dicts like [{"type": "text", "data": {"text": "hi"}}]
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Formatted message string with inline indicators (e.g., [media], @user)
|
|
24
|
+
"""
|
|
25
|
+
if not isinstance(msg, list):
|
|
26
|
+
return str(msg)
|
|
27
|
+
|
|
28
|
+
parts = []
|
|
29
|
+
for seg in msg:
|
|
30
|
+
seg_type = seg.get("type", "")
|
|
31
|
+
data = seg.get("data", {})
|
|
32
|
+
|
|
33
|
+
if seg_type == "text":
|
|
34
|
+
parts.append(data.get("text", ""))
|
|
35
|
+
elif seg_type == "at":
|
|
36
|
+
# @mention: qq is QQ number as string
|
|
37
|
+
qq = data.get("qq", "")
|
|
38
|
+
if qq:
|
|
39
|
+
parts.append(f"@{qq}")
|
|
40
|
+
elif seg_type == "image":
|
|
41
|
+
# Image: file path, summary, url
|
|
42
|
+
summary = data.get("summary", "").strip()
|
|
43
|
+
if summary:
|
|
44
|
+
parts.append(f"[图片: {summary}]")
|
|
45
|
+
else:
|
|
46
|
+
parts.append("[图片]")
|
|
47
|
+
elif seg_type == "record":
|
|
48
|
+
# Voice/audio: file, path, url
|
|
49
|
+
parts.append("[语音]")
|
|
50
|
+
elif seg_type == "video":
|
|
51
|
+
# Video: file, url
|
|
52
|
+
parts.append("[视频]")
|
|
53
|
+
elif seg_type == "reply":
|
|
54
|
+
# Reply reference
|
|
55
|
+
reply_id = data.get("id", "")
|
|
56
|
+
if reply_id:
|
|
57
|
+
parts.append(f"[回复: {reply_id}]")
|
|
58
|
+
elif seg_type == "face":
|
|
59
|
+
# QQ face emoji
|
|
60
|
+
face_id = data.get("id", "")
|
|
61
|
+
parts.append(f"[表情: {face_id}]")
|
|
62
|
+
else:
|
|
63
|
+
# Unknown segment type
|
|
64
|
+
parts.append(f"[{seg_type}]")
|
|
65
|
+
|
|
66
|
+
return "".join(parts)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def extract_files(msg: list[dict[str, Any]], base_dir: str | None = None) -> list[Path]:
|
|
70
|
+
"""Extract file paths from image/video/record segments for agent reading.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
msg: NapCat message segments
|
|
74
|
+
base_dir: Optional base directory for relative paths
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
List of Path objects pointing to files referenced in the message.
|
|
78
|
+
"""
|
|
79
|
+
if not isinstance(msg, list):
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
files = []
|
|
83
|
+
for seg in msg:
|
|
84
|
+
seg_type = seg.get("type", "")
|
|
85
|
+
data = seg.get("data", {})
|
|
86
|
+
|
|
87
|
+
# Look for file, path, or url fields
|
|
88
|
+
for key in ("file", "path"):
|
|
89
|
+
path_str = data.get(key, "")
|
|
90
|
+
if path_str:
|
|
91
|
+
p = Path(path_str)
|
|
92
|
+
if base_dir:
|
|
93
|
+
p = Path(base_dir) / p
|
|
94
|
+
if p.exists():
|
|
95
|
+
files.append(p)
|
|
96
|
+
break # Prefer file over path
|
|
97
|
+
|
|
98
|
+
return files
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def segments_from_raw(raw_message: str, msg: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
102
|
+
"""Parse message from raw_message string (legacy fallback).
|
|
103
|
+
|
|
104
|
+
NapCat provides both raw_message (plain string) and message (segments).
|
|
105
|
+
If raw_message differs from segments, this attempts to convert.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
raw_message: Plain text message
|
|
109
|
+
msg: Parsed message segments
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Normalized segment list
|
|
113
|
+
"""
|
|
114
|
+
if not msg or raw_message != format_message(msg):
|
|
115
|
+
# raw_message has different content, treat as plain text
|
|
116
|
+
return [{"type": "text", "data": {"text": raw_message}}]
|
|
117
|
+
|
|
118
|
+
return msg
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def extract_file_paths(msg: list[dict[str, Any]]) -> list[str]:
|
|
122
|
+
"""Extract file path strings from image/video/record segments.
|
|
123
|
+
|
|
124
|
+
Returns all file paths referenced in the message, regardless of whether
|
|
125
|
+
the files exist. Useful for establishing message→file mapping.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
msg: NapCat message segments
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
List of file path strings
|
|
132
|
+
"""
|
|
133
|
+
if not isinstance(msg, list):
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
files = []
|
|
137
|
+
for seg in msg:
|
|
138
|
+
seg_type = seg.get("type", "")
|
|
139
|
+
data = seg.get("data", {})
|
|
140
|
+
|
|
141
|
+
if seg_type in ("image", "record", "video"):
|
|
142
|
+
for key in ("file", "path"):
|
|
143
|
+
path_str = data.get(key, "")
|
|
144
|
+
if path_str:
|
|
145
|
+
files.append(path_str)
|
|
146
|
+
break # Prefer file over path
|
|
147
|
+
|
|
148
|
+
return files
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
__all__ = [
|
|
152
|
+
"format_message",
|
|
153
|
+
"extract_files",
|
|
154
|
+
"extract_file_paths",
|
|
155
|
+
"segments_from_raw",
|
|
156
|
+
]
|