@moxxy/plugin-telegram 0.43.0 → 0.45.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 (60) hide show
  1. package/dist/channel/callback-handler.d.ts +4 -0
  2. package/dist/channel/callback-handler.d.ts.map +1 -1
  3. package/dist/channel/callback-handler.js +8 -0
  4. package/dist/channel/callback-handler.js.map +1 -1
  5. package/dist/channel/frame-pump.d.ts +3 -1
  6. package/dist/channel/frame-pump.d.ts.map +1 -1
  7. package/dist/channel/frame-pump.js +3 -2
  8. package/dist/channel/frame-pump.js.map +1 -1
  9. package/dist/channel/mirror.d.ts +1 -1
  10. package/dist/channel/mirror.d.ts.map +1 -1
  11. package/dist/channel/mirror.js +1 -0
  12. package/dist/channel/mirror.js.map +1 -1
  13. package/dist/channel/question-cards.d.ts +37 -0
  14. package/dist/channel/question-cards.d.ts.map +1 -0
  15. package/dist/channel/question-cards.js +46 -0
  16. package/dist/channel/question-cards.js.map +1 -0
  17. package/dist/channel/send-images.d.ts +31 -0
  18. package/dist/channel/send-images.d.ts.map +1 -0
  19. package/dist/channel/send-images.js +58 -0
  20. package/dist/channel/send-images.js.map +1 -0
  21. package/dist/channel/slash-handler.js +1 -1
  22. package/dist/channel/slash-handler.js.map +1 -1
  23. package/dist/channel/text-handler.d.ts +4 -1
  24. package/dist/channel/text-handler.d.ts.map +1 -1
  25. package/dist/channel/text-handler.js +5 -1
  26. package/dist/channel/text-handler.js.map +1 -1
  27. package/dist/channel/turn-runner.d.ts.map +1 -1
  28. package/dist/channel/turn-runner.js +4 -0
  29. package/dist/channel/turn-runner.js.map +1 -1
  30. package/dist/channel.d.ts +3 -0
  31. package/dist/channel.d.ts.map +1 -1
  32. package/dist/channel.js +18 -1
  33. package/dist/channel.js.map +1 -1
  34. package/dist/permission.d.ts +1 -1
  35. package/dist/permission.d.ts.map +1 -1
  36. package/dist/permission.js +5 -3
  37. package/dist/permission.js.map +1 -1
  38. package/dist/render.d.ts +17 -5
  39. package/dist/render.d.ts.map +1 -1
  40. package/dist/render.js +51 -29
  41. package/dist/render.js.map +1 -1
  42. package/package.json +7 -7
  43. package/src/channel/callback-handler.test.ts +37 -0
  44. package/src/channel/callback-handler.ts +10 -0
  45. package/src/channel/frame-pump.ts +5 -2
  46. package/src/channel/mirror.ts +2 -1
  47. package/src/channel/question-cards.test.ts +61 -0
  48. package/src/channel/question-cards.ts +70 -0
  49. package/src/channel/send-images.test.ts +66 -0
  50. package/src/channel/send-images.ts +75 -0
  51. package/src/channel/slash-handler.ts +1 -1
  52. package/src/channel/text-handler.test.ts +34 -0
  53. package/src/channel/text-handler.ts +8 -2
  54. package/src/channel/turn-runner.test.ts +26 -3
  55. package/src/channel/turn-runner.ts +3 -0
  56. package/src/channel.ts +19 -0
  57. package/src/permission.test.ts +19 -0
  58. package/src/permission.ts +5 -3
  59. package/src/render.test.ts +100 -4
  60. package/src/render.ts +66 -32
@@ -7,6 +7,7 @@ import type { TelegramPermissionResolver } from '../permission.js';
7
7
  import type { TelegramApprovalResolver } from '../approval.js';
8
8
  import type { TelegramModel } from './model.js';
9
9
  import { PROVIDER_PREFIX, modelScreen, providerScreen } from './model-picker.js';
10
+ import { QUESTION_BUTTON_PREFIX, type ChatQuestions } from '@moxxy/channel-kit';
10
11
 
