@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.
Files changed (90) hide show
  1. package/dist/channel/callback-handler.d.ts +5 -2
  2. package/dist/channel/callback-handler.d.ts.map +1 -1
  3. package/dist/channel/callback-handler.js +31 -58
  4. package/dist/channel/callback-handler.js.map +1 -1
  5. package/dist/channel/mirror.d.ts +23 -0
  6. package/dist/channel/mirror.d.ts.map +1 -0
  7. package/dist/channel/mirror.js +23 -0
  8. package/dist/channel/mirror.js.map +1 -0
  9. package/dist/channel/model-picker.d.ts +17 -0
  10. package/dist/channel/model-picker.d.ts.map +1 -0
  11. package/dist/channel/model-picker.js +58 -0
  12. package/dist/channel/model-picker.js.map +1 -0
  13. package/dist/channel/model.d.ts +22 -0
  14. package/dist/channel/model.d.ts.map +1 -0
  15. package/dist/channel/model.js +11 -0
  16. package/dist/channel/model.js.map +1 -0
  17. package/dist/channel/pairing-handler.d.ts +2 -0
  18. package/dist/channel/pairing-handler.d.ts.map +1 -1
  19. package/dist/channel/pairing-handler.js +4 -0
  20. package/dist/channel/pairing-handler.js.map +1 -1
  21. package/dist/channel/send-rich.d.ts +16 -0
  22. package/dist/channel/send-rich.d.ts.map +1 -0
  23. package/dist/channel/send-rich.js +25 -0
  24. package/dist/channel/send-rich.js.map +1 -0
  25. package/dist/channel/slash-handler.d.ts +8 -6
  26. package/dist/channel/slash-handler.d.ts.map +1 -1
  27. package/dist/channel/slash-handler.js +29 -51
  28. package/dist/channel/slash-handler.js.map +1 -1
  29. package/dist/channel/text-handler.d.ts +6 -4
  30. package/dist/channel/text-handler.d.ts.map +1 -1
  31. package/dist/channel/text-handler.js +23 -52
  32. package/dist/channel/text-handler.js.map +1 -1
  33. package/dist/channel/turn-runner.d.ts +10 -4
  34. package/dist/channel/turn-runner.d.ts.map +1 -1
  35. package/dist/channel/turn-runner.js +22 -5
  36. package/dist/channel/turn-runner.js.map +1 -1
  37. package/dist/channel/voice-handler.d.ts.map +1 -1
  38. package/dist/channel/voice-handler.js +2 -0
  39. package/dist/channel/voice-handler.js.map +1 -1
  40. package/dist/channel.d.ts +8 -10
  41. package/dist/channel.d.ts.map +1 -1
  42. package/dist/channel.js +55 -55
  43. package/dist/channel.js.map +1 -1
  44. package/dist/format.d.ts +1 -0
  45. package/dist/format.d.ts.map +1 -1
  46. package/dist/format.js +1 -1
  47. package/dist/format.js.map +1 -1
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +3 -72
  51. package/dist/index.js.map +1 -1
  52. package/dist/keys.d.ts +3 -0
  53. package/dist/keys.d.ts.map +1 -1
  54. package/dist/keys.js +3 -0
  55. package/dist/keys.js.map +1 -1
  56. package/dist/pair-flow.d.ts +2 -1
  57. package/dist/pair-flow.d.ts.map +1 -1
  58. package/dist/pair-flow.js +9 -23
  59. package/dist/pair-flow.js.map +1 -1
  60. package/dist/tools/send-message.d.ts +21 -0
  61. package/dist/tools/send-message.d.ts.map +1 -0
  62. package/dist/tools/send-message.js +84 -0
  63. package/dist/tools/send-message.js.map +1 -0
  64. package/package.json +11 -10
  65. package/src/channel/callback-handler.test.ts +4 -33
  66. package/src/channel/callback-handler.ts +34 -65
  67. package/src/channel/mirror.test.ts +59 -0
  68. package/src/channel/mirror.ts +36 -0
  69. package/src/channel/model-picker.ts +75 -0
  70. package/src/channel/model.test.ts +203 -0
  71. package/src/channel/model.ts +33 -0
  72. package/src/channel/pairing-handler.test.ts +38 -0
  73. package/src/channel/pairing-handler.ts +5 -0
  74. package/src/channel/send-rich.ts +36 -0
  75. package/src/channel/slash-handler.test.ts +68 -0
  76. package/src/channel/slash-handler.ts +35 -66
  77. package/src/channel/text-handler.test.ts +6 -5
  78. package/src/channel/text-handler.ts +28 -52
  79. package/src/channel/turn-runner.test.ts +84 -0
  80. package/src/channel/turn-runner.ts +31 -9
  81. package/src/channel/voice-handler.ts +2 -0
  82. package/src/channel/voice-reply.test.ts +16 -14
  83. package/src/channel.ts +68 -57
  84. package/src/format.ts +1 -1
  85. package/src/index.ts +8 -75
  86. package/src/keys.ts +4 -0
  87. package/src/pair-flow.ts +9 -23
  88. package/src/subcommands.test.ts +2 -1
  89. package/src/tools/send-message.test.ts +118 -0
  90. package/src/tools/send-message.ts +97 -0
