@will-17173/telegram-cli 0.1.1 → 0.3.0

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 (111) hide show
  1. package/README.md +169 -10
  2. package/README.zh-CN.md +169 -11
  3. package/dist/cli/app.js +3 -1
  4. package/dist/cli/app.js.map +1 -1
  5. package/dist/commands/account.js +51 -5
  6. package/dist/commands/account.js.map +1 -1
  7. package/dist/commands/config.js +96 -13
  8. package/dist/commands/config.js.map +1 -1
  9. package/dist/commands/group-write.js +88 -0
  10. package/dist/commands/group-write.js.map +1 -0
  11. package/dist/commands/group.js +121 -0
  12. package/dist/commands/group.js.map +1 -0
  13. package/dist/commands/telegram-runner.js +84 -0
  14. package/dist/commands/telegram-runner.js.map +1 -0
  15. package/dist/commands/telegram.js +154 -172
  16. package/dist/commands/telegram.js.map +1 -1
  17. package/dist/config/credential-store.js +76 -22
  18. package/dist/config/credential-store.js.map +1 -1
  19. package/dist/config/env.js +12 -1
  20. package/dist/config/env.js.map +1 -1
  21. package/dist/group-commands/catalog.js +69 -0
  22. package/dist/group-commands/catalog.js.map +1 -0
  23. package/dist/group-commands/executor.js +93 -0
  24. package/dist/group-commands/executor.js.map +1 -0
  25. package/dist/group-commands/parser.js +268 -0
  26. package/dist/group-commands/parser.js.map +1 -0
  27. package/dist/group-commands/tokenize.js +51 -0
  28. package/dist/group-commands/tokenize.js.map +1 -0
  29. package/dist/group-commands/types.js +2 -0
  30. package/dist/group-commands/types.js.map +1 -0
  31. package/dist/listen-commands/catalog.js +61 -0
  32. package/dist/listen-commands/catalog.js.map +1 -0
  33. package/dist/listen-commands/dispatch.js +46 -0
  34. package/dist/listen-commands/dispatch.js.map +1 -0
  35. package/dist/listen-commands/match.js +113 -0
  36. package/dist/listen-commands/match.js.map +1 -0
  37. package/dist/presenters/group.js +130 -0
  38. package/dist/presenters/group.js.map +1 -0
  39. package/dist/presenters/human.js +29 -9
  40. package/dist/presenters/human.js.map +1 -1
  41. package/dist/presenters/ink/display-width.js +45 -7
  42. package/dist/presenters/ink/display-width.js.map +1 -1
  43. package/dist/presenters/ink/group-command-confirm.js +13 -0
  44. package/dist/presenters/ink/group-command-confirm.js.map +1 -0
  45. package/dist/presenters/ink/group-command-result.js +21 -0
  46. package/dist/presenters/ink/group-command-result.js.map +1 -0
  47. package/dist/presenters/ink/listen-command-menu.js +38 -0
  48. package/dist/presenters/ink/listen-command-menu.js.map +1 -0
  49. package/dist/presenters/ink/listen-scroll.js +5 -1
  50. package/dist/presenters/ink/listen-scroll.js.map +1 -1
  51. package/dist/presenters/ink/listen.js +806 -171
  52. package/dist/presenters/ink/listen.js.map +1 -1
  53. package/dist/presenters/ink/use-group-command.js +125 -0
  54. package/dist/presenters/ink/use-group-command.js.map +1 -0
  55. package/dist/presenters/listen-message.js +25 -241
  56. package/dist/presenters/listen-message.js.map +1 -1
  57. package/dist/presenters/logical-message.js +78 -0
  58. package/dist/presenters/logical-message.js.map +1 -0
  59. package/dist/services/attachment-download.js +5 -2
  60. package/dist/services/attachment-download.js.map +1 -1
  61. package/dist/services/auto-download-coordinator.js +214 -0
  62. package/dist/services/auto-download-coordinator.js.map +1 -0
  63. package/dist/services/group-service.js +164 -0
  64. package/dist/services/group-service.js.map +1 -0
  65. package/dist/services/group-write-service.js +165 -0
  66. package/dist/services/group-write-service.js.map +1 -0
  67. package/dist/services/listen-album-aggregator.js +9 -29
  68. package/dist/services/listen-album-aggregator.js.map +1 -1
  69. package/dist/services/listen-attachment.js +265 -0
  70. package/dist/services/listen-attachment.js.map +1 -0
  71. package/dist/services/listen-composer-command.js +102 -0
  72. package/dist/services/listen-composer-command.js.map +1 -0
  73. package/dist/services/listen-reply-resolver.js +126 -0
  74. package/dist/services/listen-reply-resolver.js.map +1 -0
  75. package/dist/services/message-service.js +54 -4
  76. package/dist/services/message-service.js.map +1 -1
  77. package/dist/services/query-service.js +95 -34
  78. package/dist/services/query-service.js.map +1 -1
  79. package/dist/services/reply-context.js +29 -0
  80. package/dist/services/reply-context.js.map +1 -0
  81. package/dist/services/sync-service.js +18 -6
  82. package/dist/services/sync-service.js.map +1 -1
  83. package/dist/storage/message-db.js +241 -5
  84. package/dist/storage/message-db.js.map +1 -1
  85. package/dist/telegram/client-factory.js +4 -2
  86. package/dist/telegram/client-factory.js.map +1 -1
  87. package/dist/telegram/fake-group-management.js +381 -0
  88. package/dist/telegram/fake-group-management.js.map +1 -0
  89. package/dist/telegram/group-types.js +59 -0
  90. package/dist/telegram/group-types.js.map +1 -0
  91. package/dist/telegram/group-write-types.js +2 -0
  92. package/dist/telegram/group-write-types.js.map +1 -0
  93. package/dist/telegram/mtcute-client.js +74 -9
  94. package/dist/telegram/mtcute-client.js.map +1 -1
  95. package/dist/telegram/mtcute-group-helpers.js +56 -0
  96. package/dist/telegram/mtcute-group-helpers.js.map +1 -0
  97. package/dist/telegram/mtcute-group-invites.js +82 -0
  98. package/dist/telegram/mtcute-group-invites.js.map +1 -0
  99. package/dist/telegram/mtcute-group-management.js +493 -0
  100. package/dist/telegram/mtcute-group-management.js.map +1 -0
  101. package/dist/telegram/mtcute-group-members.js +118 -0
  102. package/dist/telegram/mtcute-group-members.js.map +1 -0
  103. package/dist/telegram/mtcute-group-settings.js +146 -0
  104. package/dist/telegram/mtcute-group-settings.js.map +1 -0
  105. package/dist/telegram/mtcute-group-topics.js +63 -0
  106. package/dist/telegram/mtcute-group-topics.js.map +1 -0
  107. package/dist/telegram/proxy.js +27 -0
  108. package/dist/telegram/proxy.js.map +1 -0
  109. package/dist/telegram/raw-message.js +46 -0
  110. package/dist/telegram/raw-message.js.map +1 -0
  111. package/package.json +9 -1
@@ -7,11 +7,26 @@ import { homedir } from 'node:os';
7
7
  import { dirname } from 'node:path';
8
8
  import { resolveAttachmentDestination } from '../../services/attachment-download.js';
9
9
  import { ListenAlbumAggregator } from '../../services/listen-album-aggregator.js';
10
+ import { AutoDownloadCoordinator } from '../../services/auto-download-coordinator.js';
11
+ import { attachmentDownloadTarget, attachmentFileName, discoverListenAttachments, listenAttachmentKey } from '../../services/listen-attachment.js';
10
12
  import { buildListenMessage } from '../listen-message.js';
11
- import { applyMessageArrival, applyScroll, takeListenViewport } from './listen-scroll.js';
13
+ import { applyMessageArrival, takeListenViewport } from './listen-scroll.js';
12
14
  import { decodeImagePreview } from './image-preview.js';
13
15
  import { ListenScrollbar, calculateScrollbar, listenContentWidth, useTransientScrollbar } from './listen-scrollbar.js';
