@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
@@ -0,0 +1,181 @@
1
+ import type { Bot, Context } from 'grammy';
2
+ import type { VaultStore } from '@moxxy/plugin-vault';
3
+ import {
4
+ beginHostIssuedPairing,
5
+ clearPairing,
6
+ createPairingState,
7
+ handleStart,
8
+ isAuthorized,
9
+ submitChatCode,
10
+ type PairingDecision,
11
+ type PairingState,
12
+ } from '../pairing.js';
13
+ import { parseChatId, TELEGRAM_AUTHORIZED_CHAT_KEY } from '../keys.js';
14
+
15
+ const AUTHORIZED_CHAT_KEY = TELEGRAM_AUTHORIZED_CHAT_KEY;
16
+
17
+ /** Result returned by `confirmChatCode`. */
18
+ export type PairingConfirmResult =
19
+ | { ok: true; chatId: number }
20
+ | { ok: false; reason: 'mismatch' | 'expired' | 'not-pending' | 'no-window'; message: string };
21
+
22
+ export interface PairingHandlerOptions {
23
+ readonly vault: VaultStore;
24
+ readonly logger?: {
25
+ info?(msg: string, meta?: Record<string, unknown>): void;
26
+ warn(msg: string, meta?: Record<string, unknown>): void;
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Owns the pairing state machine + the bot-side `/start` handler for the
32
+ * host-issued QR pairing flow (see {@link beginHostWindow}). Keeps the bot
33
+ * reference (re-)settable so the TelegramChannel can wire it up after
34
+ * construction.
35
+ */
36
+ export class PairingHandler {
37
+ private state: PairingState = createPairingState();
38
+ private bot: Bot | null = null;
39
+ private readonly opts: PairingHandlerOptions;
40
+ private readonly pairedListeners = new Set<(chatId: number) => void>();
41
+
42
+ constructor(opts: PairingHandlerOptions) {
43
+ this.opts = opts;
44
+ }
45
+
46
+ attachBot(bot: Bot | null): void {
47
+ this.bot = bot;
48
+ }
49
+
50
+ async loadAuthorized(): Promise<void> {
51
+ const authorizedRaw = await this.opts.vault.get(AUTHORIZED_CHAT_KEY);
52
+ const authorizedChatId = parseChatId(authorizedRaw);
53
+ if (authorizedChatId == null && authorizedRaw) {
54
+ this.opts.logger?.warn('telegram pairing: stored chat id is not a number — treating as unpaired', {
55
+ raw: authorizedRaw,
56
+ });
57
+ }
58
+ this.state = createPairingState({ authorizedChatId });
59
+ }
60
+
61
+ isAuthorized(chatId: number): boolean {
62
+ return isAuthorized(this.state, chatId);
63
+ }
64
+
65
+ phase(): PairingState['phase'] {
66
+ return this.state.phase;
67
+ }
68
+
69
+ /**
70
+ * Open a host-issued pairing window and return the code to embed in the
71
+ * `t.me/<bot>?start=<code>` deep link / QR the control surface renders. The
72
+ * user proves ownership by presenting THIS code back to the bot (deep-link tap
73
+ * or a plain message), which `handleStartCommand` / `confirmChatCode`
74
+ * validate. Returns the generated code.
75
+ */
76
+ beginHostWindow(): string {
77
+ const { state, code } = beginHostIssuedPairing(this.state);
78
+ this.state = state;
79
+ return code;
80
+ }
81
+
82
+ unpair(): void {
83
+ this.state = clearPairing(this.state);
84
+ }
85
+
86
+ /**
87
+ * Subscribe to "a chat just became authorized" — fires once each time pairing
88
+ * completes. The channel uses this to publish its "connected" connect-state so
89
+ * a watching control surface can swap the QR for a "✓ Connected" affordance,
90
+ * and the `pair` terminal command uses it to know pairing finished. Returns an
91
+ * unsubscribe function.
92
+ */
93
+ onPaired(listener: (chatId: number) => void): () => void {
94
+ this.pairedListeners.add(listener);
95
+ return () => this.pairedListeners.delete(listener);
96
+ }
97
+
98
+ /**
99
+ * A chat presented a code (deep-link `/start <code>` payload or a plain 6-digit
100
+ * message). On match the chat is authorized, persisted to the vault, and
101
+ * greeted. Returns a structured result the caller can branch on.
102
+ */
103
+ async confirmChatCode(chatId: number, rawCode: string): Promise<PairingConfirmResult> {
104
+ const decision = submitChatCode(this.state, chatId, rawCode);
105
+ return this.applyConfirmDecision(decision);
106
+ }
107
+
108
+ /** Persist + greet + notify on a 'paired' decision; map every decision kind to
109
+ * a {@link PairingConfirmResult}. */
110
+ private async applyConfirmDecision(decision: PairingDecision): Promise<PairingConfirmResult> {
111
+ this.state = decision.state;
112
+ const action = decision.action;
113
+ if (action.kind === 'paired') {
114
+ await this.opts.vault.set(AUTHORIZED_CHAT_KEY, String(action.chatId));
115
+ // Greet the chat that just got authorized so the user has a confirmation
116
+ // on the Telegram side too — symmetric with the success the surface shows.
117
+ if (this.bot) {
118
+ try {
119
+ await this.bot.api.sendMessage(
120
+ action.chatId,
121
+ '✅ Paired with moxxy. Send a prompt to begin.',
122
+ );
123
+ } catch (err) {
124
+ this.opts.logger?.warn('pairing: greeting send failed', { err: String(err) });
125
+ }
126
+ }
127
+ this.emitPaired(action.chatId);
128
+ return { ok: true, chatId: action.chatId };
129
+ }
130
+ if (action.kind === 'still-paired') return { ok: true, chatId: action.chatId };
131
+ if (action.kind === 'mismatch') return { ok: false, reason: 'mismatch', message: action.message };
132
+ if (action.kind === 'expired') return { ok: false, reason: 'expired', message: action.message };
133
+ if (action.kind === 'not-pending') return { ok: false, reason: 'not-pending', message: action.message };
134
+ if (action.kind === 'reject') return { ok: false, reason: 'not-pending', message: action.message };
135
+ return { ok: false, reason: 'mismatch', message: 'unexpected pairing state' };
136
+ }
137
+
138
+ private emitPaired(chatId: number): void {
139
+ for (const listener of this.pairedListeners) {
140
+ try {
141
+ listener(chatId);
142
+ } catch (err) {
143
+ this.opts.logger?.warn('pairing paired-listener threw', { err: String(err) });
144
+ }
145
+ }
146
+ }
147
+
148
+ /** Bot-side `/start` handler. */
149
+ async handleStartCommand(ctx: Context): Promise<void> {
150
+ const chatId = ctx.chat?.id;
151
+ if (!chatId) return;
152
+
153
+ // Host-issued direction: the deep link the user scanned carries the code as
154
+ // the /start payload (grammy exposes it as `ctx.match`). Validate it here so
155
+ // "scan QR → tap START → paired" needs no typing. A bare /start (no payload —
156
+ // e.g. the user opened the chat manually) falls through to `handleStart`,
157
+ // which nudges them to use the link or send the digits (the message handler
158
+ // also accepts a bare 6-digit code).
159
+ if (this.state.phase === 'awaiting-host-code') {
160
+ const payload = typeof ctx.match === 'string' ? ctx.match.trim() : '';
161
+ if (payload) {
162
+ const result = await this.confirmChatCode(chatId, payload);
163
+ if (!result.ok && result.reason !== 'not-pending') {
164
+ await ctx.reply(result.message);
165
+ }
166
+ return;
167
+ }
168
+ }
169
+
170
+ const decision: PairingDecision = handleStart(this.state, chatId);
171
+ this.state = decision.state;
172
+ const action = decision.action;
173
+ if (action.kind === 'still-paired') {
174
+ await ctx.reply('Welcome back! Send me a prompt.');
175
+ return;
176
+ }
177
+ if (action.kind === 'reject' || action.kind === 'expired') {
178
+ await ctx.reply(action.message);
179
+ }
180
+ }
181
+ }
@@ -0,0 +1,51 @@
1
+ import { type Bot, InlineKeyboard } from 'grammy';
2
+ import type { PendingToolCall, PermissionContext } from '@moxxy/sdk';
3
+ import type { ClientSession as Session } from '@moxxy/sdk';
4
+ import type { TelegramPermissionResolver } from '../permission.js';
5
+ import { truncate } from './html.js';
6
+
7
+ export interface PermissionPromptDeps {
8
+ readonly bot: Bot | null;
9
+ readonly chatId: number | null;
10
+ readonly session: Session | null;
11
+ readonly resolver: TelegramPermissionResolver;
12
+ readonly yolo: boolean;
13
+ readonly logger?: { warn(msg: string, meta?: Record<string, unknown>): void };
14
+ }
15
+
16
+ /**
17
+ * Render an inline-keyboard permission prompt for a pending tool call.
18
+ * The decider promise resolves when the user clicks a button (routed
19
+ * back via the callback handler) or when the resolver aborts on stop.
20
+ */
21
+ export async function askForPermission(
22
+ call: PendingToolCall,
23
+ ctx: PermissionContext,
24
+ deps: PermissionPromptDeps,
25
+ ): Promise<void> {
26
+ if (!deps.bot || !deps.chatId || !deps.session) return;
27
+ void ctx;
28
+ // YOLO short-circuit: resolve immediately without rendering a prompt.
29
+ // Mirrors the TUI's `/yolo` flag — once set, every tool call passes.
30
+ if (deps.yolo) {
31
+ deps.resolver.resolvePending(call.callId, { mode: 'allow', reason: 'yolo mode' });
32
+ return;
33
+ }
34
+ const keyboard = new InlineKeyboard()
35
+ .text('Allow once', `perm:${call.callId}:allow`)
36
+ .text('Allow session', `perm:${call.callId}:allow_session`)
37
+ .row()
38
+ .text('Deny', `perm:${call.callId}:deny`);
39
+ const description = deps.session.tools.get(call.name)?.description ?? '';
40
+ const summary =
41
+ `🔐 Tool permission requested\n` +
42
+ `Tool: ${call.name}\n` +
43
+ (description ? `Desc: ${description}\n` : '') +
44
+ `Input: ${truncate(JSON.stringify(call.input), 300)}`;
45
+ try {
46
+ await deps.bot.api.sendMessage(deps.chatId, summary, { reply_markup: keyboard });
47
+ } catch (err) {
48
+ deps.logger?.warn('permission send failed', { err: String(err) });
49
+ deps.resolver.resolvePending(call.callId, { mode: 'deny', reason: 'unable to render prompt' });
50
+ }
51
+ }
@@ -0,0 +1,259 @@
1
+ import { type Bot, type Context, InlineKeyboard } from 'grammy';
2
+ import { isSelectableMode } from '@moxxy/sdk';
3
+ import type { ClientSession as Session } from '@moxxy/sdk';
4
+ import { resolveVoiceToggle } from '@moxxy/channel-kit';
5
+
6
+ /**
7
+ * Install guidance shown when enabling `/voice` with no active synthesizer —
8
+ * mirrors the voice-handler's transcriber guidance wording, pointing at the TTS
9
+ * plugin instead of the STT one.
10
+ */
11
+ export const VOICE_NO_SYNTH_HINT =
12
+ 'No text-to-speech backend is configured yet, so replies stay text-only. Install one with `moxxy plugins install tts-openai` and run `moxxy login openai` (or set OPENAI_API_KEY) to enable spoken replies.';
13
+
14
+ export interface SlashState {
15
+ readonly session: Session | null;
16
+ readonly model: string | undefined;
17
+ readonly activeModelOverride: string | null;
18
+ readonly yolo: boolean;
19
+ /** Whether voice replies are currently enabled (backs `/voice status`). */
20
+ readonly voiceReplies: boolean;
21
+ }
22
+
23
+ export interface SlashCallbacks {
24
+ /** Toggle yolo and return its new value (so we can echo the right message). */
25
+ toggleYolo(): boolean;
26
+ /** Persist + apply the voice-replies preference (backs `/voice on|off`). */
27
+ setVoiceReplies(on: boolean): Promise<void>;
28
+ /** Apply a `session-action` result emitted from a registered command. */
29
+ performSessionAction(
30
+ ctx: Context,
31
+ action: 'new' | 'clear' | 'exit',
32
+ notice: string | undefined,
33
+ ): Promise<void>;
34
+ }
35
+
36
+ /**
37
+ * Slash-command dispatcher for the Telegram channel.
38
+ *
39
+ * First tries the shared `session.commands` registry — this is where
40
+ * the universal commands (/info, /clear, /new, /exit, /help) live, so
41
+ * Telegram gets them for free alongside any plugin-contributed
42
+ * commands without needing a switch case here.
43
+ *
44
+ * Falls through to channel-local cases for Telegram-specific UI
45
+ * (model/loop pickers as inline keyboards, /yolo toggle, /tools and
46
+ * /skills as text dumps).
47
+ */
48
+ export async function runSlash(
49
+ ctx: Context,
50
+ text: string,
51
+ state: SlashState,
52
+ cb: SlashCallbacks,
53
+ ): Promise<void> {
54
+ const session = state.session;
55
+ if (!session) return;
56
+ const [head, ...rest] = text.split(/\s+/);
57
+ const name = head!.slice(1);
58
+ const args = rest.join(' ');
59
+
60
+ // 1) Shared registry dispatch.
61
+ const registered = session.commands.get(name);
62
+ if (registered) {
63
+ try {
64
+ const result = await registered.handler({
65
+ channel: 'telegram',
66
+ sessionId: session.id,
67
+ args,
68
+ session,
69
+ });
70
+ if (result.kind === 'text') {
71
+ await ctx.reply(result.text);
72
+ } else if (result.kind === 'session-action') {
73
+ await cb.performSessionAction(ctx, result.action, result.notice);
74
+ } else if (result.kind === 'error') {
75
+ await ctx.reply(`error: ${result.message}`);
76
+ }
77
+ } catch (err) {
78
+ await ctx.reply(
79
+ `command /${name} failed: ${err instanceof Error ? err.message : String(err)}`,
80
+ );
81
+ }
82
+ return;
83
+ }
84
+
85
+ // 2) Channel-local cases.
86
+ switch (head) {
87
+ case '/model':
88
+ await renderModelPicker(ctx, session, state, args);
89
+ return;
90
+ case '/mode':
91
+ await renderModePicker(ctx, session);
92
+ return;
93
+ case '/yolo': {
94
+ const enabled = cb.toggleYolo();
95
+ await ctx.reply(
96
+ enabled
97
+ ? '⚠ yolo mode ON — tool calls auto-approved for the rest of this session'
98
+ : 'yolo mode OFF — tool prompts will resume',
99
+ );
100
+ return;
101
+ }
102
+ case '/voice': {
103
+ const result = resolveVoiceToggle({
104
+ arg: args,
105
+ enabled: state.voiceReplies,
106
+ hasSynthesizer: session.synthesizers.tryGetActive() != null,
107
+ delivery: 'a voice note',
108
+ noSynthesizerHint: VOICE_NO_SYNTH_HINT,
109
+ });
110
+ if (result.persist) await cb.setVoiceReplies(result.enabled);
111
+ await ctx.reply(result.reply);
112
+ return;
113
+ }
114
+ case '/tools': {
115
+ const list = session.tools
116
+ .list()
117
+ .map((t) => `${t.name} — ${t.description}`)
118
+ .join('\n');
119
+ await ctx.reply(list || '(no tools registered)');
120
+ return;
121
+ }
122
+ case '/skills': {
123
+ const list = session.skills
124
+ .list()
125
+ .map((s) => {
126
+ const triggers = s.frontmatter.triggers ?? [];
127
+ const triggerLine = triggers.length
128
+ ? `\n triggers: ${triggers.map((t) => `"${t}"`).join(', ')}`
129
+ : '';
130
+ return `${s.frontmatter.name} — ${s.frontmatter.description}${triggerLine}`;
131
+ })
132
+ .join('\n');
133
+ await ctx.reply(list || '(no skills discovered)');
134
+ return;
135
+ }
136
+ default:
137
+ await ctx.reply(`unknown command: ${head} (try /help)`);
138
+ }
139
+ }
140
+
141
+ /** Telegram inline keyboards get unwieldy past this many rows; cap the picker. */
142
+ const MODEL_PICKER_CAP = 30;
143
+
144
+ async function renderModelPicker(
145
+ ctx: Context,
146
+ session: Session,
147
+ state: SlashState,
148
+ filter = '',
149
+ ): Promise<void> {
150
+ const providers = session.providers.list();
151
+ if (providers.length === 0) {
152
+ await ctx.reply('no providers registered');
153
+ return;
154
+ }
155
+ const keyboard = new InlineKeyboard();
156
+ const providerName = session.providers.getActiveName() ?? '';
157
+ const activeModel = state.activeModelOverride ?? state.model ?? '';
158
+ // Same boot-time readiness set the TUI uses to flag unconfigured
159
+ // providers — set by `@moxxy/cli` at setup time. Providers that
160
+ // failed credential resolution get a "(not connected)" suffix
161
+ // and a tap on them surfaces the right setup command instead of
162
+ // a no-op switch.
163
+ const ready = session.readyProviders ?? new Set<string>();
164
+ // `/model <substring>` narrows the list so a model that sorts past the
165
+ // keyboard cap is still reachable from chat (the cap below would otherwise
166
+ // silently hide it with no fallback).
167
+ const needle = filter.trim().toLowerCase();
168
+ // Flatten so we can both apply the substring filter and report how many the
169
+ // cap hides (vs. how many the filter excluded).
170
+ const all = providers.flatMap((p) =>
171
+ p.models.map((m) => ({ provider: p.name, model: m.id })),
172
+ );
173
+ const matched = needle
174
+ ? all.filter((e) => `${e.provider}::${e.model}`.toLowerCase().includes(needle))
175
+ : all;
176
+ if (matched.length === 0) {
177
+ await ctx.reply(`no model matches "${filter.trim()}" — try /model with no argument.`);
178
+ return;
179
+ }
180
+ const shown = matched.slice(0, MODEL_PICKER_CAP);
181
+ for (const e of shown) {
182
+ const isCurrent = providerName === e.provider && activeModel === e.model;
183
+ const connected = ready.has(e.provider);
184
+ const label =
185
+ `${isCurrent ? '• ' : ''}${e.provider}: ${e.model}` +
186
+ (connected ? '' : ' (not connected)');
187
+ keyboard.text(label, `model:${e.provider}::${e.model}`).row();
188
+ }
189
+ const hidden = matched.length - shown.length;
190
+ const prompt =
191
+ hidden > 0
192
+ ? `Pick a model (${hidden} more not shown — narrow with /model <text>):`
193
+ : 'Pick a model:';
194
+ await ctx.reply(prompt, { reply_markup: keyboard });
195
+ }
196
+
197
+ async function renderModePicker(ctx: Context, session: Session): Promise<void> {
198
+ // Special modes (e.g. collaborative, entered via /collab) are hidden from the
199
+ // picker the same way they are in the TUI. See ModeDef.special.
200
+ const modes = session.modes.list().filter(isSelectableMode);
201
+ if (modes.length === 0) {
202
+ await ctx.reply('no modes registered');
203
+ return;
204
+ }
205
+ const keyboard = new InlineKeyboard();
206
+ const activeModeName = (() => {
207
+ try {
208
+ return session.modes.getActive().name;
209
+ } catch {
210
+ return '';
211
+ }
212
+ })();
213
+ for (const s of modes) {
214
+ const isCurrent = s.name === activeModeName;
215
+ keyboard.text(`${isCurrent ? '• ' : ''}${s.name}`, `mode:${s.name}`).row();
216
+ }
217
+ await ctx.reply('Pick a mode:', { reply_markup: keyboard });
218
+ }
219
+
220
+ /**
221
+ * Push the union of registry commands + Telegram-local commands to
222
+ * Telegram so they appear in the chat's command menu (the "/" picker
223
+ * the official client shows). Best-effort: a network failure here
224
+ * doesn't block channel startup, the commands still work via text.
225
+ */
226
+ export async function publishBotCommands(
227
+ bot: Bot | null,
228
+ session: Session | null,
229
+ logger?: { warn?(msg: string, meta?: Record<string, unknown>): void },
230
+ ): Promise<void> {
231
+ if (!session || !bot) return;
232
+ const LOCAL: Array<{ command: string; description: string }> = [
233
+ { command: 'model', description: 'Switch provider + model (inline keyboard)' },
234
+ { command: 'mode', description: 'Switch mode' },
235
+ { command: 'yolo', description: 'Toggle auto-approve mode' },
236
+ { command: 'voice', description: 'Toggle spoken voice replies' },
237
+ { command: 'tools', description: 'List the tools the active session can call' },
238
+ { command: 'skills', description: 'List the discovered skills' },
239
+ { command: 'cancel', description: 'Abort the current turn' },
240
+ ];
241
+ const shared = session.commands
242
+ .listForChannel('telegram')
243
+ .map((c) => ({ command: c.name, description: c.description }));
244
+ const seen = new Set(shared.map((c) => c.command));
245
+ const merged = [...shared, ...LOCAL.filter((c) => !seen.has(c.command))]
246
+ .sort((a, b) => a.command.localeCompare(b.command))
247
+ // Telegram caps descriptions at 256 chars and rejects empties.
248
+ .map((c) => ({
249
+ command: c.command,
250
+ description: (c.description || c.command).slice(0, 256),
251
+ }));
252
+ try {
253
+ await bot.api.setMyCommands(merged);
254
+ } catch (err) {
255
+ logger?.warn?.('telegram setMyCommands failed', {
256
+ err: err instanceof Error ? err.message : String(err),
257
+ });
258
+ }
259
+ }