11
12
  export interface AwaitingApprovalText {
12
13
  approvalId: string;
@@ -21,6 +22,8 @@ export interface CallbackState {
21
22
  readonly approvalResolver: TelegramApprovalResolver;
22
23
  /** Pairing gate — the same authorization check the text/voice handlers enforce. */
23
24
  readonly pairing: { isAuthorized(chatId: number): boolean };
25
+ /** The conversation's open Questions, answered by their buttons. */
26
+ readonly questions: Pick<ChatQuestions, 'press'>;
24
27
  }
25
28
 
26
29
  export interface CallbackCallbacks {
@@ -33,6 +36,7 @@ export interface CallbackCallbacks {
33
36
  /** Inline-keyboard callback router. Dispatches by prefix:
34
37
  * - `perm:` → permission resolver
35
38
  * - `appr:` → approval resolver
39
+ * - `q:` → a Question's answer buttons
36
40
  * - `mprov:` → the model picker's screens (a provider's models, or back)
37
41
  * - `model:` → this bot's model (`provider::model` or `default`)
38
42
  * - `mode:` → mode switch
@@ -69,6 +73,12 @@ export async function handleCallback(
69
73
  await handleAppr(ctx, data, state, cb);
70
74
  return;
71
75
  }
76
+ if (data.startsWith(QUESTION_BUTTON_PREFIX)) {
77
+ // Answer the click at once (the card itself shows what changed).
78
+ await ctx.answerCallbackQuery();
79
+ await state.questions.press(data);
80
+ return;
81
+ }
72
82
  if (data.startsWith(PROVIDER_PREFIX)) {
73
83
  await showPickerScreen(ctx, data.slice(PROVIDER_PREFIX.length), cb);
74
84
  return;
@@ -12,6 +12,8 @@ export interface FramePumpLogger {
12
12
  export interface FramePumpOptions {
13
13
  readonly editFrameMs: number;
14
14
  readonly logger?: FramePumpLogger;
15
+ /** The name of the agent of the session's active agent connection ("Codex"), read when a frame is drawn. */
16
+ readonly agentName?: () => string | undefined;
15
17
  }
16
18
 
17
19
  /**
@@ -31,12 +33,13 @@ export class FramePump {
31
33
  private readonly editFrameMs: number;
32
34
  private readonly logger?: FramePumpLogger;
33
35
  private bot: Bot | null = null;
34
- private renderer: TurnRenderer = new TurnRenderer();
36
+ private readonly renderer: TurnRenderer;
35
37
  private pump: StreamPump<number> | null = null;
36
38
 
37
39
  constructor(opts: FramePumpOptions) {
38
40
  this.editFrameMs = opts.editFrameMs;
39
41
  if (opts.logger) this.logger = opts.logger;
42
+ this.renderer = new TurnRenderer(opts.agentName ? { agentName: opts.agentName } : {});
40
43
  }
41
44
 
42
45
  attachBot(bot: Bot | null): void {
@@ -59,7 +62,7 @@ export class FramePump {
59
62
  editFrameMs: this.editFrameMs,
60
63
  // Only the FINAL frame collapses the activity trace into its expandable
61
64
  // box — mid-stream frames keep it open so the user watches work land live.
62
- frame: (final) => composeFrame(this.renderer.snapshot({ collapse: final })),
65
+ frame: (final) => composeFrame(this.renderer.snapshot({ final })),
63
66
  // The final flush must produce at least one message so the user isn't
64
67
  // left with the typing indicator dangling.
65
68
  emptyFinalText: '<i>(no output)</i>',
@@ -4,7 +4,7 @@ import { sendMarkdown, type TelegramSender } from './send-rich.js';
4
4
  export type { TelegramSender } from './send-rich.js';
5
5
 
6
6
  export interface TelegramMirrorOptions {
7
- readonly turns: Pick<TurnCoordinator, 'mirrorPrompt' | 'mirrorText'>;
7
+ readonly turns: Pick<TurnCoordinator, 'mirrorPrompt' | 'mirrorText' | 'mirrorAgentTurn' | 'mirrorHelperAnswer' | 'mirrorNoReply'>;
8
8
  /** The running bot's API, or null while it is stopped. */
9
9
  readonly api: () => TelegramSender | null;
10
10
  /** The paired chat, or null while unpaired. */
@@ -30,6 +30,7 @@ export function createTelegramMirror(opts: TelegramMirrorOptions): {
30
30
  if (api) await sendMarkdown(api, chatId, text);
31
31
  },
32
32
  formatPrompt: (prompt) => `_typed in moxxy:_ ${prompt}`,
33
+ formatAgentTurn: (name) => `_agent continued:_ ${name}`,
33
34
  onError: (err) => opts.logger?.warn('telegram mirror failed', { err: String(err) }),
34
35
  });
35
36
  return { mirror, target };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Question cards in Telegram: a card goes out with an inline keyboard, picks
