@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.
- package/README.md +109 -6
- package/README.zh-CN.md +110 -7
- package/dist/cli/app.js +3 -1
- package/dist/cli/app.js.map +1 -1
- package/dist/commands/account.js +3 -1
- package/dist/commands/account.js.map +1 -1
- package/dist/commands/config.js +96 -13
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/group.js +107 -0
- package/dist/commands/group.js.map +1 -0
- package/dist/commands/telegram-runner.js +84 -0
- package/dist/commands/telegram-runner.js.map +1 -0
- package/dist/commands/telegram.js +92 -129
- package/dist/commands/telegram.js.map +1 -1
- package/dist/config/credential-store.js +76 -22
- package/dist/config/credential-store.js.map +1 -1
- package/dist/config/env.js +12 -1
- package/dist/config/env.js.map +1 -1
- package/dist/index.js +0 -0
- package/dist/presenters/group.js +106 -0
- package/dist/presenters/group.js.map +1 -0
- package/dist/presenters/human.js +7 -9
- package/dist/presenters/human.js.map +1 -1
- package/dist/presenters/ink/listen.js +376 -141
- package/dist/presenters/ink/listen.js.map +1 -1
- package/dist/presenters/listen-message.js +3 -236
- package/dist/presenters/listen-message.js.map +1 -1
- package/dist/services/attachment-download.js +5 -2
- package/dist/services/attachment-download.js.map +1 -1
- package/dist/services/auto-download-coordinator.js +214 -0
- package/dist/services/auto-download-coordinator.js.map +1 -0
- package/dist/services/group-service.js +164 -0
- package/dist/services/group-service.js.map +1 -0
- package/dist/services/listen-attachment.js +248 -0
- package/dist/services/listen-attachment.js.map +1 -0
- package/dist/services/message-service.js +54 -4
- package/dist/services/message-service.js.map +1 -1
- package/dist/services/query-service.js +37 -32
- package/dist/services/query-service.js.map +1 -1
- package/dist/services/sync-service.js +18 -6
- package/dist/services/sync-service.js.map +1 -1
- package/dist/telegram/client-factory.js +4 -2
- package/dist/telegram/client-factory.js.map +1 -1
- package/dist/telegram/fake-group-management.js +264 -0
- package/dist/telegram/fake-group-management.js.map +1 -0
- package/dist/telegram/group-types.js +19 -0
- package/dist/telegram/group-types.js.map +1 -0
- package/dist/telegram/mtcute-client.js +73 -8
- package/dist/telegram/mtcute-client.js.map +1 -1
- package/dist/telegram/mtcute-group-management.js +464 -0
- package/dist/telegram/mtcute-group-management.js.map +1 -0
- package/dist/telegram/proxy.js +27 -0
- package/dist/telegram/proxy.js.map +1 -0
- 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 === '
|
|
58
|
-
?
|
|
59
|
-
: state.status === '
|
|
60
|
-
? state.
|
|
61
|
-
:
|
|
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: [
|
|
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
|
|
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 =
|
|
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(
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (
|
|
229
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
293
|
-
if (!
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
578
|
+
if (isCurrent())
|
|
579
|
+
setNote(`send failed: ${messageFromError(error)}`);
|
|
335
580
|
}
|
|
336
581
|
finally {
|
|
337
|
-
|
|
582
|
+
if (isCurrent())
|
|
583
|
+
setSending(false);
|
|
338
584
|
}
|
|
339
585
|
};
|
|
340
586
|
const downloadAttachment = async (item) => {
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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(
|
|
382
|
-
const attachmentKey =
|
|
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
|
|
465
|
-
return messages.flatMap((message) => message.media.
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
}
|
|
471
|
-
|
|
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
|
}
|