opencode-telegram-bot 1.0.2 → 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
@@ -105,8 +105,68 @@ These are handled directly by the Telegram bot:
105
105
  | `/delete <id>` | Delete a session from the server |
106
106
  | `/export` | Export the current session as a markdown file |
107
107
  | `/export full` | Export with all details (thinking, costs, steps) |
108
+ | `/verbose` | Toggle verbose mode (show thinking and tool calls in chat) |
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 |
108
115
  | `/help` | Show available commands |
109
116
 
117
+ ### Verbose Mode
118
+
119
+ By default, the bot only shows the assistant's final text response. Use `/verbose` to toggle verbose mode (or `/verbose on|off` to set it explicitly), which also displays:
120
+
121
+ - **Thinking/reasoning** -- shown as plain text with a 🧠 prefix, truncated to 500 characters
122
+ - **Tool calls** -- shown as a compact one-line summary (e.g. `> read -- src/app.ts`)
123
+
124
+ Verbose mode is per-chat and persists across bot restarts. Use `/verbose` again to turn it off.
125
+
126
+ Example with verbose mode on:
127
+
128
+ ```
129
+ 🧠 Thinking: Let me analyze the authentication flow and check for potential issues...
130
+
131
+ ⚙️ grep -- pattern: "authenticate" in src/
132
+ ⚙️ read -- src/auth/handler.ts
133
+
134
+ Here's what I found in the auth module...
135
+ ```
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
+
110
170
  ### Session Export
111
171
 
