opencode-telegram-bot 1.0.10 → 1.1.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/README.md CHANGED
@@ -112,6 +112,8 @@ These are handled directly by the Telegram bot:
112
112
  | `/model` | Show current model and usage hints |
113
113
  | `/model <keyword>` | Search models by keyword |
114
114
  | `/model default` | Reset to the default model |
115
+ | `/agent` | Show current agent and switch (plan, build, ...) |
116
+ | `/agent <name>` | Switch directly to a named agent |
115
117
  | `/usage` | Show token and cost usage for this session |
116
118
  | `/help` | Show available commands |
117
119
 
@@ -177,6 +179,33 @@ Other commands:
177
179
  - `/model` shows the current model and usage hints
178
180
  - `/model default` resets to the server default
179
181
 
182
+ ### Agent Switching
183
+
184
+ OpenCode supports multiple agents: **build** (default, full edit access), **plan** (read-only analysis), and others. Use `/agent` to see and switch between them:
185
+
186
+ ```
187
+ You: /agent
188
+ Bot: Current agent: build
189
+
190
+ Available agents:
191
+ - build (active) -- The default agent
192
+ - plan -- Plan mode. Disallows all edit tools.
193
+ - general -- General-purpose agent
194
+ - explore -- Codebase explorer
195
+
196
+ [build] [plan] [general] [explore]
197
+
198
+ You: [tap "plan"]
199
+ (button message is deleted)
200
+ Bot: Agent: plan | Model: claude-opus-4 | Verbose: off [pinned]
201
+ ```
202
+
203
+ ### Pinned Status Message
204
+
205
+ A pinned message at the top of the chat shows the current agent, model, and verbose mode. It updates automatically when any of these change — via `/agent`, `/model`, or `/verbose`. The pinned message is the only confirmation; no separate reply is sent.
206
+
207
+ Agent, model, and verbose selections are per-chat and persist across bot restarts.
208
+
180
209
  ## Usage
181
210
 
182
211
  Use `/usage` to see the current session's token counts and estimated cost:
package/dist/app.d.ts CHANGED
@@ -26,6 +26,9 @@ interface OpencodeClientLike {
26
26
  reply: (params: any, options?: any) => Promise<any>;
27
27
  reject: (params: any, options?: any) => Promise<any>;
28
28
  };
29
+ app: {
30
+ agents: (params?: any, options?: any) => Promise<any>;
31
+ };
29
32
  }
30
33
  interface StartOptions {
31
34
  url: string;
@@ -48,7 +51,7 @@ interface TelegramBot {
48
51
  }) => Promise<void>;
49
52
  stop: (reason?: string) => void;
50
53
  telegram: {
51
- sendMessage: (chatId: number, text: string, options?: Record<string, unknown>) => Promise<void>;
54
+ sendMessage: (chatId: number, text: string, options?: Record<string, unknown>) => Promise<any>;
52
55
  deleteMessage: (chatId: number, messageId: number) => Promise<void>;
53
56
  sendDocument: (chatId: number, file: {
54
57
  source: Buffer;
@@ -61,6 +64,9 @@ interface TelegramBot {
61
64
  command: string;
62
65
  description: string;
63
66
  }>) => Promise<unknown>;
67
+ pinChatMessage: (chatId: number, messageId: number, extra?: Record<string, unknown>) => Promise<void>;
68
+ unpinChatMessage: (chatId: number, messageId?: number) => Promise<void>;
69
+ editMessageText: (chatId: number | undefined, messageId: number | undefined, inlineMessageId: string | undefined, text: string, extra?: Record<string, unknown>) => Promise<void>;
64
70
  };
65
71
  }
66
72
  export declare function startTelegram(options: StartOptions): Promise<TelegramBot>;