3
+ * edit it in place, and a closed question loses its buttons. The Bot API is
4
+ * the boundary, so it is the one thing faked here.
5
+ */
6
+ import { describe, expect, it } from 'vitest';
7
+ import type { QuestionCard } from '@moxxy/channel-kit';
8
+ import { TelegramQuestionCards, type QuestionCardApi } from './question-cards.js';
9
+
10
+ function fakeApi() {
11
+ const calls: Array<{ method: string; chatId: number; messageId?: number; text: string; keyboard: unknown }> = [];
12
+ const api: QuestionCardApi = {
13
+ sendMessage: async (chatId, text, other) => {
14
+ calls.push({ method: 'send', chatId, text, keyboard: other?.reply_markup?.inline_keyboard });
15
+ return { message_id: 41 + calls.length };
16
+ },
17
+ editMessageText: async (chatId, messageId, text, other) => {
18
+ calls.push({ method: 'edit', chatId, messageId, text, keyboard: other?.reply_markup?.inline_keyboard });
19
+ return true;
20
+ },
21
+ };
22
+ return { api, calls };
23
+ }
24
+
25
+ const card = (text: string, rows: QuestionCard['rows']): QuestionCard => ({ questionId: 'qa', text, rows });
26
+ const red = { kind: 'answer' as const, label: 'Red', data: 'q:qa:0:0', chosen: false };
27
+
28
+ describe('TelegramQuestionCards', () => {
29
+ it('sends a new card with its buttons, edits it on a pick and drops the buttons once it closes', async () => {
30
+ const { api, calls } = fakeApi();
31
+ const cards = new TelegramQuestionCards({ api: () => api, chatId: () => 7 });
32
+
33
+ await cards.render({ kind: 'show', card: card('❓ Question', [[red]]) });
34
+ await cards.render({ kind: 'refresh', card: card('❓ Question', [[{ ...red, label: '✓ Red', chosen: true }]]) });
35
+ await cards.render({ kind: 'closed', card: card('✓ Answered: Red', []) });
36
+
37
+ expect(calls).toEqual([
38
+ { method: 'send', chatId: 7, text: '❓ Question', keyboard: [[{ text: 'Red', callback_data: 'q:qa:0:0' }]] },
39
+ { method: 'edit', chatId: 7, messageId: 42, text: '❓ Question', keyboard: [[{ text: '✓ Red', callback_data: 'q:qa:0:0' }]] },
40
+ { method: 'edit', chatId: 7, messageId: 42, text: '✓ Answered: Red', keyboard: [] },
41
+ ]);
42
+ });
43
+
44
+ it('says why a reply did not go out in a message of its own', async () => {
45
+ const { api, calls } = fakeApi();
46
+ const cards = new TelegramQuestionCards({ api: () => api, chatId: () => 7 });
47
+
48
+ await cards.render({ kind: 'problem', text: 'This question is no longer open.' });
49
+
50
+ expect(calls).toEqual([{ method: 'send', chatId: 7, text: 'This question is no longer open.', keyboard: undefined }]);
51
+ });
52
+
53
+ it('draws nothing before a chat is paired', async () => {
54
+ const { api, calls } = fakeApi();
55
+ const cards = new TelegramQuestionCards({ api: () => api, chatId: () => null });
56
+
57
+ await cards.render({ kind: 'show', card: card('❓ Question', [[red]]) });
58
+
59
+ expect(calls).toEqual([]);
60
+ });
61
+ });
@@ -0,0 +1,70 @@
1
+ import type { QuestionCard, QuestionUpdate } from '@moxxy/channel-kit';
2
+ import { truncate } from './html.js';
3
+
4
+ // Mutable arrays: the shape grammy's `bot.api` takes.
5
+ interface InlineKeyboardMarkup {
6
+ inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
7
+ }
8
+
9
+ /** The two Bot API calls a Question card needs (grammy's `bot.api` has both). */
10
+ export interface QuestionCardApi {
11
+ sendMessage(
12
+ chatId: number,
13
+ text: string,
14
+ other?: { readonly reply_markup?: InlineKeyboardMarkup },
15
+ ): Promise<{ readonly message_id: number }>;
16
+ editMessageText(
17
+ chatId: number,
18
+ messageId: number,
19
+ text: string,
20
+ other?: { readonly reply_markup?: InlineKeyboardMarkup },
21
+ ): Promise<unknown>;
22
+ }
23
+
24
+ /** Telegram allows 4096 characters a message; a card stays well under it. */
25
+ const CARD_LIMIT = 3500;
26
+
27
+ /**
28
+ * Draws the Questions `ChatQuestions` hands over as Telegram messages: a card
29
+ * with an inline keyboard, edited in place as picks change and stripped of
30
+ * its buttons once the question closes.
31
+ */
32
+ export class TelegramQuestionCards {
33
+ private readonly messages = new Map<string, number>();
34
+
35
+ constructor(
36
+ private readonly deps: {
37
+ readonly api: () => QuestionCardApi | null;
38
+ readonly chatId: () => number | null;
39
+ readonly logger?: { warn(msg: string, meta?: Record<string, unknown>): void };
40
+ },
41
+ ) {}
42
+
43
+ async render(update: QuestionUpdate): Promise<void> {
44
+ const api = this.deps.api();
45
+ const chatId = this.deps.chatId();
46
+ if (!api || chatId === null) return;
47
+ try {
48
+ if (update.kind === 'problem') {
49
+ await api.sendMessage(chatId, update.text);
50
+ return;
51
+ }
52
+ const text = truncate(update.card.text, CARD_LIMIT);
53
+ const markup = keyboard(update.card);
54
+ const messageId = this.messages.get(update.card.questionId);
55
+ if (update.kind === 'show' || messageId === undefined) {
56
+ const sent = await api.sendMessage(chatId, text, { reply_markup: markup });
57
+ this.messages.set(update.card.questionId, sent.message_id);
58
+ } else {
59
+ await api.editMessageText(chatId, messageId, text, { reply_markup: markup });
60
+ }
61
+ if (update.kind === 'closed') this.messages.delete(update.card.questionId);
62
+ } catch (err) {
63
+ this.deps.logger?.warn('telegram question card failed', { err: String(err) });
64
+ }
65
+ }
66
+ }
67
+
68
+ function keyboard(card: QuestionCard): InlineKeyboardMarkup {
69
+ return { inline_keyboard: card.rows.map((row) => row.map((button) => ({ text: button.label, callback_data: button.data }))) };
70
+ }
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { InputFile } from 'grammy';
3
+ import { sendToolImages, type TelegramImageSender } from './send-images.js';
4
+
5
+ function recordingApi(): { api: TelegramImageSender; calls: string[] } {
6
+ const calls: string[] = [];
7
+ const api: TelegramImageSender = {
8
+ sendPhoto: async (_chatId, photo) => {
9
+ calls.push(`photo ${photo.filename}`);
10
+ },
11
+ sendDocument: async (_chatId, document) => {
12
+ calls.push(`document ${document.filename}`);
13
+ },
14
+ sendMediaGroup: async (_chatId, media) => {
15
+ calls.push(`album ${media.map((item) => (item.media as InputFile).filename).join(',')}`);
16
+ },
17
+ sendMessage: async (_chatId, text) => {
18
+ calls.push(`text ${text}`);
19
+ },
20
+ };
21
+ return { api, calls };
22
+ }
23
+
24
+ const png = { mediaType: 'image/png', base64: Buffer.from('a').toString('base64') };
25
+ const jpeg = { mediaType: 'image/jpeg', base64: Buffer.from('b').toString('base64') };
26
+
27
+ describe('sendToolImages', () => {
28
+ it('sends one image as a photo', async () => {
29
+ const { api, calls } = recordingApi();
30
+ await sendToolImages(api, 1, [png]);
31
+ expect(calls).toEqual(['photo image-1.png']);
32
+ });
33
+
34
+ it('sends several images as one album', async () => {
35
+ const { api, calls } = recordingApi();
36
+ await sendToolImages(api, 1, [png, jpeg]);
37
+ expect(calls).toEqual(['album image-1.png,image-2.jpg']);
38
+ });
39
+
40
+ it('sends an image Telegram does not show as a photo as a file', async () => {
41
+ const { api, calls } = recordingApi();
42
+ await sendToolImages(api, 1, [png, { mediaType: 'image/gif', base64: 'AAAA' }]);
43
+ expect(calls).toEqual(['photo image-1.png', 'document image-2.gif']);
44
+ });
45
+
46
+ it('says how many images were left out', async () => {
47
+ const { api, calls } = recordingApi();
48
+ const many = Array.from({ length: 12 }, (_, i) => ({ mediaType: 'image/png', base64: Buffer.from(`i${i}`).toString('base64') }));
49
+ await sendToolImages(api, 1, many);
50
+ expect(calls).toHaveLength(2);
51
+ expect(calls[1]).toBe('text <i>2 more images not sent (too many or too large).</i>');
52
+ });
53
+
54
+ it('logs a failed upload and goes on', async () => {
55
+ const { api, calls } = recordingApi();
56
+ const warnings: string[] = [];
57
+ await sendToolImages(
58
+ { ...api, sendMediaGroup: async () => Promise.reject(new Error('flood')) },
59
+ 1,
60
+ [png, jpeg],
61
+ { warn: (msg) => warnings.push(msg) },
62
+ );
63
+ expect(calls).toEqual([]);
64
+ expect(warnings).toEqual(['telegram image upload failed']);
65
+ });
66
+ });
@@ -0,0 +1,75 @@
1
+ import { InputFile } from 'grammy';
2
+ import { imageUploads, type ImageUpload, type ImageUploadLimits } from '@moxxy/channel-kit';
3
+ import type { ToolImage } from '@moxxy/sdk';
4
+
5
+ /** The slice of grammy's `Api` sending a turn's images needs (the real client satisfies it). */
6
+ export interface TelegramImageSender {
7
+ sendPhoto(chatId: number, photo: InputFile): Promise<unknown>;
8
+ sendDocument(chatId: number, document: InputFile): Promise<unknown>;
9
+ sendMediaGroup(chatId: number, media: ReadonlyArray<{ type: 'photo'; media: InputFile }>): Promise<unknown>;
10
+ sendMessage(chatId: number, text: string, opts?: { parse_mode?: 'HTML' }): Promise<unknown>;
11
+ }
12
+
13
+ /**
14
+ * An album holds 10 photos and a photo may be 10 MB; a turn sends its latest
15
+ * 10 images. Bot API uploads are capped at 50 MB per request.
16
+ */
17
+ export const TELEGRAM_IMAGE_LIMITS: ImageUploadLimits = {
18
+ perMessage: 10,
19
+ maxMessageBytes: 50_000_000,
20
+ maxFileBytes: 10_000_000,
21
+ maxImages: 10,
22
+ };
23
+
24
+ /** Types Telegram shows as a photo; any other image goes out as a file. */
25
+ const PHOTO_TYPES = new Set(['image/png', 'image/jpeg', 'image/jpg', 'image/webp']);
26
+
27
+ export interface SendImagesLogger {
28
+ warn(msg: string, meta?: Record<string, unknown>): void;
29
+ }
30
+
31
+ /**
32
+ * Send the images tools returned in a turn under its reply: one as a photo,
33
+ * several as an album, an image Telegram cannot show as a photo as a file.
34
+ * A failed upload is logged and the rest still go; images left out (too many,
35
+ * too large) are counted in one line.
36
+ */
37
+ export async function sendToolImages(
38
+ api: TelegramImageSender,
39
+ chatId: number,
40
+ images: ReadonlyArray<ToolImage>,
41
+ logger?: SendImagesLogger,
42
+ ): Promise<void> {
43
+ const { batches, left } = imageUploads(images, TELEGRAM_IMAGE_LIMITS);
44
+ for (const batch of batches) {
45
+ try {
46
+ if (batch.length > 1 && batch.every(isPhoto)) {
47
+ await api.sendMediaGroup(chatId, batch.map((file) => ({ type: 'photo' as const, media: inputFile(file) })));
48
+ } else {
49
+ for (const file of batch) {
50
+ if (isPhoto(file)) await api.sendPhoto(chatId, inputFile(file));
51
+ else await api.sendDocument(chatId, inputFile(file));
52
+ }
53
+ }
54
+ } catch (err) {
55
+ logger?.warn('telegram image upload failed', { err: String(err) });
56
+ }
57
+ }
58
+ if (left > 0) {
59
+ try {
60
+ await api.sendMessage(chatId, `<i>${left} more image${left === 1 ? '' : 's'} not sent (too many or too large).</i>`, {
61
+ parse_mode: 'HTML',
62
+ });
63
+ } catch (err) {
64
+ logger?.warn('telegram image note failed', { err: String(err) });
65
+ }
66
+ }
67
+ }
68
+
69
+ function isPhoto(file: ImageUpload): boolean {
70
+ return PHOTO_TYPES.has(file.mediaType);
71
+ }
72
+
73
+ function inputFile(file: ImageUpload): InputFile {
74
+ return new InputFile(file.bytes, file.name);
75
+ }
@@ -158,7 +158,7 @@ async function renderModelPicker(ctx: Context, model: Pick<TelegramModel, 'choic
158
158
  }