112
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,16 +18584,24 @@ 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();
18592
+ let projectDirectory = "";
18591
18593
  try {
18592
18594
  const sessions = await client.session.list();
18593
18595
  if (sessions.error) {
18594
18596
  throw new Error(`Server returned error: ${JSON.stringify(sessions.error)}`);
18595
18597
  }
18596
18598
  console.log(`[Telegram] Connected to OpenCode server at ${url}`);
18599
+ // Fetch the project directory (needed for SSE event subscription)
18600
+ const pathResult = await client.path.get();
18601
+ if (pathResult.data?.directory) {
18602
+ projectDirectory = pathResult.data.directory;
18603
+ console.log(`[Telegram] Project directory: ${projectDirectory}`);
18604
+ }
18597
18605
  // Fetch available OpenCode commands
18598
18606
  const cmds = await client.command.list();
18599
18607
  if (cmds.data) {
@@ -18608,12 +18616,18 @@ async function startTelegram(options) {
18608
18616
  }
18609
18617
  // Telegram-only commands that should not be forwarded to OpenCode
18610
18618
  const telegramCommands = new Set([
18611
- "start", "help", "new", "sessions", "switch", "title", "delete", "export",
18619
+ "start", "help", "new", "sessions", "switch", "title", "delete", "export", "verbose", "model", "usage",
18612
18620
  ]);
18613
18621
  // Map of chatId -> sessionId for the active session per chat
18614
18622
  const chatSessions = new Map();
18615
18623
  // Set of all session IDs ever created/used by this bot (for filtering)
18616
18624
  const knownSessionIds = new Set();
18625
+ // Set of chatIds with verbose mode enabled
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();
18617
18631
  /**
18618
18632
  * Save the chat-to-session mapping and known session IDs to disk.
18619
18633
  */
@@ -18622,8 +18636,10 @@ async function startTelegram(options) {
18622
18636
  const data = {
18623
18637
  active: Object.fromEntries(chatSessions),
18624
18638
  known: [...knownSessionIds],
18639
+ verbose: [...chatVerboseMode],
18640
+ models: Object.fromEntries(chatModelOverride),
18625
18641
  };
18626
- (0,external_node_fs_.writeFileSync)(SESSIONS_FILE, JSON.stringify(data, null, 2));
18642
+ (0,external_node_fs_.writeFileSync)(sessionsFile, JSON.stringify(data, null, 2));
18627
18643
  }
18628
18644
  catch (err) {
18629
18645
  console.error("[Telegram] Failed to save sessions file:", err);
@@ -18637,25 +18653,42 @@ async function startTelegram(options) {
18637
18653
  // Load from file (supports both old flat format and new {active, known} format)
18638
18654
  let storedActive = {};
18639
18655
  let storedKnown = [];
18640
- if ((0,external_node_fs_.existsSync)(SESSIONS_FILE)) {
18656
+ let storedVerbose = [];
18657
+ let storedModels = {};
18658
+ if ((0,external_node_fs_.existsSync)(sessionsFile)) {
18641
18659
  try {
18642
- const raw = (0,external_node_fs_.readFileSync)(SESSIONS_FILE, "utf-8");
18660
+ const raw = (0,external_node_fs_.readFileSync)(sessionsFile, "utf-8");
18643
18661
  const parsed = JSON.parse(raw);
18644
18662
  if (parsed.active && typeof parsed.active === "object") {
18645
- // New format: { active: {...}, known: [...] }
18663
+ // New format: { active: {...}, known: [...], verbose: [...] }
18646
18664
  storedActive = parsed.active;
18647
18665
  storedKnown = parsed.known || [];
18666
+ storedVerbose = parsed.verbose || [];
18667
+ storedModels = parsed.models || {};
18648
18668
  }
18649
18669
  else {
18650
18670
  // Old format: flat { chatId: sessionId }
18651
18671
  storedActive = parsed;
18652
18672
  }
18653
- 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}`);
18654
18674
  }
18655
18675
  catch (err) {
18656
18676
  console.warn("[Telegram] Failed to parse sessions file:", err);
18657
18677
  }
18658
18678
  }
18679
+ // Restore verbose mode preferences
18680
+ for (const chatId of storedVerbose) {
18681
+ chatVerboseMode.add(chatId);
18682
+ }
18683
+ if (storedVerbose.length > 0) {
18684
+ console.log(`[Telegram] Restored verbose mode for ${storedVerbose.length} chat(s)`);
18685
+ }
18686
+ // Restore model overrides
18687
+ for (const [chatId, modelId] of Object.entries(storedModels)) {
18688
+ if (modelId) {
18689
+ chatModelOverride.set(chatId, modelId);
18690
+ }
18691
+ }
18659
18692
  // Fetch all server sessions once for validation and fallback matching
18660
18693
  let serverSessions = [];
18661
18694
  try {
@@ -18758,6 +18791,52 @@ async function startTelegram(options) {
18758
18791
  console.warn("[Telegram] Failed to auto-title session:", err);
18759
18792
  }
18760
18793
  }
18794
+ /**
18795
+ * Build a one-line summary for a tool call, picking the most meaningful input field.
18796
+ */
18797
+ function summarizeTool(tool, input) {
18798
+ if (!input)
18799
+ return tool;
18800
+ // Pick the most descriptive field based on common tool patterns
18801
+ const summaryField = input.filePath || input.path || input.command || input.pattern ||
18802
+ input.url || input.query || input.content || input.prompt ||
18803
+ input.description || input.name;
18804
+ if (summaryField && typeof summaryField === "string") {
18805
+ return `${tool} -- ${truncate(summaryField, 80)}`;
18806
+ }
18807
+ // For tools with a glob/include pattern
18808
+ if (input.include && typeof input.include === "string") {
18809
+ return `${tool} -- ${truncate(input.include, 80)}`;
18810
+ }
18811
+ return tool;
18812
+ }
18813
+ /**
18814
+ * Send a message to Telegram, with Markdown fallback.
18815
+ * When disableLinkPreview is true, link previews are suppressed (useful for verbose messages).
18816
+ */
18817
+ async function sendTelegramMessage(chatId, text, disableLinkPreview = false) {
18818
+ const options = {
18819
+ parse_mode: "Markdown",
18820
+ };
18821
+ if (disableLinkPreview) {
18822
+ options.link_preview_options = { is_disabled: true };
18823
+ }
18824
+ try {
18825
+ await bot.telegram.sendMessage(chatId, text, options);
18826
+ }
18827
+ catch {
18828
+ try {
18829
+ const fallbackOptions = {};
18830
+ if (disableLinkPreview) {
18831
+ fallbackOptions.link_preview_options = { is_disabled: true };
18832
+ }
18833
+ await bot.telegram.sendMessage(chatId, text, fallbackOptions);
18834
+ }
18835
+ catch (fallbackErr) {
18836
+ console.error("[Telegram] Fallback error:", fallbackErr);
18837
+ }
18838
+ }
18839
+ }
18761
18840
  /**
18762
18841
  * Extract text from response parts and send to Telegram chat.
18763
18842
  */
@@ -18781,20 +18860,145 @@ async function startTelegram(options) {
18781
18860
  // Send the response, splitting if needed (Telegram has a 4096 char limit)
18782
18861
  const chunks = splitMessage(responseText, 4096);
18783
18862
  for (const chunk of chunks) {
18784
- try {
18785
- await bot.telegram.sendMessage(chatId, chunk, {
18786
- parse_mode: "Markdown",
18787
- });
18788
- }
18789
- catch {
18863
+ await sendTelegramMessage(chatId, chunk);
18864
+ }
18865
+ }
18866
+ /**
18867
+ * Send a prompt with streaming: fires the prompt asynchronously, then listens
18868
+ * to SSE events to stream thinking and tool call updates to Telegram in real-time.
18869
+ * The final text response is sent when the session goes idle.
18870
+ */
18871
+ async function sendPromptStreaming(chatId, sessionId, promptBody, processingMsgId, verbose = false) {
18872
+ // Subscribe to SSE events before sending the prompt so we don't miss anything
18873
+ const abortController = new AbortController();
18874
+ // Subscribe to SSE events before sending the prompt so we don't miss anything
18875
+ const subscribeOptions = {
18876
+ signal: abortController.signal,
18877
+ };
18878
+ if (projectDirectory) {
18879
+ subscribeOptions.query = { directory: projectDirectory };
18880
+ }
18881
+ const { stream } = await client.event.subscribe(subscribeOptions);
18882
+ // Fire the prompt asynchronously (returns immediately)
18883
+ const asyncResult = await client.session.promptAsync({
18884
+ path: { id: sessionId },
18885
+ body: promptBody,
18886
+ });
18887
+ if (asyncResult.error) {
18888
+ abortController.abort();
18889
+ throw new Error(`Prompt failed: ${JSON.stringify(asyncResult.error)}`);
18890
+ }
18891
+ // Track state as events stream in
18892
+ let finalText = "";
18893
+ let processingMsgDeleted = false;
18894
+ const sentToolIds = new Set();
18895
+ const sentReasoningIds = new Set();
18896
+ // Buffer the latest reasoning text -- we send it when a non-reasoning part arrives
18897
+ // so we get the complete thinking block rather than a partial one
18898
+ let pendingReasoning = null;
18899
+ /**
18900
+ * Delete the "processing" message once, right before sending the first real output.
18901
+ */
18902
+ async function deleteProcessingMsg() {
18903
+ if (!processingMsgDeleted) {
18904
+ processingMsgDeleted = true;
18790
18905
  try {
18791
- await bot.telegram.sendMessage(chatId, chunk);
18906
+ await bot.telegram.deleteMessage(chatId, processingMsgId);
18792
18907
  }
18793
- catch (fallbackErr) {
18794
- console.error("[Telegram] Fallback error:", fallbackErr);
18908
+ catch {
18909
+ // Ignore
18795
18910
  }
18796
18911
  }
18797
18912
  }
18913
+ /**
18914
+ * Flush any buffered reasoning to Telegram as a spoiler message.
18915
+ */
18916
+ async function flushReasoning() {
18917
+ if (verbose && pendingReasoning && !sentReasoningIds.has(pendingReasoning.id)) {
18918
+ sentReasoningIds.add(pendingReasoning.id);
18919
+ await deleteProcessingMsg();
18920
+ const truncated = truncate(pendingReasoning.text.replace(/\n/g, " "), 500);
18921
+ await sendTelegramMessage(chatId, `\u{1F9E0} Thinking: ${truncated}`, true);
18922
+ }
18923
+ pendingReasoning = null;
18924
+ }
18925
+ try {
18926
+ for await (const event of stream) {
18927
+ const ev = event;
18928
+ if (ev.type === "message.part.updated" && ev.properties) {
18929
+ const part = ev.properties.part;
18930
+ // Only process events for our session
18931
+ if (part.sessionID !== sessionId)
18932
+ continue;
18933
+ const partText = part.text;
18934
+ if (part.type === "reasoning" && part.id) {
18935
+ // Buffer reasoning -- keep updating with the latest full text
18936
+ if (partText) {
18937
+ pendingReasoning = { id: part.id, text: partText };
18938
+ }
18939
+ }
18940
+ else if (part.type === "tool" && part.id) {
18941
+ // A tool part arrived -- flush any pending reasoning first
18942
+ await flushReasoning();
18943
+ if (verbose) {
18944
+ const state = part.state;
18945
+ // Only send tool messages once they have a completed/error status
18946
+ if (state &&
18947
+ (state.status === "completed" || state.status === "error") &&
18948
+ !sentToolIds.has(part.id)) {
18949
+ sentToolIds.add(part.id);
18950
+ await deleteProcessingMsg();
18951
+ const tool = part.tool;
18952
+ const summary = summarizeTool(tool, state.input);
18953
+ let line = `\u{2699}\u{FE0F} ${summary}`;
18954
+ if (state.status === "error") {
18955
+ line += ` \u{274C}`;
18956
+ }
18957
+ await sendTelegramMessage(chatId, line, true);
18958
+ }
18959
+ }
18960
+ }
18961
+ else if (part.type === "text") {
18962
+ // A text part arrived -- flush any pending reasoning first
18963
+ await flushReasoning();
18964
+ // Accumulate text for the final response (text parts carry the full text, not deltas)
18965
+ const text = part.text;
18966
+ if (text) {
18967
+ finalText = text;
18968
+ }
18969
+ }
18970
+ }
18971
+ else if (ev.type === "session.idle" && ev.properties) {
18972
+ const idleSessionId = ev.properties.sessionID;
18973
+ if (idleSessionId === sessionId) {
18974
+ // Flush any remaining reasoning before finishing
18975
+ await flushReasoning();
18976
+ break;
18977
+ }
18978
+ }
18979
+ else if (ev.type === "session.error" && ev.properties) {
18980
+ const errorSessionId = ev.properties.sessionID;
18981
+ if (errorSessionId === sessionId) {
18982
+ const error = ev.properties.error;
18983
+ const errorMsg = error?.data?.message || "Unknown error";
18984
+ throw new Error(`Session error: ${errorMsg}`);
18985
+ }
18986
+ }
18987
+ }
18988
+ }
18989
+ finally {
18990
+ abortController.abort();
18991
+ }
18992
+ // Delete the processing message if it hasn't been deleted yet (non-verbose mode)
18993
+ await deleteProcessingMsg();
18994
+ // Send the final text response
18995
+ if (!finalText) {
18996
+ finalText = "The agent returned an empty response.";
18997
+ }
18998
+ const chunks = splitMessage(finalText, 4096);
18999
+ for (const chunk of chunks) {
19000
+ await sendTelegramMessage(chatId, chunk);
19001
+ }
18798
19002
  }
18799
19003
  /**
18800
19004
  * Handle session errors - clear invalid sessions.
@@ -18820,7 +19024,9 @@ async function startTelegram(options) {
18820
19024
  }
18821
19025
  }
18822
19026
  // Initialize Telegram bot
18823
- const bot = new lib.Telegraf(token);
19027
+ const bot = options.botFactory
19028
+ ? options.botFactory(token)
19029
+ : new lib.Telegraf(token);
18824
19030
  // Middleware to check if the user is authorized
18825
19031
  bot.use((ctx, next) => {
18826
19032
  if (!authorizedUserId) {
@@ -18847,6 +19053,10 @@ async function startTelegram(options) {
18847
19053
  "/delete <number> - Delete a session\n" +
18848
19054
  "/export - Export the current session as a markdown file\n" +
18849
19055
  "/export full - Export with all details (thinking, costs, steps)\n" +
19056
+ "/verbose - Toggle verbose mode (show thinking and tool calls)\n" +
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" +
18850
19060
  "/help - Show this help message\n";
18851
19061
  if (opencodeCommands.size > 0) {
18852
19062
  msg +=
@@ -18866,6 +19076,10 @@ async function startTelegram(options) {
18866
19076
  "/delete <number> - Delete a session\n" +
18867
19077
  "/export - Export the current session as a markdown file\n" +
18868
19078
  "/export full - Export with all details (thinking, costs, steps)\n" +
19079
+ "/verbose - Toggle verbose mode (show thinking and tool calls)\n" +
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" +
18869
19083
  "/help - Show this help message\n";
18870
19084
  if (opencodeCommands.size > 0) {
18871
19085
  msg +=
@@ -18887,9 +19101,10 @@ async function startTelegram(options) {
18887
19101
  */
18888
19102
  async function getKnownSessions() {
18889
19103
  const list = await client.session.list();
18890
- if (!list.data || list.data.length === 0)
19104
+ const data = (list.data || []);
19105
+ if (data.length === 0)
18891
19106
  return [];
18892
- return list.data
19107
+ return data
18893
19108
  .filter((s) => knownSessionIds.has(s.id))
18894
19109
  .sort((a, b) => b.time.updated - a.time.updated);
18895
19110
  }
@@ -19025,6 +19240,170 @@ async function startTelegram(options) {
19025
19240
  await ctx.reply("Failed to delete session.");
19026
19241
  }
19027
19242
  });
19243
+ // Handle /verbose command - toggle verbose mode for this chat
19244
+ // Usage: /verbose, /verbose on, /verbose off
19245
+ bot.command("verbose", (ctx) => {
19246
+ const chatId = ctx.chat.id.toString();
19247
+ const args = ctx.message.text.replace(/^\/verbose\s*/, "").trim().toLowerCase();
19248
+ if (args === "on") {
19249
+ chatVerboseMode.add(chatId);
19250
+ }
19251
+ else if (args === "off") {
19252
+ chatVerboseMode.delete(chatId);
19253
+ }
19254
+ else {
19255
+ if (chatVerboseMode.has(chatId)) {
19256
+ chatVerboseMode.delete(chatId);
19257
+ }
19258
+ else {
19259
+ chatVerboseMode.add(chatId);
19260
+ }
19261
+ }
19262
+ saveSessions();
19263
+ const enabled = chatVerboseMode.has(chatId);
19264
+ console.log(`[Telegram] Verbose mode ${enabled ? "enabled" : "disabled"} for chat ${chatId}`);
19265
+ ctx.reply(enabled
19266
+ ? "Verbose mode enabled. Responses will include thinking and tool calls."
19267
+ : "Verbose mode disabled. Responses will only show the assistant's text.");
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
+ });
19028
19407
  /**
19029
19408
  * Render a message part to markdown.
19030
19409
  * In default mode: only text and tool calls (name + input/output).
@@ -19311,20 +19690,14 @@ async function startTelegram(options) {
19311
19690
  const promptBody = {
19312
19691
  parts: [{ type: "text", text: userText }],
19313
19692
  };
19314
- if (model) {
19315
- const [providerID, ...modelParts] = model.split("/");
19693
+ const modelOverride = chatModelOverride.get(chatId) || model;
19694
+ if (modelOverride) {
19695
+ const [providerID, ...modelParts] = modelOverride.split("/");
19316
19696
  const modelID = modelParts.join("/");
19317
19697
  promptBody.model = { providerID, modelID };
19318
19698
  }
19319
- const result = await client.session.prompt({
19320
- path: { id: sessionId },
19321
- body: promptBody,
19322
- });
19323
- if (result.error) {
19324
- throw new Error(`Prompt failed: ${JSON.stringify(result.error)}`);
19325
- }
19326
- const parts = (result.data?.parts || []);
19327
- await sendResponseToChat(ctx.chat.id, parts, processingMsg.message_id);
19699
+ const verbose = chatVerboseMode.has(chatId);
19700
+ await sendPromptStreaming(ctx.chat.id, sessionId, promptBody, processingMsg.message_id, verbose);
19328
19701
  }
19329
19702
  catch (err) {
19330
19703
  console.error("[Telegram] Error processing message:", err);
@@ -19332,18 +19705,21 @@ async function startTelegram(options) {
19332
19705
  await ctx.reply("Sorry, there was an error processing your request. Try again or use /new to start a fresh session.");
19333
19706
  }
19334
19707
  });
19335
- try {
19336
- // Start the bot
19337
- await bot.launch();
19338
- console.log("[Telegram] Bot is running");
19339
- // Enable graceful stop
19340
- process.once("SIGINT", () => bot.stop("SIGINT"));
19341
- process.once("SIGTERM", () => bot.stop("SIGTERM"));
19342
- }
19343
- catch (error) {
19344
- console.error("Unable to start the Telegram bot:", error);
19345
- 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
+ }
19346
19721
  }
19722
+ return bot;
19347
19723
  }
19348
19724
  /**
19349
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.2",
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
  }