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 +60 -0
- package/dist/app.d.ts +46 -1
- package/dist/index.js +417 -41
- package/package.json +7 -3
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<
|
|
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)(
|
|
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
|
-
|
|
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)(
|
|
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 ${
|
|
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
|
-
|
|
18785
|
-
|
|
18786
|
-
|
|
18787
|
-
|
|
18788
|
-
|
|
18789
|
-
|
|
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.
|
|
18906
|
+
await bot.telegram.deleteMessage(chatId, processingMsgId);
|
|
18792
18907
|
}
|
|
18793
|
-
catch
|
|
18794
|
-
|
|
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 =
|
|
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
|
-
|
|
19104
|
+
const data = (list.data || []);
|
|
19105
|
+
if (data.length === 0)
|
|
18891
19106
|
return [];
|
|
18892
|
-
return
|
|
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
|
-
|
|
19315
|
-
|
|
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
|
|
19320
|
-
|
|
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
|
-
|
|
19336
|
-
|
|
19337
|
-
|
|
19338
|
-
|
|
19339
|
-
|
|
19340
|
-
|
|
19341
|
-
|
|
19342
|
-
|
|
19343
|
-
|
|
19344
|
-
|
|
19345
|
-
|
|
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.
|
|
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
|
}
|