@will-17173/telegram-cli 0.3.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.
Files changed (145) hide show
  1. package/README.md +185 -61
  2. package/README.zh-CN.md +199 -77
  3. package/dist/account/account-authenticator.js +95 -0
  4. package/dist/account/account-authenticator.js.map +1 -0
  5. package/dist/account/account-context.js +7 -0
  6. package/dist/account/account-context.js.map +1 -1
  7. package/dist/account/account-name.js +17 -0
  8. package/dist/account/account-name.js.map +1 -0
  9. package/dist/account/account-presets.js +20 -3
  10. package/dist/account/account-presets.js.map +1 -1
  11. package/dist/account/account-store.js +277 -37
  12. package/dist/account/account-store.js.map +1 -1
  13. package/dist/cli/app.js +12 -1
  14. package/dist/cli/app.js.map +1 -1
  15. package/dist/cli/output.js +21 -6
  16. package/dist/cli/output.js.map +1 -1
  17. package/dist/cli/secure-input.js +408 -0
  18. package/dist/cli/secure-input.js.map +1 -0
  19. package/dist/commands/account-options.js +37 -7
  20. package/dist/commands/account-options.js.map +1 -1
  21. package/dist/commands/account.js +144 -62
  22. package/dist/commands/account.js.map +1 -1
  23. package/dist/commands/archive.js +151 -0
  24. package/dist/commands/archive.js.map +1 -0
  25. package/dist/commands/config.js +119 -12
  26. package/dist/commands/config.js.map +1 -1
  27. package/dist/commands/contact.js +58 -0
  28. package/dist/commands/contact.js.map +1 -0
  29. package/dist/commands/data.js +14 -7
  30. package/dist/commands/data.js.map +1 -1
  31. package/dist/commands/dialog.js +156 -0
  32. package/dist/commands/dialog.js.map +1 -0
  33. package/dist/commands/folder.js +113 -0
  34. package/dist/commands/folder.js.map +1 -0
  35. package/dist/commands/group-write.js +45 -5
  36. package/dist/commands/group-write.js.map +1 -1
  37. package/dist/commands/group.js +23 -1
  38. package/dist/commands/group.js.map +1 -1
  39. package/dist/commands/notification.js +76 -0
  40. package/dist/commands/notification.js.map +1 -0
  41. package/dist/commands/query.js +22 -15
  42. package/dist/commands/query.js.map +1 -1
  43. package/dist/commands/telegram-runner.js +40 -18
  44. package/dist/commands/telegram-runner.js.map +1 -1
  45. package/dist/commands/telegram.js +82 -38
  46. package/dist/commands/telegram.js.map +1 -1
  47. package/dist/commands/time-range.js +39 -0
  48. package/dist/commands/time-range.js.map +1 -0
  49. package/dist/commands/types.js +3 -2
  50. package/dist/commands/types.js.map +1 -1
  51. package/dist/config/credential-store.js +24 -4
  52. package/dist/config/credential-store.js.map +1 -1
  53. package/dist/config/env.js +4 -1
  54. package/dist/config/env.js.map +1 -1
  55. package/dist/group-commands/catalog.js +10 -0
  56. package/dist/group-commands/catalog.js.map +1 -1
  57. package/dist/group-commands/executor.js +13 -4
  58. package/dist/group-commands/executor.js.map +1 -1
  59. package/dist/presenters/group.js +16 -0
  60. package/dist/presenters/group.js.map +1 -1
  61. package/dist/presenters/human.js +113 -0
  62. package/dist/presenters/human.js.map +1 -1
  63. package/dist/presenters/ink/group-command-result.js +2 -0
  64. package/dist/presenters/ink/group-command-result.js.map +1 -1
  65. package/dist/presenters/ink/listen-scroll.js +4 -3
  66. package/dist/presenters/ink/listen-scroll.js.map +1 -1
  67. package/dist/presenters/ink/listen.js +163 -43
  68. package/dist/presenters/ink/listen.js.map +1 -1
  69. package/dist/presenters/ink/mouse-scroll.js +12 -0
  70. package/dist/presenters/ink/mouse-scroll.js.map +1 -1
  71. package/dist/presenters/ink/secure-input.js +68 -0
  72. package/dist/presenters/ink/secure-input.js.map +1 -0
  73. package/dist/presenters/ink/use-group-command.js +78 -17
  74. package/dist/presenters/ink/use-group-command.js.map +1 -1
  75. package/dist/presenters/markdown.js +54 -0
  76. package/dist/presenters/markdown.js.map +1 -0
  77. package/dist/presenters/structured.js +8 -3
  78. package/dist/presenters/structured.js.map +1 -1
  79. package/dist/services/account-session-service.js +241 -0
  80. package/dist/services/account-session-service.js.map +1 -0
  81. package/dist/services/archive-layout.js +61 -0
  82. package/dist/services/archive-layout.js.map +1 -0
  83. package/dist/services/archive-manifest.js +187 -0
  84. package/dist/services/archive-manifest.js.map +1 -0
  85. package/dist/services/archive-markdown.js +383 -0
  86. package/dist/services/archive-markdown.js.map +1 -0
  87. package/dist/services/archive-service.js +718 -0
  88. package/dist/services/archive-service.js.map +1 -0
  89. package/dist/services/archive-types.js +2 -0
  90. package/dist/services/archive-types.js.map +1 -0
  91. package/dist/services/attachment-download.js +7 -2
  92. package/dist/services/attachment-download.js.map +1 -1
  93. package/dist/services/auto-download-coordinator.js +2 -4
  94. package/dist/services/auto-download-coordinator.js.map +1 -1
  95. package/dist/services/contact-service.js +78 -0
  96. package/dist/services/contact-service.js.map +1 -0
  97. package/dist/services/dialog-service.js +140 -0
  98. package/dist/services/dialog-service.js.map +1 -0
  99. package/dist/services/folder-service.js +200 -0
  100. package/dist/services/folder-service.js.map +1 -0
  101. package/dist/services/group-write-service.js +78 -18
  102. package/dist/services/group-write-service.js.map +1 -1
  103. package/dist/services/message-service.js +17 -1
  104. package/dist/services/message-service.js.map +1 -1
  105. package/dist/services/notification-service.js +142 -0
  106. package/dist/services/notification-service.js.map +1 -0
  107. package/dist/services/write-access-policy.js +19 -0
  108. package/dist/services/write-access-policy.js.map +1 -0
  109. package/dist/telegram/archive-types.js +2 -0
  110. package/dist/telegram/archive-types.js.map +1 -0
  111. package/dist/telegram/client-factory.js +51 -11
  112. package/dist/telegram/client-factory.js.map +1 -1
  113. package/dist/telegram/contact-types.js +10 -0
  114. package/dist/telegram/contact-types.js.map +1 -0
  115. package/dist/telegram/dialog-types.js +2 -0
  116. package/dist/telegram/dialog-types.js.map +1 -0
  117. package/dist/telegram/errors.js +14 -0
  118. package/dist/telegram/errors.js.map +1 -0
  119. package/dist/telegram/fake-group-management.js +5 -0
  120. package/dist/telegram/fake-group-management.js.map +1 -1
  121. package/dist/telegram/folder-types.js +13 -0
  122. package/dist/telegram/folder-types.js.map +1 -0
  123. package/dist/telegram/group-write-types.js +28 -1
  124. package/dist/telegram/group-write-types.js.map +1 -1
  125. package/dist/telegram/mtcute-archive.js +190 -0
  126. package/dist/telegram/mtcute-archive.js.map +1 -0
  127. package/dist/telegram/mtcute-client.js +72 -11
  128. package/dist/telegram/mtcute-client.js.map +1 -1
  129. package/dist/telegram/mtcute-contacts.js +90 -0
  130. package/dist/telegram/mtcute-contacts.js.map +1 -0
  131. package/dist/telegram/mtcute-dialogs.js +186 -0
  132. package/dist/telegram/mtcute-dialogs.js.map +1 -0
  133. package/dist/telegram/mtcute-folders.js +318 -0
  134. package/dist/telegram/mtcute-folders.js.map +1 -0
  135. package/dist/telegram/mtcute-group-members.js +43 -3
  136. package/dist/telegram/mtcute-group-members.js.map +1 -1
  137. package/dist/telegram/mtcute-message-normalizer.js +63 -0
  138. package/dist/telegram/mtcute-message-normalizer.js.map +1 -0
  139. package/dist/telegram/mtcute-notifications.js +101 -0
  140. package/dist/telegram/mtcute-notifications.js.map +1 -0
  141. package/dist/telegram/notification-types.js +11 -0
  142. package/dist/telegram/notification-types.js.map +1 -0
  143. package/dist/telegram/types.js +6 -1
  144. package/dist/telegram/types.js.map +1 -1
  145. package/package.json +1 -1