159
159
 
160
160
  async function renderModePicker(ctx: Context, session: Session): Promise<void> {
161
- // Special modes (e.g. collaborative, entered via /collab) are hidden from the
161
+ // Special modes are hidden from the
162
162
  // picker the same way they are in the TUI. See ModeDef.special.
163
163
  const modes = session.modes.list().filter(isSelectableMode);
164
164
  if (modes.length === 0) {
@@ -2,6 +2,8 @@ import { describe, expect, it, vi } from 'vitest';
2
2
  import type { Context } from 'grammy';
3
3
  import type { ChannelHandle } from '@moxxy/sdk';
4
4
  import { Session } from '@moxxy/core';
5
+ import { ChatQuestions } from '@moxxy/channel-kit';
6
+ import { defineTool, z, type QuestionOutcome } from '@moxxy/sdk';
5
7
  import {
6
8
  handleTextMessage,
7
9
  type TextHandlerCallbacks,
@@ -72,6 +74,7 @@ const makeHarness = (over: Partial<TextHandlerState> = {}): Harness => {
72
74
  } as never,
73
75
  permissionResolver: { abortAll: vi.fn() } as never,
74
76
  framePump: { resetRenderer: vi.fn() } as never,
77
+ questions: new ChatQuestions(() => state.session),
75
78
  };
76
79
  const tryHostPair = vi.fn(async () => false);
77
80
  const cb: TextHandlerCallbacks = {
@@ -155,6 +158,37 @@ describe('handleTextMessage — single-flight busy guard', () => {
155
158
  });
156
159
  });
157
160
 
161
+ describe('handleTextMessage — a reply to an open Question', () => {
162
+ it('answers the Question with the reply, even while the turn that asked runs', async () => {
163
+ const h = makeHarness({ busy: true });
164
+ const session = h.state.session;
165
+ if (!session) throw new Error('expected a session');
166
+ session.tools.register(
167
+ defineTool({
168
+ name: 'asks',
169
+ description: 'Asks the person.',
170
+ inputSchema: z.object({}),
171
+ handler: async (_input, ctx) =>
172
+ ctx.ask
173
+ ? ctx.ask([
174
+ { id: 'q1', question: 'Which colour?', options: [{ id: 'red', label: 'Red' }], multiSelect: false, allowOther: true },
175
+ ])
176
+ : 'no ask',
177
+ }),
178
+ );
179
+ h.deps.questions.attach(() => undefined);
180
+ const asked = session.tools.execute('asks', {}, new AbortController().signal, { turnId: 't1' }) as Promise<QuestionOutcome>;
181
+ await new Promise((resolve) => setImmediate(resolve));
182
+ const { ctx, replies } = fakeCtx({ text: 'Teal' });
183
+
184
+ await handleTextMessage(ctx, h.state, h.deps, h.cb);
185
+
186
+ await expect(asked).resolves.toEqual({ kind: 'answered', answers: { q1: { selected: [], other: 'Teal' } } });
187
+ expect(h.runUserTurn).not.toHaveBeenCalled();
188
+ expect(replies).toEqual([]);
189
+ });
190
+ });
191
+
158
192
  describe('handleTextMessage — awaiting approval text (captured BEFORE busy)', () => {
159
193
  it('captures the next message as approval text even while busy, and clears the latch', async () => {
160
194
  const awaiting: AwaitingApprovalText = { approvalId: 'appr_1', optionId: 'redraft' };
@@ -4,7 +4,7 @@ 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
+ import { applySessionAction, type ChatQuestions } from '@moxxy/channel-kit';
8
8
  import { runSlash } from './slash-handler.js';
9
9
  import type { TelegramModel } from './model.js';
10
10
  import type { AwaitingApprovalText } from './callback-handler.js';
@@ -24,6 +24,8 @@ export interface TextHandlerDeps {
24
24
  readonly approvalResolver: TelegramApprovalResolver;
25
25
  readonly permissionResolver: TelegramPermissionResolver;
26
26
  readonly framePump: FramePump;
27
+ /** The conversation's open Questions; a plain reply answers the latest one. */
28
+ readonly questions: Pick<ChatQuestions, 'typed'>;
27
29
  }
28
30
 
29
31
  export interface TextHandlerCallbacks {
@@ -44,7 +46,7 @@ export interface TextHandlerCallbacks {
44
46
 
45
47
  /**
46
48
  * Top-level dispatch for inbound text messages: authorization gate,
47
- * awaiting-approval-text capture, /cancel, slash routing, and finally
49
+ * awaiting-approval-text capture, a reply to an open Question, /cancel, slash routing, and finally
48
50
  * the user-turn path.
49
51
  */
50
52
  export async function handleTextMessage(
@@ -82,6 +84,10 @@ export async function handleTextMessage(
82
84
  return;
83
85
  }
84
86
 
87
+ // A reply to an open Question answers it; the turn that asked waits on it,
88
+ // so this too comes before the busy guard.
89
+ if (!text.startsWith('/') && (await deps.questions.typed(text))) return;
90
+
85
91
  // /cancel works even while busy; everything else routes through
86
92
  // runSlash or the user-turn path.
87
93
  if (text === '/cancel') {
@@ -20,13 +20,15 @@ function recordingSession() {
20
20
 
21
21
  const typing = { start: () => undefined, stop: () => undefined } as never;
22
22
 
23
- function pumpWith(body: string) {
23
+ function pumpWith(body: string, images: ReadonlyArray<{ mediaType: string; base64: string }> = [], order: string[] = []) {
24
24
  return {
25
25
  beginTurn: () => undefined,
26
26
  endTurn: () => undefined,
27
27
  scheduleEdit: () => undefined,
28
- flush: async () => undefined,
29
- renderState: { accept: () => ({ hasUpdate: false }), snapshot: () => ({ body }) },
28
+ flush: async (final: boolean) => {
29
+ if (final) order.push('reply');
30
+ },
31
+ renderState: { accept: () => ({ hasUpdate: false }), snapshot: () => ({ body }), images: () => images },
30
32
  } as never;
31
33
  }
32
34
 
@@ -82,3 +84,24 @@ describe('/voice decides whether a Telegram reply is spoken', () => {
82
84
  expect(spoken).toEqual([]);
83
85
  });
84
86
  });
87
+
88
+ describe('images tools returned in a Telegram turn', () => {
89
+ it('go out as photos after the reply', async () => {
90
+ const { session } = recordingSession();
91
+ const order: string[] = [];
92
+ const png = { mediaType: 'image/png', base64: Buffer.from('png').toString('base64') };
93
+ const bot = {
94
+ api: {
95
+ sendPhoto: async (chatId: number) => {
96
+ order.push(`photo to ${chatId}`);
97
+ },
98
+ },
99
+ } as never;
100
+ await runUserTurn(
101
+ { reply: async () => undefined } as unknown as Context,
102
+ { session, bot, framePump: pumpWith('Here it is.', [png], order), typing },
103
+ { chatId: 42, text: 'hi', model: undefined, controller: new AbortController(), turnId: asTurnId('t2') },
104
+ );
105
+ expect(order).toEqual(['reply', 'photo to 42']);
106
+ });
107
+ });
@@ -4,6 +4,7 @@ import type { ClientSession as Session } from '@moxxy/sdk';
4
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
+ import { sendToolImages } from './send-images.js';
7
8
 
8
9
  export interface TurnRunnerLogger {
9
10
  warn(msg: string, meta?: Record<string, unknown>): void;
@@ -92,6 +93,8 @@ export async function runUserTurn(
92
93
  signal: controller.signal,
93
94
  });
94
95
  await framePump.flush(true);
96
+ // The images tools returned go out under the reply.
97
+ if (bot) await sendToolImages(bot.api, chatId, framePump.renderState.images(), logger);
95
98
  // The text reply is now out. Speak the final assistant body if a voice
96
99
  // reply is wired — isolated so a synth/transcode/transport failure can
97
100
  // never break (or re-report) the already-delivered text turn.
package/src/channel.ts CHANGED
@@ -3,6 +3,8 @@ import type { Context } from 'grammy';
3
3
  import { newTurnId } from '@moxxy/core';
4
4
  import {
5
5
  AutoApproveSwitch,
6
+ activeAgentName,
7
+ ChatQuestions,
6
8
  TurnCoordinator,
7
9
  deliverVoiceReply,
8
10
  resolveSecret,
@@ -37,6 +39,7 @@ import { handleVoiceMessage } from './channel/voice-handler.js';
37
39
  import { loadVoiceReplies, saveVoiceReplies } from './keys.js';
38
40
  import { createTelegramMirror } from './channel/mirror.js';
39
41
  import { telegramModel, type TelegramModel } from './channel/model.js';
42
+ import { TelegramQuestionCards } from './channel/question-cards.js';
40
43
 
41
44
  const TOKEN_KEY = 'telegram_bot_token';
42
45
 
@@ -130,13 +133,24 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
130
133
  private readonly framePump: FramePump;
131
134
  private readonly typing = new TypingIndicator();
132
135
  private readonly pairing: PairingHandler;
136
+ // The conversation's Questions, drawn as cards with answer buttons.
137
+ private readonly questions = new ChatQuestions(() => this.session);
138
+ private readonly questionCards: TelegramQuestionCards;
139
+ private detachQuestions: (() => void) | null = null;
133
140
 
134
141
  constructor(opts: TelegramChannelOptions) {
135
142
  this.opts = opts;
136
143
  this.permissionResolver = new TelegramPermissionResolver();
137
144
  this.approvalResolver = new TelegramApprovalResolver();
145
+ this.questionCards = new TelegramQuestionCards({
146
+ api: () => (this.bot ? this.bot.api : null),
147
+ chatId: () => this.currentChatId,
148
+ ...(opts.logger ? { logger: opts.logger } : {}),
149
+ });
138
150
  this.framePump = new FramePump({
139
151
  editFrameMs: opts.editFrameMs ?? 1000,
152
+ // The agent's name comes from the session, so every surface names the same agent.
153
+ agentName: () => (this.session ? activeAgentName(this.session.getInfo()) : undefined),
140
154
  ...(opts.logger ? { logger: opts.logger } : {}),
141
155
  });
142
156
  this.pairing = new PairingHandler({
@@ -235,6 +249,7 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
235
249
  // this channel. setApprovalResolver(null) on stop tears it down so
236
250
  // headless code paths after channel close don't see a stale handler.
237
251
  this.session.setApprovalResolver(this.approvalResolver);
252
+ this.detachQuestions = this.questions.attach((update) => void this.questionCards.render(update));
238
253
 
239
254
  // Mirror-to-both: a turn this channel did NOT initiate (a message written
240
255
  // in the desktop's chat with this bot) shows here — its prompt, then its
@@ -334,6 +349,8 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
334
349
  this.approvalResolver.abortAll(reason);
335
350
  this.logUnsub?.();
336
351
  this.logUnsub = null;
352
+ this.detachQuestions?.();
353
+ this.detachQuestions = null;
337
354
  if (this.session) this.session.setApprovalResolver(null);
338
355
  this.framePump.endTurn();
339
356
  this.typing.stop();
@@ -394,6 +411,7 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
394
411
  approvalResolver: this.approvalResolver,
395
412
  permissionResolver: this.permissionResolver,
396
413
  framePump: this.framePump,
414
+ questions: this.questions,
397
415
  },
398
416
  {
399
417
  setAwaitingApprovalText: (state) => {
@@ -563,6 +581,7 @@ export class TelegramChannel implements Channel<TelegramStartOpts> {
563
581
  permissionResolver: this.permissionResolver,
564
582
  approvalResolver: this.approvalResolver,
565
583
  pairing: this.pairing,
584
+ questions: this.questions,
566
585
  },
567
586
  {
568
587
  setAwaitingApprovalText: (state) => {
@@ -34,6 +34,25 @@ describe('TelegramPermissionResolver', () => {
34
34
  expect(decider).toHaveBeenCalledTimes(1);
35
35
  });
36
36
 
37
+ // Allowing Safari for the session must not let a later request for Terminal through.
38
+ it('remembers allow_session only for the same request when the tool narrows it', async () => {
39
+ const r = new TelegramPermissionResolver();
40
+ const decider = vi.fn(async () => {});
41
+ r.setDecider(decider);
42
+ const narrowed = { ...ctx, toolPermission: { action: 'prompt' as const, sessionScope: { inputKeys: ['apps'] } } };
43
+ const access = (apps: string[], id: string) => ({ callId: asToolCallId(id), name: 'computer_request_access', input: { apps } });
44
+
45
+ const first = r.check(access(['Safari'], 'a1'), narrowed);
46
+ r.resolvePending('a1', { mode: 'allow_session' });
47
+ await first;
48
+ await r.check(access(['Safari'], 'a2'), narrowed);
49
+ expect(decider).toHaveBeenCalledTimes(1);
50
+
51
+ void r.check(access(['Terminal'], 'a3'), narrowed);
52
+ await vi.waitFor(() => expect(decider).toHaveBeenCalledTimes(2));
53
+ r.abortAll();
54
+ });
55
+
37
56
  it('abortAll resolves pending checks as deny', async () => {
38
57
  const r = new TelegramPermissionResolver();
39
58
  r.setDecider(async () => {});
package/src/permission.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createSessionGrants } from '@moxxy/sdk';
1
2
  import type { PendingToolCall, PermissionContext, PermissionDecision, PermissionResolver } from '@moxxy/sdk';
2
3
 
3
4
  export interface PendingPermission {
@@ -16,7 +17,8 @@ export interface PendingPermission {
16
17
  export class TelegramPermissionResolver implements PermissionResolver {
17
18
  readonly name = 'telegram';
18
19
  private readonly pending = new Map<string, PendingPermission>();
19
- private readonly sessionAllows = new Set<string>();
20
+ // Same rules as the desktop: a tool that narrows its grant is remembered per request.
21
+ private readonly grants = createSessionGrants();
20
22
  private deciderFn:
21
23
  | ((call: PendingToolCall, ctx: PermissionContext) => Promise<void>)
22
24
  | null = null;
@@ -26,7 +28,7 @@ export class TelegramPermissionResolver implements PermissionResolver {
26
28
  }
27
29
 
28
30
  async check(call: PendingToolCall, ctx: PermissionContext): Promise<PermissionDecision> {
29
- if (this.sessionAllows.has(call.name)) {
31
+ if (this.grants.covers(call)) {
30
32
  return { mode: 'allow_session', reason: 'allow_session previously granted' };
31
33
  }
32
34
  if (!this.deciderFn) {
@@ -40,7 +42,7 @@ export class TelegramPermissionResolver implements PermissionResolver {
40
42
  resolve({ mode: 'deny', reason: err instanceof Error ? err.message : String(err) });
41
43
  });
42
44
  });
43
- if (decision.mode === 'allow_session') this.sessionAllows.add(call.name);
45
+ if (decision.mode === 'allow_session') this.grants.remember(call, decision, ctx);
44
46
  return decision;
45
47
  }
46
48