package/dist/index.js CHANGED
@@ -19967,6 +19967,8 @@ async function startTelegram(options) {
19967
19967
  const sessionsFile = options.sessionsFilePath || SESSIONS_FILE;
19968
19968
  // Initialize OpenCode client
19969
19969
  const client = options.client || client_createOpencodeClient({ baseUrl: url });
19970
+ // Available agents fetched from OpenCode on startup
19971
+ let availableAgents = [];
19970
19972
  // Verify connection to the OpenCode server and fetch available commands
19971
19973
  const opencodeCommands = new Set();
19972
19974
  const opencodeCommandMenu = [];
@@ -20004,13 +20006,25 @@ async function startTelegram(options) {
20004
20006
  }
20005
20007
  console.log(`[Telegram] Available OpenCode commands: ${[...opencodeCommands].join(", ")}`);
20006
20008
  }
20009
+ // Fetch available agents
20010
+ const agentsResult = await client.app.agents({});
20011
+ if (agentsResult.data) {
20012
+ availableAgents = agentsResult.data
20013
+ .filter((a) => !a.hidden)
20014
+ .map((a) => ({
20015
+ name: a.name,
20016
+ description: a.description || "",
20017
+ mode: a.mode || "primary",
20018
+ }));
20019
+ console.log(`[Telegram] Available agents: ${availableAgents.map((a) => `${a.name} (${a.mode})`).join(", ")}`);
20020
+ }
20007
20021
  }
20008
20022
  catch (err) {
20009
20023
  throw new Error(`[Telegram] Failed to connect to OpenCode server at ${url}. Make sure it's running (npm run serve). Error: ${err}`);
20010
20024
  }
20011
20025
  // Telegram-only commands that should not be forwarded to OpenCode
20012
20026
  const telegramCommands = new Set([
20013
- "start", "help", "new", "sessions", "switch", "title", "delete", "export", "verbose", "model", "usage",
20027
+ "start", "help", "new", "sessions", "switch", "title", "delete", "export", "verbose", "model", "usage", "agent",
20014
20028
  ]);
