opencode-telegram-bot 1.0.1 → 1.0.3
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 +35 -0
- package/dist/index.js +466 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -103,8 +103,43 @@ These are handled directly by the Telegram bot:
|
|
|
103
103
|
| `/switch <id>` | Switch to a different session (prefix match supported) |
|
|
104
104
|
| `/title <text>` | Rename the current session |
|
|
105
105
|
| `/delete <id>` | Delete a session from the server |
|
|
106
|
+
| `/export` | Export the current session as a markdown file |
|
|
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 |
|
|
106
110
|
| `/help` | Show available commands |
|
|
107
111
|
|
|
112
|
+
### Verbose Mode
|
|
113
|
+
|
|
114
|
+
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:
|
|
115
|
+
|
|
116
|
+
- **Thinking/reasoning** -- shown as plain text with a 🧠 prefix, truncated to 500 characters
|
|
117
|
+
- **Tool calls** -- shown as a compact one-line summary (e.g. `> read -- src/app.ts`)
|
|
118
|
+
|
|
119
|
+
Verbose mode is per-chat and persists across bot restarts. Use `/verbose` again to turn it off.
|
|
120
|
+
|
|
121
|
+
Example with verbose mode on:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
🧠 Thinking: Let me analyze the authentication flow and check for potential issues...
|
|
125
|
+
|
|
126
|
+
⚙️ grep -- pattern: "authenticate" in src/
|
|
127
|
+
⚙️ read -- src/auth/handler.ts
|
|
128
|
+
|
|
129
|
+
Here's what I found in the auth module...
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Session Export
|
|
133
|
+
|
|
134
|
+
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.
|
|
135
|
+
|
|
136
|
+
Two modes are available:
|
|
137
|
+
|
|
138
|
+
- **`/export`** -- Default. Includes user messages, assistant text, and tool calls (name, input, output).
|
|
139
|
+
- **`/export full`** (also accepts `detailed` or `all`) -- Includes everything from the default mode plus reasoning/thinking blocks, step boundaries with token counts, costs, subtasks, retries, and compaction markers.
|
|
140
|
+
|
|
141
|
+
The exported file is named `session-<id>.md` (or `session-<id>-detailed.md` for the full export).
|
|
142
|
+
|
|
108
143
|
### OpenCode Commands
|
|
109
144
|
|
|
110
145
|
Any `/` command that isn't a bot command is automatically forwarded to the OpenCode server via its command system. The bot fetches the list of available commands on startup and validates them.
|
package/dist/index.js
CHANGED
|
@@ -18588,12 +18588,19 @@ async function startTelegram(options) {
|
|
|
18588
18588
|
const client = client_createOpencodeClient({ baseUrl: url });
|
|
18589
18589
|
// Verify connection to the OpenCode server and fetch available commands
|
|
18590
18590
|
const opencodeCommands = new Set();
|
|
18591
|
+
let projectDirectory = "";
|
|
18591
18592
|
try {
|
|
18592
18593
|
const sessions = await client.session.list();
|
|
18593
18594
|
if (sessions.error) {
|
|
18594
18595
|
throw new Error(`Server returned error: ${JSON.stringify(sessions.error)}`);
|
|
18595
18596
|
}
|
|
18596
18597
|
console.log(`[Telegram] Connected to OpenCode server at ${url}`);
|
|
18598
|
+
// Fetch the project directory (needed for SSE event subscription)
|
|
18599
|
+
const pathResult = await client.path.get();
|
|
18600
|
+
if (pathResult.data?.directory) {
|
|
18601
|
+
projectDirectory = pathResult.data.directory;
|
|
18602
|
+
console.log(`[Telegram] Project directory: ${projectDirectory}`);
|
|
18603
|
+
}
|
|
18597
18604
|
// Fetch available OpenCode commands
|
|
18598
18605
|
const cmds = await client.command.list();
|
|
18599
18606
|
if (cmds.data) {
|
|
@@ -18608,12 +18615,14 @@ async function startTelegram(options) {
|
|
|
18608
18615
|
}
|
|
18609
18616
|
// Telegram-only commands that should not be forwarded to OpenCode
|
|
18610
18617
|
const telegramCommands = new Set([
|
|
18611
|
-
"start", "help", "new", "sessions", "switch", "title", "delete",
|
|
18618
|
+
"start", "help", "new", "sessions", "switch", "title", "delete", "export", "verbose",
|
|
18612
18619
|
]);
|
|
18613
18620
|
// Map of chatId -> sessionId for the active session per chat
|
|
18614
18621
|
const chatSessions = new Map();
|
|
18615
18622
|
// Set of all session IDs ever created/used by this bot (for filtering)
|
|
18616
18623
|
const knownSessionIds = new Set();
|
|
18624
|
+
// Set of chatIds with verbose mode enabled
|
|
18625
|
+
const chatVerboseMode = new Set();
|
|
18617
18626
|
/**
|
|
18618
18627
|
* Save the chat-to-session mapping and known session IDs to disk.
|
|
18619
18628
|
*/
|
|
@@ -18622,6 +18631,7 @@ async function startTelegram(options) {
|
|
|
18622
18631
|
const data = {
|
|
18623
18632
|
active: Object.fromEntries(chatSessions),
|
|
18624
18633
|
known: [...knownSessionIds],
|
|
18634
|
+
verbose: [...chatVerboseMode],
|
|
18625
18635
|
};
|
|
18626
18636
|
(0,external_node_fs_.writeFileSync)(SESSIONS_FILE, JSON.stringify(data, null, 2));
|
|
18627
18637
|
}
|
|
@@ -18637,14 +18647,16 @@ async function startTelegram(options) {
|
|
|
18637
18647
|
// Load from file (supports both old flat format and new {active, known} format)
|
|
18638
18648
|
let storedActive = {};
|
|
18639
18649
|
let storedKnown = [];
|
|
18650
|
+
let storedVerbose = [];
|
|
18640
18651
|
if ((0,external_node_fs_.existsSync)(SESSIONS_FILE)) {
|
|
18641
18652
|
try {
|
|
18642
18653
|
const raw = (0,external_node_fs_.readFileSync)(SESSIONS_FILE, "utf-8");
|
|
18643
18654
|
const parsed = JSON.parse(raw);
|
|
18644
18655
|
if (parsed.active && typeof parsed.active === "object") {
|
|
18645
|
-
// New format: { active: {...}, known: [...] }
|
|
18656
|
+
// New format: { active: {...}, known: [...], verbose: [...] }
|
|
18646
18657
|
storedActive = parsed.active;
|
|
18647
18658
|
storedKnown = parsed.known || [];
|
|
18659
|
+
storedVerbose = parsed.verbose || [];
|
|
18648
18660
|
}
|
|
18649
18661
|
else {
|
|
18650
18662
|
// Old format: flat { chatId: sessionId }
|
|
@@ -18656,6 +18668,13 @@ async function startTelegram(options) {
|
|
|
18656
18668
|
console.warn("[Telegram] Failed to parse sessions file:", err);
|
|
18657
18669
|
}
|
|
18658
18670
|
}
|
|
18671
|
+
// Restore verbose mode preferences
|
|
18672
|
+
for (const chatId of storedVerbose) {
|
|
18673
|
+
chatVerboseMode.add(chatId);
|
|
18674
|
+
}
|
|
18675
|
+
if (storedVerbose.length > 0) {
|
|
18676
|
+
console.log(`[Telegram] Restored verbose mode for ${storedVerbose.length} chat(s)`);
|
|
18677
|
+
}
|
|
18659
18678
|
// Fetch all server sessions once for validation and fallback matching
|
|
18660
18679
|
let serverSessions = [];
|
|
18661
18680
|
try {
|
|
@@ -18758,6 +18777,52 @@ async function startTelegram(options) {
|
|
|
18758
18777
|
console.warn("[Telegram] Failed to auto-title session:", err);
|
|
18759
18778
|
}
|
|
18760
18779
|
}
|
|
18780
|
+
/**
|
|
18781
|
+
* Build a one-line summary for a tool call, picking the most meaningful input field.
|
|
18782
|
+
*/
|
|
18783
|
+
function summarizeTool(tool, input) {
|
|
18784
|
+
if (!input)
|
|
18785
|
+
return tool;
|
|
18786
|
+
// Pick the most descriptive field based on common tool patterns
|
|
18787
|
+
const summaryField = input.filePath || input.path || input.command || input.pattern ||
|
|
18788
|
+
input.url || input.query || input.content || input.prompt ||
|
|
18789
|
+
input.description || input.name;
|
|
18790
|
+
if (summaryField && typeof summaryField === "string") {
|
|
18791
|
+
return `${tool} -- ${truncate(summaryField, 80)}`;
|
|
18792
|
+
}
|
|
18793
|
+
// For tools with a glob/include pattern
|
|
18794
|
+
if (input.include && typeof input.include === "string") {
|
|
18795
|
+
return `${tool} -- ${truncate(input.include, 80)}`;
|
|
18796
|
+
}
|
|
18797
|
+
return tool;
|
|
18798
|
+
}
|
|
18799
|
+
/**
|
|
18800
|
+
* Send a message to Telegram, with Markdown fallback.
|
|
18801
|
+
* When disableLinkPreview is true, link previews are suppressed (useful for verbose messages).
|
|
18802
|
+
*/
|
|
18803
|
+
async function sendTelegramMessage(chatId, text, disableLinkPreview = false) {
|
|
18804
|
+
const options = {
|
|
18805
|
+
parse_mode: "Markdown",
|
|
18806
|
+
};
|
|
18807
|
+
if (disableLinkPreview) {
|
|
18808
|
+
options.link_preview_options = { is_disabled: true };
|
|
18809
|
+
}
|
|
18810
|
+
try {
|
|
18811
|
+
await bot.telegram.sendMessage(chatId, text, options);
|
|
18812
|
+
}
|
|
18813
|
+
catch {
|
|
18814
|
+
try {
|
|
18815
|
+
const fallbackOptions = {};
|
|
18816
|
+
if (disableLinkPreview) {
|
|
18817
|
+
fallbackOptions.link_preview_options = { is_disabled: true };
|
|
18818
|
+
}
|
|
18819
|
+
await bot.telegram.sendMessage(chatId, text, fallbackOptions);
|
|
18820
|
+
}
|
|
18821
|
+
catch (fallbackErr) {
|
|
18822
|
+
console.error("[Telegram] Fallback error:", fallbackErr);
|
|
18823
|
+
}
|
|
18824
|
+
}
|
|
18825
|
+
}
|
|
18761
18826
|
/**
|
|
18762
18827
|
* Extract text from response parts and send to Telegram chat.
|
|
18763
18828
|
*/
|
|
@@ -18781,20 +18846,145 @@ async function startTelegram(options) {
|
|
|
18781
18846
|
// Send the response, splitting if needed (Telegram has a 4096 char limit)
|
|
18782
18847
|
const chunks = splitMessage(responseText, 4096);
|
|
18783
18848
|
for (const chunk of chunks) {
|
|
18784
|
-
|
|
18785
|
-
|
|
18786
|
-
|
|
18787
|
-
|
|
18788
|
-
|
|
18789
|
-
|
|
18849
|
+
await sendTelegramMessage(chatId, chunk);
|
|
18850
|
+
}
|
|
18851
|
+
}
|
|
18852
|
+
/**
|
|
18853
|
+
* Send a prompt with streaming: fires the prompt asynchronously, then listens
|
|
18854
|
+
* to SSE events to stream thinking and tool call updates to Telegram in real-time.
|
|
18855
|
+
* The final text response is sent when the session goes idle.
|
|
18856
|
+
*/
|
|
18857
|
+
async function sendPromptStreaming(chatId, sessionId, promptBody, processingMsgId, verbose = false) {
|
|
18858
|
+
// Subscribe to SSE events before sending the prompt so we don't miss anything
|
|
18859
|
+
const abortController = new AbortController();
|
|
18860
|
+
// Subscribe to SSE events before sending the prompt so we don't miss anything
|
|
18861
|
+
const subscribeOptions = {
|
|
18862
|
+
signal: abortController.signal,
|
|
18863
|
+
};
|
|
18864
|
+
if (projectDirectory) {
|
|
18865
|
+
subscribeOptions.query = { directory: projectDirectory };
|
|
18866
|
+
}
|
|
18867
|
+
const { stream } = await client.event.subscribe(subscribeOptions);
|
|
18868
|
+
// Fire the prompt asynchronously (returns immediately)
|
|
18869
|
+
const asyncResult = await client.session.promptAsync({
|
|
18870
|
+
path: { id: sessionId },
|
|
18871
|
+
body: promptBody,
|
|
18872
|
+
});
|
|
18873
|
+
if (asyncResult.error) {
|
|
18874
|
+
abortController.abort();
|
|
18875
|
+
throw new Error(`Prompt failed: ${JSON.stringify(asyncResult.error)}`);
|
|
18876
|
+
}
|
|
18877
|
+
// Track state as events stream in
|
|
18878
|
+
let finalText = "";
|
|
18879
|
+
let processingMsgDeleted = false;
|
|
18880
|
+
const sentToolIds = new Set();
|
|
18881
|
+
const sentReasoningIds = new Set();
|
|
18882
|
+
// Buffer the latest reasoning text -- we send it when a non-reasoning part arrives
|
|
18883
|
+
// so we get the complete thinking block rather than a partial one
|
|
18884
|
+
let pendingReasoning = null;
|
|
18885
|
+
/**
|
|
18886
|
+
* Delete the "processing" message once, right before sending the first real output.
|
|
18887
|
+
*/
|
|
18888
|
+
async function deleteProcessingMsg() {
|
|
18889
|
+
if (!processingMsgDeleted) {
|
|
18890
|
+
processingMsgDeleted = true;
|
|
18790
18891
|
try {
|
|
18791
|
-
await bot.telegram.
|
|
18892
|
+
await bot.telegram.deleteMessage(chatId, processingMsgId);
|
|
18792
18893
|
}
|
|
18793
|
-
catch
|
|
18794
|
-
|
|
18894
|
+
catch {
|
|
18895
|
+
// Ignore
|
|
18795
18896
|
}
|
|
18796
18897
|
}
|
|
18797
18898
|
}
|
|
18899
|
+
/**
|
|
18900
|
+
* Flush any buffered reasoning to Telegram as a spoiler message.
|
|
18901
|
+
*/
|
|
18902
|
+
async function flushReasoning() {
|
|
18903
|
+
if (verbose && pendingReasoning && !sentReasoningIds.has(pendingReasoning.id)) {
|
|
18904
|
+
sentReasoningIds.add(pendingReasoning.id);
|
|
18905
|
+
await deleteProcessingMsg();
|
|
18906
|
+
const truncated = truncate(pendingReasoning.text.replace(/\n/g, " "), 500);
|
|
18907
|
+
await sendTelegramMessage(chatId, `\u{1F9E0} Thinking: ${truncated}`, true);
|
|
18908
|
+
}
|
|
18909
|
+
pendingReasoning = null;
|
|
18910
|
+
}
|
|
18911
|
+
try {
|
|
18912
|
+
for await (const event of stream) {
|
|
18913
|
+
const ev = event;
|
|
18914
|
+
if (ev.type === "message.part.updated" && ev.properties) {
|
|
18915
|
+
const part = ev.properties.part;
|
|
18916
|
+
// Only process events for our session
|
|
18917
|
+
if (part.sessionID !== sessionId)
|
|
18918
|
+
continue;
|
|
18919
|
+
const partText = part.text;
|
|
18920
|
+
if (part.type === "reasoning" && part.id) {
|
|
18921
|
+
// Buffer reasoning -- keep updating with the latest full text
|
|
18922
|
+
if (partText) {
|
|
18923
|
+
pendingReasoning = { id: part.id, text: partText };
|
|
18924
|
+
}
|
|
18925
|
+
}
|
|
18926
|
+
else if (part.type === "tool" && part.id) {
|
|
18927
|
+
// A tool part arrived -- flush any pending reasoning first
|
|
18928
|
+
await flushReasoning();
|
|
18929
|
+
if (verbose) {
|
|
18930
|
+
const state = part.state;
|
|
18931
|
+
// Only send tool messages once they have a completed/error status
|
|
18932
|
+
if (state &&
|
|
18933
|
+
(state.status === "completed" || state.status === "error") &&
|
|
18934
|
+
!sentToolIds.has(part.id)) {
|
|
18935
|
+
sentToolIds.add(part.id);
|
|
18936
|
+
await deleteProcessingMsg();
|
|
18937
|
+
const tool = part.tool;
|
|
18938
|
+
const summary = summarizeTool(tool, state.input);
|
|
18939
|
+
let line = `\u{2699}\u{FE0F} ${summary}`;
|
|
18940
|
+
if (state.status === "error") {
|
|
18941
|
+
line += ` \u{274C}`;
|
|
18942
|
+
}
|
|
18943
|
+
await sendTelegramMessage(chatId, line, true);
|
|
18944
|
+
}
|
|
18945
|
+
}
|
|
18946
|
+
}
|
|
18947
|
+
else if (part.type === "text") {
|
|
18948
|
+
// A text part arrived -- flush any pending reasoning first
|
|
18949
|
+
await flushReasoning();
|
|
18950
|
+
// Accumulate text for the final response (text parts carry the full text, not deltas)
|
|
18951
|
+
const text = part.text;
|
|
18952
|
+
if (text) {
|
|
18953
|
+
finalText = text;
|
|
18954
|
+
}
|
|
18955
|
+
}
|
|
18956
|
+
}
|
|
18957
|
+
else if (ev.type === "session.idle" && ev.properties) {
|
|
18958
|
+
const idleSessionId = ev.properties.sessionID;
|
|
18959
|
+
if (idleSessionId === sessionId) {
|
|
18960
|
+
// Flush any remaining reasoning before finishing
|
|
18961
|
+
await flushReasoning();
|
|
18962
|
+
break;
|
|
18963
|
+
}
|
|
18964
|
+
}
|
|
18965
|
+
else if (ev.type === "session.error" && ev.properties) {
|
|
18966
|
+
const errorSessionId = ev.properties.sessionID;
|
|
18967
|
+
if (errorSessionId === sessionId) {
|
|
18968
|
+
const error = ev.properties.error;
|
|
18969
|
+
const errorMsg = error?.data?.message || "Unknown error";
|
|
18970
|
+
throw new Error(`Session error: ${errorMsg}`);
|
|
18971
|
+
}
|
|
18972
|
+
}
|
|
18973
|
+
}
|
|
18974
|
+
}
|
|
18975
|
+
finally {
|
|
18976
|
+
abortController.abort();
|
|
18977
|
+
}
|
|
18978
|
+
// Delete the processing message if it hasn't been deleted yet (non-verbose mode)
|
|
18979
|
+
await deleteProcessingMsg();
|
|
18980
|
+
// Send the final text response
|
|
18981
|
+
if (!finalText) {
|
|
18982
|
+
finalText = "The agent returned an empty response.";
|
|
18983
|
+
}
|
|
18984
|
+
const chunks = splitMessage(finalText, 4096);
|
|
18985
|
+
for (const chunk of chunks) {
|
|
18986
|
+
await sendTelegramMessage(chatId, chunk);
|
|
18987
|
+
}
|
|
18798
18988
|
}
|
|
18799
18989
|
/**
|
|
18800
18990
|
* Handle session errors - clear invalid sessions.
|
|
@@ -18845,6 +19035,10 @@ async function startTelegram(options) {
|
|
|
18845
19035
|
"/switch <number> - Switch to a different session\n" +
|
|
18846
19036
|
"/title <text> - Rename the current session\n" +
|
|
18847
19037
|
"/delete <number> - Delete a session\n" +
|
|
19038
|
+
"/export - Export the current session as a markdown file\n" +
|
|
19039
|
+
"/export full - Export with all details (thinking, costs, steps)\n" +
|
|
19040
|
+
"/verbose - Toggle verbose mode (show thinking and tool calls)\n" +
|
|
19041
|
+
"/verbose on|off - Set verbose mode explicitly\n" +
|
|
18848
19042
|
"/help - Show this help message\n";
|
|
18849
19043
|
if (opencodeCommands.size > 0) {
|
|
18850
19044
|
msg +=
|
|
@@ -18862,6 +19056,10 @@ async function startTelegram(options) {
|
|
|
18862
19056
|
"/switch <number> - Switch to a different session\n" +
|
|
18863
19057
|
"/title <text> - Rename the current session\n" +
|
|
18864
19058
|
"/delete <number> - Delete a session\n" +
|
|
19059
|
+
"/export - Export the current session as a markdown file\n" +
|
|
19060
|
+
"/export full - Export with all details (thinking, costs, steps)\n" +
|
|
19061
|
+
"/verbose - Toggle verbose mode (show thinking and tool calls)\n" +
|
|
19062
|
+
"/verbose on|off - Set verbose mode explicitly\n" +
|
|
18865
19063
|
"/help - Show this help message\n";
|
|
18866
19064
|
if (opencodeCommands.size > 0) {
|
|
18867
19065
|
msg +=
|
|
@@ -19021,6 +19219,260 @@ async function startTelegram(options) {
|
|
|
19021
19219
|
await ctx.reply("Failed to delete session.");
|
|
19022
19220
|
}
|
|
19023
19221
|
});
|
|
19222
|
+
// Handle /verbose command - toggle verbose mode for this chat
|
|
19223
|
+
// Usage: /verbose, /verbose on, /verbose off
|
|
19224
|
+
bot.command("verbose", (ctx) => {
|
|
19225
|
+
const chatId = ctx.chat.id.toString();
|
|
19226
|
+
const args = ctx.message.text.replace(/^\/verbose\s*/, "").trim().toLowerCase();
|
|
19227
|
+
if (args === "on") {
|
|
19228
|
+
chatVerboseMode.add(chatId);
|
|
19229
|
+
}
|
|
19230
|
+
else if (args === "off") {
|
|
19231
|
+
chatVerboseMode.delete(chatId);
|
|
19232
|
+
}
|
|
19233
|
+
else {
|
|
19234
|
+
if (chatVerboseMode.has(chatId)) {
|
|
19235
|
+
chatVerboseMode.delete(chatId);
|
|
19236
|
+
}
|
|
19237
|
+
else {
|
|
19238
|
+
chatVerboseMode.add(chatId);
|
|
19239
|
+
}
|
|
19240
|
+
}
|
|
19241
|
+
saveSessions();
|
|
19242
|
+
const enabled = chatVerboseMode.has(chatId);
|
|
19243
|
+
console.log(`[Telegram] Verbose mode ${enabled ? "enabled" : "disabled"} for chat ${chatId}`);
|
|
19244
|
+
ctx.reply(enabled
|
|
19245
|
+
? "Verbose mode enabled. Responses will include thinking and tool calls."
|
|
19246
|
+
: "Verbose mode disabled. Responses will only show the assistant's text.");
|
|
19247
|
+
});
|
|
19248
|
+
/**
|
|
19249
|
+
* Render a message part to markdown.
|
|
19250
|
+
* In default mode: only text and tool calls (name + input/output).
|
|
19251
|
+
* In detailed mode: also includes reasoning, step info, subtasks, costs, etc.
|
|
19252
|
+
*/
|
|
19253
|
+
function renderPart(part, detailed) {
|
|
19254
|
+
let md = "";
|
|
19255
|
+
switch (part.type) {
|
|
19256
|
+
case "text": {
|
|
19257
|
+
const text = part.text;
|
|
19258
|
+
if (text) {
|
|
19259
|
+
md += `${text}\n\n`;
|
|
19260
|
+
}
|
|
19261
|
+
break;
|
|
19262
|
+
}
|
|
19263
|
+
case "tool": {
|
|
19264
|
+
const tool = part.tool;
|
|
19265
|
+
const state = part.state;
|
|
19266
|
+
md += `**Tool: ${tool}**\n\n`;
|
|
19267
|
+
if (state.input) {
|
|
19268
|
+
md += `**Input:**\n\`\`\`json\n${JSON.stringify(state.input, null, 2)}\n\`\`\`\n\n`;
|
|
19269
|
+
}
|
|
19270
|
+
if (state.output) {
|
|
19271
|
+
md += `**Output:**\n\`\`\`\n${state.output}\n\`\`\`\n\n`;
|
|
19272
|
+
}
|
|
19273
|
+
if (state.error) {
|
|
19274
|
+
md += `**Error:**\n\`\`\`\n${state.error}\n\`\`\`\n\n`;
|
|
19275
|
+
}
|
|
19276
|
+
if (detailed && state.time) {
|
|
19277
|
+
const duration = ((state.time.end - state.time.start) / 1000).toFixed(1);
|
|
19278
|
+
md += `*Status: ${state.status} (${duration}s)*\n\n`;
|
|
19279
|
+
}
|
|
19280
|
+
break;
|
|
19281
|
+
}
|
|
19282
|
+
case "reasoning": {
|
|
19283
|
+
if (!detailed)
|
|
19284
|
+
break;
|
|
19285
|
+
const text = part.text;
|
|
19286
|
+
if (text) {
|
|
19287
|
+
md += `<details>\n<summary>Thinking</summary>\n\n${text}\n\n</details>\n\n`;
|
|
19288
|
+
}
|
|
19289
|
+
break;
|
|
19290
|
+
}
|
|
19291
|
+
case "step-start": {
|
|
19292
|
+
if (!detailed)
|
|
19293
|
+
break;
|
|
19294
|
+
md += `---\n*Step started*\n\n`;
|
|
19295
|
+
break;
|
|
19296
|
+
}
|
|
19297
|
+
case "step-finish": {
|
|
19298
|
+
if (!detailed)
|
|
19299
|
+
break;
|
|
19300
|
+
const reason = part.reason;
|
|
19301
|
+
const cost = part.cost;
|
|
19302
|
+
const tokens = part.tokens;
|
|
19303
|
+
let info = `*Step finished`;
|
|
19304
|
+
if (reason)
|
|
19305
|
+
info += ` (${reason})`;
|
|
19306
|
+
info += `*`;
|
|
19307
|
+
if (tokens) {
|
|
19308
|
+
info += `\n*Tokens: ${tokens.input} in / ${tokens.output} out`;
|
|
19309
|
+
if (tokens.reasoning > 0)
|
|
19310
|
+
info += ` / ${tokens.reasoning} reasoning`;
|
|
19311
|
+
if (tokens.cache.read > 0 || tokens.cache.write > 0) {
|
|
19312
|
+
info += ` (cache: ${tokens.cache.read} read, ${tokens.cache.write} write)`;
|
|
19313
|
+
}
|
|
19314
|
+
info += `*`;
|
|
19315
|
+
}
|
|
19316
|
+
if (cost !== undefined && cost > 0) {
|
|
19317
|
+
info += `\n*Cost: $${cost.toFixed(4)}*`;
|
|
19318
|
+
}
|
|
19319
|
+
md += `${info}\n\n---\n\n`;
|
|
19320
|
+
break;
|
|
19321
|
+
}
|
|
19322
|
+
case "subtask": {
|
|
19323
|
+
if (!detailed)
|
|
19324
|
+
break;
|
|
19325
|
+
const description = part.description;
|
|
19326
|
+
const agent = part.agent;
|
|
19327
|
+
const prompt = part.prompt;
|
|
19328
|
+
md += `**Subtask${agent ? ` (${agent})` : ""}**`;
|
|
19329
|
+
if (description)
|
|
19330
|
+
md += `: ${description}`;
|
|
19331
|
+
md += `\n\n`;
|
|
19332
|
+
if (prompt) {
|
|
19333
|
+
md += `> ${prompt.replace(/\n/g, "\n> ")}\n\n`;
|
|
19334
|
+
}
|
|
19335
|
+
break;
|
|
19336
|
+
}
|
|
19337
|
+
case "agent": {
|
|
19338
|
+
if (!detailed)
|
|
19339
|
+
break;
|
|
19340
|
+
const name = part.name;
|
|
19341
|
+
if (name) {
|
|
19342
|
+
md += `*Agent: ${name}*\n\n`;
|
|
19343
|
+
}
|
|
19344
|
+
break;
|
|
19345
|
+
}
|
|
19346
|
+
case "retry": {
|
|
19347
|
+
if (!detailed)
|
|
19348
|
+
break;
|
|
19349
|
+
const attempt = part.attempt;
|
|
19350
|
+
const error = part.error;
|
|
19351
|
+
md += `**Retry (attempt ${attempt || "?"})**`;
|
|
19352
|
+
if (error?.data?.message) {
|
|
19353
|
+
md += `\n\`\`\`\n${error.data.message}\n\`\`\``;
|
|
19354
|
+
}
|
|
19355
|
+
md += `\n\n`;
|
|
19356
|
+
break;
|
|
19357
|
+
}
|
|
19358
|
+
case "compaction": {
|
|
19359
|
+
if (!detailed)
|
|
19360
|
+
break;
|
|
19361
|
+
const auto = part.auto;
|
|
19362
|
+
md += `*Context compacted${auto ? " (auto)" : ""}*\n\n`;
|
|
19363
|
+
break;
|
|
19364
|
+
}
|
|
19365
|
+
default:
|
|
19366
|
+
// snapshot, patch, file, etc. - skip in both modes
|
|
19367
|
+
break;
|
|
19368
|
+
}
|
|
19369
|
+
return md;
|
|
19370
|
+
}
|
|
19371
|
+
// Handle /export command - export the current session to the opencode project directory
|
|
19372
|
+
// Usage: /export - default (text + tool calls only)
|
|
19373
|
+
// /export full - detailed (includes thinking, steps, costs, subtasks, etc.)
|
|
19374
|
+
bot.command("export", async (ctx) => {
|
|
19375
|
+
const chatId = ctx.chat.id.toString();
|
|
19376
|
+
const sessionId = chatSessions.get(chatId);
|
|
19377
|
+
if (!sessionId) {
|
|
19378
|
+
await ctx.reply("No active session. Send a message first to create one.");
|
|
19379
|
+
return;
|
|
19380
|
+
}
|
|
19381
|
+
const args = ctx.message.text.replace(/^\/export\s*/, "").trim().toLowerCase();
|
|
19382
|
+
const detailed = args === "full" || args === "detailed" || args === "all";
|
|
19383
|
+
try {
|
|
19384
|
+
const modeLabel = detailed ? "detailed" : "summary";
|
|
19385
|
+
const processingMsg = await ctx.reply(`Exporting session (${modeLabel})...`);
|
|
19386
|
+
// Fetch session info and messages in parallel
|
|
19387
|
+
const [sessionResult, messagesResult, pathResult] = await Promise.all([
|
|
19388
|
+
client.session.get({ path: { id: sessionId } }),
|
|
19389
|
+
client.session.messages({ path: { id: sessionId } }),
|
|
19390
|
+
client.path.get(),
|
|
19391
|
+
]);
|
|
19392
|
+
if (sessionResult.error || !sessionResult.data) {
|
|
19393
|
+
throw new Error(`Failed to get session: ${JSON.stringify(sessionResult.error)}`);
|
|
19394
|
+
}
|
|
19395
|
+
if (messagesResult.error || !messagesResult.data) {
|
|
19396
|
+
throw new Error(`Failed to get messages: ${JSON.stringify(messagesResult.error)}`);
|
|
19397
|
+
}
|
|
19398
|
+
const session = sessionResult.data;
|
|
19399
|
+
const messages = messagesResult.data;
|
|
19400
|
+
// Build the markdown export
|
|
19401
|
+
let md = `# ${session.title}\n\n`;
|
|
19402
|
+
md += `**Session ID:** ${session.id}\n`;
|
|
19403
|
+
md += `**Created:** ${new Date(session.time.created * 1000).toLocaleString()}\n`;
|
|
19404
|
+
md += `**Updated:** ${new Date(session.time.updated * 1000).toLocaleString()}\n`;
|
|
19405
|
+
if (detailed) {
|
|
19406
|
+
md += `**Export mode:** Detailed\n`;
|
|
19407
|
+
}
|
|
19408
|
+
md += `\n---\n\n`;
|
|
19409
|
+
for (const msg of messages) {
|
|
19410
|
+
const info = msg.info;
|
|
19411
|
+
const parts = msg.parts || [];
|
|
19412
|
+
if (info.role === "user") {
|
|
19413
|
+
md += `## User\n\n`;
|
|
19414
|
+
for (const part of parts) {
|
|
19415
|
+
md += renderPart(part, detailed);
|
|
19416
|
+
}
|
|
19417
|
+
md += `---\n\n`;
|
|
19418
|
+
}
|
|
19419
|
+
else if (info.role === "assistant") {
|
|
19420
|
+
const assistant = info;
|
|
19421
|
+
const duration = assistant.time.completed
|
|
19422
|
+
? ((assistant.time.completed - assistant.time.created) / 1000).toFixed(1) + "s"
|
|
19423
|
+
: "";
|
|
19424
|
+
const modelLabel = assistant.modelID || "unknown";
|
|
19425
|
+
const modeLabel = assistant.mode || "";
|
|
19426
|
+
const header = [modeLabel, modelLabel, duration].filter(Boolean).join(" · ");
|
|
19427
|
+
md += `## Assistant (${header})\n\n`;
|
|
19428
|
+
if (detailed && assistant.tokens) {
|
|
19429
|
+
const t = assistant.tokens;
|
|
19430
|
+
md += `*Tokens: ${t.input} in / ${t.output} out`;
|
|
19431
|
+
if (t.reasoning > 0)
|
|
19432
|
+
md += ` / ${t.reasoning} reasoning`;
|
|
19433
|
+
if (t.cache.read > 0 || t.cache.write > 0) {
|
|
19434
|
+
md += ` (cache: ${t.cache.read} read, ${t.cache.write} write)`;
|
|
19435
|
+
}
|
|
19436
|
+
md += `*\n`;
|
|
19437
|
+
if (assistant.cost && assistant.cost > 0) {
|
|
19438
|
+
md += `*Cost: $${assistant.cost.toFixed(4)}*\n`;
|
|
19439
|
+
}
|
|
19440
|
+
md += `\n`;
|
|
19441
|
+
}
|
|
19442
|
+
for (const part of parts) {
|
|
19443
|
+
md += renderPart(part, detailed);
|
|
19444
|
+
}
|
|
19445
|
+
md += `---\n\n`;
|
|
19446
|
+
}
|
|
19447
|
+
}
|
|
19448
|
+
// Write to the project directory
|
|
19449
|
+
const projectDir = pathResult.data?.directory;
|
|
19450
|
+
const idPrefix = sessionId.substring(0, 8);
|
|
19451
|
+
const suffix = detailed ? "-detailed" : "";
|
|
19452
|
+
const filename = `session-${idPrefix}${suffix}.md`;
|
|
19453
|
+
const exportPath = (0,external_node_path_.resolve)(projectDir || ".", filename);
|
|
19454
|
+
(0,external_node_fs_.writeFileSync)(exportPath, md, "utf-8");
|
|
19455
|
+
console.log(`[Telegram] Exported session ${sessionId} to ${exportPath} (${detailed ? "detailed" : "summary"})`);
|
|
19456
|
+
// Delete the processing message
|
|
19457
|
+
try {
|
|
19458
|
+
await bot.telegram.deleteMessage(ctx.chat.id, processingMsg.message_id);
|
|
19459
|
+
}
|
|
19460
|
+
catch {
|
|
19461
|
+
// Ignore if we can't delete it
|
|
19462
|
+
}
|
|
19463
|
+
// Send the file to the user
|
|
19464
|
+
await bot.telegram.sendDocument(ctx.chat.id, {
|
|
19465
|
+
source: Buffer.from(md, "utf-8"),
|
|
19466
|
+
filename,
|
|
19467
|
+
});
|
|
19468
|
+
await ctx.reply(`Session exported to: ${exportPath}`);
|
|
19469
|
+
}
|
|
19470
|
+
catch (err) {
|
|
19471
|
+
console.error("[Telegram] Error exporting session:", err);
|
|
19472
|
+
await handleSessionError(chatId);
|
|
19473
|
+
await ctx.reply("Sorry, there was an error exporting the session. Try again or use /new to start a fresh session.");
|
|
19474
|
+
}
|
|
19475
|
+
});
|
|
19024
19476
|
// Catch-all for unregistered / commands - forward to OpenCode
|
|
19025
19477
|
bot.on("text", async (ctx) => {
|
|
19026
19478
|
const userText = ctx.message.text;
|
|
@@ -19052,6 +19504,7 @@ async function startTelegram(options) {
|
|
|
19052
19504
|
body: {
|
|
19053
19505
|
command: commandName,
|
|
19054
19506
|
arguments: commandArgs,
|
|
19507
|
+
agent: "default",
|
|
19055
19508
|
},
|
|
19056
19509
|
});
|
|
19057
19510
|
if (result.error) {
|
|
@@ -19083,15 +19536,8 @@ async function startTelegram(options) {
|
|
|
19083
19536
|
const modelID = modelParts.join("/");
|
|
19084
19537
|
promptBody.model = { providerID, modelID };
|
|
19085
19538
|
}
|
|
19086
|
-
const
|
|
19087
|
-
|
|
19088
|
-
body: promptBody,
|
|
19089
|
-
});
|
|
19090
|
-
if (result.error) {
|
|
19091
|
-
throw new Error(`Prompt failed: ${JSON.stringify(result.error)}`);
|
|
19092
|
-
}
|
|
19093
|
-
const parts = (result.data?.parts || []);
|
|
19094
|
-
await sendResponseToChat(ctx.chat.id, parts, processingMsg.message_id);
|
|
19539
|
+
const verbose = chatVerboseMode.has(chatId);
|
|
19540
|
+
await sendPromptStreaming(ctx.chat.id, sessionId, promptBody, processingMsg.message_id, verbose);
|
|
19095
19541
|
}
|
|
19096
19542
|
catch (err) {
|
|
19097
19543
|
console.error("[Telegram] Error processing message:", err);
|