@moxxy/plugin-telegram 0.41.3 → 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
@@ -1 +1 @@
1
- {"version":3,"file":"pair-flow.js","sourceRoot":"","sources":["../src/pair-flow.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"pair-flow.js","sourceRoot":"","sources":["../src/pair-flow.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,21 @@
1
+ import { InputFile } from 'grammy';
2
+ import { type ToolDef } from '@moxxy/sdk';
3
+ import { type TelegramSender } from '../channel/send-rich.js';
4
+ /** The slice of grammy's `Api` this tool uses (the real client satisfies it). */
5
+ export interface TelegramSendApi extends TelegramSender {
6
+ sendDocument(chatId: number, document: InputFile): Promise<unknown>;
7
+ }
8
+ export interface TelegramSendMessageToolDeps {
9
+ readonly getVault: () => {
10
+ get(name: string): Promise<string | null>;
11
+ };
12
+ /** Build the API client for a token. Defaults to grammy's lightweight `Api`. */
13
+ readonly createApi?: (token: string) => TelegramSendApi;
14
+ }
15
+ /**
16
+ * `telegram_send_message` — push a message (and files) to the paired chat from
17
+ * ANY session (desktop, goal mode, a scheduled prompt) without the Telegram
18
+ * channel running. A one-off `Api` rather than a full `Bot`: no long-polling.
19
+ */
20
+ export declare function buildTelegramSendMessageTool(deps: TelegramSendMessageToolDeps): ToolDef;
21
+ //# sourceMappingURL=send-message.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"send-message.d.ts","sourceRoot":"","sources":["../../src/tools/send-message.ts"],"names":[],"mappings":"AAAA,OAAO,EAAO,SAAS,EAAE,MAAM,QAAQ,CAAC;AACxC,OAAO,EAAiB,KAAK,OAAO,EAAE,MAAM,YAAY,CAAC;AAGzD,OAAO,EAAgB,KAAK,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAE5E,iFAAiF;AACjF,MAAM,WAAW,eAAgB,SAAQ,cAAc;IACrD,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACrE;AAED,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,QAAQ,EAAE,MAAM;QAAE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;KAAE,CAAC;IACvE,gFAAgF;IAChF,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,eAAe,CAAC;CACzD;AASD;;;;GAIG;AACH,wBAAgB,4BAA4B,CAAC,IAAI,EAAE,2BAA2B,GAAG,OAAO,CAmEvF"}
@@ -0,0 +1,84 @@
1
+ import { Api, InputFile } from 'grammy';
2
+ import { defineTool, z } 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 } from '../channel/send-rich.js';
6
+ const TOKEN_ENV = 'MOXXY_TELEGRAM_TOKEN';
7
+ /** Cap on one push — a couple of Telegram messages once split. */
8
+ const MAX_TEXT_CHARS = 8_000;
9
+ /** The Bot API accepts uploads up to 50 MB. */
10
+ const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
11
+ const MAX_FILES = 10;
12
+ /**
13
+ * `telegram_send_message` — push a message (and files) to the paired chat from
14
+ * ANY session (desktop, goal mode, a scheduled prompt) without the Telegram
15
+ * channel running. A one-off `Api` rather than a full `Bot`: no long-polling.
16
+ */
17
+ export function buildTelegramSendMessageTool(deps) {
18
+ const createApi = deps.createApi ?? ((token) => new Api(token));
19
+ return defineTool({
20
+ name: 'telegram_send_message',
21
+ description: 'Push a message to the paired Telegram chat. Use it to proactively report progress, a finished ' +
22
+ 'task, or a blocker that needs their decision — e.g. from a long goal-mode run or a scheduled ' +
23
+ 'prompt — without the Telegram channel running. The text is rendered with the same rich ' +
24
+ 'Markdown→Telegram formatting as interactive replies (bold, code, `> [!type]` callouts, ' +
25
+ '`||spoilers||`); pass an explicit `parseMode` to send the raw text under that parse mode ' +
26
+ 'instead. Long text is split into several messages. Pass `files` (local paths) to send files ' +
27
+ 'the owner asked for as documents — up to 10 files, 50 MB in total. Requires a stored bot ' +
28
+ 'token + a paired chat (run `moxxy channels telegram pair` once).',
29
+ inputSchema: z.object({
30
+ text: z.string().trim().min(1).max(MAX_TEXT_CHARS),
31
+ files: z.array(z.string().min(1).max(4096)).max(MAX_FILES).optional(),
32
+ /** Optional override; defaults to the vault-paired chat id. */
33
+ chatId: z.number().int().optional(),
34
+ /** Force a Telegram parse mode and send `text` verbatim under it. */
35
+ parseMode: z.enum(['MarkdownV2', 'Markdown', 'HTML']).optional(),
36
+ }),
37
+ permission: { action: 'prompt' },
38
+ isolation: {
39
+ capabilities: {
40
+ // Attachments are whatever local file the owner asked for; the
41
+ // permission prompt shows the paths before anything is read or sent.
42
+ fs: { read: ['~/.moxxy/vault.*', '~/**', '$cwd/**', '/tmp/**'] },
43
+ net: { mode: 'allowlist', hosts: ['api.telegram.org'] },
44
+ env: [TOKEN_ENV],
45
+ timeMs: 120_000,
46
+ },
47
+ },
48
+ handler: async ({ text, files: filePaths, chatId, parseMode }, ctx) => {
49
+ const vault = deps.getVault();
50
+ const token = await resolveSecret(vault, { envVar: TOKEN_ENV, vaultKey: TELEGRAM_TOKEN_KEY });
51
+ if (!token) {
52
+ throw new Error(`no Telegram bot token configured (set ${TOKEN_ENV} or run \`moxxy channels telegram setup\`)`);
53
+ }
54
+ const target = chatId ?? parseChatId(await vault.get(TELEGRAM_AUTHORIZED_CHAT_KEY));
55
+ if (!target) {
56
+ throw new Error('no authorized chat — run `moxxy channels telegram pair` first or pass `chatId` explicitly');
57
+ }
58
+ const files = await readLocalFiles(filePaths ?? [], {
59
+ cwd: ctx.cwd,
60
+ maxTotalBytes: MAX_UPLOAD_BYTES,
61
+ service: 'Telegram',
62
+ limitLabel: '50 MB per upload',
63
+ });
64
+ const api = createApi(token);
65
+ let parts = 1;
66
+ // An explicit mode means the caller owns the markup: send it verbatim.
67
+ if (parseMode)
68
+ await api.sendMessage(target, text, { parse_mode: parseMode });
69
+ else
70
+ parts = await sendMarkdown(api, target, text);
71
+ // Files go under the text that introduces them, in order.
72
+ for (const file of files)
73
+ await api.sendDocument(target, new InputFile(file.data, file.name));
74
+ return {
75
+ delivered: true,
76
+ chatId: target,
77
+ parts,
78
+ length: text.length,
79
+ ...(files.length > 0 ? { files: files.map((f) => f.name) } : {}),
80
+ };
81
+ },
82
+ });
83
+ }
84
+ //# sourceMappingURL=send-message.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"send-message.js","sourceRoot":"","sources":["../../src/tools/send-message.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moxxy/plugin-telegram",
3
- "version": "0.41.3",
3
+ "version": "0.42.0",
4
4
  "description": "Telegram channel for moxxy via grammy. Treats Telegram chats as a bidirectional channel: prompts in, assistant + tool prompts out, inline-keyboard permission flow, QR deep-link chat pairing.",
5
5
  "keywords": [
6
6
  "moxxy",
@@ -68,19 +68,20 @@
68
68
  "@clack/prompts": "1.8.1",
69
69
  "grammy": "^1.46.0",
70
70
  "qrcode": "^1.5.4",
71
- "@moxxy/channel-kit": "0.41.3",
72
- "@moxxy/config": "0.41.3",
73
- "@moxxy/plugin-vault": "0.41.3",
74
- "@moxxy/core": "0.41.3",
75
- "@moxxy/sdk": "0.41.3"
71
+ "@moxxy/channel-kit": "0.42.0",
72
+ "@moxxy/plugin-vault": "0.42.0",
73
+ "@moxxy/core": "0.42.0",
74
+ "@moxxy/sdk": "0.42.0",
75
+ "@moxxy/config": "0.42.0"
76
76
  },
77
77
  "devDependencies": {
78
- "@types/node": "^22.10.0",
78
+ "@types/node": "^22.20.5",
79
79
  "@types/qrcode": "^1.5.5",
80
80
  "typescript": "^6.0.3",
81
- "vitest": "^4.1.11",
82
- "@moxxy/tsconfig": "0.0.0",
83
- "@moxxy/vitest-preset": "0.0.0"
81
+ "vitest": "^5.0.3",
82
+ "@moxxy/testing": "0.0.71",
83
+ "@moxxy/vitest-preset": "0.0.0",
84
+ "@moxxy/tsconfig": "0.0.0"
84
85
  },
85
86
  "scripts": {
86
87
  "build": "tsc -p tsconfig.json",
@@ -1,13 +1,11 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import type { Context } from 'grammy';
3
3
 
4
- // The manifest writers (setCategoryDefault/setProviderModel) can reject on
5
- // disk/permission/lock. Mock them so we can drive the failure path.
4
+ // The manifest writer (setCategoryDefault) can reject on disk/permission/lock.
5
+ // Mock it so we can drive the failure path.
6
6
  const setCategoryDefault = vi.fn(async () => {});
7
- const setProviderModel = vi.fn(async () => {});
8
7
  vi.mock('@moxxy/config', () => ({
9
8
  setCategoryDefault: (...a: unknown[]) => setCategoryDefault(...a),
10
- setProviderModel: (...a: unknown[]) => setProviderModel(...a),
11
9
  }));
12
10
 
13
11
  import { handleCallback, type CallbackState, type CallbackCallbacks } from './callback-handler.js';
@@ -56,7 +54,7 @@ const makeState = (over: Partial<CallbackState> = {}): { state: CallbackState; r
56
54
 
57
55
  const cb: CallbackCallbacks = {
58
56
  setAwaitingApprovalText: () => undefined,
59
- setActiveModelOverride: () => undefined,
57
+ model: { run: async () => '', choices: async () => ({ current: null, options: [] }) },
60
58
  };
61
59
 
62
60
  describe('handleCallback — pairing gate (A46)', () => {
@@ -99,7 +97,7 @@ describe('handleCallback — pairing gate (A46)', () => {
99
97
  });
100
98
  });
101
99
 
102
- describe('handleCallback — model/mode persistence failure (u110-5)', () => {
100
+ describe('handleCallback — mode persistence failure (u110-5)', () => {
103
101
  const fakeSession = () =>
104
102
  ({
105
103
  readyProviders: new Set(['openai']),
@@ -142,24 +140,6 @@ describe('handleCallback — model/mode persistence failure (u110-5)', () => {
142
140
  pairing: { isAuthorized: () => true },
143
141
  });
144
142
 
145
- it('does NOT claim "✓ switched" when the manifest write rejects (model)', async () => {
146
- setCategoryDefault.mockReset();
147
- setProviderModel.mockReset();
148
- setCategoryDefault.mockRejectedValueOnce(new Error('EROFS'));
149
- const { ctx, editMessageText, answerCallbackQuery } = ctxWithEdit('model:openai::gpt-x');
150
- await handleCallback(ctx, stateFor(fakeSession()), cb);
151
-
152
- expect(setCategoryDefault).toHaveBeenCalledWith('provider', 'openai');
153
- // The success edit must NOT have fired; the failure surfaces via the toast.
154
- const successEdits = editMessageText.mock.calls.filter((c) =>
155
- String(c[0]).includes('✓ switched'),
156
- );
157
- expect(successEdits).toHaveLength(0);
158
- expect(
159
- answerCallbackQuery.mock.calls.some((c) => /failed/i.test(String((c[0] as { text?: string } | undefined)?.text))),
160
- ).toBe(true);
161
- });
162
-
163
143
  it('does NOT claim "✓ mode →" when the manifest write rejects (mode)', async () => {
164
144
  setCategoryDefault.mockReset();
165
145
  setCategoryDefault.mockRejectedValueOnce(new Error('EROFS'));
@@ -176,13 +156,4 @@ describe('handleCallback — model/mode persistence failure (u110-5)', () => {
176
156
  ).toBe(true);
177
157
  });
178
158
 
179
- it('persists and confirms success when the manifest writes resolve (model)', async () => {
180
- setCategoryDefault.mockReset();
181
- setProviderModel.mockReset();
182
- const { ctx, editMessageText } = ctxWithEdit('model:openai::gpt-x');
183
- await handleCallback(ctx, stateFor(fakeSession()), cb);
184
- expect(
185
- editMessageText.mock.calls.some((c) => String(c[0]).includes('✓ switched')),
186
- ).toBe(true);
187
- });
188
159
  });
@@ -1,10 +1,12 @@
1
1
  import type { Bot, Context } from 'grammy';
2
- import { setCategoryDefault, setProviderModel } from '@moxxy/config';
2
+ import { setCategoryDefault } from '@moxxy/config';
3
3
  import { isSelectableMode } from '@moxxy/sdk';
4
4
  import type { ClientSession as Session } from '@moxxy/sdk';
5
5
  import type { PermissionDecision } from '@moxxy/sdk';
6
6
  import type { TelegramPermissionResolver } from '../permission.js';
7
7
  import type { TelegramApprovalResolver } from '../approval.js';
8
+ import type { TelegramModel } from './model.js';
9
+ import { PROVIDER_PREFIX, modelScreen, providerScreen } from './model-picker.js';
8
10
 
9
11
  export interface AwaitingApprovalText {
10
12
  approvalId: string;
@@ -24,13 +26,15 @@ export interface CallbackState {
24
26
  export interface CallbackCallbacks {
25
27
  /** Latch an in-flight approval awaiting a text follow-up. */
26
28
  setAwaitingApprovalText(state: AwaitingApprovalText | null): void;
27
- setActiveModelOverride(modelId: string): void;
29
+ /** This bot's own model (the `/model` picker). */
30
+ readonly model: Pick<TelegramModel, 'run' | 'choices'>;
28
31
  }
29
32
 
30
33
  /** Inline-keyboard callback router. Dispatches by prefix:
31
34
  * - `perm:` → permission resolver
32
35
  * - `appr:` → approval resolver
33
- * - `model:` → provider+model switch (with credential resolve)
36
+ * - `mprov:` → the model picker's screens (a provider's models, or back)
37
+ * - `model:` → this bot's model (`provider::model` or `default`)
34
38
  * - `mode:` → mode switch
35
39
  */
36
40
  export async function handleCallback(
@@ -65,8 +69,12 @@ export async function handleCallback(
65
69
  await handleAppr(ctx, data, state, cb);
66
70
  return;
67
71
  }
72
+ if (data.startsWith(PROVIDER_PREFIX)) {
73
+ await showPickerScreen(ctx, data.slice(PROVIDER_PREFIX.length), cb);
74
+ return;
75
+ }
68
76
  if (data.startsWith('model:')) {
69
- await handleModel(ctx, data, state.session, cb);
77
+ await handleModel(ctx, data.slice('model:'.length), cb);
70
78
  return;
71
79
  }
72
80
  if (data.startsWith('mode:')) {
@@ -146,71 +154,32 @@ async function handleAppr(
146
154
  await ctx.answerCallbackQuery({ text: option.label });
147
155
  }
148
156
 
149
- async function handleModel(
150
- ctx: Context,
151
- data: string,
152
- session: Session | null,
153
- cb: CallbackCallbacks,
154
- ): Promise<void> {
155
- // Format: model:<providerName>::<modelId>
156
- const payload = data.slice(6);
157
- const [providerId, modelId] = payload.split('::');
158
- if (!providerId || !modelId || !session) {
159
- await ctx.answerCallbackQuery({ text: 'invalid model selection' });
160
- return;
157
+ /** A provider button (its models) or `‹ Providers` (back), redrawn in place. */
158
+ async function showPickerScreen(ctx: Context, provider: string, cb: CallbackCallbacks): Promise<void> {
159
+ const { current, options } = await cb.model.choices();
160
+ const screen = provider ? modelScreen(provider, current, options) : providerScreen(current, options);
161
+ await ctx.answerCallbackQuery();
162
+ try {
163
+ await ctx.editMessageText(screen.text, { parse_mode: 'HTML', reply_markup: screen.keyboard });
164
+ } catch {
165
+ /* ignore — an unchanged screen can't be re-sent */
161
166
  }
162
- // Intercept switches to unconfigured providers — otherwise OAuth-
163
- // backed providers (openai-codex) would surface a credential
164
- // error on the next turn. Match the TUI's wording so the user
165
- // sees the same setup command in both channels.
166
- const ready = session.readyProviders ?? new Set<string>();
167
- if (!ready.has(providerId)) {
168
- const cmd =
169
- providerId === 'openai-codex'
170
- ? 'moxxy login openai-codex'
171
- : `moxxy init # (will prompt for ${providerId.toUpperCase()}_API_KEY)`;
172
- await ctx.answerCallbackQuery({ text: `${providerId} not connected` });
173
- if (ctx.callbackQuery?.message) {
174
- try {
175
- await ctx.editMessageText(
176
- `${providerId} isn't connected.\n\nRun \`${cmd}\` then restart moxxy.`,
177
- { parse_mode: 'Markdown' },
178
- );
179
- } catch {
180
- /* ignore */
181
- }
182
- }
167
+ }
168
+
169
+ /** A tapped `/model` button: the same switch as `/model <value>`, answered in place. */
170
+ async function handleModel(ctx: Context, value: string, cb: CallbackCallbacks): Promise<void> {
171
+ if (!value) {
172
+ await ctx.answerCallbackQuery({ text: 'invalid model selection' });
183
173
  return;
184
174
  }
185
- try {
186
- if (session.providers.getActiveName() !== providerId) {
187
- // Resolve credentials and drop the cached instance, same as
188
- // the TUI. Without this the new provider gets createClient({})
189
- // and openai-codex throws "no OAuth credentials" on next turn.
190
- const resolver = session.credentialResolver;
191
- const cfg = resolver ? await resolver(providerId) : {};
192
- const def = session.providers.list().find((p) => p.name === providerId);
193
- if (def) session.providers.replace(def);
194
- session.providers.setActive(providerId, cfg);
195
- }
196
- cb.setActiveModelOverride(modelId);
197
- // Persist to the unified manifest for the next CLI run — same config the TUI
198
- // writes. Await so a write failure (disk/permission/lock) is caught below and
199
- // reported, instead of telling the user "✓ switched" while it never landed.
200
- await setCategoryDefault('provider', providerId);
201
- await setProviderModel(providerId, modelId);
202
- await ctx.answerCallbackQuery({ text: `→ ${providerId}:${modelId}` });
203
- if (ctx.callbackQuery?.message) {
204
- try {
205
- await ctx.editMessageText(`✓ switched to ${providerId}:${modelId}`);
206
- } catch {
207
- /* ignore */
208
- }
175
+ const reply = await cb.model.run(value);
176
+ await ctx.answerCallbackQuery({ text: reply.slice(0, 190) });
177
+ if (ctx.callbackQuery?.message) {
178
+ try {
179
+ await ctx.editMessageText(reply);
180
+ } catch {
181
+ /* ignore */
209
182
  }
210
- } catch (err) {
211
- await ctx.answerCallbackQuery({
212
- text: `failed: ${err instanceof Error ? err.message : String(err)}`,
213
- });
214
183
  }
215
184
  }
216
185
 
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { TurnCoordinator } from '@moxxy/channel-kit';
3
+ import type { MoxxyEvent, TurnId } from '@moxxy/sdk';
4
+ import { createTelegramMirror, type TelegramSender } from './mirror.js';
5
+
6
+ const prompt = (turnId: string, text: string) =>
7
+ ({ type: 'user_prompt', turnId: turnId as TurnId, text }) as unknown as MoxxyEvent;
8
+ const reply = (turnId: string, content: string) =>
9
+ ({ type: 'assistant_message', turnId: turnId as TurnId, content }) as unknown as MoxxyEvent;
10
+
11
+ function api() {
12
+ const sent: Array<{ chatId: number; text: string; parseMode?: string }> = [];
13
+ const sender: TelegramSender = {
14
+ sendMessage: async (chatId, text, opts) => {
15
+ sent.push({ chatId, text, ...(opts?.parse_mode ? { parseMode: opts.parse_mode } : {}) });
16
+ return undefined;
17
+ },
18
+ };
19
+ return { sent, sender };
20
+ }
21
+
22
+ describe('a message written in the app shows in the Telegram chat', () => {
23
+ it("posts the app's prompt and its reply to the paired chat, formatted like the bot's replies", async () => {
24
+ const { sent, sender } = api();
25
+ const { mirror } = createTelegramMirror({ turns: new TurnCoordinator(), api: () => sender, pairedChat: () => 42 });
26
+
27
+ mirror.accept(prompt('t1', 'what is on my calendar?'));
28
+ mirror.accept(reply('t1', 'Two **meetings**.'));
29
+ await mirror.idle();
30
+
31
+ expect(sent).toEqual([
32
+ { chatId: 42, text: '<i>typed in moxxy:</i> what is on my calendar?', parseMode: 'HTML' },
33
+ { chatId: 42, text: 'Two <b>meetings</b>.', parseMode: 'HTML' },
34
+ ]);
35
+ });
36
+
37
+ it('goes to the chat the owner last wrote from', async () => {
38
+ const { sent, sender } = api();
39
+ const { mirror, target } = createTelegramMirror({ turns: new TurnCoordinator(), api: () => sender, pairedChat: () => 42 });
40
+ target.remember(7);
41
+
42
+ mirror.accept(reply('t1', 'hello'));
43
+ await mirror.idle();
44
+
45
+ expect(sent.map((m) => m.chatId)).toEqual([7]);
46
+ });
47
+
48
+ it('stays quiet while no chat is paired or the bot is not running', async () => {
49
+ const { sent, sender } = api();
50
+ const unpaired = createTelegramMirror({ turns: new TurnCoordinator(), api: () => sender, pairedChat: () => null });
51
+ const stopped = createTelegramMirror({ turns: new TurnCoordinator(), api: () => null, pairedChat: () => 42 });
52
+
53
+ unpaired.mirror.accept(reply('t1', 'hello'));
54
+ stopped.mirror.accept(reply('t2', 'hello'));
55
+ await Promise.all([unpaired.mirror.idle(), stopped.mirror.idle()]);
56
+
57
+ expect(sent).toEqual([]);
58
+ });
59
+ });
@@ -0,0 +1,36 @@
1
+ import { ForeignTurnMirror, MirrorTarget, type TurnCoordinator } from '@moxxy/channel-kit';
2
+ import { sendMarkdown, type TelegramSender } from './send-rich.js';
3
+
4
+ export type { TelegramSender } from './send-rich.js';
5
+
6
+ export interface TelegramMirrorOptions {
7
+ readonly turns: Pick<TurnCoordinator, 'mirrorPrompt' | 'mirrorText'>;
8
+ /** The running bot's API, or null while it is stopped. */
9
+ readonly api: () => TelegramSender | null;
10
+ /** The paired chat, or null while unpaired. */
11
+ readonly pairedChat: () => number | null;
12
+ readonly logger?: { warn(msg: string, meta?: Record<string, unknown>): void };
13
+ }
14
+
15
+ /**
16
+ * Turns another surface ran on the bot's session (the desktop's chat with this
17
+ * bot) shown in Telegram: the prompt written in the app, then its reply — into
18
+ * the chat the owner last wrote from, else the paired chat.
19
+ */
20
+ export function createTelegramMirror(opts: TelegramMirrorOptions): {
21
+ readonly mirror: ForeignTurnMirror<number>;
22
+ readonly target: MirrorTarget<number>;
23
+ } {
24
+ const target = new MirrorTarget<number>(async () => opts.pairedChat());
25
+ const mirror = new ForeignTurnMirror<number>({
26
+ turns: opts.turns,
27
+ target,
28
+ post: async (chatId, text) => {
29
+ const api = opts.api();
30
+ if (api) await sendMarkdown(api, chatId, text);
31
+ },
32
+ formatPrompt: (prompt) => `_typed in moxxy:_ ${prompt}`,
33
+ onError: (err) => opts.logger?.warn('telegram mirror failed', { err: String(err) }),
34
+ });
35
+ return { mirror, target };
36
+ }
@@ -0,0 +1,75 @@
1
+ import { InlineKeyboard } from 'grammy';
2
+ import { formatModelChoice, type ModelChoice, type ModelOption } from '@moxxy/channel-kit';
3
+ import { escapeHtml } from '../format.js';
4
+
5
+ /**
6
+ * The `/model` picker as two screens in one message: the providers, then the
7
+ * models of the one tapped (with a way back). The bot's current choice is the
8
+ * green button.
9
+ */
10
+ export interface PickerScreen {
11
+ /** HTML (`parse_mode: 'HTML'`). */
12
+ readonly text: string;
13
+ readonly keyboard: InlineKeyboard;
14
+ }
15
+
16
+ /** Callback data prefix of a provider button; empty provider = back to the list. */
17
+ export const PROVIDER_PREFIX = 'mprov:';
18
+ /** Telegram rejects a button whose callback data exceeds 64 bytes. */
19
+ const CALLBACK_DATA_MAX_BYTES = 64;
20
+ /** A provider's models shown at once; the rest are reachable with /model <text>. */
21
+ const MODELS_CAP = 30;
22
+
23
+ const fits = (data: string) => Buffer.byteLength(data) <= CALLBACK_DATA_MAX_BYTES;
24
+
25
+ function nowLine(current: ModelChoice | null): string {
26
+ return current ? `Now: <code>${escapeHtml(formatModelChoice(current))}</code>` : 'Now: the default model';
27
+ }
28
+
29
+ export function providerScreen(current: ModelChoice | null, options: ReadonlyArray<ModelOption>): PickerScreen {
30
+ const keyboard = new InlineKeyboard().text(current ? 'Default model' : '✓ Default model', 'model:default');
31
+ if (!current) keyboard.success();
32
+ keyboard.row();
33
+ const providers = [...new Set(options.map((o) => o.provider))];
34
+ providers.forEach((provider, index) => {
35
+ const models = options.filter((o) => o.provider === provider);
36
+ const connected = models.some((o) => o.connected);
37
+ const isCurrent = current?.provider === provider;
38
+ const label = `${isCurrent ? '✓ ' : ''}${provider} · ${
39
+ connected ? `${models.length} ${models.length === 1 ? 'model' : 'models'}` : 'not connected'
40
+ }`;
41
+ if (!fits(`${PROVIDER_PREFIX}${provider}`)) return;
42
+ keyboard.text(label, `${PROVIDER_PREFIX}${provider}`);
43
+ if (isCurrent) keyboard.success();
44
+ if (index % 2 === 1) keyboard.row();
45
+ });
46
+ return {
47
+ text: `<b>Model for this bot</b>\n${nowLine(current)}\n\nPick a provider:`,
48
+ keyboard,
49
+ };
50
+ }
51
+
52
+ export function modelScreen(
53
+ provider: string,
54
+ current: ModelChoice | null,
55
+ options: ReadonlyArray<ModelOption>,
56
+ ): PickerScreen {
57
+ const models = options.filter((o) => o.provider === provider && fits(`model:${formatModelChoice(o)}`));
58
+ const shown = models.slice(0, MODELS_CAP);
59
+ const keyboard = new InlineKeyboard();
60
+ for (const o of shown) {
61
+ const isCurrent = current != null && formatModelChoice(current) === formatModelChoice(o);
62
+ keyboard.text(`${isCurrent ? '✓ ' : ''}${o.model}`, `model:${formatModelChoice(o)}`);
63
+ if (isCurrent) keyboard.success();
64
+ keyboard.row();
65
+ }
66
+ keyboard.text('‹ Providers', PROVIDER_PREFIX);
67
+ const connected = models.some((o) => o.connected);
68
+ const more = models.length - shown.length;
69
+ return {
70
+ text:
71
+ `<b>${escapeHtml(provider)}</b>${connected ? '' : ' · not connected'}\n${nowLine(current)}\n\nPick a model:` +
72
+ (more > 0 ? `\n<i>${more} more — use /model &lt;text&gt;</i>` : ''),
73
+ keyboard,
74
+ };
75
+ }