@moxxy/plugin-telegram 0.27.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 (123) hide show
  1. package/LICENSE +21 -0
  2. package/dist/approval.d.ts +37 -0
  3. package/dist/approval.d.ts.map +1 -0
  4. package/dist/approval.js +75 -0
  5. package/dist/approval.js.map +1 -0
  6. package/dist/channel/approval-prompt.d.ts +20 -0
  7. package/dist/channel/approval-prompt.d.ts.map +1 -0
  8. package/dist/channel/approval-prompt.js +34 -0
  9. package/dist/channel/approval-prompt.js.map +1 -0
  10. package/dist/channel/callback-handler.d.ts +32 -0
  11. package/dist/channel/callback-handler.d.ts.map +1 -0
  12. package/dist/channel/callback-handler.js +212 -0
  13. package/dist/channel/callback-handler.js.map +1 -0
  14. package/dist/channel/frame-pump.d.ts +58 -0
  15. package/dist/channel/frame-pump.d.ts.map +1 -0
  16. package/dist/channel/frame-pump.js +159 -0
  17. package/dist/channel/frame-pump.js.map +1 -0
  18. package/dist/channel/html.d.ts +24 -0
  19. package/dist/channel/html.d.ts.map +1 -0
  20. package/dist/channel/html.js +62 -0
  21. package/dist/channel/html.js.map +1 -0
  22. package/dist/channel/pairing-handler.d.ts +66 -0
  23. package/dist/channel/pairing-handler.d.ts.map +1 -0
  24. package/dist/channel/pairing-handler.js +147 -0
  25. package/dist/channel/pairing-handler.js.map +1 -0
  26. package/dist/channel/permission-prompt.d.ts +21 -0
  27. package/dist/channel/permission-prompt.d.ts.map +1 -0
  28. package/dist/channel/permission-prompt.js +36 -0
  29. package/dist/channel/permission-prompt.js.map +1 -0
  30. package/dist/channel/slash-handler.d.ts +47 -0
  31. package/dist/channel/slash-handler.d.ts.map +1 -0
  32. package/dist/channel/slash-handler.js +214 -0
  33. package/dist/channel/slash-handler.js.map +1 -0
  34. package/dist/channel/text-handler.d.ts +43 -0
  35. package/dist/channel/text-handler.d.ts.map +1 -0
  36. package/dist/channel/text-handler.js +116 -0
  37. package/dist/channel/text-handler.js.map +1 -0
  38. package/dist/channel/turn-runner.d.ts +40 -0
  39. package/dist/channel/turn-runner.d.ts.map +1 -0
  40. package/dist/channel/turn-runner.js +67 -0
  41. package/dist/channel/turn-runner.js.map +1 -0
  42. package/dist/channel/typing-indicator.d.ts +14 -0
  43. package/dist/channel/typing-indicator.d.ts.map +1 -0
  44. package/dist/channel/typing-indicator.js +29 -0
  45. package/dist/channel/typing-indicator.js.map +1 -0
  46. package/dist/channel/voice-handler.d.ts +42 -0
  47. package/dist/channel/voice-handler.d.ts.map +1 -0
  48. package/dist/channel/voice-handler.js +138 -0
  49. package/dist/channel/voice-handler.js.map +1 -0
  50. package/dist/channel.d.ts +118 -0
  51. package/dist/channel.d.ts.map +1 -0
  52. package/dist/channel.js +478 -0
  53. package/dist/channel.js.map +1 -0
  54. package/dist/channel.test-d.d.ts +2 -0
  55. package/dist/channel.test-d.d.ts.map +1 -0
  56. package/dist/channel.test-d.js +5 -0
  57. package/dist/channel.test-d.js.map +1 -0
  58. package/dist/format.d.ts +38 -0
  59. package/dist/format.d.ts.map +1 -0
  60. package/dist/format.js +237 -0
  61. package/dist/format.js.map +1 -0
  62. package/dist/index.d.ts +24 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +304 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/keys.d.ts +37 -0
  67. package/dist/keys.d.ts.map +1 -0
  68. package/dist/keys.js +42 -0
  69. package/dist/keys.js.map +1 -0
  70. package/dist/pair-flow.d.ts +18 -0
  71. package/dist/pair-flow.d.ts.map +1 -0
  72. package/dist/pair-flow.js +119 -0
  73. package/dist/pair-flow.js.map +1 -0
  74. package/dist/pairing.d.ts +90 -0
  75. package/dist/pairing.d.ts.map +1 -0
  76. package/dist/pairing.js +97 -0
  77. package/dist/pairing.js.map +1 -0
  78. package/dist/permission.d.ts +25 -0
  79. package/dist/permission.d.ts.map +1 -0
  80. package/dist/permission.js +49 -0
  81. package/dist/permission.js.map +1 -0
  82. package/dist/render.d.ts +68 -0
  83. package/dist/render.d.ts.map +1 -0
  84. package/dist/render.js +439 -0
  85. package/dist/render.js.map +1 -0
  86. package/dist/setup-wizard.d.ts +21 -0
  87. package/dist/setup-wizard.d.ts.map +1 -0
  88. package/dist/setup-wizard.js +140 -0
  89. package/dist/setup-wizard.js.map +1 -0
  90. package/package.json +91 -0
  91. package/src/approval.test.ts +103 -0
  92. package/src/approval.ts +86 -0
  93. package/src/channel/approval-prompt.ts +46 -0
  94. package/src/channel/callback-handler.test.ts +188 -0
  95. package/src/channel/callback-handler.ts +256 -0
  96. package/src/channel/frame-pump.ts +176 -0
  97. package/src/channel/html.test.ts +40 -0
  98. package/src/channel/html.ts +60 -0
  99. package/src/channel/pairing-handler.ts +181 -0
  100. package/src/channel/permission-prompt.ts +51 -0
  101. package/src/channel/slash-handler.ts +259 -0
  102. package/src/channel/text-handler.test.ts +222 -0
  103. package/src/channel/text-handler.ts +174 -0
  104. package/src/channel/turn-runner.ts +102 -0
  105. package/src/channel/typing-indicator.ts +31 -0
  106. package/src/channel/voice-handler.test.ts +328 -0
  107. package/src/channel/voice-handler.ts +198 -0
  108. package/src/channel/voice-reply.test.ts +223 -0
  109. package/src/channel.test-d.ts +7 -0
  110. package/src/channel.ts +564 -0
  111. package/src/format.test.ts +159 -0
  112. package/src/format.ts +257 -0
  113. package/src/index.ts +347 -0
  114. package/src/keys.ts +52 -0
  115. package/src/pair-flow.ts +131 -0
  116. package/src/pairing.test.ts +113 -0
  117. package/src/pairing.ts +184 -0
  118. package/src/permission.test.ts +56 -0
  119. package/src/permission.ts +61 -0
  120. package/src/render.test.ts +350 -0
  121. package/src/render.ts +522 -0
  122. package/src/setup-wizard.ts +178 -0
  123. package/src/subcommands.test.ts +153 -0
