@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.
Files changed (197) hide show
  1. package/README.md +241 -61
  2. package/README.zh-CN.md +250 -73
  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 +192 -66
  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 +128 -0
  36. package/dist/commands/group-write.js.map +1 -0
  37. package/dist/commands/group.js +43 -7
  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 +172 -109
  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 +79 -0
  56. package/dist/group-commands/catalog.js.map +1 -0
  57. package/dist/group-commands/executor.js +102 -0
  58. package/dist/group-commands/executor.js.map +1 -0
  59. package/dist/group-commands/parser.js +268 -0
  60. package/dist/group-commands/parser.js.map +1 -0
  61. package/dist/group-commands/tokenize.js +51 -0
  62. package/dist/group-commands/tokenize.js.map +1 -0
  63. package/dist/group-commands/types.js +2 -0
  64. package/dist/group-commands/types.js.map +1 -0
  65. package/dist/index.js +0 -0
  66. package/dist/listen-commands/catalog.js +61 -0
  67. package/dist/listen-commands/catalog.js.map +1 -0
  68. package/dist/listen-commands/dispatch.js +46 -0
  69. package/dist/listen-commands/dispatch.js.map +1 -0
  70. package/dist/listen-commands/match.js +113 -0
  71. package/dist/listen-commands/match.js.map +1 -0
  72. package/dist/presenters/group.js +40 -0
  73. package/dist/presenters/group.js.map +1 -1
  74. package/dist/presenters/human.js +135 -0
  75. package/dist/presenters/human.js.map +1 -1
  76. package/dist/presenters/ink/display-width.js +45 -7
  77. package/dist/presenters/ink/display-width.js.map +1 -1
  78. package/dist/presenters/ink/group-command-confirm.js +13 -0
  79. package/dist/presenters/ink/group-command-confirm.js.map +1 -0
  80. package/dist/presenters/ink/group-command-result.js +23 -0
  81. package/dist/presenters/ink/group-command-result.js.map +1 -0
  82. package/dist/presenters/ink/listen-command-menu.js +38 -0
  83. package/dist/presenters/ink/listen-command-menu.js.map +1 -0
  84. package/dist/presenters/ink/listen-scroll.js +9 -4
  85. package/dist/presenters/ink/listen-scroll.js.map +1 -1
  86. package/dist/presenters/ink/listen.js +572 -52
  87. package/dist/presenters/ink/listen.js.map +1 -1
  88. package/dist/presenters/ink/mouse-scroll.js +12 -0
  89. package/dist/presenters/ink/mouse-scroll.js.map +1 -1
  90. package/dist/presenters/ink/secure-input.js +68 -0
  91. package/dist/presenters/ink/secure-input.js.map +1 -0
  92. package/dist/presenters/ink/use-group-command.js +186 -0
  93. package/dist/presenters/ink/use-group-command.js.map +1 -0
  94. package/dist/presenters/listen-message.js +23 -6
  95. package/dist/presenters/listen-message.js.map +1 -1
  96. package/dist/presenters/logical-message.js +78 -0
  97. package/dist/presenters/logical-message.js.map +1 -0
  98. package/dist/presenters/markdown.js +54 -0
  99. package/dist/presenters/markdown.js.map +1 -0
  100. package/dist/presenters/structured.js +8 -3
  101. package/dist/presenters/structured.js.map +1 -1
  102. package/dist/services/account-session-service.js +241 -0
  103. package/dist/services/account-session-service.js.map +1 -0
  104. package/dist/services/archive-layout.js +61 -0
  105. package/dist/services/archive-layout.js.map +1 -0
  106. package/dist/services/archive-manifest.js +187 -0
  107. package/dist/services/archive-manifest.js.map +1 -0
  108. package/dist/services/archive-markdown.js +383 -0
  109. package/dist/services/archive-markdown.js.map +1 -0
  110. package/dist/services/archive-service.js +718 -0
  111. package/dist/services/archive-service.js.map +1 -0
  112. package/dist/services/archive-types.js +2 -0
  113. package/dist/services/archive-types.js.map +1 -0
  114. package/dist/services/attachment-download.js +7 -2
  115. package/dist/services/attachment-download.js.map +1 -1
  116. package/dist/services/auto-download-coordinator.js +2 -4
  117. package/dist/services/auto-download-coordinator.js.map +1 -1
  118. package/dist/services/contact-service.js +78 -0
  119. package/dist/services/contact-service.js.map +1 -0
  120. package/dist/services/dialog-service.js +140 -0
  121. package/dist/services/dialog-service.js.map +1 -0
  122. package/dist/services/folder-service.js +200 -0
  123. package/dist/services/folder-service.js.map +1 -0
  124. package/dist/services/group-service.js.map +1 -1
  125. package/dist/services/group-write-service.js +225 -0
  126. package/dist/services/group-write-service.js.map +1 -0
  127. package/dist/services/listen-album-aggregator.js +9 -29
  128. package/dist/services/listen-album-aggregator.js.map +1 -1
  129. package/dist/services/listen-attachment.js +23 -6
  130. package/dist/services/listen-attachment.js.map +1 -1
  131. package/dist/services/listen-composer-command.js +102 -0
  132. package/dist/services/listen-composer-command.js.map +1 -0
  133. package/dist/services/listen-reply-resolver.js +126 -0
  134. package/dist/services/listen-reply-resolver.js.map +1 -0
  135. package/dist/services/message-service.js +17 -1
  136. package/dist/services/message-service.js.map +1 -1
  137. package/dist/services/notification-service.js +142 -0
  138. package/dist/services/notification-service.js.map +1 -0
  139. package/dist/services/query-service.js +59 -3
  140. package/dist/services/query-service.js.map +1 -1
  141. package/dist/services/reply-context.js +29 -0
  142. package/dist/services/reply-context.js.map +1 -0
  143. package/dist/services/write-access-policy.js +19 -0
  144. package/dist/services/write-access-policy.js.map +1 -0
  145. package/dist/storage/message-db.js +241 -5
  146. package/dist/storage/message-db.js.map +1 -1
  147. package/dist/telegram/archive-types.js +2 -0
  148. package/dist/telegram/archive-types.js.map +1 -0
  149. package/dist/telegram/client-factory.js +51 -11
  150. package/dist/telegram/client-factory.js.map +1 -1
  151. package/dist/telegram/contact-types.js +10 -0
  152. package/dist/telegram/contact-types.js.map +1 -0
  153. package/dist/telegram/dialog-types.js +2 -0
  154. package/dist/telegram/dialog-types.js.map +1 -0
  155. package/dist/telegram/errors.js +14 -0
  156. package/dist/telegram/errors.js.map +1 -0
  157. package/dist/telegram/fake-group-management.js +122 -0
  158. package/dist/telegram/fake-group-management.js.map +1 -1
  159. package/dist/telegram/folder-types.js +13 -0
  160. package/dist/telegram/folder-types.js.map +1 -0
  161. package/dist/telegram/group-types.js +40 -0
  162. package/dist/telegram/group-types.js.map +1 -1
  163. package/dist/telegram/group-write-types.js +29 -0
  164. package/dist/telegram/group-write-types.js.map +1 -0
  165. package/dist/telegram/mtcute-archive.js +190 -0
  166. package/dist/telegram/mtcute-archive.js.map +1 -0
  167. package/dist/telegram/mtcute-client.js +73 -12
  168. package/dist/telegram/mtcute-client.js.map +1 -1
  169. package/dist/telegram/mtcute-contacts.js +90 -0
  170. package/dist/telegram/mtcute-contacts.js.map +1 -0
  171. package/dist/telegram/mtcute-dialogs.js +186 -0
  172. package/dist/telegram/mtcute-dialogs.js.map +1 -0
  173. package/dist/telegram/mtcute-folders.js +318 -0
  174. package/dist/telegram/mtcute-folders.js.map +1 -0
  175. package/dist/telegram/mtcute-group-helpers.js +56 -0
  176. package/dist/telegram/mtcute-group-helpers.js.map +1 -0
  177. package/dist/telegram/mtcute-group-invites.js +82 -0
  178. package/dist/telegram/mtcute-group-invites.js.map +1 -0
  179. package/dist/telegram/mtcute-group-management.js +60 -31
  180. package/dist/telegram/mtcute-group-management.js.map +1 -1
  181. package/dist/telegram/mtcute-group-members.js +158 -0
  182. package/dist/telegram/mtcute-group-members.js.map +1 -0
  183. package/dist/telegram/mtcute-group-settings.js +146 -0
  184. package/dist/telegram/mtcute-group-settings.js.map +1 -0
  185. package/dist/telegram/mtcute-group-topics.js +63 -0
  186. package/dist/telegram/mtcute-group-topics.js.map +1 -0
  187. package/dist/telegram/mtcute-message-normalizer.js +63 -0
  188. package/dist/telegram/mtcute-message-normalizer.js.map +1 -0
  189. package/dist/telegram/mtcute-notifications.js +101 -0
  190. package/dist/telegram/mtcute-notifications.js.map +1 -0
  191. package/dist/telegram/notification-types.js +11 -0
  192. package/dist/telegram/notification-types.js.map +1 -0
  193. package/dist/telegram/raw-message.js +46 -0
  194. package/dist/telegram/raw-message.js.map +1 -0
  195. package/dist/telegram/types.js +6 -1
  196. package/dist/telegram/types.js.map +1 -1
  197. 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, useMouseScroll, withMouseReporting } from './mouse-scroll.js';
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 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, }) {
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: 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 })] })] }));
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 currentClient?.close().catch(() => undefined);
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 client.close().catch(() => undefined);
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 withMouseReporting({ write, run });
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 clientRef = useRef(null);
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 handleMouseScroll = useCallback((direction) => {
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 (sending)
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
- setInput((current) => current + inputText);
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
- stopSignal.addEventListener('abort', stopFromSignal);
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
- setScrollState((current) => applyMessageArrival(current));
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: stopSignal,
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
- void clientRef.current?.close().catch(() => undefined);
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 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
- });
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 = Number.isFinite(total) && total > 0 ? Math.round(downloaded / total * 100) : null;
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
- clientRef.current?.close().catch(() => undefined);
1129
+ lifecycleStopRef.current?.abort();
630
1130
  };
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 })] }));
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((group) => {
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)