@moxxy/plugin-telegram 0.41.2 → 0.42.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/dist/channel/callback-handler.d.ts +5 -2
- package/dist/channel/callback-handler.d.ts.map +1 -1
- package/dist/channel/callback-handler.js +31 -58
- package/dist/channel/callback-handler.js.map +1 -1
- package/dist/channel/mirror.d.ts +23 -0
- package/dist/channel/mirror.d.ts.map +1 -0
- package/dist/channel/mirror.js +23 -0
- package/dist/channel/mirror.js.map +1 -0
- package/dist/channel/model-picker.d.ts +17 -0
- package/dist/channel/model-picker.d.ts.map +1 -0
- package/dist/channel/model-picker.js +58 -0
- package/dist/channel/model-picker.js.map +1 -0
- package/dist/channel/model.d.ts +22 -0
- package/dist/channel/model.d.ts.map +1 -0
- package/dist/channel/model.js +11 -0
- package/dist/channel/model.js.map +1 -0
- package/dist/channel/pairing-handler.d.ts +2 -0
- package/dist/channel/pairing-handler.d.ts.map +1 -1
- package/dist/channel/pairing-handler.js +4 -0
- package/dist/channel/pairing-handler.js.map +1 -1
- package/dist/channel/send-rich.d.ts +16 -0
- package/dist/channel/send-rich.d.ts.map +1 -0
- package/dist/channel/send-rich.js +25 -0
- package/dist/channel/send-rich.js.map +1 -0
- package/dist/channel/slash-handler.d.ts +8 -6
- package/dist/channel/slash-handler.d.ts.map +1 -1
- package/dist/channel/slash-handler.js +29 -51
- package/dist/channel/slash-handler.js.map +1 -1
- package/dist/channel/text-handler.d.ts +6 -4
- package/dist/channel/text-handler.d.ts.map +1 -1
- package/dist/channel/text-handler.js +23 -52
- package/dist/channel/text-handler.js.map +1 -1
- package/dist/channel/turn-runner.d.ts +10 -4
- package/dist/channel/turn-runner.d.ts.map +1 -1
- package/dist/channel/turn-runner.js +22 -5
- package/dist/channel/turn-runner.js.map +1 -1
- package/dist/channel/voice-handler.d.ts.map +1 -1
- package/dist/channel/voice-handler.js +2 -0
- package/dist/channel/voice-handler.js.map +1 -1
- package/dist/channel.d.ts +8 -10
- package/dist/channel.d.ts.map +1 -1
- package/dist/channel.js +55 -55
- package/dist/channel.js.map +1 -1
- package/dist/format.d.ts +1 -0
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +1 -1
- package/dist/format.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -72
- package/dist/index.js.map +1 -1
- package/dist/keys.d.ts +3 -0
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +3 -0
- package/dist/keys.js.map +1 -1
- package/dist/pair-flow.d.ts +2 -1
- package/dist/pair-flow.d.ts.map +1 -1
- package/dist/pair-flow.js +9 -23
- package/dist/pair-flow.js.map +1 -1
- package/dist/tools/send-message.d.ts +21 -0
- package/dist/tools/send-message.d.ts.map +1 -0
- package/dist/tools/send-message.js +84 -0
- package/dist/tools/send-message.js.map +1 -0
- package/package.json +11 -10
- package/src/channel/callback-handler.test.ts +4 -33
- package/src/channel/callback-handler.ts +34 -65
- package/src/channel/mirror.test.ts +59 -0
- package/src/channel/mirror.ts +36 -0
- package/src/channel/model-picker.ts +75 -0
- package/src/channel/model.test.ts +203 -0
- package/src/channel/model.ts +33 -0
- package/src/channel/pairing-handler.test.ts +38 -0
- package/src/channel/pairing-handler.ts +5 -0
- package/src/channel/send-rich.ts +36 -0
- package/src/channel/slash-handler.test.ts +68 -0
- package/src/channel/slash-handler.ts +35 -66
- package/src/channel/text-handler.test.ts +6 -5
- package/src/channel/text-handler.ts +28 -52
- package/src/channel/turn-runner.test.ts +84 -0
- package/src/channel/turn-runner.ts +31 -9
- package/src/channel/voice-handler.ts +2 -0
- package/src/channel/voice-reply.test.ts +16 -14
- package/src/channel.ts +68 -57
- package/src/format.ts +1 -1
- package/src/index.ts +8 -75
- package/src/keys.ts +4 -0
- package/src/pair-flow.ts +9 -23
- package/src/subcommands.test.ts +2 -1
- package/src/tools/send-message.test.ts +118 -0
- package/src/tools/send-message.ts +97 -0
package/src/index.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { defineChannel, defineTool, definePlugin, z, type LifecycleHooks, type Plugin } from '@moxxy/sdk';
|
|
2
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
3
|
import { TelegramChannel } from './channel.js';
|
|
4
|
+
import { buildTelegramSendMessageTool } from './tools/send-message.js';
|
|
7
5
|
import { TELEGRAM_AUTHORIZED_CHAT_KEY, TELEGRAM_TOKEN_KEY, parseChatId } from './keys.js';
|
|
8
6
|
import { runTelegramWizard } from './setup-wizard.js';
|
|
9
7
|
import { runPairFlow } from './pair-flow.js';
|
|
@@ -34,7 +32,12 @@ export interface BuildTelegramPluginOptions {
|
|
|
34
32
|
readonly vault: VaultStore;
|
|
35
33
|
}
|
|
36
34
|
|
|
37
|
-
export {
|
|
35
|
+
export {
|
|
36
|
+
TELEGRAM_TOKEN_KEY,
|
|
37
|
+
TELEGRAM_AUTHORIZED_CHAT_KEY,
|
|
38
|
+
TELEGRAM_MODEL_KEY,
|
|
39
|
+
TELEGRAM_TOKEN_RE,
|
|
40
|
+
} from './keys.js';
|
|
38
41
|
|
|
39
42
|
// Backwards-compat aliases for the existing call sites in this file.
|
|
40
43
|
const TOKEN_KEY = TELEGRAM_TOKEN_KEY;
|
|
@@ -251,77 +254,7 @@ function makeTelegramPlugin(getVault: () => VaultStore, hooks?: LifecycleHooks):
|
|
|
251
254
|
};
|
|
252
255
|
},
|
|
253
256
|
}),
|
|
254
|
-
|
|
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
|
-
}),
|
|
257
|
+
buildTelegramSendMessageTool({ getVault }),
|
|
325
258
|
defineTool({
|
|
326
259
|
name: 'telegram_unpair',
|
|
327
260
|
description: 'Forget the currently authorized Telegram chat. The next /start will start a fresh pairing.',
|
package/src/keys.ts
CHANGED
|
@@ -50,3 +50,7 @@ export function parseChatId(raw: string | null | undefined): number | null {
|
|
|
50
50
|
const n = Number(raw);
|
|
51
51
|
return Number.isFinite(n) ? n : null;
|
|
52
52
|
}
|
|
53
|
+
|
|
54
|
+
/** Vault key for this bot's model (`provider::model`); absent = the default model.
|
|
55
|
+
* The desktop's Channels panel writes the same key. */
|
|
56
|
+
export const TELEGRAM_MODEL_KEY = 'telegram_model';
|
package/src/pair-flow.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { log, outro, spinner } from '@clack/prompts';
|
|
2
2
|
import QRCode from 'qrcode';
|
|
3
|
-
import {
|
|
3
|
+
import { finishPairing, type ChannelSubcommandContext } from '@moxxy/sdk';
|
|
4
4
|
import type { VaultStore } from '@moxxy/plugin-vault';
|
|
5
5
|
import { TelegramChannel } from './channel.js';
|
|
6
6
|
|
|
@@ -21,7 +21,8 @@ const dim = (s: string): string => (ANSI ? `\x1b[2m${s}\x1b[22m` : s);
|
|
|
21
21
|
* 4. Render that deep link as a scannable QR (+ the plain link).
|
|
22
22
|
* 5. The user scans / opens the link and taps START (or sends the 6 digits);
|
|
23
23
|
* the bot authorizes that chat and `onPaired` fires.
|
|
24
|
-
* 6.
|
|
24
|
+
* 6. Stop the pairing bot (it ran on the provider-less probe session) and
|
|
25
|
+
* start the channel for real with the configured model (`finishPairing`).
|
|
25
26
|
*/
|
|
26
27
|
export async function runPairFlow(ctx: ChannelSubcommandContext): Promise<number> {
|
|
27
28
|
const session = ctx.session;
|
|
@@ -78,7 +79,7 @@ export async function runPairFlow(ctx: ChannelSubcommandContext): Promise<number
|
|
|
78
79
|
await printPairQr(url);
|
|
79
80
|
log.info('Scan the QR with your phone (or open the link and tap START) — your chat pairs automatically.');
|
|
80
81
|
|
|
81
|
-
// Graceful Ctrl-C while waiting
|
|
82
|
+
// Graceful Ctrl-C while waiting for the pairing: stop the bot and exit.
|
|
82
83
|
let stopping = false;
|
|
83
84
|
const shutdown = async (): Promise<void> => {
|
|
84
85
|
if (stopping) return;
|
|
@@ -96,26 +97,11 @@ export async function runPairFlow(ctx: ChannelSubcommandContext): Promise<number
|
|
|
96
97
|
const chatId = await paired;
|
|
97
98
|
spin.stop(`Paired ✓ — chat ${chatId} is authorized.`);
|
|
98
99
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
}
|
|
100
|
+
// Drop the pairing-only Ctrl+C handlers first: their `process.exit` would
|
|
101
|
+
// otherwise cut the real channel (or an orchestrator) short.
|
|
102
|
+
process.removeListener('SIGINT', onSignal);
|
|
103
|
+
process.removeListener('SIGTERM', onSignal);
|
|
104
|
+
return finishPairing(ctx, stopBot);
|
|
119
105
|
}
|
|
120
106
|
|
|
121
107
|
/** Render the pairing deep link as a scannable terminal QR + the plain link. */
|
package/src/subcommands.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
5
5
|
import { VaultStore, createStaticKeySource, deriveKey, generateSalt } from '@moxxy/plugin-vault';
|
|
6
6
|
import type { ChannelDef } from '@moxxy/sdk';
|
|
7
7
|
import { buildTelegramPlugin } from './index.js';
|
|
8
|
+
import { removeDir } from '@moxxy/vitest-preset/fs';
|
|
8
9
|
|
|
9
10
|
let tmp: string;
|
|
10
11
|
let vault: VaultStore;
|
|
@@ -37,7 +38,7 @@ beforeEach(async () => {
|
|
|
37
38
|
});
|
|
38
39
|
|
|
39
40
|
afterEach(async () => {
|
|
40
|
-
await
|
|
41
|
+
await removeDir(tmp);
|
|
41
42
|
process.stdout.write = origStdoutWrite;
|
|
42
43
|
process.stderr.write = origStderrWrite;
|
|
43
44
|
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import type { InputFile } from 'grammy';
|
|
6
|
+
import { VaultStore, createStaticKeySource, deriveKey, generateSalt } from '@moxxy/plugin-vault';
|
|
7
|
+
import type { ToolContext } from '@moxxy/sdk';
|
|
8
|
+
import { TELEGRAM_AUTHORIZED_CHAT_KEY, TELEGRAM_TOKEN_KEY } from '../keys.js';
|
|
9
|
+
import { buildTelegramSendMessageTool, type TelegramSendApi } from './send-message.js';
|
|
10
|
+
import { removeDir } from '@moxxy/vitest-preset/fs';
|
|
11
|
+
|
|
12
|
+
let tmp: string;
|
|
13
|
+
let vault: VaultStore;
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-tg-send-'));
|
|
17
|
+
vault = new VaultStore({
|
|
18
|
+
filePath: path.join(tmp, 'vault.json'),
|
|
19
|
+
keySource: createStaticKeySource(deriveKey('test', generateSalt())),
|
|
20
|
+
});
|
|
21
|
+
await vault.set(TELEGRAM_TOKEN_KEY, '123456:ABCDEFGHIJKLMNOPQRSTUVWXYZ');
|
|
22
|
+
await vault.set(TELEGRAM_AUTHORIZED_CHAT_KEY, '42');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
await removeDir(tmp);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
type Call =
|
|
30
|
+
| { kind: 'message'; chatId: number; text: string; parseMode?: string }
|
|
31
|
+
| { kind: 'document'; chatId: number; name: string | undefined };
|
|
32
|
+
|
|
33
|
+
function fakeApi() {
|
|
34
|
+
const calls: Call[] = [];
|
|
35
|
+
const tokens: string[] = [];
|
|
36
|
+
const api: TelegramSendApi = {
|
|
37
|
+
sendMessage: async (chatId, text, opts) => {
|
|
38
|
+
calls.push({ kind: 'message', chatId, text, ...(opts?.parse_mode ? { parseMode: opts.parse_mode } : {}) });
|
|
39
|
+
return undefined;
|
|
40
|
+
},
|
|
41
|
+
sendDocument: async (chatId, file: InputFile) => {
|
|
42
|
+
calls.push({ kind: 'document', chatId, name: file.filename });
|
|
43
|
+
return undefined;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
const tool = buildTelegramSendMessageTool({
|
|
47
|
+
getVault: () => vault,
|
|
48
|
+
createApi: (token) => {
|
|
49
|
+
tokens.push(token);
|
|
50
|
+
return api;
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
return { calls, tokens, tool };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ctx = () => ({ cwd: tmp }) as unknown as ToolContext;
|
|
57
|
+
|
|
58
|
+
describe('telegram_send_message', () => {
|
|
59
|
+
it('sends the text to the paired chat, formatted like the bot replies', async () => {
|
|
60
|
+
const { calls, tokens, tool } = fakeApi();
|
|
61
|
+
|
|
62
|
+
const out = await tool.handler({ text: 'Build **done**.' }, ctx());
|
|
63
|
+
|
|
64
|
+
expect(tokens).toEqual(['123456:ABCDEFGHIJKLMNOPQRSTUVWXYZ']);
|
|
65
|
+
expect(calls).toEqual([{ kind: 'message', chatId: 42, text: 'Build <b>done</b>.', parseMode: 'HTML' }]);
|
|
66
|
+
expect(out).toMatchObject({ delivered: true, chatId: 42 });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('sends the files the owner asked for as documents under the text', async () => {
|
|
70
|
+
await fs.writeFile(path.join(tmp, 'report.pdf'), 'pdf');
|
|
71
|
+
await fs.writeFile(path.join(tmp, 'photo.png'), 'png');
|
|
72
|
+
const { calls, tool } = fakeApi();
|
|
73
|
+
|
|
74
|
+
const out = await tool.handler({ text: 'Here you go', files: ['report.pdf', path.join(tmp, 'photo.png')] }, ctx());
|
|
75
|
+
|
|
76
|
+
expect(calls).toEqual([
|
|
77
|
+
{ kind: 'message', chatId: 42, text: 'Here you go', parseMode: 'HTML' },
|
|
78
|
+
{ kind: 'document', chatId: 42, name: 'report.pdf' },
|
|
79
|
+
{ kind: 'document', chatId: 42, name: 'photo.png' },
|
|
80
|
+
]);
|
|
81
|
+
expect(out).toMatchObject({ files: ['report.pdf', 'photo.png'] });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('refuses files larger than a bot may upload before sending anything', async () => {
|
|
85
|
+
const big = path.join(tmp, 'film.mp4');
|
|
86
|
+
await fs.writeFile(big, '');
|
|
87
|
+
await fs.truncate(big, 51 * 1024 * 1024);
|
|
88
|
+
const { calls, tool } = fakeApi();
|
|
89
|
+
|
|
90
|
+
await expect(tool.handler({ text: 'film', files: [big] }, ctx())).rejects.toThrow(/Telegram accepts at most 50 MB/);
|
|
91
|
+
expect(calls).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('splits a long text into several messages, in order', async () => {
|
|
95
|
+
const { calls, tool } = fakeApi();
|
|
96
|
+
|
|
97
|
+
await tool.handler({ text: `${'a'.repeat(4000)}\n${'b'.repeat(4000)}` }, ctx());
|
|
98
|
+
|
|
99
|
+
const texts = calls.map((c) => (c.kind === 'message' ? c.text : ''));
|
|
100
|
+
expect(texts.length).toBeGreaterThan(1);
|
|
101
|
+
expect(texts.join('').replace(/\s/g, '')).toBe(`${'a'.repeat(4000)}${'b'.repeat(4000)}`);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('keeps an explicit parse mode verbatim', async () => {
|
|
105
|
+
const { calls, tool } = fakeApi();
|
|
106
|
+
|
|
107
|
+
await tool.handler({ text: '*raw*', parseMode: 'MarkdownV2' }, ctx());
|
|
108
|
+
|
|
109
|
+
expect(calls).toEqual([{ kind: 'message', chatId: 42, text: '*raw*', parseMode: 'MarkdownV2' }]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('says to pair first when no chat is paired', async () => {
|
|
113
|
+
await vault.delete(TELEGRAM_AUTHORIZED_CHAT_KEY);
|
|
114
|
+
const { tool } = fakeApi();
|
|
115
|
+
|
|
116
|
+
await expect(tool.handler({ text: 'hi' }, ctx())).rejects.toThrow(/no authorized chat/);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Api, InputFile } from 'grammy';
|
|
2
|
+
import { defineTool, z, type ToolDef } from '@moxxy/sdk';
|
|
3
|
+
import { readLocalFiles, resolveSecret } from '@moxxy/channel-kit';
|
|
4
|
+
import { TELEGRAM_AUTHORIZED_CHAT_KEY, TELEGRAM_TOKEN_KEY, parseChatId } from '../keys.js';
|
|
5
|
+
import { sendMarkdown, type TelegramSender } from '../channel/send-rich.js';
|
|
6
|
+
|
|
7
|
+
/** The slice of grammy's `Api` this tool uses (the real client satisfies it). */
|
|
8
|
+
export interface TelegramSendApi extends TelegramSender {
|
|
9
|
+
sendDocument(chatId: number, document: InputFile): Promise<unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TelegramSendMessageToolDeps {
|
|
13
|
+
readonly getVault: () => { get(name: string): Promise<string | null> };
|
|
14
|
+
/** Build the API client for a token. Defaults to grammy's lightweight `Api`. */
|
|
15
|
+
readonly createApi?: (token: string) => TelegramSendApi;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const TOKEN_ENV = 'MOXXY_TELEGRAM_TOKEN';
|
|
19
|
+
/** Cap on one push — a couple of Telegram messages once split. */
|
|
20
|
+
const MAX_TEXT_CHARS = 8_000;
|
|
21
|
+
/** The Bot API accepts uploads up to 50 MB. */
|
|
22
|
+
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
23
|
+
const MAX_FILES = 10;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `telegram_send_message` — push a message (and files) to the paired chat from
|
|
27
|
+
* ANY session (desktop, goal mode, a scheduled prompt) without the Telegram
|
|
28
|
+
* channel running. A one-off `Api` rather than a full `Bot`: no long-polling.
|
|
29
|
+
*/
|
|
30
|
+
export function buildTelegramSendMessageTool(deps: TelegramSendMessageToolDeps): ToolDef {
|
|
31
|
+
const createApi = deps.createApi ?? ((token: string) => new Api(token));
|
|
32
|
+
return defineTool({
|
|
33
|
+
name: 'telegram_send_message',
|
|
34
|
+
description:
|
|
35
|
+
'Push a message to the paired Telegram chat. Use it to proactively report progress, a finished ' +
|
|
36
|
+
'task, or a blocker that needs their decision — e.g. from a long goal-mode run or a scheduled ' +
|
|
37
|
+
'prompt — without the Telegram channel running. The text is rendered with the same rich ' +
|
|
38
|
+
'Markdown→Telegram formatting as interactive replies (bold, code, `> [!type]` callouts, ' +
|
|
39
|
+
'`||spoilers||`); pass an explicit `parseMode` to send the raw text under that parse mode ' +
|
|
40
|
+
'instead. Long text is split into several messages. Pass `files` (local paths) to send files ' +
|
|
41
|
+
'the owner asked for as documents — up to 10 files, 50 MB in total. Requires a stored bot ' +
|
|
42
|
+
'token + a paired chat (run `moxxy channels telegram pair` once).',
|
|
43
|
+
inputSchema: z.object({
|
|
44
|
+
text: z.string().trim().min(1).max(MAX_TEXT_CHARS),
|
|
45
|
+
files: z.array(z.string().min(1).max(4096)).max(MAX_FILES).optional(),
|
|
46
|
+
/** Optional override; defaults to the vault-paired chat id. */
|
|
47
|
+
chatId: z.number().int().optional(),
|
|
48
|
+
/** Force a Telegram parse mode and send `text` verbatim under it. */
|
|
49
|
+
parseMode: z.enum(['MarkdownV2', 'Markdown', 'HTML']).optional(),
|
|
50
|
+
}),
|
|
51
|
+
permission: { action: 'prompt' },
|
|
52
|
+
isolation: {
|
|
53
|
+
capabilities: {
|
|
54
|
+
// Attachments are whatever local file the owner asked for; the
|
|
55
|
+
// permission prompt shows the paths before anything is read or sent.
|
|
56
|
+
fs: { read: ['~/.moxxy/vault.*', '~/**', '$cwd/**', '/tmp/**'] },
|
|
57
|
+
net: { mode: 'allowlist', hosts: ['api.telegram.org'] },
|
|
58
|
+
env: [TOKEN_ENV],
|
|
59
|
+
timeMs: 120_000,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
handler: async ({ text, files: filePaths, chatId, parseMode }, ctx) => {
|
|
63
|
+
const vault = deps.getVault();
|
|
64
|
+
const token = await resolveSecret(vault, { envVar: TOKEN_ENV, vaultKey: TELEGRAM_TOKEN_KEY });
|
|
65
|
+
if (!token) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`no Telegram bot token configured (set ${TOKEN_ENV} or run \`moxxy channels telegram setup\`)`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
const target = chatId ?? parseChatId(await vault.get(TELEGRAM_AUTHORIZED_CHAT_KEY));
|
|
71
|
+
if (!target) {
|
|
72
|
+
throw new Error('no authorized chat — run `moxxy channels telegram pair` first or pass `chatId` explicitly');
|
|
73
|
+
}
|
|
74
|
+
const files = await readLocalFiles(filePaths ?? [], {
|
|
75
|
+
cwd: ctx.cwd,
|
|
76
|
+
maxTotalBytes: MAX_UPLOAD_BYTES,
|
|
77
|
+
service: 'Telegram',
|
|
78
|
+
limitLabel: '50 MB per upload',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const api = createApi(token);
|
|
82
|
+
let parts = 1;
|
|
83
|
+
// An explicit mode means the caller owns the markup: send it verbatim.
|
|
84
|
+
if (parseMode) await api.sendMessage(target, text, { parse_mode: parseMode });
|
|
85
|
+
else parts = await sendMarkdown(api, target, text);
|
|
86
|
+
// Files go under the text that introduces them, in order.
|
|
87
|
+
for (const file of files) await api.sendDocument(target, new InputFile(file.data, file.name));
|
|
88
|
+
return {
|
|
89
|
+
delivered: true,
|
|
90
|
+
chatId: target,
|
|
91
|
+
parts,
|
|
92
|
+
length: text.length,
|
|
93
|
+
...(files.length > 0 ? { files: files.map((f) => f.name) } : {}),
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|