@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.
- package/LICENSE +21 -0
- package/dist/approval.d.ts +37 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +75 -0
- package/dist/approval.js.map +1 -0
- package/dist/channel/approval-prompt.d.ts +20 -0
- package/dist/channel/approval-prompt.d.ts.map +1 -0
- package/dist/channel/approval-prompt.js +34 -0
- package/dist/channel/approval-prompt.js.map +1 -0
- package/dist/channel/callback-handler.d.ts +32 -0
- package/dist/channel/callback-handler.d.ts.map +1 -0
- package/dist/channel/callback-handler.js +212 -0
- package/dist/channel/callback-handler.js.map +1 -0
- package/dist/channel/frame-pump.d.ts +58 -0
- package/dist/channel/frame-pump.d.ts.map +1 -0
- package/dist/channel/frame-pump.js +159 -0
- package/dist/channel/frame-pump.js.map +1 -0
- package/dist/channel/html.d.ts +24 -0
- package/dist/channel/html.d.ts.map +1 -0
- package/dist/channel/html.js +62 -0
- package/dist/channel/html.js.map +1 -0
- package/dist/channel/pairing-handler.d.ts +66 -0
- package/dist/channel/pairing-handler.d.ts.map +1 -0
- package/dist/channel/pairing-handler.js +147 -0
- package/dist/channel/pairing-handler.js.map +1 -0
- package/dist/channel/permission-prompt.d.ts +21 -0
- package/dist/channel/permission-prompt.d.ts.map +1 -0
- package/dist/channel/permission-prompt.js +36 -0
- package/dist/channel/permission-prompt.js.map +1 -0
- package/dist/channel/slash-handler.d.ts +47 -0
- package/dist/channel/slash-handler.d.ts.map +1 -0
- package/dist/channel/slash-handler.js +214 -0
- package/dist/channel/slash-handler.js.map +1 -0
- package/dist/channel/text-handler.d.ts +43 -0
- package/dist/channel/text-handler.d.ts.map +1 -0
- package/dist/channel/text-handler.js +116 -0
- package/dist/channel/text-handler.js.map +1 -0
- package/dist/channel/turn-runner.d.ts +40 -0
- package/dist/channel/turn-runner.d.ts.map +1 -0
- package/dist/channel/turn-runner.js +67 -0
- package/dist/channel/turn-runner.js.map +1 -0
- package/dist/channel/typing-indicator.d.ts +14 -0
- package/dist/channel/typing-indicator.d.ts.map +1 -0
- package/dist/channel/typing-indicator.js +29 -0
- package/dist/channel/typing-indicator.js.map +1 -0
- package/dist/channel/voice-handler.d.ts +42 -0
- package/dist/channel/voice-handler.d.ts.map +1 -0
- package/dist/channel/voice-handler.js +138 -0
- package/dist/channel/voice-handler.js.map +1 -0
- package/dist/channel.d.ts +118 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +478 -0
- package/dist/channel.js.map +1 -0
- package/dist/channel.test-d.d.ts +2 -0
- package/dist/channel.test-d.d.ts.map +1 -0
- package/dist/channel.test-d.js +5 -0
- package/dist/channel.test-d.js.map +1 -0
- package/dist/format.d.ts +38 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +237 -0
- package/dist/format.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +304 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +37 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +42 -0
- package/dist/keys.js.map +1 -0
- package/dist/pair-flow.d.ts +18 -0
- package/dist/pair-flow.d.ts.map +1 -0
- package/dist/pair-flow.js +119 -0
- package/dist/pair-flow.js.map +1 -0
- package/dist/pairing.d.ts +90 -0
- package/dist/pairing.d.ts.map +1 -0
- package/dist/pairing.js +97 -0
- package/dist/pairing.js.map +1 -0
- package/dist/permission.d.ts +25 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/permission.js +49 -0
- package/dist/permission.js.map +1 -0
- package/dist/render.d.ts +68 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +439 -0
- package/dist/render.js.map +1 -0
- package/dist/setup-wizard.d.ts +21 -0
- package/dist/setup-wizard.d.ts.map +1 -0
- package/dist/setup-wizard.js +140 -0
- package/dist/setup-wizard.js.map +1 -0
- package/package.json +91 -0
- package/src/approval.test.ts +103 -0
- package/src/approval.ts +86 -0
- package/src/channel/approval-prompt.ts +46 -0
- package/src/channel/callback-handler.test.ts +188 -0
- package/src/channel/callback-handler.ts +256 -0
- package/src/channel/frame-pump.ts +176 -0
- package/src/channel/html.test.ts +40 -0
- package/src/channel/html.ts +60 -0
- package/src/channel/pairing-handler.ts +181 -0
- package/src/channel/permission-prompt.ts +51 -0
- package/src/channel/slash-handler.ts +259 -0
- package/src/channel/text-handler.test.ts +222 -0
- package/src/channel/text-handler.ts +174 -0
- package/src/channel/turn-runner.ts +102 -0
- package/src/channel/typing-indicator.ts +31 -0
- package/src/channel/voice-handler.test.ts +328 -0
- package/src/channel/voice-handler.ts +198 -0
- package/src/channel/voice-reply.test.ts +223 -0
- package/src/channel.test-d.ts +7 -0
- package/src/channel.ts +564 -0
- package/src/format.test.ts +159 -0
- package/src/format.ts +257 -0
- package/src/index.ts +347 -0
- package/src/keys.ts +52 -0
- package/src/pair-flow.ts +131 -0
- package/src/pairing.test.ts +113 -0
- package/src/pairing.ts +184 -0
- package/src/permission.test.ts +56 -0
- package/src/permission.ts +61 -0
- package/src/render.test.ts +350 -0
- package/src/render.ts +522 -0
- package/src/setup-wizard.ts +178 -0
- 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
|
+
}
|