@@ -5,15 +5,15 @@ 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';
12
12
  import { buildListenMessage } from '../listen-message.js';
13
- import { applyMessageArrival, takeListenViewport } from './listen-scroll.js';
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 { DISABLE_MOUSE_REPORTING, isMouseInput } from './mouse-scroll.js';
16
+ import { isMouseInput, withAlternateScroll } from './mouse-scroll.js';
17
17
  import { createListenReplyResolver } from '../../services/listen-reply-resolver.js';
18
18
  import { formatReplyContext } from '../../services/reply-context.js';
19
19
  import { executeListenReply, parseListenComposerInput } from '../../services/listen-composer-command.js';
@@ -25,9 +25,12 @@ import { useGroupCommand } from './use-group-command.js';
25
25
  import { executeGroupCommand } from '../../group-commands/executor.js';
26
26
  import { GroupWriteService } from '../../services/group-write-service.js';
27
27
  import { ADMIN_RIGHT_KEYS } from '../../services/group-write-service.js';
28
+ import { WriteAccessPolicy } from '../../services/write-access-policy.js';
28
29
  import { GroupCommandConfirm } from './group-command-confirm.js';
29
30
  import { truncateCell } from './display-width.js';
31
+ import { SecureInput } from './secure-input.js';
30
32
  const MESSAGE_SEPARATOR = '────────────────────────────────────────────';
