@tormentalabs/opencode-telegram-plugin 0.2.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 +674 -0
- package/README.md +222 -0
- package/package.json +34 -0
- package/src/bot.ts +143 -0
- package/src/config.ts +218 -0
- package/src/handlers/callbacks.ts +209 -0
- package/src/handlers/commands.ts +562 -0
- package/src/handlers/messages.ts +163 -0
- package/src/hooks/message.ts +448 -0
- package/src/hooks/permission.ts +81 -0
- package/src/hooks/session.ts +126 -0
- package/src/hooks/tool.ts +99 -0
- package/src/index.ts +395 -0
- package/src/state/mapping.ts +112 -0
- package/src/state/mode.ts +40 -0
- package/src/state/store.ts +167 -0
- package/src/utils/chunk.ts +186 -0
- package/src/utils/format.ts +120 -0
- package/src/utils/safeSend.ts +99 -0
- package/src/utils/throttle.ts +128 -0
- package/src/utils/typing.ts +30 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import type { Context } from "grammy";
|
|
2
|
+
import { InlineKeyboard } from "grammy";
|
|
3
|
+
import { getChatState, cleanupChatStream, registerCallback, type SelectedModel, type EffortLevel } from "../state/store.js";
|
|
4
|
+
import {
|
|
5
|
+
getActiveSessionId,
|
|
6
|
+
getMode,
|
|
7
|
+
attachSession,
|
|
8
|
+
detachSession,
|
|
9
|
+
startIndependentSession,
|
|
10
|
+
} from "../state/mode.js";
|
|
11
|
+
import { setMapping } from "../state/mapping.js";
|
|
12
|
+
import { safeSend } from "../utils/safeSend.js";
|
|
13
|
+
import { escapeHtml } from "../utils/format.js";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Client interface — only the methods used in this file
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
interface SessionSummary {
|
|
20
|
+
id: string;
|
|
21
|
+
title: string;
|
|
22
|
+
createdAt: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface OpenCodeClient {
|
|
26
|
+
session: {
|
|
27
|
+
list(): Promise<{ data: SessionSummary[] }>;
|
|
28
|
+
create(params: { body: { title: string } }): Promise<{ data: { id: string } }>;
|
|
29
|
+
abort(params: { path: { id: string } }): Promise<boolean>;
|
|
30
|
+
};
|
|
31
|
+
config: {
|
|
32
|
+
providers(): Promise<{
|
|
33
|
+
data: {
|
|
34
|
+
providers: Array<{
|
|
35
|
+
id: string;
|
|
36
|
+
name: string;
|
|
37
|
+
models: Record<string, { id: string; name: string }>;
|
|
38
|
+
}>;
|
|
39
|
+
};
|
|
40
|
+
}>;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let _client: OpenCodeClient | null = null;
|
|
45
|
+
|
|
46
|
+
export function setClient(client: unknown): void {
|
|
47
|
+
_client = client as OpenCodeClient;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getClient(): OpenCodeClient {
|
|
51
|
+
if (!_client) throw new Error("OpenCode client not initialized");
|
|
52
|
+
return _client;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Helpers
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
const HELP_TEXT = `
|
|
60
|
+
<b>OpenCode Telegram Bot</b> 🤖
|
|
61
|
+
|
|
62
|
+
<b>Session Management</b>
|
|
63
|
+
/attach [id] — Attach to an existing session (lists sessions if no ID given)
|
|
64
|
+
/detach — Detach from the current session
|
|
65
|
+
/new [title] — Create and attach to a new independent session
|
|
66
|
+
/switch [id] — Switch to a different session
|
|
67
|
+
/sessions — List all available sessions
|
|
68
|
+
|
|
69
|
+
<b>While in a Session</b>
|
|
70
|
+
Just send a message to prompt OpenCode
|
|
71
|
+
/abort — Abort the current running operation
|
|
72
|
+
|
|
73
|
+
<b>Model & Config</b>
|
|
74
|
+
/model — List available models
|
|
75
|
+
/model provider/id — Set active model (e.g. <code>/model anthropic/claude-sonnet-4-20250514</code>)
|
|
76
|
+
/model reset — Reset to default model
|
|
77
|
+
/effort [low|medium|high] — Set reasoning effort (default: high)
|
|
78
|
+
/status — Show current bot status
|
|
79
|
+
/help — Show this help message
|
|
80
|
+
`.trim();
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Returns sessions sorted newest-first, capped at `limit`.
|
|
84
|
+
*/
|
|
85
|
+
function sortedSessions(sessions: SessionSummary[], limit = 10): SessionSummary[] {
|
|
86
|
+
return [...sessions]
|
|
87
|
+
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
|
88
|
+
.slice(0, limit);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Builds an InlineKeyboard where each button attaches to a session.
|
|
93
|
+
*/
|
|
94
|
+
function buildSessionKeyboard(sessions: SessionSummary[]): InlineKeyboard {
|
|
95
|
+
const keyboard = new InlineKeyboard();
|
|
96
|
+
for (const session of sessions) {
|
|
97
|
+
const label = `${session.title || "Untitled"} (${session.id.slice(0, 8)}…)`;
|
|
98
|
+
const key = registerCallback("attach_session", { sessionId: session.id });
|
|
99
|
+
keyboard.text(label, key).row();
|
|
100
|
+
}
|
|
101
|
+
return keyboard;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Command handlers
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
export async function startCommand(ctx: Context): Promise<void> {
|
|
109
|
+
const chatId = ctx.chat?.id;
|
|
110
|
+
if (!chatId) return;
|
|
111
|
+
|
|
112
|
+
// Register the chat mapping (username may be undefined for users with no username)
|
|
113
|
+
setMapping(chatId, { username: ctx.from?.username });
|
|
114
|
+
|
|
115
|
+
await safeSend(() =>
|
|
116
|
+
ctx.reply(
|
|
117
|
+
`Welcome to <b>OpenCode Bot</b>! 🤖\n\n` +
|
|
118
|
+
`I give you Telegram access to OpenCode — an AI coding assistant.\n\n` +
|
|
119
|
+
`Send me any message to prompt OpenCode, or use /help to see all commands.`,
|
|
120
|
+
{ parse_mode: "HTML" },
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Auto-attach to the most recent session when enabled (default: on)
|
|
125
|
+
const autoAttach = process.env["TELEGRAM_AUTO_ATTACH"] !== "false";
|
|
126
|
+
if (!autoAttach) return;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const { data: sessions } = await getClient().session.list();
|
|
130
|
+
if (sessions.length === 0) return;
|
|
131
|
+
|
|
132
|
+
const latest = sortedSessions(sessions, 1)[0]!;
|
|
133
|
+
attachSession(chatId, latest.id);
|
|
134
|
+
|
|
135
|
+
await safeSend(() =>
|
|
136
|
+
ctx.reply(
|
|
137
|
+
`✅ Auto-attached to: <b>${escapeHtml(latest.title || "Untitled")}</b>\n` +
|
|
138
|
+
`<code>${escapeHtml(latest.id)}</code>`,
|
|
139
|
+
{ parse_mode: "HTML" },
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
} catch {
|
|
143
|
+
// Auto-attach failure is non-fatal — the user can attach manually
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function helpCommand(ctx: Context): Promise<void> {
|
|
148
|
+
await safeSend(() => ctx.reply(HELP_TEXT, { parse_mode: "HTML" }));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function attachCommand(ctx: Context): Promise<void> {
|
|
152
|
+
const chatId = ctx.chat?.id;
|
|
153
|
+
if (!chatId) return;
|
|
154
|
+
|
|
155
|
+
const sessionId = typeof ctx.match === "string" ? ctx.match.trim() : undefined;
|
|
156
|
+
|
|
157
|
+
if (sessionId) {
|
|
158
|
+
attachSession(chatId, sessionId);
|
|
159
|
+
await safeSend(() =>
|
|
160
|
+
ctx.reply(
|
|
161
|
+
`✅ Attached to session:\n<code>${escapeHtml(sessionId)}</code>`,
|
|
162
|
+
{ parse_mode: "HTML" },
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// No session ID provided — show a picker
|
|
169
|
+
try {
|
|
170
|
+
const { data: sessions } = await getClient().session.list();
|
|
171
|
+
|
|
172
|
+
if (sessions.length === 0) {
|
|
173
|
+
await safeSend(() =>
|
|
174
|
+
ctx.reply("No sessions found. Use /new to create one."),
|
|
175
|
+
);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const recent = sortedSessions(sessions);
|
|
180
|
+
const keyboard = buildSessionKeyboard(recent);
|
|
181
|
+
const note =
|
|
182
|
+
sessions.length > 10
|
|
183
|
+
? ` (showing 10 of ${sessions.length} most recent)`
|
|
184
|
+
: "";
|
|
185
|
+
|
|
186
|
+
await safeSend(() =>
|
|
187
|
+
ctx.reply(`Select a session to attach to${note}:`, {
|
|
188
|
+
reply_markup: keyboard,
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
} catch (err) {
|
|
192
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
193
|
+
await safeSend(() =>
|
|
194
|
+
ctx.reply(`❌ Failed to list sessions: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function detachCommand(ctx: Context): Promise<void> {
|
|
200
|
+
const chatId = ctx.chat?.id;
|
|
201
|
+
if (!chatId) return;
|
|
202
|
+
|
|
203
|
+
detachSession(chatId);
|
|
204
|
+
await safeSend(() =>
|
|
205
|
+
ctx.reply("🔌 Detached. Use /attach or /new to start a session."),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function newCommand(ctx: Context): Promise<void> {
|
|
210
|
+
const chatId = ctx.chat?.id;
|
|
211
|
+
if (!chatId) return;
|
|
212
|
+
|
|
213
|
+
const title =
|
|
214
|
+
(typeof ctx.match === "string" ? ctx.match.trim() : "") || "Telegram Session";
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const { data } = await getClient().session.create({ body: { title } });
|
|
218
|
+
startIndependentSession(chatId, data.id);
|
|
219
|
+
|
|
220
|
+
await safeSend(() =>
|
|
221
|
+
ctx.reply(
|
|
222
|
+
`✅ Created new session:\n` +
|
|
223
|
+
`<b>${escapeHtml(title)}</b>\n` +
|
|
224
|
+
`<code>${escapeHtml(data.id)}</code>`,
|
|
225
|
+
{ parse_mode: "HTML" },
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
230
|
+
await safeSend(() =>
|
|
231
|
+
ctx.reply(`❌ Failed to create session: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function sessionsCommand(ctx: Context): Promise<void> {
|
|
237
|
+
const chatId = ctx.chat?.id;
|
|
238
|
+
if (!chatId) return;
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const { data: sessions } = await getClient().session.list();
|
|
242
|
+
|
|
243
|
+
if (sessions.length === 0) {
|
|
244
|
+
await safeSend(() =>
|
|
245
|
+
ctx.reply("No sessions found. Use /new to create one."),
|
|
246
|
+
);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const activeId = getActiveSessionId(chatId);
|
|
251
|
+
const recent = sortedSessions(sessions);
|
|
252
|
+
|
|
253
|
+
const lines = recent.map((s, i) => {
|
|
254
|
+
const active = s.id === activeId ? " ✅" : "";
|
|
255
|
+
const title = escapeHtml(s.title || "Untitled");
|
|
256
|
+
const shortId = escapeHtml(s.id.slice(0, 12));
|
|
257
|
+
return `${i + 1}. <b>${title}</b>${active}\n <code>${shortId}…</code>`;
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const header =
|
|
261
|
+
sessions.length > 10
|
|
262
|
+
? `Showing 10 of ${sessions.length} sessions (most recent):\n\n`
|
|
263
|
+
: `<b>${sessions.length} session${sessions.length === 1 ? "" : "s"}:</b>\n\n`;
|
|
264
|
+
|
|
265
|
+
await safeSend(() =>
|
|
266
|
+
ctx.reply(header + lines.join("\n\n"), { parse_mode: "HTML" }),
|
|
267
|
+
);
|
|
268
|
+
} catch (err) {
|
|
269
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
270
|
+
await safeSend(() =>
|
|
271
|
+
ctx.reply(`❌ Failed to list sessions: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function switchCommand(ctx: Context): Promise<void> {
|
|
277
|
+
const chatId = ctx.chat?.id;
|
|
278
|
+
if (!chatId) return;
|
|
279
|
+
|
|
280
|
+
const sessionId = typeof ctx.match === "string" ? ctx.match.trim() : undefined;
|
|
281
|
+
|
|
282
|
+
if (sessionId) {
|
|
283
|
+
const mode = getMode(chatId);
|
|
284
|
+
// Preserve mode semantics: re-attach if attached, switch independent otherwise
|
|
285
|
+
if (mode === "attached") {
|
|
286
|
+
attachSession(chatId, sessionId);
|
|
287
|
+
} else {
|
|
288
|
+
startIndependentSession(chatId, sessionId);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
await safeSend(() =>
|
|
292
|
+
ctx.reply(
|
|
293
|
+
`✅ Switched to session:\n<code>${escapeHtml(sessionId)}</code>`,
|
|
294
|
+
{ parse_mode: "HTML" },
|
|
295
|
+
),
|
|
296
|
+
);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// No ID provided — show a picker (same UX as /attach)
|
|
301
|
+
try {
|
|
302
|
+
const { data: sessions } = await getClient().session.list();
|
|
303
|
+
|
|
304
|
+
if (sessions.length === 0) {
|
|
305
|
+
await safeSend(() =>
|
|
306
|
+
ctx.reply("No sessions found. Use /new to create one."),
|
|
307
|
+
);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const recent = sortedSessions(sessions);
|
|
312
|
+
const keyboard = buildSessionKeyboard(recent);
|
|
313
|
+
const note =
|
|
314
|
+
sessions.length > 10
|
|
315
|
+
? ` (showing 10 of ${sessions.length} most recent)`
|
|
316
|
+
: "";
|
|
317
|
+
|
|
318
|
+
await safeSend(() =>
|
|
319
|
+
ctx.reply(`Select a session to switch to${note}:`, {
|
|
320
|
+
reply_markup: keyboard,
|
|
321
|
+
}),
|
|
322
|
+
);
|
|
323
|
+
} catch (err) {
|
|
324
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
325
|
+
await safeSend(() =>
|
|
326
|
+
ctx.reply(`❌ Failed to list sessions: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export async function modelCommand(ctx: Context): Promise<void> {
|
|
332
|
+
const chatId = ctx.chat?.id;
|
|
333
|
+
if (!chatId) return;
|
|
334
|
+
|
|
335
|
+
const arg = typeof ctx.match === "string" ? ctx.match.trim() : "";
|
|
336
|
+
|
|
337
|
+
// /model reset — clear override
|
|
338
|
+
if (arg.toLowerCase() === "reset") {
|
|
339
|
+
getChatState(chatId).selectedModel = null;
|
|
340
|
+
await safeSend(() =>
|
|
341
|
+
ctx.reply("✅ Model reset to default.", { parse_mode: "HTML" }),
|
|
342
|
+
);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// /model provider/model-id — set model
|
|
347
|
+
if (arg && arg.includes("/")) {
|
|
348
|
+
const slashIdx = arg.indexOf("/");
|
|
349
|
+
const providerID = arg.substring(0, slashIdx);
|
|
350
|
+
const modelID = arg.substring(slashIdx + 1);
|
|
351
|
+
|
|
352
|
+
if (!providerID || !modelID) {
|
|
353
|
+
await safeSend(() =>
|
|
354
|
+
ctx.reply("Usage: <code>/model provider/model-id</code>\nExample: <code>/model anthropic/claude-sonnet-4-20250514</code>", { parse_mode: "HTML" }),
|
|
355
|
+
);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Validate provider exists, but allow any model ID (favorites may not be in models list)
|
|
360
|
+
try {
|
|
361
|
+
const { data } = await getClient().config.providers();
|
|
362
|
+
const providers = data?.providers ?? [];
|
|
363
|
+
const provider = providers.find((p) => p.id === providerID);
|
|
364
|
+
|
|
365
|
+
if (!provider) {
|
|
366
|
+
const available = providers.map((p) => p.id).join(", ");
|
|
367
|
+
await safeSend(() =>
|
|
368
|
+
ctx.reply(`❌ Unknown provider: <code>${escapeHtml(providerID)}</code>\nAvailable: ${available}`, { parse_mode: "HTML" }),
|
|
369
|
+
);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Try to resolve display name from models list, fall back to raw ID
|
|
374
|
+
const model = (provider.models ?? {})[modelID];
|
|
375
|
+
const displayName = model?.name ?? modelID;
|
|
376
|
+
|
|
377
|
+
const selected: SelectedModel = {
|
|
378
|
+
providerID,
|
|
379
|
+
modelID,
|
|
380
|
+
displayName,
|
|
381
|
+
};
|
|
382
|
+
getChatState(chatId).selectedModel = selected;
|
|
383
|
+
|
|
384
|
+
await safeSend(() =>
|
|
385
|
+
ctx.reply(
|
|
386
|
+
`✅ Model set to:\n<b>${escapeHtml(selected.displayName)}</b>\n<code>${escapeHtml(providerID)}/${escapeHtml(modelID)}</code>`,
|
|
387
|
+
{ parse_mode: "HTML" },
|
|
388
|
+
),
|
|
389
|
+
);
|
|
390
|
+
} catch (err) {
|
|
391
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
392
|
+
await safeSend(() =>
|
|
393
|
+
ctx.reply(`❌ Failed to validate model: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// /model (no args) — list available models
|
|
400
|
+
try {
|
|
401
|
+
const { data } = await getClient().config.providers();
|
|
402
|
+
const providers = data?.providers ?? [];
|
|
403
|
+
|
|
404
|
+
if (providers.length === 0) {
|
|
405
|
+
await safeSend(() => ctx.reply("No models configured."));
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Show current selection
|
|
410
|
+
const state = getChatState(chatId);
|
|
411
|
+
const currentLine = state.selectedModel
|
|
412
|
+
? `Current: <b>${escapeHtml(state.selectedModel.displayName)}</b> (<code>${escapeHtml(state.selectedModel.providerID)}/${escapeHtml(state.selectedModel.modelID)}</code>)`
|
|
413
|
+
: "Current: <i>default</i>";
|
|
414
|
+
|
|
415
|
+
// Sort providers alphabetically by display name
|
|
416
|
+
const sorted = [...providers].sort((a, b) =>
|
|
417
|
+
(a.name || a.id).localeCompare(b.name || b.id),
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
// Build per-provider blocks with models sorted alphabetically by name
|
|
421
|
+
const blocks: string[] = [];
|
|
422
|
+
for (const provider of sorted) {
|
|
423
|
+
const modelEntries = Object.entries(provider.models ?? {});
|
|
424
|
+
if (modelEntries.length === 0) continue;
|
|
425
|
+
|
|
426
|
+
const modelLines = modelEntries
|
|
427
|
+
.sort(([, a], [, b]) => (a.name ?? "").localeCompare(b.name ?? ""))
|
|
428
|
+
.map(
|
|
429
|
+
([id, model]) =>
|
|
430
|
+
` • <code>${escapeHtml(id)}</code> — ${escapeHtml(model.name ?? id)}`,
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
blocks.push(
|
|
434
|
+
`<b>${escapeHtml(provider.name || provider.id)}</b>\n${modelLines.join("\n")}`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (blocks.length === 0) {
|
|
439
|
+
await safeSend(() => ctx.reply("No models available."));
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Assemble output
|
|
444
|
+
const MAX_LEN = 4000;
|
|
445
|
+
let header = `<b>Available Models:</b>\n${currentLine}\n`;
|
|
446
|
+
header += `\nUse <code>/model provider/model-id</code> to set.\n`;
|
|
447
|
+
|
|
448
|
+
let current = header;
|
|
449
|
+
for (const block of blocks) {
|
|
450
|
+
if (current.length + block.length + 2 > MAX_LEN) {
|
|
451
|
+
await safeSend(() =>
|
|
452
|
+
ctx.reply(current, { parse_mode: "HTML" }),
|
|
453
|
+
);
|
|
454
|
+
current = "";
|
|
455
|
+
}
|
|
456
|
+
current += "\n" + block + "\n";
|
|
457
|
+
}
|
|
458
|
+
if (current.trim()) {
|
|
459
|
+
await safeSend(() =>
|
|
460
|
+
ctx.reply(current, { parse_mode: "HTML" }),
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
} catch (err) {
|
|
464
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
465
|
+
await safeSend(() =>
|
|
466
|
+
ctx.reply(`❌ Failed to fetch models: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export async function effortCommand(ctx: Context): Promise<void> {
|
|
472
|
+
const chatId = ctx.chat?.id;
|
|
473
|
+
if (!chatId) return;
|
|
474
|
+
|
|
475
|
+
const arg = typeof ctx.match === "string" ? ctx.match.trim().toLowerCase() : "";
|
|
476
|
+
const state = getChatState(chatId);
|
|
477
|
+
|
|
478
|
+
if (!arg) {
|
|
479
|
+
await safeSend(() =>
|
|
480
|
+
ctx.reply(
|
|
481
|
+
`Current effort: <b>${escapeHtml(state.effort)}</b>\n\nUsage: <code>/effort low|medium|high</code>`,
|
|
482
|
+
{ parse_mode: "HTML" },
|
|
483
|
+
),
|
|
484
|
+
);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const validEfforts: EffortLevel[] = ["low", "medium", "high"];
|
|
489
|
+
if (!validEfforts.includes(arg as EffortLevel)) {
|
|
490
|
+
await safeSend(() =>
|
|
491
|
+
ctx.reply(
|
|
492
|
+
`❌ Invalid effort level: <code>${escapeHtml(arg)}</code>\nValid: <code>low</code>, <code>medium</code>, <code>high</code>`,
|
|
493
|
+
{ parse_mode: "HTML" },
|
|
494
|
+
),
|
|
495
|
+
);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
state.effort = arg as EffortLevel;
|
|
500
|
+
const emoji = { low: "🔋", medium: "⚡", high: "🔥" }[state.effort];
|
|
501
|
+
await safeSend(() =>
|
|
502
|
+
ctx.reply(`${emoji} Effort set to: <b>${escapeHtml(state.effort)}</b>`, { parse_mode: "HTML" }),
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export async function statusCommand(ctx: Context): Promise<void> {
|
|
507
|
+
const chatId = ctx.chat?.id;
|
|
508
|
+
if (!chatId) return;
|
|
509
|
+
|
|
510
|
+
const mode = getMode(chatId);
|
|
511
|
+
const activeId = getActiveSessionId(chatId);
|
|
512
|
+
const state = getChatState(chatId);
|
|
513
|
+
|
|
514
|
+
const modeEmoji: Record<string, string> = {
|
|
515
|
+
attached: "🔗",
|
|
516
|
+
independent: "🆓",
|
|
517
|
+
detached: "🔌",
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const effortEmoji = { low: "🔋", medium: "⚡", high: "🔥" }[state.effort] ?? "❓";
|
|
521
|
+
const modelLine = state.selectedModel
|
|
522
|
+
? `<code>${escapeHtml(state.selectedModel.providerID)}/${escapeHtml(state.selectedModel.modelID)}</code>`
|
|
523
|
+
: "<i>default</i>";
|
|
524
|
+
|
|
525
|
+
const lines = [
|
|
526
|
+
`<b>Bot Status</b>`,
|
|
527
|
+
``,
|
|
528
|
+
`Mode: ${modeEmoji[mode] ?? "❓"} <b>${escapeHtml(mode)}</b>`,
|
|
529
|
+
activeId
|
|
530
|
+
? `Session: <code>${escapeHtml(activeId)}</code>`
|
|
531
|
+
: `Session: <i>none</i>`,
|
|
532
|
+
`Model: ${modelLine}`,
|
|
533
|
+
`Effort: ${effortEmoji} <b>${escapeHtml(state.effort)}</b>`,
|
|
534
|
+
`Stream: ${state.stream.state !== "IDLE" && state.stream.state !== "FINAL" ? "⏳ active" : "⬜ idle"}`,
|
|
535
|
+
];
|
|
536
|
+
|
|
537
|
+
await safeSend(() =>
|
|
538
|
+
ctx.reply(lines.join("\n"), { parse_mode: "HTML" }),
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export async function abortCommand(ctx: Context): Promise<void> {
|
|
543
|
+
const chatId = ctx.chat?.id;
|
|
544
|
+
if (!chatId) return;
|
|
545
|
+
|
|
546
|
+
const activeId = getActiveSessionId(chatId);
|
|
547
|
+
if (!activeId) {
|
|
548
|
+
await safeSend(() => ctx.reply("No active session to abort."));
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
await getClient().session.abort({ path: { id: activeId } });
|
|
554
|
+
cleanupChatStream(chatId);
|
|
555
|
+
await safeSend(() => ctx.reply("⛔ Aborted."));
|
|
556
|
+
} catch (err) {
|
|
557
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
558
|
+
await safeSend(() =>
|
|
559
|
+
ctx.reply(`❌ Failed to abort: ${escapeHtml(msg)}`, { parse_mode: "HTML" }),
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
}
|