@moxxy/plugin-telegram 0.27.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 (123) hide show
  1. package/LICENSE +21 -0
  2. package/dist/approval.d.ts +37 -0
  3. package/dist/approval.d.ts.map +1 -0
  4. package/dist/approval.js +75 -0
  5. package/dist/approval.js.map +1 -0
  6. package/dist/channel/approval-prompt.d.ts +20 -0
  7. package/dist/channel/approval-prompt.d.ts.map +1 -0
  8. package/dist/channel/approval-prompt.js +34 -0
  9. package/dist/channel/approval-prompt.js.map +1 -0
  10. package/dist/channel/callback-handler.d.ts +32 -0
  11. package/dist/channel/callback-handler.d.ts.map +1 -0
  12. package/dist/channel/callback-handler.js +212 -0
  13. package/dist/channel/callback-handler.js.map +1 -0
  14. package/dist/channel/frame-pump.d.ts +58 -0
  15. package/dist/channel/frame-pump.d.ts.map +1 -0
  16. package/dist/channel/frame-pump.js +159 -0
  17. package/dist/channel/frame-pump.js.map +1 -0
  18. package/dist/channel/html.d.ts +24 -0
  19. package/dist/channel/html.d.ts.map +1 -0
  20. package/dist/channel/html.js +62 -0
  21. package/dist/channel/html.js.map +1 -0
  22. package/dist/channel/pairing-handler.d.ts +66 -0
  23. package/dist/channel/pairing-handler.d.ts.map +1 -0
  24. package/dist/channel/pairing-handler.js +147 -0
  25. package/dist/channel/pairing-handler.js.map +1 -0
  26. package/dist/channel/permission-prompt.d.ts +21 -0
  27. package/dist/channel/permission-prompt.d.ts.map +1 -0
  28. package/dist/channel/permission-prompt.js +36 -0
  29. package/dist/channel/permission-prompt.js.map +1 -0
  30. package/dist/channel/slash-handler.d.ts +47 -0
  31. package/dist/channel/slash-handler.d.ts.map +1 -0
  32. package/dist/channel/slash-handler.js +214 -0
  33. package/dist/channel/slash-handler.js.map +1 -0
  34. package/dist/channel/text-handler.d.ts +43 -0
  35. package/dist/channel/text-handler.d.ts.map +1 -0
  36. package/dist/channel/text-handler.js +116 -0
  37. package/dist/channel/text-handler.js.map +1 -0
  38. package/dist/channel/turn-runner.d.ts +40 -0
  39. package/dist/channel/turn-runner.d.ts.map +1 -0
  40. package/dist/channel/turn-runner.js +67 -0
  41. package/dist/channel/turn-runner.js.map +1 -0
  42. package/dist/channel/typing-indicator.d.ts +14 -0
  43. package/dist/channel/typing-indicator.d.ts.map +1 -0
  44. package/dist/channel/typing-indicator.js +29 -0
  45. package/dist/channel/typing-indicator.js.map +1 -0
  46. package/dist/channel/voice-handler.d.ts +42 -0
  47. package/dist/channel/voice-handler.d.ts.map +1 -0
  48. package/dist/channel/voice-handler.js +138 -0
  49. package/dist/channel/voice-handler.js.map +1 -0
  50. package/dist/channel.d.ts +118 -0
  51. package/dist/channel.d.ts.map +1 -0
  52. package/dist/channel.js +478 -0
  53. package/dist/channel.js.map +1 -0
  54. package/dist/channel.test-d.d.ts +2 -0
  55. package/dist/channel.test-d.d.ts.map +1 -0
  56. package/dist/channel.test-d.js +5 -0
  57. package/dist/channel.test-d.js.map +1 -0
  58. package/dist/format.d.ts +38 -0
  59. package/dist/format.d.ts.map +1 -0
  60. package/dist/format.js +237 -0
  61. package/dist/format.js.map +1 -0
  62. package/dist/index.d.ts +24 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +304 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/keys.d.ts +37 -0
  67. package/dist/keys.d.ts.map +1 -0
  68. package/dist/keys.js +42 -0
  69. package/dist/keys.js.map +1 -0
  70. package/dist/pair-flow.d.ts +18 -0
  71. package/dist/pair-flow.d.ts.map +1 -0
  72. package/dist/pair-flow.js +119 -0
  73. package/dist/pair-flow.js.map +1 -0
  74. package/dist/pairing.d.ts +90 -0
  75. package/dist/pairing.d.ts.map +1 -0
  76. package/dist/pairing.js +97 -0
  77. package/dist/pairing.js.map +1 -0
  78. package/dist/permission.d.ts +25 -0
  79. package/dist/permission.d.ts.map +1 -0
  80. package/dist/permission.js +49 -0
  81. package/dist/permission.js.map +1 -0
  82. package/dist/render.d.ts +68 -0
  83. package/dist/render.d.ts.map +1 -0
  84. package/dist/render.js +439 -0
  85. package/dist/render.js.map +1 -0
  86. package/dist/setup-wizard.d.ts +21 -0
  87. package/dist/setup-wizard.d.ts.map +1 -0
  88. package/dist/setup-wizard.js +140 -0
  89. package/dist/setup-wizard.js.map +1 -0
  90. package/package.json +91 -0
  91. package/src/approval.test.ts +103 -0
  92. package/src/approval.ts +86 -0
  93. package/src/channel/approval-prompt.ts +46 -0
  94. package/src/channel/callback-handler.test.ts +188 -0
  95. package/src/channel/callback-handler.ts +256 -0
  96. package/src/channel/frame-pump.ts +176 -0
  97. package/src/channel/html.test.ts +40 -0
  98. package/src/channel/html.ts +60 -0
  99. package/src/channel/pairing-handler.ts +181 -0
  100. package/src/channel/permission-prompt.ts +51 -0
  101. package/src/channel/slash-handler.ts +259 -0
  102. package/src/channel/text-handler.test.ts +222 -0
  103. package/src/channel/text-handler.ts +174 -0
  104. package/src/channel/turn-runner.ts +102 -0
  105. package/src/channel/typing-indicator.ts +31 -0
  106. package/src/channel/voice-handler.test.ts +328 -0
  107. package/src/channel/voice-handler.ts +198 -0
  108. package/src/channel/voice-reply.test.ts +223 -0
  109. package/src/channel.test-d.ts +7 -0
  110. package/src/channel.ts +564 -0
  111. package/src/format.test.ts +159 -0
  112. package/src/format.ts +257 -0
  113. package/src/index.ts +347 -0
  114. package/src/keys.ts +52 -0
  115. package/src/pair-flow.ts +131 -0
  116. package/src/pairing.test.ts +113 -0
  117. package/src/pairing.ts +184 -0
  118. package/src/permission.test.ts +56 -0
  119. package/src/permission.ts +61 -0
  120. package/src/render.test.ts +350 -0
  121. package/src/render.ts +522 -0
  122. package/src/setup-wizard.ts +178 -0
  123. package/src/subcommands.test.ts +153 -0
