opencode-telegram-bot 1.0.3 → 1.0.5
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 +46 -1
- package/dist/app.d.ts +49 -1
- package/dist/index.js +275 -29
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -106,7 +106,12 @@ These are handled directly by the Telegram bot:
|
|
|
106
106
|
| `/export` | Export the current session as a markdown file |
|
|
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
|
-
| `/verbose on
|
|
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.
|
|
@@ -152,6 +190,13 @@ If you type an unknown command, the bot will reply with the list of available co
|
|
|
152
190
|
|
|
153
191
|
Any other text message is forwarded to the OpenCode agent as a prompt. Follow-up messages go into the same session, so the agent has full conversation context. Use `/new` when you want a fresh conversation.
|
|
154
192
|
|
|
193
|
+
### Files and Images
|
|
194
|
+
|
|
195
|
+
You can send files or images to the bot. They are forwarded to OpenCode as file parts (with optional caption text). Text-based files (including markdown) are sent as text instead of file parts for better model compatibility. Supported types:
|
|
196
|
+
|
|
197
|
+
- Photos sent via Telegram
|
|
198
|
+
- Documents (PDFs, images, etc.)
|
|
199
|
+
|
|
155
200
|
## Session Management
|
|
156
201
|
|
|
157
202
|
Sessions are never deleted automatically. You can have multiple sessions and switch between them.
|
package/dist/app.d.ts
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
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
|
+
getFileLink: (fileId: string) => Promise<{
|
|
49
|
+
toString(): string;
|
|
50
|
+
}>;
|
|
51
|
+
};
|
|
4
52
|
}
|
|
5
|
-
export declare function startTelegram(options: StartOptions): Promise<
|
|
53
|
+
export declare function startTelegram(options: StartOptions): Promise<TelegramBot>;
|
|
6
54
|
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)(
|
|
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
|
-
|
|
18657
|
+
let storedModels = {};
|
|
18658
|
+
if ((0,external_node_fs_.existsSync)(sessionsFile)) {
|
|
18652
18659
|
try {
|
|
18653
|
-
const raw = (0,external_node_fs_.readFileSync)(
|
|
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 ${
|
|
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 {
|
|
@@ -18777,6 +18791,45 @@ async function startTelegram(options) {
|
|
|
18777
18791
|
console.warn("[Telegram] Failed to auto-title session:", err);
|
|
18778
18792
|
}
|
|
18779
18793
|
}
|
|
18794
|
+
function buildPromptBody(chatId, parts) {
|
|
18795
|
+
const promptBody = { parts };
|
|
18796
|
+
const modelOverride = chatModelOverride.get(chatId) || model;
|
|
18797
|
+
if (modelOverride) {
|
|
18798
|
+
const [providerID, ...modelParts] = modelOverride.split("/");
|
|
18799
|
+
const modelID = modelParts.join("/");
|
|
18800
|
+
promptBody.model = { providerID, modelID };
|
|
18801
|
+
}
|
|
18802
|
+
return promptBody;
|
|
18803
|
+
}
|
|
18804
|
+
async function getTelegramFileUrl(fileId) {
|
|
18805
|
+
const link = await bot.telegram.getFileLink(fileId);
|
|
18806
|
+
return link.toString();
|
|
18807
|
+
}
|
|
18808
|
+
function isTextMime(mime) {
|
|
18809
|
+
const normalized = mime.toLowerCase();
|
|
18810
|
+
return (normalized.startsWith("text/") ||
|
|
18811
|
+
normalized === "application/json" ||
|
|
18812
|
+
normalized === "application/xml" ||
|
|
18813
|
+
normalized === "application/yaml" ||
|
|
18814
|
+
normalized === "application/x-yaml" ||
|
|
18815
|
+
normalized === "application/markdown");
|
|
18816
|
+
}
|
|
18817
|
+
async function fetchTelegramFileText(fileId, maxBytes = 200_000) {
|
|
18818
|
+
const url = await getTelegramFileUrl(fileId);
|
|
18819
|
+
const response = await fetch(url);
|
|
18820
|
+
if (!response.ok) {
|
|
18821
|
+
throw new Error(`Failed to fetch file: ${response.status}`);
|
|
18822
|
+
}
|
|
18823
|
+
const contentLength = response.headers.get("content-length");
|
|
18824
|
+
if (contentLength && Number(contentLength) > maxBytes) {
|
|
18825
|
+
throw new Error("File is too large to send as text.");
|
|
18826
|
+
}
|
|
18827
|
+
const buffer = await response.arrayBuffer();
|
|
18828
|
+
if (buffer.byteLength > maxBytes) {
|
|
18829
|
+
throw new Error("File is too large to send as text.");
|
|
18830
|
+
}
|
|
18831
|
+
return new TextDecoder("utf-8").decode(buffer);
|
|
18832
|
+
}
|
|
18780
18833
|
/**
|
|
18781
18834
|
* Build a one-line summary for a tool call, picking the most meaningful input field.
|
|
18782
18835
|
*/
|
|
@@ -18986,6 +19039,40 @@ async function startTelegram(options) {
|
|
|
18986
19039
|
await sendTelegramMessage(chatId, chunk);
|
|
18987
19040
|
}
|
|
18988
19041
|
}
|
|
19042
|
+
async function handleFileMessage(ctx, fileId, mime, filename, caption) {
|
|
19043
|
+
const chatId = ctx.chat.id.toString();
|
|
19044
|
+
try {
|
|
19045
|
+
const processingMsg = await ctx.reply("Processing your request...");
|
|
19046
|
+
const sessionId = await getOrCreateSession(chatId);
|
|
19047
|
+
const titleText = caption || filename || "File upload";
|
|
19048
|
+
await autoTitleSession(sessionId, titleText);
|
|
19049
|
+
const parts = [];
|
|
19050
|
+
if (caption) {
|
|
19051
|
+
parts.push({ type: "text", text: caption });
|
|
19052
|
+
}
|
|
19053
|
+
const normalizedMime = mime.toLowerCase();
|
|
19054
|
+
if (isTextMime(normalizedMime)) {
|
|
19055
|
+
const textContent = await fetchTelegramFileText(fileId);
|
|
19056
|
+
const header = filename ? `File: ${filename}` : "File";
|
|
19057
|
+
parts.push({
|
|
19058
|
+
type: "text",
|
|
19059
|
+
text: `${header}\n\n${textContent}`,
|
|
19060
|
+
});
|
|
19061
|
+
}
|
|
19062
|
+
else {
|
|
19063
|
+
const url = await getTelegramFileUrl(fileId);
|
|
19064
|
+
parts.push({ type: "file", mime: normalizedMime, filename, url });
|
|
19065
|
+
}
|
|
19066
|
+
const promptBody = buildPromptBody(chatId, parts);
|
|
19067
|
+
const verbose = chatVerboseMode.has(chatId);
|
|
19068
|
+
await sendPromptStreaming(ctx.chat.id, sessionId, promptBody, processingMsg.message_id, verbose);
|
|
19069
|
+
}
|
|
19070
|
+
catch (err) {
|
|
19071
|
+
console.error("[Telegram] Error processing file message:", err);
|
|
19072
|
+
await handleSessionError(chatId);
|
|
19073
|
+
await ctx.reply("Sorry, there was an error processing your request. Try again or use /new to start a fresh session.");
|
|
19074
|
+
}
|
|
19075
|
+
}
|
|
18989
19076
|
/**
|
|
18990
19077
|
* Handle session errors - clear invalid sessions.
|
|
18991
19078
|
*/
|
|
@@ -19010,7 +19097,9 @@ async function startTelegram(options) {
|
|
|
19010
19097
|
}
|
|
19011
19098
|
}
|
|
19012
19099
|
// Initialize Telegram bot
|
|
19013
|
-
const bot =
|
|
19100
|
+
const bot = options.botFactory
|
|
19101
|
+
? options.botFactory(token)
|
|
19102
|
+
: new lib.Telegraf(token);
|
|
19014
19103
|
// Middleware to check if the user is authorized
|
|
19015
19104
|
bot.use((ctx, next) => {
|
|
19016
19105
|
if (!authorizedUserId) {
|
|
@@ -19039,6 +19128,8 @@ async function startTelegram(options) {
|
|
|
19039
19128
|
"/export full - Export with all details (thinking, costs, steps)\n" +
|
|
19040
19129
|
"/verbose - Toggle verbose mode (show thinking and tool calls)\n" +
|
|
19041
19130
|
"/verbose on|off - Set verbose mode explicitly\n" +
|
|
19131
|
+
"/model - Show or search available models\n" +
|
|
19132
|
+
"/usage - Show token and cost usage for this session\n" +
|
|
19042
19133
|
"/help - Show this help message\n";
|
|
19043
19134
|
if (opencodeCommands.size > 0) {
|
|
19044
19135
|
msg +=
|
|
@@ -19060,6 +19151,8 @@ async function startTelegram(options) {
|
|
|
19060
19151
|
"/export full - Export with all details (thinking, costs, steps)\n" +
|
|
19061
19152
|
"/verbose - Toggle verbose mode (show thinking and tool calls)\n" +
|
|
19062
19153
|
"/verbose on|off - Set verbose mode explicitly\n" +
|
|
19154
|
+
"/model - Show or search available models\n" +
|
|
19155
|
+
"/usage - Show token and cost usage for this session\n" +
|
|
19063
19156
|
"/help - Show this help message\n";
|
|
19064
19157
|
if (opencodeCommands.size > 0) {
|
|
19065
19158
|
msg +=
|
|
@@ -19081,9 +19174,10 @@ async function startTelegram(options) {
|
|
|
19081
19174
|
*/
|
|
19082
19175
|
async function getKnownSessions() {
|
|
19083
19176
|
const list = await client.session.list();
|
|
19084
|
-
|
|
19177
|
+
const data = (list.data || []);
|
|
19178
|
+
if (data.length === 0)
|
|
19085
19179
|
return [];
|
|
19086
|
-
return
|
|
19180
|
+
return data
|
|
19087
19181
|
.filter((s) => knownSessionIds.has(s.id))
|
|
19088
19182
|
.sort((a, b) => b.time.updated - a.time.updated);
|
|
19089
19183
|
}
|
|
@@ -19245,6 +19339,161 @@ async function startTelegram(options) {
|
|
|
19245
19339
|
? "Verbose mode enabled. Responses will include thinking and tool calls."
|
|
19246
19340
|
: "Verbose mode disabled. Responses will only show the assistant's text.");
|
|
19247
19341
|
});
|
|
19342
|
+
/**
|
|
19343
|
+
* Fetch and search available models from connected providers.
|
|
19344
|
+
*/
|
|
19345
|
+
async function searchModels(keyword) {
|
|
19346
|
+
const list = await client.provider.list();
|
|
19347
|
+
if (list.error || !list.data) {
|
|
19348
|
+
throw new Error(`Failed to list providers: ${JSON.stringify(list.error)}`);
|
|
19349
|
+
}
|
|
19350
|
+
const connected = new Set(list.data.connected || []);
|
|
19351
|
+
const results = [];
|
|
19352
|
+
const query = keyword.toLowerCase();
|
|
19353
|
+
for (const provider of list.data.all || []) {
|
|
19354
|
+
if (!connected.has(provider.id))
|
|
19355
|
+
continue;
|
|
19356
|
+
const providerName = (provider.name || provider.id).toLowerCase();
|
|
19357
|
+
const models = provider.models;
|
|
19358
|
+
for (const [modelID, model] of Object.entries(models || {})) {
|
|
19359
|
+
const modelName = ((model && model.name) || modelID).toLowerCase();
|
|
19360
|
+
if (modelID.toLowerCase().includes(query) ||
|
|
19361
|
+
modelName.includes(query) ||
|
|
19362
|
+
providerName.includes(query)) {
|
|
19363
|
+
const displayName = `${(model && model.name) || modelID} (${provider.id})`;
|
|
19364
|
+
results.push({ providerID: provider.id, modelID, displayName });
|
|
19365
|
+
}
|
|
19366
|
+
}
|
|
19367
|
+
}
|
|
19368
|
+
return results;
|
|
19369
|
+
}
|
|
19370
|
+
// Handle /model command
|
|
19371
|
+
// Usage: /model, /model <keyword>, /model <number>, /model default
|
|
19372
|
+
bot.command("model", async (ctx) => {
|
|
19373
|
+
const chatId = ctx.chat.id.toString();
|
|
19374
|
+
const args = ctx.message.text.replace(/^\/model\s*/, "").trim();
|
|
19375
|
+
if (!args) {
|
|
19376
|
+
const current = chatModelOverride.get(chatId) || model || "server default";
|
|
19377
|
+
await ctx.reply(`Current model: ${current}\n\n` +
|
|
19378
|
+
"Use /model <keyword> to search available models.\n" +
|
|
19379
|
+
"Use /model default to reset to the default model.");
|
|
19380
|
+
return;
|
|
19381
|
+
}
|
|
19382
|
+
if (args.toLowerCase() === "default") {
|
|
19383
|
+
chatModelOverride.delete(chatId);
|
|
19384
|
+
saveSessions();
|
|
19385
|
+
await ctx.reply("Model reset to the default model.");
|
|
19386
|
+
return;
|
|
19387
|
+
}
|
|
19388
|
+
const asNumber = Number.parseInt(args, 10);
|
|
19389
|
+
if (!Number.isNaN(asNumber) && String(asNumber) === args) {
|
|
19390
|
+
const results = chatModelSearchResults.get(chatId) || [];
|
|
19391
|
+
if (results.length === 0) {
|
|
19392
|
+
await ctx.reply("No recent search results. Use /model <keyword> first.");
|
|
19393
|
+
return;
|
|
19394
|
+
}
|
|
19395
|
+
if (asNumber < 1 || asNumber > results.length) {
|
|
19396
|
+
await ctx.reply("Invalid selection. Use /model <number> from the latest search results.");
|
|
19397
|
+
return;
|
|
19398
|
+
}
|
|
19399
|
+
const selection = results[asNumber - 1];
|
|
19400
|
+
const value = `${selection.providerID}/${selection.modelID}`;
|
|
19401
|
+
chatModelOverride.set(chatId, value);
|
|
19402
|
+
saveSessions();
|
|
19403
|
+
await ctx.reply(`Switched to ${selection.displayName}`);
|
|
19404
|
+
return;
|
|
19405
|
+
}
|
|
19406
|
+
try {
|
|
19407
|
+
const results = await searchModels(args);
|
|
19408
|
+
if (results.length === 0) {
|
|
19409
|
+
await ctx.reply(`No models found matching "${args}".`);
|
|
19410
|
+
return;
|
|
19411
|
+
}
|
|
19412
|
+
const limited = results.slice(0, 10);
|
|
19413
|
+
chatModelSearchResults.set(chatId, limited);
|
|
19414
|
+
let msg = `Models matching "${args}":\n\n`;
|
|
19415
|
+
for (const [index, item] of limited.entries()) {
|
|
19416
|
+
msg += `${index + 1}. ${item.displayName}\n`;
|
|
19417
|
+
}
|
|
19418
|
+
if (results.length > limited.length) {
|
|
19419
|
+
msg += `\nFound ${results.length} models. Refine your search to narrow the list.`;
|
|
19420
|
+
}
|
|
19421
|
+
msg += "\nUse /model <number> to select.";
|
|
19422
|
+
await ctx.reply(msg);
|
|
19423
|
+
}
|
|
19424
|
+
catch (err) {
|
|
19425
|
+
console.error("[Telegram] Error searching models:", err);
|
|
19426
|
+
await ctx.reply("Failed to list models. Try again later.");
|
|
19427
|
+
}
|
|
19428
|
+
});
|
|
19429
|
+
// Handle /usage command - show token and cost usage for current session
|
|
19430
|
+
bot.command("usage", async (ctx) => {
|
|
19431
|
+
const chatId = ctx.chat.id.toString();
|
|
19432
|
+
const sessionId = chatSessions.get(chatId);
|
|
19433
|
+
if (!sessionId) {
|
|
19434
|
+
await ctx.reply("No active session. Send a message first to create one.");
|
|
19435
|
+
return;
|
|
19436
|
+
}
|
|
19437
|
+
try {
|
|
19438
|
+
const messagesResult = await client.session.messages({
|
|
19439
|
+
path: { id: sessionId },
|
|
19440
|
+
});
|
|
19441
|
+
if (messagesResult.error || !messagesResult.data) {
|
|
19442
|
+
throw new Error(`Failed to get messages: ${JSON.stringify(messagesResult.error)}`);
|
|
19443
|
+
}
|
|
19444
|
+
let assistantCount = 0;
|
|
19445
|
+
let costTotal = 0;
|
|
19446
|
+
let inputTokens = 0;
|
|
19447
|
+
let outputTokens = 0;
|
|
19448
|
+
let reasoningTokens = 0;
|
|
19449
|
+
let cacheRead = 0;
|
|
19450
|
+
let cacheWrite = 0;
|
|
19451
|
+
for (const msg of messagesResult.data) {
|
|
19452
|
+
const info = msg.info;
|
|
19453
|
+
if (info.role !== "assistant")
|
|
19454
|
+
continue;
|
|
19455
|
+
assistantCount += 1;
|
|
19456
|
+
costTotal += info.cost || 0;
|
|
19457
|
+
if (info.tokens) {
|
|
19458
|
+
inputTokens += info.tokens.input || 0;
|
|
19459
|
+
outputTokens += info.tokens.output || 0;
|
|
19460
|
+
reasoningTokens += info.tokens.reasoning || 0;
|
|
19461
|
+
cacheRead += info.tokens.cache?.read || 0;
|
|
19462
|
+
cacheWrite += info.tokens.cache?.write || 0;
|
|
19463
|
+
}
|
|
19464
|
+
}
|
|
19465
|
+
const totalTokens = inputTokens + outputTokens + reasoningTokens;
|
|
19466
|
+
const lines = [
|
|
19467
|
+
`Session usage:`,
|
|
19468
|
+
`- Assistant responses: ${assistantCount}`,
|
|
19469
|
+
`- Tokens: ${totalTokens} total (input ${inputTokens}, output ${outputTokens}, reasoning ${reasoningTokens})`,
|
|
19470
|
+
`- Cache: read ${cacheRead}, write ${cacheWrite}`,
|
|
19471
|
+
`- Cost: $${costTotal.toFixed(4)}`,
|
|
19472
|
+
];
|
|
19473
|
+
await ctx.reply(lines.join("\n"));
|
|
19474
|
+
}
|
|
19475
|
+
catch (err) {
|
|
19476
|
+
console.error("[Telegram] Error getting usage:", err);
|
|
19477
|
+
await ctx.reply("Failed to fetch usage. Try again later.");
|
|
19478
|
+
}
|
|
19479
|
+
});
|
|
19480
|
+
// Handle photo messages (images)
|
|
19481
|
+
bot.on("photo", async (ctx) => {
|
|
19482
|
+
const photos = ctx.message.photo;
|
|
19483
|
+
if (!photos || photos.length === 0)
|
|
19484
|
+
return;
|
|
19485
|
+
const largest = photos[photos.length - 1];
|
|
19486
|
+
const caption = ctx.message.caption;
|
|
19487
|
+
await handleFileMessage(ctx, largest.file_id, "image/jpeg", "photo.jpg", caption);
|
|
19488
|
+
});
|
|
19489
|
+
// Handle document messages (files)
|
|
19490
|
+
bot.on("document", async (ctx) => {
|
|
19491
|
+
const doc = ctx.message.document;
|
|
19492
|
+
if (!doc)
|
|
19493
|
+
return;
|
|
19494
|
+
const caption = ctx.message.caption;
|
|
19495
|
+
await handleFileMessage(ctx, doc.file_id, doc.mime_type || "application/octet-stream", doc.file_name, caption);
|
|
19496
|
+
});
|
|
19248
19497
|
/**
|
|
19249
19498
|
* Render a message part to markdown.
|
|
19250
19499
|
* In default mode: only text and tool calls (name + input/output).
|
|
@@ -19527,15 +19776,9 @@ async function startTelegram(options) {
|
|
|
19527
19776
|
const sessionId = await getOrCreateSession(chatId);
|
|
19528
19777
|
// Auto-title if this is the first message in a new session
|
|
19529
19778
|
await autoTitleSession(sessionId, userText);
|
|
19530
|
-
|
|
19531
|
-
|
|
19532
|
-
|
|
19533
|
-
};
|
|
19534
|
-
if (model) {
|
|
19535
|
-
const [providerID, ...modelParts] = model.split("/");
|
|
19536
|
-
const modelID = modelParts.join("/");
|
|
19537
|
-
promptBody.model = { providerID, modelID };
|
|
19538
|
-
}
|
|
19779
|
+
const promptBody = buildPromptBody(chatId, [
|
|
19780
|
+
{ type: "text", text: userText },
|
|
19781
|
+
]);
|
|
19539
19782
|
const verbose = chatVerboseMode.has(chatId);
|
|
19540
19783
|
await sendPromptStreaming(ctx.chat.id, sessionId, promptBody, processingMsg.message_id, verbose);
|
|
19541
19784
|
}
|
|
@@ -19545,18 +19788,21 @@ async function startTelegram(options) {
|
|
|
19545
19788
|
await ctx.reply("Sorry, there was an error processing your request. Try again or use /new to start a fresh session.");
|
|
19546
19789
|
}
|
|
19547
19790
|
});
|
|
19548
|
-
|
|
19549
|
-
|
|
19550
|
-
|
|
19551
|
-
|
|
19552
|
-
|
|
19553
|
-
|
|
19554
|
-
|
|
19555
|
-
|
|
19556
|
-
|
|
19557
|
-
|
|
19558
|
-
|
|
19791
|
+
if (options.launch !== false) {
|
|
19792
|
+
try {
|
|
19793
|
+
// Start the bot
|
|
19794
|
+
await bot.launch();
|
|
19795
|
+
console.log("[Telegram] Bot is running");
|
|
19796
|
+
// Enable graceful stop
|
|
19797
|
+
process.once("SIGINT", () => bot.stop("SIGINT"));
|
|
19798
|
+
process.once("SIGTERM", () => bot.stop("SIGTERM"));
|
|
19799
|
+
}
|
|
19800
|
+
catch (error) {
|
|
19801
|
+
console.error("Unable to start the Telegram bot:", error);
|
|
19802
|
+
throw error;
|
|
19803
|
+
}
|
|
19559
19804
|
}
|
|
19805
|
+
return bot;
|
|
19560
19806
|
}
|
|
19561
19807
|
/**
|
|
19562
19808
|
* 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.5",
|
|
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
|
}
|