@will-17173/telegram-cli 0.1.1 → 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 +92 -129
  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 +376 -141
  25. package/dist/presenters/ink/listen.js.map +1 -1
  26. package/dist/presenters/listen-message.js +3 -236
  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, showChatName, 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);
@@ -107,15 +321,19 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, sh
107
321
  colorDepth: previewColorDepth,
108
322
  showChatName,
109
323
  }), [messageGroups, showMedia, previewWidth, previewColorDepth, showChatName]);
110
- const reservedLines = 7 + (note ? 1 : 0);
111
- const messagePaneHeight = Math.max(2, terminalHeight - reservedLines);
324
+ const messagePaneHeight = calculateListenMessagePaneHeight(terminalHeight, note.length > 0, autoDownload);
112
325
  const visibleMessages = takeListenViewport(messages, messagePaneHeight, scrollState.offset);
113
326
  const clientRef = useRef(null);
114
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();
115
333
  const stoppingRef = useRef(false);
116
334
  const seenRef = useRef(new Set());
117
335
  const seenOrderRef = useRef([]);
118
- const downloadableAttachments = collectAttachments(visibleMessages);
336
+ const downloadableAttachments = collectDownloadableAttachments(visibleMessages);
119
337
  const selectedAttachment = downloadableAttachments[selectedAttachmentIndex] ?? downloadableAttachments[0];
120
338
  const { visible: scrollbarVisible, show: showScrollbar } = useTransientScrollbar();
121
339
  const scrollbarGeometry = calculateScrollbar({
@@ -138,11 +356,12 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, sh
138
356
  ? current
139
357
  : { offset, unseenCount };
140
358
  });
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
- });
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);
146
365
  setSelectedAttachmentIndex((current) => Math.min(current, Math.max(0, downloadableAttachments.length - 1)));
147
366
  if (downloadableAttachments.length === 0)
148
367
  setFocus('input');
@@ -181,7 +400,9 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, sh
181
400
  return;
182
401
  }
183
402
  if (key.return && selectedAttachment != null) {
184
- void downloadAttachment(selectedAttachment);
403
+ const state = downloadStates[selectedAttachment.key] ?? { status: 'idle' };
404
+ if (canManuallyDownload(state))
405
+ void downloadAttachment(selectedAttachment);
185
406
  }
186
407
  return;
187
408
  }
@@ -203,6 +424,8 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, sh
203
424
  }
204
425
  });
205
426
  useEffect(() => {
427
+ const generation = operationControllerRef.current.beginGeneration();
428
+ const isActive = generation.isActive;
206
429
  if (stopSignal.aborted) {
207
430
  exit();
208
431
  return;
@@ -213,99 +436,111 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, sh
213
436
  stopSignal.addEventListener('abort', stopFromSignal);
214
437
  const albumAggregator = new ListenAlbumAggregator({
215
438
  emit: (group) => {
439
+ if (!isActive())
440
+ return;
216
441
  setScrollState((current) => applyMessageArrival(current));
217
442
  setMessageGroups((current) => pruneListenMessageGroups([...current, group]).groups);
218
443
  },
219
444
  });
220
445
  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
- });
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();
233
455
  }
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;
275
- }
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;
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
+ });
290
481
  }
291
- }
292
- finally {
293
- if (!stopSignal.aborted) {
294
- onRequestStop();
295
- 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);
296
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();
297
528
  }
298
- };
299
- void run();
529
+ });
300
530
  return () => {
531
+ generation.dispose();
301
532
  stopSignal.removeEventListener('abort', stopFromSignal);
302
533
  albumAggregator.dispose();
303
534
  if (albumAggregatorRef.current === albumAggregator)
304
535
  albumAggregatorRef.current = null;
305
536
  void clientRef.current?.close().catch(() => undefined);
306
537
  clientRef.current = null;
538
+ autoDownloader?.stop();
539
+ void autoDownloader?.waitForActive();
540
+ if (autoDownloaderRef.current === autoDownloader)
541
+ autoDownloaderRef.current = null;
307
542
  };
308
- }, [chats, createClient, persist, retrySeconds, sendTo, showMedia, exit, stopSignal, onRequestStop]);
543
+ }, [autoDownload, chats, createClient, persist, retrySeconds, sendTo, showMedia, exit, stopSignal, onRequestStop]);
309
544
  const sendMessage = async (text) => {
310
545
  const trimmed = text.trim();
311
546
  if (!trimmed)
@@ -319,51 +554,65 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, sh
319
554
  setNote('connection is not ready');
320
555
  return;
321
556
  }
557
+ const isCurrent = operationControllerRef.current.beginSend();
322
558
  setInput('');
323
559
  setSending(true);
324
560
  setNote('sending...');
325
561
  try {
326
- await client.sendMessage({
562
+ const result = await client.sendMessage({
327
563
  chat: sendTo,
328
564
  message: trimmed,
329
565
  linkPreview: true,
330
566
  });
331
- 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');
332
576
  }
333
577
  catch (error) {
334
- setNote(`send failed: ${messageFromError(error)}`);
578
+ if (isCurrent())
579
+ setNote(`send failed: ${messageFromError(error)}`);
335
580
  }
336
581
  finally {
337
- setSending(false);
582
+ if (isCurrent())
583
+ setSending(false);
338
584
  }
339
585
  };
340
586
  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 {
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 } }));
354
601
  await client.downloadMessageMedia({
355
602
  ...attachmentDownloadTarget(item.attachment),
356
603
  destination,
357
604
  onProgress: (downloaded, total) => {
605
+ if (!isCurrent())
606
+ return;
358
607
  const progress = Number.isFinite(total) && total > 0 ? Math.round(downloaded / total * 100) : null;
359
608
  setDownloadStates((current) => ({ ...current, [item.key]: { status: 'downloading', progress } }));
360
609
  },
361
610
  });
362
- setDownloadStates((current) => ({ ...current, [item.key]: { status: 'completed', path: destination } }));
363
- }
364
- catch (error) {
611
+ if (isCurrent())
612
+ setDownloadStates((current) => ({ ...current, [item.key]: { status: 'completed', path: destination } }));
613
+ }, (error) => {
365
614
  setDownloadStates((current) => ({ ...current, [item.key]: { status: 'failed', error: messageFromError(error) } }));
366
- }
615
+ });
367
616
  };
368
617
  const stopListening = () => {
369
618
  if (stoppingRef.current)
@@ -376,11 +625,12 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, sh
376
625
  else
377
626
  setTimeout(exit, 0);
378
627
  onRequestStop();
628
+ autoDownloaderRef.current?.stop();
379
629
  clientRef.current?.close().catch(() => undefined);
380
630
  };
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));
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));
384
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 })] }));
385
635
  }
386
636
  export class ListenMessageViewCache {
@@ -461,35 +711,20 @@ export function toListenMessage(messages, context) {
461
711
  function normalizedPreviewWidth(previewWidth) {
462
712
  return Math.max(1, Math.min(24, previewWidth));
463
713
  }
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 };
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
+ : [])));
479
722
  }
723
+ export { attachmentDownloadTarget } from '../../services/listen-attachment.js';
480
724
  export function flushListenBeforeExit(aggregator, exit) {
481
725
  aggregator.flush();
482
726
  setTimeout(exit, 0);
483
727
  }
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
728
  function messageFromError(error) {
494
729
  return error instanceof Error ? error.message : String(error);
495
730
  }