@will-17173/telegram-cli 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +65 -9
  2. package/README.zh-CN.md +64 -9
  3. package/dist/cli/app.js +1 -1
  4. package/dist/commands/account.js +48 -4
  5. package/dist/commands/account.js.map +1 -1
  6. package/dist/commands/group-write.js +88 -0
  7. package/dist/commands/group-write.js.map +1 -0
  8. package/dist/commands/group.js +20 -6
  9. package/dist/commands/group.js.map +1 -1
  10. package/dist/commands/telegram.js +90 -71
  11. package/dist/commands/telegram.js.map +1 -1
  12. package/dist/group-commands/catalog.js +69 -0
  13. package/dist/group-commands/catalog.js.map +1 -0
  14. package/dist/group-commands/executor.js +93 -0
  15. package/dist/group-commands/executor.js.map +1 -0
  16. package/dist/group-commands/parser.js +268 -0
  17. package/dist/group-commands/parser.js.map +1 -0
  18. package/dist/group-commands/tokenize.js +51 -0
  19. package/dist/group-commands/tokenize.js.map +1 -0
  20. package/dist/group-commands/types.js +2 -0
  21. package/dist/group-commands/types.js.map +1 -0
  22. package/dist/index.js +0 -0
  23. package/dist/listen-commands/catalog.js +61 -0
  24. package/dist/listen-commands/catalog.js.map +1 -0
  25. package/dist/listen-commands/dispatch.js +46 -0
  26. package/dist/listen-commands/dispatch.js.map +1 -0
  27. package/dist/listen-commands/match.js +113 -0
  28. package/dist/listen-commands/match.js.map +1 -0
  29. package/dist/presenters/group.js +24 -0
  30. package/dist/presenters/group.js.map +1 -1
  31. package/dist/presenters/human.js +22 -0
  32. package/dist/presenters/human.js.map +1 -1
  33. package/dist/presenters/ink/display-width.js +45 -7
  34. package/dist/presenters/ink/display-width.js.map +1 -1
  35. package/dist/presenters/ink/group-command-confirm.js +13 -0
  36. package/dist/presenters/ink/group-command-confirm.js.map +1 -0
  37. package/dist/presenters/ink/group-command-result.js +21 -0
  38. package/dist/presenters/ink/group-command-result.js.map +1 -0
  39. package/dist/presenters/ink/listen-command-menu.js +38 -0
  40. package/dist/presenters/ink/listen-command-menu.js.map +1 -0
  41. package/dist/presenters/ink/listen-scroll.js +5 -1
  42. package/dist/presenters/ink/listen-scroll.js.map +1 -1
  43. package/dist/presenters/ink/listen.js +445 -45
  44. package/dist/presenters/ink/listen.js.map +1 -1
  45. package/dist/presenters/ink/use-group-command.js +125 -0
  46. package/dist/presenters/ink/use-group-command.js.map +1 -0
  47. package/dist/presenters/listen-message.js +23 -6
  48. package/dist/presenters/listen-message.js.map +1 -1
  49. package/dist/presenters/logical-message.js +78 -0
  50. package/dist/presenters/logical-message.js.map +1 -0
  51. package/dist/services/group-service.js.map +1 -1
  52. package/dist/services/group-write-service.js +165 -0
  53. package/dist/services/group-write-service.js.map +1 -0
  54. package/dist/services/listen-album-aggregator.js +9 -29
  55. package/dist/services/listen-album-aggregator.js.map +1 -1
  56. package/dist/services/listen-attachment.js +23 -6
  57. package/dist/services/listen-attachment.js.map +1 -1
  58. package/dist/services/listen-composer-command.js +102 -0
  59. package/dist/services/listen-composer-command.js.map +1 -0
  60. package/dist/services/listen-reply-resolver.js +126 -0
  61. package/dist/services/listen-reply-resolver.js.map +1 -0
  62. package/dist/services/query-service.js +59 -3
  63. package/dist/services/query-service.js.map +1 -1
  64. package/dist/services/reply-context.js +29 -0
  65. package/dist/services/reply-context.js.map +1 -0
  66. package/dist/storage/message-db.js +241 -5
  67. package/dist/storage/message-db.js.map +1 -1
  68. package/dist/telegram/fake-group-management.js +117 -0
  69. package/dist/telegram/fake-group-management.js.map +1 -1
  70. package/dist/telegram/group-types.js +40 -0
  71. package/dist/telegram/group-types.js.map +1 -1
  72. package/dist/telegram/group-write-types.js +2 -0
  73. package/dist/telegram/group-write-types.js.map +1 -0
  74. package/dist/telegram/mtcute-client.js +1 -1
  75. package/dist/telegram/mtcute-client.js.map +1 -1
  76. package/dist/telegram/mtcute-group-helpers.js +56 -0
  77. package/dist/telegram/mtcute-group-helpers.js.map +1 -0
  78. package/dist/telegram/mtcute-group-invites.js +82 -0
  79. package/dist/telegram/mtcute-group-invites.js.map +1 -0
  80. package/dist/telegram/mtcute-group-management.js +60 -31
  81. package/dist/telegram/mtcute-group-management.js.map +1 -1
  82. package/dist/telegram/mtcute-group-members.js +118 -0
  83. package/dist/telegram/mtcute-group-members.js.map +1 -0
  84. package/dist/telegram/mtcute-group-settings.js +146 -0
  85. package/dist/telegram/mtcute-group-settings.js.map +1 -0
  86. package/dist/telegram/mtcute-group-topics.js +63 -0
  87. package/dist/telegram/mtcute-group-topics.js.map +1 -0
  88. package/dist/telegram/raw-message.js +46 -0
  89. package/dist/telegram/raw-message.js.map +1 -0
  90. package/package.json +20 -10
