@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.
- 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 +98 -130
- 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 +381 -145
- package/dist/presenters/ink/listen.js.map +1 -1
- package/dist/presenters/listen-message.js +6 -237
- 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, 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
|
-
|
|
109
|
-
|
|
110
|
-
const messagePaneHeight =
|
|
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 =
|
|
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(
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
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
|
-
|
|
292
|
-
if (!
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
578
|
+
if (isCurrent())
|
|
579
|
+
setNote(`send failed: ${messageFromError(error)}`);
|
|
334
580
|
}
|
|
335
581
|
finally {
|
|
336
|
-
|
|
582
|
+
if (isCurrent())
|
|
583
|
+
setSending(false);
|
|
337
584
|
}
|
|
338
585
|
};
|
|
339
586
|
const downloadAttachment = async (item) => {
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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(
|
|
381
|
-
const attachmentKey =
|
|
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
|
|
464
|
-
return messages.flatMap((message) => message.media.
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
|
|
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
|
}
|