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,380 @@
1
+ """Interactive setup wizard for napcat-cli configuration."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from napcat_cli.lib.config import NapCatConfig, DATA_DIR
12
+ from napcat_cli.lib.api import NapCatAPI
13
+
14
+
15
+ def _write_daemon_json(cfg: NapCatConfig, data_dir: str | Path) -> None:
16
+ """Write a complete daemon.json with all fields consumed by watch.py."""
17
+ cfg_dict = {
18
+ "self_id": cfg.self_id or "",
19
+ "wake_command": cfg.wake_command,
20
+ "wake_on_event": cfg.wake_on_event,
21
+ "ws_port": cfg.ws_port,
22
+ "http_port": cfg.http_port,
23
+ "group_trigger_word": cfg.group_trigger_word,
24
+ "private_trigger": cfg.private_trigger,
25
+ "skills_fs_enabled": cfg.skills_fs_enabled,
26
+ "skills_fs_mountpoint": cfg.skills_fs_mountpoint,
27
+ "skills_fs_binary": cfg.skills_fs_binary,
28
+ "skills_fs_config": cfg.skills_fs_config,
29
+ "wake_enabled": cfg.wake_enabled,
30
+ "wake_preset": cfg.wake_preset,
31
+ "wake_primary": cfg.wake_primary,
32
+ "wake_session": cfg.wake_session,
33
+ "wake_http_url": cfg.wake_http_url,
34
+ "wake_http_session_id": cfg.wake_http_session_id,
35
+ "wake_cli_command": cfg.wake_cli_command,
36
+ "wake_debounce_seconds": cfg.wake_debounce_seconds,
37
+ "wake_cooldown_seconds": cfg.wake_cooldown_seconds,
38
+ "wake_new_message_idle_seconds": cfg.wake_new_message_idle_seconds,
39
+ }
40
+ daemon_path = Path(data_dir) / "daemon.json"
41
+ daemon_path.write_text(json.dumps(cfg_dict, indent=2))
42
+
43
+
44
+ def _prompt_token_with_validation(api_url: str, non_interactive: bool = False) -> str:
45
+ """Validate a token against NapCat; re-prompt on failure."""
46
+ try:
47
+ token = os.environ.get("NAPCAT_TOKEN", "")
48
+ except Exception:
49
+ token = ""
50
+
51
+ if non_interactive:
52
+ # Try once, never block
53
+ try:
54
+ api = NapCatAPI(api_url=api_url, token=token, timeout=5)
55
+ r = api.call("get_login_info")
56
+ if r.get("retcode") == 0:
57
+ print(f" Token validated OK")
58
+ else:
59
+ print(f" Token validation skipped in non-interactive mode")
60
+ except Exception:
61
+ pass
62
+ return token
63
+
64
+ # Interactive: validate, re-prompt on failure
65
+ try:
66
+ api = NapCatAPI(api_url=api_url, token=token, timeout=5)
67
+ r = api.call("get_login_info")
68
+ if r.get("retcode") == 0:
69
+ print(f" Token validated OK")
70
+ return token
71
+ except Exception:
72
+ pass
73
+
74
+ # Validation failed — re-prompt
75
+ print(f" Token 校验失败。重新输入 token(直接回车则忽略校验继续):", file=sys.stderr)
76
+ while True:
77
+ try:
78
+ new_token = input(" > ")
79
+ except (EOFError, KeyboardInterrupt):
80
+ print(file=sys.stderr)
81
+ return token
82
+ if not new_token:
83
+ print(f" Keeping current token, skipping validation.")
84
+ return token
85
+ token = new_token
86
+ try:
87
+ api = NapCatAPI(api_url=api_url, token=token, timeout=5)
88
+ r = api.call("get_login_info")
89
+ if r.get("retcode") == 0:
90
+ print(f" Token validated OK")
91
+ return token
92
+ except Exception:
93
+ pass
94
+ print(f" Token 校验失败。重新输入 token(直接回车则忽略校验继续):", file=sys.stderr)
95
+
96
+
97
+ def _check_skills_fs_binary(cfg: NapCatConfig) -> tuple[str, str]:
98
+ """Check for skills-fs binary. Returns (path_or_empty, how)."""
99
+ binary = cfg.skills_fs_binary
100
+ if binary:
101
+ p = Path(binary)
102
+ if p.exists() and os.access(p, os.X_OK):
103
+ return binary, "configured"
104
+ print(f" Configured binary '{binary}' not found or not executable.", file=sys.stderr)
105
+
106
+ # Try shipped binary next to repo
107
+ from napcat_cli.daemon.watch import _resolve_shipped_binary
108
+ shipped = _resolve_shipped_binary()
109
+ if shipped and Path(shipped).exists():
110
+ return shipped, "shipped"
111
+
112
+ # Search PATH
113
+ import shutil
114
+ found = shutil.which("skills-fs")
115
+ if found:
116
+ return found, "PATH"
117
+
118
+ return "", "missing"
119
+
120
+
121
+ def _install_hermes_skill(cfg: NapCatConfig, force: bool = False) -> None:
122
+ """Copy SKILL.md, persona.md, and references/ into ~/.hermes/skills/napcat-cli/."""
123
+ hermes_dir = Path.home() / ".hermes" / "skills" / "napcat-cli"
124
+
125
+ if hermes_dir.exists() and not force:
126
+ print(f" Hermes skill already installed at {hermes_dir}. Use --force to overwrite.")
127
+ return
128
+
129
+ try:
130
+ import importlib.resources as pkg_resources
131
+ skill_data = pkg_resources.files("napcat_cli.data")
132
+ skill_md = skill_data.joinpath("SKILL.md").read_text()
133
+ persona_md = skill_data.joinpath("persona.md").read_text()
134
+ ref_files: dict[str, str] = {}
135
+ try:
136
+ for child in skill_data.joinpath("references").iterdir():
137
+ if str(child).endswith(".md"):
138
+ ref_files[child.name] = child.read_text()
139
+ except (FileNotFoundError, NotADirectoryError, OSError):
140
+ pass
141
+ except Exception:
142
+ print(" Could not read bundled skill files, skipping Hermes skill install.", file=sys.stderr)
143
+ return
144
+
145
+ hermes_dir.mkdir(parents=True, exist_ok=True)
146
+ (hermes_dir / "SKILL.md").write_text(skill_md)
147
+ (hermes_dir / "persona.md").write_text(persona_md)
148
+ if ref_files:
149
+ ref_dir = hermes_dir / "references"
150
+ ref_dir.mkdir(exist_ok=True)
151
+ for name, content in ref_files.items():
152
+ (ref_dir / name).write_text(content)
153
+ print(f" Hermes skill installed at {hermes_dir}")
154
+
155
+
156
+ HERMES_GATEWAY_UNIT = "hermes-gateway.service"
157
+
158
+
159
+ def _enable_hermes_api_server(cfg: NapCatConfig) -> bool:
160
+ """Opt-in: enable the Hermes API server and restart the gateway.
161
+
162
+ Appends ``API_SERVER_ENABLED=true`` + a generated key to ``~/.hermes/.env``
163
+ (append-only, never rewrites), restarts the systemd unit (passwordless sudo),
164
+ and wires ``cfg.wake_http_url`` / ``cfg.wake_http_key``. Returns True on success.
165
+ """
166
+ import secrets as _secrets
167
+ import subprocess
168
+ hermes_env = Path.home() / ".hermes" / ".env"
169
+ key = _secrets.token_hex(32)
170
+ try:
171
+ with open(hermes_env, "a") as f:
172
+ f.write("\n# Added by napcat-cli setup (agent wake)\n")
173
+ f.write("API_SERVER_ENABLED=true\n")
174
+ f.write(f"API_SERVER_KEY={key}\n")
175
+ except Exception as e:
176
+ print(f" Could not write {hermes_env}: {e}", file=sys.stderr)
177
+ return False
178
+ print(f" Appended API_SERVER_ENABLED=true + key to {hermes_env}")
179
+ try:
180
+ subprocess.run(["sudo", "-n", "systemctl", "restart", HERMES_GATEWAY_UNIT],
181
+ check=True, timeout=60)
182
+ print(f" Restarted {HERMES_GATEWAY_UNIT}")
183
+ except Exception as e:
184
+ print(f" WARNING: could not restart {HERMES_GATEWAY_UNIT}: {e}", file=sys.stderr)
185
+ print(f" Restart manually: sudo systemctl restart {HERMES_GATEWAY_UNIT}", file=sys.stderr)
186
+ cfg.wake_http_url = "http://127.0.0.1:8642"
187
+ cfg.wake_http_key = key
188
+ return True
189
+
190
+
191
+ def _check_cli_symlink(yes: bool = False) -> None:
192
+ """Check if napcat is available on PATH; offer symlink if not."""
193
+ import shutil
194
+ if shutil.which("napcat"):
195
+ print(" 'napcat' is already on PATH.")
196
+ return
197
+
198
+ local_bin = Path.home() / ".local" / "bin"
199
+ target = local_bin / "napcat"
200
+
201
+ if target.exists():
202
+ print(f" Symlink exists at {target}")
203
+ return
204
+
205
+ # Check if installed via pip/uv (entry point)
206
+ try:
207
+ import importlib.metadata
208
+ info = importlib.metadata.distribution("napcat-cli")
209
+ if info:
210
+ print(" 'napcat' is installed via pip/uv — should be on PATH.")
211
+ return
212
+ except Exception:
213
+ pass
214
+
215
+ # Source tree — offer symlink
216
+ print(f" 'napcat' not found on PATH.")
217
+ print(f" Create symlink: ln -sf <path-to-napcat> {target}")
218
+
219
+
220
+ def _prompt_str(label: str, default: str, current: str = "") -> str:
221
+ """Interactive prompt showing current value; bare Enter keeps current."""
222
+ display = current if current else default
223
+ if not current:
224
+ print(f" {label} [{display}]: ", end="", file=sys.stderr)
225
+ else:
226
+ print(f" {label} (current: {current}) [{default}]: ", end="", file=sys.stderr)
227
+ try:
228
+ val = input()
229
+ except (EOFError, KeyboardInterrupt):
230
+ print(file=sys.stderr)
231
+ val = ""
232
+ return val if val else current if current else default
233
+
234
+
235
+ def _prompt_str_ni(default: str) -> str:
236
+ """Non-interactive: return env var or default."""
237
+ return default
238
+
239
+
240
+ def run_setup(non_interactive: bool = False, yes: bool = False, force: bool = False) -> int:
241
+ """Run the interactive setup wizard."""
242
+ print("=== napcat-cli Setup ===")
243
+ print()
244
+
245
+ cfg = NapCatConfig()
246
+
247
+ # --- 1. NapCat connection ---
248
+ print("[1] NapCat connection")
249
+ if non_interactive:
250
+ api_url = os.environ.get("NAPCAT_API_URL", "http://127.0.0.1:18801")
251
+ token = _prompt_token_with_validation(api_url, non_interactive=True)
252
+ else:
253
+ api_url = _prompt_str("API URL", "http://127.0.0.1:18801")
254
+ token = _prompt_token_with_validation(api_url)
255
+
256
+ cfg.api_url = api_url
257
+ cfg.token = token
258
+ print()
259
+
260
+ # --- 2. Data dir ---
261
+ print("[2] Data directory")
262
+ if non_interactive:
263
+ data_dir = os.environ.get("NAPCAT_DATA_DIR", str(Path.home() / ".napcat-data"))
264
+ else:
265
+ data_dir = _prompt_str("Data directory", str(Path.home() / ".napcat-data"))
266
+ print(f" Data dir: {data_dir}")
267
+ print()
268
+
269
+ # --- 3. skills-fs ---
270
+ print("[3] skills-fs configuration")
271
+ _skill_dir = str(Path.home() / ".hermes" / "skills" / "napcat-cli")
272
+ if non_interactive:
273
+ cfg.skills_fs_mountpoint = os.environ.get("NAPCAT_SKILLSFS_MOUNTPOINT", _skill_dir)
274
+ cfg.skills_fs_config = os.environ.get("NAPCAT_SKILLSFS_CONFIG",
275
+ str(Path.home() / ".napcat-data" / "skills-fs.json"))
276
+ else:
277
+ cfg.skills_fs_mountpoint = _prompt_str(
278
+ "skills-fs mountpoint (overlay target = the skill dir)", _skill_dir)
279
+ cfg.skills_fs_config = _prompt_str(
280
+ "skills-fs config path", str(Path.home() / ".napcat-data" / "skills-fs.json"))
281
+ cfg.skills_fs_enabled = True
282
+
283
+ # Check binary
284
+ binary_path, how = _check_skills_fs_binary(cfg)
285
+ if how == "missing":
286
+ print(" Go binary not found. Build it: cd skills-fs && make build (needs Go).", file=sys.stderr)
287
+ else:
288
+ print(f" skills-fs binary found: {binary_path} ({how})")
289
+ if binary_path:
290
+ cfg.skills_fs_binary = binary_path
291
+ print()
292
+
293
+ # --- 4. Wake agent preset ---
294
+ print("[4] Wake agent configuration")
295
+ print(" Wake is pluggable: Hermes is the default preset, but any HTTP endpoint or")
296
+ print(" shell command works. Default transport = CLI one-shot (zero infra); HTTP is opt-in.")
297
+ if non_interactive:
298
+ preset = "hermes"
299
+ else:
300
+ print(" Choose wake agent: [H]ermes (default) / [C]ustom / [N]one", file=sys.stderr)
301
+ try:
302
+ choice = input(" > ").lower().strip()
303
+ except (EOFError, KeyboardInterrupt):
304
+ print(file=sys.stderr)
305
+ choice = "h"
306
+ preset = "custom" if choice in ("c", "custom") else "none" if choice in ("n", "none") else "hermes"
307
+
308
+ cfg.wake_enabled = preset != "none"
309
+ cfg.wake_preset = preset
310
+ cfg.wake_primary = "auto"
311
+
312
+ if preset == "hermes":
313
+ session = "napcat-qq" if non_interactive else _prompt_str("Agent session name", "napcat-qq")
314
+ cfg.wake_session = session
315
+ cfg.wake_cli_command = "" # hermes preset fills the default CLI command at wake time
316
+ cfg.wake_command = "" # clear any legacy (broken) wake command
317
+ cfg.wake_on_event = True
318
+ print(f" preset=hermes session={session} primary=auto (CLI one-shot by default)")
319
+ enable_http = False
320
+ if not non_interactive:
321
+ print(f" Enable the Hermes HTTP API server now? Appends to ~/.hermes/.env and runs\n"
322
+ f" sudo systemctl restart {HERMES_GATEWAY_UNIT}\n"
323
+ f" (briefly interrupts your messaging platforms). [y/N]", file=sys.stderr)
324
+ try:
325
+ enable_http = input(" > ").lower().strip() in ("y", "yes")
326
+ except (EOFError, KeyboardInterrupt):
327
+ print(file=sys.stderr)
328
+ if enable_http:
329
+ _enable_hermes_api_server(cfg)
330
+ else:
331
+ print(" HTTP API server not enabled — CLI one-shot transport will be used.\n"
332
+ " (Re-run setup or set wake_http_* later to switch to HTTP.)")
333
+ elif preset == "custom":
334
+ cfg.wake_session = "napcat-qq" if non_interactive else _prompt_str("Session name", "napcat-qq")
335
+ if not non_interactive:
336
+ cfg.wake_http_url = _prompt_str("HTTP base URL (blank = skip http)", "")
337
+ if cfg.wake_http_url:
338
+ cfg.wake_http_key = _prompt_str("HTTP bearer key", "")
339
+ cfg.wake_cli_command = _prompt_str("CLI command template (blank = skip cli)", "")
340
+ cfg.wake_command = ""
341
+ cfg.wake_on_event = True
342
+ else:
343
+ cfg.wake_command = ""
344
+ cfg.wake_on_event = False
345
+ print(" Wake disabled.")
346
+ print()
347
+
348
+ # --- 5. Install skill into Hermes ---
349
+ print("[5] Hermes skill installation")
350
+ if non_interactive or yes:
351
+ _install_hermes_skill(cfg, force=force)
352
+ else:
353
+ print(f" Install skill to ~/.hermes/skills/napcat-cli/? [Y/n]", file=sys.stderr)
354
+ try:
355
+ choice = input(" > ").lower().strip()
356
+ except (EOFError, KeyboardInterrupt):
357
+ print(file=sys.stderr)
358
+ choice = "y"
359
+ if choice in ("", "y", "yes"):
360
+ _install_hermes_skill(cfg, force=force)
361
+ print()
362
+
363
+ # --- 6. Check CLI symlink ---
364
+ print("[6] CLI availability")
365
+ _check_cli_symlink(yes=yes)
366
+ print()
367
+
368
+ # --- Persist ---
369
+ print("Writing configuration...")
370
+ os.environ["NAPCAT_DATA_DIR"] = data_dir
371
+ cfg.save()
372
+ _write_daemon_json(cfg, data_dir)
373
+ print(f" config.json written to {data_dir}/config.json")
374
+ print(f" daemon.json written to {data_dir}/daemon.json")
375
+ print()
376
+ print("=== Setup complete ===")
377
+ print("Start the daemon with: napcat daemon start")
378
+ print("Test wake with: napcat wake --dry-run")
379
+
380
+ return 0
@@ -0,0 +1 @@
1
+ """napcat TUI — Textual-based mobile QQ-style interface."""
@@ -0,0 +1,13 @@
1
+ """napcat TUI entry point."""
2
+ from __future__ import annotations
3
+
4
+ from .app import NapCatApp
5
+
6
+
7
+ def main() -> None:
8
+ app = NapCatApp()
9
+ app.run()
10
+
11
+
12
+ if __name__ == "__main__":
13
+ main()
napcat_cli/tui/api.py ADDED
@@ -0,0 +1,158 @@
1
+ """HTTP client for the napcat daemon provider (port 18821)."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import json
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ import urllib.request
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from napcat_cli.lib.message import format_message, extract_file_paths
14
+
15
+
16
+ @dataclass
17
+ class ChatItem:
18
+ """A conversation entry (group or friend)."""
19
+ id: str # group_id or user_id
20
+ name: str # display name
21
+ kind: str # "group" or "private"
22
+ remark: str = "" # remark set by user for friends
23
+ last_message: str = ""
24
+ last_sender: str = ""
25
+ last_time: int = 0
26
+ unread: int = 0
27
+
28
+
29
+ @dataclass
30
+ class Message:
31
+ """A single message."""
32
+ id: str
33
+ sender_id: str
34
+ sender_name: str
35
+ content: str
36
+ time: int
37
+ is_self: bool = False
38
+
39
+
40
+ def _http_post(url: str, body: dict, timeout: int = 10) -> dict:
41
+ """Synchronous HTTP POST helper using urllib."""
42
+ data = json.dumps(body).encode("utf-8")
43
+ req = urllib.request.Request(
44
+ url, data=data,
45
+ headers={"Content-Type": "application/json"},
46
+ method="POST",
47
+ )
48
+ try:
49
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
50
+ return json.loads(resp.read().decode("utf-8"))
51
+ except Exception as e:
52
+ return {"error": str(e)}
53
+
54
+
55
+ # Repo root (the directory containing the ``napcat_cli`` package). Put it on
56
+ # PYTHONPATH for the slash-command subprocess so ``-m napcat_cli.cli`` resolves
57
+ # regardless of the caller's cwd or whether the package is pip-installed.
58
+ _REPO_ROOT = str(Path(__file__).resolve().parents[2])
59
+
60
+
61
+ class DaemonClient:
62
+ """Client for the napcat daemon HTTP provider on port 18821."""
63
+
64
+ def __init__(self, port: int | None = None) -> None:
65
+ self.url = f"http://127.0.0.1:{port or int(os.environ.get('NAPCAT_HTTP_PORT', '18821'))}/invoke"
66
+
67
+ async def call(self, action: str, params: dict | None = None) -> dict:
68
+ """Call a daemon action via POST JSON (async, non-blocking)."""
69
+ body = {"action": action, "params": params or {}}
70
+ return await asyncio.to_thread(_http_post, self.url, body)
71
+
72
+ async def get_events(self, limit: int = 100) -> list[dict]:
73
+ """Get recent events from daemon."""
74
+ result = await self.call("get_events", {"limit": limit})
75
+ return result.get("events", [])
76
+
77
+ async def get_alerts(self) -> list[dict]:
78
+ """Get pending alerts."""
79
+ result = await self.call("get_alerts", {})
80
+ return result.get("alerts", [])
81
+
82
+ async def get_friends(self) -> list[dict]:
83
+ """Get friend list."""
84
+ result = await self.call("napcat_get_friend_list", {})
85
+ if result.get("retcode") == 0:
86
+ return result.get("data", [])
87
+ return []
88
+
89
+ async def get_groups(self) -> list[dict]:
90
+ """Get group list."""
91
+ result = await self.call("napcat_get_group_list", {})
92
+ if result.get("retcode") == 0:
93
+ return result.get("data", [])
94
+ return []
95
+
96
+ async def get_message_history(self, message_type: str, target_id: str, count: int = 50, start_id: int = 0) -> list[dict]:
97
+ """Get message history for a group or friend."""
98
+ params: dict = {"count": count}
99
+ if message_type == "group":
100
+ params["group_id"] = str(target_id)
101
+ action = "napcat_get_group_msg_history"
102
+ else:
103
+ params["user_id"] = str(target_id)
104
+ action = "napcat_get_friend_msg_history"
105
+ if start_id:
106
+ params["message_seq"] = start_id
107
+ result = await self.call(action, params)
108
+ if result.get("retcode") == 0:
109
+ data = result.get("data") or {}
110
+ messages = data.get("messages", []) if isinstance(data, dict) else (data or [])
111
+ # Add formatted_text and files to each message
112
+ for msg in messages:
113
+ msg_segments = msg.get("message", [])
114
+ if isinstance(msg_segments, list):
115
+ msg["formatted_text"] = format_message(msg_segments)
116
+ msg["files"] = extract_file_paths(msg_segments)
117
+ else:
118
+ msg["formatted_text"] = str(msg_segments)
119
+ msg["files"] = []
120
+ return messages
121
+ return []
122
+
123
+ async def send_message(self, message_type: str, target_id: str, message: str) -> dict:
124
+ """Send a message."""
125
+ segments = [{"type": "text", "data": {"text": message}}]
126
+ params: dict = {"message_type": message_type, "message": segments}
127
+ if message_type == "group":
128
+ params["group_id"] = str(target_id)
129
+ else:
130
+ params["user_id"] = str(target_id)
131
+ return await self.call("napcat_send_msg", params)
132
+
133
+ async def run_napcat_cli(self, args: list[str]) -> tuple[str, str, int]:
134
+ """Run a napcat CLI command via the package entrypoint, returning
135
+ ``(stdout, stderr, code)``. This is the reuse path for ``/commands``."""
136
+ def _run() -> tuple[str, str, int]:
137
+ env = os.environ.copy()
138
+ pp = env.get("PYTHONPATH", "")
139
+ env["PYTHONPATH"] = _REPO_ROOT + (os.pathsep + pp if pp else "")
140
+ try:
141
+ result = subprocess.run(
142
+ [sys.executable, "-m", "napcat_cli.cli"] + list(args),
143
+ capture_output=True, text=True, timeout=30, env=env,
144
+ )
145
+ return result.stdout, result.stderr, result.returncode
146
+ except Exception as e:
147
+ return "", str(e), 1
148
+ return await asyncio.to_thread(_run)
149
+
150
+
151
+ # Cached instance
152
+ _client: DaemonClient | None = None
153
+
154
+ def get_client() -> DaemonClient:
155
+ global _client
156
+ if _client is None:
157
+ _client = DaemonClient()
158
+ return _client
napcat_cli/tui/app.py ADDED
@@ -0,0 +1,127 @@
1
+ """napcat TUI — root application."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ from textual.app import App, ComposeResult
7
+ from textual.widgets import Footer, Header
8
+
9
+ from napcat_cli.lib.message import format_message
10
+ from .api import ChatItem, DaemonClient, get_client
11
+
12
+
13
+ class NapCatApp(App):
14
+ """Root application for the napcat TUI."""
15
+ SCREENS: dict[str, type] = {}
16
+
17
+ CSS_PATH = "styles.tcss"
18
+
19
+ BINDINGS = [
20
+ ("escape", "quit", "Quit"),
21
+ ("f5", "refresh", "Refresh"),
22
+ ]
23
+
24
+ def __init__(self) -> None:
25
+ super().__init__()
26
+ self.client: DaemonClient = get_client()
27
+ self.chats: dict[str, ChatItem] = {}
28
+ self.alerts: list[dict[str, Any]] = []
29
+ self._seen_alerts: set[str] = set()
30
+ self._seen_message_ids: set[str] = set()
31
+
32
+ @staticmethod
33
+ def _alert_signature(alert: dict[str, Any]) -> str:
34
+ import json
35
+ return json.dumps(alert, sort_keys=True)
36
+
37
+ def compose(self) -> ComposeResult:
38
+ yield Header()
39
+ yield Footer()
40
+
41
+ def on_mount(self) -> None:
42
+ from .chat_list import ChatListScreen
43
+ self.set_interval(2, self._poll_loop)
44
+ self.push_screen(ChatListScreen())
45
+
46
+ async def _poll_loop(self) -> None:
47
+ await self.refresh_data()
48
+ screen = self.screen
49
+ if hasattr(screen, "_refresh_list"):
50
+ screen._refresh_list()
51
+ if hasattr(screen, "_refresh_messages"):
52
+ screen._refresh_messages()
53
+
54
+ async def refresh_data(self) -> None:
55
+ """Fetch fresh data from the daemon and merge into app state."""
56
+ try:
57
+ events = await self.client.get_events(limit=200)
58
+ friends = await self.client.get_friends()
59
+ groups = await self.client.get_groups()
60
+ self.alerts = await self.client.get_alerts()
61
+
62
+ # Build/update chat items from friends and groups
63
+ for f in friends:
64
+ fid = str(f.get("user_id", ""))
65
+ self.chats.setdefault(fid, ChatItem(
66
+ id=fid,
67
+ name=f.get("nickname", fid),
68
+ kind="private",
69
+ remark=f.get("remark", ""),
70
+ ))
71
+ self.chats[fid].name = f.get("nickname", fid)
72
+ self.chats[fid].remark = f.get("remark", "")
73
+
74
+ for g in groups:
75
+ gid = str(g.get("group_id", ""))
76
+ self.chats.setdefault(gid, ChatItem(
77
+ id=gid,
78
+ name=g.get("group_name", gid),
79
+ kind="group",
80
+ ))
81
+ self.chats[gid].name = g.get("group_name", gid)
82
+
83
+ # Update last message / unread from events
84
+ for ev in events:
85
+ post_type = ev.get("post_type", "")
86
+ if post_type == "message":
87
+ target_id = str(ev.get("group_id") or ev.get("user_id", ""))
88
+ sender_name = ""
89
+ sender_info = ev.get("sender", {})
90
+ if sender_info:
91
+ sender_name = sender_info.get("nickname") or sender_info.get("card") or ""
92
+ content = ""
93
+ raw = ev.get("raw_message", "") or ev.get("message", [])
94
+ if isinstance(raw, str):
95
+ content = raw
96
+ elif isinstance(raw, list):
97
+ content = format_message(raw)
98
+ if not target_id:
99
+ continue
100
+ if target_id not in self.chats:
101
+ is_group = bool(ev.get("group_id"))
102
+ self.chats.setdefault(target_id, ChatItem(
103
+ id=target_id,
104
+ name=(sender_name or target_id) if not is_group else target_id,
105
+ kind="group" if is_group else "private",
106
+ ))
107
+ item = self.chats[target_id]
108
+ item.last_message = content
109
+ item.last_sender = sender_name
110
+ item.last_time = ev.get("time", 0)
111
+ mid = str(ev.get("message_id", "")) or str(ev.get("message_seq", ""))
112
+ if mid and mid not in self._seen_message_ids:
113
+ self._seen_message_ids.add(mid)
114
+ item.unread += 1
115
+ elif post_type == "notice":
116
+ notice_type = ev.get("notice_type", "")
117
+ if notice_type == "friend_increase":
118
+ fid = str(ev.get("user_id", ""))
119
+ self.chats.setdefault(fid, ChatItem(
120
+ id=fid, name=fid, kind="private",
121
+ ))
122
+
123
+ except Exception:
124
+ pass
125
+
126
+ async def action_refresh(self) -> None:
127
+ await self.refresh_data()