@@ -10,10 +10,23 @@ 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';
12
12
  import { buildListenMessage } from '../listen-message.js';
13
- import { applyMessageArrival, applyScroll, takeListenViewport } from './listen-scroll.js';
13
+ import { applyMessageArrival, 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, useMouseScroll, withMouseReporting } from './mouse-scroll.js';
16
+ import { DISABLE_MOUSE_REPORTING, isMouseInput } 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 { GroupCommandConfirm } from './group-command-confirm.js';
29
+ import { truncateCell } from './display-width.js';
17
30
  const MESSAGE_SEPARATOR = '────────────────────────────────────────────';
18
31
  /** Maximum number of grouped messages retained by a long-running interactive listener. */
19
32
  export const LISTEN_HISTORY_LIMIT = 500;
@@ -268,6 +281,81 @@ export function ListenAttachmentWithPreview({ label, downloadable = true, select
268
281
  ? _jsx(ListenAttachmentLine, { label: label, selected: selected, state: state })
269
282
  : _jsxs(Text, { wrap: "truncate-end", children: [" ", label] }), previewCells == null ? null : _jsx(ListenImagePreview, { rows: previewCells })] }));
270
283
  }
284
+ export function ListenMessageBody({ message }) {
285
+ 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))))] }));
286
+ }
287
+ export function createInteractiveListenGroupQueue(options) {
288
+ const pending = [];
289
+ const schedule = options.schedule ?? ((run) => setImmediate(run));
290
+ let scheduled = null;
291
+ let resolveScheduled = null;
292
+ let closing = false;
293
+ let closed = false;
294
+ let closePromise = null;
295
+ const closeResolver = async () => {
296
+ if (closed)
297
+ return;
298
+ closed = true;
299
+ if (options.resolver.closeAsync != null)
300
+ await options.resolver.closeAsync();
301
+ else
302
+ options.resolver.close();
303
+ };
304
+ const drain = async () => {
305
+ try {
306
+ while (pending.length > 0) {
307
+ const messages = pending.shift();
308
+ try {
309
+ const replyContext = options.resolver.resolveAsync == null
310
+ ? options.resolver.resolve(messages)
311
+ : await options.resolver.resolveAsync(messages);
312
+ options.resolver.remember(messages);
313
+ const first = messages[0];
314
+ if (first != null && options.isActive()) {
315
+ options.onGroup({ key: `${first.chat_id}:${first.msg_id}`, messages, replyContext });
316
+ }
317
+ }
318
+ catch (error) {
319
+ options.onError(error);
320
+ pending.length = 0;
321
+ break;
322
+ }
323
+ }
324
+ }
325
+ finally {
326
+ resolveScheduled?.();
327
+ resolveScheduled = null;
328
+ scheduled = null;
329
+ }
330
+ };
331
+ return {
332
+ enqueue(messages) {
333
+ if (closing)
334
+ return;
335
+ pending.push(messages);
336
+ if (scheduled != null)
337
+ return;
338
+ scheduled = new Promise((resolve) => { resolveScheduled = resolve; });
339
+ schedule(() => { void drain(); });
340
+ },
341
+ close() {
342
+ closePromise ??= (async () => {
343
+ closing = true;
344
+ const drainCompletion = scheduled;
345
+ if (drainCompletion != null)
346
+ await drainCompletion;
347
+ await closeResolver();
348
+ })();
349
+ return closePromise;
350
+ },
351
+ };
352
+ }
353
+ export function createInteractiveListenRuntime(dbPath, factory, options) {
354
+ return createInteractiveListenGroupQueue({
355
+ ...options,
356
+ resolver: factory(dbPath, LISTEN_HISTORY_LIMIT),
357
+ });
358
+ }
271
359
  export function ListenStatus({ status, unseenCount }) {
272
360
  return _jsxs(Text, { dimColor: true, children: [status, unseenCount > 0 ? ` · ↓ ${unseenCount} new messages` : ''] });
273
361
  }
