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,168 @@
1
+ """Chat list screen - main conversation list."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime
5
+ from typing import TYPE_CHECKING
6
+
7
+ from textual.screen import Screen
8
+ from textual.widgets import ListView, ListItem, Label
9
+ from textual.containers import Vertical
10
+
11
+ if TYPE_CHECKING:
12
+ from .app import NapCatApp
13
+
14
+
15
+
16
+ class ChatListScreen(Screen):
17
+ """Main screen showing sorted list of conversations."""
18
+
19
+ CSS = """
20
+ ChatListScreen {
21
+ layout: vertical;
22
+ }
23
+ #header-label {
24
+ height: 2;
25
+ dock: top;
26
+ background: $primary;
27
+ color: $text;
28
+ text-align: center;
29
+ }
30
+ #chat-listview {
31
+ height: 1fr;
32
+ overflow-y: scroll;
33
+ border: solid $accent;
34
+ }
35
+ ListItem {
36
+ height: 3;
37
+ padding: 0 1;
38
+ color: $text;
39
+ }
40
+ ListItem .item-msg {
41
+ color: $text-muted;
42
+ }
43
+ ListItem:hover {
44
+ background: $accent;
45
+ color: $text;
46
+ }
47
+ ListItem:hover .item-msg {
48
+ color: $text;
49
+ }
50
+ /* ListView selection highlight: keep text readable on the highlight bg */
51
+ ListItem.-highlight,
52
+ ListItem.-highlight .item-msg {
53
+ color: $text;
54
+ }
55
+ ListItem.-unread {
56
+ background: $accent-lighten-2;
57
+ }
58
+ """
59
+
60
+ BINDINGS = [
61
+ ("escape", "back", "Back"),
62
+ ("ctrl+q", "quit", "Quit"),
63
+ ]
64
+
65
+ def compose(self) -> None:
66
+ yield Label(" QQ 消息", id="header-label")
67
+ yield ListView(id="chat-listview")
68
+
69
+ async def on_mount(self) -> None:
70
+ app = self._app()
71
+ await app.refresh_data()
72
+ self._refresh_list()
73
+
74
+ def action_back(self) -> None:
75
+ self._app().exit()
76
+
77
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
78
+ """Open chat when a list item is selected (Enter key via ListView)."""
79
+ item = event.item
80
+ chat_id = getattr(item, "chat_id", None)
81
+ chat_name = getattr(item, "chat_name", None)
82
+ chat_type = getattr(item, "chat_type", None)
83
+ if chat_id:
84
+ self._open_chat(chat_id, chat_name or "", chat_type or "private")
85
+
86
+ def _open_chat(self, chat_id: str, chat_name: str, chat_type: str) -> None:
87
+ """Decrement unread count and push the chat view screen."""
88
+ app = self._app()
89
+ chat_item = app.chats.get(chat_id)
90
+ if chat_item:
91
+ chat_item.unread = 0
92
+ from .chat_view import ChatViewScreen
93
+ app.push_screen(
94
+ ChatViewScreen(
95
+ chat_id=chat_id,
96
+ chat_name=chat_name,
97
+ chat_type=chat_type,
98
+ )
99
+ )
100
+
101
+ def _refresh_list(self) -> None:
102
+ """Refresh the chat list from app state."""
103
+ app = self._app()
104
+
105
+ # Collect new alerts (deduplicated by stable signature). Fire ONE aggregated
106
+ # toast per refresh instead of one-per-alert, so they don't pile up in
107
+ # the corner; per-chat unread counts in the list still show the detail.
108
+ new_alerts = []
109
+ for alert in app.alerts:
110
+ sig = app._alert_signature(alert)
111
+ if sig not in app._seen_alerts:
112
+ app._seen_alerts.add(sig)
113
+ new_alerts.append(alert)
114
+ if new_alerts:
115
+ if len(new_alerts) == 1:
116
+ summary = new_alerts[0].get("summary", new_alerts[0].get("message", "alert"))
117
+ self.app.notify(f"\U0001f4e9 {summary}", severity="information", markup=False)
118
+ else:
119
+ self.app.notify(f"\U0001f4e9 {len(new_alerts)} 条新提醒", severity="information", markup=False)
120
+
121
+ listview = self.query_one("#chat-listview", ListView)
122
+
123
+ # Save current selection index before clearing
124
+ old_index = listview.index if listview.index is not None else 0
125
+
126
+ sorted_items = sorted(app.chats.values(), key=lambda c: c.last_time, reverse=True)
127
+
128
+ items: list[ListItem] = []
129
+ for chat in sorted_items:
130
+ name_label, msg_label = self._format_item(chat)
131
+ li = ListItem(Vertical(name_label, msg_label, classes="item-vbox"))
132
+ li.chat_id = chat.id # type: ignore[attr-defined]
133
+ li.chat_name = chat.name # type: ignore[attr-defined]
134
+ li.chat_type = chat.kind # type: ignore[attr-defined]
135
+ if chat.unread > 0:
136
+ li.add_class("-unread")
137
+ items.append(li)
138
+
139
+ listview.clear()
140
+ listview.extend(items)
141
+ listview.index = min(old_index, max(0, len(items) - 1))
142
+
143
+ def _format_item(self, chat) -> tuple[Label, Label]:
144
+ """Return (name_label, msg_label) for a chat item."""
145
+ if chat.kind == "group":
146
+ name = f"群[{chat.name}]"
147
+ else:
148
+ remark = chat.remark
149
+ qq = chat.id
150
+ name = f"{qq} [{remark}]" if remark else f"{qq}"
151
+ badge = f" [{chat.unread}]" if chat.unread > 0 else ""
152
+ name_label = Label(f"{name}{badge}", markup=False)
153
+ # Second line: time + sender + last message
154
+ parts: list[str] = []
155
+ if chat.last_time:
156
+ dt = datetime.fromtimestamp(chat.last_time)
157
+ parts.append(dt.strftime("%H:%M"))
158
+ sender = chat.last_sender
159
+ if sender:
160
+ parts.append(f"{sender}:")
161
+ msg = (chat.last_message or "")[:40]
162
+ if msg:
163
+ parts.append(msg)
164
+ msg_label = Label(" ".join(parts), markup=False, classes="item-msg")
165
+ return name_label, msg_label
166
+
167
+ def _app(self) -> "NapCatApp":
168
+ return self.app # type: ignore[return-value]
@@ -0,0 +1,456 @@
1
+ """Chat view screen — detailed conversation with messages."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime
5
+ from typing import TYPE_CHECKING
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.screen import Screen
9
+ from textual.widgets import Input, Button, RichLog, Label, Select
10
+ from textual.widgets import ListItem, ListView
11
+ from textual.reactive import reactive
12
+ from rich.text import Text
13
+ from textual.containers import Horizontal, Container
14
+ from textual.binding import Binding
15
+ from napcat_cli.lib.message import format_message
16
+
17
+ if TYPE_CHECKING:
18
+ from .app import NapCatApp
19
+
20
+ # NapCat CLI command catalog for autocomplete
21
+ _NAPCAT_COMMANDS: list[tuple[str, str]] = [
22
+ ("api", "Raw API access"),
23
+ ("send", "Send a message"),
24
+ ("recall", "Recall a message"),
25
+ ("group", "Group management"),
26
+ ("friend", "Friend management"),
27
+ ("file", "File operations"),
28
+ ("daemon", "Manage watch daemon"),
29
+ ("events", "Read events"),
30
+ ("alerts", "Check alerts"),
31
+ ("config", "Manage configuration"),
32
+ ("status", "Check bot login status"),
33
+ ("ocr", "OCR an image"),
34
+ ("translate", "QQ translation"),
35
+ ("like", "Like a message"),
36
+ ("react", "React to a message"),
37
+ ("search", "Search messages"),
38
+ ("batch", "Batch operations"),
39
+ ]
40
+
41
+ _NAPCAT_GROUP_SUB: list[str] = [
42
+ "info", "members", "member", "mute", "unmute", "kick",
43
+ "admin", "rename", "remark", "announce", "list", "essence", "poke",
44
+ ]
45
+ _NAPCAT_FRIEND_SUB: list[str] = ["list", "info", "remark", "add", "delete"]
46
+ _NAPCAT_FILE_SUB: list[str] = [
47
+ "upload-group", "upload-private", "list-group", "list-folder", "info", "download",
48
+ ]
49
+
50
+
51
+ class CommandInput(Input):
52
+ """Message input with inline ``/command`` autocomplete.
53
+
54
+ Completions render in the screen's static ``#cmd-overlay`` ListView (part of
55
+ the layout, directly above this input) instead of a dynamically-mounted
56
+ floating widget — robust under Textual 8.x. ``/cmd`` + Enter runs a napcat
57
+ CLI command; plain text + Enter sends a message (both are routed through
58
+ ``ChatViewScreen._handle_submit`` via ``Input.Submitted``).
59
+ """
60
+
61
+ def __init__(self, *, placeholder: str = "输入消息...", **kwargs) -> None:
62
+ super().__init__(placeholder=placeholder, **kwargs)
63
+ self._completions: list[str] = []
64
+
65
+ # --- overlay (the static #cmd-overlay ListView in the screen) --------
66
+ def _overlay(self) -> ListView:
67
+ return self.screen.query_one("#cmd-overlay", ListView)
68
+
69
+ def _overlay_visible(self) -> bool:
70
+ return self._overlay().has_class("-visible")
71
+
72
+ def _hide_overlay(self) -> None:
73
+ self._completions = []
74
+ overlay = self._overlay()
75
+ overlay.remove_class("-visible")
76
+ overlay.clear()
77
+ self.remove_class("-cmd-mode")
78
+
79
+ # --- completions -----------------------------------------------------
80
+ def _get_completions(self) -> list[str]:
81
+ # Preserve trailing whitespace: "/group " must still read as "command +
82
+ # space" so the subcommand list is offered (stripping it would collapse
83
+ # "/group " back to the top-level "group" match).
84
+ raw = self.value.lstrip("/")
85
+ if " " in raw:
86
+ cmd, _, rest = raw.partition(" ")
87
+ cmd = cmd.strip()
88
+ rest = rest.strip()
89
+ if cmd == "group":
90
+ return [f"group {s}" for s in _NAPCAT_GROUP_SUB if s.startswith(rest)]
91
+ if cmd == "friend":
92
+ return [f"friend {s}" for s in _NAPCAT_FRIEND_SUB if s.startswith(rest)]
93
+ if cmd == "file":
94
+ return [f"file {s}" for s in _NAPCAT_FILE_SUB if s.startswith(rest)]
95
+ return []
96
+ return [c for c, _ in _NAPCAT_COMMANDS if c.startswith(raw.strip())]
97
+
98
+ async def _refresh_overlay(self) -> None:
99
+ overlay = self._overlay()
100
+ completions = self._get_completions()
101
+ if not self.value.startswith("/") or not completions:
102
+ self._hide_overlay()
103
+ return
104
+ self._completions = completions
105
+ overlay.clear()
106
+ await overlay.extend(ListItem(Label(c)) for c in completions)
107
+ overlay.index = 0
108
+ overlay.add_class("-visible")
109
+ self.add_class("-cmd-mode")
110
+
111
+ def _cycle(self, direction: int) -> None:
112
+ if not self._completions:
113
+ return
114
+ overlay = self._overlay()
115
+ idx = overlay.index if overlay.index is not None else 0
116
+ overlay.index = (idx + direction) % len(self._completions)
117
+
118
+ def _apply_highlighted(self) -> None:
119
+ """Insert the highlighted suggestion into the value (menu-complete)."""
120
+ if not self._completions:
121
+ return
122
+ overlay = self._overlay()
123
+ idx = overlay.index if overlay.index is not None else 0
124
+ if 0 <= idx < len(self._completions):
125
+ self.value = "/" + self._completions[idx] + " "
126
+
127
+ # --- events ----------------------------------------------------------
128
+ async def _on_key(self, event) -> None:
129
+ # Override Input's private key handler (runs before its default action)
130
+ # so command-mode keys are intercepted first. Shell-like semantics:
131
+ # Tab menu-complete the highlighted suggestion
132
+ # Up/Down move the highlight through suggestions
133
+ # Enter submit the input as typed (run /command or send message)
134
+ # Escape dismiss the suggestion list
135
+ if self.value.startswith("/"):
136
+ if event.key == "tab":
137
+ if not self._completions:
138
+ await self._refresh_overlay()
139
+ self._apply_highlighted()
140
+ await self._refresh_overlay() # show subcommands for the completion
141
+ event.stop()
142
+ event.prevent_default()
143
+ return
144
+ if event.key in ("down", "up"):
145
+ if not self._overlay_visible():
146
+ await self._refresh_overlay()
147
+ self._cycle(1 if event.key == "down" else -1)
148
+ event.stop()
149
+ event.prevent_default()
150
+ return
151
+ if event.key == "escape":
152
+ self._hide_overlay()
153
+ event.stop()
154
+ event.prevent_default()
155
+ return
156
+ # Enter and ordinary characters fall through to Input's default:
157
+ # Enter fires Input.Submitted -> ChatViewScreen._handle_submit, which
158
+ # runs the /command or sends the message exactly as typed.
159
+ # Let Input process the key (insert/delete/cursor) ...
160
+ await super()._on_key(event)
161
+ # ... then refresh the overlay against the now-current value. Doing it
162
+ # here (synchronously in the key handler, not via a background worker)
163
+ # is deterministic: the list updates as part of handling the keypress,
164
+ # so it can never lag behind or be dropped while the user is typing.
165
+ if self.value.startswith("/"):
166
+ await self._refresh_overlay()
167
+ elif self._overlay_visible():
168
+ self._hide_overlay()
169
+
170
+
171
+ class ChatViewScreen(Screen):
172
+ """Detailed chat view for a single conversation."""
173
+
174
+ CSS = """
175
+ ChatViewScreen {
176
+ layout: vertical;
177
+ }
178
+ #view-header {
179
+ height: 2;
180
+ dock: top;
181
+ background: $primary;
182
+ color: $text;
183
+ text-align: center;
184
+ }
185
+ #messages {
186
+ height: 1fr;
187
+ overflow-y: scroll;
188
+ padding: 1 2;
189
+ border: solid $accent;
190
+ }
191
+ /* /command autocomplete dropdown — in-layout, directly above the input
192
+ bar; hidden unless the input is in command mode. */
193
+ #cmd-overlay {
194
+ height: auto;
195
+ max-height: 6;
196
+ display: none;
197
+ background: $surface-lighten-2;
198
+ border: solid $warning;
199
+ }
200
+ #cmd-overlay.-visible {
201
+ display: block;
202
+ }
203
+ #cmd-overlay > ListItem {
204
+ height: 1;
205
+ padding: 0 1;
206
+ }
207
+ #cmd-overlay > ListItem.-highlight,
208
+ #cmd-overlay > ListItem:hover {
209
+ background: $warning;
210
+ color: $text;
211
+ }
212
+ /* Bottom input bar: children keep their natural height (3, bordered) so
213
+ the typed text and the button label are actually visible. */
214
+ #input-bar {
215
+ dock: bottom;
216
+ height: auto;
217
+ padding: 0 1;
218
+ background: $surface;
219
+ }
220
+ #msg-input {
221
+ width: 1fr;
222
+ }
223
+ #send-btn {
224
+ width: auto;
225
+ min-width: 8;
226
+ }
227
+ CommandInput.-cmd-mode {
228
+ border: solid $warning;
229
+ }
230
+ """
231
+
232
+ BINDINGS = [
233
+ ("escape", "back", "Back"),
234
+ ("up", "scroll_up", "Up"),
235
+ ("down", "scroll_down", "Down"),
236
+ ("pageup", "page_up", "PgUp"),
237
+ ("pagedown", "page_down", "PgDn"),
238
+ ]
239
+
240
+ def __init__(
241
+ self, *, chat_id: str, chat_name: str, chat_type: str,
242
+ ) -> None:
243
+ super().__init__()
244
+ self.chat_id = chat_id
245
+ self.chat_name = chat_name
246
+ self.chat_type = chat_type
247
+ self._last_message_time: float = 0
248
+ self._loaded_message_ids: set[str] = set()
249
+
250
+ def compose(self) -> ComposeResult:
251
+ yield Label(f" {self.chat_name}", id="view-header")
252
+ yield RichLog(id="messages")
253
+ yield ListView(id="cmd-overlay")
254
+ yield Horizontal(
255
+ CommandInput(placeholder="输入消息 /命令...", id="msg-input"),
256
+ Button("发送", id="send-btn"),
257
+ id="input-bar",
258
+ )
259
+
260
+ def on_mount(self) -> None:
261
+ self._load_messages()
262
+ self.query_one("#msg-input", CommandInput).focus()
263
+
264
+ def on_button_pressed(self, event: Button.Pressed) -> None:
265
+ if event.button.id == "send-btn":
266
+ self._handle_submit()
267
+
268
+ def on_input_submitted(self, event: Input.Submitted) -> None:
269
+ if event.input.id == "msg-input":
270
+ self._handle_submit()
271
+
272
+ def _handle_submit(self) -> None:
273
+ """Handle input submission — command or message."""
274
+ input_widget = self.query_one("#msg-input", CommandInput)
275
+ text = input_widget.value.strip()
276
+ if not text:
277
+ return
278
+ input_widget.value = ""
279
+ input_widget.remove_class("-cmd-mode")
280
+ input_widget._hide_overlay()
281
+
282
+ if text.startswith("/"):
283
+ cmd = text[1:].strip()
284
+ self._execute_command(cmd)
285
+ else:
286
+ self._send_message(text)
287
+
288
+ def action_back(self) -> None:
289
+ self._app().pop_screen()
290
+
291
+ def _load_messages(self) -> None:
292
+ self.run_worker(self._load_messages_worker())
293
+
294
+ async def _load_messages_worker(self) -> None:
295
+ client = self._app().client
296
+ msgs = await client.get_message_history(self.chat_type, self.chat_id, count=100)
297
+
298
+ rich_log = self.query_one("#messages", RichLog)
299
+ rich_log.clear()
300
+ self._loaded_message_ids.clear()
301
+
302
+ try:
303
+ from napcat_cli.lib.config import get_config
304
+ cfg = get_config()
305
+ self_id = str(cfg.self_id or "")
306
+ except Exception:
307
+ self_id = ""
308
+
309
+ sorted_msgs = sorted(msgs, key=lambda m: m.get("time", 0))
310
+
311
+ for m in sorted_msgs:
312
+ mid = self._get_msg_id(m)
313
+ if mid:
314
+ self._loaded_message_ids.add(mid)
315
+
316
+ combined = Text()
317
+ for i, m in enumerate(sorted_msgs):
318
+ msg_text = self._format_message_line(m, self_id)
319
+ combined.append_text(msg_text)
320
+ if i < len(sorted_msgs) - 1:
321
+ combined.append("\n", style="dim")
322
+ if sorted_msgs:
323
+ rich_log.write(combined, scroll_end=True)
324
+
325
+ if sorted_msgs:
326
+ self._last_message_time = max(m.get("time", 0) for m in sorted_msgs)
327
+
328
+ def _send_message(self, text: str) -> None:
329
+ self.run_worker(self._send_async(text))
330
+
331
+ async def _send_async(self, text: str) -> None:
332
+ client = self._app().client
333
+ result = await client.send_message(self.chat_type, self.chat_id, text)
334
+
335
+ if isinstance(result, dict):
336
+ server_id = str(result.get("message_id", "")) or str(result.get("message_seq", ""))
337
+ if server_id:
338
+ self._loaded_message_ids.add(server_id)
339
+
340
+ ts = datetime.now().timestamp()
341
+ now_str = datetime.fromtimestamp(ts).strftime("%H:%M")
342
+
343
+ rich_log = self.query_one("#messages", RichLog)
344
+ rich_log.write(Text.assemble(("[我] ", "green"), (f"{now_str}\n", ""), (text, "")), scroll_end=True)
345
+
346
+ self._last_message_time = ts
347
+
348
+ chat = self._app().chats.get(self.chat_id)
349
+ if chat:
350
+ chat.last_message = text
351
+ chat.last_time = int(ts)
352
+
353
+ def _refresh_messages(self) -> None:
354
+ self.run_worker(self._append_new_messages())
355
+
356
+ async def _append_new_messages(self) -> None:
357
+ client = self._app().client
358
+ msgs = await client.get_message_history(self.chat_type, self.chat_id, count=100)
359
+
360
+ rich_log = self.query_one("#messages", RichLog)
361
+ at_end = rich_log.scroll_y >= rich_log.max_scroll_y
362
+
363
+ new_msgs = [
364
+ m for m in msgs
365
+ if m.get("time", 0) > self._last_message_time
366
+ and self._get_msg_id(m) not in self._loaded_message_ids
367
+ ]
368
+
369
+ if not new_msgs:
370
+ return
371
+
372
+ new_msgs = sorted(new_msgs, key=lambda m: m.get("time", 0))
373
+
374
+ for m in new_msgs:
375
+ mid = self._get_msg_id(m)
376
+ if mid:
377
+ self._loaded_message_ids.add(mid)
378
+ self._last_message_time = max(self._last_message_time, m.get("time", 0))
379
+
380
+ try:
381
+ from napcat_cli.lib.config import get_config
382
+ cfg = get_config()
383
+ self_id = str(cfg.self_id or "")
384
+ except Exception:
385
+ self_id = ""
386
+
387
+ combined = Text()
388
+ for i, m in enumerate(new_msgs):
389
+ msg_text = self._format_message_line(m, self_id)
390
+ combined.append_text(msg_text)
391
+ if i < len(new_msgs) - 1:
392
+ combined.append("\n", style="dim")
393
+ if new_msgs:
394
+ rich_log.write(combined)
395
+ if at_end:
396
+ rich_log.scroll_end()
397
+
398
+ def _get_msg_id(self, m: dict) -> str:
399
+ return str(m.get("message_id", "")) or str(m.get("message_seq", ""))
400
+
401
+ def _format_message_line(self, m: dict, self_id: str) -> Text:
402
+ sender = m.get("sender", {})
403
+ sender_name = ""
404
+ sender_uid = ""
405
+ if isinstance(sender, dict):
406
+ sender_name = sender.get("nickname") or sender.get("card") or ""
407
+ sender_uid = str(sender.get("user_id", ""))
408
+
409
+ t = m.get("time", 0)
410
+ time_str = ""
411
+ if t:
412
+ time_str = datetime.fromtimestamp(t).strftime("%H:%M")
413
+ msg = m.get("message", [])
414
+ if isinstance(msg, list):
415
+ content = format_message(msg)
416
+ elif isinstance(msg, str):
417
+ content = msg
418
+ else:
419
+ content = ""
420
+ if not content:
421
+ content = "[media]"
422
+
423
+ is_self = sender_uid == self_id
424
+ prefix_name = "我" if is_self else sender_name
425
+ prefix_style = "green" if is_self else "blue"
426
+
427
+ return Text.assemble(
428
+ (f"[{prefix_name}] ", prefix_style),
429
+ (time_str, ""),
430
+ ("\n", ""),
431
+ (content, ""),
432
+ )
433
+
434
+ def _execute_command(self, cmd: str) -> None:
435
+ self.run_worker(self._execute_command_worker(cmd))
436
+
437
+ async def _execute_command_worker(self, cmd: str) -> None:
438
+ stdout, stderr, _ = await self._app().client.run_napcat_cli(cmd.split())
439
+ output = stdout or stderr or "(no output)"
440
+ rich_log = self.query_one("#messages", RichLog)
441
+ rich_log.write(Text.assemble((f"⚡ {cmd}\n", "bold cyan"), (output, "")), scroll_end=True)
442
+
443
+ def action_scroll_down(self) -> None:
444
+ self.query_one("#messages", RichLog).scroll_down()
445
+
446
+ def action_scroll_up(self) -> None:
447
+ self.query_one("#messages", RichLog).scroll_up()
448
+
449
+ def action_page_up(self) -> None:
450
+ self.query_one("#messages", RichLog).scroll_page_up()
451
+
452
+ def action_page_down(self) -> None:
453
+ self.query_one("#messages", RichLog).scroll_page_down()
454
+
455
+ def _app(self) -> "NapCatApp":
456
+ return self.app # type: ignore[return-value]
@@ -0,0 +1,9 @@
1
+ /* napcat TUI styles — app-wide structural rules ONLY.
2
+ Widget-specific CSS lives in each screen/widget's own CSS block so there is
3
+ a single source of truth (splitting rules across files caused specificity
4
+ conflicts that collapsed the input bar to an invisible 1-row box). */
5
+
6
+ Screen {
7
+ layout: vertical;
8
+ background: $surface;
9
+ }
napcat_cli/wake.py ADDED
@@ -0,0 +1,51 @@
1
+ """Wake-command builder — shared by daemon, orchestrator, and CLI.
2
+
3
+ Renders a shell command template, substituting placeholders with shlex-quoted
4
+ values so the rendered string is safe to pass to ``subprocess.run(..., shell=True)``.
5
+
6
+ Supported placeholders (all shlex-quoted):
7
+ $REASON / ${REASON} / {reason} wake reason (e.g. ``AT_ME``, ``NEW_MESSAGE``)
8
+ {prompt} the prompt text to send to the agent
9
+ {session} the target session name/id
10
+
11
+ Empty template -> ``''``. Empty values render as ``''``.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import shlex
16
+
17
+
18
+ def _q(value: str) -> str:
19
+ """shlex.quote a value; empty -> ''."""
20
+ return shlex.quote(value) if value else "''"
21
+
22
+
23
+ def render_wake_command(
24
+ template: str,
25
+ *,
26
+ reason: str = "",
27
+ prompt: str = "",
28
+ session: str = "",
29
+ ) -> str:
30
+ """Render a wake command template with shlex-quoted placeholders."""
31
+ if not template:
32
+ return ""
33
+ q_reason = _q(reason)
34
+ q_prompt = _q(prompt)
35
+ q_session = _q(session)
36
+ return (
37
+ template.replace("$REASON", q_reason)
38
+ .replace("${REASON}", q_reason)
39
+ .replace("{reason}", q_reason)
40
+ .replace("{prompt}", q_prompt)
41
+ .replace("{session}", q_session)
42
+ )
43
+
44
+
45
+ def build_wake_command(wake_command: str, reason: str) -> str:
46
+ """Back-compat wrapper: render with only $REASON/${REASON}/{reason}.
47
+
48
+ Kept so existing callers (and tests) that only pass a reason keep working;
49
+ new code should call :func:`render_wake_command` with prompt/session.
50
+ """
51
+ return render_wake_command(wake_command, reason=reason)