@will-17173/telegram-cli 0.1.0 → 0.2.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 (54) hide show
  1. package/README.md +109 -6
  2. package/README.zh-CN.md +110 -7
  3. package/dist/cli/app.js +3 -1
  4. package/dist/cli/app.js.map +1 -1
  5. package/dist/commands/account.js +3 -1
  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.js +107 -0
  10. package/dist/commands/group.js.map +1 -0
  11. package/dist/commands/telegram-runner.js +84 -0
  12. package/dist/commands/telegram-runner.js.map +1 -0
  13. package/dist/commands/telegram.js +98 -130
  14. package/dist/commands/telegram.js.map +1 -1
  15. package/dist/config/credential-store.js +76 -22
  16. package/dist/config/credential-store.js.map +1 -1
  17. package/dist/config/env.js +12 -1
  18. package/dist/config/env.js.map +1 -1
  19. package/dist/index.js +0 -0
  20. package/dist/presenters/group.js +106 -0
  21. package/dist/presenters/group.js.map +1 -0
  22. package/dist/presenters/human.js +7 -9
  23. package/dist/presenters/human.js.map +1 -1
  24. package/dist/presenters/ink/listen.js +381 -145
  25. package/dist/presenters/ink/listen.js.map +1 -1
  26. package/dist/presenters/listen-message.js +6 -237
  27. package/dist/presenters/listen-message.js.map +1 -1
  28. package/dist/services/attachment-download.js +5 -2
  29. package/dist/services/attachment-download.js.map +1 -1
  30. package/dist/services/auto-download-coordinator.js +214 -0
  31. package/dist/services/auto-download-coordinator.js.map +1 -0
  32. package/dist/services/group-service.js +164 -0
  33. package/dist/services/group-service.js.map +1 -0
  34. package/dist/services/listen-attachment.js +248 -0
  35. package/dist/services/listen-attachment.js.map +1 -0
  36. package/dist/services/message-service.js +54 -4
  37. package/dist/services/message-service.js.map +1 -1
  38. package/dist/services/query-service.js +37 -32
  39. package/dist/services/query-service.js.map +1 -1
  40. package/dist/services/sync-service.js +18 -6
  41. package/dist/services/sync-service.js.map +1 -1
  42. package/dist/telegram/client-factory.js +4 -2
  43. package/dist/telegram/client-factory.js.map +1 -1
  44. package/dist/telegram/fake-group-management.js +264 -0
  45. package/dist/telegram/fake-group-management.js.map +1 -0
  46. package/dist/telegram/group-types.js +19 -0
  47. package/dist/telegram/group-types.js.map +1 -0
  48. package/dist/telegram/mtcute-client.js +73 -8
  49. package/dist/telegram/mtcute-client.js.map +1 -1
  50. package/dist/telegram/mtcute-group-management.js +464 -0
  51. package/dist/telegram/mtcute-group-management.js.map +1 -0
  52. package/dist/telegram/proxy.js +27 -0
  53. package/dist/telegram/proxy.js.map +1 -0
  54. package/package.json +10 -12
@@ -7,6 +7,8 @@ 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
13
  import { applyMessageArrival, applyScroll, takeListenViewport } from './listen-scroll.js';
12
14
  import { decodeImagePreview } from './image-preview.js';