@@ -277,6 +365,9 @@ export function formatInteractiveListenSender(message) {
277
365
  : `${message.sender} (${message.senderId})`;
278
366
  return message.chatName == null ? sender : `${message.chatName} | ${sender}`;
279
367
  }
368
+ export function formatInteractiveListenHeader(message) {
369
+ return `[${message.time}] #${message.msgId} ${formatInteractiveListenSender(message)}`;
370
+ }
280
371
  export function ListenStatusArea({ status, unseenCount, autoDownload, }) {
281
372
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(ListenStatus, { status: status, unseenCount: unseenCount }), autoDownload ? _jsx(Text, { dimColor: true, children: "Auto-download enabled" }) : null] }));
282
373
  }
@@ -285,7 +376,8 @@ export function calculateListenMessagePaneHeight(terminalHeight, hasNote, autoDo
285
376
  return Math.max(2, terminalHeight - reservedLines);
286
377
  }
287
378
  export async function runInteractiveListen(write, run) {
288
- return withMouseReporting({ write, run });
379
+ write(DISABLE_MOUSE_REPORTING);
380
+ return run();
289
381
  }
290
382
  export async function renderInteractiveListen(options) {
291
383
  await runInteractiveListen(process.stdout.write.bind(process.stdout), async () => {
@@ -293,7 +385,7 @@ export async function renderInteractiveListen(options) {
293
385
  await app.waitUntilExit();
294
386
  });
295
387
  }
296
- function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, autoDownload, showChatName, createClient, stopSignal, onRequestStop, }) {
388
+ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo, showMedia, autoDownload, showChatName, createClient, stopSignal, onRequestStop, createReplyResolver, }) {
297
389
  const { exit } = useApp();
298
390
  const { stdout } = useStdout();
299
391
  const terminalMetrics = useTerminalMetrics(stdout);
@@ -307,23 +399,79 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, au
307
399
  const [downloadStates, setDownloadStates] = useState({});
308
400
  const [scrollState, setScrollState] = useState({ offset: 0, unseenCount: 0 });
309
401
  const [sendTargetLabel, setSendTargetLabel] = useState(sendTo == null ? '' : buildSendTargetLabel(sendTo));
402
+ const [knownGroup, setKnownGroup] = useState(undefined);
403
+ const clientRef = useRef(null);
404
+ const replyExecutionLockRef = useRef(false);
405
+ const inputGenerationRef = useRef(0);
406
+ const commandSelectionRef = useRef(0);
407
+ const knownGroupRef = useRef(undefined);
408
+ const groupLookupGenerationRef = useRef(0);
409
+ useEffect(() => {
410
+ groupLookupGenerationRef.current++;
411
+ knownGroupRef.current = undefined;
412
+ setKnownGroup(undefined);
413
+ }, [sendTo]);
414
+ const groupCommand = useGroupCommand(useCallback(async (request, options) => {
415
+ const client = clientRef.current;
416
+ if (client == null)
417
+ return { ok: false, error: { code: 'connection_not_ready', message: 'Telegram connection is not ready.' } };
418
+ if (sendTo == null)
419
+ return { ok: false, error: { code: 'ambiguous_chat', message: 'Select exactly one target chat with --send-to.' } };
420
+ let knownGroup = knownGroupRef.current;
421
+ if (knownGroup == null) {
422
+ const lookup = ++groupLookupGenerationRef.current;
423
+ knownGroup = await client.groups.getGroup(sendTo);
424
+ if (lookup === groupLookupGenerationRef.current && clientRef.current === client) {
425
+ knownGroupRef.current = knownGroup;
426
+ setKnownGroup(knownGroup);
427
+ }
428
+ }
429
+ return executeGroupCommand(request, {
430
+ chat: sendTo,
431
+ groups: new GroupWriteService(client.groups),
432
+ confirmed: options?.confirmed ?? false,
433
+ confirmationTitle: options?.confirmationTitle,
434
+ knownGroup,
435
+ connectionReady: true,
436
+ targetAvailable: true,
437
+ targetCount: 1,
438
+ invalidateGroup: async () => {
439
+ knownGroupRef.current = undefined;
440
+ setKnownGroup(undefined);
441
+ if (request.key === 'chat delete' || request.key === 'chat leave')
442
+ return;
443
+ const lookup = ++groupLookupGenerationRef.current;
444
+ try {
445
+ const refreshed = await client.groups.getGroup(sendTo);
446
+ if (lookup === groupLookupGenerationRef.current && clientRef.current === client) {
447
+ knownGroupRef.current = refreshed;
448
+ setKnownGroup(refreshed);
449
+ }
450
+ }
451
+ catch (error) {
452
+ if (lookup === groupLookupGenerationRef.current && clientRef.current === client)
453
+ setNote(`Group updated; refresh failed: ${messageFromError(error)}`);
454
+ }
455
+ },
456
+ });
457
+ }, [sendTo]));
310
458
  const terminalWidth = terminalMetrics.columns;
311
459
  const terminalHeight = terminalMetrics.rows;
312
460
  const previewColorDepth = interactiveListenPreviewColorDepth(terminalMetrics.colorDepth);
313
461
  const contentWidth = listenContentWidth(terminalWidth);
314
462
  const previewWidth = Math.max(1, Math.min(24, contentWidth - 2));
463
+ const renderContextRef = useRef({ showMedia, previewWidth, colorDepth: previewColorDepth, showChatName });
464
+ renderContextRef.current = { showMedia, previewWidth, colorDepth: previewColorDepth, showChatName };
315
465
  const messageViewCacheRef = useRef(null);
316
466
  if (messageViewCacheRef.current == null)
317
467
  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]);
