@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.
- package/LICENSE +21 -0
- package/dist/approval.d.ts +37 -0
- package/dist/approval.d.ts.map +1 -0
- package/dist/approval.js +75 -0
- package/dist/approval.js.map +1 -0
- package/dist/channel/approval-prompt.d.ts +20 -0
- package/dist/channel/approval-prompt.d.ts.map +1 -0
- package/dist/channel/approval-prompt.js +34 -0
- package/dist/channel/approval-prompt.js.map +1 -0
- package/dist/channel/callback-handler.d.ts +32 -0
- package/dist/channel/callback-handler.d.ts.map +1 -0
- package/dist/channel/callback-handler.js +212 -0
- package/dist/channel/callback-handler.js.map +1 -0
- package/dist/channel/frame-pump.d.ts +58 -0
- package/dist/channel/frame-pump.d.ts.map +1 -0
- package/dist/channel/frame-pump.js +159 -0
- package/dist/channel/frame-pump.js.map +1 -0
- package/dist/channel/html.d.ts +24 -0
- package/dist/channel/html.d.ts.map +1 -0
- package/dist/channel/html.js +62 -0
- package/dist/channel/html.js.map +1 -0
- package/dist/channel/pairing-handler.d.ts +66 -0
- package/dist/channel/pairing-handler.d.ts.map +1 -0
- package/dist/channel/pairing-handler.js +147 -0
- package/dist/channel/pairing-handler.js.map +1 -0
- package/dist/channel/permission-prompt.d.ts +21 -0
- package/dist/channel/permission-prompt.d.ts.map +1 -0
- package/dist/channel/permission-prompt.js +36 -0
- package/dist/channel/permission-prompt.js.map +1 -0
- package/dist/channel/slash-handler.d.ts +47 -0
- package/dist/channel/slash-handler.d.ts.map +1 -0
- package/dist/channel/slash-handler.js +214 -0
- package/dist/channel/slash-handler.js.map +1 -0
- package/dist/channel/text-handler.d.ts +43 -0
- package/dist/channel/text-handler.d.ts.map +1 -0
- package/dist/channel/text-handler.js +116 -0
- package/dist/channel/text-handler.js.map +1 -0
- package/dist/channel/turn-runner.d.ts +40 -0
- package/dist/channel/turn-runner.d.ts.map +1 -0
- package/dist/channel/turn-runner.js +67 -0
- package/dist/channel/turn-runner.js.map +1 -0
- package/dist/channel/typing-indicator.d.ts +14 -0
- package/dist/channel/typing-indicator.d.ts.map +1 -0
- package/dist/channel/typing-indicator.js +29 -0
- package/dist/channel/typing-indicator.js.map +1 -0
- package/dist/channel/voice-handler.d.ts +42 -0
- package/dist/channel/voice-handler.d.ts.map +1 -0
- package/dist/channel/voice-handler.js +138 -0
- package/dist/channel/voice-handler.js.map +1 -0
- package/dist/channel.d.ts +118 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +478 -0
- package/dist/channel.js.map +1 -0
- package/dist/channel.test-d.d.ts +2 -0
- package/dist/channel.test-d.d.ts.map +1 -0
- package/dist/channel.test-d.js +5 -0
- package/dist/channel.test-d.js.map +1 -0
- package/dist/format.d.ts +38 -0
- package/dist/format.d.ts.map +1 -0
- package/dist/format.js +237 -0
- package/dist/format.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +304 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +37 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +42 -0
- package/dist/keys.js.map +1 -0
- package/dist/pair-flow.d.ts +18 -0
- package/dist/pair-flow.d.ts.map +1 -0
- package/dist/pair-flow.js +119 -0
- package/dist/pair-flow.js.map +1 -0
- package/dist/pairing.d.ts +90 -0
- package/dist/pairing.d.ts.map +1 -0
- package/dist/pairing.js +97 -0
- package/dist/pairing.js.map +1 -0
- package/dist/permission.d.ts +25 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/permission.js +49 -0
- package/dist/permission.js.map +1 -0
- package/dist/render.d.ts +68 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +439 -0
- package/dist/render.js.map +1 -0
- package/dist/setup-wizard.d.ts +21 -0
- package/dist/setup-wizard.d.ts.map +1 -0
- package/dist/setup-wizard.js +140 -0
- package/dist/setup-wizard.js.map +1 -0
- package/package.json +91 -0
- package/src/approval.test.ts +103 -0
- package/src/approval.ts +86 -0
- package/src/channel/approval-prompt.ts +46 -0
- package/src/channel/callback-handler.test.ts +188 -0
- package/src/channel/callback-handler.ts +256 -0
- package/src/channel/frame-pump.ts +176 -0
- package/src/channel/html.test.ts +40 -0
- package/src/channel/html.ts +60 -0
- package/src/channel/pairing-handler.ts +181 -0
- package/src/channel/permission-prompt.ts +51 -0
- package/src/channel/slash-handler.ts +259 -0
- package/src/channel/text-handler.test.ts +222 -0
- package/src/channel/text-handler.ts +174 -0
- package/src/channel/turn-runner.ts +102 -0
- package/src/channel/typing-indicator.ts +31 -0
- package/src/channel/voice-handler.test.ts +328 -0
- package/src/channel/voice-handler.ts +198 -0
- package/src/channel/voice-reply.test.ts +223 -0
- package/src/channel.test-d.ts +7 -0
- package/src/channel.ts +564 -0
- package/src/format.test.ts +159 -0
- package/src/format.ts +257 -0
- package/src/index.ts +347 -0
- package/src/keys.ts +52 -0
- package/src/pair-flow.ts +131 -0
- package/src/pairing.test.ts +113 -0
- package/src/pairing.ts +184 -0
- package/src/permission.test.ts +56 -0
- package/src/permission.ts +61 -0
- package/src/render.test.ts +350 -0
- package/src/render.ts +522 -0
- package/src/setup-wizard.ts +178 -0
- 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
|
+
}
|