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,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
+ ]