14
- import { isMouseInput, useMouseScroll, withMouseReporting } from './mouse-scroll.js';
16
+ import { DISABLE_MOUSE_REPORTING, isMouseInput } from './mouse-scroll.js';
17
+ import { createListenReplyResolver } from '../../services/listen-reply-resolver.js';
18
+ import { formatReplyContext } from '../../services/reply-context.js';
19
+ import { executeListenReply, parseListenComposerInput } from '../../services/listen-composer-command.js';
20
+ import { ListenCommandMenu, listenCommandMenuAvailability, moveListenCommandSelectionEnabled } from './listen-command-menu.js';
21
+ import { completeListenCommand, visibleListenCommandMatches } from '../../listen-commands/match.js';
22
+ import { parseSelectedListenCommand } from '../../listen-commands/dispatch.js';
23
+ import { GroupCommandResult } from './group-command-result.js';
24
+ import { useGroupCommand } from './use-group-command.js';
25
+ import { executeGroupCommand } from '../../group-commands/executor.js';
26
+ import { GroupWriteService } from '../../services/group-write-service.js';
27
+ import { ADMIN_RIGHT_KEYS } from '../../services/group-write-service.js';
28
+ import { GroupCommandConfirm } from './group-command-confirm.js';
29
+ import { truncateCell } from './display-width.js';
15
30
  const MESSAGE_SEPARATOR = '────────────────────────────────────────────';
16
31
  /** Maximum number of grouped messages retained by a long-running interactive listener. */
17
32
  export const LISTEN_HISTORY_LIMIT = 500;
@@ -54,24 +69,315 @@ export function ListenComposer({ input, sendTargetLabel, terminalWidth, sending
54
69
  export function ListenAttachmentLine({ label, selected, state }) {
55
70
  const action = state.status === 'idle'
56
71
  ? '󰇚 Download'
57
- : state.status === 'downloading'
58
- ? `Downloading${state.progress == null ? '...' : ` ${state.progress}%`}`
59
- : state.status === 'completed'
60
- ? state.path
61
- : `Failed: ${state.error}`;
72
+ : state.status === 'queued'
73
+ ? 'Queued'
74
+ : state.status === 'downloading'
75
+ ? `Downloading${state.progress == null ? '...' : ` ${state.progress}%`}`
76
+ : state.status === 'completed'
77
+ ? state.path
78
+ : `Failed: ${state.error}`;
62
79
  return (_jsxs(Text, { wrap: "truncate-end", backgroundColor: selected ? '#3b5368' : undefined, color: selected ? 'white' : undefined, children: [selected ? '› ' : ' ', label, " [", action, "]"] }));
63
80
  }
81
+ export function applyAutoDownloadEvent(current, event, showMedia = true) {
82
+ if (!showMedia)
83
+ return Object.keys(current).length === 0 ? current : {};
84
+ if (event.status === 'cancelled') {
85
+ if (!(event.key in current))
86
+ return current;
87
+ const next = { ...current };
88
+ delete next[event.key];
89
+ return next;
90
+ }
91
+ const state = event.status === 'queued'
92
+ ? { status: 'queued' }
93
+ : event.status === 'downloading'
94
+ ? { status: 'downloading', progress: event.progress }
95
+ : event.status === 'completed'
96
+ ? { status: 'completed', path: event.path }
97
+ : { status: 'failed', error: event.error };
98
+ return { ...current, [event.key]: state };
99
+ }
100
+ export function canManuallyDownload(state) {
101
+ return state.status === 'idle' || state.status === 'failed';
102
+ }
103
+ export function createInteractiveOperationController() {
104
+ let generation = 0;
105
+ let sequence = 0;
106
+ let sendOperation = 0;
107
+ const downloadOperations = new Map();
108
+ return {
109
+ beginGeneration() {
110
+ const ownedGeneration = ++generation;
111
+ let active = true;
112
+ return {
113
+ isActive: () => active && generation === ownedGeneration,
114
+ dispose: () => {
115
+ active = false;
116
+ if (generation === ownedGeneration) {
117
+ generation += 1;
118
+ downloadOperations.clear();
119
+ }
120
+ },
121
+ };
122
+ },
123
+ beginSend() {
124
+ const ownedGeneration = generation;
125
+ const operation = ++sendOperation;
126
+ return () => generation === ownedGeneration && sendOperation === operation;
127
+ },
128
+ beginDownload(key) {
129
+ const ownedGeneration = generation;
130
+ const operation = ++sequence;
131
+ downloadOperations.set(key, operation);
132
+ return {
133
+ isCurrent: () => generation === ownedGeneration && downloadOperations.get(key) === operation,
134
+ release: () => {
135
+ if (downloadOperations.get(key) === operation)
136
+ downloadOperations.delete(key);
137
+ },
138
+ };
139
+ },
140
+ claimDownload(key) {
141
+ const operation = ++sequence;
142
+ downloadOperations.set(key, operation);
143
+ return {
144
+ release: () => {
145
+ if (downloadOperations.get(key) === operation)
146
+ downloadOperations.delete(key);
147
+ },
148
+ };
149
+ },
150
+ downloadOwnershipSize: () => downloadOperations.size,
151
+ };
152
+ }
153
+ export async function runOwnedAttachmentOperation(ownership, operation, onError) {
154
+ try {
155
+ await operation();
156
+ }
157
+ catch (error) {
158
+ if (ownership.isCurrent())
159
+ onError(error);
160
+ }
161
+ finally {
162
+ ownership.release();
163
+ }
164
+ }
165
+ export function pruneAttachmentDownloadStates(current, validKeys, pendingKeys = new Set()) {
166
+ const retained = Object.fromEntries(Object.entries(current).filter(([key, state]) => (validKeys.has(key) || pendingKeys.has(key) || state.status === 'queued' || state.status === 'downloading')));
167
+ return Object.keys(retained).length === Object.keys(current).length ? current : retained;
168
+ }
169
+ export function registerPendingAttachmentKeys(pendingKeys, message, showMedia) {
170
+ if (!showMedia) {
171
+ pendingKeys.clear();
172
+ return;
173
+ }
174
+ discoverListenAttachments(message).forEach((attachment, index) => {
175
+ if (attachment.downloadable)
176
+ pendingKeys.add(listenAttachmentKey(attachment, index));
177
+ });
178
+ }
179
+ export function acceptListenMessage(message, seen, seenOrder, emit) {
180
+ const key = `${message.chat_id}:${message.msg_id}`;
181
+ if (seen.has(key))
182
+ return false;
183
+ seen.add(key);
184
+ seenOrder.push(key);
185
+ if (seen.size > 5000) {
186
+ const oldest = seenOrder.shift();
187
+ if (oldest != null)
188
+ seen.delete(oldest);
189
+ }
190
+ emit(message);
191
+ return true;
192
+ }
193
+ export async function runInteractiveAutoDownloadLifecycle(options) {
194
+ const coordinator = options.autoDownload
195
+ ? (options.createCoordinator ?? (() => new AutoDownloadCoordinator()))()
196
+ : null;
197
+ options.onCoordinator?.(coordinator);
198
+ let currentClient = null;
199
+ const abort = () => {
200
+ coordinator?.stop();
201
+ void currentClient?.close().catch(() => undefined);
202
+ };
203
+ options.signal.addEventListener('abort', abort);
204
+ try {
205
+ while (!options.signal.aborted) {
206
+ options.onStatus?.('connecting');
207
+ const client = options.createClient();
208
+ currentClient = client;
209
+ options.onClient?.(client);
210
+ coordinator?.setClient(client);
211
+ let retry = false;
212
+ try {
213
+ const result = await client.listen({
214
+ chats: options.chats,
215
+ signal: options.signal,
216
+ onConnected: () => options.onStatus?.('connected'),
217
+ onMessage: (message) => {
218
+ if (options.signal.aborted)
219
+ return;
220
+ if (options.acceptMessage?.(message) === false)
221
+ return;
222
+ options.onBeforeEnqueue?.(message);
223
+ coordinator?.enqueue(message);
224
+ options.onMessage(message);
225
+ },
226
+ });
227
+ if (options.persist && result === 'disconnected') {
228
+ retry = true;
229
+ options.onStatus?.('disconnected');
230
+ }
231
+ else {
232
+ options.onStatus?.('stopped');
233
+ }
234
+ }
235
+ catch (error) {
236
+ if (options.persist && !options.signal.aborted)
237
+ retry = true;
238
+ options.onError?.(error);
239
+ }
240
+ finally {
241
+ options.flush?.();
242
+ if (retry) {
243
+ coordinator?.setClient(null);
244
+ await coordinator?.waitForActive();
245
+ }
246
+ else if (!options.signal.aborted) {
247
+ await coordinator?.waitForIdle();
248
+ }
249
+ else {
250
+ coordinator?.stop();
251
+ }
252
+ await client.close().catch(() => undefined);
253
+ if (options.signal.aborted)
254
+ await coordinator?.waitForActive();
255
+ if (currentClient === client)
256
+ currentClient = null;
257
+ options.onClient?.(null);
258
+ }
259
+ if (!retry)
260
+ break;
261
+ await (options.sleep ?? sleep)(options.retrySeconds);
262
+ }
263
+ }
264
+ finally {
265
+ options.signal.removeEventListener('abort', abort);
266
+ options.onCoordinator?.(null);
267
+ }
268
+ }
269
+ export function attachmentDownloadKeyAt(attachments, index) {
270
+ const attachment = attachments[index];
271
+ if (attachment == null)
272
+ throw new Error(`Missing attachment at index ${index}`);
273
+ const sourceIndex = attachments.slice(0, index).filter((candidate) => (candidate.chatId === attachment.chatId && candidate.messageId === attachment.messageId)).length;
274
+ return listenAttachmentKey(attachment, sourceIndex);
275
+ }
64
276
  export function ListenImagePreview({ rows }) {
65
277
  return (_jsx(Box, { flexDirection: "column", children: rows.map((row, rowIndex) => (_jsxs(Text, { children: [" ", row.map((cell, cellIndex) => (_jsx(Text, { color: cell.foreground, backgroundColor: cell.background, children: cell.glyph }, cellIndex)))] }, rowIndex))) }));
66
278
  }
67
- export function ListenAttachmentWithPreview({ label, selected, state, previewCells, }) {
68
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ListenAttachmentLine, { label: label, selected: selected, state: state }), previewCells == null ? null : _jsx(ListenImagePreview, { rows: previewCells })] }));
279
+ export function ListenAttachmentWithPreview({ label, downloadable = true, selected, state, previewCells, }) {
280
+ return (_jsxs(Box, { flexDirection: "column", children: [downloadable
281
+ ? _jsx(ListenAttachmentLine, { label: label, selected: selected, state: state })
282
+ : _jsxs(Text, { wrap: "truncate-end", children: [" ", label] }), previewCells == null ? null : _jsx(ListenImagePreview, { rows: previewCells })] }));
283
+ }
284
+ export function ListenMessageBody({ message }) {
285
+ return (_jsxs(Box, { flexDirection: "column", children: [message.replyContext == null ? null : _jsx(Text, { wrap: "truncate-end", children: formatReplyContext(message.replyContext) }), message.content == null ? null : _jsx(Text, { wrap: "truncate-end", children: message.content }), message.mediaSummary == null ? null : _jsx(Text, { wrap: "truncate-end", children: message.mediaSummary }), message.media.map((item, mediaIndex) => (_jsx(ListenAttachmentWithPreview, { label: item.label, downloadable: item.downloadable, selected: false, state: { status: 'idle' }, previewCells: item.previewCells }, attachmentDownloadKeyAt(message.media, mediaIndex))))] }));
286
+ }
287
+ export function createInteractiveListenGroupQueue(options) {
288
+ const pending = [];
289
+ const schedule = options.schedule ?? ((run) => setImmediate(run));
290
+ let scheduled = null;
291
+ let resolveScheduled = null;
292
+ let closing = false;
293
+ let closed = false;
294
+ let closePromise = null;
295
+ const closeResolver = async () => {
296
+ if (closed)
297
+ return;
298
+ closed = true;
299
+ if (options.resolver.closeAsync != null)
300
+ await options.resolver.closeAsync();
301
+ else
302
+ options.resolver.close();
303
+ };
304
+ const drain = async () => {
305
+ try {
306
+ while (pending.length > 0) {
307
+ const messages = pending.shift();
308
+ try {
309
+ const replyContext = options.resolver.resolveAsync == null
310
+ ? options.resolver.resolve(messages)
311
+ : await options.resolver.resolveAsync(messages);
312
+ options.resolver.remember(messages);
313
+ const first = messages[0];
314
+ if (first != null && options.isActive()) {
315
+ options.onGroup({ key: `${first.chat_id}:${first.msg_id}`, messages, replyContext });
316
+ }
317
+ }
318
+ catch (error) {
319
+ options.onError(error);
320
+ pending.length = 0;
321
+ break;
322
+ }
323
+ }
324
+ }
325
+ finally {
326
+ resolveScheduled?.();
327
+ resolveScheduled = null;
328
+ scheduled = null;
329
+ }
330
+ };
331
+ return {
332
+ enqueue(messages) {
333
+ if (closing)
334
+ return;
335
+ pending.push(messages);
336
+ if (scheduled != null)
337
+ return;
338
+ scheduled = new Promise((resolve) => { resolveScheduled = resolve; });
339
+ schedule(() => { void drain(); });
340
+ },
341
+ close() {
342
+ closePromise ??= (async () => {
343
+ closing = true;
344
+ const drainCompletion = scheduled;
345
+ if (drainCompletion != null)
346
+ await drainCompletion;
347
+ await closeResolver();
348
+ })();
349
+ return closePromise;
350
+ },
351
+ };
352
+ }
353
+ export function createInteractiveListenRuntime(dbPath, factory, options) {
354
+ return createInteractiveListenGroupQueue({
355
+ ...options,
356
+ resolver: factory(dbPath, LISTEN_HISTORY_LIMIT),
357
+ });
69
358
  }