468
+ const messages = useMemo(() => messageViewCacheRef.current.build(messageGroups, renderContextRef.current), [messageGroups, showMedia, previewWidth, previewColorDepth, showChatName]);
324
469
  const messagePaneHeight = calculateListenMessagePaneHeight(terminalHeight, note.length > 0, autoDownload);
325
470
  const visibleMessages = takeListenViewport(messages, messagePaneHeight, scrollState.offset);
326
- const clientRef = useRef(null);
471
+ const closeGroupCommand = () => {
472
+ commandSelectionRef.current = 0;
473
+ groupCommand.close();
474
+ };
327
475
  const albumAggregatorRef = useRef(null);
328
476
  const autoDownloaderRef = useRef(null);
329
477
  const pendingAttachmentKeysRef = useRef(new Set());
@@ -335,18 +483,13 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, au
335
483
  const seenOrderRef = useRef([]);
336
484
  const downloadableAttachments = collectDownloadableAttachments(visibleMessages);
337
485
  const selectedAttachment = downloadableAttachments[selectedAttachmentIndex] ?? downloadableAttachments[0];
338
- const { visible: scrollbarVisible, show: showScrollbar } = useTransientScrollbar();
486
+ const { visible: scrollbarVisible } = useTransientScrollbar();
339
487
  const scrollbarGeometry = calculateScrollbar({
340
488
  height: terminalHeight,
341
489
  total: messages.length,
342
490
  visible: visibleMessages.length,
343
491
  offset: scrollState.offset,
344
492
  });