@@ -54,22 +56,234 @@ export function ListenComposer({ input, sendTargetLabel, terminalWidth, sending
54
56
  export function ListenAttachmentLine({ label, selected, state }) {
55
57
  const action = state.status === 'idle'
56
58
  ? '󰇚 Download'
57
- : state.status === 'downloading'
58
- ? `Downloading${state.progress == null ? '...' : ` ${state.progress}%`}`
59
- : state.status === 'completed'
60
- ? state.path
61
- : `Failed: ${state.error}`;
59
+ : state.status === 'queued'
60
+ ? 'Queued'
61
+ : state.status === 'downloading'
62
+ ? `Downloading${state.progress == null ? '...' : ` ${state.progress}%`}`
63
+ : state.status === 'completed'
64
+ ? state.path
65
+ : `Failed: ${state.error}`;
62
66
  return (_jsxs(Text, { wrap: "truncate-end", backgroundColor: selected ? '#3b5368' : undefined, color: selected ? 'white' : undefined, children: [selected ? '› ' : ' ', label, " [", action, "]"] }));
63
67
  }
68
+ export function applyAutoDownloadEvent(current, event, showMedia = true) {
69
+ if (!showMedia)
70
+ return Object.keys(current).length === 0 ? current : {};
71
+ if (event.status === 'cancelled') {
72
+ if (!(event.key in current))
73
+ return current;
74
+ const next = { ...current };
75
+ delete next[event.key];
76
+ return next;
77
+ }
78
+ const state = event.status === 'queued'
79
+ ? { status: 'queued' }
80
+ : event.status === 'downloading'
81
+ ? { status: 'downloading', progress: event.progress }
82
+ : event.status === 'completed'
83
+ ? { status: 'completed', path: event.path }
84
+ : { status: 'failed', error: event.error };
85
+ return { ...current, [event.key]: state };
86
+ }
87
+ export function canManuallyDownload(state) {
88
+ return state.status === 'idle' || state.status === 'failed';
89
+ }
90
+ export function createInteractiveOperationController() {
91
+ let generation = 0;
92
+ let sequence = 0;
93
+ let sendOperation = 0;
94
+ const downloadOperations = new Map();
95
+ return {
96
+ beginGeneration() {
97
+ const ownedGeneration = ++generation;
98
+ let active = true;
99
+ return {
100
+ isActive: () => active && generation === ownedGeneration,
101
+ dispose: () => {
102
+ active = false;
103
+ if (generation === ownedGeneration) {
104
+ generation += 1;
105
+ downloadOperations.clear();
106
+ }
107
+ },
108
+ };
109
+ },
110
+ beginSend() {
111
+ const ownedGeneration = generation;
112
+ const operation = ++sendOperation;
113
+ return () => generation === ownedGeneration && sendOperation === operation;
114
+ },
115
+ beginDownload(key) {
116
+ const ownedGeneration = generation;
117
+ const operation = ++sequence;
118
+ downloadOperations.set(key, operation);
119
+ return {
120
+ isCurrent: () => generation === ownedGeneration && downloadOperations.get(key) === operation,
121
+ release: () => {
122
+ if (downloadOperations.get(key) === operation)
123
+ downloadOperations.delete(key);
124
+ },
125
+ };
126
+ },
127
+ claimDownload(key) {
128
+ const operation = ++sequence;
129
+ downloadOperations.set(key, operation);
130
+ return {
131
+ release: () => {
132
+ if (downloadOperations.get(key) === operation)
133
+ downloadOperations.delete(key);
134
+ },
135
+ };
136
+ },
137
+ downloadOwnershipSize: () => downloadOperations.size,
138
+ };
139
+ }
140
+ export async function runOwnedAttachmentOperation(ownership, operation, onError) {
141
+ try {
142
+ await operation();
143
+ }
144
+ catch (error) {
145
+ if (ownership.isCurrent())
146
+ onError(error);
147
+ }
148
+ finally {
149
+ ownership.release();
150
+ }
151
+ }
152
+ export function pruneAttachmentDownloadStates(current, validKeys, pendingKeys = new Set()) {
153
+ const retained = Object.fromEntries(Object.entries(current).filter(([key, state]) => (validKeys.has(key) || pendingKeys.has(key) || state.status === 'queued' || state.status === 'downloading')));
154
+ return Object.keys(retained).length === Object.keys(current).length ? current : retained;
155
+ }
156
+ export function registerPendingAttachmentKeys(pendingKeys, message, showMedia) {
157
+ if (!showMedia) {
158
+ pendingKeys.clear();
159
+ return;
160
+ }
161
+ discoverListenAttachments(message).forEach((attachment, index) => {
162
+ if (attachment.downloadable)
163
+ pendingKeys.add(listenAttachmentKey(attachment, index));
164
+ });
165
+ }
166
+ export function acceptListenMessage(message, seen, seenOrder, emit) {
167
+ const key = `${message.chat_id}:${message.msg_id}`;
168
+ if (seen.has(key))
169
+ return false;
170
+ seen.add(key);
171
+ seenOrder.push(key);
172
+ if (seen.size > 5000) {
173
+ const oldest = seenOrder.shift();
174
+ if (oldest != null)
175
+ seen.delete(oldest);
176
+ }
177
+ emit(message);
178
+ return true;
179
+ }
180
+ export async function runInteractiveAutoDownloadLifecycle(options) {
181
+ const coordinator = options.autoDownload
182
+ ? (options.createCoordinator ?? (() => new AutoDownloadCoordinator()))()
183
+ : null;
184
+ options.onCoordinator?.(coordinator);
185
+ let currentClient = null;
186
+ const abort = () => {
187
+ coordinator?.stop();
188
+ void currentClient?.close().catch(() => undefined);
189
+ };
190
+ options.signal.addEventListener('abort', abort);
191
+ try {
192
+ while (!options.signal.aborted) {
193
+ options.onStatus?.('connecting');
194
+ const client = options.createClient();
195
+ currentClient = client;
196
+ options.onClient?.(client);
197
+ coordinator?.setClient(client);
198
+ let retry = false;
199
+ try {
200
+ const result = await client.listen({
201
+ chats: options.chats,
202
+ signal: options.signal,
203
+ onConnected: () => options.onStatus?.('connected'),
204
+ onMessage: (message) => {
205
+ if (options.signal.aborted)
206
+ return;
207
+ if (options.acceptMessage?.(message) === false)
208
+ return;
209
+ options.onBeforeEnqueue?.(message);
210
+ coordinator?.enqueue(message);
211
+ options.onMessage(message);
212
+ },
213
+ });
214
+ if (options.persist && result === 'disconnected') {
215
+ retry = true;
216
+ options.onStatus?.('disconnected');
217
+ }
218
+ else {
219
+ options.onStatus?.('stopped');
220
+ }
221
+ }
222
+ catch (error) {
223
+ if (options.persist && !options.signal.aborted)
224
+ retry = true;
225
+ options.onError?.(error);
226
+ }
227
+ finally {
228
+ options.flush?.();
229
+ if (retry) {
230
+ coordinator?.setClient(null);
231
+ await coordinator?.waitForActive();
232
+ }
233
+ else if (!options.signal.aborted) {
234
+ await coordinator?.waitForIdle();
235
+ }
236
+ else {
237
+ coordinator?.stop();
238
+ }
239
+ await client.close().catch(() => undefined);
240
+ if (options.signal.aborted)
241
+ await coordinator?.waitForActive();
242
+ if (currentClient === client)
243
+ currentClient = null;
244
+ options.onClient?.(null);
245
+ }
246
+ if (!retry)
247
+ break;
248
+ await (options.sleep ?? sleep)(options.retrySeconds);
249
+ }
250
+ }
251
+ finally {
252
+ options.signal.removeEventListener('abort', abort);
253
+ options.onCoordinator?.(null);
254
+ }
255
+ }
256
+ export function attachmentDownloadKeyAt(attachments, index) {
257
+ const attachment = attachments[index];
258
+ if (attachment == null)
259
+ throw new Error(`Missing attachment at index ${index}`);
260
+ const sourceIndex = attachments.slice(0, index).filter((candidate) => (candidate.chatId === attachment.chatId && candidate.messageId === attachment.messageId)).length;
261
+ return listenAttachmentKey(attachment, sourceIndex);
262
+ }
64
263
  export function ListenImagePreview({ rows }) {
65
264
  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
265
  }
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 })] }));
266
+ export function ListenAttachmentWithPreview({ label, downloadable = true, selected, state, previewCells, }) {
267
+ return (_jsxs(Box, { flexDirection: "column", children: [downloadable
268
+ ? _jsx(ListenAttachmentLine, { label: label, selected: selected, state: state })
269
+ : _jsxs(Text, { wrap: "truncate-end", children: [" ", label] }), previewCells == null ? null : _jsx(ListenImagePreview, { rows: previewCells })] }));
69
270
  }
