@will-17173/telegram-cli 0.2.0 → 0.4.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 +241 -61
- package/README.zh-CN.md +250 -73
- package/dist/account/account-authenticator.js +95 -0
- package/dist/account/account-authenticator.js.map +1 -0
- package/dist/account/account-context.js +7 -0
- package/dist/account/account-context.js.map +1 -1
- package/dist/account/account-name.js +17 -0
- package/dist/account/account-name.js.map +1 -0
- package/dist/account/account-presets.js +20 -3
- package/dist/account/account-presets.js.map +1 -1
- package/dist/account/account-store.js +277 -37
- package/dist/account/account-store.js.map +1 -1
- package/dist/cli/app.js +12 -1
- package/dist/cli/app.js.map +1 -1
- package/dist/cli/output.js +21 -6
- package/dist/cli/output.js.map +1 -1
- package/dist/cli/secure-input.js +408 -0
- package/dist/cli/secure-input.js.map +1 -0
- package/dist/commands/account-options.js +37 -7
- package/dist/commands/account-options.js.map +1 -1
- package/dist/commands/account.js +192 -66
- package/dist/commands/account.js.map +1 -1
- package/dist/commands/archive.js +151 -0
- package/dist/commands/archive.js.map +1 -0
- package/dist/commands/config.js +119 -12
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/contact.js +58 -0
- package/dist/commands/contact.js.map +1 -0
- package/dist/commands/data.js +14 -7
- package/dist/commands/data.js.map +1 -1
- package/dist/commands/dialog.js +156 -0
- package/dist/commands/dialog.js.map +1 -0
- package/dist/commands/folder.js +113 -0
- package/dist/commands/folder.js.map +1 -0
- package/dist/commands/group-write.js +128 -0
- package/dist/commands/group-write.js.map +1 -0
- package/dist/commands/group.js +43 -7
- package/dist/commands/group.js.map +1 -1
- package/dist/commands/notification.js +76 -0
- package/dist/commands/notification.js.map +1 -0
- package/dist/commands/query.js +22 -15
- package/dist/commands/query.js.map +1 -1
- package/dist/commands/telegram-runner.js +40 -18
- package/dist/commands/telegram-runner.js.map +1 -1
- package/dist/commands/telegram.js +172 -109
- package/dist/commands/telegram.js.map +1 -1
- package/dist/commands/time-range.js +39 -0
- package/dist/commands/time-range.js.map +1 -0
- package/dist/commands/types.js +3 -2
- package/dist/commands/types.js.map +1 -1
- package/dist/config/credential-store.js +24 -4
- package/dist/config/credential-store.js.map +1 -1
- package/dist/config/env.js +4 -1
- package/dist/config/env.js.map +1 -1
- package/dist/group-commands/catalog.js +79 -0
- package/dist/group-commands/catalog.js.map +1 -0
- package/dist/group-commands/executor.js +102 -0
- package/dist/group-commands/executor.js.map +1 -0
- package/dist/group-commands/parser.js +268 -0
- package/dist/group-commands/parser.js.map +1 -0
- package/dist/group-commands/tokenize.js +51 -0
- package/dist/group-commands/tokenize.js.map +1 -0
- package/dist/group-commands/types.js +2 -0
- package/dist/group-commands/types.js.map +1 -0
- package/dist/index.js +0 -0
- package/dist/listen-commands/catalog.js +61 -0
- package/dist/listen-commands/catalog.js.map +1 -0
- package/dist/listen-commands/dispatch.js +46 -0
- package/dist/listen-commands/dispatch.js.map +1 -0
- package/dist/listen-commands/match.js +113 -0
- package/dist/listen-commands/match.js.map +1 -0
- package/dist/presenters/group.js +40 -0
- package/dist/presenters/group.js.map +1 -1
- package/dist/presenters/human.js +135 -0
- package/dist/presenters/human.js.map +1 -1
- package/dist/presenters/ink/display-width.js +45 -7
- package/dist/presenters/ink/display-width.js.map +1 -1
- package/dist/presenters/ink/group-command-confirm.js +13 -0
- package/dist/presenters/ink/group-command-confirm.js.map +1 -0
- package/dist/presenters/ink/group-command-result.js +23 -0
- package/dist/presenters/ink/group-command-result.js.map +1 -0
- package/dist/presenters/ink/listen-command-menu.js +38 -0
- package/dist/presenters/ink/listen-command-menu.js.map +1 -0
- package/dist/presenters/ink/listen-scroll.js +9 -4
- package/dist/presenters/ink/listen-scroll.js.map +1 -1
- package/dist/presenters/ink/listen.js +572 -52
- package/dist/presenters/ink/listen.js.map +1 -1
- package/dist/presenters/ink/mouse-scroll.js +12 -0
- package/dist/presenters/ink/mouse-scroll.js.map +1 -1
- package/dist/presenters/ink/secure-input.js +68 -0
- package/dist/presenters/ink/secure-input.js.map +1 -0
- package/dist/presenters/ink/use-group-command.js +186 -0
- package/dist/presenters/ink/use-group-command.js.map +1 -0
- package/dist/presenters/listen-message.js +23 -6
- package/dist/presenters/listen-message.js.map +1 -1
- package/dist/presenters/logical-message.js +78 -0
- package/dist/presenters/logical-message.js.map +1 -0
- package/dist/presenters/markdown.js +54 -0
- package/dist/presenters/markdown.js.map +1 -0
- package/dist/presenters/structured.js +8 -3
- package/dist/presenters/structured.js.map +1 -1
- package/dist/services/account-session-service.js +241 -0
- package/dist/services/account-session-service.js.map +1 -0
- package/dist/services/archive-layout.js +61 -0
- package/dist/services/archive-layout.js.map +1 -0
- package/dist/services/archive-manifest.js +187 -0
- package/dist/services/archive-manifest.js.map +1 -0
- package/dist/services/archive-markdown.js +383 -0
- package/dist/services/archive-markdown.js.map +1 -0
- package/dist/services/archive-service.js +718 -0
- package/dist/services/archive-service.js.map +1 -0
- package/dist/services/archive-types.js +2 -0
- package/dist/services/archive-types.js.map +1 -0
- package/dist/services/attachment-download.js +7 -2
- package/dist/services/attachment-download.js.map +1 -1
- package/dist/services/auto-download-coordinator.js +2 -4
- package/dist/services/auto-download-coordinator.js.map +1 -1
- package/dist/services/contact-service.js +78 -0
- package/dist/services/contact-service.js.map +1 -0
- package/dist/services/dialog-service.js +140 -0
- package/dist/services/dialog-service.js.map +1 -0
- package/dist/services/folder-service.js +200 -0
- package/dist/services/folder-service.js.map +1 -0
- package/dist/services/group-service.js.map +1 -1
- package/dist/services/group-write-service.js +225 -0
- package/dist/services/group-write-service.js.map +1 -0
- package/dist/services/listen-album-aggregator.js +9 -29
- package/dist/services/listen-album-aggregator.js.map +1 -1
- package/dist/services/listen-attachment.js +23 -6
- package/dist/services/listen-attachment.js.map +1 -1
- package/dist/services/listen-composer-command.js +102 -0
- package/dist/services/listen-composer-command.js.map +1 -0
- package/dist/services/listen-reply-resolver.js +126 -0
- package/dist/services/listen-reply-resolver.js.map +1 -0
- package/dist/services/message-service.js +17 -1
- package/dist/services/message-service.js.map +1 -1
- package/dist/services/notification-service.js +142 -0
- package/dist/services/notification-service.js.map +1 -0
- package/dist/services/query-service.js +59 -3
- package/dist/services/query-service.js.map +1 -1
- package/dist/services/reply-context.js +29 -0
- package/dist/services/reply-context.js.map +1 -0
- package/dist/services/write-access-policy.js +19 -0
- package/dist/services/write-access-policy.js.map +1 -0
- package/dist/storage/message-db.js +241 -5
- package/dist/storage/message-db.js.map +1 -1
- package/dist/telegram/archive-types.js +2 -0
- package/dist/telegram/archive-types.js.map +1 -0
- package/dist/telegram/client-factory.js +51 -11
- package/dist/telegram/client-factory.js.map +1 -1
- package/dist/telegram/contact-types.js +10 -0
- package/dist/telegram/contact-types.js.map +1 -0
- package/dist/telegram/dialog-types.js +2 -0
- package/dist/telegram/dialog-types.js.map +1 -0
- package/dist/telegram/errors.js +14 -0
- package/dist/telegram/errors.js.map +1 -0
- package/dist/telegram/fake-group-management.js +122 -0
- package/dist/telegram/fake-group-management.js.map +1 -1
- package/dist/telegram/folder-types.js +13 -0
- package/dist/telegram/folder-types.js.map +1 -0
- package/dist/telegram/group-types.js +40 -0
- package/dist/telegram/group-types.js.map +1 -1
- package/dist/telegram/group-write-types.js +29 -0
- package/dist/telegram/group-write-types.js.map +1 -0
- package/dist/telegram/mtcute-archive.js +190 -0
- package/dist/telegram/mtcute-archive.js.map +1 -0
- package/dist/telegram/mtcute-client.js +73 -12
- package/dist/telegram/mtcute-client.js.map +1 -1
- package/dist/telegram/mtcute-contacts.js +90 -0
- package/dist/telegram/mtcute-contacts.js.map +1 -0
- package/dist/telegram/mtcute-dialogs.js +186 -0
- package/dist/telegram/mtcute-dialogs.js.map +1 -0
- package/dist/telegram/mtcute-folders.js +318 -0
- package/dist/telegram/mtcute-folders.js.map +1 -0
- package/dist/telegram/mtcute-group-helpers.js +56 -0
- package/dist/telegram/mtcute-group-helpers.js.map +1 -0
- package/dist/telegram/mtcute-group-invites.js +82 -0
- package/dist/telegram/mtcute-group-invites.js.map +1 -0
- package/dist/telegram/mtcute-group-management.js +60 -31
- package/dist/telegram/mtcute-group-management.js.map +1 -1
- package/dist/telegram/mtcute-group-members.js +158 -0
- package/dist/telegram/mtcute-group-members.js.map +1 -0
- package/dist/telegram/mtcute-group-settings.js +146 -0
- package/dist/telegram/mtcute-group-settings.js.map +1 -0
- package/dist/telegram/mtcute-group-topics.js +63 -0
- package/dist/telegram/mtcute-group-topics.js.map +1 -0
- package/dist/telegram/mtcute-message-normalizer.js +63 -0
- package/dist/telegram/mtcute-message-normalizer.js.map +1 -0
- package/dist/telegram/mtcute-notifications.js +101 -0
- package/dist/telegram/mtcute-notifications.js.map +1 -0
- package/dist/telegram/notification-types.js +11 -0
- package/dist/telegram/notification-types.js.map +1 -0
- package/dist/telegram/raw-message.js +46 -0
- package/dist/telegram/raw-message.js.map +1 -0
- package/dist/telegram/types.js +6 -1
- package/dist/telegram/types.js.map +1 -1
- package/package.json +20 -10
|
@@ -5,7 +5,7 @@ import stringWidth from 'string-width';
|
|
|
5
5
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import { dirname } from 'node:path';
|
|
8
|
-
import { resolveAttachmentDestination } from '../../services/attachment-download.js';
|
|
8
|
+
import { attachmentDownloadProgress, resolveAttachmentDestination, } from '../../services/attachment-download.js';
|
|
9
9
|
import { ListenAlbumAggregator } from '../../services/listen-album-aggregator.js';
|
|
10
10
|
import { AutoDownloadCoordinator } from '../../services/auto-download-coordinator.js';
|
|
11
11
|
import { attachmentDownloadTarget, attachmentFileName, discoverListenAttachments, listenAttachmentKey } from '../../services/listen-attachment.js';
|
|
@@ -13,8 +13,24 @@ import { buildListenMessage } from '../listen-message.js';
|
|
|
13
13
|
import { applyMessageArrival, applyScroll, takeListenViewport } from './listen-scroll.js';
|
|
14
14
|
import { decodeImagePreview } from './image-preview.js';
|
|
15
15
|
import { ListenScrollbar, calculateScrollbar, listenContentWidth, useTransientScrollbar } from './listen-scrollbar.js';
|
|
16
|
-
import { isMouseInput,
|
|
16
|
+
import { isMouseInput, withAlternateScroll } from './mouse-scroll.js';
|
|
17
|
+
import { createListenReplyResolver } from '../../services/listen-reply-resolver.js';
|
|
18
|
+
import { formatReplyContext } from '../../services/reply-context.js';
|
|
19
|
+
import { executeListenReply, parseListenComposerInput } from '../../services/listen-composer-command.js';
|
|
20
|
+
import { ListenCommandMenu, listenCommandMenuAvailability, moveListenCommandSelectionEnabled } from './listen-command-menu.js';
|
|
21
|
+
import { completeListenCommand, visibleListenCommandMatches } from '../../listen-commands/match.js';
|
|
22
|
+
import { parseSelectedListenCommand } from '../../listen-commands/dispatch.js';
|
|
23
|
+
import { GroupCommandResult } from './group-command-result.js';
|
|
24
|
+
import { useGroupCommand } from './use-group-command.js';
|
|
25
|
+
import { executeGroupCommand } from '../../group-commands/executor.js';
|
|
26
|
+
import { GroupWriteService } from '../../services/group-write-service.js';
|
|
27
|
+
import { ADMIN_RIGHT_KEYS } from '../../services/group-write-service.js';
|
|
28
|
+
import { WriteAccessPolicy } from '../../services/write-access-policy.js';
|
|
29
|
+
import { GroupCommandConfirm } from './group-command-confirm.js';
|
|
30
|
+
import { truncateCell } from './display-width.js';
|
|
31
|
+
import { SecureInput } from './secure-input.js';
|
|
17
32
|
const MESSAGE_SEPARATOR = '────────────────────────────────────────────';
|
|
33
|
+
const OWNERSHIP_SHUTDOWN_GRACE_MS = 250;
|
|
18
34
|
/** Maximum number of grouped messages retained by a long-running interactive listener. */
|
|
19
35
|
export const LISTEN_HISTORY_LIMIT = 500;
|
|
20
36
|
const LISTEN_IMAGE_PREVIEWS_ENABLED = false;
|
|
@@ -47,11 +63,14 @@ export const LISTEN_COMPOSER_THEME = {
|
|
|
47
63
|
target: '#f0d38a',
|
|
48
64
|
hint: '#9bdca8',
|
|
49
65
|
};
|
|
50
|
-
export function
|
|
66
|
+
export function listenComposerCursorColor(visible) {
|
|
67
|
+
return visible ? LISTEN_COMPOSER_THEME.cursor : LISTEN_COMPOSER_THEME.background;
|
|
68
|
+
}
|
|
69
|
+
export function ListenComposer({ input, sendTargetLabel, terminalWidth, sending = false, hint = 'Enter to send · Ctrl+C to exit', cursorVisible = true, }) {
|
|
51
70
|
const inputText = `› ${input}${sending ? ' (sending...)' : ''}`;
|
|
52
71
|
const blankRow = ' '.repeat(Math.max(0, terminalWidth));
|
|
53
72
|
const inputRowFill = ' '.repeat(Math.max(0, terminalWidth - stringWidth(inputText) - 1));
|
|
54
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { backgroundColor: LISTEN_COMPOSER_THEME.background, children: blankRow }), _jsxs(Text, { backgroundColor: LISTEN_COMPOSER_THEME.background, color: LISTEN_COMPOSER_THEME.foreground, children: [inputText, _jsx(Text, { backgroundColor:
|
|
73
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { backgroundColor: LISTEN_COMPOSER_THEME.background, children: blankRow }), _jsxs(Text, { backgroundColor: LISTEN_COMPOSER_THEME.background, color: LISTEN_COMPOSER_THEME.foreground, children: [inputText, _jsx(Text, { backgroundColor: listenComposerCursorColor(cursorVisible), children: " " }), inputRowFill] }), _jsx(Text, { backgroundColor: LISTEN_COMPOSER_THEME.background, children: blankRow }), _jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { color: LISTEN_COMPOSER_THEME.target, children: ["To: ", sendTargetLabel] }), _jsx(Text, { color: LISTEN_COMPOSER_THEME.hint, children: hint })] })] }));
|
|
55
74
|
}
|
|
56
75
|
export function ListenAttachmentLine({ label, selected, state }) {
|
|
57
76
|
const action = state.status === 'idle'
|
|
@@ -183,16 +202,23 @@ export async function runInteractiveAutoDownloadLifecycle(options) {
|
|
|
183
202
|
: null;
|
|
184
203
|
options.onCoordinator?.(coordinator);
|
|
185
204
|
let currentClient = null;
|
|
205
|
+
let closeCurrentClient = null;
|
|
186
206
|
const abort = () => {
|
|
187
207
|
coordinator?.stop();
|
|
188
|
-
void
|
|
208
|
+
void closeCurrentClient?.();
|
|
189
209
|
};
|
|
190
210
|
options.signal.addEventListener('abort', abort);
|
|
191
211
|
try {
|
|
192
212
|
while (!options.signal.aborted) {
|
|
193
213
|
options.onStatus?.('connecting');
|
|
194
214
|
const client = options.createClient();
|
|
215
|
+
let closePromise;
|
|
216
|
+
const closeClient = () => {
|
|
217
|
+
closePromise ??= Promise.resolve().then(() => client.close()).catch(() => undefined);
|
|
218
|
+
return closePromise;
|
|
219
|
+
};
|
|
195
220
|
currentClient = client;
|
|
221
|
+
closeCurrentClient = closeClient;
|
|
196
222
|
options.onClient?.(client);
|
|
197
223
|
coordinator?.setClient(client);
|
|
198
224
|
let retry = false;
|
|
@@ -236,11 +262,13 @@ export async function runInteractiveAutoDownloadLifecycle(options) {
|
|
|
236
262
|
else {
|
|
237
263
|
coordinator?.stop();
|
|
238
264
|
}
|
|
239
|
-
await
|
|
265
|
+
await closeClient();
|
|
240
266
|
if (options.signal.aborted)
|
|
241
267
|
await coordinator?.waitForActive();
|
|
242
268
|
if (currentClient === client)
|
|
243
269
|
currentClient = null;
|
|
270
|
+
if (closeCurrentClient === closeClient)
|
|
271
|
+
closeCurrentClient = null;
|
|
244
272
|
options.onClient?.(null);
|
|
245
273
|
}
|
|
246
274
|
if (!retry)
|
|
@@ -268,6 +296,81 @@ export function ListenAttachmentWithPreview({ label, downloadable = true, select
|
|
|
268
296
|
? _jsx(ListenAttachmentLine, { label: label, selected: selected, state: state })
|
|
269
297
|
: _jsxs(Text, { wrap: "truncate-end", children: [" ", label] }), previewCells == null ? null : _jsx(ListenImagePreview, { rows: previewCells })] }));
|
|
270
298
|
}
|
|
299
|
+
export function ListenMessageBody({ message }) {
|
|
300
|
+
return (_jsxs(Box, { flexDirection: "column", children: [message.replyContext == null ? null : _jsx(Text, { wrap: "truncate-end", children: formatReplyContext(message.replyContext) }), message.content == null ? null : _jsx(Text, { wrap: "truncate-end", children: message.content }), message.mediaSummary == null ? null : _jsx(Text, { wrap: "truncate-end", children: message.mediaSummary }), message.media.map((item, mediaIndex) => (_jsx(ListenAttachmentWithPreview, { label: item.label, downloadable: item.downloadable, selected: false, state: { status: 'idle' }, previewCells: item.previewCells }, attachmentDownloadKeyAt(message.media, mediaIndex))))] }));
|
|
301
|
+
}
|
|
302
|
+
export function createInteractiveListenGroupQueue(options) {
|
|
303
|
+
const pending = [];
|
|
304
|
+
const schedule = options.schedule ?? ((run) => setImmediate(run));
|
|
305
|
+
let scheduled = null;
|
|
306
|
+
let resolveScheduled = null;
|
|
307
|
+
let closing = false;
|
|
308
|
+
let closed = false;
|
|
309
|
+
let closePromise = null;
|
|
310
|
+
const closeResolver = async () => {
|
|
311
|
+
if (closed)
|
|
312
|
+
return;
|
|
313
|
+
closed = true;
|
|
314
|
+
if (options.resolver.closeAsync != null)
|
|
315
|
+
await options.resolver.closeAsync();
|
|
316
|
+
else
|
|
317
|
+
options.resolver.close();
|
|
318
|
+
};
|
|
319
|
+
const drain = async () => {
|
|
320
|
+
try {
|
|
321
|
+
while (pending.length > 0) {
|
|
322
|
+
const messages = pending.shift();
|
|
323
|
+
try {
|
|
324
|
+
const replyContext = options.resolver.resolveAsync == null
|
|
325
|
+
? options.resolver.resolve(messages)
|
|
326
|
+
: await options.resolver.resolveAsync(messages);
|
|
327
|
+
options.resolver.remember(messages);
|
|
328
|
+
const first = messages[0];
|
|
329
|
+
if (first != null && options.isActive()) {
|
|
330
|
+
options.onGroup({ key: `${first.chat_id}:${first.msg_id}`, messages, replyContext });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
options.onError(error);
|
|
335
|
+
pending.length = 0;
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
finally {
|
|
341
|
+
resolveScheduled?.();
|
|
342
|
+
resolveScheduled = null;
|
|
343
|
+
scheduled = null;
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
return {
|
|
347
|
+
enqueue(messages) {
|
|
348
|
+
if (closing)
|
|
349
|
+
return;
|
|
350
|
+
pending.push(messages);
|
|
351
|
+
if (scheduled != null)
|
|
352
|
+
return;
|
|
353
|
+
scheduled = new Promise((resolve) => { resolveScheduled = resolve; });
|
|
354
|
+
schedule(() => { void drain(); });
|
|
355
|
+
},
|
|
356
|
+
close() {
|
|
357
|
+
closePromise ??= (async () => {
|
|
358
|
+
closing = true;
|
|
359
|
+
const drainCompletion = scheduled;
|
|
360
|
+
if (drainCompletion != null)
|
|
361
|
+
await drainCompletion;
|
|
362
|
+
await closeResolver();
|
|
363
|
+
})();
|
|
364
|
+
return closePromise;
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
export function createInteractiveListenRuntime(dbPath, factory, options) {
|
|
369
|
+
return createInteractiveListenGroupQueue({
|
|
370
|
+
...options,
|
|
371
|
+
resolver: factory(dbPath, LISTEN_HISTORY_LIMIT),
|
|
372
|
+
});
|
|
373
|
+
}
|
|
271
374
|
export function ListenStatus({ status, unseenCount }) {
|
|
272
375
|
return _jsxs(Text, { dimColor: true, children: [status, unseenCount > 0 ? ` · ↓ ${unseenCount} new messages` : ''] });
|
|
273
376
|
}
|
|
@@ -277,6 +380,9 @@ export function formatInteractiveListenSender(message) {
|
|
|
277
380
|
: `${message.sender} (${message.senderId})`;
|
|
278
381
|
return message.chatName == null ? sender : `${message.chatName} | ${sender}`;
|
|
279
382
|
}
|
|
383
|
+
export function formatInteractiveListenHeader(message) {
|
|
384
|
+
return `[${message.time}] #${message.msgId} ${formatInteractiveListenSender(message)}`;
|
|
385
|
+
}
|
|
280
386
|
export function ListenStatusArea({ status, unseenCount, autoDownload, }) {
|
|
281
387
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ListenStatus, { status: status, unseenCount: unseenCount }), autoDownload ? _jsx(Text, { dimColor: true, children: "Auto-download enabled" }) : null] }));
|
|
282
388
|
}
|
|
@@ -285,7 +391,7 @@ export function calculateListenMessagePaneHeight(terminalHeight, hasNote, autoDo
|
|
|
285
391
|
return Math.max(2, terminalHeight - reservedLines);
|
|
286
392
|
}
|
|
287
393
|
export async function runInteractiveListen(write, run) {
|
|
288
|
-
return
|
|
394
|
+
return withAlternateScroll({ write, run });
|
|
289
395
|
}
|
|
290
396
|
export async function renderInteractiveListen(options) {
|
|
291
397
|
await runInteractiveListen(process.stdout.write.bind(process.stdout), async () => {
|
|
@@ -293,7 +399,7 @@ export async function renderInteractiveListen(options) {
|
|
|
293
399
|
await app.waitUntilExit();
|
|
294
400
|
});
|
|
295
401
|
}
|
|
296
|
-
function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, autoDownload, showChatName, createClient, stopSignal, onRequestStop, }) {
|
|
402
|
+
export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo, showMedia, autoDownload, showChatName, createClient, stopSignal, onRequestStop, createReplyResolver, shutdownRequests, }) {
|
|
297
403
|
const { exit } = useApp();
|
|
298
404
|
const { stdout } = useStdout();
|
|
299
405
|
const terminalMetrics = useTerminalMetrics(stdout);
|
|
@@ -307,23 +413,88 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, au
|
|
|
307
413
|
const [downloadStates, setDownloadStates] = useState({});
|
|
308
414
|
const [scrollState, setScrollState] = useState({ offset: 0, unseenCount: 0 });
|
|
309
415
|
const [sendTargetLabel, setSendTargetLabel] = useState(sendTo == null ? '' : buildSendTargetLabel(sendTo));
|
|
416
|
+
const [knownGroup, setKnownGroup] = useState(undefined);
|
|
417
|
+
const clientRef = useRef(null);
|
|
418
|
+
const replyExecutionLockRef = useRef(false);
|
|
419
|
+
const inputGenerationRef = useRef({});
|
|
420
|
+
const commandSelectionRef = useRef(0);
|
|
421
|
+
const knownGroupRef = useRef(undefined);
|
|
422
|
+
const groupLookupGenerationRef = useRef({});
|
|
423
|
+
useEffect(() => {
|
|
424
|
+
groupLookupGenerationRef.current = {};
|
|
425
|
+
knownGroupRef.current = undefined;
|
|
426
|
+
setKnownGroup(undefined);
|
|
427
|
+
}, [sendTo]);
|
|
428
|
+
const groupCommand = useGroupCommand(useCallback(async (request, options) => {
|
|
429
|
+
const client = clientRef.current;
|
|
430
|
+
if (client == null)
|
|
431
|
+
return { ok: false, error: { code: 'connection_not_ready', message: 'Telegram connection is not ready.' } };
|
|
432
|
+
if (sendTo == null)
|
|
433
|
+
return { ok: false, error: { code: 'ambiguous_chat', message: 'Select exactly one target chat with --send-to.' } };
|
|
434
|
+
let knownGroup = knownGroupRef.current;
|
|
435
|
+
const refreshOwnershipCapability = request.key === 'admin transfer-owner'
|
|
436
|
+
&& options?.confirmed === true
|
|
437
|
+
&& options.ownershipPassword == null;
|
|
438
|
+
if (knownGroup == null || refreshOwnershipCapability) {
|
|
439
|
+
const lookup = {};
|
|
440
|
+
groupLookupGenerationRef.current = lookup;
|
|
441
|
+
knownGroup = await client.groups.getGroup(sendTo);
|
|
442
|
+
if (lookup !== groupLookupGenerationRef.current || clientRef.current !== client) {
|
|
443
|
+
return { ok: false, error: { code: 'connection_not_ready', message: 'Telegram connection changed during group verification.' } };
|
|
444
|
+
}
|
|
445
|
+
knownGroupRef.current = knownGroup;
|
|
446
|
+
setKnownGroup(knownGroup);
|
|
447
|
+
}
|
|
448
|
+
return executeGroupCommand(request, {
|
|
449
|
+
chat: sendTo,
|
|
450
|
+
groups: new GroupWriteService(client.groups),
|
|
451
|
+
confirmed: options?.confirmed ?? false,
|
|
452
|
+
confirmationTitle: options?.confirmationTitle,
|
|
453
|
+
ownershipPassword: options?.ownershipPassword,
|
|
454
|
+
knownGroup,
|
|
455
|
+
connectionReady: true,
|
|
456
|
+
targetAvailable: true,
|
|
457
|
+
targetCount: 1,
|
|
458
|
+
invalidateGroup: async () => {
|
|
459
|
+
knownGroupRef.current = undefined;
|
|
460
|
+
setKnownGroup(undefined);
|
|
461
|
+
if (request.key === 'chat delete' || request.key === 'chat leave')
|
|
462
|
+
return;
|
|
463
|
+
const lookup = {};
|
|
464
|
+
groupLookupGenerationRef.current = lookup;
|
|
465
|
+
try {
|
|
466
|
+
const refreshed = await client.groups.getGroup(sendTo);
|
|
467
|
+
if (lookup === groupLookupGenerationRef.current && clientRef.current === client) {
|
|
468
|
+
knownGroupRef.current = refreshed;
|
|
469
|
+
setKnownGroup(refreshed);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
if (lookup === groupLookupGenerationRef.current && clientRef.current === client)
|
|
474
|
+
setNote(`Group updated; refresh failed: ${messageFromError(error)}`);
|
|
475
|
+
}
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
}, [sendTo]));
|
|
479
|
+
const irreversibleGroupWriteRef = useRef(false);
|
|
480
|
+
irreversibleGroupWriteRef.current = groupCommand.state.kind === 'executing' && groupCommand.state.irreversible === true;
|
|
310
481
|
const terminalWidth = terminalMetrics.columns;
|
|
311
482
|
const terminalHeight = terminalMetrics.rows;
|
|
312
483
|
const previewColorDepth = interactiveListenPreviewColorDepth(terminalMetrics.colorDepth);
|
|
313
484
|
const contentWidth = listenContentWidth(terminalWidth);
|
|
314
485
|
const previewWidth = Math.max(1, Math.min(24, contentWidth - 2));
|
|
486
|
+
const renderContextRef = useRef({ showMedia, previewWidth, colorDepth: previewColorDepth, showChatName });
|
|
487
|
+
renderContextRef.current = { showMedia, previewWidth, colorDepth: previewColorDepth, showChatName };
|
|
315
488
|
const messageViewCacheRef = useRef(null);
|
|
316
489
|
if (messageViewCacheRef.current == null)
|
|
317
490
|
messageViewCacheRef.current = new ListenMessageViewCache();
|
|
318
|
-
const messages = useMemo(() => messageViewCacheRef.current.build(messageGroups,
|
|
319
|
-
showMedia,
|
|
320
|
-
previewWidth,
|
|
321
|
-
colorDepth: previewColorDepth,
|
|
322
|
-
showChatName,
|
|
323
|
-
}), [messageGroups, showMedia, previewWidth, previewColorDepth, showChatName]);
|
|
491
|
+
const messages = useMemo(() => messageViewCacheRef.current.build(messageGroups, renderContextRef.current), [messageGroups, showMedia, previewWidth, previewColorDepth, showChatName]);
|
|
324
492
|
const messagePaneHeight = calculateListenMessagePaneHeight(terminalHeight, note.length > 0, autoDownload);
|
|
325
493
|
const visibleMessages = takeListenViewport(messages, messagePaneHeight, scrollState.offset);
|
|
326
|
-
const
|
|
494
|
+
const closeGroupCommand = () => {
|
|
495
|
+
commandSelectionRef.current = 0;
|
|
496
|
+
groupCommand.close();
|
|
497
|
+
};
|
|
327
498
|
const albumAggregatorRef = useRef(null);
|
|
328
499
|
const autoDownloaderRef = useRef(null);
|
|
329
500
|
const pendingAttachmentKeysRef = useRef(new Set());
|
|
@@ -331,6 +502,37 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, au
|
|
|
331
502
|
if (operationControllerRef.current == null)
|
|
332
503
|
operationControllerRef.current = createInteractiveOperationController();
|
|
333
504
|
const stoppingRef = useRef(false);
|
|
505
|
+
const lifecycleStopRef = useRef(null);
|
|
506
|
+
const deferredStopRef = useRef(false);
|
|
507
|
+
const shutdownRequestedRef = useRef(false);
|
|
508
|
+
const shutdownGraceTimerRef = useRef(null);
|
|
509
|
+
function requestOwnershipShutdown(source) {
|
|
510
|
+
if (deferredStopRef.current || shutdownRequestedRef.current) {
|
|
511
|
+
deferredStopRef.current = false;
|
|
512
|
+
shutdownRequestedRef.current = false;
|
|
513
|
+
if (shutdownGraceTimerRef.current)
|
|
514
|
+
clearTimeout(shutdownGraceTimerRef.current);
|
|
515
|
+
shutdownGraceTimerRef.current = null;
|
|
516
|
+
groupCommand.setState({ kind: 'error', message: 'Ownership transfer outcome is indeterminate after forced shutdown.' });
|
|
517
|
+
setTimeout(stopListening, 0);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (source === 'terminal') {
|
|
521
|
+
deferredStopRef.current = true;
|
|
522
|
+
setNote('Group write is already running; wait for its outcome.');
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
shutdownRequestedRef.current = true;
|
|
526
|
+
setNote('Shutdown requested; waiting briefly for the ownership transfer outcome.');
|
|
527
|
+
shutdownGraceTimerRef.current = setTimeout(() => {
|
|
528
|
+
shutdownGraceTimerRef.current = null;
|
|
529
|
+
groupCommand.setState({
|
|
530
|
+
kind: 'error',
|
|
531
|
+
message: 'Ownership transfer outcome is indeterminate because shutdown grace expired.',
|
|
532
|
+
});
|
|
533
|
+
setTimeout(stopListening, 0);
|
|
534
|
+
}, OWNERSHIP_SHUTDOWN_GRACE_MS);
|
|
535
|
+
}
|
|
334
536
|
const seenRef = useRef(new Set());
|
|
335
537
|
const seenOrderRef = useRef([]);
|
|
336
538
|
const downloadableAttachments = collectDownloadableAttachments(visibleMessages);
|
|
@@ -342,11 +544,10 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, au
|
|
|
342
544
|
visible: visibleMessages.length,
|
|
343
545
|
offset: scrollState.offset,
|
|
344
546
|
});
|
|
345
|
-
const
|
|
547
|
+
const scrollMessages = useCallback((direction, amount = 1) => {
|
|
346
548
|
showScrollbar();
|
|
347
|
-
setScrollState((current) => applyScroll(current, direction, Math.max(0, messages.length - 1)));
|
|
549
|
+
setScrollState((current) => applyScroll(current, direction, Math.max(0, messages.length - 1), amount));
|
|
348
550
|
}, [messages.length, showScrollbar]);
|
|
349
|
-
useMouseScroll(handleMouseScroll);
|
|
350
551
|
useEffect(() => {
|
|
351
552
|
const maxOffset = Math.max(0, messages.length - 1);
|
|
352
553
|
setScrollState((current) => {
|
|
@@ -369,10 +570,160 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, au
|
|
|
369
570
|
useInput((inputText, key) => {
|
|
370
571
|
if (isMouseInput(inputText))
|
|
371
572
|
return;
|
|
573
|
+
const modal = groupCommand.state;
|
|
574
|
+
if (modal.kind === 'executing' && modal.irreversible === true) {
|
|
575
|
+
if (key.ctrl && (inputText === 'c' || inputText === 'C' || inputText === '\u0003')) {
|
|
576
|
+
requestOwnershipShutdown('terminal');
|
|
577
|
+
}
|
|
578
|
+
else if (key.escape) {
|
|
579
|
+
setNote('Group write is already running; wait for its outcome.');
|
|
580
|
+
}
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
372
583
|
if (key.ctrl && (inputText === 'c' || inputText === 'C' || inputText === '\u0003')) {
|
|
373
584
|
stopListening();
|
|
374
585
|
return;
|
|
375
586
|
}
|
|
587
|
+
if (modal.kind === 'password')
|
|
588
|
+
return;
|
|
589
|
+
if (modal.kind === 'confirm') {
|
|
590
|
+
if (key.escape) {
|
|
591
|
+
closeGroupCommand();
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
if (key.upArrow || key.downArrow) {
|
|
595
|
+
groupCommand.setState({ ...modal, selectedIndex: modal.selectedIndex === 0 ? 1 : 0 });
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (key.return) {
|
|
599
|
+
if (modal.selectedIndex === 0)
|
|
600
|
+
void groupCommand.runConfirmed(modal.request);
|
|
601
|
+
else
|
|
602
|
+
closeGroupCommand();
|
|
603
|
+
}
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (modal.kind === 'confirm-title') {
|
|
607
|
+
if (key.escape) {
|
|
608
|
+
if (modal.stage === 'title')
|
|
609
|
+
groupCommand.setState({ ...modal, stage: 'confirm', confirmText: '', mismatch: false });
|
|
610
|
+
else
|
|
611
|
+
closeGroupCommand();
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (modal.stage === 'confirm') {
|
|
615
|
+
if (key.upArrow || key.downArrow)
|
|
616
|
+
groupCommand.setState({ ...modal, selectedIndex: modal.selectedIndex === 0 ? 1 : 0 });
|
|
617
|
+
else if (key.return)
|
|
618
|
+
modal.selectedIndex === 0 ? groupCommand.setState({ ...modal, stage: 'title' }) : closeGroupCommand();
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (key.return) {
|
|
622
|
+
const client = clientRef.current;
|
|
623
|
+
if (client == null || sendTo == null) {
|
|
624
|
+
groupCommand.setState({ kind: 'error', message: 'Telegram connection is not ready.' });
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
const submittedTitle = modal.confirmText;
|
|
628
|
+
const lookup = {};
|
|
629
|
+
groupLookupGenerationRef.current = lookup;
|
|
630
|
+
void client.groups.getGroup(sendTo).then((fresh) => {
|
|
631
|
+
if (lookup !== groupLookupGenerationRef.current || clientRef.current !== client)
|
|
632
|
+
return;
|
|
633
|
+
knownGroupRef.current = fresh;
|
|
634
|
+
setKnownGroup(fresh);
|
|
635
|
+
if (fresh.title === submittedTitle)
|
|
636
|
+
void groupCommand.runConfirmed(modal.request, submittedTitle);
|
|
637
|
+
else if ('confirmation' in modal.pending)
|
|
638
|
+
groupCommand.setState({
|
|
639
|
+
...modal,
|
|
640
|
+
pending: { ...modal.pending, confirmation: { ...modal.pending.confirmation, title: fresh.title, target: fresh.title } },
|
|
641
|
+
mismatch: true,
|
|
642
|
+
});
|
|
643
|
+
}).catch((error) => {
|
|
644
|
+
if (lookup === groupLookupGenerationRef.current && clientRef.current === client)
|
|
645
|
+
groupCommand.setState({ kind: 'error', message: messageFromError(error) });
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
else if (key.backspace || key.delete)
|
|
650
|
+
groupCommand.setState({ ...modal, confirmText: modal.confirmText.slice(0, -1), mismatch: false });
|
|
651
|
+
else if (!key.ctrl && !key.meta && inputText)
|
|
652
|
+
groupCommand.setState({ ...modal, confirmText: modal.confirmText + inputText, mismatch: false });
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
if (modal.kind === 'select-permissions') {
|
|
656
|
+
if (key.escape) {
|
|
657
|
+
closeGroupCommand();
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (key.upArrow || key.downArrow) {
|
|
661
|
+
const delta = key.upArrow ? -1 : 1;
|
|
662
|
+
groupCommand.setState({ ...modal, selectedIndex: (modal.selectedIndex + delta + ADMIN_RIGHT_KEYS.length) % ADMIN_RIGHT_KEYS.length });
|
|
663
|
+
}
|
|
664
|
+
else if (inputText === ' ') {
|
|
665
|
+
const right = ADMIN_RIGHT_KEYS[modal.selectedIndex];
|
|
666
|
+
groupCommand.setState({ ...modal, selected: modal.selected.includes(right) ? modal.selected.filter(item => item !== right) : [...modal.selected, right], warning: undefined });
|
|
667
|
+
}
|
|
668
|
+
else if (key.return) {
|
|
669
|
+
if (modal.selected.length === 0)
|
|
670
|
+
groupCommand.setState({ ...modal, warning: 'Select at least one permission.' });
|
|
671
|
+
else {
|
|
672
|
+
const request = Object.freeze({ ...modal.request, values: Object.freeze({ ...modal.request.values, permissions: Object.freeze([...modal.selected]) }) });
|
|
673
|
+
void (async () => {
|
|
674
|
+
const result = await groupCommand.submit(`${modal.originalInput} ${modal.selected.join(',')}`, 0);
|
|
675
|
+
if (result.kind === 'pending' && 'confirmation' in result.pending)
|
|
676
|
+
groupCommand.setState({ kind: 'confirm', pending: result.pending, request, originalInput: modal.originalInput, selectedIndex: 1 });
|
|
677
|
+
})();
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (groupCommand.state.kind === 'result') {
|
|
683
|
+
if (key.escape)
|
|
684
|
+
closeGroupCommand();
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
if (key.escape && groupCommand.state.kind === 'error') {
|
|
688
|
+
closeGroupCommand();
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (replyExecutionLockRef.current) {
|
|
692
|
+
if (key.escape)
|
|
693
|
+
setNote('Reply is still sending; wait for its outcome.');
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (key.pageUp || key.pageDown) {
|
|
697
|
+
scrollMessages(key.pageUp ? 'up' : 'down', Math.max(1, visibleMessages.length));
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
const slashMode = input.trimStart().startsWith('/');
|
|
701
|
+
if (slashMode && key.escape) {
|
|
702
|
+
closeGroupCommand();
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
if (slashMode && (key.upArrow || key.downArrow)) {
|
|
706
|
+
const count = visibleListenCommandMatches(input).length;
|
|
707
|
+
const disabled = listenCommandMenuAvailability(input, knownGroup).map(Boolean);
|
|
708
|
+
const selectedIndex = moveListenCommandSelectionEnabled(commandSelectionRef.current, key.upArrow ? -1 : 1, disabled.slice(0, count));
|
|
709
|
+
commandSelectionRef.current = selectedIndex;
|
|
710
|
+
groupCommand.setState({ kind: 'menu', selectedIndex });
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (slashMode && key.tab) {
|
|
714
|
+
const selected = commandSelectionRef.current;
|
|
715
|
+
const failure = listenCommandMenuAvailability(input, knownGroup)[selected];
|
|
716
|
+
if (failure && 'error' in failure) {
|
|
717
|
+
setNote(failure.error.message);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
inputGenerationRef.current = {};
|
|
721
|
+
setInput(completeListenCommand(input, selected));
|
|
722
|
+
commandSelectionRef.current = 0;
|
|
723
|
+
groupCommand.setState({ kind: 'menu', selectedIndex: 0 });
|
|
724
|
+
setFocus('input');
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
376
727
|
if (key.tab) {
|
|
377
728
|
if (focus === 'attachments') {
|
|
378
729
|
setFocus('input');
|
|
@@ -406,13 +757,95 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, au
|
|
|
406
757
|
}
|
|
407
758
|
return;
|
|
408
759
|
}
|
|
409
|
-
if (
|
|
760
|
+
if (key.upArrow || key.downArrow) {
|
|
761
|
+
scrollMessages(key.upArrow ? 'up' : 'down');
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
if (sending || groupCommand.state.kind === 'executing')
|
|
410
765
|
return;
|
|
411
766
|
if (key.return) {
|
|
767
|
+
if (slashMode) {
|
|
768
|
+
const selected = commandSelectionRef.current;
|
|
769
|
+
const failure = listenCommandMenuAvailability(input, knownGroup)[selected];
|
|
770
|
+
if (failure && 'error' in failure) {
|
|
771
|
+
setNote(failure.error.message);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
const match = visibleListenCommandMatches(input)[selected];
|
|
775
|
+
if (!match) {
|
|
776
|
+
setNote('No matching command.');
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
const parsed = parseSelectedListenCommand(input, match);
|
|
780
|
+
if (parsed.kind === 'complete') {
|
|
781
|
+
inputGenerationRef.current = {};
|
|
782
|
+
setInput(parsed.input);
|
|
783
|
+
commandSelectionRef.current = 0;
|
|
784
|
+
groupCommand.setState({ kind: 'menu', selectedIndex: 0 });
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (parsed.kind === 'error') {
|
|
788
|
+
setNote(parsed.usage == null ? parsed.message : `${parsed.message} · usage: /${parsed.usage}`);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (parsed.kind === 'reply') {
|
|
792
|
+
if (replyExecutionLockRef.current)
|
|
793
|
+
return;
|
|
794
|
+
if (sendTo == null) {
|
|
795
|
+
setNote('set --send-to before replying');
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
const client = clientRef.current;
|
|
799
|
+
if (client == null) {
|
|
800
|
+
setNote('connection is not ready');
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
const access = new WriteAccessPolicy().check();
|
|
804
|
+
if (!access.ok) {
|
|
805
|
+
setNote(access.error.message);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
replyExecutionLockRef.current = true;
|
|
809
|
+
const ownedGeneration = inputGenerationRef.current;
|
|
810
|
+
const originalInput = input;
|
|
811
|
+
setSending(true);
|
|
812
|
+
setNote('sending...');
|
|
813
|
+
void executeListenReply(client, sendTo, parsed.command).then((sentMessages) => {
|
|
814
|
+
if (ownedGeneration !== inputGenerationRef.current)
|
|
815
|
+
return;
|
|
816
|
+
for (const sentMessage of sentMessages)
|
|
817
|
+
acceptListenMessage(sentMessage, seenRef.current, seenOrderRef.current, (message) => {
|
|
818
|
+
registerPendingAttachmentKeys(pendingAttachmentKeysRef.current, message, showMedia);
|
|
819
|
+
autoDownloaderRef.current?.enqueue(message);
|
|
820
|
+
albumAggregatorRef.current?.add(message);
|
|
821
|
+
});
|
|
822
|
+
setInput('');
|
|
823
|
+
setNote(`replied to #${parsed.command.reply}`);
|
|
824
|
+
}).catch((error) => {
|
|
825
|
+
if (ownedGeneration === inputGenerationRef.current) {
|
|
826
|
+
setInput(originalInput);
|
|
827
|
+
setNote(`send failed: ${messageFromError(error)}`);
|
|
828
|
+
}
|
|
829
|
+
}).finally(() => {
|
|
830
|
+
replyExecutionLockRef.current = false;
|
|
831
|
+
if (ownedGeneration === inputGenerationRef.current)
|
|
832
|
+
setSending(false);
|
|
833
|
+
});
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
void groupCommand.submitParsed(parsed.request, input, selected).then((outcome) => {
|
|
837
|
+
if (!outcome.applied)
|
|
838
|
+
return;
|
|
839
|
+
if (outcome.kind === 'result' && outcome.result.ok)
|
|
840
|
+
setInput('');
|
|
841
|
+
});
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
412
844
|
void sendMessage(input);
|
|
413
845
|
return;
|
|
414
846
|
}
|
|
415
847
|
if (key.backspace || key.delete) {
|
|
848
|
+
inputGenerationRef.current = {};
|
|
416
849
|
setInput((current) => current.slice(0, -1));
|
|
417
850
|
return;
|
|
418
851
|
}
|
|
@@ -420,26 +853,58 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, au
|
|
|
420
853
|
return;
|
|
421
854
|
}
|
|
422
855
|
if (!key.ctrl && !key.meta && inputText.length > 0) {
|
|
423
|
-
|
|
856
|
+
inputGenerationRef.current = {};
|
|
857
|
+
setInput((current) => {
|
|
858
|
+
const next = current + inputText;
|
|
859
|
+
if (next.trimStart().startsWith('/')) {
|
|
860
|
+
setFocus('input');
|
|
861
|
+
commandSelectionRef.current = 0;
|
|
862
|
+
groupCommand.setState({ kind: 'menu', selectedIndex: 0 });
|
|
863
|
+
}
|
|
864
|
+
return next;
|
|
865
|
+
});
|
|
424
866
|
}
|
|
425
867
|
});
|
|
426
868
|
useEffect(() => {
|
|
427
869
|
const generation = operationControllerRef.current.beginGeneration();
|
|
428
870
|
const isActive = generation.isActive;
|
|
871
|
+
const lifecycleStop = new AbortController();
|
|
872
|
+
lifecycleStopRef.current = lifecycleStop;
|
|
429
873
|
if (stopSignal.aborted) {
|
|
430
874
|
exit();
|
|
431
875
|
return;
|
|
432
876
|
}
|
|
433
877
|
const stopFromSignal = () => {
|
|
878
|
+
if (stoppingRef.current)
|
|
879
|
+
return;
|
|
880
|
+
if (irreversibleGroupWriteRef.current) {
|
|
881
|
+
requestOwnershipShutdown('external');
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
434
884
|
stopListening();
|
|
435
885
|
};
|
|
436
|
-
|
|
886
|
+
const unsubscribeShutdown = shutdownRequests?.subscribe(stopFromSignal);
|
|
887
|
+
if (shutdownRequests == null)
|
|
888
|
+
stopSignal.addEventListener('abort', stopFromSignal);
|
|
889
|
+
const groupQueue = createInteractiveListenRuntime(dbPath, createReplyResolver ?? createListenReplyResolver, {
|
|
890
|
+
isActive,
|
|
891
|
+
onGroup: (group) => {
|
|
892
|
+
setScrollState((current) => applyMessageArrival(current));
|
|
893
|
+
setMessageGroups((current) => [...current, group].slice(-LISTEN_HISTORY_LIMIT));
|
|
894
|
+
},
|
|
895
|
+
onError: (error) => {
|
|
896
|
+
if (!isActive())
|
|
897
|
+
return;
|
|
898
|
+
setStatus(`listen failed: ${messageFromError(error)}`);
|
|
899
|
+
onRequestStop();
|
|
900
|
+
exit();
|
|
901
|
+
},
|
|
902
|
+
});
|
|
437
903
|
const albumAggregator = new ListenAlbumAggregator({
|
|
438
904
|
emit: (group) => {
|
|
439
905
|
if (!isActive())
|
|
440
906
|
return;
|
|
441
|
-
|
|
442
|
-
setMessageGroups((current) => pruneListenMessageGroups([...current, group]).groups);
|
|
907
|
+
groupQueue.enqueue(group);
|
|
443
908
|
},
|
|
444
909
|
});
|
|
445
910
|
albumAggregatorRef.current = albumAggregator;
|
|
@@ -462,7 +927,7 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, au
|
|
|
462
927
|
chats,
|
|
463
928
|
persist,
|
|
464
929
|
retrySeconds,
|
|
465
|
-
signal:
|
|
930
|
+
signal: lifecycleStop.signal,
|
|
466
931
|
createClient,
|
|
467
932
|
createCoordinator: () => autoDownloader,
|
|
468
933
|
onCoordinator: (coordinator) => {
|
|
@@ -472,8 +937,19 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, au
|
|
|
472
937
|
onClient: (client) => {
|
|
473
938
|
if (!isActive())
|
|
474
939
|
return;
|
|
940
|
+
groupLookupGenerationRef.current = {};
|
|
941
|
+
knownGroupRef.current = undefined;
|
|
942
|
+
setKnownGroup(undefined);
|
|
475
943
|
clientRef.current = client;
|
|
476
944
|
if (client != null && sendTo != null) {
|
|
945
|
+
const lookup = {};
|
|
946
|
+
groupLookupGenerationRef.current = lookup;
|
|
947
|
+
void client.groups.getGroup(sendTo).then((group) => {
|
|
948
|
+
if (isActive() && lookup === groupLookupGenerationRef.current && clientRef.current === client) {
|
|
949
|
+
knownGroupRef.current = group;
|
|
950
|
+
setKnownGroup(group);
|
|
951
|
+
}
|
|
952
|
+
}).catch(() => undefined);
|
|
477
953
|
void resolveSendTargetLabel(client, sendTo).then((label) => {
|
|
478
954
|
if (isActive() && label != null)
|
|
479
955
|
setSendTargetLabel(label);
|
|
@@ -522,29 +998,50 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, au
|
|
|
522
998
|
albumAggregator.flush();
|
|
523
999
|
},
|
|
524
1000
|
}).finally(() => {
|
|
525
|
-
if (isActive() && !stopSignal.aborted) {
|
|
1001
|
+
if (isActive() && !stopSignal.aborted && !stoppingRef.current) {
|
|
526
1002
|
onRequestStop();
|
|
527
1003
|
exit();
|
|
528
1004
|
}
|
|
529
1005
|
});
|
|
530
1006
|
return () => {
|
|
1007
|
+
groupLookupGenerationRef.current = {};
|
|
1008
|
+
knownGroupRef.current = undefined;
|
|
1009
|
+
unsubscribeShutdown?.();
|
|
1010
|
+
if (shutdownRequests == null)
|
|
1011
|
+
stopSignal.removeEventListener('abort', stopFromSignal);
|
|
1012
|
+
albumAggregator.flush();
|
|
531
1013
|
generation.dispose();
|
|
532
|
-
stopSignal.removeEventListener('abort', stopFromSignal);
|
|
533
1014
|
albumAggregator.dispose();
|
|
1015
|
+
void groupQueue.close();
|
|
534
1016
|
if (albumAggregatorRef.current === albumAggregator)
|
|
535
1017
|
albumAggregatorRef.current = null;
|
|
536
|
-
|
|
1018
|
+
lifecycleStop.abort();
|
|
537
1019
|
clientRef.current = null;
|
|
538
1020
|
autoDownloader?.stop();
|
|
539
1021
|
void autoDownloader?.waitForActive();
|
|
540
1022
|
if (autoDownloaderRef.current === autoDownloader)
|
|
541
1023
|
autoDownloaderRef.current = null;
|
|
1024
|
+
if (lifecycleStopRef.current === lifecycleStop)
|
|
1025
|
+
lifecycleStopRef.current = null;
|
|
1026
|
+
if (shutdownGraceTimerRef.current)
|
|
1027
|
+
clearTimeout(shutdownGraceTimerRef.current);
|
|
1028
|
+
shutdownGraceTimerRef.current = null;
|
|
542
1029
|
};
|
|
543
|
-
}, [autoDownload, chats, createClient, persist, retrySeconds, sendTo, showMedia, exit, stopSignal, onRequestStop]);
|
|
1030
|
+
}, [autoDownload, chats, createClient, createReplyResolver, dbPath, persist, retrySeconds, sendTo, showMedia, exit, stopSignal, shutdownRequests, onRequestStop]);
|
|
544
1031
|
const sendMessage = async (text) => {
|
|
1032
|
+
const access = new WriteAccessPolicy().check();
|
|
1033
|
+
if (!access.ok) {
|
|
1034
|
+
setNote(access.error.message);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
545
1037
|
const trimmed = text.trim();
|
|
546
1038
|
if (!trimmed)
|
|
547
1039
|
return;
|
|
1040
|
+
const command = parseListenComposerInput(trimmed);
|
|
1041
|
+
if (command.kind === 'error') {
|
|
1042
|
+
setNote(command.error);
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
548
1045
|
if (sendTo == null) {
|
|
549
1046
|
setNote('set --send-to before sending');
|
|
550
1047
|
return;
|
|
@@ -559,20 +1056,23 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, au
|
|
|
559
1056
|
setSending(true);
|
|
560
1057
|
setNote('sending...');
|
|
561
1058
|
try {
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
1059
|
+
const sentMessages = command.kind === 'reply'
|
|
1060
|
+
? await executeListenReply(client, sendTo, command)
|
|
1061
|
+
: [await client.sendMessage({
|
|
1062
|
+
chat: sendTo,
|
|
1063
|
+
message: command.content,
|
|
1064
|
+
linkPreview: true,
|
|
1065
|
+
})].flatMap(({ sent_message: message }) => message == null ? [] : [message]);
|
|
1066
|
+
if (isCurrent()) {
|
|
1067
|
+
for (const sentMessage of sentMessages)
|
|
1068
|
+
acceptListenMessage(sentMessage, seenRef.current, seenOrderRef.current, (message) => {
|
|
1069
|
+
registerPendingAttachmentKeys(pendingAttachmentKeysRef.current, message, showMedia);
|
|
1070
|
+
autoDownloaderRef.current?.enqueue(message);
|
|
1071
|
+
albumAggregatorRef.current?.add(message);
|
|
1072
|
+
});
|
|
573
1073
|
}
|
|
574
1074
|
if (isCurrent())
|
|
575
|
-
setNote('sent');
|
|
1075
|
+
setNote(command.kind === 'reply' ? `replied to #${command.reply}` : 'sent');
|
|
576
1076
|
}
|
|
577
1077
|
catch (error) {
|
|
578
1078
|
if (isCurrent())
|
|
@@ -604,7 +1104,7 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, au
|
|
|
604
1104
|
onProgress: (downloaded, total) => {
|
|
605
1105
|
if (!isCurrent())
|
|
606
1106
|
return;
|
|
607
|
-
const progress =
|
|
1107
|
+
const progress = attachmentDownloadProgress(downloaded, total);
|
|
608
1108
|
setDownloadStates((current) => ({ ...current, [item.key]: { status: 'downloading', progress } }));
|
|
609
1109
|
},
|
|
610
1110
|
});
|
|
@@ -626,12 +1126,28 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, au
|
|
|
626
1126
|
setTimeout(exit, 0);
|
|
627
1127
|
onRequestStop();
|
|
628
1128
|
autoDownloaderRef.current?.stop();
|
|
629
|
-
|
|
1129
|
+
lifecycleStopRef.current?.abort();
|
|
630
1130
|
};
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
1131
|
+
useEffect(() => {
|
|
1132
|
+
if (groupCommand.state.kind !== 'result' && groupCommand.state.kind !== 'error')
|
|
1133
|
+
return;
|
|
1134
|
+
if (!deferredStopRef.current && !shutdownRequestedRef.current)
|
|
1135
|
+
return;
|
|
1136
|
+
deferredStopRef.current = false;
|
|
1137
|
+
shutdownRequestedRef.current = false;
|
|
1138
|
+
if (shutdownGraceTimerRef.current)
|
|
1139
|
+
clearTimeout(shutdownGraceTimerRef.current);
|
|
1140
|
+
shutdownGraceTimerRef.current = null;
|
|
1141
|
+
const pending = setTimeout(stopListening, 0);
|
|
1142
|
+
return () => clearTimeout(pending);
|
|
1143
|
+
}, [groupCommand.state]);
|
|
1144
|
+
const permissionState = groupCommand.state.kind === 'select-permissions' ? groupCommand.state : null;
|
|
1145
|
+
return (_jsxs(Box, { flexDirection: "row", width: terminalWidth, height: terminalHeight, overflow: "hidden", children: [_jsxs(Box, { flexDirection: "column", width: contentWidth, height: terminalHeight, overflow: "hidden", children: [_jsx(ListenStatusArea, { status: status, unseenCount: scrollState.unseenCount, autoDownload: autoDownload }), note ? _jsx(Text, { dimColor: true, children: note }) : null, groupCommand.state.kind === 'result' ? _jsx(Box, { flexGrow: 1, flexDirection: "column", children: _jsx(GroupCommandResult, { state: groupCommand.state, width: contentWidth }) }) : _jsxs(Box, { flexGrow: 1, flexDirection: "column", children: [_jsx(GroupCommandResult, { state: groupCommand.state, width: contentWidth }), groupCommand.state.kind === 'confirm' && 'confirmation' in groupCommand.state.pending ? _jsx(GroupCommandConfirm, { confirmation: groupCommand.state.pending.confirmation, selectedIndex: groupCommand.state.selectedIndex, width: contentWidth }) : null, groupCommand.state.kind === 'confirm-title' && 'confirmation' in groupCommand.state.pending ? groupCommand.state.stage === 'confirm'
|
|
1146
|
+
? _jsx(GroupCommandConfirm, { confirmation: groupCommand.state.pending.confirmation, selectedIndex: groupCommand.state.selectedIndex, width: contentWidth })
|
|
1147
|
+
: _jsxs(Box, { flexDirection: "column", width: contentWidth, children: [_jsx(Text, { color: "yellow", children: truncateCell('Type the exact title to permanently delete this chat:', contentWidth) }), _jsx(Text, { children: truncateCell(groupCommand.state.pending.confirmation.title ?? '', contentWidth) }), _jsx(Text, { color: "#8ecbff", children: truncateCell(`› ${groupCommand.state.confirmText}`, contentWidth) }), groupCommand.state.mismatch ? _jsx(Text, { color: "red", children: truncateCell('Title does not match exactly.', contentWidth) }) : null, _jsx(Text, { dimColor: true, children: truncateCell('Enter verify · Esc back', contentWidth) })] }) : null, groupCommand.state.kind === 'password' ? _jsx(SecureInput, { label: "Telegram 2FA password", onSubmit: (value) => { void groupCommand.runWithOwnershipPassword(value); }, onCancel: closeGroupCommand }) : null, permissionState ? _jsxs(Box, { flexDirection: "column", width: contentWidth, children: [_jsx(Text, { color: "#8ecbff", children: truncateCell('Administrator permissions', contentWidth) }), ADMIN_RIGHT_KEYS.map((right, index) => _jsx(Text, { color: index === permissionState.selectedIndex ? '#8ecbff' : undefined, children: truncateCell(`${index === permissionState.selectedIndex ? '› ' : ' '}[${permissionState.selected.includes(right) ? 'x' : ' '}] ${right}`, contentWidth) }, right)), permissionState.warning ? _jsx(Text, { color: "yellow", children: truncateCell(permissionState.warning, contentWidth) }) : null, _jsx(Text, { dimColor: true, children: truncateCell('↑/↓ select · Space toggle · Enter continue · Esc cancel', contentWidth) })] }) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", flexGrow: 1, overflow: "hidden", children: [messages.length === 0 ? _jsx(Text, { dimColor: true, children: "Waiting for new messages..." }) : null, visibleMessages.map((message) => (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { dimColor: true, wrap: "truncate-end", children: formatInteractiveListenHeader(message) }), message.replyContext == null ? null : _jsx(Text, { wrap: "truncate-end", children: formatReplyContext(message.replyContext) }), message.content == null ? null : _jsx(Text, { wrap: "truncate-end", children: message.content }), message.mediaSummary == null ? null : _jsx(Text, { wrap: "truncate-end", children: message.mediaSummary }), message.media.map((item, mediaIndex) => {
|
|
1148
|
+
const attachmentKey = attachmentDownloadKeyAt(message.media, mediaIndex);
|
|
1149
|
+
return (_jsx(ListenAttachmentWithPreview, { label: item.label, downloadable: item.downloadable, selected: focus === 'attachments' && selectedAttachment?.key === attachmentKey, state: downloadStates[attachmentKey] ?? { status: 'idle' }, previewCells: item.previewCells }, attachmentKey));
|
|
1150
|
+
}), _jsx(Text, { dimColor: true, children: MESSAGE_SEPARATOR })] }, message.key)))] })] }), groupCommand.state.kind !== 'result' ? _jsxs(Box, { flexDirection: "column", children: [sendTo == null ? _jsx(Text, { dimColor: true, children: "Set --send-to <chat> (or pass one chat to listen) before sending messages." }) : null, _jsxs(Box, { marginTop: 1, flexDirection: "column", flexShrink: 0, children: [input.trimStart().startsWith('/') && groupCommand.state.kind === 'menu' ? _jsx(ListenCommandMenu, { input: input, selectedIndex: groupCommand.state.selectedIndex, width: contentWidth, knownGroup: knownGroup }) : null, _jsx(ListenComposer, { input: input, sendTargetLabel: sendTo == null ? '(not selected)' : sendTargetLabel, terminalWidth: contentWidth, sending: sending, cursorVisible: focus === 'input', hint: focus === 'attachments' ? '↑/↓ select · Enter download · Tab input' : 'Wheel/↑/↓/PgUp/PgDn scroll · Drag select · Ctrl+C exit' })] })] }) : null] }), _jsx(ListenScrollbar, { height: terminalHeight, visible: scrollbarVisible, geometry: scrollbarGeometry })] }));
|
|
635
1151
|
}
|
|
636
1152
|
export class ListenMessageViewCache {
|
|
637
1153
|
entries = new Map();
|
|
@@ -641,13 +1157,16 @@ export class ListenMessageViewCache {
|
|
|
641
1157
|
build(groups, context) {
|
|
642
1158
|
const previewWidth = normalizedPreviewWidth(context.previewWidth);
|
|
643
1159
|
const nextEntries = new Map();
|
|
644
|
-
const messages = groups.map((
|
|
1160
|
+
const messages = groups.map((input) => {
|
|
1161
|
+
const group = Array.isArray(input) ? input : input.messages;
|
|
1162
|
+
const replyContext = Array.isArray(input) ? context.replyContext : input.replyContext;
|
|
645
1163
|
const first = group[0];
|
|
646
1164
|
if (first == null)
|
|
647
1165
|
throw new Error('Cannot render an empty listen message group');
|
|
648
|
-
const key = `${first.chat_id}:${first.msg_id}
|
|
1166
|
+
const key = Array.isArray(input) ? `${first.chat_id}:${first.msg_id}` : input.key;
|
|
649
1167
|
const cached = this.entries.get(key);
|
|
650
1168
|
if (cached?.group === group
|
|
1169
|
+
&& cached.replyContext === replyContext
|
|
651
1170
|
&& cached.showMedia === context.showMedia
|
|
652
1171
|
&& cached.previewWidth === previewWidth
|
|
653
1172
|
&& cached.colorDepth === context.colorDepth
|
|
@@ -655,9 +1174,10 @@ export class ListenMessageViewCache {
|
|
|
655
1174
|
nextEntries.set(key, cached);
|
|
656
1175
|
return cached.message;
|
|
657
1176
|
}
|
|
658
|
-
const message = toListenMessage(group, { ...context, previewWidth });
|
|
1177
|
+
const message = toListenMessage(group, { ...context, previewWidth, replyContext });
|
|
659
1178
|
nextEntries.set(key, {
|
|
660
1179
|
group,
|
|
1180
|
+
replyContext,
|
|
661
1181
|
showMedia: context.showMedia,
|
|
662
1182
|
previewWidth,
|
|
663
1183
|
colorDepth: context.colorDepth,
|
|
@@ -677,7 +1197,7 @@ export function pruneListenMessageGroups(groups, limit = LISTEN_HISTORY_LIMIT) {
|
|
|
677
1197
|
return {
|
|
678
1198
|
groups: groups.slice(removeCount),
|
|
679
1199
|
removedKeys: removed.flatMap((group) => {
|
|
680
|
-
const first = group[0];
|
|
1200
|
+
const first = Array.isArray(group) ? group[0] : group.messages[0];
|
|
681
1201
|
return first == null ? [] : [`${first.chat_id}:${first.msg_id}`];
|
|
682
1202
|
}),
|
|
683
1203
|
};
|
|
@@ -689,8 +1209,8 @@ export function toListenMessage(messages, context) {
|
|
|
689
1209
|
const renderContext = typeof context === 'boolean'
|
|
690
1210
|
? { showMedia: context, previewWidth: 1, colorDepth: 1, showChatName: false }
|
|
691
1211
|
: context;
|
|
692
|
-
const { showMedia, previewWidth, colorDepth, showChatName = false } = renderContext;
|
|
693
|
-
const formatted = buildListenMessage(messages, { showMedia, showChatName });
|
|
1212
|
+
const { showMedia, previewWidth, colorDepth, showChatName = false, replyContext } = renderContext;
|
|
1213
|
+
const formatted = buildListenMessage(messages, { showMedia, showChatName, replyContext });
|
|
694
1214
|
const decodePreview = renderContext.decodePreview ?? decodeImagePreview;
|
|
695
1215
|
const media = formatted.media.map((attachment) => {
|
|
696
1216
|
if (attachment.previewJpegBase64 == null || colorDepth < 24)
|