70
359
  export function ListenStatus({ status, unseenCount }) {
71
360
  return _jsxs(Text, { dimColor: true, children: [status, unseenCount > 0 ? ` · ↓ ${unseenCount} new messages` : ''] });
72
361
  }
362
+ export function formatInteractiveListenSender(message) {
363
+ const sender = message.senderId == null || message.sender === String(message.senderId)
364
+ ? message.sender
365
+ : `${message.sender} (${message.senderId})`;
366
+ return message.chatName == null ? sender : `${message.chatName} | ${sender}`;
367
+ }
368
+ export function formatInteractiveListenHeader(message) {
369
+ return `[${message.time}] #${message.msgId} ${formatInteractiveListenSender(message)}`;
370
+ }
371
+ export function ListenStatusArea({ status, unseenCount, autoDownload, }) {
372
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ListenStatus, { status: status, unseenCount: unseenCount }), autoDownload ? _jsx(Text, { dimColor: true, children: "Auto-download enabled" }) : null] }));
373
+ }
374
+ export function calculateListenMessagePaneHeight(terminalHeight, hasNote, autoDownload) {
375
+ const reservedLines = 7 + (hasNote ? 1 : 0) + (autoDownload ? 1 : 0);
376
+ return Math.max(2, terminalHeight - reservedLines);
377
+ }
73
378
  export async function runInteractiveListen(write, run) {
74
- return withMouseReporting({ write, run });
379
+ write(DISABLE_MOUSE_REPORTING);
380
+ return run();
75
381
  }
