opencode-telegram-bot 1.0.3 → 1.0.4

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/README.md CHANGED
@@ -107,6 +107,11 @@ These are handled directly by the Telegram bot:
107
107
  | `/export full` | Export with all details (thinking, costs, steps) |
108
108
  | `/verbose` | Toggle verbose mode (show thinking and tool calls in chat) |
109
109
  | `/verbose on|off` | Explicitly enable/disable verbose mode |
110
+ | `/model` | Show current model and usage hints |
111
+ | `/model <keyword>` | Search models by keyword |
112
+ | `/model <number>` | Switch to a model from the last search |
113
+ | `/model default` | Reset to the default model |
114
+ | `/usage` | Show token and cost usage for this session |
110
115
  | `/help` | Show available commands |
111
116
 
112
117
  ### Verbose Mode
@@ -129,6 +134,39 @@ Example with verbose mode on:
129
134
  Here's what I found in the auth module...
130
135
  ```
131
136
 
137
+ ### Model Switching
138
+
139
+ Use `/model` to search and switch models without typing long names:
140
+
141
+ ```
142
+ You: /model sonnet
143
+ Bot: Models matching "sonnet":
144
+ 1. claude-sonnet-4-5 (google-vertex-anthropic)
145
+ 2. claude-sonnet-4 (anthropic)
146
+
147
+ Use /model <number> to select.
148
+
149
+ You: /model 1
150
+ Bot: Switched to claude-sonnet-4-5 (google-vertex-anthropic)
151
+ ```
152
+
153
+ Other commands:
154
+
155
+ - `/model` shows the current model and usage hints
156
+ - `/model default` resets to the server default
157
+
158
+ ## Usage
159
+
160
+ Use `/usage` to see the current session's token counts and estimated cost:
161
+
162
+ ```
163
+ Session usage:
164
+ - Assistant responses: 4
165
+ - Tokens: 1200 total (input 600, output 500, reasoning 100)
166
+ - Cache: read 1200, write 80
167
+ - Cost: $0.0123
168
+ ```
169
+
132
170
  ### Session Export
133
171
 
134
172
  The `/export` command builds a markdown file from the current session and saves it to the directory where OpenCode is running. The file is also sent back to you as a Telegram document.
package/dist/app.d.ts CHANGED
@@ -1,6 +1,51 @@
1
+ interface OpencodeClientLike {
2
+ session: {
3
+ list: (options?: any) => Promise<any>;
4
+ create: (options: any) => Promise<any>;
5
+ update: (options: any) => Promise<any>;
6
+ delete: (options: any) => Promise<any>;
7
+ get: (options: any) => Promise<any>;
8
+ messages: (options: any) => Promise<any>;
9
+ command: (options: any) => Promise<any>;
10
+ promptAsync: (options: any) => Promise<any>;
11
+ };
12
+ command: {
13
+ list: (options?: any) => Promise<any>;
14
+ };
15
+ provider: {
16
+ list: (options?: any) => Promise<any>;
17
+ };
18
+ path: {
19
+ get: (options?: any) => Promise<any>;
20
+ };
21
+ event: {
22
+ subscribe: (options?: any) => Promise<any>;
23
+ };
24
+ }
1
25
  interface StartOptions {
2
26
  url: string;
3
27
  model?: string;
28
+ launch?: boolean;
29
+ client?: OpencodeClientLike;
30
+ botFactory?: (token: string) => TelegramBot;
31
+ sessionsFilePath?: string;
32
+ }
33
+ interface TelegramBot {
34
+ use: (fn: (ctx: any, next: () => Promise<void>) => Promise<void> | void) => void;
35
+ start: (fn: (ctx: any) => void | Promise<void>) => void;
36
+ help: (fn: (ctx: any) => void | Promise<void>) => void;
37
+ command: (command: string, fn: (ctx: any) => void | Promise<void>) => void;
38
+ on: (event: string, fn: (ctx: any) => void | Promise<void>) => void;
39
+ launch: () => Promise<void>;
40
+ stop: (reason?: string) => void;
41
+ telegram: {
42
+ sendMessage: (chatId: number, text: string, options?: Record<string, unknown>) => Promise<void>;
43
+ deleteMessage: (chatId: number, messageId: number) => Promise<void>;
44
+ sendDocument: (chatId: number, file: {
45
+ source: Buffer;
46
+ filename: string;
47
+ }) => Promise<void>;
48
+ };
4
49
  }
5
- export declare function startTelegram(options: StartOptions): Promise<void>;
50
+ export declare function startTelegram(options: StartOptions): Promise<TelegramBot>;
6
51
  export {};
package/dist/index.js CHANGED
@@ -18584,8 +18584,9 @@ async function startTelegram(options) {
18584
18584
  else {
18585
18585
  console.log(`[Telegram] Bot is restricted to user ID: ${authorizedUserId}`);
18586
18586
  }
18587
+ const sessionsFile = options.sessionsFilePath || SESSIONS_FILE;
18587
18588
  // Initialize OpenCode client
18588
- const client = client_createOpencodeClient({ baseUrl: url });
18589
+ const client = options.client || client_createOpencodeClient({ baseUrl: url });
18589
18590
  // Verify connection to the OpenCode server and fetch available commands
18590
18591
  const opencodeCommands = new Set();
18591
18592
  let projectDirectory = "";
@@ -18615,7 +18616,7 @@ async function startTelegram(options) {
18615
18616
  }
18616
18617
  // Telegram-only commands that should not be forwarded to OpenCode
18617
18618
  const telegramCommands = new Set([
18618
- "start", "help", "new", "sessions", "switch", "title", "delete", "export", "verbose",
18619
+ "start", "help", "new", "sessions", "switch", "title", "delete", "export", "verbose", "model", "usage",
18619
18620
  ]);
18620
18621
  // Map of chatId -> sessionId for the active session per chat
18621
18622
  const chatSessions = new Map();
@@ -18623,6 +18624,10 @@ async function startTelegram(options) {
18623
18624
  const knownSessionIds = new Set();
18624
18625
  // Set of chatIds with verbose mode enabled
18625
18626
  const chatVerboseMode = new Set();
18627
+ // Map of chatId -> model override (provider/model)
18628
+ const chatModelOverride = new Map();
18629
+ // Map of chatId -> last search results (in-memory only)
18630
+ const chatModelSearchResults = new Map();
18626
18631
  /**
18627
18632
  * Save the chat-to-session mapping and known session IDs to disk.
18628
18633
  */
@@ -18632,8 +18637,9 @@ async function startTelegram(options) {
18632
18637
  active: Object.fromEntries(chatSessions),
18633
18638
  known: [...knownSessionIds],
18634
18639
  verbose: [...chatVerboseMode],
18640
+ models: Object.fromEntries(chatModelOverride),
18635
18641
  };
18636
- (0,external_node_fs_.writeFileSync)(SESSIONS_FILE, JSON.stringify(data, null, 2));
18642
+ (0,external_node_fs_.writeFileSync)(sessionsFile, JSON.stringify(data, null, 2));
18637
18643
  }
18638
18644
  catch (err) {
18639
18645
  console.error("[Telegram] Failed to save sessions file:", err);
@@ -18648,21 +18654,23 @@ async function startTelegram(options) {
18648
18654
  let storedActive = {};
18649
18655
  let storedKnown = [];
18650
18656
  let storedVerbose = [];
18651
- if ((0,external_node_fs_.existsSync)(SESSIONS_FILE)) {
18657
+ let storedModels = {};
18658
+ if ((0,external_node_fs_.existsSync)(sessionsFile)) {
18652
18659
  try {
18653
- const raw = (0,external_node_fs_.readFileSync)(SESSIONS_FILE, "utf-8");
18660
+ const raw = (0,external_node_fs_.readFileSync)(sessionsFile, "utf-8");
18654
18661
  const parsed = JSON.parse(raw);
18655
18662
  if (parsed.active && typeof parsed.active === "object") {
18656
18663
  // New format: { active: {...}, known: [...], verbose: [...] }
18657
18664
  storedActive = parsed.active;
18658
18665
  storedKnown = parsed.known || [];
18659
18666
  storedVerbose = parsed.verbose || [];
18667
+ storedModels = parsed.models || {};
18660
18668
  }
18661
18669
  else {
18662
18670
  // Old format: flat { chatId: sessionId }
18663
18671
  storedActive = parsed;
18664
18672
  }
18665
- console.log(`[Telegram] Loaded ${Object.keys(storedActive).length} active session(s) and ${storedKnown.length} known session(s) from ${SESSIONS_FILE}`);
18673
+ console.log(`[Telegram] Loaded ${Object.keys(storedActive).length} active session(s) and ${storedKnown.length} known session(s) from ${sessionsFile}`);
18666
18674
  }
18667
18675
  catch (err) {
18668
18676
  console.warn("[Telegram] Failed to parse sessions file:", err);
@@ -18675,6 +18683,12 @@ async function startTelegram(options) {
18675
18683
  if (storedVerbose.length > 0) {
18676
18684
  console.log(`[Telegram] Restored verbose mode for ${storedVerbose.length} chat(s)`);
18677
18685
  }
18686
+ // Restore model overrides
18687
+ for (const [chatId, modelId] of Object.entries(storedModels)) {
18688
+ if (modelId) {
18689
+ chatModelOverride.set(chatId, modelId);
18690
+ }
18691
+ }
18678
18692
  // Fetch all server sessions once for validation and fallback matching
18679
18693
  let serverSessions = [];
18680
18694
  try {
@@ -19010,7 +19024,9 @@ async function startTelegram(options) {
19010
19024
  }
19011
19025
  }
19012
19026
  // Initialize Telegram bot
19013
- const bot = new lib.Telegraf(token);
19027
+ const bot = options.botFactory
19028
+ ? options.botFactory(token)
19029
+ : new lib.Telegraf(token);
19014
19030
  // Middleware to check if the user is authorized
19015
19031
  bot.use((ctx, next) => {
19016
19032
  if (!authorizedUserId) {
@@ -19039,6 +19055,8 @@ async function startTelegram(options) {
19039
19055
  "/export full - Export with all details (thinking, costs, steps)\n" +
19040
19056
  "/verbose - Toggle verbose mode (show thinking and tool calls)\n" +
19041
19057
  "/verbose on|off - Set verbose mode explicitly\n" +
19058
+ "/model - Show or search available models\n" +
19059
+ "/usage - Show token and cost usage for this session\n" +
19042
19060
  "/help - Show this help message\n";
19043
19061
  if (opencodeCommands.size > 0) {
19044
19062
  msg +=
@@ -19060,6 +19078,8 @@ async function startTelegram(options) {
19060
19078
  "/export full - Export with all details (thinking, costs, steps)\n" +
19061
19079
  "/verbose - Toggle verbose mode (show thinking and tool calls)\n" +
19062
19080
  "/verbose on|off - Set verbose mode explicitly\n" +
19081
+ "/model - Show or search available models\n" +
19082
+ "/usage - Show token and cost usage for this session\n" +
19063
19083
  "/help - Show this help message\n";
19064
19084
  if (opencodeCommands.size > 0) {
19065
19085
  msg +=
@@ -19081,9 +19101,10 @@ async function startTelegram(options) {
19081
19101
  */
19082
19102
  async function getKnownSessions() {
19083
19103
  const list = await client.session.list();
19084
- if (!list.data || list.data.length === 0)
19104
+ const data = (list.data || []);
19105
+ if (data.length === 0)
19085
19106
  return [];
19086
- return list.data
19107
+ return data
19087
19108
  .filter((s) => knownSessionIds.has(s.id))
19088
19109
  .sort((a, b) => b.time.updated - a.time.updated);
19089
19110
  }
@@ -19245,6 +19266,144 @@ async function startTelegram(options) {
19245
19266
  ? "Verbose mode enabled. Responses will include thinking and tool calls."
19246
19267
  : "Verbose mode disabled. Responses will only show the assistant's text.");
19247
19268
  });
19269
+ /**
19270
+ * Fetch and search available models from connected providers.
19271
+ */
19272
+ async function searchModels(keyword) {
19273
+ const list = await client.provider.list();
19274
+ if (list.error || !list.data) {
19275
+ throw new Error(`Failed to list providers: ${JSON.stringify(list.error)}`);
19276
+ }
19277
+ const connected = new Set(list.data.connected || []);
19278
+ const results = [];
19279
+ const query = keyword.toLowerCase();
19280
+ for (const provider of list.data.all || []) {
19281
+ if (!connected.has(provider.id))
19282
+ continue;
19283
+ const providerName = (provider.name || provider.id).toLowerCase();
19284
+ const models = provider.models;
19285
+ for (const [modelID, model] of Object.entries(models || {})) {
19286
+ const modelName = ((model && model.name) || modelID).toLowerCase();
19287
+ if (modelID.toLowerCase().includes(query) ||
19288
+ modelName.includes(query) ||
19289
+ providerName.includes(query)) {
19290
+ const displayName = `${(model && model.name) || modelID} (${provider.id})`;
19291
+ results.push({ providerID: provider.id, modelID, displayName });
19292
+ }
19293
+ }
19294
+ }
19295
+ return results;
19296
+ }
19297
+ // Handle /model command
19298
+ // Usage: /model, /model <keyword>, /model <number>, /model default
19299
+ bot.command("model", async (ctx) => {
19300
+ const chatId = ctx.chat.id.toString();
19301
+ const args = ctx.message.text.replace(/^\/model\s*/, "").trim();
19302
+ if (!args) {
19303
+ const current = chatModelOverride.get(chatId) || model || "server default";
19304
+ await ctx.reply(`Current model: ${current}\n\n` +
19305
+ "Use /model <keyword> to search available models.\n" +
19306
+ "Use /model default to reset to the default model.");
19307
+ return;
19308
+ }
19309
+ if (args.toLowerCase() === "default") {
19310
+ chatModelOverride.delete(chatId);
19311
+ saveSessions();
19312
+ await ctx.reply("Model reset to the default model.");
19313
+ return;
19314
+ }
19315
+ const asNumber = Number.parseInt(args, 10);
19316
+ if (!Number.isNaN(asNumber) && String(asNumber) === args) {
19317
+ const results = chatModelSearchResults.get(chatId) || [];
19318
+ if (results.length === 0) {
19319
+ await ctx.reply("No recent search results. Use /model <keyword> first.");
19320
+ return;
19321
+ }
19322
+ if (asNumber < 1 || asNumber > results.length) {
19323
+ await ctx.reply("Invalid selection. Use /model <number> from the latest search results.");
19324
+ return;
19325
+ }
19326
+ const selection = results[asNumber - 1];
19327
+ const value = `${selection.providerID}/${selection.modelID}`;
19328
+ chatModelOverride.set(chatId, value);
19329
+ saveSessions();
19330
+ await ctx.reply(`Switched to ${selection.displayName}`);
19331
+ return;
19332
+ }
19333
+ try {
19334
+ const results = await searchModels(args);
19335
+ if (results.length === 0) {
19336
+ await ctx.reply(`No models found matching "${args}".`);
19337
+ return;
19338
+ }
19339
+ const limited = results.slice(0, 10);
19340
+ chatModelSearchResults.set(chatId, limited);
19341
+ let msg = `Models matching "${args}":\n\n`;
19342
+ for (const [index, item] of limited.entries()) {
19343
+ msg += `${index + 1}. ${item.displayName}\n`;
19344
+ }
19345
+ if (results.length > limited.length) {
19346
+ msg += `\nFound ${results.length} models. Refine your search to narrow the list.`;
19347
+ }
19348
+ msg += "\nUse /model <number> to select.";
19349
+ await ctx.reply(msg);
19350
+ }
19351
+ catch (err) {
19352
+ console.error("[Telegram] Error searching models:", err);
19353
+ await ctx.reply("Failed to list models. Try again later.");
19354
+ }
19355
+ });
19356
+ // Handle /usage command - show token and cost usage for current session
19357
+ bot.command("usage", async (ctx) => {
19358
+ const chatId = ctx.chat.id.toString();
19359
+ const sessionId = chatSessions.get(chatId);
19360
+ if (!sessionId) {
19361
+ await ctx.reply("No active session. Send a message first to create one.");
19362
+ return;
19363
+ }
19364
+ try {
19365
+ const messagesResult = await client.session.messages({
19366
+ path: { id: sessionId },
19367
+ });
19368
+ if (messagesResult.error || !messagesResult.data) {
19369
+ throw new Error(`Failed to get messages: ${JSON.stringify(messagesResult.error)}`);
19370
+ }
19371
+ let assistantCount = 0;
19372
+ let costTotal = 0;
19373
+ let inputTokens = 0;
19374
+ let outputTokens = 0;
19375
+ let reasoningTokens = 0;
19376
+ let cacheRead = 0;
19377
+ let cacheWrite = 0;
19378
+ for (const msg of messagesResult.data) {
19379
+ const info = msg.info;
19380
+ if (info.role !== "assistant")
19381
+ continue;
19382
+ assistantCount += 1;
19383
+ costTotal += info.cost || 0;
19384
+ if (info.tokens) {
19385
+ inputTokens += info.tokens.input || 0;
19386
+ outputTokens += info.tokens.output || 0;
19387
+ reasoningTokens += info.tokens.reasoning || 0;
19388
+ cacheRead += info.tokens.cache?.read || 0;
19389
+ cacheWrite += info.tokens.cache?.write || 0;
19390
+ }
19391
+ }
19392
+ const totalTokens = inputTokens + outputTokens + reasoningTokens;
19393
+ const lines = [
19394
+ `Session usage:`,
19395
+ `- Assistant responses: ${assistantCount}`,
19396
+ `- Tokens: ${totalTokens} total (input ${inputTokens}, output ${outputTokens}, reasoning ${reasoningTokens})`,
19397
+ `- Cache: read ${cacheRead}, write ${cacheWrite}`,
19398
+ `- Cost: $${costTotal.toFixed(4)}`,
19399
+ ];
19400
+ await ctx.reply(lines.join("\n"));
19401
+ }
19402
+ catch (err) {
19403
+ console.error("[Telegram] Error getting usage:", err);
19404
+ await ctx.reply("Failed to fetch usage. Try again later.");
19405
+ }
19406
+ });
19248
19407
  /**
19249
19408
  * Render a message part to markdown.
19250
19409
  * In default mode: only text and tool calls (name + input/output).
@@ -19531,8 +19690,9 @@ async function startTelegram(options) {
19531
19690
  const promptBody = {
19532
19691
  parts: [{ type: "text", text: userText }],
19533
19692
  };
19534
- if (model) {
19535
- const [providerID, ...modelParts] = model.split("/");
19693
+ const modelOverride = chatModelOverride.get(chatId) || model;
19694
+ if (modelOverride) {
19695
+ const [providerID, ...modelParts] = modelOverride.split("/");
19536
19696
  const modelID = modelParts.join("/");
19537
19697
  promptBody.model = { providerID, modelID };
19538
19698
  }
@@ -19545,18 +19705,21 @@ async function startTelegram(options) {
19545
19705
  await ctx.reply("Sorry, there was an error processing your request. Try again or use /new to start a fresh session.");
19546
19706
  }
19547
19707
  });
19548
- try {
19549
- // Start the bot
19550
- await bot.launch();
19551
- console.log("[Telegram] Bot is running");
19552
- // Enable graceful stop
19553
- process.once("SIGINT", () => bot.stop("SIGINT"));
19554
- process.once("SIGTERM", () => bot.stop("SIGTERM"));
19555
- }
19556
- catch (error) {
19557
- console.error("Unable to start the Telegram bot:", error);
19558
- throw error;
19708
+ if (options.launch !== false) {
19709
+ try {
19710
+ // Start the bot
19711
+ await bot.launch();
19712
+ console.log("[Telegram] Bot is running");
19713
+ // Enable graceful stop
19714
+ process.once("SIGINT", () => bot.stop("SIGINT"));
19715
+ process.once("SIGTERM", () => bot.stop("SIGTERM"));
19716
+ }
19717
+ catch (error) {
19718
+ console.error("Unable to start the Telegram bot:", error);
19719
+ throw error;
19720
+ }
19559
19721
  }
19722
+ return bot;
19560
19723
  }
19561
19724
  /**
19562
19725
  * Format a Unix timestamp (seconds) into a human-readable relative time.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-telegram-bot",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "type": "module",
5
5
  "description": "Telegram bot that forwards messages to an OpenCode agent",
6
6
  "main": "./dist/index.js",
@@ -14,7 +14,10 @@
14
14
  "start": "tsx src/index.ts",
15
15
  "serve": "opencode serve",
16
16
  "build": "ncc build src/index.ts --out dist",
17
- "prepublishOnly": "npm run build"
17
+ "prepublishOnly": "npm run build",
18
+ "test": "vitest",
19
+ "test:run": "vitest run",
20
+ "test:integration": "vitest run tests/integration.test.ts"
18
21
  },
19
22
  "keywords": [
20
23
  "opencode",
@@ -35,6 +38,7 @@
35
38
  "devDependencies": {
36
39
  "@vercel/ncc": "^0.38.3",
37
40
  "tsx": "^4.0.0",
38
- "@types/node": "^20.0.0"
41
+ "@types/node": "^20.0.0",
42
+ "vitest": "^2.1.5"
39
43
  }
40
44
  }