345
- const handleMouseScroll = useCallback((direction) => {
346
- showScrollbar();
347
- setScrollState((current) => applyScroll(current, direction, Math.max(0, messages.length - 1)));
348
- }, [messages.length, showScrollbar]);
349
- useMouseScroll(handleMouseScroll);
350
493
  useEffect(() => {
351
494
  const maxOffset = Math.max(0, messages.length - 1);
352
495
  setScrollState((current) => {
@@ -373,6 +516,139 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, au
373
516
  stopListening();
374
517
  return;
375
518
  }
519
+ const modal = groupCommand.state;
520
+ if (modal.kind === 'confirm') {
521
+ if (key.escape) {
522
+ closeGroupCommand();
523
+ return;
524
+ }
525
+ if (key.upArrow || key.downArrow) {
526
+ groupCommand.setState({ ...modal, selectedIndex: modal.selectedIndex === 0 ? 1 : 0 });
527
+ return;
528
+ }
529
+ if (key.return) {
530
+ if (modal.selectedIndex === 0)
531
+ void groupCommand.runConfirmed(modal.request);
532
+ else
533
+ closeGroupCommand();
534
+ }
535
+ return;
536
+ }
537
+ if (modal.kind === 'confirm-title') {
538
+ if (key.escape) {
539
+ if (modal.stage === 'title')
540
+ groupCommand.setState({ ...modal, stage: 'confirm', confirmText: '', mismatch: false });
541
+ else
542
+ closeGroupCommand();
543
+ return;
544
+ }
545
+ if (modal.stage === 'confirm') {
546
+ if (key.upArrow || key.downArrow)
547
+ groupCommand.setState({ ...modal, selectedIndex: modal.selectedIndex === 0 ? 1 : 0 });
548
+ else if (key.return)
549
+ modal.selectedIndex === 0 ? groupCommand.setState({ ...modal, stage: 'title' }) : closeGroupCommand();
550
+ return;
551
+ }
552
+ if (key.return) {
553
+ const client = clientRef.current;
554
+ if (client == null || sendTo == null) {
555
+ groupCommand.setState({ kind: 'error', message: 'Telegram connection is not ready.' });
556
+ }
557
+ else {
558
+ const submittedTitle = modal.confirmText;
559
+ const lookup = ++groupLookupGenerationRef.current;
560
+ void client.groups.getGroup(sendTo).then((fresh) => {
561
+ if (lookup !== groupLookupGenerationRef.current || clientRef.current !== client)
562
+ return;
563
+ knownGroupRef.current = fresh;
564
+ setKnownGroup(fresh);
565
+ if (fresh.title === submittedTitle)
566
+ void groupCommand.runConfirmed(modal.request, submittedTitle);
567
+ else if ('confirmation' in modal.pending)
568
+ groupCommand.setState({
569
+ ...modal,
570
+ pending: { ...modal.pending, confirmation: { ...modal.pending.confirmation, title: fresh.title, target: fresh.title } },
571
+ mismatch: true,
572
+ });
573
+ }).catch((error) => {
574
+ if (lookup === groupLookupGenerationRef.current && clientRef.current === client)
575
+ groupCommand.setState({ kind: 'error', message: messageFromError(error) });
576
+ });
577
+ }
578
+ }
579
+ else if (key.backspace || key.delete)
580
+ groupCommand.setState({ ...modal, confirmText: modal.confirmText.slice(0, -1), mismatch: false });
581
+ else if (!key.ctrl && !key.meta && inputText)
582
+ groupCommand.setState({ ...modal, confirmText: modal.confirmText + inputText, mismatch: false });
583
+ return;
584
+ }
585
+ if (modal.kind === 'select-permissions') {
586
+ if (key.escape) {
587
+ closeGroupCommand();
588
+ return;
589
+ }
590
+ if (key.upArrow || key.downArrow) {
591
+ const delta = key.upArrow ? -1 : 1;
592
+ groupCommand.setState({ ...modal, selectedIndex: (modal.selectedIndex + delta + ADMIN_RIGHT_KEYS.length) % ADMIN_RIGHT_KEYS.length });
593
+ }
594
+ else if (inputText === ' ') {
595
+ const right = ADMIN_RIGHT_KEYS[modal.selectedIndex];
596
+ groupCommand.setState({ ...modal, selected: modal.selected.includes(right) ? modal.selected.filter(item => item !== right) : [...modal.selected, right], warning: undefined });
597
+ }
598
+ else if (key.return) {
599
+ if (modal.selected.length === 0)
600
+ groupCommand.setState({ ...modal, warning: 'Select at least one permission.' });
601
+ else {
602
+ const request = Object.freeze({ ...modal.request, values: Object.freeze({ ...modal.request.values, permissions: Object.freeze([...modal.selected]) }) });
603
+ void (async () => {
604
+ const result = await groupCommand.submit(`${modal.originalInput} ${modal.selected.join(',')}`, 0);
605
+ if (result.kind === 'pending' && 'confirmation' in result.pending)
606
+ groupCommand.setState({ kind: 'confirm', pending: result.pending, request, originalInput: modal.originalInput, selectedIndex: 1 });
607
+ })();
608
+ }
609
+ }
610
+ return;
611
+ }
612
+ if (groupCommand.state.kind === 'result') {
613
+ if (key.escape)
614
+ closeGroupCommand();
615
+ return;
616
+ }
617
+ if (key.escape && groupCommand.state.kind === 'error') {
618
+ closeGroupCommand();
619
+ return;
620
+ }
621
+ const slashMode = input.trimStart().startsWith('/');
622
+ if (slashMode && key.escape) {
623
+ if (replyExecutionLockRef.current) {
624
+ inputGenerationRef.current++;
625
+ setSending(false);
626
+ }
627
+ closeGroupCommand();
628
+ return;
629
+ }
630
+ if (slashMode && (key.upArrow || key.downArrow)) {
631
+ const count = visibleListenCommandMatches(input).length;
632
+ const disabled = listenCommandMenuAvailability(input, knownGroup).map(Boolean);
633
+ const selectedIndex = moveListenCommandSelectionEnabled(commandSelectionRef.current, key.upArrow ? -1 : 1, disabled.slice(0, count));
634
+ commandSelectionRef.current = selectedIndex;
635
+ groupCommand.setState({ kind: 'menu', selectedIndex });
636
+ return;
637
+ }
638
+ if (slashMode && key.tab) {
639
+ const selected = commandSelectionRef.current;
640
+ const failure = listenCommandMenuAvailability(input, knownGroup)[selected];
641
+ if (failure && 'error' in failure) {
642
+ setNote(failure.error.message);
643
+ return;
644
+ }
645
+ inputGenerationRef.current++;
646
+ setInput(completeListenCommand(input, selected));
647
+ commandSelectionRef.current = 0;
648
+ groupCommand.setState({ kind: 'menu', selectedIndex: 0 });
649
+ setFocus('input');
650
+ return;
651
+ }
376
652
  if (key.tab) {
377
653
  if (focus === 'attachments') {
378
654
  setFocus('input');
@@ -406,13 +682,86 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, au
406
682
  }
407
683
  return;
408
684
  }
409
- if (sending)
685
+ if (sending || groupCommand.state.kind === 'executing')
410
686
  return;
411
687
  if (key.return) {
688
+ if (slashMode) {
689
+ const selected = commandSelectionRef.current;
690
+ const failure = listenCommandMenuAvailability(input, knownGroup)[selected];
691
+ if (failure && 'error' in failure) {
692
+ setNote(failure.error.message);
693
+ return;
694
+ }
695
+ const match = visibleListenCommandMatches(input)[selected];
696
+ if (!match) {
697
+ setNote('No matching command.');
698
+ return;
699
+ }
700
+ const parsed = parseSelectedListenCommand(input, match);
701
+ if (parsed.kind === 'complete') {
702
+ inputGenerationRef.current++;
703
+ setInput(parsed.input);
704
+ commandSelectionRef.current = 0;
705
+ groupCommand.setState({ kind: 'menu', selectedIndex: 0 });
706
+ return;
707
+ }
708
+ if (parsed.kind === 'error') {
709
+ setNote(parsed.usage == null ? parsed.message : `${parsed.message} · usage: /${parsed.usage}`);
710
+ return;
711
+ }
712
+ if (parsed.kind === 'reply') {
713
+ if (replyExecutionLockRef.current)
714
+ return;
715
+ if (sendTo == null) {
716
+ setNote('set --send-to before replying');
717
+ return;
718
+ }
719
+ const client = clientRef.current;
720
+ if (client == null) {
721
+ setNote('connection is not ready');
722
+ return;
723
+ }
724
+ replyExecutionLockRef.current = true;
725
+ const ownedGeneration = inputGenerationRef.current;
726
+ const originalInput = input;
727
+ setSending(true);
728
+ setNote('sending...');
729
+ void executeListenReply(client, sendTo, parsed.command).then((sentMessages) => {
730
+ if (ownedGeneration !== inputGenerationRef.current)
731
+ return;
732
+ for (const sentMessage of sentMessages)
733
+ acceptListenMessage(sentMessage, seenRef.current, seenOrderRef.current, (message) => {
734
+ registerPendingAttachmentKeys(pendingAttachmentKeysRef.current, message, showMedia);
735
+ autoDownloaderRef.current?.enqueue(message);
736
+ albumAggregatorRef.current?.add(message);
737
+ });
738
+ setInput('');
739
+ setNote(`replied to #${parsed.command.reply}`);
740
+ }).catch((error) => {
741
+ if (ownedGeneration === inputGenerationRef.current) {
742
+ setInput(originalInput);
743
+ setNote(`send failed: ${messageFromError(error)}`);
744
+ }
745
+ }).finally(() => {
746
+ replyExecutionLockRef.current = false;
747
+ if (ownedGeneration === inputGenerationRef.current)
748
+ setSending(false);
749
+ });
750
+ return;
751
+ }
752
+ void groupCommand.submitParsed(parsed.request, input, selected).then((outcome) => {
753
+ if (!outcome.applied)
754
+ return;
755
+ if (outcome.kind === 'result' && outcome.result.ok)
756
+ setInput('');
757
+ });
758
+ return;
759
+ }
412
760
  void sendMessage(input);
413
761
  return;
414
762
  }
415
763
  if (key.backspace || key.delete) {
764
+ inputGenerationRef.current++;
416
765
  setInput((current) => current.slice(0, -1));
417
766
  return;
418
767
  }
@@ -420,7 +769,16 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, au
420
769
  return;
421
770
  }
422
771
  if (!key.ctrl && !key.meta && inputText.length > 0) {
423
- setInput((current) => current + inputText);
772
+ inputGenerationRef.current++;
773
+ setInput((current) => {
774
+ const next = current + inputText;
775
+ if (next.trimStart().startsWith('/')) {
776
+ setFocus('input');
777
+ commandSelectionRef.current = 0;
778
+ groupCommand.setState({ kind: 'menu', selectedIndex: 0 });
779
+ }
780
+ return next;
781
+ });
424
782
  }
425
783
  });