70
271
  export function ListenStatus({ status, unseenCount }) {
71
272
  return _jsxs(Text, { dimColor: true, children: [status, unseenCount > 0 ? ` · ↓ ${unseenCount} new messages` : ''] });
72
273
  }
274
+ export function formatInteractiveListenSender(message) {
275
+ const sender = message.senderId == null || message.sender === String(message.senderId)
276
+ ? message.sender
277
+ : `${message.sender} (${message.senderId})`;
278
+ return message.chatName == null ? sender : `${message.chatName} | ${sender}`;
279
+ }
280
+ export function ListenStatusArea({ status, unseenCount, autoDownload, }) {
281
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ListenStatus, { status: status, unseenCount: unseenCount }), autoDownload ? _jsx(Text, { dimColor: true, children: "Auto-download enabled" }) : null] }));
282
+ }
283
+ export function calculateListenMessagePaneHeight(terminalHeight, hasNote, autoDownload) {
284
+ const reservedLines = 7 + (hasNote ? 1 : 0) + (autoDownload ? 1 : 0);
285
+ return Math.max(2, terminalHeight - reservedLines);
286
+ }
73
287
  export async function runInteractiveListen(write, run) {
74
288
  return withMouseReporting({ write, run });
75
289
  }
@@ -79,7 +293,7 @@ export async function renderInteractiveListen(options) {
79
293
  await app.waitUntilExit();
80
294
  });
