@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/index.ts
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { defineChannel, defineTool, definePlugin, z, type LifecycleHooks, type Plugin } from '@moxxy/sdk';
|
|
2
|
+
import type { VaultStore } from '@moxxy/plugin-vault';
|
|
3
|
+
import { Api, GrammyError } from 'grammy';
|
|
4
|
+
import { markdownToTelegramHtml } from './format.js';
|
|
5
|
+
import { stripHtml } from './channel/html.js';
|
|
6
|
+
import { TelegramChannel } from './channel.js';
|
|
7
|
+
import { TELEGRAM_AUTHORIZED_CHAT_KEY, TELEGRAM_TOKEN_KEY, parseChatId } from './keys.js';
|
|
8
|
+
import { runTelegramWizard } from './setup-wizard.js';
|
|
9
|
+
import { runPairFlow } from './pair-flow.js';
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
TelegramChannel,
|
|
13
|
+
type TelegramChannelOptions,
|
|
14
|
+
type TelegramStartOpts,
|
|
15
|
+
} from './channel.js';
|
|
16
|
+
export { TelegramPermissionResolver, type PendingPermission } from './permission.js';
|
|
17
|
+
export { TelegramApprovalResolver, type PendingApproval } from './approval.js';
|
|
18
|
+
export {
|
|
19
|
+
createPairingState,
|
|
20
|
+
beginHostIssuedPairing,
|
|
21
|
+
handleStart,
|
|
22
|
+
submitChatCode,
|
|
23
|
+
isAuthorized,
|
|
24
|
+
clearPairing,
|
|
25
|
+
type PairingPhase,
|
|
26
|
+
type PairingState,
|
|
27
|
+
type PairingDecision,
|
|
28
|
+
} from './pairing.js';
|
|
29
|
+
export type { PairingConfirmResult } from './channel.js';
|
|
30
|
+
export { TurnRenderer, splitForTelegram } from './render.js';
|
|
31
|
+
export { markdownToTelegramHtml } from './format.js';
|
|
32
|
+
|
|
33
|
+
export interface BuildTelegramPluginOptions {
|
|
34
|
+
readonly vault: VaultStore;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export { TELEGRAM_TOKEN_KEY, TELEGRAM_AUTHORIZED_CHAT_KEY, TELEGRAM_TOKEN_RE } from './keys.js';
|
|
38
|
+
|
|
39
|
+
// Backwards-compat aliases for the existing call sites in this file.
|
|
40
|
+
const TOKEN_KEY = TELEGRAM_TOKEN_KEY;
|
|
41
|
+
const AUTHORIZED_CHAT_KEY = TELEGRAM_AUTHORIZED_CHAT_KEY;
|
|
42
|
+
|
|
43
|
+
export function buildTelegramPlugin(opts: BuildTelegramPluginOptions): Plugin {
|
|
44
|
+
// Host-injected vault (available immediately).
|
|
45
|
+
return makeTelegramPlugin(() => opts.vault);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Discovery-loadable default export: resolves the vault from the inter-plugin
|
|
50
|
+
* service registry in `onInit` (the vault plugin publishes `'vault'`). Requires
|
|
51
|
+
* `@moxxy/plugin-vault` to load first (declared in `package.json`
|
|
52
|
+
* `moxxy.requirements`). The channel + tools read the vault via `getVault()`,
|
|
53
|
+
* so resolution is deferred to call time — after `onInit` has wired it.
|
|
54
|
+
*/
|
|
55
|
+
export const telegramPlugin: Plugin = (() => {
|
|
56
|
+
let resolved: VaultStore | null = null;
|
|
57
|
+
const getVault = (): VaultStore => {
|
|
58
|
+
if (!resolved) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
'@moxxy/plugin-telegram: the "vault" service is unavailable — @moxxy/plugin-vault must load first',
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return resolved;
|
|
64
|
+
};
|
|
65
|
+
const hooks: LifecycleHooks = {
|
|
66
|
+
onInit: (ctx) => {
|
|
67
|
+
resolved = ctx.services.require<VaultStore>('vault');
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
return makeTelegramPlugin(getVault, hooks);
|
|
71
|
+
})();
|
|
72
|
+
|
|
73
|
+
function makeTelegramPlugin(getVault: () => VaultStore, hooks?: LifecycleHooks): Plugin {
|
|
74
|
+
return definePlugin({
|
|
75
|
+
name: '@moxxy/plugin-telegram',
|
|
76
|
+
version: '0.0.0',
|
|
77
|
+
...(hooks ? { hooks } : {}),
|
|
78
|
+
channels: [
|
|
79
|
+
defineChannel({
|
|
80
|
+
name: 'telegram',
|
|
81
|
+
description: 'Telegram bot channel via grammy. QR deep-link chat pairing.',
|
|
82
|
+
// Like Slack, run on a dedicated, isolated runner (separate socket +
|
|
83
|
+
// sticky session) so the bot keeps its own persistent history apart from
|
|
84
|
+
// the user's desktop/TUI work. Telegram long-polls (no tunnel needed).
|
|
85
|
+
dedicatedRunner: true,
|
|
86
|
+
sessionSource: 'telegram',
|
|
87
|
+
// Self-described config so a control surface (TUI `/channels`, `moxxy
|
|
88
|
+
// channels start`) can configure + run Telegram without a hardcoded table.
|
|
89
|
+
config: {
|
|
90
|
+
fields: [
|
|
91
|
+
{
|
|
92
|
+
name: 'token',
|
|
93
|
+
label: 'Bot token',
|
|
94
|
+
vaultKey: TELEGRAM_TOKEN_KEY,
|
|
95
|
+
required: true,
|
|
96
|
+
secret: true,
|
|
97
|
+
placeholder: '123456:ABC-DEF…',
|
|
98
|
+
help: 'Create a bot with @BotFather and paste its token',
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
hasRequestUrl: false,
|
|
102
|
+
runHint:
|
|
103
|
+
'Scan the QR (or open the link) and tap START in Telegram to pair your chat — no code to type.',
|
|
104
|
+
connect: {
|
|
105
|
+
kind: 'qr',
|
|
106
|
+
title: 'Connect your Telegram',
|
|
107
|
+
hint: 'Scan the QR with your phone (or tap Open in Telegram), then press START — your chat pairs automatically.',
|
|
108
|
+
openable: true,
|
|
109
|
+
openLabel: 'Open in Telegram',
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
create: (deps) =>
|
|
113
|
+
new TelegramChannel({
|
|
114
|
+
vault: getVault(),
|
|
115
|
+
token: (deps.options?.['token'] as string | undefined) ?? undefined,
|
|
116
|
+
logger: deps.logger as never,
|
|
117
|
+
}),
|
|
118
|
+
isAvailable: async () => {
|
|
119
|
+
const envToken = process.env.MOXXY_TELEGRAM_TOKEN;
|
|
120
|
+
if (envToken) return { ok: true };
|
|
121
|
+
try {
|
|
122
|
+
const stored = await getVault().has(TOKEN_KEY);
|
|
123
|
+
if (stored) return { ok: true };
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
reason:
|
|
127
|
+
"No bot token. Set MOXXY_TELEGRAM_TOKEN, or store one in the vault as '" +
|
|
128
|
+
TOKEN_KEY +
|
|
129
|
+
"' via the `telegram-setup` skill.",
|
|
130
|
+
};
|
|
131
|
+
} catch {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
reason:
|
|
135
|
+
'Set MOXXY_TELEGRAM_TOKEN to skip the vault, or unlock the vault first.',
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
interactiveCommand: 'setup',
|
|
140
|
+
subcommands: {
|
|
141
|
+
setup: {
|
|
142
|
+
description:
|
|
143
|
+
'Interactive setup: store a bot token, pair a chat, then start the bot. Shown by default for `moxxy telegram` on a TTY.',
|
|
144
|
+
run: async (ctx) => {
|
|
145
|
+
// The wizard drives token entry + pairing through clack
|
|
146
|
+
// prompts, so it needs an interactive terminal. In a
|
|
147
|
+
// headless invocation we just start the bot directly.
|
|
148
|
+
if (process.stdin.isTTY !== true) {
|
|
149
|
+
return ctx.startChannel();
|
|
150
|
+
}
|
|
151
|
+
return runTelegramWizard(ctx);
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
pair: {
|
|
155
|
+
description:
|
|
156
|
+
'Open a pairing window and print a QR. Scan it (or open the link) and tap START in Telegram to pair your chat — the same mechanism the desktop uses.',
|
|
157
|
+
run: async (ctx) => {
|
|
158
|
+
// Pairing renders a QR for the user to scan, so it needs an
|
|
159
|
+
// interactive terminal. In a headless invocation we bail with a
|
|
160
|
+
// clear message instead of starting a bot nobody can pair. (The
|
|
161
|
+
// desktop Channels panel pairs the same way without a TTY — it
|
|
162
|
+
// renders the QR in the GUI.)
|
|
163
|
+
if (process.stdin.isTTY !== true) {
|
|
164
|
+
process.stderr.write(
|
|
165
|
+
'Pairing needs a TTY to show the QR. Run `moxxy channels telegram pair` on a workstation, or pair from the desktop Channels panel.\n',
|
|
166
|
+
);
|
|
167
|
+
return 1;
|
|
168
|
+
}
|
|
169
|
+
return runPairFlow(ctx);
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
unpair: {
|
|
173
|
+
description: 'Forget the currently authorized Telegram chat.',
|
|
174
|
+
run: async (ctx) => {
|
|
175
|
+
const vault = ctx.deps.vault as VaultStore | undefined;
|
|
176
|
+
if (!vault) {
|
|
177
|
+
process.stderr.write('vault unavailable\n');
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
const removed = await vault.delete(AUTHORIZED_CHAT_KEY);
|
|
181
|
+
process.stdout.write(removed ? 'unpaired\n' : 'no pairing was active\n');
|
|
182
|
+
return 0;
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
status: {
|
|
186
|
+
description: 'Report whether a Telegram token + an authorized chat are configured.',
|
|
187
|
+
run: async (ctx) => {
|
|
188
|
+
const vault = ctx.deps.vault as VaultStore | undefined;
|
|
189
|
+
if (!vault) {
|
|
190
|
+
process.stderr.write('vault unavailable\n');
|
|
191
|
+
return 1;
|
|
192
|
+
}
|
|
193
|
+
const hasToken = await vault.has(TOKEN_KEY);
|
|
194
|
+
const authorized = await vault.get(AUTHORIZED_CHAT_KEY);
|
|
195
|
+
process.stdout.write(
|
|
196
|
+
JSON.stringify(
|
|
197
|
+
{
|
|
198
|
+
tokenConfigured: hasToken,
|
|
199
|
+
authorizedChatId: parseChatId(authorized),
|
|
200
|
+
},
|
|
201
|
+
null,
|
|
202
|
+
2,
|
|
203
|
+
) + '\n',
|
|
204
|
+
);
|
|
205
|
+
return 0;
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
}),
|
|
210
|
+
],
|
|
211
|
+
tools: [
|
|
212
|
+
defineTool({
|
|
213
|
+
name: 'telegram_set_token',
|
|
214
|
+
description:
|
|
215
|
+
'Store a Telegram bot token (from @BotFather) in the vault under telegram_bot_token. Validates the token shape but does not test connectivity.',
|
|
216
|
+
inputSchema: z.object({
|
|
217
|
+
token: z.string().regex(/^\d+:[A-Za-z0-9_-]{20,}$/, 'token must look like 1234567890:ABC...'),
|
|
218
|
+
}),
|
|
219
|
+
permission: { action: 'prompt' },
|
|
220
|
+
// Vault writes land in ~/.moxxy/vault.json (key material in
|
|
221
|
+
// ~/.moxxy/vault.key) — hence the vault.* glob.
|
|
222
|
+
isolation: {
|
|
223
|
+
capabilities: {
|
|
224
|
+
fs: { read: ['~/.moxxy/vault.*'], write: ['~/.moxxy/vault.*'] },
|
|
225
|
+
net: { mode: 'none' },
|
|
226
|
+
timeMs: 10_000,
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
handler: async ({ token }) => {
|
|
230
|
+
await getVault().set(TOKEN_KEY, token, ['telegram']);
|
|
231
|
+
return `stored Telegram token (${token.split(':')[0]}:…) in vault`;
|
|
232
|
+
},
|
|
233
|
+
}),
|
|
234
|
+
defineTool({
|
|
235
|
+
name: 'telegram_status',
|
|
236
|
+
description: 'Report whether a Telegram token + an authorized chat are configured.',
|
|
237
|
+
inputSchema: z.object({}),
|
|
238
|
+
isolation: {
|
|
239
|
+
capabilities: {
|
|
240
|
+
fs: { read: ['~/.moxxy/vault.*'] },
|
|
241
|
+
net: { mode: 'none' },
|
|
242
|
+
timeMs: 10_000,
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
handler: async () => {
|
|
246
|
+
const hasToken = await getVault().has(TOKEN_KEY);
|
|
247
|
+
const authorized = await getVault().get(AUTHORIZED_CHAT_KEY);
|
|
248
|
+
return {
|
|
249
|
+
tokenConfigured: hasToken,
|
|
250
|
+
authorizedChatId: parseChatId(authorized),
|
|
251
|
+
};
|
|
252
|
+
},
|
|
253
|
+
}),
|
|
254
|
+
defineTool({
|
|
255
|
+
name: 'telegram_send_message',
|
|
256
|
+
description:
|
|
257
|
+
'Push a one-off message to the currently authorized Telegram chat. Use this from a ' +
|
|
258
|
+
'scheduled prompt to deliver results without an interactive channel running. By ' +
|
|
259
|
+
'default the text is rendered with the same rich Markdown→Telegram formatting as ' +
|
|
260
|
+
'interactive replies (bold, code, `> [!type]` callouts, `||spoilers||`, etc.); pass ' +
|
|
261
|
+
'an explicit `parseMode` to send the raw text under that parse mode instead. Requires ' +
|
|
262
|
+
'a stored bot token + a paired chat (run `moxxy channels telegram pair` once).',
|
|
263
|
+
inputSchema: z.object({
|
|
264
|
+
text: z.string().min(1).max(4096),
|
|
265
|
+
/** Optional override; defaults to the vault-paired chat id. */
|
|
266
|
+
chatId: z.number().int().optional(),
|
|
267
|
+
/**
|
|
268
|
+
* Force a specific Telegram parse mode and send `text` verbatim under
|
|
269
|
+
* it. Omit to get the default Markdown→HTML rendering (recommended).
|
|
270
|
+
*/
|
|
271
|
+
parseMode: z.enum(['MarkdownV2', 'Markdown', 'HTML']).optional(),
|
|
272
|
+
}),
|
|
273
|
+
permission: { action: 'prompt' },
|
|
274
|
+
isolation: {
|
|
275
|
+
capabilities: {
|
|
276
|
+
fs: { read: ['~/.moxxy/vault.*'] },
|
|
277
|
+
net: { mode: 'allowlist', hosts: ['api.telegram.org'] },
|
|
278
|
+
env: ['MOXXY_TELEGRAM_TOKEN'],
|
|
279
|
+
timeMs: 60_000,
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
handler: async ({ text, chatId, parseMode }) => {
|
|
283
|
+
const token = process.env.MOXXY_TELEGRAM_TOKEN ?? (await getVault().get(TOKEN_KEY));
|
|
284
|
+
if (!token) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
'no Telegram bot token configured (set MOXXY_TELEGRAM_TOKEN or run `moxxy init` to store one)',
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
const targetChat =
|
|
290
|
+
chatId ?? parseChatId(await getVault().get(AUTHORIZED_CHAT_KEY));
|
|
291
|
+
if (!targetChat) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
'no authorized chat — run `moxxy channels telegram pair` first or pass `chatId` explicitly',
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
// Use grammy's lightweight `Api` client directly rather than a full
|
|
297
|
+
// `Bot` (which builds a polling-capable instance + lazily resolves
|
|
298
|
+
// bot info). This is a one-off send — no long-polling — so the Bot
|
|
299
|
+
// wrapper was pure overhead per invocation.
|
|
300
|
+
const api = new Api(token);
|
|
301
|
+
if (parseMode) {
|
|
302
|
+
// Explicit mode → caller owns the markup; send text verbatim.
|
|
303
|
+
await api.sendMessage(targetChat, text, { parse_mode: parseMode });
|
|
304
|
+
} else {
|
|
305
|
+
// Default → render Markdown to Telegram HTML for the same rich look
|
|
306
|
+
// as interactive replies, falling back to plain text if the model's
|
|
307
|
+
// text produced an entity Telegram can't parse (rare).
|
|
308
|
+
const html = markdownToTelegramHtml(text);
|
|
309
|
+
try {
|
|
310
|
+
await api.sendMessage(targetChat, html, {
|
|
311
|
+
parse_mode: 'HTML',
|
|
312
|
+
link_preview_options: { is_disabled: true },
|
|
313
|
+
});
|
|
314
|
+
} catch (err) {
|
|
315
|
+
if (err instanceof GrammyError && /can't parse entities|Bad Request: can't parse/i.test(err.description ?? '')) {
|
|
316
|
+
await api.sendMessage(targetChat, stripHtml(html));
|
|
317
|
+
} else {
|
|
318
|
+
throw err;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return { delivered: true, chatId: targetChat, length: text.length };
|
|
323
|
+
},
|
|
324
|
+
}),
|
|
325
|
+
defineTool({
|
|
326
|
+
name: 'telegram_unpair',
|
|
327
|
+
description: 'Forget the currently authorized Telegram chat. The next /start will start a fresh pairing.',
|
|
328
|
+
inputSchema: z.object({}),
|
|
329
|
+
permission: { action: 'prompt' },
|
|
330
|
+
isolation: {
|
|
331
|
+
capabilities: {
|
|
332
|
+
fs: { read: ['~/.moxxy/vault.*'], write: ['~/.moxxy/vault.*'] },
|
|
333
|
+
net: { mode: 'none' },
|
|
334
|
+
timeMs: 10_000,
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
handler: async () => {
|
|
338
|
+
const removed = await getVault().delete(AUTHORIZED_CHAT_KEY);
|
|
339
|
+
return removed ? 'unpaired' : 'no pairing was active';
|
|
340
|
+
},
|
|
341
|
+
}),
|
|
342
|
+
],
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Discovery entry: `createPluginLoader` requires a default Plugin export.
|
|
347
|
+
export default telegramPlugin;
|
package/src/keys.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vault keys + token validation shared by the channel, its subcommands, and the
|
|
3
|
+
* interactive setup wizard. Kept in their own module so the wizard / pair-flow
|
|
4
|
+
* helpers can import them without pulling in the plugin's full index.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Vault key the plugin uses for the Bot API token. */
|
|
8
|
+
export const TELEGRAM_TOKEN_KEY = 'telegram_bot_token';
|
|
9
|
+
/** Vault key the plugin uses for the paired chat id. */
|
|
10
|
+
export const TELEGRAM_AUTHORIZED_CHAT_KEY = 'telegram_authorized_chat_id';
|
|
11
|
+
/**
|
|
12
|
+
* Vault key for the voice-replies preference. Single-paired-chat model, so a
|
|
13
|
+
* simple present-flag (`'1'` = on, absent = off) keyed like the other telegram
|
|
14
|
+
* keys, toggled from the paired chat via `/voice`.
|
|
15
|
+
*/
|
|
16
|
+
export const TELEGRAM_VOICE_REPLIES_KEY = 'telegram_voice_replies';
|
|
17
|
+
/** Regex validating a Telegram bot token (`<digits>:<22+ url-safe>`). */
|
|
18
|
+
export const TELEGRAM_TOKEN_RE = /^\d+:[A-Za-z0-9_-]{20,}$/;
|
|
19
|
+
|
|
20
|
+
/** The narrow vault slice the voice-replies flag helpers use. */
|
|
21
|
+
interface VoiceFlagVault {
|
|
22
|
+
get(name: string): Promise<string | null>;
|
|
23
|
+
set(name: string, value: string): Promise<void>;
|
|
24
|
+
delete(name: string): Promise<boolean>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Read the persisted voice-replies preference (`'1'` → on). */
|
|
28
|
+
export async function loadVoiceReplies(vault: Pick<VoiceFlagVault, 'get'>): Promise<boolean> {
|
|
29
|
+
return (await vault.get(TELEGRAM_VOICE_REPLIES_KEY)) === '1';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Persist the voice-replies preference: store `'1'` when on, remove it when off. */
|
|
33
|
+
export async function saveVoiceReplies(
|
|
34
|
+
vault: Pick<VoiceFlagVault, 'set' | 'delete'>,
|
|
35
|
+
on: boolean,
|
|
36
|
+
): Promise<void> {
|
|
37
|
+
if (on) await vault.set(TELEGRAM_VOICE_REPLIES_KEY, '1');
|
|
38
|
+
else await vault.delete(TELEGRAM_VOICE_REPLIES_KEY);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse a stored chat-id (the vault keeps it as a string). Returns `null` for a
|
|
43
|
+
* missing OR corrupt value rather than letting `Number(x)` yield `NaN` — a
|
|
44
|
+
* silent NaN serializes to `null` in status output (masking the corruption) and
|
|
45
|
+
* would otherwise be passed to `api.sendMessage(NaN, …)` as a real target,
|
|
46
|
+
* producing an opaque Telegram 400 instead of a clear "not paired" signal.
|
|
47
|
+
*/
|
|
48
|
+
export function parseChatId(raw: string | null | undefined): number | null {
|
|
49
|
+
if (raw == null || raw === '') return null;
|
|
50
|
+
const n = Number(raw);
|
|
51
|
+
return Number.isFinite(n) ? n : null;
|
|
52
|
+
}
|
package/src/pair-flow.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { log, outro, spinner } from '@clack/prompts';
|
|
2
|
+
import QRCode from 'qrcode';
|
|
3
|
+
import { exitAfterPairRequested, type ChannelSubcommandContext } from '@moxxy/sdk';
|
|
4
|
+
import type { VaultStore } from '@moxxy/plugin-vault';
|
|
5
|
+
import { TelegramChannel } from './channel.js';
|
|
6
|
+
|
|
7
|
+
// Tiny zero-dep ANSI dim helper, so this flow stays inside the plugin.
|
|
8
|
+
const ANSI = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
9
|
+
const dim = (s: string): string => (ANSI ? `\x1b[2m${s}\x1b[22m` : s);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Drive the host-issued QR pairing flow end-to-end in a terminal — the SAME
|
|
13
|
+
* mechanism the desktop Channels panel uses, just rendered inline here.
|
|
14
|
+
*
|
|
15
|
+
* Steps:
|
|
16
|
+
* 1. Build a TelegramChannel from the subcommand ctx and wire the session's
|
|
17
|
+
* permission resolver.
|
|
18
|
+
* 2. Subscribe to "paired" BEFORE starting the bot so a fast scan can't race us.
|
|
19
|
+
* 3. Start the bot in pairing mode — it mints a code and publishes a
|
|
20
|
+
* `t.me/<bot>?start=<code>` deep link as `channel.requestUrl`.
|
|
21
|
+
* 4. Render that deep link as a scannable QR (+ the plain link).
|
|
22
|
+
* 5. The user scans / opens the link and taps START (or sends the 6 digits);
|
|
23
|
+
* the bot authorizes that chat and `onPaired` fires.
|
|
24
|
+
* 6. Hand off SIGINT to keep the bot running until the user Ctrl-Cs.
|
|
25
|
+
*/
|
|
26
|
+
export async function runPairFlow(ctx: ChannelSubcommandContext): Promise<number> {
|
|
27
|
+
const session = ctx.session;
|
|
28
|
+
const channel = new TelegramChannel({
|
|
29
|
+
vault: ctx.deps.vault as VaultStore,
|
|
30
|
+
token: (ctx.deps.options?.['token'] as string | undefined) ?? undefined,
|
|
31
|
+
logger: ctx.deps.logger as never,
|
|
32
|
+
});
|
|
33
|
+
session.setPermissionResolver(channel.permissionResolver);
|
|
34
|
+
|
|
35
|
+
// Subscribe BEFORE start so the first scan can't fire before us.
|
|
36
|
+
let pairedResolve: ((chatId: number) => void) | null = null;
|
|
37
|
+
const paired = new Promise<number>((resolve) => {
|
|
38
|
+
pairedResolve = resolve;
|
|
39
|
+
});
|
|
40
|
+
const unsubscribe = channel.onPaired((chatId) => {
|
|
41
|
+
pairedResolve?.(chatId);
|
|
42
|
+
pairedResolve = null;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
outro(dim('opening pairing window...'));
|
|
46
|
+
|
|
47
|
+
// `pair: true` opens the host-issued QR window and resolves the bot's deep link.
|
|
48
|
+
const handle = await channel.start({ session, pair: true });
|
|
49
|
+
|
|
50
|
+
// From here on we own the bot lifecycle. Any failure path needs to
|
|
51
|
+
// call stopBot() before returning.
|
|
52
|
+
const stopBot = async (): Promise<void> => {
|
|
53
|
+
unsubscribe();
|
|
54
|
+
try {
|
|
55
|
+
await handle.stop('wizard');
|
|
56
|
+
} catch {
|
|
57
|
+
/* ignore */
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Already paired (no window was opened): there's nothing to scan. Tell the
|
|
62
|
+
// user how to re-pair rather than hanging on a pairing that can't happen.
|
|
63
|
+
if (channel.connected) {
|
|
64
|
+
log.info(
|
|
65
|
+
'This bot is already paired. Run `moxxy channels telegram unpair` first to pair a different chat.',
|
|
66
|
+
);
|
|
67
|
+
await stopBot();
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const url = channel.requestUrl;
|
|
72
|
+
if (!url) {
|
|
73
|
+
log.error('Could not resolve the bot link — check the token is valid and the network is reachable.');
|
|
74
|
+
await stopBot();
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
await printPairQr(url);
|
|
79
|
+
log.info('Scan the QR with your phone (or open the link and tap START) — your chat pairs automatically.');
|
|
80
|
+
|
|
81
|
+
// Graceful Ctrl-C while waiting (or once running): stop the bot and exit.
|
|
82
|
+
let stopping = false;
|
|
83
|
+
const shutdown = async (): Promise<void> => {
|
|
84
|
+
if (stopping) return;
|
|
85
|
+
stopping = true;
|
|
86
|
+
await stopBot();
|
|
87
|
+
await session.close('SIGINT').catch(() => undefined);
|
|
88
|
+
process.exit(0);
|
|
89
|
+
};
|
|
90
|
+
const onSignal = (): void => void shutdown();
|
|
91
|
+
process.once('SIGINT', onSignal);
|
|
92
|
+
process.once('SIGTERM', onSignal);
|
|
93
|
+
|
|
94
|
+
const spin = spinner();
|
|
95
|
+
spin.start('Waiting for you to pair in Telegram...');
|
|
96
|
+
const chatId = await paired;
|
|
97
|
+
spin.stop(`Paired ✓ — chat ${chatId} is authorized.`);
|
|
98
|
+
|
|
99
|
+
if (exitAfterPairRequested(ctx)) {
|
|
100
|
+
// Orchestrated pairing (`moxxy onboard`): hand control back — the caller
|
|
101
|
+
// starts the bot under its own service afterwards. Our SIGINT handlers
|
|
102
|
+
// would `process.exit` the orchestrator, so drop them first.
|
|
103
|
+
process.removeListener('SIGINT', onSignal);
|
|
104
|
+
process.removeListener('SIGTERM', onSignal);
|
|
105
|
+
await stopBot();
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
log.info('Bot is running. Press Ctrl+C to stop.');
|
|
110
|
+
try {
|
|
111
|
+
// Only reached if the bot stops on its own (a signal path exits via
|
|
112
|
+
// shutdown()); remove our handlers so they don't outlive this flow.
|
|
113
|
+
await handle.running;
|
|
114
|
+
return 0;
|
|
115
|
+
} finally {
|
|
116
|
+
process.removeListener('SIGINT', onSignal);
|
|
117
|
+
process.removeListener('SIGTERM', onSignal);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Render the pairing deep link as a scannable terminal QR + the plain link. */
|
|
122
|
+
async function printPairQr(url: string): Promise<void> {
|
|
123
|
+
let qr = '';
|
|
124
|
+
try {
|
|
125
|
+
qr = await QRCode.toString(url, { type: 'terminal', small: true });
|
|
126
|
+
} catch {
|
|
127
|
+
qr = '';
|
|
128
|
+
}
|
|
129
|
+
// CLI surface — intentional stdout.
|
|
130
|
+
console.log(['', qr, ` link: ${url}`, ''].join('\n'));
|
|
131
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
beginHostIssuedPairing,
|
|
4
|
+
createPairingState,
|
|
5
|
+
handleStart,
|
|
6
|
+
isAuthorized,
|
|
7
|
+
submitChatCode,
|
|
8
|
+
} from './pairing.js';
|
|
9
|
+
|
|
10
|
+
describe('pairing protocol (host-issued QR code)', () => {
|
|
11
|
+
it('starts in idle phase', () => {
|
|
12
|
+
const s = createPairingState();
|
|
13
|
+
expect(s.phase).toBe('idle');
|
|
14
|
+
expect(s.authorizedChatId).toBeNull();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('starts in paired phase when an authorized chat is restored', () => {
|
|
18
|
+
const s = createPairingState({ authorizedChatId: 42 });
|
|
19
|
+
expect(s.phase).toBe('paired');
|
|
20
|
+
expect(isAuthorized(s, 42)).toBe(true);
|
|
21
|
+
expect(isAuthorized(s, 99)).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('beginHostIssuedPairing mints a 6-digit code up front and opens the window', () => {
|
|
25
|
+
const { state, code } = beginHostIssuedPairing(createPairingState());
|
|
26
|
+
expect(state.phase).toBe('awaiting-host-code');
|
|
27
|
+
expect(code).toMatch(/^\d{6}$/);
|
|
28
|
+
expect(state.code).toBe(code);
|
|
29
|
+
// No TTL by default — the window lives as long as the channel runs unpaired.
|
|
30
|
+
expect(state.expiresAt).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('submitChatCode pairs the presenting chat on the correct code', () => {
|
|
34
|
+
const { state, code } = beginHostIssuedPairing(createPairingState());
|
|
35
|
+
const r = submitChatCode(state, 1, code);
|
|
36
|
+
expect(r.action.kind).toBe('paired');
|
|
37
|
+
expect(r.state.phase).toBe('paired');
|
|
38
|
+
expect(r.state.authorizedChatId).toBe(1);
|
|
39
|
+
expect(isAuthorized(r.state, 1)).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('submitChatCode accepts the code with surrounding whitespace', () => {
|
|
43
|
+
const { state, code } = beginHostIssuedPairing(createPairingState());
|
|
44
|
+
const r = submitChatCode(state, 7, ` ${code} `);
|
|
45
|
+
expect(r.action.kind).toBe('paired');
|
|
46
|
+
expect(r.state.authorizedChatId).toBe(7);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('submitChatCode reports a mismatch on a wrong code', () => {
|
|
50
|
+
const { state, code } = beginHostIssuedPairing(createPairingState());
|
|
51
|
+
const wrong = code === '000000' ? '111111' : '000000';
|
|
52
|
+
const r = submitChatCode(state, 1, wrong);
|
|
53
|
+
expect(r.action.kind).toBe('mismatch');
|
|
54
|
+
if (r.action.kind !== 'mismatch') return;
|
|
55
|
+
expect(r.action.message).toMatch(/didn't match/);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('submitChatCode treats non-digit input as a mismatch', () => {
|
|
59
|
+
const { state } = beginHostIssuedPairing(createPairingState());
|
|
60
|
+
const r = submitChatCode(state, 1, 'hello!');
|
|
61
|
+
expect(r.action.kind).toBe('mismatch');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('submitChatCode reports not-pending when no window is open', () => {
|
|
65
|
+
const r = submitChatCode(createPairingState(), 1, '123456');
|
|
66
|
+
expect(r.action.kind).toBe('not-pending');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('submitChatCode acknowledges the already-paired chat (idempotent)', () => {
|
|
70
|
+
const r = submitChatCode(createPairingState({ authorizedChatId: 5 }), 5, '123456');
|
|
71
|
+
expect(r.action.kind).toBe('still-paired');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('submitChatCode rejects a different chat once one is paired', () => {
|
|
75
|
+
const r = submitChatCode(createPairingState({ authorizedChatId: 5 }), 6, '123456');
|
|
76
|
+
expect(r.action.kind).toBe('reject');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('expires after the (optional) TTL window', () => {
|
|
80
|
+
const t0 = 1_000_000;
|
|
81
|
+
const { state, code } = beginHostIssuedPairing(createPairingState(), undefined, t0, 1000);
|
|
82
|
+
const r = submitChatCode(state, 1, code, t0 + 2000);
|
|
83
|
+
expect(r.action.kind).toBe('expired');
|
|
84
|
+
expect(r.state.phase).toBe('expired');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('handleStart (bare /start, no code payload)', () => {
|
|
89
|
+
it('rejects with a "start pairing" nudge when no window is open', () => {
|
|
90
|
+
const r = handleStart(createPairingState(), 1);
|
|
91
|
+
expect(r.action.kind).toBe('reject');
|
|
92
|
+
if (r.action.kind !== 'reject') return;
|
|
93
|
+
expect(r.action.message).toMatch(/No pairing window/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('nudges to use the QR / send the code while a host window is open', () => {
|
|
97
|
+
const { state } = beginHostIssuedPairing(createPairingState());
|
|
98
|
+
const r = handleStart(state, 1);
|
|
99
|
+
expect(r.action.kind).toBe('reject');
|
|
100
|
+
if (r.action.kind !== 'reject') return;
|
|
101
|
+
expect(r.action.message).toMatch(/QR|code/i);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('acknowledges the already-paired chat', () => {
|
|
105
|
+
const r = handleStart(createPairingState({ authorizedChatId: 7 }), 7);
|
|
106
|
+
expect(r.action.kind).toBe('still-paired');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('rejects a different chat when one is paired', () => {
|
|
110
|
+
const r = handleStart(createPairingState({ authorizedChatId: 7 }), 999);
|
|
111
|
+
expect(r.action.kind).toBe('reject');
|
|
112
|
+
});
|
|
113
|
+
});
|