20015
20029
  const telegramCommandMenu = [
20016
20030
  { command: "new", description: "Start a new conversation" },
@@ -20019,6 +20033,7 @@ async function startTelegram(options) {
20019
20033
  { command: "export", description: "Export session (/export full for details)" },
20020
20034
  { command: "verbose", description: "Toggle verbose mode" },
20021
20035
  { command: "model", description: "Search models (/model <keyword>)" },
20036
+ { command: "agent", description: "Switch agent (plan, build, ...)" },
20022
20037
  { command: "usage", description: "Show token and cost usage" },
20023
20038
  { command: "help", description: "Show available commands" },
20024
20039
  ];
@@ -20031,6 +20046,10 @@ async function startTelegram(options) {
20031
20046
  const chatVerboseMode = new Set();
20032
20047
  // Map of chatId -> model override (provider/model)
20033
20048
  const chatModelOverride = new Map();
20049
+ // Map of chatId -> agent override (e.g. "plan", "build")
20050
+ const chatAgentOverride = new Map();
20051
+ // Map of chatId -> pinned status message ID
20052
+ const chatPinnedStatusMsg = new Map();
20034
20053
  // Map of chatId -> last search results (in-memory only)
20035
20054
  const chatModelSearchResults = new Map();
20036
20055
  // Map of questionId -> pending question context (for forwarding OpenCode questions to Telegram)
@@ -20045,6 +20064,8 @@ async function startTelegram(options) {
20045
20064
  known: [...knownSessionIds],
20046
20065
  verbose: [...chatVerboseMode],
20047
20066
  models: Object.fromEntries(chatModelOverride),
20067
+ agents: Object.fromEntries(chatAgentOverride),
20068
+ pinnedStatus: Object.fromEntries(chatPinnedStatusMsg),
20048
20069
  };
20049
20070
  (0,external_node_fs_.writeFileSync)(sessionsFile, JSON.stringify(data, null, 2));
20050
20071
  }
@@ -20062,6 +20083,8 @@ async function startTelegram(options) {
20062
20083
  let storedKnown = [];
20063
20084
  let storedVerbose = [];
20064
20085
  let storedModels = {};
20086
+ let storedAgents = {};
20087
+ let storedPinnedStatus = {};
20065
20088
  if ((0,external_node_fs_.existsSync)(sessionsFile)) {
20066
20089
  try {
20067
20090
  const raw = (0,external_node_fs_.readFileSync)(sessionsFile, "utf-8");
@@ -20072,6 +20095,8 @@ async function startTelegram(options) {
20072
20095
  storedKnown = parsed.known || [];
20073
20096
  storedVerbose = parsed.verbose || [];
20074
20097
  storedModels = parsed.models || {};
20098
+ storedAgents = parsed.agents || {};
20099
+ storedPinnedStatus = parsed.pinnedStatus || {};
20075
20100
  }
20076
20101
  else {
20077
20102
  // Old format: flat { chatId: sessionId }
@@ -20096,6 +20121,18 @@ async function startTelegram(options) {
20096
20121
  chatModelOverride.set(chatId, modelId);
20097
20122
  }
20098
20123
  }
20124
+ // Restore agent overrides
20125
+ for (const [chatId, agentName] of Object.entries(storedAgents)) {
20126
+ if (agentName) {
20127
+ chatAgentOverride.set(chatId, agentName);
20128
+ }
20129
+ }
20130
+ // Restore pinned status message IDs
20131
+ for (const [chatId, msgId] of Object.entries(storedPinnedStatus)) {
20132
+ if (msgId) {
20133
+ chatPinnedStatusMsg.set(chatId, msgId);
20134
+ }
20135
+ }
20099
20136
  // Fetch all server sessions once for validation and fallback matching
20100
20137
  let serverSessions = [];
20101
20138
  try {
@@ -20214,6 +20251,10 @@ async function startTelegram(options) {
20214
20251
  const modelID = modelParts.join("/");
20215
20252
  promptBody.model = { providerID, modelID };
20216
20253
  }
20254
+ const agentOverride = chatAgentOverride.get(chatId);
20255
+ if (agentOverride) {
20256
+ promptBody.agent = agentOverride;
20257
+ }
20217
20258
  return promptBody;
20218
20259
  }
20219
20260
  function getChatIdFromContext(ctx) {
@@ -20787,7 +20828,12 @@ async function startTelegram(options) {
20787
20828
  if (!authorizedUserId) {
20788
20829
  return next();
20789
20830
  }
20790
- const userId = ctx.from?.id.toString();
20831
+ // Skip auth for service messages without a sender or from the bot itself
20832
+ // (e.g. pin notifications where ctx.from is the bot)
20833
+ if (!ctx.from || ctx.from.is_bot) {
20834
+ return next();
20835
+ }
20836
+ const userId = ctx.from.id.toString();
20791
20837
  if (userId === authorizedUserId) {
20792
20838
  return next();
20793
20839
  }
@@ -20810,6 +20856,7 @@ async function startTelegram(options) {
20810
20856
  "/verbose - Toggle verbose mode (show thinking and tool calls)\n" +
20811
20857
  "/verbose on|off - Set verbose mode explicitly\n" +
20812
20858
  "/model <keyword> - Search available models\n" +
20859
+ "/agent - Switch agent (plan, build, ...)\n" +
20813
20860
  "/usage - Show token and cost usage for this session\n" +
20814
20861
  "/help - Show this help message\n";
20815
20862
  const visibleOpenCodeCommands = getVisibleOpenCodeCommands();
@@ -20832,6 +20879,7 @@ async function startTelegram(options) {
20832
20879
  "/verbose - Toggle verbose mode (show thinking and tool calls)\n" +
20833
20880
  "/verbose on|off - Set verbose mode explicitly\n" +
20834
20881
  "/model <keyword> - Search available models\n" +
20882
+ "/agent - Switch agent (plan, build, ...)\n" +
20835
20883
  "/usage - Show token and cost usage for this session\n" +
20836
20884
  "/help - Show this help message\n";
20837
20885
  const visibleOpenCodeCommands = getVisibleOpenCodeCommands();
@@ -21084,9 +21132,7 @@ async function startTelegram(options) {
21084
21132
  saveSessions();
21085
21133
  const enabled = chatVerboseMode.has(chatId);
21086
21134
  console.log(`[Telegram] Verbose mode ${enabled ? "enabled" : "disabled"} for chat ${chatId}`);
21087
- ctx.reply(enabled
21088
- ? "Verbose mode enabled. Responses will include thinking and tool calls."
21089
- : "Verbose mode disabled. Responses will only show the assistant's text.");
21135
+ updatePinnedStatus(chatId, ctx.chat.id);
21090
21136
  });
21091
21137
  /**
21092
21138
  * Fetch and search available models from connected providers.
@@ -21186,7 +21232,15 @@ async function startTelegram(options) {
21186
21232
  const value = `${selection.providerID}/${selection.modelID}`;
21187
21233
  chatModelOverride.set(chatId, value);
21188
21234
  saveSessions();
21189
- await answerAndEdit(ctx, `Switched to ${selection.displayName}`);
21235
+ try {
21236
+ await ctx.answerCbQuery();
21237
+ }
21238
+ catch { /* ignore */ }
21239
+ try {
21240
+ await ctx.deleteMessage();
21241
+ }
21242
+ catch { /* ignore */ }
21243
+ updatePinnedStatus(chatId, Number(chatId));
21190
21244
  });
21191
21245
  bot.action("model_default", async (ctx) => {
21192
21246
  const chatId = getChatIdFromContext(ctx);
@@ -21194,7 +21248,144 @@ async function startTelegram(options) {
21194
21248
  return;
21195
21249
  chatModelOverride.delete(chatId);
21196
21250
  saveSessions();
21197
- await answerAndEdit(ctx, "Model reset to the default model.");
21251
+ try {
21252
+ await ctx.answerCbQuery();
21253
+ }
21254
+ catch { /* ignore */ }
21255
+ try {
21256
+ await ctx.deleteMessage();
21257
+ }
21258
+ catch { /* ignore */ }
21259
+ updatePinnedStatus(chatId, Number(chatId));
21260
+ });
21261
+ // --- Agent switching and pinned status message ---
21262
+ function getActiveAgent(chatId) {
21263
+ return chatAgentOverride.get(chatId) || "build";
21264
+ }
21265
+ function getActiveModelDisplay(chatId) {
21266
+ const m = chatModelOverride.get(chatId) || model;
21267
+ if (!m)
21268
+ return "default";
21269
+ const parts = m.split("/");
21270
+ return parts.length > 1 ? parts.slice(1).join("/") : m;
21271
+ }
21272
+ function buildStatusText(chatId) {
21273
+ const agent = getActiveAgent(chatId);
21274
+ const modelDisplay = getActiveModelDisplay(chatId);
21275
+ const verbose = chatVerboseMode.has(chatId) ? "on" : "off";
21276
+ return `Agent: *${agent}* | Model: ${modelDisplay} | Verbose: ${verbose}`;
21277
+ }
21278
+ function buildStatusKeyboard(chatId) {
21279
+ const active = getActiveAgent(chatId);
21280
+ // Show non-hidden agents as buttons, highlight the active one
21281
+ const buttons = availableAgents
21282
+ .filter((a) => a.mode === "primary" || a.mode === "subagent")
21283
+ .map((a) => ({
21284
+ text: a.name === active ? `[${a.name}]` : a.name,
21285
+ callback_data: `agent:${a.name}`,
21286
+ }));
21287
+ // Arrange in rows of 3
21288
+ const keyboard = [];
21289
+ for (let i = 0; i < buttons.length; i += 3) {
21290
+ keyboard.push(buttons.slice(i, i + 3));
21291
+ }
21292
+ return keyboard;
21293
+ }
21294
+ async function updatePinnedStatus(chatId, numericChatId) {
21295
+ const text = buildStatusText(chatId);
21296
+ // Delete the old status message to keep the chat clean
21297
+ const existingMsgId = chatPinnedStatusMsg.get(chatId);
21298
+ if (existingMsgId) {
21299
+ try {
21300
+ await bot.telegram.deleteMessage(numericChatId, existingMsgId);
21301
+ }
21302
+ catch {
21303
+ // Already deleted
21304
+ }
21305
+ chatPinnedStatusMsg.delete(chatId);
21306
+ }
21307
+ // Always send a fresh message and pin it. Editing in place doesn't
21308
+ // refresh the pinned bar on Android, so delete+send+pin is the only
21309
+ // reliable approach. The pinned message itself serves as the
21310
+ // confirmation — no separate reply is needed.
21311
+ try {
21312
+ const msg = await bot.telegram.sendMessage(numericChatId, text, {
21313
+ parse_mode: "Markdown",
21314
+ });
21315
+ const messageId = msg.message_id;
21316
+ if (messageId) {
21317
+ chatPinnedStatusMsg.set(chatId, messageId);
21318
+ saveSessions();
21319
+ try {
21320
+ await bot.telegram.pinChatMessage(numericChatId, messageId, {
21321
+ disable_notification: true,
21322
+ });
21323
+ }
21324
+ catch (pinErr) {
21325
+ console.warn("[Telegram] Failed to pin status message:", pinErr);
21326
+ }
21327
+ }
21328
+ }
21329
+ catch (err) {
21330
+ console.warn("[Telegram] Failed to send status message:", err);
21331
+ }
21332
+ }
21333
+ // Handle /agent command
21334
+ bot.command("agent", async (ctx) => {
21335
+ const chatId = ctx.chat.id.toString();
21336
+ const args = (ctx.message?.text || "").replace(/^\/agent\s*/i, "").trim();
21337
+ if (!args) {
21338
+ // Show current agent and list available ones
21339
+ const current = getActiveAgent(chatId);
21340
+ let msg = `Current agent: *${current}*\n\nAvailable agents:\n`;
21341
+ for (const a of availableAgents) {
21342
+ const marker = a.name === current ? " (active)" : "";
21343
+ const desc = a.description ? ` -- ${truncate(a.description, 80)}` : "";
21344
+ msg += `- *${a.name}*${marker}${desc}\n`;
21345
+ }
21346
+ msg += "\nTap a button or use `/agent <name>` to switch.";
21347
+ const keyboard = buildStatusKeyboard(chatId);
21348
+ await ctx.reply(msg, {
21349
+ parse_mode: "Markdown",
21350
+ reply_markup: { inline_keyboard: keyboard },
21351
+ });
21352
+ return;
21353
+ }
21354
+ // Direct switch by name
21355
+ const target = args.toLowerCase();
21356
+ const match = availableAgents.find((a) => a.name === target);
21357
+ if (!match) {
21358
+ await ctx.reply(`Unknown agent "${args}". Available: ${availableAgents.map((a) => a.name).join(", ")}`);
21359
+ return;
21360
+ }
21361
+ chatAgentOverride.set(chatId, match.name);
21362
+ saveSessions();
21363
+ await updatePinnedStatus(chatId, ctx.chat.id);
21364
+ });
21365
+ // Handle agent switch via inline button
21366
+ bot.action(/^agent:(.+)$/, async (ctx) => {
21367
+ const chatId = getChatIdFromContext(ctx);
21368
+ if (!chatId)
21369
+ return;
21370
+ const agentName = ctx.match?.[1];
21371
+ if (!agentName)
21372
+ return;
21373
+ const match = availableAgents.find((a) => a.name === agentName);
21374
+ if (!match) {
21375
+ await answerAndEdit(ctx, `Unknown agent: ${agentName}`);
21376
+ return;
21377
+ }
21378
+ chatAgentOverride.set(chatId, match.name);
21379
+ saveSessions();
21380
+ try {
21381
+ await ctx.answerCbQuery();
21382
+ }
21383
+ catch { /* ignore */ }
21384
+ try {
21385
+ await ctx.deleteMessage();
21386
+ }
21387
+ catch { /* ignore */ }
21388
+ await updatePinnedStatus(chatId, Number(chatId));
21198
21389
  });
21199
21390
  // Handle question answer callback (from OpenCode question.asked events)
21200
21391
  bot.action(/^qa:([^:]+):(\d+):(\d+)$/, async (ctx) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-telegram-bot",
3
- "version": "1.0.10",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "description": "Telegram bot that forwards messages to an OpenCode agent",
6
6
  "main": "./dist/index.js",