@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
@@ -4,15 +4,14 @@ import type { TelegramApprovalResolver } from '../approval.js';
4
4
  import type { TelegramPermissionResolver } from '../permission.js';
5
5
  import type { ChannelHandle } from '@moxxy/sdk';
6
6
  import type { FramePump } from './frame-pump.js';
7
+ import { applySessionAction } from '@moxxy/channel-kit';
7
8
  import { runSlash } from './slash-handler.js';
9
+ import type { TelegramModel } from './model.js';
8
10
  import type { AwaitingApprovalText } from './callback-handler.js';
9
11
  import type { PairingHandler } from './pairing-handler.js';
10
12
 
11
13
  export interface TextHandlerState {
12
14
  readonly session: Session | null;
13
- readonly model: string | undefined;
14
- readonly activeModelOverride: string | null;
15
- readonly yolo: boolean;
16
15
  readonly voiceReplies: boolean;
17
16
  readonly busy: boolean;
18
17
  readonly turnController: AbortController | null;
@@ -29,9 +28,13 @@ export interface TextHandlerDeps {
29
28
 
30
29
  export interface TextHandlerCallbacks {
31
30
  readonly setAwaitingApprovalText: (state: AwaitingApprovalText | null) => void;
32
- readonly toggleYolo: () => boolean;
31
+ /** `/auto-approve` — switch the conversation's shared auto-approve. */
32
+ readonly toggleYolo: () => Promise<boolean>;
33
+ /** Drop the bot-local auto-approve flag (a `/new` conversation starts with it off). */
33
34
  readonly setYolo: (value: boolean) => void;
34
35
  readonly setVoiceReplies: (on: boolean) => Promise<void>;
36
+ /** This bot's own model (`/model`). */
37
+ readonly model: Pick<TelegramModel, 'run' | 'choices'>;
35
38
  readonly runUserTurn: (ctx: Context, chatId: number, text: string) => Promise<void>;
36
39
  /** Host-issued pairing fallback: try to pair an unauthorized chat whose message
37
40
  * is the 6-digit code. Returns true when handled (so we skip the generic
@@ -95,16 +98,11 @@ export async function handleTextMessage(
95
98
  await runSlash(
96
99
  ctx,
97
100
  text,
98
- {
99
- session: state.session,
100
- model: state.model,
101
- activeModelOverride: state.activeModelOverride,
102
- yolo: state.yolo,
103
- voiceReplies: state.voiceReplies,
104
- },
101
+ { session: state.session, voiceReplies: state.voiceReplies },
105
102
  {
106
103
  toggleYolo: cb.toggleYolo,
107
104
  setVoiceReplies: cb.setVoiceReplies,
105
+ model: cb.model,
108
106
  performSessionAction: (c, action, notice) =>
109
107
  performSessionAction(c, action, notice, state, deps, cb),
110
108
  },
@@ -121,10 +119,8 @@ export async function handleTextMessage(
121
119
  }
122
120
 
123
121
  /**
124
- * Channel-side handler for `session-action` outputs from registered
125
- * commands. The TUI does the same thing; both channels translate the
126
- * action into their own UI semantics (Telegram = reply text, Ink =
127
- * setState + exit).
122
+ * Channel-side handler for `session-action` outputs from registered commands
123
+ * (`applySessionAction` in @moxxy/channel-kit, shared with the other bots).
128
124
  */
129
125
  async function performSessionAction(
130
126
  ctx: Context,
@@ -134,41 +130,21 @@ async function performSessionAction(
134
130
  deps: TextHandlerDeps,
135
131
  cb: TextHandlerCallbacks,
136
132
  ): Promise<void> {
137
- if (!state.session) return;
138
- if (action === 'exit') {
139
- await ctx.reply(notice ?? 'closing Telegram channel');
140
- if (state.handle) await state.handle.stop('user /exit');
141
- return;
142
- }
143
- if (action === 'clear') {
144
- deps.framePump.resetRenderer();
145
- if (notice) await ctx.reply(`✓ ${notice}`);
146
- return;
147
- }
148
- if (action === 'new') {
149
- if (state.turnController && !state.turnController.signal.aborted) {
150
- state.turnController.abort('user reset');
151
- }
152
- deps.framePump.resetRenderer();
153
- cb.setYolo(false);
154
- cb.setAwaitingApprovalText(null);
155
- deps.approvalResolver.abortAll('session reset');
156
- deps.permissionResolver.abortAll('session reset');
157
- // Wipe the history at its source. On a RemoteSession `reset()` asks the
158
- // runner to clear its authoritative log (and persisted JSONL) and
159
- // re-sync every attached mirror; on a local Session it clears the
160
- // EventLog + truncates the sidecar. A mirror-only `log.clear()` would
161
- // be cosmetic AND desync this client, so only claim success when the
162
- // reset actually happened.
163
- try {
164
- if (typeof state.session.reset === 'function') await state.session.reset();
165
- else state.session.log.clear();
166
- } catch (err) {
167
- await ctx.reply(
168
- `⚠ /new failed: ${err instanceof Error ? err.message : String(err)} — history NOT cleared`,
169
- );
170
- return;
171
- }
172
- await ctx.reply(`✓ ${notice ?? 'new session — conversation history cleared'}`);
173
- }
133
+ const reply = await applySessionAction(action, notice, {
134
+ session: state.session,
135
+ turnController: state.turnController,
136
+ handle: state.handle,
137
+ channelName: 'Telegram',
138
+ abortPending: (reason) => {
139
+ deps.approvalResolver.abortAll(reason);
140
+ deps.permissionResolver.abortAll(reason);
141
+ },
142
+ onReset: (kind) => {
143
+ deps.framePump.resetRenderer();
144
+ if (kind !== 'new') return;
145
+ cb.setYolo(false);
146
+ cb.setAwaitingApprovalText(null);
147
+ },
148
+ });
149
+ await ctx.reply(reply);
174
150
  }
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { asTurnId, type MoxxyEvent, type RunTurnOptions } from '@moxxy/sdk';
3
+ import type { ClientSession as Session } from '@moxxy/sdk';
4
+ import type { Context } from 'grammy';
5
+ import { TELEGRAM_TURN_CONTEXT, TELEGRAM_VOICE_CONTEXT, runUserTurn } from './turn-runner.js';
6
+
7
+ function recordingSession() {
8
+ const runs: RunTurnOptions[] = [];
9
+ const session = {
10
+ log: { subscribe: () => () => undefined },
11
+ runTurn: (_prompt: string, opts: RunTurnOptions) => {
12
+ runs.push(opts);
13
+ return (async function* () {
14
+ yield undefined as unknown as MoxxyEvent;
15
+ })();
16
+ },
17
+ } as unknown as Session;
18
+ return { session, runs };
19
+ }
20
+
21
+ const typing = { start: () => undefined, stop: () => undefined } as never;
22
+
23
+ function pumpWith(body: string) {
24
+ return {
25
+ beginTurn: () => undefined,
26
+ endTurn: () => undefined,
27
+ scheduleEdit: () => undefined,
28
+ flush: async () => undefined,
29
+ renderState: { accept: () => ({ hasUpdate: false }), snapshot: () => ({ body }) },
30
+ } as never;
31
+ }
32
+
33
+ async function run(opts: { spoken?: boolean } = {}, spoken: string[] = []) {
34
+ const { session, runs } = recordingSession();
35
+ const deps = {
36
+ session,
37
+ bot: null,
38
+ framePump: pumpWith('Cześć!'),
39
+ typing,
40
+ speakReply: async (text: string) => {
41
+ spoken.push(text);
42
+ },
43
+ };
44
+ await runUserTurn({ reply: async () => undefined } as unknown as Context, deps, {
45
+ chatId: 42,
46
+ text: 'hi',
47
+ model: 'a-large',
48
+ controller: new AbortController(),
49
+ turnId: asTurnId('t1'),
50
+ ...opts,
51
+ });
52
+ return runs[0];
53
+ }
54
+
55
+ describe('a Telegram turn tells the model where it replies', () => {
56
+ it('passes the Telegram context with the model the bot runs', async () => {
57
+ const opts = await run();
58
+
59
+ expect(opts?.systemPrompt).toBe(TELEGRAM_TURN_CONTEXT);
60
+ expect(opts?.model).toBe('a-large');
61
+ expect(TELEGRAM_TURN_CONTEXT).toContain('`telegram_send_message` with `files:');
62
+ });
63
+
64
+ it('asks for a short spoken answer when the prompt was a voice message', async () => {
65
+ const opts = await run({ spoken: true });
66
+
67
+ expect(opts?.systemPrompt).toBe(`${TELEGRAM_TURN_CONTEXT}\n\n${TELEGRAM_VOICE_CONTEXT}`);
68
+ expect(TELEGRAM_VOICE_CONTEXT).toMatch(/voice message/);
69
+ });
70
+ });
71
+
72
+ describe('/voice decides whether a Telegram reply is spoken', () => {
73
+ it('sends the reply as a voice note while voice replies are on', async () => {
74
+ const spoken: string[] = [];
75
+ await run({ spoken: true }, spoken);
76
+ expect(spoken).toEqual(['Cześć!']);
77
+ });
78
+
79
+ it('answers in text only while voice replies are off, even to a voice message', async () => {
80
+ const spoken: string[] = [];
81
+ await run({}, spoken);
82
+ expect(spoken).toEqual([]);
83
+ });
84
+ });
@@ -1,7 +1,7 @@
1
1
  import type { Bot, Context } from 'grammy';
2
2
  import type { newTurnId } from '@moxxy/core';
3
3
  import type { ClientSession as Session } from '@moxxy/sdk';
4
- import { driveTurn, subscribeTurn } from '@moxxy/channel-kit';
4
+ import { channelTurnContext, driveTurn, subscribeTurn } from '@moxxy/channel-kit';
5
5
  import type { FramePump } from './frame-pump.js';
6
6
  import type { TypingIndicator } from './typing-indicator.js';
7
7
 
@@ -16,16 +16,32 @@ export interface TurnRunnerDeps {
16
16
  readonly typing: TypingIndicator;
17
17
  readonly logger?: TurnRunnerLogger;
18
18
  /**
19
- * Called once with the FINAL assistant text after it has been flushed to the
20
- * chat (so the text reply always lands first). Backs the optional voice
21
- * reply. Best-effort — its failure is logged and never breaks the text turn.
19
+ * Sends the FINAL assistant text as a voice note after it has been flushed to
20
+ * the chat (so the text reply always lands first), on a `spoken` turn only.
21
+ * Best-effort — its failure is logged and never breaks the text turn.
22
22
  */
23
- readonly onFinalReply?: (text: string) => Promise<void>;
23
+ readonly speakReply?: (text: string) => Promise<void>;
24
24
  }
25
25
 
26
+ /** What the model must know about replying on Telegram (see channelTurnContext). */
27
+ export const TELEGRAM_TURN_CONTEXT = channelTurnContext({
28
+ service: 'Telegram',
29
+ sendTool: 'telegram_send_message',
30
+ delivery: 'their Telegram chat with the bot',
31
+ uploadLimit: '50 MB per file',
32
+ });
33
+
34
+ /** Added while voice replies are on: the reply goes back as a voice message too. */
35
+ export const TELEGRAM_VOICE_CONTEXT =
36
+ 'Your reply will also be sent back as a voice message, read ' +
37
+ 'aloud: answer briefly, in plain spoken sentences, without markdown, code blocks, tables or links. ' +
38
+ 'Speak in the language of the request.';
39
+
26
40
  export interface TurnRunnerOptions {
27
41
  readonly chatId: number;
28
42
  readonly text: string;
43
+ /** Voice replies are on (`/voice`): the reply is also spoken back. */
44
+ readonly spoken?: boolean;
29
45
  readonly model: string | undefined;
30
46
  readonly controller: AbortController;
31
47
  /** turnId for this turn. The channel mints it so it can also record it as an
@@ -46,7 +62,7 @@ export async function runUserTurn(
46
62
  deps: TurnRunnerDeps,
47
63
  opts: TurnRunnerOptions,
48
64
  ): Promise<void> {
49
- const { session, bot, framePump, typing, logger, onFinalReply } = deps;
65
+ const { session, bot, framePump, typing, logger, speakReply } = deps;
50
66
  const { chatId, text, model, controller, turnId } = opts;
51
67
 
52
68
  framePump.beginTurn(chatId);
@@ -68,16 +84,22 @@ export async function runUserTurn(
68
84
  });
69
85
 
70
86
  try {
71
- await driveTurn(session, { turnId, prompt: text, model, signal: controller.signal });
87
+ await driveTurn(session, {
88
+ turnId,
89
+ prompt: text,
90
+ model,
91
+ systemPrompt: opts.spoken ? `${TELEGRAM_TURN_CONTEXT}\n\n${TELEGRAM_VOICE_CONTEXT}` : TELEGRAM_TURN_CONTEXT,
92
+ signal: controller.signal,
93
+ });
72
94
  await framePump.flush(true);
73
95
  // The text reply is now out. Speak the final assistant body if a voice
74
96
  // reply is wired — isolated so a synth/transcode/transport failure can
75
97
  // never break (or re-report) the already-delivered text turn.
76
- if (onFinalReply) {
98
+ if (opts.spoken && speakReply) {
77
99
  const finalText = framePump.renderState.snapshot().body;
78
100
  if (finalText.trim()) {
79
101
  try {
80
- await onFinalReply(finalText);
102
+ await speakReply(finalText);
81
103
  } catch (err) {
82
104
  logger?.warn('telegram voice reply hook failed', {
83
105
  err: err instanceof Error ? err.message : String(err),
@@ -194,5 +194,7 @@ export async function handleVoiceMessage(
194
194
  // reply.
195
195
  await ctx.reply(`_heard:_ ${transcript}`, { parse_mode: 'Markdown' });
196
196
 
197
+ // The transcript is a prompt like any other: `/voice` decides whether the
198
+ // reply is spoken back.
197
199
  await cb.runUserTurn(ctx, chatId, transcript);
198
200
  }
@@ -8,6 +8,7 @@ import type { ClientSession as Session } from '@moxxy/sdk';
8
8
  import { loadVoiceReplies, saveVoiceReplies, TELEGRAM_VOICE_REPLIES_KEY } from '../keys.js';
9
9
  import { runSlash } from './slash-handler.js';
10
10
  import { runUserTurn } from './turn-runner.js';
11
+ import { removeDir } from '@moxxy/vitest-preset/fs';
11
12
 
12
13
  describe('telegram voice-replies vault flag round-trip', () => {
13
14
  let tmp: string;
@@ -21,7 +22,7 @@ describe('telegram voice-replies vault flag round-trip', () => {
21
22
  });
22
23
  });
23
24
  afterEach(async () => {
24
- await fs.rm(tmp, { recursive: true, force: true });
25
+ await removeDir(tmp);
25
26
  });
26
27
 
27
28
  it('defaults to off, persists on, then off', async () => {
@@ -52,7 +53,8 @@ function slashCtx(): { ctx: any; replies: string[] } {
52
53
 
53
54
  describe('/voice slash command', () => {
54
55
  const cbBase = {
55
- toggleYolo: () => false,
56
+ toggleYolo: async () => false,
57
+ model: { run: async () => '', choices: async () => ({ current: null, options: [] }) },
56
58
  performSessionAction: async () => undefined,
57
59
  };
58
60
 
@@ -62,7 +64,7 @@ describe('/voice slash command', () => {
62
64
  await runSlash(
63
65
  ctx,
64
66
  '/voice on',
65
- { session: slashSession({ synth: { name: 'fake' } }), model: undefined, activeModelOverride: null, yolo: false, voiceReplies: false },
67
+ { session: slashSession({ synth: { name: 'fake' } }), voiceReplies: false },
66
68
  { ...cbBase, setVoiceReplies: async (on: boolean) => void persisted.push(on) },
67
69
  );
68
70
  expect(persisted).toEqual([true]);
@@ -76,7 +78,7 @@ describe('/voice slash command', () => {
76
78
  await runSlash(
77
79
  ctx,
78
80
  '/voice on',
79
- { session: slashSession(), model: undefined, activeModelOverride: null, yolo: false, voiceReplies: false },
81
+ { session: slashSession(), voiceReplies: false },
80
82
  { ...cbBase, setVoiceReplies: async (on: boolean) => void persisted.push(on) },
81
83
  );
82
84
  expect(persisted).toEqual([true]);
@@ -91,7 +93,7 @@ describe('/voice slash command', () => {
91
93
  await runSlash(
92
94
  off.ctx,
93
95
  '/voice',
94
- { session: slashSession(), model: undefined, activeModelOverride: null, yolo: false, voiceReplies: true },
96
+ { session: slashSession(), voiceReplies: true },
95
97
  { ...cbBase, setVoiceReplies },
96
98
  );
97
99
  expect(persisted).toEqual([false]);
@@ -101,7 +103,7 @@ describe('/voice slash command', () => {
101
103
  await runSlash(
102
104
  status.ctx,
103
105
  '/voice status',
104
- { session: slashSession(), model: undefined, activeModelOverride: null, yolo: false, voiceReplies: true },
106
+ { session: slashSession(), voiceReplies: true },
105
107
  { ...cbBase, setVoiceReplies },
106
108
  );
107
109
  // status did NOT persist anything new.
@@ -161,8 +163,8 @@ function fakeFramePump(): { framePump: any; flushedFinal: boolean[] } {
161
163
 
162
164
  const typingNoop = { start: () => {}, stop: () => {} } as any;
163
165
 
164
- describe('turn-runner onFinalReply seam (final assistant text)', () => {
165
- it('calls onFinalReply with the final assistant body AFTER flushing the text', async () => {
166
+ describe('turn-runner speakReply seam (final assistant text, voice replies on)', () => {
167
+ it('calls speakReply with the final assistant body AFTER flushing the text', async () => {
166
168
  const { framePump, flushedFinal } = fakeFramePump();
167
169
  const seen: string[] = [];
168
170
  const session = turnSession((emit) => {
@@ -176,9 +178,9 @@ describe('turn-runner onFinalReply seam (final assistant text)', () => {
176
178
  bot: null,
177
179
  framePump,
178
180
  typing: typingNoop,
179
- onFinalReply: async (t) => void seen.push(t),
181
+ speakReply: async (t) => void seen.push(t),
180
182
  },
181
- { chatId: 1, text: 'hi', model: undefined, controller: new AbortController(), turnId: asTurnId('t1') },
183
+ { chatId: 1, text: 'hi', model: undefined, controller: new AbortController(), turnId: asTurnId('t1'), spoken: true },
182
184
  );
183
185
  expect(flushedFinal).toContain(true);
184
186
  expect(seen).toEqual(['Hello there.']);
@@ -192,8 +194,8 @@ describe('turn-runner onFinalReply seam (final assistant text)', () => {
192
194
  });
193
195
  await runUserTurn(
194
196
  { reply: async () => {} } as any,
195
- { session, bot: null, framePump, typing: typingNoop, onFinalReply: async (t) => void seen.push(t) },
196
- { chatId: 1, text: 'hi', model: undefined, controller: new AbortController(), turnId: asTurnId('t2') },
197
+ { session, bot: null, framePump, typing: typingNoop, speakReply: async (t) => void seen.push(t) },
198
+ { chatId: 1, text: 'hi', model: undefined, controller: new AbortController(), turnId: asTurnId('t2'), spoken: true },
197
199
  );
198
200
  expect(seen).toEqual([]);
199
201
  });
@@ -210,11 +212,11 @@ describe('turn-runner onFinalReply seam (final assistant text)', () => {
210
212
  bot: null,
211
213
  framePump,
212
214
  typing: typingNoop,
213
- onFinalReply: async () => {
215
+ speakReply: async () => {
214
216
  throw new Error('tts exploded');
215
217
  },
216
218
  },
217
- { chatId: 1, text: 'hi', model: undefined, controller: new AbortController(), turnId: asTurnId('t3') },
219
+ { chatId: 1, text: 'hi', model: undefined, controller: new AbortController(), turnId: asTurnId('t3'), spoken: true },
218
220
  ),
219
221
  ).resolves.toBeUndefined();
220
222
  // No "Turn failed" reply — the hook failure was swallowed.
package/src/channel.ts CHANGED
@@ -1,14 +1,20 @@
1
1
  import { Bot, GrammyError, HttpError, InputFile } from 'grammy';
2
2
  import type { Context } from 'grammy';
3
3
  import { newTurnId } from '@moxxy/core';
4
- import { TurnCoordinator, deliverVoiceReply, resolveSecret } from '@moxxy/channel-kit';
4
+ import {
5
+ AutoApproveSwitch,
6
+ TurnCoordinator,
7
+ deliverVoiceReply,
8
+ resolveSecret,
9
+ type ForeignTurnMirror,
10
+ type MirrorTarget,
11
+ } from '@moxxy/channel-kit';
5
12
  import type { ClientSession as Session } from '@moxxy/sdk';
6
13
  import type {
7
14
  ApprovalRequest,
8
15
  Channel,
9
16
  ChannelHandle,
10
17
  ChannelStartOptsBase,
11
- MoxxyEvent,
12
18
  PendingToolCall,
13
19
  PermissionContext,
14
20
  } from '@moxxy/sdk';
@@ -29,6 +35,8 @@ import { runUserTurn } from './channel/turn-runner.js';
29
35
  import { handleTextMessage } from './channel/text-handler.js';
30
36
  import { handleVoiceMessage } from './channel/voice-handler.js';
31
37
  import { loadVoiceReplies, saveVoiceReplies } from './keys.js';
38
+ import { createTelegramMirror } from './channel/mirror.js';
39
+ import { telegramModel, type TelegramModel } from './channel/model.js';
32
40
 
33
41
  const TOKEN_KEY = 'telegram_bot_token';
34
42
 
@@ -91,14 +99,15 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
91
99
  // re-publish the channel's status file. See `onConnectChange` on the handle.
92
100
  private readonly connectListeners = new Set<() => void>();
93
101
  private currentChatId: number | null = null;
94
- // Last chat we ran a turn for — the target for mirroring turns this channel
95
- // did NOT initiate (e.g. a web-surface action on a shared session).
96
- private lastChatId: number | null = null;
102
+ // Turns another surface ran on this bot's session (the desktop's chat with
103
+ // the bot) shown here: into the chat we last served, else the paired chat.
104
+ private readonly mirror: ForeignTurnMirror<number>;
105
+ private readonly mirrorTarget: MirrorTarget<number>;
97
106
  private logUnsub: (() => void) | null = null;
98
107
  private session: Session | null = null;
99
108
  private model: string | undefined;
100
- private activeModelOverride: string | null = null;
101
- private yolo = false;
109
+ /** `/auto-approve` — the conversation's shared switch (see AutoApproveSwitch). */
110
+ private readonly autoApprove = new AutoApproveSwitch(() => this.session);
102
111
  // When true, the final assistant reply of each turn is also synthesized (via
103
112
  // the session's active Synthesizer) and sent as a voice note. Persisted per
104
113
  // paired chat in the vault (`telegram_voice_replies`), toggled with `/voice`.
@@ -137,6 +146,14 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
137
146
  // A completed pairing flips this channel's connect-state to "connected";
138
147
  // notify the host so it can swap the QR for a "✓ Connected" affordance.
139
148
  this.pairing.onPaired(() => this.notifyConnectChange());
149
+ const { mirror, target } = createTelegramMirror({
150
+ turns: this.turns,
151
+ api: () => this.bot?.api ?? null,
152
+ pairedChat: () => this.pairing.authorizedChatId(),
153
+ ...(opts.logger ? { logger: opts.logger } : {}),
154
+ });
155
+ this.mirror = mirror;
156
+ this.mirrorTarget = target;
140
157
  }
141
158
 
142
159
  /** This channel's connect value (see {@link Channel.requestUrl}): the resolved
@@ -219,11 +236,10 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
219
236
  // headless code paths after channel close don't see a stale handler.
220
237
  this.session.setApprovalResolver(this.approvalResolver);
221
238
 
222
- // Mirror-to-both: when the session runs a turn this channel did NOT
223
- // initiate (e.g. a user submitted a form on the co-attached web surface),
224
- // post the assistant's prose into the last chat we served. Our OWN turns
225
- // are rendered by the FramePump (gated by `busy`), so skip those.
226
- this.logUnsub = this.session.log.subscribe((event) => this.mirrorForeignTurn(event));
239
+ // Mirror-to-both: a turn this channel did NOT initiate (a message written
240
+ // in the desktop's chat with this bot) shows here — its prompt, then its
241
+ // reply. Our OWN turns are rendered by the FramePump, so the mirror skips them.
242
+ this.logUnsub = this.session.log.subscribe((event) => this.mirror.accept(event));
227
243
 
228
244
  this.bot.command('start', (ctx) => this.pairing.handleStartCommand(ctx));
229
245
  this.bot.on('callback_query:data', (ctx) => this.dispatchCallback(ctx));
@@ -367,9 +383,6 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
367
383
  ctx,
368
384
  {
369
385
  session: this.session,
370
- model: this.model,
371
- activeModelOverride: this.activeModelOverride,
372
- yolo: this.yolo,
373
386
  voiceReplies: this.voiceReplies,
374
387
  busy: this.turns.busy,
375
388
  turnController: this.turns.controller,
@@ -386,14 +399,10 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
386
399
  setAwaitingApprovalText: (state) => {
387
400
  this.awaitingApprovalText = state;
388
401
  },
389
- toggleYolo: () => {
390
- this.yolo = !this.yolo;
391
- return this.yolo;
392
- },
393
- setYolo: (value) => {
394
- this.yolo = value;
395
- },
402
+ toggleYolo: () => this.autoApprove.toggle(),
403
+ setYolo: () => this.autoApprove.forgetLocal(),
396
404
  setVoiceReplies: (on) => this.setVoiceReplies(on),
405
+ model: this.pickerModel(),
397
406
  runUserTurn: (c, chatId, text) => this.runUserTurn(c, chatId, text),
398
407
  tryHostPair: (chatId, text) => this.tryHostPair(ctx, chatId, text),
399
408
  },
@@ -417,7 +426,7 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
417
426
  * unavailable), and deliver via grammy. The text reply already went out.
418
427
  */
419
428
  private async sendVoiceReply(chatId: number, text: string): Promise<void> {
420
- if (!this.voiceReplies || !this.bot || !this.session) return;
429
+ if (!this.bot || !this.session) return;
421
430
  const bot = this.bot;
422
431
  const outcome = await deliverVoiceReply(this.session, text, {
423
432
  send: async (audio, meta) => {
@@ -464,25 +473,41 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
464
473
  });
465
474
  }
466
475
 
467
- private async runUserTurn(ctx: Context, chatId: number, text: string): Promise<void> {
468
- if (!this.session) throw new Error('TelegramChannel.start() must be called first');
476
+ /** `/model` and its picker, answering "not ready" before start(). */
477
+ private pickerModel(): Pick<TelegramModel, 'run' | 'choices'> {
478
+ return {
479
+ run: (arg) => this.botModel()?.run(arg) ?? Promise.resolve('Session is not ready yet.'),
480
+ choices: () => this.botModel()?.choices() ?? Promise.resolve({ current: null, options: [] }),
481
+ };
482
+ }
483
+
484
+ /** This bot's own model (see TelegramModel); null before start(). */
485
+ private botModel(): TelegramModel | null {
486
+ return this.session ? telegramModel({ session: this.session, vault: this.opts.vault }) : null;
487
+ }
488
+
489
+ private async runUserTurn(
490
+ ctx: Context,
491
+ chatId: number,
492
+ text: string,
493
+ ): Promise<void> {
494
+ const model = this.botModel();
495
+ if (!this.session || !model) throw new Error('TelegramChannel.start() must be called first');
469
496
  // Atomic single-flight guard: `begin` claims the slot synchronously BEFORE
470
497
  // any await so a second turn dispatched concurrently (the poll loop is no
471
498
  // longer parked on us) can't slip past the busy check in the text/voice
472
- // handlers. If we are already busy, refuse rather than corrupt the
473
- // single-instance per-turn state (framePump / currentChatId / controller).
474
- // The turnId is minted here so the coordinator records it as an own-turn
475
- // id — that's what mirrorForeignTurn filters on.
499
+ // handlers. The turnId is minted here so the coordinator records it as an
500
+ // own-turn id — that's what the mirror filters on.
476
501
  const lease = this.turns.begin(newTurnId());
477
502
  if (!lease) {
478
503
  await ctx.reply('I am still working on the previous prompt. Send /cancel to abort it.');
479
504
  return;
480
505
  }
481
506
  this.currentChatId = chatId;
482
- this.lastChatId = chatId;
483
- const effectiveModel = this.activeModelOverride ?? this.model;
484
-
507
+ this.mirrorTarget.remember(chatId);
485
508
  try {
509
+ const channelModel = await model.resolve();
510
+ if (channelModel.warning) await ctx.reply(channelModel.warning);
486
511
  await runUserTurn(
487
512
  ctx,
488
513
  {
@@ -491,9 +516,16 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
491
516
  framePump: this.framePump,
492
517
  typing: this.typing,
493
518
  ...(this.opts.logger ? { logger: this.opts.logger } : {}),
494
- onFinalReply: (finalText) => this.sendVoiceReply(chatId, finalText),
519
+ speakReply: (finalText) => this.sendVoiceReply(chatId, finalText),
520
+ },
521
+ {
522
+ chatId,
523
+ text,
524
+ ...(this.voiceReplies ? { spoken: true } : {}),
525
+ model: channelModel.model ?? this.model,
526
+ controller: lease.controller,
527
+ turnId: lease.turnId,
495
528
  },
496
- { chatId, text, model: effectiveModel, controller: lease.controller, turnId: lease.turnId },
497
529
  );
498
530
  } finally {
499
531
  lease.end();
@@ -501,32 +533,13 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
501
533
  }
502
534
  }
503
535
 
504
- /**
505
- * Post the assistant's prose for a turn this channel did not initiate. Gated
506
- * by `!busy` (our own turns are rendered by the FramePump from the runUserTurn
507
- * iterator) and by having served a chat at least once. Sent as plain text to
508
- * avoid parse-mode pitfalls; the view itself lives on the web surface.
509
- */
510
- private mirrorForeignTurn(event: MoxxyEvent): void {
511
- // The coordinator skips turns THIS channel initiated, by turnId — robust to
512
- // events that arrive after `busy` flips false (async ordering /
513
- // RemoteSession replay), which the `busy` flag alone could mis-mirror as
514
- // foreign (invariant #8) — and yields the trimmed assistant prose.
515
- const text = this.turns.mirrorText(event);
516
- if (text == null) return;
517
- if (!this.bot || this.lastChatId == null) return;
518
- void this.bot.api.sendMessage(this.lastChatId, text).catch((err) => {
519
- this.opts.logger?.warn('telegram mirror failed', { err: String(err) });
520
- });
521
- }
522
-
523
536
  private askForPermission(call: PendingToolCall, ctx: PermissionContext): Promise<void> {
524
537
  return askForPermission(call, ctx, {
525
538
  bot: this.bot,
526
539
  chatId: this.currentChatId,
527
540
  session: this.session,
528
541
  resolver: this.permissionResolver,
529
- yolo: this.yolo,
542
+ yolo: this.autoApprove.enabled,
530
543
  ...(this.opts.logger ? { logger: this.opts.logger } : {}),
531
544
  });
532
545
  }
@@ -555,9 +568,7 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
555
568
  setAwaitingApprovalText: (state) => {
556
569
  this.awaitingApprovalText = state;
557
570
  },
558
- setActiveModelOverride: (modelId) => {
559
- this.activeModelOverride = modelId;
560
- },
571
+ model: this.pickerModel(),
561
572
  },
562
573
  );
563
574
  }
package/src/format.ts CHANGED
@@ -290,7 +290,7 @@ function isAllowedUrl(url: string): boolean {
290
290
  return ALLOWED_URL_SCHEMES.has((m[1] ?? '').toLowerCase());
291
291
  }
292
292
 
293
- function escapeHtml(s: string): string {
293
+ export function escapeHtml(s: string): string {
294
294
  return s.replace(/[&<>]/g, (c) => (c === '&' ? '&amp;' : c === '<' ? '&lt;' : '&gt;'));
295
295
  }
296
296