package/src/channel.ts ADDED
@@ -0,0 +1,564 @@
1
+ import { Bot, GrammyError, HttpError, InputFile } from 'grammy';
2
+ import type { Context } from 'grammy';
3
+ import { newTurnId } from '@moxxy/core';
4
+ import { TurnCoordinator, deliverVoiceReply, resolveSecret } from '@moxxy/channel-kit';
5
+ import type { ClientSession as Session } from '@moxxy/sdk';
6
+ import type {
7
+ ApprovalRequest,
8
+ Channel,
9
+ ChannelHandle,
10
+ ChannelStartOptsBase,
11
+ MoxxyEvent,
12
+ PendingToolCall,
13
+ PermissionContext,
14
+ } from '@moxxy/sdk';
15
+ import type { VaultStore } from '@moxxy/plugin-vault';
16
+ import { TelegramPermissionResolver } from './permission.js';
17
+ import { TelegramApprovalResolver } from './approval.js';
18
+ import { FramePump } from './channel/frame-pump.js';
19
+ import { TypingIndicator } from './channel/typing-indicator.js';
20
+ import { PairingHandler } from './channel/pairing-handler.js';
21
+ import { askForPermission } from './channel/permission-prompt.js';
22
+ import { askForApproval } from './channel/approval-prompt.js';
23
+ import { publishBotCommands } from './channel/slash-handler.js';
24
+ import {
25
+ handleCallback,
26
+ type AwaitingApprovalText,
27
+ } from './channel/callback-handler.js';
28
+ import { runUserTurn } from './channel/turn-runner.js';
29
+ import { handleTextMessage } from './channel/text-handler.js';
30
+ import { handleVoiceMessage } from './channel/voice-handler.js';
31
+ import { loadVoiceReplies, saveVoiceReplies } from './keys.js';
32
+
33
+ const TOKEN_KEY = 'telegram_bot_token';
34
+
35
+ /** Cap on the up-front `getMe` (bot identity → `t.me` connect link). It must
36
+ * never gate channel startup: if Telegram is slow/unreachable, we proceed
37
+ * without the link rather than wedge `start()` (and the readiness it unblocks). */
38
+ const BOT_IDENTITY_TIMEOUT_MS = 8_000;
39
+
40
+ export type { PairingConfirmResult } from './channel/pairing-handler.js';
41
+
42
+ export interface TelegramStartOpts extends ChannelStartOptsBase {
43
+ readonly session: Session;
44
+ /**
45
+ * If true (and no chat is paired yet), open a host-issued QR pairing window on
46
+ * startup: mint a code, publish a `t.me/<bot>?start=<code>` deep link as the
47
+ * connect value, and pair whichever chat presents that code back (deep-link tap
48
+ * or a plain 6-digit message). Set by the `moxxy channels telegram pair` command
49
+ * (which renders the deep link as a terminal QR and waits via `onPaired`).
50
+ */
51
+ readonly pair?: boolean;
52
+ /**
53
+ * The channel is running on its own dedicated runner under a GUI control
54
+ * surface (the desktop Channels panel) rather than a terminal. Equivalent to
55
+ * `pair` for the unpaired case — `start()` opens the same host-issued QR
56
+ * pairing window instead of throwing — so the desktop pairs with the identical
57
+ * mechanism. Threaded by the CLI from `ChannelDef.dedicatedRunner` /
58
+ * `--dedicated` / `MOXXY_DEDICATED_RUNNER`.
59
+ */
60
+ readonly dedicated?: boolean;
61
+ }
62
+
63
+ export interface TelegramChannelOptions {
64
+ readonly vault: VaultStore;
65
+ readonly token?: string;
66
+ readonly logger?: {
67
+ info(msg: string, meta?: Record<string, unknown>): void;
68
+ warn(msg: string, meta?: Record<string, unknown>): void;
69
+ };
70
+ readonly editFrameMs?: number;
71
+ }
72
+
73
+ export class TelegramChannel implements Channel<TelegramStartOpts> {
74
+ readonly name = 'telegram';
75
+ readonly permissionResolver: TelegramPermissionResolver;
76
+ readonly approvalResolver: TelegramApprovalResolver;
77
+ private readonly opts: TelegramChannelOptions;
78
+ private bot: Bot | null = null;
79
+ // The resolved bot's `t.me/<botname>` link (from getMe at start), published as
80
+ // this channel's `requestUrl` connect value so control surfaces can render a
81
+ // QR / "open the bot" step. While a host-issued pairing window is open it
82
+ // carries the `?start=<code>` deep-link payload. Null until resolved (or if
83
+ // getMe failed).
84
+ private botLink: string | null = null;
85
+ // The host-issued pairing code embedded in `botLink`'s `?start=` payload, set
86
+ // when `start()` opens a host pairing window (dedicated + unpaired). Null
87
+ // otherwise (terminal pairing, or already paired).
88
+ private hostPairingCode: string | null = null;
89
+ // Listeners notified when this channel's connect-state changes (i.e. a chat
90
+ // just paired). The dedicated-runner host subscribes via the handle to
91
+ // re-publish the channel's status file. See `onConnectChange` on the handle.
92
+ private readonly connectListeners = new Set<() => void>();
93
+ private currentChatId: number | null = null;
94
+ // Last chat we ran a turn for — the target for mirroring turns this channel
95
+ // did NOT initiate (e.g. a web-surface action on a shared session).
96
+ private lastChatId: number | null = null;
97
+ private logUnsub: (() => void) | null = null;
98
+ private session: Session | null = null;
99
+ private model: string | undefined;
100
+ private activeModelOverride: string | null = null;
101
+ private yolo = false;
102
+ // When true, the final assistant reply of each turn is also synthesized (via
103
+ // the session's active Synthesizer) and sent as a voice note. Persisted per
104
+ // paired chat in the vault (`telegram_voice_replies`), toggled with `/voice`.
105
+ private voiceReplies = false;
106
+ // Single-flight turn state: `busy` guard, per-turn AbortController (so
107
+ // /cancel aborts only the current turn without poisoning the session-level
108
+ // signal other channels share), and the bounded own-turn-id set that
109
+ // mirrorForeignTurn filters on (invariant #8: filter event-log subscribers
110
+ // by turnId when multiplexing turns on one Session) rather than the coarse
111
+ // `busy` flag alone — so an assistant_message dispatched for our own turn
112
+ // AFTER `busy` flips false (async event ordering / RemoteSession replay)
113
+ // isn't re-mirrored as foreign.
114
+ private readonly turns = new TurnCoordinator();
115
+ // When a user clicks an approval option that needs text follow-up
116
+ // (e.g. plan-execute "Redraft with feedback"), we stash the
117
+ // approval+option pair and capture the user's NEXT message as the
118
+ // follow-up text — same mechanism the TUI uses, just over chat.
119
+ private awaitingApprovalText: AwaitingApprovalText | null = null;
120
+ private handle: ChannelHandle | null = null;
121
+ private readonly framePump: FramePump;
122
+ private readonly typing = new TypingIndicator();
123
+ private readonly pairing: PairingHandler;
124
+
125
+ constructor(opts: TelegramChannelOptions) {
126
+ this.opts = opts;
127
+ this.permissionResolver = new TelegramPermissionResolver();
128
+ this.approvalResolver = new TelegramApprovalResolver();
129
+ this.framePump = new FramePump({
130
+ editFrameMs: opts.editFrameMs ?? 1000,
131
+ ...(opts.logger ? { logger: opts.logger } : {}),
132
+ });
133
+ this.pairing = new PairingHandler({
134
+ vault: opts.vault,
135
+ ...(opts.logger ? { logger: opts.logger } : {}),
136
+ });
137
+ // A completed pairing flips this channel's connect-state to "connected";
138
+ // notify the host so it can swap the QR for a "✓ Connected" affordance.
139
+ this.pairing.onPaired(() => this.notifyConnectChange());
140
+ }
141
+
142
+ /** This channel's connect value (see {@link Channel.requestUrl}): the resolved
143
+ * bot's `t.me` link, surfaced by control surfaces as a QR / open-bot step.
144
+ * While a host pairing window is open it includes the `?start=<code>` payload. */
145
+ get requestUrl(): string | null {
146
+ return this.botLink;
147
+ }
148
+
149
+ /** Whether the "connect the other side" step is satisfied — i.e. a chat is
150
+ * paired (see {@link Channel.connected}). A host shows "✓ Connected" instead
151
+ * of the pairing QR once this is true. */
152
+ get connected(): boolean {
153
+ return this.pairing.phase() === 'paired';
154
+ }
155
+
156
+ private notifyConnectChange(): void {
157
+ for (const listener of this.connectListeners) {
158
+ try {
159
+ listener();
160
+ } catch (err) {
161
+ this.opts.logger?.warn?.('telegram connect-change listener threw', { err: String(err) });
162
+ }
163
+ }
164
+ }
165
+
166
+ async start(startOpts: TelegramStartOpts): Promise<ChannelHandle> {
167
+ if (this.handle) return this.handle;
168
+ this.session = startOpts.session;
169
+ this.model = startOpts.model;
170
+
171
+ // Precedence mirrors Slack (and this channel's own `isAvailable` gate +
172
+ // error message): an explicit option wins, then the `MOXXY_TELEGRAM_TOKEN`
173
+ // env override, then the vault (the shared env→vault resolution in
174
+ // @moxxy/channel-kit). Without the env fallback a headless
175
+ // `moxxy channels start telegram` that set the env var would pass the
176
+ // availability check but then fail to boot — the gate and the channel must
177
+ // agree on where the token comes from.
178
+ const token =
179
+ this.opts.token ??
180
+ (await resolveSecret(this.opts.vault, {
181
+ envVar: 'MOXXY_TELEGRAM_TOKEN',
182
+ vaultKey: TOKEN_KEY,
183
+ }));
184
+ if (!token) {
185
+ throw new Error(
186
+ `Telegram bot token not found. Store one via vault_set('${TOKEN_KEY}', ...) or set MOXXY_TELEGRAM_TOKEN.`,
187
+ );
188
+ }
189
+ await this.pairing.loadAuthorized();
190
+ this.voiceReplies = await loadVoiceReplies(this.opts.vault);
191
+
192
+ // Open the single host-issued QR pairing window when a pairing surface asked
193
+ // for it (`pair`, the terminal `pair` command) OR when running GUI-supervised
194
+ // on a dedicated runner (the desktop) and no chat is paired yet. The code is
195
+ // minted now so we can embed it in the `t.me/<bot>?start=<code>` deep link
196
+ // below; the surface renders that as a QR, and pairing completes when the
197
+ // user taps START (or sends the digits) — no terminal round-trip. Only a
198
+ // headless start with neither signal still errors with a pairing hint.
199
+ const dedicated = startOpts.dedicated === true || process.env.MOXXY_DEDICATED_RUNNER === '1';
200
+ if (this.pairing.phase() !== 'paired') {
201
+ if (startOpts.pair || dedicated) {
202
+ this.hostPairingCode = this.pairing.beginHostWindow();
203
+ this.opts.logger?.info?.('telegram pairing window open');
204
+ } else {
205
+ throw new Error(
206
+ 'No Telegram chat is paired yet. Run `moxxy channels telegram pair` to pair, or start it from the desktop Channels panel.',
207
+ );
208
+ }
209
+ }
210
+
211
+ this.bot = new Bot(token);
212
+ this.framePump.attachBot(this.bot);
213
+ this.pairing.attachBot(this.bot);
214
+ this.permissionResolver.setDecider((call, ctx) => this.askForPermission(call, ctx));
215
+ this.approvalResolver.setDecider((id, request) => this.askForApproval(id, request));
216
+ // Register the approval resolver on the session so loop strategies
217
+ // (plan-execute) actually surface their plan-validation dialog on
218
+ // this channel. setApprovalResolver(null) on stop tears it down so
219
+ // headless code paths after channel close don't see a stale handler.
220
+ this.session.setApprovalResolver(this.approvalResolver);
221
+
222
+ // Mirror-to-both: when the session runs a turn this channel did NOT
223
+ // initiate (e.g. a user submitted a form on the co-attached web surface),
224
+ // post the assistant's prose into the last chat we served. Our OWN turns
225
+ // are rendered by the FramePump (gated by `busy`), so skip those.
226
+ this.logUnsub = this.session.log.subscribe((event) => this.mirrorForeignTurn(event));
227
+
228
+ this.bot.command('start', (ctx) => this.pairing.handleStartCommand(ctx));
229
+ this.bot.on('callback_query:data', (ctx) => this.dispatchCallback(ctx));
230
+ // grammy's built-in long-poller (Bot.handleUpdates) awaits each update
231
+ // handler before fetching the next getUpdates batch — so awaiting a
232
+ // whole user turn here would PARK the poll loop for the turn's lifetime.
233
+ // A mid-turn permission prompt waits for a `callback_query` click that
234
+ // grammy could never deliver while parked (deadlock), and `/cancel`
235
+ // would sit undelivered until the turn ends. We dispatch text/voice
236
+ // turns in the background (fire-and-track) so the poll loop stays free
237
+ // to deliver callback_query / permission clicks / `/cancel` mid-turn.
238
+ // Single-flight is preserved because runUserTurn sets `busy` true
239
+ // synchronously and the text/voice handlers reject overlapping turns.
240
+ this.bot.on('message:text', (ctx) => this.dispatchInBackground(this.handleText(ctx), 'text'));
241
+ // Voice notes (press-and-hold) and uploaded audio files. Both go
242
+ // through the same transcribe-then-runUserTurn pipeline; the
243
+ // handler picks the right one off `ctx.message`.
244
+ this.bot.on(['message:voice', 'message:audio'], (ctx) =>
245
+ this.dispatchInBackground(this.handleVoice(ctx, token), 'voice'),
246
+ );
247
+ // Surface the shared registry commands in Telegram's bot-command
248
+ // menu so users see /info, /clear, /new, /exit, /help next to the
249
+ // Telegram-local /model, /loop, /yolo, /tools, /skills, /cancel.
250
+ void publishBotCommands(this.bot, this.session, this.opts.logger);
251
+ this.bot.catch((err) => {
252
+ const e = err.error;
253
+ if (e instanceof GrammyError) this.opts.logger?.warn('grammy error', { description: e.description });
254
+ else if (e instanceof HttpError) this.opts.logger?.warn('http error', { message: e.message });
255
+ else this.opts.logger?.warn('telegram error', { err: String(e) });
256
+ });
257
+
258
+ this.opts.logger?.info?.('telegram channel starting', {
259
+ paired: this.pairing.phase() === 'paired',
260
+ });
261
+
262
+ // Resolve the bot's own identity up front (getMe) so a control surface can
263
+ // show a `t.me/<botname>` connect step. grammy caches this on `bot.botInfo`,
264
+ // and `bot.start()` below reuses it instead of calling getMe again. Non-fatal
265
+ // AND non-blocking: a transient getMe failure must not block the channel from
266
+ // starting — and neither must a STALLED one. We bound it with a timeout so a
267
+ // slow/unreachable Telegram can't wedge `start()` (which gates bot polling,
268
+ // the web-surface co-attach, and the dedicated runner's status file). On
269
+ // timeout/error we proceed without the link; the poll loop surfaces a
270
+ // genuinely invalid token on its own. (We race a timer rather than pass
271
+ // grammy an AbortSignal — its `init(signal)` types the param against the
272
+ // `abort-controller` polyfill, not the global signal.)
273
+ const bot = this.bot;
274
+ const init = bot.init();
275
+ init.catch(() => undefined); // a late rejection (post-timeout) isn't unhandled
276
+ try {
277
+ await Promise.race([
278
+ init,
279
+ new Promise<never>((_, reject) => {
280
+ const t = setTimeout(
281
+ () => reject(new Error(`getMe timed out after ${BOT_IDENTITY_TIMEOUT_MS}ms`)),
282
+ BOT_IDENTITY_TIMEOUT_MS,
283
+ );
284
+ t.unref?.();
285
+ }),
286
+ ]);
287
+ const username = bot.botInfo.username;
288
+ if (username) {
289
+ // Embed the host-issued code as the `?start=` deep-link payload so a scan
290
+ // → tap-START round-trips `/start <code>` back to us, pairing with zero
291
+ // typing. Plain `t.me/<bot>` once paired (or in terminal-pair mode).
292
+ this.botLink = this.hostPairingCode
293
+ ? `https://t.me/${username}?start=${this.hostPairingCode}`
294
+ : `https://t.me/${username}`;
295
+ }
296
+ } catch (err) {
297
+ this.opts.logger?.warn?.('telegram: could not resolve bot identity (getMe)', {
298
+ error: err instanceof Error ? err.message : String(err),
299
+ });
300
+ }
301
+
302
+ const running = bot.start({ drop_pending_updates: false });
303
+ this.handle = {
304
+ running,
305
+ // Let the dedicated-runner host re-publish our status file when a chat
306
+ // pairs (connect-state → connected), so its panel swaps QR for "Connected".
307
+ onConnectChange: (listener) => {
308
+ this.connectListeners.add(listener);
309
+ return () => this.connectListeners.delete(listener);
310
+ },
311
+ stop: async (reason = 'shutdown') => {
312
+ // Abort the in-flight turn FIRST so the model loop stops generating
313
+ // tokens / executing side-effecting tools the moment the operator
314
+ // asks to shut down — otherwise (shared/remote Session) spend and
315
+ // tool calls continue to completion and only their output is discarded.
316
+ this.turns.abort(reason);
317
+ this.permissionResolver.abortAll(reason);
318
+ this.approvalResolver.abortAll(reason);
319
+ this.logUnsub?.();
320
+ this.logUnsub = null;
321
+ if (this.session) this.session.setApprovalResolver(null);
322
+ this.framePump.endTurn();
323
+ this.typing.stop();
324
+ if (this.bot) await this.bot.stop();
325
+ },
326
+ };
327
+ return this.handle;
328
+ }
329
+
330
+ pairingPhase(): ReturnType<PairingHandler['phase']> {
331
+ return this.pairing.phase();
332
+ }
333
+
334
+ unpair(): void {
335
+ this.pairing.unpair();
336
+ }
337
+
338
+ /**
339
+ * Subscribe to "a chat just paired" — fires once when the host-issued QR
340
+ * pairing completes. The `moxxy channels telegram pair` command uses this to
341
+ * print success and stop waiting; returns an unsubscribe function.
342
+ */
343
+ onPaired(listener: (chatId: number) => void): () => void {
344
+ return this.pairing.onPaired(listener);
345
+ }
346
+
347
+ private handleVoice(ctx: Context, token: string): Promise<void> {
348
+ return handleVoiceMessage(
349
+ ctx,
350
+ {
351
+ session: this.session,
352
+ busy: this.turns.busy,
353
+ },
354
+ {
355
+ pairing: this.pairing,
356
+ token,
357
+ ...(this.opts.logger ? { logger: this.opts.logger } : {}),
358
+ },
359
+ {
360
+ runUserTurn: (c, chatId, text) => this.runUserTurn(c, chatId, text),
361
+ },
362
+ );
363
+ }
364
+
365
+ private handleText(ctx: Context): Promise<void> {
366
+ return handleTextMessage(
367
+ ctx,
368
+ {
369
+ session: this.session,
370
+ model: this.model,
371
+ activeModelOverride: this.activeModelOverride,
372
+ yolo: this.yolo,
373
+ voiceReplies: this.voiceReplies,
374
+ busy: this.turns.busy,
375
+ turnController: this.turns.controller,
376
+ awaitingApprovalText: this.awaitingApprovalText,
377
+ handle: this.handle,
378
+ },
379
+ {
380
+ pairing: this.pairing,
381
+ approvalResolver: this.approvalResolver,
382
+ permissionResolver: this.permissionResolver,
383
+ framePump: this.framePump,
384
+ },
385
+ {
386
+ setAwaitingApprovalText: (state) => {
387
+ this.awaitingApprovalText = state;
388
+ },
389
+ toggleYolo: () => {
390
+ this.yolo = !this.yolo;
391
+ return this.yolo;
392
+ },
393
+ setYolo: (value) => {
394
+ this.yolo = value;
395
+ },
396
+ setVoiceReplies: (on) => this.setVoiceReplies(on),
397
+ runUserTurn: (c, chatId, text) => this.runUserTurn(c, chatId, text),
398
+ tryHostPair: (chatId, text) => this.tryHostPair(ctx, chatId, text),
399
+ },
400
+ );
401
+ }
402
+
403
+ /** Persist + apply the voice-replies preference (the `/voice` toggle). */
404
+ private async setVoiceReplies(on: boolean): Promise<void> {
405
+ this.voiceReplies = on;
406
+ try {
407
+ await saveVoiceReplies(this.opts.vault, on);
408
+ } catch (err) {
409
+ this.opts.logger?.warn('telegram voice-replies persist failed', { err: String(err) });
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Speak the final assistant reply as a voice note, when enabled. Best-effort
415
+ * and fully isolated (never throws): synthesize via the session's active
416
+ * Synthesizer, transcode to OGG/Opus (or send plain audio when ffmpeg is
417
+ * unavailable), and deliver via grammy. The text reply already went out.
418
+ */
419
+ private async sendVoiceReply(chatId: number, text: string): Promise<void> {
420
+ if (!this.voiceReplies || !this.bot || !this.session) return;
421
+ const bot = this.bot;
422
+ const outcome = await deliverVoiceReply(this.session, text, {
423
+ send: async (audio, meta) => {
424
+ const file = new InputFile(audio, meta.filename);
425
+ if (meta.isVoiceNote) await bot.api.sendVoice(chatId, file);
426
+ else await bot.api.sendAudio(chatId, file);
427
+ },
428
+ });
429
+ if (outcome.status === 'failed') {
430
+ this.opts.logger?.warn('telegram voice reply failed', {
431
+ reason: outcome.reason,
432
+ ...(outcome.error ? { err: outcome.error } : {}),
433
+ });
434
+ }
435
+ }
436
+
437
+ /**
438
+ * Plain-message fallback for host-issued pairing: if a host window is open and
439
+ * an unauthorized chat sends exactly the 6-digit code, pair it (covers clients
440
+ * that don't auto-deliver the `?start=` deep-link payload). Returns true when
441
+ * the message was a pairing attempt we handled (paired, or a wrong-code reply)
442
+ * so the caller doesn't also emit the generic "not paired" rejection.
443
+ */
444
+ private async tryHostPair(ctx: Context, chatId: number, text: string): Promise<boolean> {
445
+ if (this.pairing.phase() !== 'awaiting-host-code') return false;
446
+ const normalized = text.replace(/\s+/g, '');
447
+ if (!/^\d{6}$/.test(normalized)) return false;
448
+ const result = await this.pairing.confirmChatCode(chatId, normalized);
449
+ if (result.ok) return true; // confirmChatCode greeted + fired onPaired
450
+ await ctx.reply(result.message);
451
+ return true;
452
+ }
453
+
454
+ /**
455
+ * Run a handler promise detached from the grammy poll loop. grammy awaits the
456
+ * value a middleware returns before fetching the next update batch; returning
457
+ * void here (instead of the handler promise) lets the loop keep delivering
458
+ * callback_query / `/cancel` while a turn runs. Errors are logged here because
459
+ * `bot.catch` only sees rejections of the AWAITED middleware chain.
460
+ */
461
+ private dispatchInBackground(work: Promise<void>, kind: string): void {
462
+ void work.catch((err) => {
463
+ this.opts.logger?.warn('telegram handler failed', { kind, err: String(err) });
464
+ });
465
+ }
466
+
467
+ private async runUserTurn(ctx: Context, chatId: number, text: string): Promise<void> {
468
+ if (!this.session) throw new Error('TelegramChannel.start() must be called first');
469
+ // Atomic single-flight guard: `begin` claims the slot synchronously BEFORE
470
+ // any await so a second turn dispatched concurrently (the poll loop is no
471
+ // longer parked on us) can't slip past the busy check in the text/voice
472
+ // handlers. If we are already busy, refuse rather than corrupt the
473
+ // single-instance per-turn state (framePump / currentChatId / controller).
474
+ // The turnId is minted here so the coordinator records it as an own-turn
475
+ // id — that's what mirrorForeignTurn filters on.
476
+ const lease = this.turns.begin(newTurnId());
477
+ if (!lease) {
478
+ await ctx.reply('I am still working on the previous prompt. Send /cancel to abort it.');
479
+ return;
480
+ }
481
+ this.currentChatId = chatId;
482
+ this.lastChatId = chatId;
483
+ const effectiveModel = this.activeModelOverride ?? this.model;
484
+
485
+ try {
486
+ await runUserTurn(
487
+ ctx,
488
+ {
489
+ session: this.session,
490
+ bot: this.bot,
491
+ framePump: this.framePump,
492
+ typing: this.typing,
493
+ ...(this.opts.logger ? { logger: this.opts.logger } : {}),
494
+ onFinalReply: (finalText) => this.sendVoiceReply(chatId, finalText),
495
+ },
496
+ { chatId, text, model: effectiveModel, controller: lease.controller, turnId: lease.turnId },
497
+ );
498
+ } finally {
499
+ lease.end();
500
+ this.currentChatId = null;
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Post the assistant's prose for a turn this channel did not initiate. Gated
506
+ * by `!busy` (our own turns are rendered by the FramePump from the runUserTurn
507
+ * iterator) and by having served a chat at least once. Sent as plain text to
508
+ * avoid parse-mode pitfalls; the view itself lives on the web surface.
509
+ */
510
+ private mirrorForeignTurn(event: MoxxyEvent): void {
511
+ // The coordinator skips turns THIS channel initiated, by turnId — robust to
512
+ // events that arrive after `busy` flips false (async ordering /
513
+ // RemoteSession replay), which the `busy` flag alone could mis-mirror as
514
+ // foreign (invariant #8) — and yields the trimmed assistant prose.
515
+ const text = this.turns.mirrorText(event);
516
+ if (text == null) return;
517
+ if (!this.bot || this.lastChatId == null) return;
518
+ void this.bot.api.sendMessage(this.lastChatId, text).catch((err) => {
519
+ this.opts.logger?.warn('telegram mirror failed', { err: String(err) });
520
+ });
521
+ }
522
+
523
+ private askForPermission(call: PendingToolCall, ctx: PermissionContext): Promise<void> {
524
+ return askForPermission(call, ctx, {
525
+ bot: this.bot,
526
+ chatId: this.currentChatId,
527
+ session: this.session,
528
+ resolver: this.permissionResolver,
529
+ yolo: this.yolo,
530
+ ...(this.opts.logger ? { logger: this.opts.logger } : {}),
531
+ });
532
+ }
533
+
534
+ private askForApproval(id: string, request: ApprovalRequest): Promise<void> {
535
+ return askForApproval(id, request, {
536
+ bot: this.bot,
537
+ chatId: this.currentChatId,
538
+ resolver: this.approvalResolver,
539
+ ...(this.opts.logger ? { logger: this.opts.logger } : {}),
540
+ });
541
+ }
542
+
543
+ private dispatchCallback(ctx: Context): Promise<void> {
544
+ return handleCallback(
545
+ ctx,
546
+ {
547
+ bot: this.bot,
548
+ session: this.session,
549
+ chatId: this.currentChatId,
550
+ permissionResolver: this.permissionResolver,
551
+ approvalResolver: this.approvalResolver,
552
+ pairing: this.pairing,
553
+ },
554
+ {
555
+ setAwaitingApprovalText: (state) => {
556
+ this.awaitingApprovalText = state;
557
+ },
558
+ setActiveModelOverride: (modelId) => {
559
+ this.activeModelOverride = modelId;
560
+ },
561
+ },
562
+ );
563
+ }
564
+ }