81
295
  }
82
- function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, createClient, stopSignal, onRequestStop, }) {
296
+ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, autoDownload, showChatName, createClient, stopSignal, onRequestStop, }) {
83
297
  const { exit } = useApp();
84
298
  const { stdout } = useStdout();
85
299
  const terminalMetrics = useTerminalMetrics(stdout);
@@ -105,16 +319,21 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, cr
105
319
  showMedia,
106
320
  previewWidth,
107
321
  colorDepth: previewColorDepth,
108
- }), [messageGroups, showMedia, previewWidth, previewColorDepth]);
109
- const reservedLines = 7 + (note ? 1 : 0);
110
- const messagePaneHeight = Math.max(2, terminalHeight - reservedLines);
322
+ showChatName,
323
+ }), [messageGroups, showMedia, previewWidth, previewColorDepth, showChatName]);
324
+ const messagePaneHeight = calculateListenMessagePaneHeight(terminalHeight, note.length > 0, autoDownload);
111
325
  const visibleMessages = takeListenViewport(messages, messagePaneHeight, scrollState.offset);
112
326
  const clientRef = useRef(null);
113
327
  const albumAggregatorRef = useRef(null);
328
+ const autoDownloaderRef = useRef(null);
329
+ const pendingAttachmentKeysRef = useRef(new Set());
330
+ const operationControllerRef = useRef(null);
331
+ if (operationControllerRef.current == null)
332
+ operationControllerRef.current = createInteractiveOperationController();
114
333
  const stoppingRef = useRef(false);
115
334
  const seenRef = useRef(new Set());
116
335
  const seenOrderRef = useRef([]);
117
- const downloadableAttachments = collectAttachments(visibleMessages);
336
+ const downloadableAttachments = collectDownloadableAttachments(visibleMessages);
118
337
  const selectedAttachment = downloadableAttachments[selectedAttachmentIndex] ?? downloadableAttachments[0];
119
338
  const { visible: scrollbarVisible, show: showScrollbar } = useTransientScrollbar();
120
339
  const scrollbarGeometry = calculateScrollbar({
@@ -137,11 +356,12 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, cr
137
356
  ? current
138
357
  : { offset, unseenCount };
139
358
  });
140
- const validAttachmentKeys = new Set(collectAttachments(messages).map((item) => item.key));
141
- setDownloadStates((current) => {
142
- const retained = Object.fromEntries(Object.entries(current).filter(([key]) => validAttachmentKeys.has(key)));
143
- return Object.keys(retained).length === Object.keys(current).length ? current : retained;
144
- });
359
+ const validAttachmentKeys = new Set(collectDownloadableAttachments(messages).map((item) => item.key));
360
+ if (!showMedia)
361
+ pendingAttachmentKeysRef.current.clear();
362
+ setDownloadStates((current) => pruneAttachmentDownloadStates(current, validAttachmentKeys, pendingAttachmentKeysRef.current));
363
+ for (const key of validAttachmentKeys)
364
+ pendingAttachmentKeysRef.current.delete(key);
145
365
  setSelectedAttachmentIndex((current) => Math.min(current, Math.max(0, downloadableAttachments.length - 1)));
