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,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)
|