426
784
  useEffect(() => {
@@ -434,12 +792,25 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, au
434
792
  stopListening();
435
793
  };
436
794
  stopSignal.addEventListener('abort', stopFromSignal);
795
+ const groupQueue = createInteractiveListenRuntime(dbPath, createReplyResolver ?? createListenReplyResolver, {
796
+ isActive,
797
+ onGroup: (group) => {
798
+ setScrollState((current) => applyMessageArrival(current));
799
+ setMessageGroups((current) => [...current, group].slice(-LISTEN_HISTORY_LIMIT));
800
+ },
801
+ onError: (error) => {
802
+ if (!isActive())
803
+ return;
804
+ setStatus(`listen failed: ${messageFromError(error)}`);
805
+ onRequestStop();
806
+ exit();
807
+ },
808
+ });
437
809
  const albumAggregator = new ListenAlbumAggregator({
438
810
  emit: (group) => {
439
811
  if (!isActive())
440
812
  return;
441
- setScrollState((current) => applyMessageArrival(current));
442
- setMessageGroups((current) => pruneListenMessageGroups([...current, group]).groups);
813
+ groupQueue.enqueue(group);
443
814
  },
444
815
  });
445
816
  albumAggregatorRef.current = albumAggregator;
@@ -472,8 +843,18 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, au
472
843
  onClient: (client) => {
473
844
  if (!isActive())
474
845
  return;
846
+ groupLookupGenerationRef.current++;
847
+ knownGroupRef.current = undefined;
848
+ setKnownGroup(undefined);
475
849
  clientRef.current = client;
476
850
  if (client != null && sendTo != null) {
851
+ const lookup = ++groupLookupGenerationRef.current;
852
+ void client.groups.getGroup(sendTo).then((group) => {
853
+ if (isActive() && lookup === groupLookupGenerationRef.current && clientRef.current === client) {
854
+ knownGroupRef.current = group;
855
+ setKnownGroup(group);
856
+ }
857
+ }).catch(() => undefined);
477
858
  void resolveSendTargetLabel(client, sendTo).then((label) => {
478
859
  if (isActive() && label != null)
479
860
  setSendTargetLabel(label);
@@ -528,9 +909,13 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, au
528
909
  }
529
910
  });
530
911
  return () => {
531
- generation.dispose();
912
+ groupLookupGenerationRef.current += 1;
913
+ knownGroupRef.current = undefined;
532
914
  stopSignal.removeEventListener('abort', stopFromSignal);
915
+ albumAggregator.flush();
916
+ generation.dispose();
533
917
  albumAggregator.dispose();
918
+ void groupQueue.close();
534
919
  if (albumAggregatorRef.current === albumAggregator)
535
920
  albumAggregatorRef.current = null;
536
921
  void clientRef.current?.close().catch(() => undefined);
@@ -540,11 +925,16 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, au
540
925
  if (autoDownloaderRef.current === autoDownloader)
541
926
  autoDownloaderRef.current = null;
542
927
  };
543
- }, [autoDownload, chats, createClient, persist, retrySeconds, sendTo, showMedia, exit, stopSignal, onRequestStop]);
928
+ }, [autoDownload, chats, createClient, createReplyResolver, dbPath, persist, retrySeconds, sendTo, showMedia, exit, stopSignal, onRequestStop]);
544
929
  const sendMessage = async (text) => {
545
930
  const trimmed = text.trim();
546
931
  if (!trimmed)
547
932
  return;
933
+ const command = parseListenComposerInput(trimmed);
934
+ if (command.kind === 'error') {
935
+ setNote(command.error);
936
+ return;
937
+ }
548
938
  if (sendTo == null) {
549
939
  setNote('set --send-to before sending');
550
940
  return;
@@ -559,20 +949,23 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, au
559
949
  setSending(true);
560
950
  setNote('sending...');
561
951
  try {
562
- const result = await client.sendMessage({
563
- chat: sendTo,
564
- message: trimmed,
565
- linkPreview: true,
566
- });
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
- });
952
+ const sentMessages = command.kind === 'reply'
953
+ ? await executeListenReply(client, sendTo, command)
954
+ : [await client.sendMessage({
955
+ chat: sendTo,
956
+ message: command.content,
957
+ linkPreview: true,
958
+ })].flatMap(({ sent_message: message }) => message == null ? [] : [message]);
959
+ if (isCurrent()) {
960
+ for (const sentMessage of sentMessages)
961
+ acceptListenMessage(sentMessage, seenRef.current, seenOrderRef.current, (message) => {
962
+ registerPendingAttachmentKeys(pendingAttachmentKeysRef.current, message, showMedia);
963
+ autoDownloaderRef.current?.enqueue(message);
964
+ albumAggregatorRef.current?.add(message);
965
+ });
573
966
  }
574
967
  if (isCurrent())
575
- setNote('sent');
968
+ setNote(command.kind === 'reply' ? `replied to #${command.reply}` : 'sent');
576
969
  }