146
366
  if (downloadableAttachments.length === 0)
147
367
  setFocus('input');
@@ -180,7 +400,9 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, cr
180
400
  return;
181
401
  }
182
402
  if (key.return && selectedAttachment != null) {
183
- void downloadAttachment(selectedAttachment);
403
+ const state = downloadStates[selectedAttachment.key] ?? { status: 'idle' };
404
+ if (canManuallyDownload(state))
405
+ void downloadAttachment(selectedAttachment);
184
406
  }
185
407
  return;
186
408
  }
@@ -202,6 +424,8 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, cr
202
424
  }
203
425
  });
204
426
  useEffect(() => {
427
+ const generation = operationControllerRef.current.beginGeneration();
428
+ const isActive = generation.isActive;
205
429
  if (stopSignal.aborted) {
206
430
  exit();
207
431
  return;
@@ -212,99 +436,111 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, cr
212
436
  stopSignal.addEventListener('abort', stopFromSignal);
213
437
  const albumAggregator = new ListenAlbumAggregator({
214
438
  emit: (group) => {
439
+ if (!isActive())
440
+ return;
215
441
  setScrollState((current) => applyMessageArrival(current));
216
442
  setMessageGroups((current) => pruneListenMessageGroups([...current, group]).groups);
217
443
  },
218
444
  });
219
445
  albumAggregatorRef.current = albumAggregator;
220
- const run = async () => {
221
- try {
222
- while (true) {
223
- setStatus('connecting...');
224
- const client = createClient();
225
- let retry = false;
226
- clientRef.current = client;
227
- if (sendTo != null) {
228
- void resolveSendTargetLabel(client, sendTo).then((label) => {
229
- if (label != null)
230
- setSendTargetLabel(label);
231
- });
446
+ const autoDownloader = autoDownload
447
+ ? new AutoDownloadCoordinator({
448
+ onEvent: (event) => {
449
+ if (!isActive())
450
+ return;
451
+ const ownership = operationControllerRef.current.claimDownload(event.key);
452
+ setDownloadStates((current) => applyAutoDownloadEvent(current, event, showMedia));
453
+ if (event.status === 'completed' || event.status === 'failed' || event.status === 'cancelled') {
454
+ ownership.release();
232
455
  }
233
- try {
234
- const result = await client.listen({
235
- chats,
236
- signal: stopSignal,
237
- onConnected: () => setStatus('connected'),
238
- onMessage: (message) => {
239
- const key = `${message.chat_id}:${message.msg_id}`;
240
- if (seenRef.current.has(key))
241
- return;
242
- seenRef.current.add(key);
243
- seenOrderRef.current.push(key);
244
- if (seenRef.current.size > 5000) {
245
- const oldest = seenOrderRef.current.shift();
246
- if (oldest != null)
247
- seenRef.current.delete(oldest);
248
- }
249
- if (sendTo != null) {
250
- const inferred = inferSendTargetLabel(sendTo, message);
251
- if (inferred != null)
252
- setSendTargetLabel(inferred);
253
- }
254
- albumAggregator.add(message);
255
- },
256
- });
257
- if (!persist || result === 'stopped') {
258
- setStatus('stopped');
259
- break;
260
- }
261
- if (result === 'disconnected') {
262
- if (!persist) {
263
- setStatus('stopped');
264
- break;
265
- }
266
- setStatus(`disconnected, retry in ${retrySeconds}s...`);
267
- retry = true;
268
- }
269
- }
270
- catch (error) {
271
- if (!persist) {
272
- setStatus(`listen failed: ${messageFromError(error)}`);
273
- break;
274
- }
275
- setNote(`listen failed: ${messageFromError(error)}`);
276
- retry = true;
277
- }
278
- finally {
279
- albumAggregator.flush();
280
- await client.close().catch(() => undefined);
281
- if (clientRef.current === client)
282
- clientRef.current = null;
283
- }
284
- if (retry) {
285
- await sleep(retrySeconds);
286
- continue;
287
- }
288
- break;
456
+ },
457
+ })
458
+ : null;
459
+ autoDownloaderRef.current = autoDownloader;
460
+ void runInteractiveAutoDownloadLifecycle({
461
+ autoDownload,
462
+ chats,
463
+ persist,
464
+ retrySeconds,
465
+ signal: stopSignal,
466
+ createClient,
467
+ createCoordinator: () => autoDownloader,
468
+ onCoordinator: (coordinator) => {
469
+ if (isActive())
470
+ autoDownloaderRef.current = coordinator;
471
+ },
472
+ onClient: (client) => {
473
+ if (!isActive())
474
+ return;
475
+ clientRef.current = client;
476
+ if (client != null && sendTo != null) {
477
+ void resolveSendTargetLabel(client, sendTo).then((label) => {
478
+ if (isActive() && label != null)
479
+ setSendTargetLabel(label);
480
+ });
289
481
  }
290
- }
291
- finally {
292
- if (!stopSignal.aborted) {
293
- onRequestStop();
294
- exit();
482
+ },
483
+ acceptMessage: (message) => {
484
+ if (!isActive())
485
+ return false;
486
+ return acceptListenMessage(message, seenRef.current, seenOrderRef.current, () => undefined);
487
+ },
488
+ onBeforeEnqueue: (message) => {
489
+ if (!isActive())
490
+ return;
491
+ registerPendingAttachmentKeys(pendingAttachmentKeysRef.current, message, showMedia);
492
+ },
493
+ onMessage: (message) => {
494
+ if (!isActive())
495
+ return;
496
+ if (sendTo != null) {
497
+ const inferred = inferSendTargetLabel(sendTo, message);
498
+ if (inferred != null)
499
+ setSendTargetLabel(inferred);
295
500
  }
501
+ albumAggregator.add(message);
502
+ },
503
+ onStatus: (next) => {
504
+ if (!isActive())
505
+ return;
506
+ setStatus(next === 'connecting'
507
+ ? 'connecting...'
508
+ : next === 'disconnected'
509
+ ? `disconnected, retry in ${retrySeconds}s...`
510
+ : next);
511
+ },
512
+ onError: (error) => {
513
+ if (!isActive())
514
+ return;
515
+ if (persist)
516
+ setNote(`listen failed: ${messageFromError(error)}`);
517
+ else
518
+ setStatus(`listen failed: ${messageFromError(error)}`);
519
+ },
520
+ flush: () => {
521
+ if (isActive())
522
+ albumAggregator.flush();
523
+ },
524
+ }).finally(() => {
525
+ if (isActive() && !stopSignal.aborted) {
526
+ onRequestStop();
527
+ exit();
296
528
  }
297
- };
298
- void run();
529
+ });
299
530
  return () => {
531
+ generation.dispose();
300
532
  stopSignal.removeEventListener('abort', stopFromSignal);
301
533
  albumAggregator.dispose();
302
534
  if (albumAggregatorRef.current === albumAggregator)
303
535
  albumAggregatorRef.current = null;
304
536
  void clientRef.current?.close().catch(() => undefined);
305
537
  clientRef.current = null;
538
+ autoDownloader?.stop();
539
+ void autoDownloader?.waitForActive();
540
+ if (autoDownloaderRef.current === autoDownloader)
541
+ autoDownloaderRef.current = null;
306
542
  };
307
- }, [chats, createClient, persist, retrySeconds, sendTo, showMedia, exit, stopSignal, onRequestStop]);
543
+ }, [autoDownload, chats, createClient, persist, retrySeconds, sendTo, showMedia, exit, stopSignal, onRequestStop]);
308
544
  const sendMessage = async (text) => {
309
545
  const trimmed = text.trim();
310
546
  if (!trimmed)
@@ -318,51 +554,65 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, cr
318
554
  setNote('connection is not ready');
319
555
  return;
320
556
  }
557
+ const isCurrent = operationControllerRef.current.beginSend();
321
558
  setInput('');
322
559
  setSending(true);
323
560
  setNote('sending...');
324
561
  try {
325
- await client.sendMessage({
562
+ const result = await client.sendMessage({
326
563
  chat: sendTo,
327
564
  message: trimmed,
328
565
  linkPreview: true,
329
566
  });
330
- setNote('sent');
567
+ if (isCurrent() && result.sent_message != null) {
568
+ acceptListenMessage(result.sent_message, seenRef.current, seenOrderRef.current, (message) => {
569
+ registerPendingAttachmentKeys(pendingAttachmentKeysRef.current, message, showMedia);
570
+ autoDownloaderRef.current?.enqueue(message);
571
+ albumAggregatorRef.current?.add(message);
572
+ });
573
+ }
574
+ if (isCurrent())
575
+ setNote('sent');
331
576
  }
332
577
  catch (error) {
333
- setNote(`send failed: ${messageFromError(error)}`);
578
+ if (isCurrent())
579
+ setNote(`send failed: ${messageFromError(error)}`);
334
580
  }
335
581
  finally {
336
- setSending(false);
582
+ if (isCurrent())
583
+ setSending(false);
337
584
  }
338
585
  };
339
586
  const downloadAttachment = async (item) => {
340
- const client = clientRef.current;
341
- if (!client) {
342
- setDownloadStates((current) => ({ ...current, [item.key]: { status: 'failed', error: 'not connected' } }));
343
- return;
344
- }
345
- const destination = resolveAttachmentDestination({
346
- homeDir: homedir(),
347
- fileName: attachmentFileName(item),
348
- exists: existsSync,
349
- });
350
- mkdirSync(dirname(destination), { recursive: true });
351
- setDownloadStates((current) => ({ ...current, [item.key]: { status: 'downloading', progress: 0 } }));
352
- try {
587
+ const ownership = operationControllerRef.current.beginDownload(item.key);
588
+ const isCurrent = ownership.isCurrent;
589
+ await runOwnedAttachmentOperation(ownership, async () => {
590
+ const client = clientRef.current;
591
+ if (!client)
592
+ throw new Error('not connected');
593
+ const destination = resolveAttachmentDestination({
594
+ homeDir: homedir(),
595
+ fileName: attachmentFileName(item.attachment),
596
+ exists: existsSync,
597
+ });
598
+ mkdirSync(dirname(destination), { recursive: true });
599
+ if (isCurrent())
600
+ setDownloadStates((current) => ({ ...current, [item.key]: { status: 'downloading', progress: 0 } }));
353
601
  await client.downloadMessageMedia({
354
602
  ...attachmentDownloadTarget(item.attachment),
355
603
  destination,
356
604
  onProgress: (downloaded, total) => {
605
+ if (!isCurrent())
606
+ return;
357
607
  const progress = Number.isFinite(total) && total > 0 ? Math.round(downloaded / total * 100) : null;
358
608
  setDownloadStates((current) => ({ ...current, [item.key]: { status: 'downloading', progress } }));
359
609
  },
360
610
  });
361
- setDownloadStates((current) => ({ ...current, [item.key]: { status: 'completed', path: destination } }));
362
- }
363
- catch (error) {
611
+ if (isCurrent())
612
+ setDownloadStates((current) => ({ ...current, [item.key]: { status: 'completed', path: destination } }));
613
+ }, (error) => {
364
614
  setDownloadStates((current) => ({ ...current, [item.key]: { status: 'failed', error: messageFromError(error) } }));
365
- }
615
+ });
366
616
  };
367
617
  const stopListening = () => {
368
618
  if (stoppingRef.current)
@@ -375,11 +625,12 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, cr
375
625
  else
376
626
  setTimeout(exit, 0);
377
627
  onRequestStop();
628
+ autoDownloaderRef.current?.stop();
378
629
  clientRef.current?.close().catch(() => undefined);
379
630
  };
380
- 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.sender] }), message.content == null ? null : _jsx(Text, { wrap: "truncate-end", children: message.content }), message.media.map((item, mediaIndex) => {
381
- const attachmentKey = `${message.key}:${mediaIndex}`;
382
- return (_jsx(ListenAttachmentWithPreview, { label: item.label, selected: focus === 'attachments' && selectedAttachment?.key === attachmentKey, state: downloadStates[attachmentKey] ?? { status: 'idle' }, previewCells: item.previewCells }, attachmentKey));
631
+ 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, _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, "] ", formatInteractiveListenSender(message)] }), message.content == null ? null : _jsx(Text, { wrap: "truncate-end", children: message.content }), message.media.map((item, mediaIndex) => {
632
+ const attachmentKey = attachmentDownloadKeyAt(message.media, mediaIndex);
633
+ return (_jsx(ListenAttachmentWithPreview, { label: item.label, downloadable: item.downloadable, selected: focus === 'attachments' && selectedAttachment?.key === attachmentKey, state: downloadStates[attachmentKey] ?? { status: 'idle' }, previewCells: item.previewCells }, attachmentKey));
383
634
  }), _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 })] }));