76
382
  export async function renderInteractiveListen(options) {
77
383
  await runInteractiveListen(process.stdout.write.bind(process.stdout), async () => {
@@ -79,7 +385,7 @@ export async function renderInteractiveListen(options) {
79
385
  await app.waitUntilExit();
80
386
  });
81
387
  }
82
- function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, showChatName, createClient, stopSignal, onRequestStop, }) {
388
+ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo, showMedia, autoDownload, showChatName, createClient, stopSignal, onRequestStop, createReplyResolver, }) {
83
389
  const { exit } = useApp();
84
390
  const { stdout } = useStdout();
85
391
  const terminalMetrics = useTerminalMetrics(stdout);
@@ -93,42 +399,97 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, sh
93
399
  const [downloadStates, setDownloadStates] = useState({});
94
400
  const [scrollState, setScrollState] = useState({ offset: 0, unseenCount: 0 });
95
401
  const [sendTargetLabel, setSendTargetLabel] = useState(sendTo == null ? '' : buildSendTargetLabel(sendTo));
402
+ const [knownGroup, setKnownGroup] = useState(undefined);
403
+ const clientRef = useRef(null);
404
+ const replyExecutionLockRef = useRef(false);
405
+ const inputGenerationRef = useRef(0);
406
+ const commandSelectionRef = useRef(0);
407
+ const knownGroupRef = useRef(undefined);
408
+ const groupLookupGenerationRef = useRef(0);
409
+ useEffect(() => {
410
+ groupLookupGenerationRef.current++;
411
+ knownGroupRef.current = undefined;
412
+ setKnownGroup(undefined);
413
+ }, [sendTo]);
414
+ const groupCommand = useGroupCommand(useCallback(async (request, options) => {
415
+ const client = clientRef.current;
416
+ if (client == null)
417
+ return { ok: false, error: { code: 'connection_not_ready', message: 'Telegram connection is not ready.' } };
418
+ if (sendTo == null)
419
+ return { ok: false, error: { code: 'ambiguous_chat', message: 'Select exactly one target chat with --send-to.' } };
420
+ let knownGroup = knownGroupRef.current;
421
+ if (knownGroup == null) {
422
+ const lookup = ++groupLookupGenerationRef.current;
423
+ knownGroup = await client.groups.getGroup(sendTo);
424
+ if (lookup === groupLookupGenerationRef.current && clientRef.current === client) {
425
+ knownGroupRef.current = knownGroup;
426
+ setKnownGroup(knownGroup);
427
+ }
428
+ }
429
+ return executeGroupCommand(request, {
430
+ chat: sendTo,
431
+ groups: new GroupWriteService(client.groups),
432
+ confirmed: options?.confirmed ?? false,
433
+ confirmationTitle: options?.confirmationTitle,
434
+ knownGroup,
435
+ connectionReady: true,
436
+ targetAvailable: true,
437
+ targetCount: 1,
438
+ invalidateGroup: async () => {
439
+ knownGroupRef.current = undefined;
440
+ setKnownGroup(undefined);
441
+ if (request.key === 'chat delete' || request.key === 'chat leave')
442
+ return;
443
+ const lookup = ++groupLookupGenerationRef.current;
444
+ try {
445
+ const refreshed = await client.groups.getGroup(sendTo);
446
+ if (lookup === groupLookupGenerationRef.current && clientRef.current === client) {
447
+ knownGroupRef.current = refreshed;
448
+ setKnownGroup(refreshed);
449
+ }
450
+ }
451
+ catch (error) {
452
+ if (lookup === groupLookupGenerationRef.current && clientRef.current === client)
453
+ setNote(`Group updated; refresh failed: ${messageFromError(error)}`);
454
+ }
455
+ },
456
+ });
457
+ }, [sendTo]));
96
458
  const terminalWidth = terminalMetrics.columns;
97
459
  const terminalHeight = terminalMetrics.rows;
98
460
  const previewColorDepth = interactiveListenPreviewColorDepth(terminalMetrics.colorDepth);
99
461
  const contentWidth = listenContentWidth(terminalWidth);
100
462
  const previewWidth = Math.max(1, Math.min(24, contentWidth - 2));
463
+ const renderContextRef = useRef({ showMedia, previewWidth, colorDepth: previewColorDepth, showChatName });
464
+ renderContextRef.current = { showMedia, previewWidth, colorDepth: previewColorDepth, showChatName };
101
465
  const messageViewCacheRef = useRef(null);
102
466
  if (messageViewCacheRef.current == null)
103
467
  messageViewCacheRef.current = new ListenMessageViewCache();
104
- const messages = useMemo(() => messageViewCacheRef.current.build(messageGroups, {
105
- showMedia,
106
- previewWidth,
107
- colorDepth: previewColorDepth,
108
- showChatName,
109
- }), [messageGroups, showMedia, previewWidth, previewColorDepth, showChatName]);
110
- const reservedLines = 7 + (note ? 1 : 0);
111
- const messagePaneHeight = Math.max(2, terminalHeight - reservedLines);
468
+ const messages = useMemo(() => messageViewCacheRef.current.build(messageGroups, renderContextRef.current), [messageGroups, showMedia, previewWidth, previewColorDepth, showChatName]);
469
+ const messagePaneHeight = calculateListenMessagePaneHeight(terminalHeight, note.length > 0, autoDownload);
112
470
  const visibleMessages = takeListenViewport(messages, messagePaneHeight, scrollState.offset);
113
- const clientRef = useRef(null);
471
+ const closeGroupCommand = () => {
472
+ commandSelectionRef.current = 0;
473
+ groupCommand.close();
474
+ };
114
475
  const albumAggregatorRef = useRef(null);
476
+ const autoDownloaderRef = useRef(null);
477
+ const pendingAttachmentKeysRef = useRef(new Set());
478
+ const operationControllerRef = useRef(null);
479
+ if (operationControllerRef.current == null)
480
+ operationControllerRef.current = createInteractiveOperationController();
115
481
  const stoppingRef = useRef(false);
116
482
  const seenRef = useRef(new Set());
117
483
  const seenOrderRef = useRef([]);
118
- const downloadableAttachments = collectAttachments(visibleMessages);
484
+ const downloadableAttachments = collectDownloadableAttachments(visibleMessages);
119
485
  const selectedAttachment = downloadableAttachments[selectedAttachmentIndex] ?? downloadableAttachments[0];
120
- const { visible: scrollbarVisible, show: showScrollbar } = useTransientScrollbar();
486
+ const { visible: scrollbarVisible } = useTransientScrollbar();
121
487
  const scrollbarGeometry = calculateScrollbar({
122
488
  height: terminalHeight,
123
489
  total: messages.length,
124
490
  visible: visibleMessages.length,
125
491
  offset: scrollState.offset,
126
492
  });
127
- const handleMouseScroll = useCallback((direction) => {
128
- showScrollbar();
129
- setScrollState((current) => applyScroll(current, direction, Math.max(0, messages.length - 1)));
130
- }, [messages.length, showScrollbar]);
131
- useMouseScroll(handleMouseScroll);
132
493
  useEffect(() => {
133
494
  const maxOffset = Math.max(0, messages.length - 1);
134
495
  setScrollState((current) => {
@@ -138,11 +499,12 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, sh
138
499
  ? current
139
500
  : { offset, unseenCount };
140
501
  });
141
- const validAttachmentKeys = new Set(collectAttachments(messages).map((item) => item.key));
142
- setDownloadStates((current) => {
143
- const retained = Object.fromEntries(Object.entries(current).filter(([key]) => validAttachmentKeys.has(key)));
144
- return Object.keys(retained).length === Object.keys(current).length ? current : retained;
145
- });
502
+ const validAttachmentKeys = new Set(collectDownloadableAttachments(messages).map((item) => item.key));
503
+ if (!showMedia)
504
+ pendingAttachmentKeysRef.current.clear();
505
+ setDownloadStates((current) => pruneAttachmentDownloadStates(current, validAttachmentKeys, pendingAttachmentKeysRef.current));
506
+ for (const key of validAttachmentKeys)
507
+ pendingAttachmentKeysRef.current.delete(key);
146
508
  setSelectedAttachmentIndex((current) => Math.min(current, Math.max(0, downloadableAttachments.length - 1)));
147
509
  if (downloadableAttachments.length === 0)
148
510
  setFocus('input');
@@ -154,6 +516,139 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, sh
154
516
  stopListening();
155
517
  return;
156
518
  }
519
+ const modal = groupCommand.state;
520
+ if (modal.kind === 'confirm') {
521
+ if (key.escape) {
522
+ closeGroupCommand();
523
+ return;
524
+ }
525
+ if (key.upArrow || key.downArrow) {
526
+ groupCommand.setState({ ...modal, selectedIndex: modal.selectedIndex === 0 ? 1 : 0 });
527
+ return;
528
+ }
529
+ if (key.return) {
530
+ if (modal.selectedIndex === 0)
531
+ void groupCommand.runConfirmed(modal.request);
532
+ else
533
+ closeGroupCommand();
534
+ }
535
+ return;
536
+ }
537
+ if (modal.kind === 'confirm-title') {
538
+ if (key.escape) {
539
+ if (modal.stage === 'title')
540
+ groupCommand.setState({ ...modal, stage: 'confirm', confirmText: '', mismatch: false });
541
+ else
542
+ closeGroupCommand();
543
+ return;
544
+ }
545
+ if (modal.stage === 'confirm') {
546
+ if (key.upArrow || key.downArrow)
547
+ groupCommand.setState({ ...modal, selectedIndex: modal.selectedIndex === 0 ? 1 : 0 });
548
+ else if (key.return)
549
+ modal.selectedIndex === 0 ? groupCommand.setState({ ...modal, stage: 'title' }) : closeGroupCommand();
550
+ return;
551
+ }
552
+ if (key.return) {
553
+ const client = clientRef.current;
554
+ if (client == null || sendTo == null) {
555
+ groupCommand.setState({ kind: 'error', message: 'Telegram connection is not ready.' });
556
+ }
557
+ else {
558
+ const submittedTitle = modal.confirmText;
559
+ const lookup = ++groupLookupGenerationRef.current;
560
+ void client.groups.getGroup(sendTo).then((fresh) => {
561
+ if (lookup !== groupLookupGenerationRef.current || clientRef.current !== client)
562
+ return;
563
+ knownGroupRef.current = fresh;
564
+ setKnownGroup(fresh);
565
+ if (fresh.title === submittedTitle)
566
+ void groupCommand.runConfirmed(modal.request, submittedTitle);
567
+ else if ('confirmation' in modal.pending)
568
+ groupCommand.setState({
569
+ ...modal,
570
+ pending: { ...modal.pending, confirmation: { ...modal.pending.confirmation, title: fresh.title, target: fresh.title } },
571
+ mismatch: true,
572
+ });
573
+ }).catch((error) => {
574
+ if (lookup === groupLookupGenerationRef.current && clientRef.current === client)
575
+ groupCommand.setState({ kind: 'error', message: messageFromError(error) });
576
+ });
577
+ }
578
+ }
579
+ else if (key.backspace || key.delete)
580
+ groupCommand.setState({ ...modal, confirmText: modal.confirmText.slice(0, -1), mismatch: false });
581
+ else if (!key.ctrl && !key.meta && inputText)
582
+ groupCommand.setState({ ...modal, confirmText: modal.confirmText + inputText, mismatch: false });
583
+ return;
584
+ }
585
+ if (modal.kind === 'select-permissions') {
586
+ if (key.escape) {
587
+ closeGroupCommand();
588
+ return;
589
+ }
590
+ if (key.upArrow || key.downArrow) {
591
+ const delta = key.upArrow ? -1 : 1;
592
+ groupCommand.setState({ ...modal, selectedIndex: (modal.selectedIndex + delta + ADMIN_RIGHT_KEYS.length) % ADMIN_RIGHT_KEYS.length });
593
+ }
594
+ else if (inputText === ' ') {
595
+ const right = ADMIN_RIGHT_KEYS[modal.selectedIndex];
596
+ groupCommand.setState({ ...modal, selected: modal.selected.includes(right) ? modal.selected.filter(item => item !== right) : [...modal.selected, right], warning: undefined });
597
+ }
598
+ else if (key.return) {
599
+ if (modal.selected.length === 0)
600
+ groupCommand.setState({ ...modal, warning: 'Select at least one permission.' });
601
+ else {
602
+ const request = Object.freeze({ ...modal.request, values: Object.freeze({ ...modal.request.values, permissions: Object.freeze([...modal.selected]) }) });
603
+ void (async () => {
604
+ const result = await groupCommand.submit(`${modal.originalInput} ${modal.selected.join(',')}`, 0);
605
+ if (result.kind === 'pending' && 'confirmation' in result.pending)
606
+ groupCommand.setState({ kind: 'confirm', pending: result.pending, request, originalInput: modal.originalInput, selectedIndex: 1 });
607
+ })();
608
+ }
609
+ }
610
+ return;
611
+ }
612
+ if (groupCommand.state.kind === 'result') {
613
+ if (key.escape)
614
+ closeGroupCommand();
615
+ return;
616
+ }
617
+ if (key.escape && groupCommand.state.kind === 'error') {
618
+ closeGroupCommand();
619
+ return;
620
+ }
621
+ const slashMode = input.trimStart().startsWith('/');
622
+ if (slashMode && key.escape) {
623
+ if (replyExecutionLockRef.current) {
624
+ inputGenerationRef.current++;
625
+ setSending(false);
626
+ }
627
+ closeGroupCommand();
628
+ return;
629
+ }
630
+ if (slashMode && (key.upArrow || key.downArrow)) {
631
+ const count = visibleListenCommandMatches(input).length;
632
+ const disabled = listenCommandMenuAvailability(input, knownGroup).map(Boolean);
633
+ const selectedIndex = moveListenCommandSelectionEnabled(commandSelectionRef.current, key.upArrow ? -1 : 1, disabled.slice(0, count));
634
+ commandSelectionRef.current = selectedIndex;
635
+ groupCommand.setState({ kind: 'menu', selectedIndex });
636
+ return;
637
+ }
638
+ if (slashMode && key.tab) {
639
+ const selected = commandSelectionRef.current;
640
+ const failure = listenCommandMenuAvailability(input, knownGroup)[selected];
641
+ if (failure && 'error' in failure) {
642
+ setNote(failure.error.message);
643
+ return;
644
+ }
645
+ inputGenerationRef.current++;
646
+ setInput(completeListenCommand(input, selected));
647
+ commandSelectionRef.current = 0;
648
+ groupCommand.setState({ kind: 'menu', selectedIndex: 0 });
649
+ setFocus('input');
650
+ return;
651
+ }
157
652
  if (key.tab) {
158
653
  if (focus === 'attachments') {
159
654
  setFocus('input');
@@ -181,17 +676,92 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, sh
181
676
  return;
182
677
  }
183
678
  if (key.return && selectedAttachment != null) {
184
- void downloadAttachment(selectedAttachment);
679
+ const state = downloadStates[selectedAttachment.key] ?? { status: 'idle' };
680
+ if (canManuallyDownload(state))
681
+ void downloadAttachment(selectedAttachment);
185
682
  }
186
683
  return;
187
684
  }
188
- if (sending)
685
+ if (sending || groupCommand.state.kind === 'executing')
189
686
  return;
190
687
  if (key.return) {
688
+ if (slashMode) {
689
+ const selected = commandSelectionRef.current;
690
+ const failure = listenCommandMenuAvailability(input, knownGroup)[selected];
691
+ if (failure && 'error' in failure) {
692
+ setNote(failure.error.message);
693
+ return;
694
+ }
695
+ const match = visibleListenCommandMatches(input)[selected];
696
+ if (!match) {
697
+ setNote('No matching command.');
698
+ return;
699
+ }
700
+ const parsed = parseSelectedListenCommand(input, match);
701
+ if (parsed.kind === 'complete') {
702
+ inputGenerationRef.current++;
703
+ setInput(parsed.input);
704
+ commandSelectionRef.current = 0;
705
+ groupCommand.setState({ kind: 'menu', selectedIndex: 0 });
706
+ return;
707
+ }
708
+ if (parsed.kind === 'error') {
709
+ setNote(parsed.usage == null ? parsed.message : `${parsed.message} · usage: /${parsed.usage}`);
710
+ return;
711
+ }
712
+ if (parsed.kind === 'reply') {
713
+ if (replyExecutionLockRef.current)
714
+ return;
715
+ if (sendTo == null) {
716
+ setNote('set --send-to before replying');
717
+ return;
718
+ }
719
+ const client = clientRef.current;
720
+ if (client == null) {
721
+ setNote('connection is not ready');
722
+ return;
723
+ }
724
+ replyExecutionLockRef.current = true;
725
+ const ownedGeneration = inputGenerationRef.current;
726
+ const originalInput = input;
727
+ setSending(true);
728
+ setNote('sending...');
729
+ void executeListenReply(client, sendTo, parsed.command).then((sentMessages) => {
730
+ if (ownedGeneration !== inputGenerationRef.current)
731
+ return;
732
+ for (const sentMessage of sentMessages)
733
+ acceptListenMessage(sentMessage, seenRef.current, seenOrderRef.current, (message) => {
734
+ registerPendingAttachmentKeys(pendingAttachmentKeysRef.current, message, showMedia);
735
+ autoDownloaderRef.current?.enqueue(message);
736
+ albumAggregatorRef.current?.add(message);
737
+ });
738
+ setInput('');
739
+ setNote(`replied to #${parsed.command.reply}`);
740
+ }).catch((error) => {
741
+ if (ownedGeneration === inputGenerationRef.current) {
742
+ setInput(originalInput);
743
+ setNote(`send failed: ${messageFromError(error)}`);
744
+ }
745
+ }).finally(() => {
746
+ replyExecutionLockRef.current = false;
747
+ if (ownedGeneration === inputGenerationRef.current)
748
+ setSending(false);
749
+ });
750
+ return;
751
+ }
752
+ void groupCommand.submitParsed(parsed.request, input, selected).then((outcome) => {
753
+ if (!outcome.applied)
754
+ return;
755
+ if (outcome.kind === 'result' && outcome.result.ok)
756
+ setInput('');
757
+ });
758
+ return;
759
+ }
191
760
  void sendMessage(input);
192
761
  return;
193
762
  }
194
763
  if (key.backspace || key.delete) {
764
+ inputGenerationRef.current++;
195
765
  setInput((current) => current.slice(0, -1));
196
766
  return;
197
767
  }
@@ -199,10 +769,21 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, sh
199
769
  return;
200
770
  }
201
771
  if (!key.ctrl && !key.meta && inputText.length > 0) {
202
- setInput((current) => current + inputText);
772
+ inputGenerationRef.current++;
773
+ setInput((current) => {
774
+ const next = current + inputText;
775
+ if (next.trimStart().startsWith('/')) {
776
+ setFocus('input');
777
+ commandSelectionRef.current = 0;
778
+ groupCommand.setState({ kind: 'menu', selectedIndex: 0 });
779
+ }
780
+ return next;
781
+ });
203
782
  }
204
783
  });