33
+ const OWNERSHIP_SHUTDOWN_GRACE_MS = 250;
31
34
  /** Maximum number of grouped messages retained by a long-running interactive listener. */
32
35
  export const LISTEN_HISTORY_LIMIT = 500;
33
36
  const LISTEN_IMAGE_PREVIEWS_ENABLED = false;
@@ -60,11 +63,14 @@ export const LISTEN_COMPOSER_THEME = {
60
63
  target: '#f0d38a',
61
64
  hint: '#9bdca8',
62
65
  };
63
- export function ListenComposer({ input, sendTargetLabel, terminalWidth, sending = false, hint = 'Enter to send · Ctrl+C to exit', }) {
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, }) {
64
70
  const inputText = `› ${input}${sending ? ' (sending...)' : ''}`;
65
71
  const blankRow = ' '.repeat(Math.max(0, terminalWidth));
66
72
  const inputRowFill = ' '.repeat(Math.max(0, terminalWidth - stringWidth(inputText) - 1));
67
- 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: LISTEN_COMPOSER_THEME.cursor, 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 })] })] }));
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 })] })] }));
68
74
  }
69
75
  export function ListenAttachmentLine({ label, selected, state }) {
70
76
  const action = state.status === 'idle'
@@ -196,16 +202,23 @@ export async function runInteractiveAutoDownloadLifecycle(options) {
196
202
  : null;
197
203
  options.onCoordinator?.(coordinator);
198
204
  let currentClient = null;
205
+ let closeCurrentClient = null;
199
206
  const abort = () => {
200
207
  coordinator?.stop();
201
- void currentClient?.close().catch(() => undefined);
208
+ void closeCurrentClient?.();
202
209
  };
203
210
  options.signal.addEventListener('abort', abort);
204
211
  try {
205
212
  while (!options.signal.aborted) {
206
213
  options.onStatus?.('connecting');
207
214
  const client = options.createClient();
215
+ let closePromise;
216
+ const closeClient = () => {
217
+ closePromise ??= Promise.resolve().then(() => client.close()).catch(() => undefined);
218
+ return closePromise;
219
+ };
208
220
  currentClient = client;
221
+ closeCurrentClient = closeClient;
209
222
  options.onClient?.(client);
210
223
  coordinator?.setClient(client);
211
224
  let retry = false;
@@ -249,11 +262,13 @@ export async function runInteractiveAutoDownloadLifecycle(options) {
249
262
  else {
250
263
  coordinator?.stop();
251
264
  }
252
- await client.close().catch(() => undefined);
265
+ await closeClient();
253
266
  if (options.signal.aborted)
254
267
  await coordinator?.waitForActive();
255
268
  if (currentClient === client)
256
269
  currentClient = null;
270
+ if (closeCurrentClient === closeClient)
271
+ closeCurrentClient = null;
257
272
  options.onClient?.(null);
258
273
  }
259
274
  if (!retry)
@@ -376,8 +391,7 @@ export function calculateListenMessagePaneHeight(terminalHeight, hasNote, autoDo
376
391
  return Math.max(2, terminalHeight - reservedLines);
377
392
  }
378
393
  export async function runInteractiveListen(write, run) {
379
- write(DISABLE_MOUSE_REPORTING);
380
- return run();
394
+ return withAlternateScroll({ write, run });
381
395
  }
382
396
  export async function renderInteractiveListen(options) {
383
397
  await runInteractiveListen(process.stdout.write.bind(process.stdout), async () => {
@@ -385,7 +399,7 @@ export async function renderInteractiveListen(options) {
385
399
  await app.waitUntilExit();
386
400
  });
387
401
  }
388
- export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo, showMedia, autoDownload, showChatName, createClient, stopSignal, onRequestStop, createReplyResolver, }) {
402
+ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo, showMedia, autoDownload, showChatName, createClient, stopSignal, onRequestStop, createReplyResolver, shutdownRequests, }) {
389
403
  const { exit } = useApp();
390
404
  const { stdout } = useStdout();
391
405
  const terminalMetrics = useTerminalMetrics(stdout);
@@ -402,12 +416,12 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
402
416
  const [knownGroup, setKnownGroup] = useState(undefined);
403
417
  const clientRef = useRef(null);
404
418
  const replyExecutionLockRef = useRef(false);
405
- const inputGenerationRef = useRef(0);
419
+ const inputGenerationRef = useRef({});
406
420
  const commandSelectionRef = useRef(0);
407
421
  const knownGroupRef = useRef(undefined);
408
- const groupLookupGenerationRef = useRef(0);
422
+ const groupLookupGenerationRef = useRef({});
409
423
  useEffect(() => {
410
- groupLookupGenerationRef.current++;
424
+ groupLookupGenerationRef.current = {};
411
425
  knownGroupRef.current = undefined;
412
426
  setKnownGroup(undefined);
413
427
  }, [sendTo]);
@@ -418,19 +432,25 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
418
432
  if (sendTo == null)
419
433
  return { ok: false, error: { code: 'ambiguous_chat', message: 'Select exactly one target chat with --send-to.' } };
420
434
  let knownGroup = knownGroupRef.current;
421
- if (knownGroup == null) {
422
- const lookup = ++groupLookupGenerationRef.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;
423
441
  knownGroup = await client.groups.getGroup(sendTo);
424
- if (lookup === groupLookupGenerationRef.current && clientRef.current === client) {
425
- knownGroupRef.current = knownGroup;
426
- setKnownGroup(knownGroup);
442
+ if (lookup !== groupLookupGenerationRef.current || clientRef.current !== client) {
443
+ return { ok: false, error: { code: 'connection_not_ready', message: 'Telegram connection changed during group verification.' } };
427
444
  }
445
+ knownGroupRef.current = knownGroup;
446
+ setKnownGroup(knownGroup);
428
447
  }
429
448
  return executeGroupCommand(request, {
430
449
  chat: sendTo,
431
450
  groups: new GroupWriteService(client.groups),
432
451
  confirmed: options?.confirmed ?? false,
433
452
  confirmationTitle: options?.confirmationTitle,
453
+ ownershipPassword: options?.ownershipPassword,
434
454
  knownGroup,
435
455
  connectionReady: true,
436
456
  targetAvailable: true,
@@ -440,7 +460,8 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
440
460
  setKnownGroup(undefined);
441
461
  if (request.key === 'chat delete' || request.key === 'chat leave')
442
462
  return;
443
- const lookup = ++groupLookupGenerationRef.current;
463
+ const lookup = {};
464
+ groupLookupGenerationRef.current = lookup;
444
465
  try {
445
466
  const refreshed = await client.groups.getGroup(sendTo);
446
467
  if (lookup === groupLookupGenerationRef.current && clientRef.current === client) {
@@ -455,6 +476,8 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
455
476
  },
456
477
  });
457
478
  }, [sendTo]));
479
+ const irreversibleGroupWriteRef = useRef(false);
480
+ irreversibleGroupWriteRef.current = groupCommand.state.kind === 'executing' && groupCommand.state.irreversible === true;
458
481
  const terminalWidth = terminalMetrics.columns;
459
482
  const terminalHeight = terminalMetrics.rows;
460
483
  const previewColorDepth = interactiveListenPreviewColorDepth(terminalMetrics.colorDepth);
@@ -479,17 +502,52 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
479
502
  if (operationControllerRef.current == null)
480
503
  operationControllerRef.current = createInteractiveOperationController();
481
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
+ }
482
536
  const seenRef = useRef(new Set());
483
537
  const seenOrderRef = useRef([]);
484
538
  const downloadableAttachments = collectDownloadableAttachments(visibleMessages);
485
539
  const selectedAttachment = downloadableAttachments[selectedAttachmentIndex] ?? downloadableAttachments[0];
486
- const { visible: scrollbarVisible } = useTransientScrollbar();
540
+ const { visible: scrollbarVisible, show: showScrollbar } = useTransientScrollbar();
487
541
  const scrollbarGeometry = calculateScrollbar({
488
542
  height: terminalHeight,
489
543
  total: messages.length,
490
544
  visible: visibleMessages.length,
491
545
  offset: scrollState.offset,
492
546
  });
547
+ const scrollMessages = useCallback((direction, amount = 1) => {
548
+ showScrollbar();
549
+ setScrollState((current) => applyScroll(current, direction, Math.max(0, messages.length - 1), amount));
550
+ }, [messages.length, showScrollbar]);
493
551
  useEffect(() => {
494
552
  const maxOffset = Math.max(0, messages.length - 1);
495
553
  setScrollState((current) => {
@@ -512,11 +570,22 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
512
570
  useInput((inputText, key) => {
513
571
  if (isMouseInput(inputText))
514
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
+ }
515
583
  if (key.ctrl && (inputText === 'c' || inputText === 'C' || inputText === '\u0003')) {
516
584
  stopListening();
517
585
  return;
518
586
  }
519
- const modal = groupCommand.state;
587
+ if (modal.kind === 'password')
588
+ return;
520
589
  if (modal.kind === 'confirm') {
521
590
  if (key.escape) {
522
591
  closeGroupCommand();
@@ -556,7 +625,8 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
556
625
  }
557
626
  else {
558
627
  const submittedTitle = modal.confirmText;
559
- const lookup = ++groupLookupGenerationRef.current;
628
+ const lookup = {};
629
+ groupLookupGenerationRef.current = lookup;
560
630
  void client.groups.getGroup(sendTo).then((fresh) => {
561
631
  if (lookup !== groupLookupGenerationRef.current || clientRef.current !== client)
562
632
  return;
@@ -618,12 +688,17 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
618
688
  closeGroupCommand();
619
689
  return;
620
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
+ }
621
700
  const slashMode = input.trimStart().startsWith('/');
622
701
  if (slashMode && key.escape) {
623
- if (replyExecutionLockRef.current) {
624
- inputGenerationRef.current++;
625
- setSending(false);
626
- }
627
702
  closeGroupCommand();
628
703
  return;
629
704
  }
@@ -642,7 +717,7 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
642
717
  setNote(failure.error.message);
643
718
  return;
644
719
  }
645
- inputGenerationRef.current++;
720
+ inputGenerationRef.current = {};
646
721
  setInput(completeListenCommand(input, selected));
647
722
  commandSelectionRef.current = 0;
648
723
  groupCommand.setState({ kind: 'menu', selectedIndex: 0 });
@@ -682,6 +757,10 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
682
757
  }
683
758
  return;
684
759
  }
760
+ if (key.upArrow || key.downArrow) {
761
+ scrollMessages(key.upArrow ? 'up' : 'down');
762
+ return;
763
+ }
685
764
  if (sending || groupCommand.state.kind === 'executing')
686
765
  return;
687
766
  if (key.return) {
@@ -699,7 +778,7 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
699
778
  }
700
779
  const parsed = parseSelectedListenCommand(input, match);
701
780
  if (parsed.kind === 'complete') {
702
- inputGenerationRef.current++;
781
+ inputGenerationRef.current = {};
703
782
  setInput(parsed.input);
704
783
  commandSelectionRef.current = 0;
705
784
  groupCommand.setState({ kind: 'menu', selectedIndex: 0 });
@@ -721,6 +800,11 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
721
800
  setNote('connection is not ready');
722
801
  return;
723
802
  }
803
+ const access = new WriteAccessPolicy().check();
804
+ if (!access.ok) {
805
+ setNote(access.error.message);
806
+ return;
807
+ }
724
808
  replyExecutionLockRef.current = true;
725
809
  const ownedGeneration = inputGenerationRef.current;
726
810
  const originalInput = input;
@@ -761,7 +845,7 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
761
845
  return;
762
846
  }
763
847
  if (key.backspace || key.delete) {
764
- inputGenerationRef.current++;
848
+ inputGenerationRef.current = {};
765
849
  setInput((current) => current.slice(0, -1));
766
850
  return;
767
851
  }
@@ -769,7 +853,7 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
769
853
  return;
770
854
  }
771
855
  if (!key.ctrl && !key.meta && inputText.length > 0) {
772
- inputGenerationRef.current++;
856
+ inputGenerationRef.current = {};
773
857
  setInput((current) => {
774
858
  const next = current + inputText;
775
859
  if (next.trimStart().startsWith('/')) {
@@ -784,14 +868,24 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
784
868
  useEffect(() => {
785
869
  const generation = operationControllerRef.current.beginGeneration();
786
870
  const isActive = generation.isActive;
871
+ const lifecycleStop = new AbortController();
872
+ lifecycleStopRef.current = lifecycleStop;
787
873
  if (stopSignal.aborted) {
788
874
  exit();
789
875
  return;
790
876
  }
791
877
  const stopFromSignal = () => {
878
+ if (stoppingRef.current)
879
+ return;
880
+ if (irreversibleGroupWriteRef.current) {
881
+ requestOwnershipShutdown('external');
882
+ return;
883
+ }
792
884
  stopListening();
793
885
  };
794
- stopSignal.addEventListener('abort', stopFromSignal);
886
+ const unsubscribeShutdown = shutdownRequests?.subscribe(stopFromSignal);
887
+ if (shutdownRequests == null)
888
+ stopSignal.addEventListener('abort', stopFromSignal);
795
889
  const groupQueue = createInteractiveListenRuntime(dbPath, createReplyResolver ?? createListenReplyResolver, {
796
890
  isActive,
797
891
  onGroup: (group) => {
@@ -833,7 +927,7 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
833
927
  chats,
834
928
  persist,
835
929
  retrySeconds,
836
- signal: stopSignal,
930
+ signal: lifecycleStop.signal,
837
931
  createClient,
838
932
  createCoordinator: () => autoDownloader,
839
933
  onCoordinator: (coordinator) => {
@@ -843,12 +937,13 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
843
937
  onClient: (client) => {
844
938
  if (!isActive())
845
939
  return;
846
- groupLookupGenerationRef.current++;
940
+ groupLookupGenerationRef.current = {};
847
941
  knownGroupRef.current = undefined;
848
942
  setKnownGroup(undefined);
849
943
  clientRef.current = client;
850
944
  if (client != null && sendTo != null) {
851
- const lookup = ++groupLookupGenerationRef.current;
945
+ const lookup = {};
946
+ groupLookupGenerationRef.current = lookup;
852
947
  void client.groups.getGroup(sendTo).then((group) => {
853
948
  if (isActive() && lookup === groupLookupGenerationRef.current && clientRef.current === client) {
854
949
  knownGroupRef.current = group;
@@ -903,30 +998,42 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
903
998
  albumAggregator.flush();
904
999
  },
905
1000
  }).finally(() => {
906
- if (isActive() && !stopSignal.aborted) {
1001
+ if (isActive() && !stopSignal.aborted && !stoppingRef.current) {
907
1002
  onRequestStop();
908
1003
  exit();
909
1004
  }
910
1005
  });
911
1006
  return () => {
912
- groupLookupGenerationRef.current += 1;
1007
+ groupLookupGenerationRef.current = {};
913
1008
  knownGroupRef.current = undefined;
914
- stopSignal.removeEventListener('abort', stopFromSignal);
1009
+ unsubscribeShutdown?.();
1010
+ if (shutdownRequests == null)
1011
+ stopSignal.removeEventListener('abort', stopFromSignal);
915
1012
  albumAggregator.flush();
916
1013
  generation.dispose();
917
1014
  albumAggregator.dispose();
918
1015
  void groupQueue.close();
919
1016
  if (albumAggregatorRef.current === albumAggregator)
920
1017
  albumAggregatorRef.current = null;
921
- void clientRef.current?.close().catch(() => undefined);
1018
+ lifecycleStop.abort();
922
1019
  clientRef.current = null;
923
1020
  autoDownloader?.stop();
924
1021
  void autoDownloader?.waitForActive();
925
1022
  if (autoDownloaderRef.current === autoDownloader)
926
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;
927
1029
  };
928
- }, [autoDownload, chats, createClient, createReplyResolver, dbPath, persist, retrySeconds, sendTo, showMedia, exit, stopSignal, onRequestStop]);
1030
+ }, [autoDownload, chats, createClient, createReplyResolver, dbPath, persist, retrySeconds, sendTo, showMedia, exit, stopSignal, shutdownRequests, onRequestStop]);
929
1031
  const sendMessage = async (text) => {
1032
+ const access = new WriteAccessPolicy().check();
1033
+ if (!access.ok) {
1034
+ setNote(access.error.message);
1035
+ return;
1036
+ }
930
1037
  const trimmed = text.trim();
931
1038
  if (!trimmed)
932
1039
  return;
@@ -997,7 +1104,7 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
997
1104
  onProgress: (downloaded, total) => {
998
1105
  if (!isCurrent())
999
1106
  return;
1000
- const progress = Number.isFinite(total) && total > 0 ? Math.round(downloaded / total * 100) : null;
1107
+ const progress = attachmentDownloadProgress(downloaded, total);
1001
1108
  setDownloadStates((current) => ({ ...current, [item.key]: { status: 'downloading', progress } }));
1002
1109
  },
1003
1110
  });
@@ -1019,15 +1126,28 @@ export function InteractiveListen({ dbPath, chats, persist, retrySeconds, sendTo
1019
1126
  setTimeout(exit, 0);
1020
1127
  onRequestStop();
1021
1128
  autoDownloaderRef.current?.stop();
1022
- clientRef.current?.close().catch(() => undefined);
1129
+ lifecycleStopRef.current?.abort();
1023
1130
  };
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]);
1024
1144
  const permissionState = groupCommand.state.kind === 'select-permissions' ? groupCommand.state : null;
1025
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'
1026
1146
  ? _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) => {
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) => {
1028
1148
  const attachmentKey = attachmentDownloadKeyAt(message.media, mediaIndex);
1029
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));
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 })] }));
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 })] }));
1031
1151
  }
1032
1152
  export class ListenMessageViewCache {
1033
1153
  entries = new Map();