577
970
  catch (error) {
578
971
  if (isCurrent())
@@ -628,10 +1021,13 @@ function InteractiveListen({ chats, persist, retrySeconds, sendTo, showMedia, au
628
1021
  autoDownloaderRef.current?.stop();
629
1022
  clientRef.current?.close().catch(() => undefined);
630
1023
  };
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));
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 })] }));
1024
+ const permissionState = groupCommand.state.kind === 'select-permissions' ? groupCommand.state : null;
1025
+ 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'
1026
+ ? _jsx(GroupCommandConfirm, { confirmation: groupCommand.state.pending.confirmation, selectedIndex: groupCommand.state.selectedIndex, width: contentWidth })
1027
+ : _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, 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) => {
1028
+ const attachmentKey = attachmentDownloadKeyAt(message.media, mediaIndex);
1029
+ return (_jsx(ListenAttachmentWithPreview, { label: item.label, downloadable: item.downloadable, selected: focus === 'attachments' && selectedAttachment?.key === attachmentKey, state: downloadStates[attachmentKey] ?? { status: 'idle' }, previewCells: item.previewCells }, attachmentKey));
1030
+ }), _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, hint: focus === 'attachments' ? '↑/↓ select · Enter download · Tab input' : 'Enter send · /reply <id> ... · Tab attachments · Ctrl+C exit' })] })] }) : null] }), _jsx(ListenScrollbar, { height: terminalHeight, visible: scrollbarVisible, geometry: scrollbarGeometry })] }));
635
1031
  }