205
784
  useEffect(() => {
785
+ const generation = operationControllerRef.current.beginGeneration();
786
+ const isActive = generation.isActive;
206
787
  if (stopSignal.aborted) {
207
788
  exit();
208
789
  return;
@@ -211,105 +792,149 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, sh
211
792
  stopListening();
212
793
  };
213
794
  stopSignal.addEventListener('abort', stopFromSignal);
795
+ const groupQueue = createInteractiveListenRuntime(dbPath, createReplyResolver ?? createListenReplyResolver, {
796
+ isActive,
797
+ onGroup: (group) => {
798
+ setScrollState((current) => applyMessageArrival(current));
799
+ setMessageGroups((current) => [...current, group].slice(-LISTEN_HISTORY_LIMIT));
800
+ },
801
+ onError: (error) => {
802
+ if (!isActive())
803
+ return;
804
+ setStatus(`listen failed: ${messageFromError(error)}`);
805
+ onRequestStop();
806
+ exit();
807
+ },
808
+ });
214
809
  const albumAggregator = new ListenAlbumAggregator({
215
810
  emit: (group) => {
216
- setScrollState((current) => applyMessageArrival(current));
217
- setMessageGroups((current) => pruneListenMessageGroups([...current, group]).groups);
811
+ if (!isActive())
812
+ return;
813
+ groupQueue.enqueue(group);
218
814
  },
219
815
  });
220
816
  albumAggregatorRef.current = albumAggregator;
221
- const run = async () => {
222
- try {
223
- while (true) {
224
- setStatus('connecting...');
225
- const client = createClient();
226
- let retry = false;
227
- clientRef.current = client;
228
- if (sendTo != null) {
229
- void resolveSendTargetLabel(client, sendTo).then((label) => {
230
- if (label != null)
231
- setSendTargetLabel(label);
232
- });
817
+ const autoDownloader = autoDownload
818
+ ? new AutoDownloadCoordinator({
819
+ onEvent: (event) => {
820
+ if (!isActive())
821
+ return;
822
+ const ownership = operationControllerRef.current.claimDownload(event.key);
823
+ setDownloadStates((current) => applyAutoDownloadEvent(current, event, showMedia));
824
+ if (event.status === 'completed' || event.status === 'failed' || event.status === 'cancelled') {
825
+ ownership.release();
233
826
  }
234
- try {
235
- const result = await client.listen({
236
- chats,
237
- signal: stopSignal,
238
- onConnected: () => setStatus('connected'),
239
- onMessage: (message) => {
240
- const key = `${message.chat_id}:${message.msg_id}`;
241
- if (seenRef.current.has(key))
242
- return;
243
- seenRef.current.add(key);
244
- seenOrderRef.current.push(key);
245
- if (seenRef.current.size > 5000) {
246
- const oldest = seenOrderRef.current.shift();
247
- if (oldest != null)
248
- seenRef.current.delete(oldest);
249
- }
250
- if (sendTo != null) {
251
- const inferred = inferSendTargetLabel(sendTo, message);
252
- if (inferred != null)
253
- setSendTargetLabel(inferred);
254
- }
255
- albumAggregator.add(message);
256
- },
257
- });
258
- if (!persist || result === 'stopped') {
259
- setStatus('stopped');
260
- break;
261
- }
262
- if (result === 'disconnected') {
263
- if (!persist) {
264
- setStatus('stopped');
265
- break;
266
- }
267
- setStatus(`disconnected, retry in ${retrySeconds}s...`);
268
- retry = true;
269
- }
270
- }
271
- catch (error) {
272
- if (!persist) {
273
- setStatus(`listen failed: ${messageFromError(error)}`);
274
- break;
827
+ },
828
+ })
829
+ : null;
830
+ autoDownloaderRef.current = autoDownloader;
831
+ void runInteractiveAutoDownloadLifecycle({
832
+ autoDownload,
833
+ chats,
834
+ persist,
835
+ retrySeconds,
836
+ signal: stopSignal,
837
+ createClient,
838
+ createCoordinator: () => autoDownloader,
839
+ onCoordinator: (coordinator) => {
840
+ if (isActive())
841
+ autoDownloaderRef.current = coordinator;
842
+ },
843
+ onClient: (client) => {
844
+ if (!isActive())
845
+ return;
846
+ groupLookupGenerationRef.current++;
847
+ knownGroupRef.current = undefined;
848
+ setKnownGroup(undefined);
849
+ clientRef.current = client;
850
+ if (client != null && sendTo != null) {
851
+ const lookup = ++groupLookupGenerationRef.current;
852
+ void client.groups.getGroup(sendTo).then((group) => {
853
+ if (isActive() && lookup === groupLookupGenerationRef.current && clientRef.current === client) {
854
+ knownGroupRef.current = group;
855
+ setKnownGroup(group);
275
856
  }
276
- setNote(`listen failed: ${messageFromError(error)}`);
277
- retry = true;
278
- }
279
- finally {
280
- albumAggregator.flush();
281
- await client.close().catch(() => undefined);
282
- if (clientRef.current === client)
283
- clientRef.current = null;
284
- }
285
- if (retry) {
286
- await sleep(retrySeconds);
287
- continue;
288
- }
289
- break;
857
+ }).catch(() => undefined);
858
+ void resolveSendTargetLabel(client, sendTo).then((label) => {
859
+ if (isActive() && label != null)
860
+ setSendTargetLabel(label);
861
+ });
290
862
  }
291
- }
292
- finally {
293
- if (!stopSignal.aborted) {
294
- onRequestStop();
295
- exit();
863
+ },
864
+ acceptMessage: (message) => {
865
+ if (!isActive())
866
+ return false;
867
+ return acceptListenMessage(message, seenRef.current, seenOrderRef.current, () => undefined);
868
+ },
869
+ onBeforeEnqueue: (message) => {
870
+ if (!isActive())
871
+ return;
872
+ registerPendingAttachmentKeys(pendingAttachmentKeysRef.current, message, showMedia);
873
+ },
874
+ onMessage: (message) => {
875
+ if (!isActive())
876
+ return;
877
+ if (sendTo != null) {
878
+ const inferred = inferSendTargetLabel(sendTo, message);
879
+ if (inferred != null)
880
+ setSendTargetLabel(inferred);
296
881
  }
882
+ albumAggregator.add(message);
883
+ },
884
+ onStatus: (next) => {
885
+ if (!isActive())
886
+ return;
887
+ setStatus(next === 'connecting'
888
+ ? 'connecting...'
889
+ : next === 'disconnected'
890
+ ? `disconnected, retry in ${retrySeconds}s...`
891
+ : next);
892
+ },
893
+ onError: (error) => {
894
+ if (!isActive())
895
+ return;
896
+ if (persist)
897
+ setNote(`listen failed: ${messageFromError(error)}`);
898
+ else
899
+ setStatus(`listen failed: ${messageFromError(error)}`);
900
+ },
901
+ flush: () => {
902
+ if (isActive())
903
+ albumAggregator.flush();
904
+ },
905
+ }).finally(() => {
906
+ if (isActive() && !stopSignal.aborted) {
907
+ onRequestStop();
908
+ exit();
297
909
  }
298
- };
299
- void run();
910
+ });
300
911
  return () => {
912
+ groupLookupGenerationRef.current += 1;
913
+ knownGroupRef.current = undefined;
301
914
  stopSignal.removeEventListener('abort', stopFromSignal);
915
+ albumAggregator.flush();
916
+ generation.dispose();
302
917
  albumAggregator.dispose();
918
+ void groupQueue.close();
303
919
  if (albumAggregatorRef.current === albumAggregator)
304
920
  albumAggregatorRef.current = null;
305
921
  void clientRef.current?.close().catch(() => undefined);
306
922
  clientRef.current = null;
923
+ autoDownloader?.stop();
924
+ void autoDownloader?.waitForActive();
925
+ if (autoDownloaderRef.current === autoDownloader)
926
+ autoDownloaderRef.current = null;
307
927
  };
308
- }, [chats, createClient, persist, retrySeconds, sendTo, showMedia, exit, stopSignal, onRequestStop]);
928
+ }, [autoDownload, chats, createClient, createReplyResolver, dbPath, persist, retrySeconds, sendTo, showMedia, exit, stopSignal, onRequestStop]);
309
929
  const sendMessage = async (text) => {
310
930
  const trimmed = text.trim();
311
931
  if (!trimmed)
312
932
  return;
933
+ const command = parseListenComposerInput(trimmed);
934
+ if (command.kind === 'error') {
935
+ setNote(command.error);
936
+ return;
937
+ }
313
938
  if (sendTo == null) {
314
939
  setNote('set --send-to before sending');
315
940
  return;
@@ -319,51 +944,68 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, sh
319
944
  setNote('connection is not ready');
320
945
  return;
321
946
  }
947
+ const isCurrent = operationControllerRef.current.beginSend();
322
948
  setInput('');
323
949
  setSending(true);
324
950
  setNote('sending...');
325
951
  try {
326
- await client.sendMessage({
327
- chat: sendTo,
328
- message: trimmed,
329
- linkPreview: true,
330
- });
331
- setNote('sent');
952
+ const sentMessages = command.kind === 'reply'
953
+ ? await executeListenReply(client, sendTo, command)
954
+ : [await client.sendMessage({
955
+ chat: sendTo,
956
+ message: command.content,
957
+ linkPreview: true,
958
+ })].flatMap(({ sent_message: message }) => message == null ? [] : [message]);
959
+ if (isCurrent()) {
960
+ for (const sentMessage of sentMessages)
961
+ acceptListenMessage(sentMessage, seenRef.current, seenOrderRef.current, (message) => {
962
+ registerPendingAttachmentKeys(pendingAttachmentKeysRef.current, message, showMedia);
963
+ autoDownloaderRef.current?.enqueue(message);
964
+ albumAggregatorRef.current?.add(message);
965
+ });
966
+ }
967
+ if (isCurrent())
968
+ setNote(command.kind === 'reply' ? `replied to #${command.reply}` : 'sent');
332
969
  }
333
970
  catch (error) {
334
- setNote(`send failed: ${messageFromError(error)}`);
971
+ if (isCurrent())
972
+ setNote(`send failed: ${messageFromError(error)}`);
335
973
  }
336
974
  finally {
337
- setSending(false);
975
+ if (isCurrent())
976
+ setSending(false);
338
977
  }
339
978
  };
340
979
  const downloadAttachment = async (item) => {
341
- const client = clientRef.current;
342
- if (!client) {
343
- setDownloadStates((current) => ({ ...current, [item.key]: { status: 'failed', error: 'not connected' } }));
344
- return;
345
- }
346
- const destination = resolveAttachmentDestination({
347
- homeDir: homedir(),
348
- fileName: attachmentFileName(item),
349
- exists: existsSync,
350
- });
351
- mkdirSync(dirname(destination), { recursive: true });
352
- setDownloadStates((current) => ({ ...current, [item.key]: { status: 'downloading', progress: 0 } }));
353
- try {
980
+ const ownership = operationControllerRef.current.beginDownload(item.key);
981
+ const isCurrent = ownership.isCurrent;
982
+ await runOwnedAttachmentOperation(ownership, async () => {
983
+ const client = clientRef.current;
984
+ if (!client)
985
+ throw new Error('not connected');
986
+ const destination = resolveAttachmentDestination({
987
+ homeDir: homedir(),
988
+ fileName: attachmentFileName(item.attachment),
989
+ exists: existsSync,
990
+ });
991
+ mkdirSync(dirname(destination), { recursive: true });
992
+ if (isCurrent())
993
+ setDownloadStates((current) => ({ ...current, [item.key]: { status: 'downloading', progress: 0 } }));
354
994
  await client.downloadMessageMedia({
355
995
  ...attachmentDownloadTarget(item.attachment),
356
996
  destination,
357
997
  onProgress: (downloaded, total) => {
998
+ if (!isCurrent())
999
+ return;
358
1000
  const progress = Number.isFinite(total) && total > 0 ? Math.round(downloaded / total * 100) : null;
359
1001
  setDownloadStates((current) => ({ ...current, [item.key]: { status: 'downloading', progress } }));
360
1002
  },
361
1003
  });
362
- setDownloadStates((current) => ({ ...current, [item.key]: { status: 'completed', path: destination } }));
363
- }
364
- catch (error) {
1004
+ if (isCurrent())
1005
+ setDownloadStates((current) => ({ ...current, [item.key]: { status: 'completed', path: destination } }));
1006
+ }, (error) => {
365
1007
  setDownloadStates((current) => ({ ...current, [item.key]: { status: 'failed', error: messageFromError(error) } }));
366
- }
1008
+ });
367
1009
  };
368
1010
  const stopListening = () => {
369
1011
  if (stoppingRef.current)
@@ -376,12 +1018,16 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, sh
376
1018
  else
377
1019
  setTimeout(exit, 0);
378
1020
  onRequestStop();
1021
+ autoDownloaderRef.current?.stop();
379
1022
  clientRef.current?.close().catch(() => undefined);
380
1023
  };
381
- return (_jsxs(Box, { flexDirection: "row", width: terminalWidth, height: terminalHeight, overflow: "hidden", children: [_jsxs(Box, { flexDirection: "column", width: contentWidth, height: terminalHeight, overflow: "hidden", children: [_jsx(ListenStatus, { status: status, unseenCount: scrollState.unseenCount }), note ? _jsx(Text, { dimColor: true, children: note }) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", flexGrow: 1, overflow: "hidden", children: [messages.length === 0 ? _jsx(Text, { dimColor: true, children: "Waiting for new messages..." }) : null, visibleMessages.map((message) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["[", message.time, "] ", message.chatName == null ? message.sender : `${message.chatName} | ${message.sender}`] }), message.content == null ? null : _jsx(Text, { wrap: "truncate-end", children: message.content }), message.media.map((item, mediaIndex) => {
382
- const attachmentKey = `${message.key}:${mediaIndex}`;
383
- return (_jsx(ListenAttachmentWithPreview, { label: item.label, selected: focus === 'attachments' && selectedAttachment?.key === attachmentKey, state: downloadStates[attachmentKey] ?? { status: 'idle' }, previewCells: item.previewCells }, attachmentKey));
384
- }), _jsx(Text, { dimColor: true, children: MESSAGE_SEPARATOR })] }, message.key)))] }), sendTo == null ? (_jsx(Text, { dimColor: true, children: "Set --send-to <chat> (or pass one chat to listen) before sending messages." })) : (_jsx(Box, { marginTop: 1, flexDirection: "column", flexShrink: 0, children: _jsx(ListenComposer, { input: input, sendTargetLabel: sendTargetLabel, terminalWidth: contentWidth, sending: sending, hint: focus === 'attachments' ? '↑/↓ select · Enter download · Tab input' : 'Enter to send · Tab attachments · Ctrl+C exit' }) }))] }), _jsx(ListenScrollbar, { height: terminalHeight, visible: scrollbarVisible, geometry: scrollbarGeometry })] }));
1024
+ const permissionState = groupCommand.state.kind === 'select-permissions' ? groupCommand.state : null;
1025
+ return (_jsxs(Box, { flexDirection: "row", width: terminalWidth, height: terminalHeight, overflow: "hidden", children: [_jsxs(Box, { flexDirection: "column", width: contentWidth, height: terminalHeight, overflow: "hidden", children: [_jsx(ListenStatusArea, { status: status, unseenCount: scrollState.unseenCount, autoDownload: autoDownload }), note ? _jsx(Text, { dimColor: true, children: note }) : null, groupCommand.state.kind === 'result' ? _jsx(Box, { flexGrow: 1, flexDirection: "column", children: _jsx(GroupCommandResult, { state: groupCommand.state, width: contentWidth }) }) : _jsxs(Box, { flexGrow: 1, flexDirection: "column", children: [_jsx(GroupCommandResult, { state: groupCommand.state, width: contentWidth }), groupCommand.state.kind === 'confirm' && 'confirmation' in groupCommand.state.pending ? _jsx(GroupCommandConfirm, { confirmation: groupCommand.state.pending.confirmation, selectedIndex: groupCommand.state.selectedIndex, width: contentWidth }) : null, groupCommand.state.kind === 'confirm-title' && 'confirmation' in groupCommand.state.pending ? groupCommand.state.stage === 'confirm'
1026
+ ? _jsx(GroupCommandConfirm, { confirmation: groupCommand.state.pending.confirmation, selectedIndex: groupCommand.state.selectedIndex, width: contentWidth })
1027
+ : _jsxs(Box, { flexDirection: "column", width: contentWidth, children: [_jsx(Text, { color: "yellow", children: truncateCell('Type the exact title to permanently delete this chat:', contentWidth) }), _jsx(Text, { children: truncateCell(groupCommand.state.pending.confirmation.title ?? '', contentWidth) }), _jsx(Text, { color: "#8ecbff", children: truncateCell(`› ${groupCommand.state.confirmText}`, contentWidth) }), groupCommand.state.mismatch ? _jsx(Text, { color: "red", children: truncateCell('Title does not match exactly.', contentWidth) }) : null, _jsx(Text, { dimColor: true, children: truncateCell('Enter verify · Esc back', contentWidth) })] }) : null, permissionState ? _jsxs(Box, { flexDirection: "column", width: contentWidth, children: [_jsx(Text, { color: "#8ecbff", children: truncateCell('Administrator permissions', contentWidth) }), ADMIN_RIGHT_KEYS.map((right, index) => _jsx(Text, { color: index === permissionState.selectedIndex ? '#8ecbff' : undefined, children: truncateCell(`${index === permissionState.selectedIndex ? '' : ' '}[${permissionState.selected.includes(right) ? 'x' : ' '}] ${right}`, contentWidth) }, right)), permissionState.warning ? _jsx(Text, { color: "yellow", children: truncateCell(permissionState.warning, contentWidth) }) : null, _jsx(Text, { dimColor: true, children: truncateCell('↑/↓ select · Space toggle · Enter continue · Esc cancel', contentWidth) })] }) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", flexGrow: 1, overflow: "hidden", children: [messages.length === 0 ? _jsx(Text, { dimColor: true, children: "Waiting for new messages..." }) : null, visibleMessages.map((message) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, wrap: "truncate-end", children: formatInteractiveListenHeader(message) }), message.replyContext == null ? null : _jsx(Text, { wrap: "truncate-end", children: formatReplyContext(message.replyContext) }), message.content == null ? null : _jsx(Text, { wrap: "truncate-end", children: message.content }), message.mediaSummary == null ? null : _jsx(Text, { wrap: "truncate-end", children: message.mediaSummary }), message.media.map((item, mediaIndex) => {
1028
+ const attachmentKey = attachmentDownloadKeyAt(message.media, mediaIndex);
1029
+ return (_jsx(ListenAttachmentWithPreview, { label: item.label, downloadable: item.downloadable, selected: focus === 'attachments' && selectedAttachment?.key === attachmentKey, state: downloadStates[attachmentKey] ?? { status: 'idle' }, previewCells: item.previewCells }, attachmentKey));
1030
+ }), _jsx(Text, { dimColor: true, children: MESSAGE_SEPARATOR })] }, message.key)))] })] }), groupCommand.state.kind !== 'result' ? _jsxs(Box, { flexDirection: "column", children: [sendTo == null ? _jsx(Text, { dimColor: true, children: "Set --send-to <chat> (or pass one chat to listen) before sending messages." }) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", flexShrink: 0, children: [input.trimStart().startsWith('/') && groupCommand.state.kind === 'menu' ? _jsx(ListenCommandMenu, { input: input, selectedIndex: groupCommand.state.selectedIndex, width: contentWidth, knownGroup: knownGroup }) : null, _jsx(ListenComposer, { input: input, sendTargetLabel: sendTo == null ? '(not selected)' : sendTargetLabel, terminalWidth: contentWidth, sending: sending, hint: focus === 'attachments' ? '↑/↓ select · Enter download · Tab input' : 'Enter send · /reply <id> ... · Tab attachments · Ctrl+C exit' })] })] }) : null] }), _jsx(ListenScrollbar, { height: terminalHeight, visible: scrollbarVisible, geometry: scrollbarGeometry })] }));
385
1031
  }
386
1032
  export class ListenMessageViewCache {
387
1033
  entries = new Map();
@@ -391,13 +1037,16 @@ export class ListenMessageViewCache {
391
1037
  build(groups, context) {
392
1038
  const previewWidth = normalizedPreviewWidth(context.previewWidth);
393
1039
  const nextEntries = new Map();
394
- const messages = groups.map((group) => {
1040
+ const messages = groups.map((input) => {
1041
+ const group = Array.isArray(input) ? input : input.messages;
1042
+ const replyContext = Array.isArray(input) ? context.replyContext : input.replyContext;
395
1043
  const first = group[0];
396
1044
  if (first == null)
397
1045
  throw new Error('Cannot render an empty listen message group');
398
- const key = `${first.chat_id}:${first.msg_id}`;
1046
+ const key = Array.isArray(input) ? `${first.chat_id}:${first.msg_id}` : input.key;
399
1047
  const cached = this.entries.get(key);
400
1048
  if (cached?.group === group
1049
+ && cached.replyContext === replyContext
401
1050
  && cached.showMedia === context.showMedia
402
1051
  && cached.previewWidth === previewWidth
403
1052
  && cached.colorDepth === context.colorDepth
@@ -405,9 +1054,10 @@ export class ListenMessageViewCache {
405
1054
  nextEntries.set(key, cached);
406
1055
  return cached.message;
407
1056
  }
408
- const message = toListenMessage(group, { ...context, previewWidth });
1057
+ const message = toListenMessage(group, { ...context, previewWidth, replyContext });
409
1058
  nextEntries.set(key, {
410
1059
  group,
1060
+ replyContext,
411
1061
  showMedia: context.showMedia,
412
1062
  previewWidth,
413
1063
  colorDepth: context.colorDepth,
@@ -427,7 +1077,7 @@ export function pruneListenMessageGroups(groups, limit = LISTEN_HISTORY_LIMIT) {
427
1077
  return {
428
1078
  groups: groups.slice(removeCount),
429
1079
  removedKeys: removed.flatMap((group) => {
430
- const first = group[0];
1080
+ const first = Array.isArray(group) ? group[0] : group.messages[0];
431
1081
  return first == null ? [] : [`${first.chat_id}:${first.msg_id}`];
432
1082
  }),
433
1083
  };
@@ -439,8 +1089,8 @@ export function toListenMessage(messages, context) {
439
1089
  const renderContext = typeof context === 'boolean'
440
1090
  ? { showMedia: context, previewWidth: 1, colorDepth: 1, showChatName: false }
441
1091
  : context;
442
- const { showMedia, previewWidth, colorDepth, showChatName = false } = renderContext;
443
- const formatted = buildListenMessage(messages, { showMedia, showChatName });
1092
+ const { showMedia, previewWidth, colorDepth, showChatName = false, replyContext } = renderContext;
1093
+ const formatted = buildListenMessage(messages, { showMedia, showChatName, replyContext });
444
1094
  const decodePreview = renderContext.decodePreview ?? decodeImagePreview;
445
1095
  const media = formatted.media.map((attachment) => {
446
1096
  if (attachment.previewJpegBase64 == null || colorDepth < 24)
@@ -461,35 +1111,20 @@ export function toListenMessage(messages, context) {
461
1111
  function normalizedPreviewWidth(previewWidth) {
462
1112
  return Math.max(1, Math.min(24, previewWidth));
463
1113
  }
464
- function collectAttachments(messages) {
465
- return messages.flatMap((message) => message.media.map((attachment, index) => ({
466
- key: `${message.key}:${index}`,
467
- message,
468
- attachment,
469
- })));
470
- }
471
- function attachmentFileName(item) {
472
- if (item.attachment.fileName != null)
473
- return item.attachment.fileName;
474
- const extension = MEDIA_EXTENSIONS[item.attachment.kind] ?? 'bin';
475
- return `${item.attachment.chatId}-${item.attachment.messageId}.${extension}`;
476
- }
477
- export function attachmentDownloadTarget(attachment) {
478
- return { chat: attachment.chatId, msgId: attachment.messageId };
1114
+ export function collectDownloadableAttachments(messages) {
1115
+ return messages.flatMap((message) => message.media.flatMap((attachment, index) => (attachment.downloadable === true
1116
+ ? [{
1117
+ key: attachmentDownloadKeyAt(message.media, index),
1118
+ message,
1119
+ attachment,
1120
+ }]
1121
+ : [])));
479
1122
  }
1123
+ export { attachmentDownloadTarget } from '../../services/listen-attachment.js';
480
1124
  export function flushListenBeforeExit(aggregator, exit) {
481
1125
  aggregator.flush();
482
1126
  setTimeout(exit, 0);
483
1127
  }
484
- const MEDIA_EXTENSIONS = {
485
- Photo: 'jpg',
486
- Video: 'mp4',
487
- Audio: 'mp3',
488
- Voice: 'ogg',
489
- Sticker: 'webp',
490
- Animation: 'mp4',
491
- Document: 'bin',
492
- };
493
1128
  function messageFromError(error) {
494
1129
  return error instanceof Error ? error.message : String(error);
495
1130
  }