package/package.json ADDED
@@ -0,0 +1,91 @@
1
+ {
2
+ "name": "@moxxy/plugin-telegram",
3
+ "version": "0.27.0",
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
+ "keywords": [
6
+ "moxxy",
7
+ "agent",
8
+ "channel",
9
+ "telegram",
10
+ "bot"
11
+ ],
12
+ "homepage": "https://moxxy.ai",
13
+ "bugs": {
14
+ "url": "https://github.com/moxxy-ai/moxxy/issues"
15
+ },
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/moxxy-ai/moxxy.git",
19
+ "directory": "packages/plugin-telegram"
20
+ },
21
+ "author": "Michal Makowski <michal.makowski97@gmail.com>",
22
+ "license": "MIT",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "type": "module",
27
+ "main": "./dist/index.js",
28
+ "types": "./dist/index.d.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./dist/index.d.ts",
32
+ "import": "./dist/index.js"
33
+ }
34
+ },
35
+ "files": [
36
+ "dist",
37
+ "src"
38
+ ],
39
+ "moxxy": {
40
+ "plugin": {
41
+ "entry": "./dist/index.js",
42
+ "kind": "cli"
43
+ },
44
+ "requirements": [
45
+ {
46
+ "kind": "plugin",
47
+ "name": "@moxxy/plugin-vault",
48
+ "hint": "telegram resolves the vault from the service registry for its bot token + pairing; @moxxy/plugin-vault must load first"
49
+ }
50
+ ],
51
+ "setup": {
52
+ "title": "Telegram bot",
53
+ "description": "Create a bot with @BotFather in Telegram (/newbot) and paste its token below. It goes into the encrypted vault. Pairing happens afterwards via QR (`moxxy telegram` → pair).",
54
+ "fields": [
55
+ {
56
+ "key": "botToken",
57
+ "label": "Bot token",
58
+ "kind": "secret",
59
+ "vaultKey": "telegram_bot_token",
60
+ "required": true,
61
+ "placeholder": "123456789:AA…",
62
+ "description": "From @BotFather → /newbot (or /token for an existing bot)."
63
+ }
64
+ ]
65
+ }
66
+ },
67
+ "dependencies": {
68
+ "@clack/prompts": "^1.4.0",
69
+ "grammy": "^1.30.0",
70
+ "qrcode": "^1.5.4",
71
+ "@moxxy/config": "0.27.0",
72
+ "@moxxy/plugin-vault": "0.27.0",
73
+ "@moxxy/core": "0.27.0",
74
+ "@moxxy/sdk": "0.27.0",
75
+ "@moxxy/channel-kit": "0.27.0"
76
+ },
77
+ "devDependencies": {
78
+ "@types/node": "^22.10.0",
79
+ "@types/qrcode": "^1.5.5",
80
+ "typescript": "^5.7.3",
81
+ "vitest": "^2.1.8",
82
+ "@moxxy/tsconfig": "0.0.0",
83
+ "@moxxy/vitest-preset": "0.0.0"
84
+ },
85
+ "scripts": {
86
+ "build": "tsc -p tsconfig.json",
87
+ "typecheck": "tsc -p tsconfig.json --noEmit",
88
+ "test": "vitest run",
89
+ "clean": "rm -rf dist .turbo"
90
+ }
91
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { ApprovalRequest } from '@moxxy/sdk';
3
+ import { TelegramApprovalResolver } from './approval.js';
4
+
5
+ const req = (over: Partial<ApprovalRequest> = {}): ApprovalRequest => ({
6
+ title: 'Run plan?',
7
+ body: 'Details',
8
+ options: [
9
+ { id: 'approve', label: 'Approve' },
10
+ { id: 'cancel', label: 'Cancel' },
11
+ ],
12
+ defaultOptionId: 'approve',
13
+ ...over,
14
+ });
15
+
16
+ describe('TelegramApprovalResolver', () => {
17
+ it('falls back to defaultOptionId when no decider is attached', async () => {
18
+ const r = new TelegramApprovalResolver();
19
+ expect((await r.confirm(req())).optionId).toBe('approve');
20
+ });
21
+
22
+ it('falls back to options[0] then "approve" when no default is set', async () => {
23
+ const r = new TelegramApprovalResolver();
24
+ expect((await r.confirm(req({ defaultOptionId: undefined }))).optionId).toBe('approve');
25
+ const noOpts = await r.confirm(req({ defaultOptionId: undefined, options: [] }));
26
+ expect(noOpts.optionId).toBe('approve');
27
+ const firstWins = await r.confirm(
28
+ req({ defaultOptionId: undefined, options: [{ id: 'x', label: 'X' }] }),
29
+ );
30
+ expect(firstWins.optionId).toBe('x');
31
+ });
32
+
33
+ it('routes to the decider and resolves via resolvePending', async () => {
34
+ const r = new TelegramApprovalResolver();
35
+ r.setDecider(async () => {});
36
+ const promise = r.confirm(req());
37
+ // The pending id is appr_1 (nextId starts at 1).
38
+ expect(r.resolvePending('appr_1', 'cancel')).toBe(true);
39
+ expect(await promise).toEqual({ optionId: 'cancel' });
40
+ // Pending is removed; a second resolve is a no-op.
41
+ expect(r.resolvePending('appr_1', 'approve')).toBe(false);
42
+ });
43
+
44
+ it('resolvePendingWithText carries the follow-up text', async () => {
45
+ const r = new TelegramApprovalResolver();
46
+ r.setDecider(async () => {});
47
+ const promise = r.confirm(req());
48
+ expect(r.resolvePendingWithText('appr_1', 'approve', 'go ahead')).toBe(true);
49
+ expect(await promise).toEqual({ optionId: 'approve', text: 'go ahead' });
50
+ });
51
+
52
+ it('returns false for resolving an unknown id', () => {
53
+ const r = new TelegramApprovalResolver();
54
+ expect(r.resolvePending('nope', 'approve')).toBe(false);
55
+ expect(r.resolvePendingWithText('nope', 'approve', 'x')).toBe(false);
56
+ });
57
+
58
+ it('resolves with the default + error text when the decider rejects', async () => {
59
+ const r = new TelegramApprovalResolver();
60
+ r.setDecider(async () => {
61
+ throw new Error('render boom');
62
+ });
63
+ const decision = await r.confirm(req());
64
+ expect(decision.optionId).toBe('approve');
65
+ expect(decision.text).toContain('render boom');
66
+ // The failed pending is cleaned up.
67
+ expect(r.resolvePending('appr_1', 'cancel')).toBe(false);
68
+ });
69
+
70
+ it('decider rejection falls back to "cancel" when no default/options exist', async () => {
71
+ const r = new TelegramApprovalResolver();
72
+ r.setDecider(async () => {
73
+ throw new Error('x');
74
+ });
75
+ const decision = await r.confirm(req({ defaultOptionId: undefined, options: [] }));
76
+ expect(decision.optionId).toBe('cancel');
77
+ });
78
+
79
+ it('abortAll resolves every pending with the default and clears the map', async () => {
80
+ const r = new TelegramApprovalResolver();
81
+ r.setDecider(async () => {});
82
+ const a = r.confirm(req());
83
+ const b = r.confirm(req({ defaultOptionId: 'cancel' }));
84
+ r.abortAll('channel closed');
85
+ expect(await a).toEqual({ optionId: 'approve', text: 'channel closed' });
86
+ expect(await b).toEqual({ optionId: 'cancel', text: 'channel closed' });
87
+ // Map cleared: the previously-pending ids no longer resolve.
88
+ expect(r.resolvePending('appr_1', 'approve')).toBe(false);
89
+ expect(r.resolvePending('appr_2', 'approve')).toBe(false);
90
+ });
91
+
92
+ it('getPending returns the pending without resolving it', async () => {
93
+ const r = new TelegramApprovalResolver();
94
+ const decider = vi.fn(async () => {});
95
+ r.setDecider(decider);
96
+ const promise = r.confirm(req());
97
+ const pending = r.getPending('appr_1');
98
+ expect(pending?.request.title).toBe('Run plan?');
99
+ // Still pending — resolve it so the promise settles.
100
+ r.resolvePending('appr_1', 'approve');
101
+ await promise;
102
+ });
103
+ });
@@ -0,0 +1,86 @@
1
+ import type { ApprovalDecision, ApprovalRequest, ApprovalResolver } from '@moxxy/sdk';
2
+
3
+ export interface PendingApproval {
4
+ readonly id: string;
5
+ readonly request: ApprovalRequest;
6
+ readonly resolve: (decision: ApprovalDecision) => void;
7
+ }
8
+
9
+ /**
10
+ * Generic approval resolver for the Telegram channel — counterpart to the
11
+ * TUI's ApprovalDialog. The channel renders the request as a message with
12
+ * an inline-keyboard option list and routes the user's button click (or
13
+ * follow-up text, for options with `requestsText: true`) back here via
14
+ * `resolvePending` / `resolvePendingWithText`.
15
+ *
16
+ * Mirrors TelegramPermissionResolver in shape: a Map<callId, pending>
17
+ * plus a `setDecider` callback the channel uses to actually render the
18
+ * UI. The strategy never knows whether the dialog is rendered as Ink
19
+ * components, inline keyboards, or a CLI prompt — it just awaits a
20
+ * promise.
21
+ */
22
+ export class TelegramApprovalResolver implements ApprovalResolver {
23
+ readonly name = 'telegram-approval';
24
+ private readonly pending = new Map<string, PendingApproval>();
25
+ private deciderFn: ((id: string, request: ApprovalRequest) => Promise<void>) | null = null;
26
+ private nextId = 1;
27
+
28
+ setDecider(fn: (id: string, request: ApprovalRequest) => Promise<void>): void {
29
+ this.deciderFn = fn;
30
+ }
31
+
32
+ async confirm(request: ApprovalRequest): Promise<ApprovalDecision> {
33
+ if (!this.deciderFn) {
34
+ // No bot wired up — best fallback is the request's defaultOptionId.
35
+ // For plan-execute, the default is "approve" which preserves the
36
+ // headless behavior (run the plan as drafted).
37
+ return { optionId: request.defaultOptionId ?? request.options[0]?.id ?? 'approve' };
38
+ }
39
+ const id = `appr_${this.nextId++}`;
40
+ return new Promise<ApprovalDecision>((resolve) => {
41
+ this.pending.set(id, { id, request, resolve });
42
+ this.deciderFn!(id, request).catch((err) => {
43
+ this.pending.delete(id);
44
+ resolve({
45
+ optionId: request.defaultOptionId ?? request.options[0]?.id ?? 'cancel',
46
+ text: `decider failed: ${err instanceof Error ? err.message : String(err)}`,
47
+ });
48
+ });
49
+ });
50
+ }
51
+
52
+ /** Look up a pending approval without resolving it. Used by the channel
53
+ * when the user clicks an option that needs text follow-up — we keep
54
+ * the approval pending and capture the next message. */
55
+ getPending(id: string): PendingApproval | undefined {
56
+ return this.pending.get(id);
57
+ }
58
+
59
+ /** Resolve directly with an option id (no text). */
60
+ resolvePending(id: string, optionId: string): boolean {
61
+ const pending = this.pending.get(id);
62
+ if (!pending) return false;
63
+ this.pending.delete(id);
64
+ pending.resolve({ optionId });
65
+ return true;
66
+ }
67
+
68
+ /** Resolve with an option id AND a free-text follow-up. */
69
+ resolvePendingWithText(id: string, optionId: string, text: string): boolean {
70
+ const pending = this.pending.get(id);
71
+ if (!pending) return false;
72
+ this.pending.delete(id);
73
+ pending.resolve({ optionId, text });
74
+ return true;
75
+ }
76
+
77
+ abortAll(reason = 'channel closed'): void {
78
+ for (const pending of this.pending.values()) {
79
+ pending.resolve({
80
+ optionId: pending.request.defaultOptionId ?? pending.request.options[0]?.id ?? 'cancel',
81
+ text: reason,
82
+ });
83
+ }
84
+ this.pending.clear();
85
+ }
86
+ }
@@ -0,0 +1,46 @@
1
+ import { type Bot, InlineKeyboard } from 'grammy';
2
+ import type { ApprovalRequest } from '@moxxy/sdk';
3
+ import type { TelegramApprovalResolver } from '../approval.js';
4
+ import { truncate } from './html.js';
5
+
6
+ export interface ApprovalPromptDeps {
7
+ readonly bot: Bot | null;
8
+ readonly chatId: number | null;
9
+ readonly resolver: TelegramApprovalResolver;
10
+ readonly logger?: { warn(msg: string, meta?: Record<string, unknown>): void };
11
+ }
12
+
13
+ /**
14
+ * Render an approval request (e.g. plan-execute "validate plan") as a
15
+ * message + inline-keyboard option list. Options with `requestsText`
16
+ * are still picked here; the callback handler then captures the user's
17
+ * NEXT message as the follow-up text via the channel's awaiting-text
18
+ * state.
19
+ */
20
+ export async function askForApproval(
21
+ id: string,
22
+ request: ApprovalRequest,
23
+ deps: ApprovalPromptDeps,
24
+ ): Promise<void> {
25
+ if (!deps.bot || !deps.chatId) return;
26
+ const keyboard = new InlineKeyboard();
27
+ for (const opt of request.options) {
28
+ const label = `${opt.hotkey ? `[${opt.hotkey}] ` : ''}${opt.label}`;
29
+ keyboard.text(label, `appr:${id}:${opt.id}`).row();
30
+ }
31
+ // Telegram has a ~4096-char message limit; truncate the body so a
32
+ // verbose plan doesn't fail to send. The full plan is also in the
33
+ // chat scrollback (the assistant_message events get streamed in).
34
+ const bodySnippet = truncate(request.body, 3000);
35
+ const summary =
36
+ `📋 ${request.title}\n\n${bodySnippet}\n\n` +
37
+ `Pick an option below. Some options (e.g. Redraft) will prompt for follow-up text after you click.`;
38
+ try {
39
+ await deps.bot.api.sendMessage(deps.chatId, summary, { reply_markup: keyboard });
40
+ } catch (err) {
41
+ deps.logger?.warn('approval send failed', { err: String(err) });
42
+ // Default-resolve on send failure so the loop strategy doesn't hang.
43
+ const fallback = request.defaultOptionId ?? request.options[0]?.id ?? 'cancel';
44
+ deps.resolver.resolvePending(id, fallback);
45
+ }
46
+ }
@@ -0,0 +1,188 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { Context } from 'grammy';
3
+
4
+ // The manifest writers (setCategoryDefault/setProviderModel) can reject on
5
+ // disk/permission/lock. Mock them so we can drive the failure path.
6
+ const setCategoryDefault = vi.fn(async () => {});
7
+ const setProviderModel = vi.fn(async () => {});
8
+ vi.mock('@moxxy/config', () => ({
9
+ setCategoryDefault: (...a: unknown[]) => setCategoryDefault(...a),
10
+ setProviderModel: (...a: unknown[]) => setProviderModel(...a),
11
+ }));
12
+
13
+ import { handleCallback, type CallbackState, type CallbackCallbacks } from './callback-handler.js';
14
+
15
+ /**
16
+ * A46: the inline-keyboard callback path must enforce the SAME pairing gate
17
+ * the text/voice handlers do — button clicks resolve permission prompts and
18
+ * approvals, so an unpaired chat's clicks must be refused, not processed.
19
+ */
20
+
21
+ const fakeCtx = (over: Partial<{ chatId: number | undefined; data: string }> = {}) => {
22
+ const answers: Array<{ text?: string }> = [];
23
+ const answerCallbackQuery = vi.fn(async (arg?: { text?: string }) => {
24
+ answers.push(arg ?? {});
25
+ return true;
26
+ });
27
+ const chat = over.chatId === undefined ? undefined : { id: over.chatId };
28
+ return {
29
+ ctx: {
30
+ chat,
31
+ callbackQuery: {
32
+ data: over.data ?? 'perm:call-1:allow',
33
+ message: chat ? { chat } : undefined,
34
+ },
35
+ answerCallbackQuery,
36
+ editMessageReplyMarkup: vi.fn(async () => true),
37
+ } as unknown as Context,
38
+ answers,
39
+ answerCallbackQuery,
40
+ };
41
+ };
42
+
43
+ const makeState = (over: Partial<CallbackState> = {}): { state: CallbackState; resolvePending: ReturnType<typeof vi.fn> } => {
44
+ const resolvePending = vi.fn(() => true);
45
+ const state: CallbackState = {
46
+ bot: null,
47
+ session: null,
48
+ chatId: null,
49
+ permissionResolver: { resolvePending } as never,
50
+ approvalResolver: { getPending: () => undefined, resolvePending: vi.fn() } as never,
51
+ pairing: { isAuthorized: () => true },
52
+ ...over,
53
+ };
54
+ return { state, resolvePending };
55
+ };
56
+
57
+ const cb: CallbackCallbacks = {
58
+ setAwaitingApprovalText: () => undefined,
59
+ setActiveModelOverride: () => undefined,
60
+ };
61
+
62
+ describe('handleCallback — pairing gate (A46)', () => {
63
+ it('refuses a callback from an unpaired chat without touching any resolver', async () => {
64
+ const { ctx, answers } = fakeCtx({ chatId: 666 });
65
+ const { state, resolvePending } = makeState({ pairing: { isAuthorized: (id) => id !== 666 } });
66
+ await handleCallback(ctx, state, cb);
67
+ expect(resolvePending).not.toHaveBeenCalled();
68
+ expect(answers.some((a) => /paired/i.test(a.text ?? ''))).toBe(true);
69
+ });
70
+
71
+ it('refuses a callback with no resolvable chat id (e.g. inline-message callbacks)', async () => {
72
+ const { ctx } = fakeCtx({ chatId: undefined });
73
+ const { state, resolvePending } = makeState({ pairing: { isAuthorized: () => true } });
74
+ await handleCallback(ctx, state, cb);
75
+ expect(resolvePending).not.toHaveBeenCalled();
76
+ });
77
+
78
+ it('processes a callback from the paired chat (perm: resolves the pending call)', async () => {
79
+ const { ctx } = fakeCtx({ chatId: 42, data: 'perm:call-1:allow' });
80
+ const { state, resolvePending } = makeState({ pairing: { isAuthorized: (id) => id === 42 } });
81
+ await handleCallback(ctx, state, cb);
82
+ expect(resolvePending).toHaveBeenCalledWith('call-1', { mode: 'allow' });
83
+ });
84
+
85
+ it('gates every dispatch prefix, not just perm:', async () => {
86
+ for (const data of ['appr:a1:opt', 'model:openai::gpt-x', 'mode:goal']) {
87
+ const { ctx, answerCallbackQuery } = fakeCtx({ chatId: 666, data });
88
+ const getPending = vi.fn();
89
+ const { state } = makeState({
90
+ pairing: { isAuthorized: () => false },
91
+ approvalResolver: { getPending, resolvePending: vi.fn() } as never,
92
+ });
93
+ await handleCallback(ctx, state, cb);
94
+ // The only reply is the refusal — the handlers behind the prefixes
95
+ // were never consulted.
96
+ expect(getPending).not.toHaveBeenCalled();
97
+ expect(answerCallbackQuery).toHaveBeenCalledTimes(1);
98
+ }
99
+ });
100
+ });
101
+
102
+ describe('handleCallback — model/mode persistence failure (u110-5)', () => {
103
+ const fakeSession = () =>
104
+ ({
105
+ readyProviders: new Set(['openai']),
106
+ providers: {
107
+ getActiveName: () => 'openai',
108
+ list: () => [{ name: 'openai' }],
109
+ replace: vi.fn(),
110
+ setActive: vi.fn(),
111
+ },
112
+ modes: {
113
+ setActive: vi.fn(),
114
+ list: () => [{ name: 'default' }, { name: 'goal' }],
115
+ },
116
+ credentialResolver: undefined,
117
+ }) as unknown as CallbackState['session'];
118
+
119
+ const ctxWithEdit = (data: string) => {
120
+ const editMessageText = vi.fn(async () => true);
121
+ const answerCallbackQuery = vi.fn(async () => true);
122
+ const chat = { id: 42 };
123
+ return {
124
+ ctx: {
125
+ chat,
126
+ callbackQuery: { data, message: { chat } },
127
+ answerCallbackQuery,
128
+ editMessageText,
129
+ editMessageReplyMarkup: vi.fn(async () => true),
130
+ } as unknown as Context,
131
+ editMessageText,
132
+ answerCallbackQuery,
133
+ };
134
+ };
135
+
136
+ const stateFor = (session: CallbackState['session']): CallbackState => ({
137
+ bot: null,
138
+ session,
139
+ chatId: null,
140
+ permissionResolver: { resolvePending: vi.fn() } as never,
141
+ approvalResolver: { getPending: () => undefined, resolvePending: vi.fn() } as never,
142
+ pairing: { isAuthorized: () => true },
143
+ });
144
+
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
+ it('does NOT claim "✓ mode →" when the manifest write rejects (mode)', async () => {
164
+ setCategoryDefault.mockReset();
165
+ setCategoryDefault.mockRejectedValueOnce(new Error('EROFS'));
166
+ const { ctx, editMessageText, answerCallbackQuery } = ctxWithEdit('mode:goal');
167
+ await handleCallback(ctx, stateFor(fakeSession()), cb);
168
+
169
+ expect(setCategoryDefault).toHaveBeenCalledWith('mode', 'goal');
170
+ const successEdits = editMessageText.mock.calls.filter((c) =>
171
+ String(c[0]).includes('✓ mode'),
172
+ );
173
+ expect(successEdits).toHaveLength(0);
174
+ expect(
175
+ answerCallbackQuery.mock.calls.some((c) => /failed/i.test(String((c[0] as { text?: string } | undefined)?.text))),
176
+ ).toBe(true);
177
+ });
178
+
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
+ });