636
1032
  export class ListenMessageViewCache {
637
1033
  entries = new Map();
@@ -641,13 +1037,16 @@ export class ListenMessageViewCache {
641
1037
  build(groups, context) {
642
1038
  const previewWidth = normalizedPreviewWidth(context.previewWidth);
643
1039
  const nextEntries = new Map();
644
- const messages = groups.map((group) => {
1040
+ const messages = groups.map((input) => {
1041
+ const group = Array.isArray(input) ? input : input.messages;
1042
+ const replyContext = Array.isArray(input) ? context.replyContext : input.replyContext;
645
1043
  const first = group[0];
646
1044
  if (first == null)
647
1045
  throw new Error('Cannot render an empty listen message group');
648
- const key = `${first.chat_id}:${first.msg_id}`;
1046
+ const key = Array.isArray(input) ? `${first.chat_id}:${first.msg_id}` : input.key;
649
1047
  const cached = this.entries.get(key);
650
1048
  if (cached?.group === group
1049
+ && cached.replyContext === replyContext
651
1050
  && cached.showMedia === context.showMedia
652
1051
  && cached.previewWidth === previewWidth
653
1052
  && cached.colorDepth === context.colorDepth
@@ -655,9 +1054,10 @@ export class ListenMessageViewCache {
655
1054
  nextEntries.set(key, cached);
656
1055
  return cached.message;
657
1056
  }
658
- const message = toListenMessage(group, { ...context, previewWidth });
1057
+ const message = toListenMessage(group, { ...context, previewWidth, replyContext });
659
1058
  nextEntries.set(key, {
660
1059
  group,
1060
+ replyContext,
661
1061
  showMedia: context.showMedia,
662
1062
  previewWidth,
663
1063
  colorDepth: context.colorDepth,
@@ -677,7 +1077,7 @@ export function pruneListenMessageGroups(groups, limit = LISTEN_HISTORY_LIMIT) {
677
1077
  return {
678
1078
  groups: groups.slice(removeCount),
679
1079
  removedKeys: removed.flatMap((group) => {
680
- const first = group[0];
1080
+ const first = Array.isArray(group) ? group[0] : group.messages[0];
681
1081
  return first == null ? [] : [`${first.chat_id}:${first.msg_id}`];
682
1082
  }),
683
1083
  };
@@ -689,8 +1089,8 @@ export function toListenMessage(messages, context) {
689
1089
  const renderContext = typeof context === 'boolean'
690
1090
  ? { showMedia: context, previewWidth: 1, colorDepth: 1, showChatName: false }
691
1091
  : context;
692
- const { showMedia, previewWidth, colorDepth, showChatName = false } = renderContext;
693
- const formatted = buildListenMessage(messages, { showMedia, showChatName });
1092
+ const { showMedia, previewWidth, colorDepth, showChatName = false, replyContext } = renderContext;
1093
+ const formatted = buildListenMessage(messages, { showMedia, showChatName, replyContext });
694
1094
  const decodePreview = renderContext.decodePreview ?? decodeImagePreview;
695
1095
  const media = formatted.media.map((attachment) => {
696
1096
  if (attachment.previewJpegBase64 == null || colorDepth < 24)