@@ -0,0 +1,203 @@
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, vi } from 'vitest';
5
+ import type { Context } from 'grammy';
6
+ import { Session, autoAllowResolver, silentLogger } from '@moxxy/core';
7
+ import { FakeProvider } from '@moxxy/testing';
8
+ import { defineProvider, definePlugin } from '@moxxy/sdk';
9
+ import { VaultStore, createStaticKeySource, deriveKey, generateSalt } from '@moxxy/plugin-vault';
10
+
11
+ // A model picked for the bot must never become the desktop's/TUI's model.
12
+ const setCategoryDefault = vi.fn(async () => undefined);
13
+ const setProviderModel = vi.fn(async () => undefined);
14
+ vi.mock('@moxxy/config', () => ({
15
+ setCategoryDefault: (...a: unknown[]) => setCategoryDefault(...a),
16
+ setProviderModel: (...a: unknown[]) => setProviderModel(...a),
17
+ }));
18
+
19
+ import { TELEGRAM_MODEL_KEY } from '../keys.js';
20
+ import { telegramModel } from './model.js';
21
+ import { runSlash } from './slash-handler.js';
22
+ import { handleCallback } from './callback-handler.js';
23
+ import { removeDir } from '@moxxy/vitest-preset/fs';
24
+
25
+ let tmp: string;
26
+ let vault: VaultStore;
27
+ let session: Session;
28
+
29
+ function providerDef(name: string, modelIds: ReadonlyArray<string>) {
30
+ const models = modelIds.map((id) => ({
31
+ id,
32
+ contextWindow: 100_000,
33
+ maxOutputTokens: 4_000,
34
+ supportsTools: true,
35
+ supportsStreaming: true,
36
+ }));
37
+ const instance = new FakeProvider({ name, models });
38
+ return defineProvider({ name, models, createClient: () => instance });
39
+ }
40
+
41
+ beforeEach(async () => {
42
+ tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-tg-model-'));
43
+ vault = new VaultStore({
44
+ filePath: path.join(tmp, 'vault.json'),
45
+ keySource: createStaticKeySource(deriveKey('test', generateSalt())),
46
+ });
47
+ session = new Session({ cwd: tmp, logger: silentLogger, permissionResolver: autoAllowResolver });
48
+ session.pluginHost.registerStatic(
49
+ definePlugin({
50
+ name: 'telegram-model-test',
51
+ providers: [providerDef('alpha', ['a-small', 'a-large']), providerDef('beta', ['b-fast'])],
52
+ }),
53
+ );
54
+ session.providers.setActive('alpha');
55
+ session.readyProviders = new Set(['alpha']);
56
+ session.credentialResolver = async () => ({});
57
+ setCategoryDefault.mockClear();
58
+ setProviderModel.mockClear();
59
+ });
60
+
61
+ afterEach(async () => {
62
+ await removeDir(tmp);
63
+ });
64
+
65
+ interface Keyboard {
66
+ inline_keyboard: Array<Array<{ text: string; callback_data: string; style?: string }>>;
67
+ }
68
+
69
+ function chat() {
70
+ const replies: Array<{ text: string; keyboard?: Keyboard }> = [];
71
+ const ctx = {
72
+ reply: async (text: string, extra?: { reply_markup?: Keyboard }) => {
73
+ replies.push({ text, ...(extra?.reply_markup ? { keyboard: extra.reply_markup } : {}) });
74
+ },
75
+ } as unknown as Context;
76
+ return { ctx, replies };
77
+ }
78
+
79
+ function slash(text: string) {
80
+ const { ctx, replies } = chat();
81
+ const model = telegramModel({ session, vault });
82
+ return runSlash(
83
+ ctx,
84
+ text,
85
+ { session, voiceReplies: false },
86
+ {
87
+ toggleYolo: async () => false,
88
+ setVoiceReplies: async () => undefined,
89
+ model,
90
+ performSessionAction: async () => undefined,
91
+ },
92
+ ).then(() => replies);
93
+ }
94
+
95
+ function tap(data: string) {
96
+ const edits: Array<{ text: string; keyboard?: Keyboard }> = [];
97
+ const toasts: string[] = [];
98
+ const target = { id: 42 };
99
+ const ctx = {
100
+ chat: target,
101
+ callbackQuery: { data, message: { chat: target } },
102
+ answerCallbackQuery: async (arg?: { text?: string }) => void toasts.push(arg?.text ?? ''),
103
+ editMessageText: async (text: string, extra?: { reply_markup?: Keyboard }) =>
104
+ void edits.push({ text, ...(extra?.reply_markup ? { keyboard: extra.reply_markup } : {}) }),
105
+ editMessageReplyMarkup: async () => true,
106
+ } as unknown as Context;
107
+ const model = telegramModel({ session, vault });
108
+ return handleCallback(
109
+ ctx,
110
+ {
111
+ bot: null,
112
+ session,
113
+ chatId: 42,
114
+ permissionResolver: { resolvePending: vi.fn() } as never,
115
+ approvalResolver: { getPending: () => undefined, resolvePending: vi.fn() } as never,
116
+ pairing: { isAuthorized: () => true },
117
+ },
118
+ { setAwaitingApprovalText: () => undefined, model },
119
+ ).then(() => ({ edits, toasts }));
120
+ }
121
+
122
+ const buttons = (keyboard?: Keyboard) => (keyboard?.inline_keyboard ?? []).flat();
123
+
124
+ describe('/model on Telegram (the bot keeps a model of its own, like the Discord bot)', () => {
125
+ it('without an argument offers the providers first, marking the one the bot runs', async () => {
126
+ await vault.set(TELEGRAM_MODEL_KEY, 'alpha::a-large');
127
+
128
+ const [picker] = await slash('/model');
129
+
130
+ expect(picker?.text).toContain('alpha::a-large');
131
+ expect(buttons(picker?.keyboard).map((b) => [b.text, b.callback_data, b.style])).toEqual([
132
+ ['Default model', 'model:default', undefined],
133
+ ['✓ alpha · 2 models', 'mprov:alpha', 'success'],
134
+ ['beta · not connected', 'mprov:beta', undefined],
135
+ ]);
136
+ });
137
+
138
+ it('marks the default model when the bot has none of its own', async () => {
139
+ const [picker] = await slash('/model');
140
+ expect(buttons(picker?.keyboard)[0]).toMatchObject({ text: '✓ Default model', style: 'success' });
141
+ });
142
+
143
+ it("tapping a provider shows its models, the bot's one marked, with a way back", async () => {
144
+ await vault.set(TELEGRAM_MODEL_KEY, 'alpha::a-large');
145
+
146
+ const { edits } = await tap('mprov:alpha');
147
+
148
+ expect(edits[0]?.text).toContain('alpha');
149
+ expect(buttons(edits[0]?.keyboard).map((b) => [b.text, b.callback_data, b.style])).toEqual([
150
+ ['a-small', 'model:alpha::a-small', undefined],
151
+ ['✓ a-large', 'model:alpha::a-large', 'success'],
152
+ ['‹ Providers', 'mprov:', undefined],
153
+ ]);
154
+ });
155
+
156
+ it('‹ Providers goes back to the providers', async () => {
157
+ const { edits } = await tap('mprov:');
158
+ expect(buttons(edits[0]?.keyboard).map((b) => b.callback_data)).toEqual([
159
+ 'model:default',
160
+ 'mprov:alpha',
161
+ 'mprov:beta',
162
+ ]);
163
+ });
164
+
165
+ it('with an argument switches this bot to the model it names', async () => {
166
+ const [reply] = await slash('/model a-large');
167
+
168
+ expect(reply?.text).toMatch(/✓ switched to alpha::a-large/);
169
+ expect(await vault.get(TELEGRAM_MODEL_KEY)).toBe('alpha::a-large');
170
+ });
171
+
172
+ it('a tapped model is saved for this bot only — the global default stays', async () => {
173
+ const { edits } = await tap('model:alpha::a-large');
174
+
175
+ expect(edits.map((e) => e.text)).toEqual(['✓ switched to alpha::a-large for this bot.']);
176
+ expect(await vault.get(TELEGRAM_MODEL_KEY)).toBe('alpha::a-large');
177
+ expect(setCategoryDefault).not.toHaveBeenCalled();
178
+ expect(setProviderModel).not.toHaveBeenCalled();
179
+ });
180
+
181
+ it('tapping "default" goes back to the default model', async () => {
182
+ await vault.set(TELEGRAM_MODEL_KEY, 'alpha::a-large');
183
+
184
+ const { edits } = await tap('model:default');
185
+
186
+ expect(edits.map((e) => e.text)).toEqual(['✓ back to the default model.']);
187
+ expect(await vault.get(TELEGRAM_MODEL_KEY)).toBeNull();
188
+ });
189
+
190
+ it('a provider that is not connected is refused and nothing is saved', async () => {
191
+ const { edits } = await tap('model:beta::b-fast');
192
+
193
+ expect(edits[0]?.text).toMatch(/isn't connected/);
194
+ expect(await vault.get(TELEGRAM_MODEL_KEY)).toBeNull();
195
+ });
196
+ });
197
+
198
+ describe('the model for the next turn', () => {
199
+ it('is the one saved for the bot — also when it was changed from the desktop panel', async () => {
200
+ await vault.set(TELEGRAM_MODEL_KEY, 'alpha::a-large');
201
+ expect(await telegramModel({ session, vault }).resolve()).toEqual({ model: 'a-large' });
202
+ });
203
+ });
@@ -0,0 +1,33 @@
1
+ import {
2
+ listModelOptions,
3
+ resolveChannelModel,
4
+ runModelCommand,
5
+ savedChannelModel,
6
+ type ModelChoice,
7
+ type ModelCommandDeps,
8
+ type ModelOption,
9
+ } from '@moxxy/channel-kit';
10
+ import { TELEGRAM_MODEL_KEY } from '../keys.js';
11
+
12
+ /**
13
+ * The Telegram bot's own model (`telegram_model` in the vault, the key the
14
+ * desktop panel writes too): `/model` and its buttons switch it, and it is read
15
+ * before every turn — never the global default the desktop/TUI run with.
16
+ */
17
+ export interface TelegramModel {
18
+ /** `/model [name|default]` — switch or reset; returns the reply. */
19
+ run(arg: string): Promise<string>;
20
+ /** What the picker shows: the saved choice and every model. */
21
+ choices(): Promise<{ readonly current: ModelChoice | null; readonly options: ReadonlyArray<ModelOption> }>;
22
+ /** The model for the next turn, with a warning when the saved one can't be used. */
23
+ resolve(): Promise<{ readonly model?: string; readonly warning?: string }>;
24
+ }
25
+
26
+ export function telegramModel(deps: Omit<ModelCommandDeps, 'vaultKey'>): TelegramModel {
27
+ const scoped: ModelCommandDeps = { ...deps, vaultKey: TELEGRAM_MODEL_KEY };
28
+ return {
29
+ run: (arg) => runModelCommand(arg, scoped),
30
+ choices: async () => ({ current: await savedChannelModel(scoped), options: listModelOptions(deps.session) }),
31
+ resolve: () => resolveChannelModel(scoped),
32
+ };
33
+ }
@@ -0,0 +1,38 @@
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 { VaultStore, createStaticKeySource, deriveKey, generateSalt } from '@moxxy/plugin-vault';
6
+ import { TELEGRAM_AUTHORIZED_CHAT_KEY } from '../keys.js';
7
+ import { PairingHandler } from './pairing-handler.js';
8
+ import { removeDir } from '@moxxy/vitest-preset/fs';
9
+
10
+ let tmp: string;
11
+ let vault: VaultStore;
12
+ beforeEach(async () => {
13
+ tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-tg-pair-'));
14
+ vault = new VaultStore({
15
+ filePath: path.join(tmp, 'vault.json'),
16
+ keySource: createStaticKeySource(deriveKey('test', generateSalt())),
17
+ });
18
+ });
19
+ afterEach(async () => {
20
+ await removeDir(tmp);
21
+ });
22
+
23
+ describe('PairingHandler.authorizedChatId (where the bot reaches its owner)', () => {
24
+ it('is the paired chat restored from the vault', async () => {
25
+ await vault.set(TELEGRAM_AUTHORIZED_CHAT_KEY, '42');
26
+ const pairing = new PairingHandler({ vault });
27
+
28
+ await pairing.loadAuthorized();
29
+
30
+ expect(pairing.authorizedChatId()).toBe(42);
31
+ });
32
+
33
+ it('is null while no chat is paired', async () => {
34
+ const pairing = new PairingHandler({ vault });
35
+ await pairing.loadAuthorized();
36
+ expect(pairing.authorizedChatId()).toBeNull();
37
+ });
38
+ });
@@ -66,6 +66,11 @@ export class PairingHandler {
66
66
  return this.state.phase;
67
67
  }
68
68
 
69
+ /** The paired chat — where the bot reaches its owner — or null while unpaired. */
70
+ authorizedChatId(): number | null {
71
+ return this.state.authorizedChatId;
72
+ }
73
+
69
74
  /**
70
75
  * Open a host-issued pairing window and return the code to embed in the
71
76
  * `t.me/<bot>?start=<code>` deep link / QR the control surface renders. The
@@ -0,0 +1,36 @@
1
+ import { GrammyError } from 'grammy';
2
+ import { markdownToTelegramHtml } from '../format.js';
3
+ import { splitForTelegram } from '../render.js';
4
+ import { stripHtml } from './html.js';
5
+
6
+ /** The slice of grammy's `Api` a text message needs (the real client satisfies it). */
7
+ export interface TelegramSender {
8
+ sendMessage(
9
+ chatId: number,
10
+ text: string,
11
+ opts?: {
12
+ parse_mode?: 'HTML' | 'MarkdownV2' | 'Markdown';
13
+ link_preview_options?: { is_disabled: boolean };
14
+ },
15
+ ): Promise<unknown>;
16
+ }
17
+
18
+ const PARSE_ERROR = /can't parse entities|Bad Request: can't parse/i;
19
+
20
+ /**
21
+ * Send Markdown with the same rich look as interactive replies (bold, code,
22
+ * callouts, spoilers), split to Telegram's size cap. A part Telegram can't
23
+ * parse (rare) goes out as plain text instead. Returns how many messages went out.
24
+ */
25
+ export async function sendMarkdown(api: TelegramSender, chatId: number, markdown: string): Promise<number> {
26
+ const parts = splitForTelegram(markdownToTelegramHtml(markdown));
27
+ for (const part of parts) {
28
+ try {
29
+ await api.sendMessage(chatId, part, { parse_mode: 'HTML', link_preview_options: { is_disabled: true } });
30
+ } catch (err) {
31
+ if (!(err instanceof GrammyError && PARSE_ERROR.test(err.description ?? ''))) throw err;
32
+ await api.sendMessage(chatId, stripHtml(part));
33
+ }
34
+ }
35
+ return parts.length;
36
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { Bot, Context } from 'grammy';
3
+ import { Session, autoAllowResolver, silentLogger } from '@moxxy/core';
4
+ import { publishBotCommands, runSlash, type SlashCallbacks } from './slash-handler.js';
5
+
6
+ const session = () => new Session({ cwd: '/tmp', logger: silentLogger, permissionResolver: autoAllowResolver });
7
+
8
+ async function send(text: string, over: Partial<SlashCallbacks> = {}) {
9
+ const replies: string[] = [];
10
+ const ctx = { reply: async (t: string) => void replies.push(t) } as unknown as Context;
11
+ await runSlash(ctx, text, { session: session(), voiceReplies: false }, {
12
+ toggleYolo: async () => true,
13
+ setVoiceReplies: async () => undefined,
14
+ model: { run: async () => '', choices: async () => ({ current: null, options: [] }) },
15
+ performSessionAction: async () => undefined,
16
+ ...over,
17
+ });
18
+ return replies;
19
+ }
20
+
21
+ describe('/auto-approve on Telegram', () => {
22
+ it.each(['/auto-approve', '/auto_approve', '/yolo'])('%s switches the shared auto-approve', async (command) => {
23
+ let toggled = 0;
24
+ const replies = await send(command, {
25
+ toggleYolo: async () => {
26
+ toggled += 1;
27
+ return true;
28
+ },
29
+ });
30
+
31
+ expect(toggled).toBe(1);
32
+ expect(replies[0]).toMatch(/auto-approve ON/);
33
+ });
34
+
35
+ it('says tool prompts resume when it is switched off', async () => {
36
+ const replies = await send('/auto-approve', { toggleYolo: async () => false });
37
+ expect(replies[0]).toMatch(/auto-approve OFF/);
38
+ });
39
+ });
40
+
41
+ describe('/call on Telegram', () => {
42
+ it('explains that Telegram bots cannot call, and offers a voice message instead', async () => {
43
+ const [reply] = await send('/call');
44
+ expect(reply).toMatch(/Telegram does not let bots/);
45
+ expect(reply).toMatch(/voice message/);
46
+ });
47
+ });
48
+
49
+ describe('the command menu Telegram shows', () => {
50
+ it('lists /auto_approve (Telegram command names take no hyphen) and /call, not the /yolo alias', async () => {
51
+ let published: Array<{ command: string }> = [];
52
+ const bot = {
53
+ api: {
54
+ setMyCommands: async (commands: Array<{ command: string }>) => {
55
+ published = commands;
56
+ return true;
57
+ },
58
+ },
59
+ } as unknown as Bot;
60
+
61
+ await publishBotCommands(bot, session());
62
+
63
+ const names = published.map((c) => c.command);
64
+ expect(names).toEqual(expect.arrayContaining(['auto_approve', 'call', 'model', 'voice']));
65
+ expect(names).not.toContain('yolo');
66
+ expect(names.every((n) => /^[a-z0-9_]{1,32}$/.test(n))).toBe(true);
67
+ });
68
+ });
@@ -2,6 +2,8 @@ import { type Bot, type Context, InlineKeyboard } from 'grammy';
2
2
  import { assertDefined, isSelectableMode } from '@moxxy/sdk';
3
3
  import type { ClientSession as Session } from '@moxxy/sdk';
4
4
  import { resolveVoiceToggle } from '@moxxy/channel-kit';
5
+ import { providerScreen } from './model-picker.js';
6
+ import type { TelegramModel } from './model.js';
5
7
 
6
8
  /**
7
9
  * Install guidance shown when enabling `/voice` with no active synthesizer —
@@ -13,18 +15,17 @@ export const VOICE_NO_SYNTH_HINT =
13
15
 
14
16
  export interface SlashState {
15
17
  readonly session: Session | null;
16
- readonly model: string | undefined;
17
- readonly activeModelOverride: string | null;
18
- readonly yolo: boolean;
19
18
  /** Whether voice replies are currently enabled (backs `/voice status`). */
20
19
  readonly voiceReplies: boolean;
21
20
  }
22
21
 
23
22
  export interface SlashCallbacks {
24
- /** Toggle yolo and return its new value (so we can echo the right message). */
25
- toggleYolo(): boolean;
23
+ /** Switch the conversation's shared auto-approve; returns its new value. */
24
+ toggleYolo(): Promise<boolean>;
26
25
  /** Persist + apply the voice-replies preference (backs `/voice on|off`). */
27
26
  setVoiceReplies(on: boolean): Promise<void>;
27
+ /** This bot's own model (`/model`). */
28
+ readonly model: Pick<TelegramModel, 'run' | 'choices'>;
28
29
  /** Apply a `session-action` result emitted from a registered command. */
29
30
  performSessionAction(
30
31
  ctx: Context,
@@ -33,6 +34,10 @@ export interface SlashCallbacks {
33
34
  ): Promise<void>;
34
35
  }
35
36
 
37
+ /** Telegram bots have no calls; a voice message is the way to talk. */
38
+ export const NO_CALLS_REPLY =
39
+ 'Telegram does not let bots take or place calls. Send me a voice message instead — I will answer with one.';
40
+
36
41
  /**
37
42
  * Slash-command dispatcher for the Telegram channel.
38
43
  *
@@ -42,7 +47,7 @@ export interface SlashCallbacks {
42
47
  * commands without needing a switch case here.
43
48
  *
44
49
  * Falls through to channel-local cases for Telegram-specific UI
45
- * (model/loop pickers as inline keyboards, /yolo toggle, /tools and
50
+ * (model/mode pickers as inline keyboards, /auto-approve, /tools and
46
51
  * /skills as text dumps).
47
52
  */
48
53
  export async function runSlash(
@@ -86,20 +91,26 @@ export async function runSlash(
86
91
  // 2) Channel-local cases.
87
92
  switch (head) {
88
93
  case '/model':
89
- await renderModelPicker(ctx, session, state, args);
94
+ if (args.trim()) await ctx.reply(await cb.model.run(args));
95
+ else await renderModelPicker(ctx, cb.model);
90
96
  return;
91
97
  case '/mode':
92
98
  await renderModePicker(ctx, session);
93
99
  return;
100
+ case '/auto-approve':
101
+ case '/auto_approve':
94
102
  case '/yolo': {
95
- const enabled = cb.toggleYolo();
103
+ const enabled = await cb.toggleYolo();
96
104
  await ctx.reply(
97
105
  enabled
98
- ? '⚠ yolo mode ON — tool calls auto-approved for the rest of this session'
99
- : 'yolo mode OFF — tool prompts will resume',
106
+ ? '⚠ auto-approve ON — tool calls run without asking for the rest of this session'
107
+ : 'auto-approve OFF — tool prompts will resume',
100
108
  );
101
109
  return;
102
110
  }
111
+ case '/call':
112
+ await ctx.reply(NO_CALLS_REPLY);
113
+ return;
103
114
  case '/voice': {
104
115
  const result = resolveVoiceToggle({
105
116
  arg: args,
@@ -139,60 +150,11 @@ export async function runSlash(
139
150
  }
140
151
  }
141
152
 
142
- /** Telegram inline keyboards get unwieldy past this many rows; cap the picker. */
143
- const MODEL_PICKER_CAP = 30;
144
-
145
- async function renderModelPicker(
146
- ctx: Context,
147
- session: Session,
148
- state: SlashState,
149
- filter = '',
150
- ): Promise<void> {
151
- const providers = session.providers.list();
152
- if (providers.length === 0) {
153
- await ctx.reply('no providers registered');
154
- return;
155
- }
156
- const keyboard = new InlineKeyboard();
157
- const providerName = session.providers.getActiveName() ?? '';
158
- const activeModel = state.activeModelOverride ?? state.model ?? '';
159
- // Same boot-time readiness set the TUI uses to flag unconfigured
160
- // providers — set by `@moxxy/cli` at setup time. Providers that
161
- // failed credential resolution get a "(not connected)" suffix
162
- // and a tap on them surfaces the right setup command instead of
163
- // a no-op switch.
164
- const ready = session.readyProviders ?? new Set<string>();
165
- // `/model <substring>` narrows the list so a model that sorts past the
166
- // keyboard cap is still reachable from chat (the cap below would otherwise
167
- // silently hide it with no fallback).
168
- const needle = filter.trim().toLowerCase();
169
- // Flatten so we can both apply the substring filter and report how many the
170
- // cap hides (vs. how many the filter excluded).
171
- const all = providers.flatMap((p) =>
172
- p.models.map((m) => ({ provider: p.name, model: m.id })),
173
- );
174
- const matched = needle
175
- ? all.filter((e) => `${e.provider}::${e.model}`.toLowerCase().includes(needle))
176
- : all;
177
- if (matched.length === 0) {
178
- await ctx.reply(`no model matches "${filter.trim()}" — try /model with no argument.`);
179
- return;
180
- }
181
- const shown = matched.slice(0, MODEL_PICKER_CAP);
182
- for (const e of shown) {
183
- const isCurrent = providerName === e.provider && activeModel === e.model;
184
- const connected = ready.has(e.provider);
185
- const label =
186
- `${isCurrent ? '• ' : ''}${e.provider}: ${e.model}` +
187
- (connected ? '' : ' (not connected)');
188
- keyboard.text(label, `model:${e.provider}::${e.model}`).row();
189
- }
190
- const hidden = matched.length - shown.length;
191
- const prompt =
192
- hidden > 0
193
- ? `Pick a model (${hidden} more not shown — narrow with /model <text>):`
194
- : 'Pick a model:';
195
- await ctx.reply(prompt, { reply_markup: keyboard });
153
+ /** `/model` without an argument: the providers first (see model-picker). */
154
+ async function renderModelPicker(ctx: Context, model: Pick<TelegramModel, 'choices'>): Promise<void> {
155
+ const { current, options } = await model.choices();
156
+ const screen = providerScreen(current, options);
157
+ await ctx.reply(screen.text, { parse_mode: 'HTML', reply_markup: screen.keyboard });
196
158
  }
197
159
 
198
160
  async function renderModePicker(ctx: Context, session: Session): Promise<void> {
@@ -218,6 +180,9 @@ async function renderModePicker(ctx: Context, session: Session): Promise<void> {
218
180
  await ctx.reply('Pick a mode:', { reply_markup: keyboard });
219
181
  }
220
182
 
183
+ /** Telegram's bot-command name constraint (no hyphens). */
184
+ const BOT_COMMAND_RE = /^[a-z0-9_]{1,32}$/;
185
+
221
186
  /**
222
187
  * Push the union of registry commands + Telegram-local commands to
223
188
  * Telegram so they appear in the chat's command menu (the "/" picker
@@ -231,9 +196,10 @@ export async function publishBotCommands(
231
196
  ): Promise<void> {
232
197
  if (!session || !bot) return;
233
198
  const LOCAL: Array<{ command: string; description: string }> = [
234
- { command: 'model', description: 'Switch provider + model (inline keyboard)' },
199
+ { command: 'model', description: 'Show or switch the model this bot uses' },
235
200
  { command: 'mode', description: 'Switch mode' },
236
- { command: 'yolo', description: 'Toggle auto-approve mode' },
201
+ { command: 'auto_approve', description: 'Toggle auto-approve: tool calls run without asking' },
202
+ { command: 'call', description: 'Talk by voice (Telegram bots cannot call — send a voice message)' },
237
203
  { command: 'voice', description: 'Toggle spoken voice replies' },
238
204
  { command: 'tools', description: 'List the tools the active session can call' },
239
205
  { command: 'skills', description: 'List the discovered skills' },
@@ -244,6 +210,9 @@ export async function publishBotCommands(
244
210
  .map((c) => ({ command: c.name, description: c.description }));
245
211
  const seen = new Set(shared.map((c) => c.command));
246
212
  const merged = [...shared, ...LOCAL.filter((c) => !seen.has(c.command))]
213
+ // Telegram rejects the whole list over one name outside [a-z0-9_]; such
214
+ // commands still work typed out.
215
+ .filter((c) => BOT_COMMAND_RE.test(c.command))
247
216
  .sort((a, b) => a.command.localeCompare(b.command))
248
217
  // Telegram caps descriptions at 256 chars and rejects empties.
249
218
  .map((c) => ({
@@ -57,9 +57,7 @@ const makeHarness = (over: Partial<TextHandlerState> = {}): Harness => {
57
57
  const setAwaitingApprovalText = vi.fn();
58
58
  const state: TextHandlerState = {
59
59
  session: makeSession(),
60
- model: undefined,
61
- activeModelOverride: null,
62
- yolo: false,
60
+ voiceReplies: false,
63
61
  busy: false,
64
62
  turnController: null,
65
63
  awaitingApprovalText: null,
@@ -78,8 +76,10 @@ const makeHarness = (over: Partial<TextHandlerState> = {}): Harness => {
78
76
  const tryHostPair = vi.fn(async () => false);
79
77
  const cb: TextHandlerCallbacks = {
80
78
  setAwaitingApprovalText,
81
- toggleYolo: () => false,
79
+ toggleYolo: async () => false,
82
80
  setYolo: () => undefined,
81
+ setVoiceReplies: async () => undefined,
82
+ model: { run: async () => '', choices: async () => ({ current: null, options: [] }) },
83
83
  runUserTurn,
84
84
  tryHostPair,
85
85
  };
@@ -216,7 +216,8 @@ describe('handleTextMessage — /exit teardown', () => {
216
216
  const { ctx, replies } = fakeCtx({ text: '/exit' });
217
217
  const h = makeHarness({ session, handle });
218
218
  await handleTextMessage(ctx, h.state, h.deps, h.cb);
219
- expect(stop).toHaveBeenCalledTimes(1);
219
+ // The reply goes out first; the channel stops right after it.
220
220
  expect(replies.some((r) => /bye/i.test(r))).toBe(true);
221
+ await vi.waitFor(() => expect(stop).toHaveBeenCalledTimes(1));
221
222
  });
222
223
  });