384
635
  }
385
636
  export class ListenMessageViewCache {
@@ -436,10 +687,10 @@ export function toListenMessage(messages, context) {
436
687
  if (message == null)
437
688
  throw new Error('Cannot render an empty listen message group');
438
689
  const renderContext = typeof context === 'boolean'
439
- ? { showMedia: context, previewWidth: 1, colorDepth: 1 }
690
+ ? { showMedia: context, previewWidth: 1, colorDepth: 1, showChatName: false }
440
691
  : context;
441
- const { showMedia, previewWidth, colorDepth } = renderContext;
442
- const formatted = buildListenMessage(messages, { showMedia });
692
+ const { showMedia, previewWidth, colorDepth, showChatName = false } = renderContext;
693
+ const formatted = buildListenMessage(messages, { showMedia, showChatName });
443
694
  const decodePreview = renderContext.decodePreview ?? decodeImagePreview;
444
695
  const media = formatted.media.map((attachment) => {
445
696
  if (attachment.previewJpegBase64 == null || colorDepth < 24)
@@ -460,35 +711,20 @@ export function toListenMessage(messages, context) {
460
711
  function normalizedPreviewWidth(previewWidth) {
461
712
  return Math.max(1, Math.min(24, previewWidth));
462
713
  }
463
- function collectAttachments(messages) {
464
- return messages.flatMap((message) => message.media.map((attachment, index) => ({
465
- key: `${message.key}:${index}`,
466
- message,
467
- attachment,
468
- })));
469
- }
470
- function attachmentFileName(item) {
471
- if (item.attachment.fileName != null)
472
- return item.attachment.fileName;
473
- const extension = MEDIA_EXTENSIONS[item.attachment.kind] ?? 'bin';
474
- return `${item.attachment.chatId}-${item.attachment.messageId}.${extension}`;
475
- }
476
- export function attachmentDownloadTarget(attachment) {
477
- return { chat: attachment.chatId, msgId: attachment.messageId };
714
+ export function collectDownloadableAttachments(messages) {
715
+ return messages.flatMap((message) => message.media.flatMap((attachment, index) => (attachment.downloadable === true
716
+ ? [{
717
+ key: attachmentDownloadKeyAt(message.media, index),
718
+ message,
719
+ attachment,
720
+ }]
721
+ : [])));
478
722
  }
723
+ export { attachmentDownloadTarget } from '../../services/listen-attachment.js';
479
724
  export function flushListenBeforeExit(aggregator, exit) {
480
725
  aggregator.flush();
481
726
  setTimeout(exit, 0);
482
727
  }
483
- const MEDIA_EXTENSIONS = {
484
- Photo: 'jpg',
485
- Video: 'mp4',
486
- Audio: 'mp3',
487
- Voice: 'ogg',
488
- Sticker: 'webp',
489
- Animation: 'mp4',
490
- Document: 'bin',
491
- };
492
728
  function messageFromError(error) {
493
729
  return error instanceof Error ? error.message : String(error);
494
730
  }