@grinev/opencode-telegram-bot 0.20.6 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +5 -2
- package/README.md +10 -0
- package/dist/bot/commands/definitions.js +1 -0
- package/dist/bot/commands/messages.js +537 -0
- package/dist/bot/handlers/model.js +205 -42
- package/dist/bot/index.js +16 -3
- package/dist/bot/streaming/response-streamer.js +8 -0
- package/dist/bot/utils/assistant-run-footer.js +15 -4
- package/dist/bot/utils/busy-reconciliation.js +8 -0
- package/dist/config.js +2 -1
- package/dist/i18n/de.js +27 -0
- package/dist/i18n/en.js +27 -0
- package/dist/i18n/es.js +27 -0
- package/dist/i18n/fr.js +27 -0
- package/dist/i18n/ru.js +27 -0
- package/dist/i18n/zh.js +27 -0
- package/dist/model/manager.js +38 -0
- package/package.json +1 -1
package/.env.example
CHANGED
|
@@ -64,6 +64,9 @@ OPENCODE_MODEL_ID=big-pickle
|
|
|
64
64
|
# Maximum number of sessions shown in /sessions (default: 10)
|
|
65
65
|
# SESSIONS_LIST_LIMIT=10
|
|
66
66
|
|
|
67
|
+
# Maximum number of user messages shown per page in /messages (default: 10)
|
|
68
|
+
# MESSAGES_LIST_LIMIT=10
|
|
69
|
+
|
|
67
70
|
# Maximum number of projects shown in /projects (default: 10)
|
|
68
71
|
# PROJECTS_LIST_LIMIT=10
|
|
69
72
|
|
|
@@ -77,9 +80,9 @@ OPENCODE_MODEL_ID=big-pickle
|
|
|
77
80
|
# If exceeded, the bot stops waiting for the result and marks the run as failed.
|
|
78
81
|
# SCHEDULED_TASK_EXECUTION_TIMEOUT_MINUTES=120
|
|
79
82
|
|
|
80
|
-
# Stream update throttle in milliseconds for assistant/tool message edits (default:
|
|
83
|
+
# Stream update throttle in milliseconds for assistant/tool message edits (default: 1000)
|
|
81
84
|
# Higher value = fewer Telegram edit requests, lower value = more real-time updates
|
|
82
|
-
# RESPONSE_STREAM_THROTTLE_MS=
|
|
85
|
+
# RESPONSE_STREAM_THROTTLE_MS=1000
|
|
83
86
|
|
|
84
87
|
# Maximum displayed length for bash tool commands in Telegram summaries (default: 128)
|
|
85
88
|
# Longer commands are truncated with "..."
|
package/README.md
CHANGED
|
@@ -134,6 +134,7 @@ opencode-telegram config
|
|
|
134
134
|
| `/abort` | Abort the current task |
|
|
135
135
|
| `/detach` | Detach from the current session without stopping it |
|
|
136
136
|
| `/sessions` | Browse and switch between recent sessions |
|
|
137
|
+
| `/messages` | Browse user messages, revert or fork from a previous state |
|
|
137
138
|
| `/projects` | Switch between OpenCode projects |
|
|
138
139
|
| `/worktree` | Switch between existing git worktrees |
|
|
139
140
|
| `/open` | Add a project by browsing directories |
|
|
@@ -153,6 +154,14 @@ Any regular text message is sent as a prompt to the coding agent only when no bl
|
|
|
153
154
|
|
|
154
155
|
When the current project is a git repository, `/worktree` shows the existing worktrees for that repository. Status and pinned updates display the main project path with the active branch, and show a separate `Worktree` line when a linked worktree is selected.
|
|
155
156
|
|
|
157
|
+
## Message History, Revert, and Fork
|
|
158
|
+
|
|
159
|
+
The `/messages` command displays all user messages in the current session, sorted by time (newest first). Select a message to view its full text and access the **Revert** and **Fork** actions.
|
|
160
|
+
|
|
161
|
+
**Revert** rolls back the session state to the selected message, discarding all subsequent messages and agent responses. This is useful when you want to retry a different approach from a specific point in the conversation.
|
|
162
|
+
|
|
163
|
+
**Fork** creates a new session that branches from the selected message. The original session remains unchanged, and you can continue working in the new forked session. This is useful when you want to explore an alternative approach without losing the original conversation history.
|
|
164
|
+
|
|
156
165
|
## Scheduled Tasks
|
|
157
166
|
|
|
158
167
|
Scheduled tasks let you prepare prompts in advance and run them automatically later or on a recurring schedule. This is useful for periodic checks, routine code maintenance, or tasks you want OpenCode to execute while you are away from your computer. Use `/task` to create a scheduled task and `/tasklist` to review or delete existing ones.
|
|
@@ -210,6 +219,7 @@ When installed via npm, the configuration wizard handles the initial setup. The
|
|
|
210
219
|
| `OPENCODE_MODEL_ID` | Default model ID | Yes | `big-pickle` |
|
|
211
220
|
| `BOT_LOCALE` | Bot UI language (supported locale code, e.g. `en`, `de`, `es`, `fr`, `ru`, `zh`) | No | `en` |
|
|
212
221
|
| `SESSIONS_LIST_LIMIT` | Sessions per page in `/sessions` | No | `10` |
|
|
222
|
+
| `MESSAGES_LIST_LIMIT` | User messages per page in `/messages` | No | `10` |
|
|
213
223
|
| `PROJECTS_LIST_LIMIT` | Projects per page in `/projects` | No | `10` |
|
|
214
224
|
| `OPEN_BROWSER_ROOTS` | Comma-separated paths `/open` is allowed to browse (supports `~`) | No | `~` (home directory) |
|
|
215
225
|
| `COMMANDS_LIST_LIMIT` | Items per page in `/commands` and `/skills` | No | `10` |
|
|
@@ -9,6 +9,7 @@ const COMMAND_DEFINITIONS = [
|
|
|
9
9
|
{ command: "abort", descriptionKey: "cmd.description.stop" },
|
|
10
10
|
{ command: "detach", descriptionKey: "cmd.description.detach" },
|
|
11
11
|
{ command: "sessions", descriptionKey: "cmd.description.sessions" },
|
|
12
|
+
{ command: "messages", descriptionKey: "cmd.description.messages" },
|
|
12
13
|
{ command: "tts", descriptionKey: "cmd.description.tts" },
|
|
13
14
|
{ command: "projects", descriptionKey: "cmd.description.projects" },
|
|
14
15
|
{ command: "worktree", descriptionKey: "cmd.description.worktree" },
|
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import { InlineKeyboard } from "grammy";
|
|
2
|
+
import { config } from "../../config.js";
|
|
3
|
+
import { interactionManager } from "../../interaction/manager.js";
|
|
4
|
+
import { opencodeClient } from "../../opencode/client.js";
|
|
5
|
+
import { getCurrentSession, setCurrentSession } from "../../session/manager.js";
|
|
6
|
+
import { getCurrentProject } from "../../settings/manager.js";
|
|
7
|
+
import { clearAllInteractionState } from "../../interaction/cleanup.js";
|
|
8
|
+
import { attachToSession } from "../../attach/service.js";
|
|
9
|
+
import { ingestSessionInfoForCache } from "../../session/cache-manager.js";
|
|
10
|
+
import { t } from "../../i18n/index.js";
|
|
11
|
+
import { logger } from "../../utils/logger.js";
|
|
12
|
+
import { safeBackgroundTask } from "../../utils/safe-background-task.js";
|
|
13
|
+
import { renderAssistantFinalPartsSafe } from "../utils/assistant-rendering.js";
|
|
14
|
+
import { sendRenderedBotPart } from "../utils/telegram-text.js";
|
|
15
|
+
const MESSAGES_CALLBACK_PREFIX = "messages:";
|
|
16
|
+
const MESSAGES_CALLBACK_SELECT_PREFIX = `${MESSAGES_CALLBACK_PREFIX}select:`;
|
|
17
|
+
const MESSAGES_CALLBACK_PAGE_PREFIX = `${MESSAGES_CALLBACK_PREFIX}page:`;
|
|
18
|
+
const MESSAGES_CALLBACK_REVERT = `${MESSAGES_CALLBACK_PREFIX}revert`;
|
|
19
|
+
const MESSAGES_CALLBACK_FORK = `${MESSAGES_CALLBACK_PREFIX}fork`;
|
|
20
|
+
const MESSAGES_CALLBACK_BACK = `${MESSAGES_CALLBACK_PREFIX}back`;
|
|
21
|
+
const MESSAGES_CALLBACK_CANCEL = `${MESSAGES_CALLBACK_PREFIX}cancel`;
|
|
22
|
+
const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
|
|
23
|
+
const TELEGRAM_MESSAGE_LIMIT = 4096;
|
|
24
|
+
const LATEST_ASSISTANT_RESPONSE_MESSAGES_LIMIT = 20;
|
|
25
|
+
function getCallbackMessageId(ctx) {
|
|
26
|
+
const message = ctx.callbackQuery?.message;
|
|
27
|
+
if (!message || !("message_id" in message)) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const messageId = message.message_id;
|
|
31
|
+
return typeof messageId === "number" ? messageId : null;
|
|
32
|
+
}
|
|
33
|
+
function extractTextParts(parts) {
|
|
34
|
+
const text = parts
|
|
35
|
+
.filter((part) => part.type === "text" && typeof part.text === "string")
|
|
36
|
+
.map((part) => part.text)
|
|
37
|
+
.join("")
|
|
38
|
+
.trim();
|
|
39
|
+
return text.length > 0 ? text : null;
|
|
40
|
+
}
|
|
41
|
+
function truncateText(text, maxLength) {
|
|
42
|
+
if (text.length <= maxLength) {
|
|
43
|
+
return text;
|
|
44
|
+
}
|
|
45
|
+
return `${text.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
|
|
46
|
+
}
|
|
47
|
+
function normalizeButtonText(text) {
|
|
48
|
+
return text.replace(/\s+/g, " ").trim();
|
|
49
|
+
}
|
|
50
|
+
function formatMessageTime(created) {
|
|
51
|
+
const date = new Date(created);
|
|
52
|
+
const hours = date.getHours().toString().padStart(2, "0");
|
|
53
|
+
const minutes = date.getMinutes().toString().padStart(2, "0");
|
|
54
|
+
return `${hours}:${minutes}`;
|
|
55
|
+
}
|
|
56
|
+
function formatMessageButtonLabel(message) {
|
|
57
|
+
const prefix = `[${formatMessageTime(message.created)}] `;
|
|
58
|
+
const text = normalizeButtonText(message.text);
|
|
59
|
+
return `${prefix}${truncateText(text, MAX_INLINE_BUTTON_LABEL_LENGTH - prefix.length)}`;
|
|
60
|
+
}
|
|
61
|
+
function formatMessagesSelectText(page) {
|
|
62
|
+
if (page === 0) {
|
|
63
|
+
return t("messages.select");
|
|
64
|
+
}
|
|
65
|
+
return t("messages.select_page", { page: page + 1 });
|
|
66
|
+
}
|
|
67
|
+
function formatMessageDetailText(message) {
|
|
68
|
+
const prefix = `[${formatMessageTime(message.created)}]\n\n`;
|
|
69
|
+
return truncateText(`${prefix}${message.text}`, TELEGRAM_MESSAGE_LIMIT);
|
|
70
|
+
}
|
|
71
|
+
export function buildMessagePageCallback(page) {
|
|
72
|
+
return `${MESSAGES_CALLBACK_PAGE_PREFIX}${page}`;
|
|
73
|
+
}
|
|
74
|
+
export function parseMessagePageCallback(data) {
|
|
75
|
+
if (!data.startsWith(MESSAGES_CALLBACK_PAGE_PREFIX)) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
const rawPage = data.slice(MESSAGES_CALLBACK_PAGE_PREFIX.length);
|
|
79
|
+
const page = Number(rawPage);
|
|
80
|
+
if (!Number.isInteger(page) || page < 0) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return page;
|
|
84
|
+
}
|
|
85
|
+
export function calculateMessagesPaginationRange(totalMessages, page, pageSize) {
|
|
86
|
+
const safePageSize = Math.max(1, pageSize);
|
|
87
|
+
const totalPages = Math.max(1, Math.ceil(totalMessages / safePageSize));
|
|
88
|
+
const normalizedPage = Math.min(Math.max(0, page), totalPages - 1);
|
|
89
|
+
const startIndex = normalizedPage * safePageSize;
|
|
90
|
+
const endIndex = Math.min(startIndex + safePageSize, totalMessages);
|
|
91
|
+
return {
|
|
92
|
+
page: normalizedPage,
|
|
93
|
+
totalPages,
|
|
94
|
+
startIndex,
|
|
95
|
+
endIndex,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function buildMessagesListKeyboard(messages, page, pageSize) {
|
|
99
|
+
const keyboard = new InlineKeyboard();
|
|
100
|
+
const { page: normalizedPage, totalPages, startIndex, endIndex, } = calculateMessagesPaginationRange(messages.length, page, pageSize);
|
|
101
|
+
messages.slice(startIndex, endIndex).forEach((message, index) => {
|
|
102
|
+
const globalIndex = startIndex + index;
|
|
103
|
+
keyboard.text(formatMessageButtonLabel(message), `${MESSAGES_CALLBACK_SELECT_PREFIX}${globalIndex}`).row();
|
|
104
|
+
});
|
|
105
|
+
if (totalPages > 1) {
|
|
106
|
+
if (normalizedPage > 0) {
|
|
107
|
+
keyboard.text(t("messages.button.prev_page"), buildMessagePageCallback(normalizedPage - 1));
|
|
108
|
+
}
|
|
109
|
+
if (normalizedPage < totalPages - 1) {
|
|
110
|
+
keyboard.text(t("messages.button.next_page"), buildMessagePageCallback(normalizedPage + 1));
|
|
111
|
+
}
|
|
112
|
+
keyboard.row();
|
|
113
|
+
}
|
|
114
|
+
keyboard.text(t("messages.button.cancel"), MESSAGES_CALLBACK_CANCEL);
|
|
115
|
+
return keyboard;
|
|
116
|
+
}
|
|
117
|
+
function buildMessageDetailKeyboard() {
|
|
118
|
+
return new InlineKeyboard()
|
|
119
|
+
.text(t("messages.button.revert"), MESSAGES_CALLBACK_REVERT)
|
|
120
|
+
.row()
|
|
121
|
+
.text(t("messages.button.fork"), MESSAGES_CALLBACK_FORK)
|
|
122
|
+
.row()
|
|
123
|
+
.text(t("messages.button.back"), MESSAGES_CALLBACK_BACK)
|
|
124
|
+
.text(t("messages.button.cancel"), MESSAGES_CALLBACK_CANCEL);
|
|
125
|
+
}
|
|
126
|
+
function parseMessages(value) {
|
|
127
|
+
if (!Array.isArray(value)) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const messages = [];
|
|
131
|
+
for (const item of value) {
|
|
132
|
+
if (!item || typeof item !== "object") {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const id = item.id;
|
|
136
|
+
const text = item.text;
|
|
137
|
+
const created = item.created;
|
|
138
|
+
if (typeof id !== "string" || typeof text !== "string" || typeof created !== "number") {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
messages.push({ id, text, created });
|
|
142
|
+
}
|
|
143
|
+
return messages;
|
|
144
|
+
}
|
|
145
|
+
function parseMessagesMetadata(state) {
|
|
146
|
+
if (!state || state.kind !== "custom") {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
const flow = state.metadata.flow;
|
|
150
|
+
const stage = state.metadata.stage;
|
|
151
|
+
const messageId = state.metadata.messageId;
|
|
152
|
+
const projectDirectory = state.metadata.projectDirectory;
|
|
153
|
+
const sessionId = state.metadata.sessionId;
|
|
154
|
+
const messages = parseMessages(state.metadata.messages);
|
|
155
|
+
const page = typeof state.metadata.page === "number" && Number.isInteger(state.metadata.page)
|
|
156
|
+
? Math.max(0, state.metadata.page)
|
|
157
|
+
: 0;
|
|
158
|
+
if (flow !== "messages" ||
|
|
159
|
+
typeof messageId !== "number" ||
|
|
160
|
+
typeof projectDirectory !== "string" ||
|
|
161
|
+
typeof sessionId !== "string" ||
|
|
162
|
+
!messages) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
if (stage === "list") {
|
|
166
|
+
return {
|
|
167
|
+
flow,
|
|
168
|
+
stage,
|
|
169
|
+
messageId,
|
|
170
|
+
projectDirectory,
|
|
171
|
+
sessionId,
|
|
172
|
+
messages,
|
|
173
|
+
page,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
if (stage === "detail") {
|
|
177
|
+
const selectedIndex = state.metadata.selectedIndex;
|
|
178
|
+
if (typeof selectedIndex !== "number" || !Number.isInteger(selectedIndex) || selectedIndex < 0) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
flow,
|
|
183
|
+
stage,
|
|
184
|
+
messageId,
|
|
185
|
+
projectDirectory,
|
|
186
|
+
sessionId,
|
|
187
|
+
messages,
|
|
188
|
+
page,
|
|
189
|
+
selectedIndex,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
function clearMessagesInteraction(reason) {
|
|
195
|
+
const metadata = parseMessagesMetadata(interactionManager.getSnapshot());
|
|
196
|
+
if (metadata) {
|
|
197
|
+
interactionManager.clear(reason);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function loadUserMessages(sessionId, directory) {
|
|
201
|
+
const { data, error } = await opencodeClient.session.messages({
|
|
202
|
+
sessionID: sessionId,
|
|
203
|
+
directory,
|
|
204
|
+
});
|
|
205
|
+
if (error || !data) {
|
|
206
|
+
throw error || new Error("No message data received");
|
|
207
|
+
}
|
|
208
|
+
// Get session info to check for revert
|
|
209
|
+
const { data: sessionData } = await opencodeClient.session.get({
|
|
210
|
+
sessionID: sessionId,
|
|
211
|
+
directory,
|
|
212
|
+
});
|
|
213
|
+
const revertMessageID = sessionData?.revert?.messageID;
|
|
214
|
+
const messages = data
|
|
215
|
+
.map((message) => {
|
|
216
|
+
if (message.info.role !== "user") {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
const text = extractTextParts(message.parts);
|
|
220
|
+
if (!text) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
id: message.info.id ?? `${message.info.time?.created ?? 0}`,
|
|
225
|
+
text,
|
|
226
|
+
created: message.info.time?.created ?? 0,
|
|
227
|
+
};
|
|
228
|
+
})
|
|
229
|
+
.filter((message) => Boolean(message))
|
|
230
|
+
.sort((a, b) => b.created - a.created);
|
|
231
|
+
// If there's a revert, filter messages to only include those before the revert point
|
|
232
|
+
// Messages are sorted newest first, so we need to skip the revert message and everything after it
|
|
233
|
+
if (revertMessageID) {
|
|
234
|
+
const revertIndex = messages.findIndex((msg) => msg.id === revertMessageID);
|
|
235
|
+
if (revertIndex !== -1) {
|
|
236
|
+
return messages.slice(revertIndex + 1);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return messages;
|
|
240
|
+
}
|
|
241
|
+
async function loadLatestAssistantResponse(sessionId, directory) {
|
|
242
|
+
try {
|
|
243
|
+
const { data: messages, error } = await opencodeClient.session.messages({
|
|
244
|
+
sessionID: sessionId,
|
|
245
|
+
directory,
|
|
246
|
+
limit: LATEST_ASSISTANT_RESPONSE_MESSAGES_LIMIT,
|
|
247
|
+
});
|
|
248
|
+
if (error || !messages) {
|
|
249
|
+
logger.warn("[Messages] Failed to fetch latest assistant response:", error);
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
const latestResponse = messages.reduce((latest, message) => {
|
|
253
|
+
if (message.info.role !== "assistant") {
|
|
254
|
+
return latest;
|
|
255
|
+
}
|
|
256
|
+
const assistantInfo = message.info;
|
|
257
|
+
if (assistantInfo.summary) {
|
|
258
|
+
return latest;
|
|
259
|
+
}
|
|
260
|
+
const text = extractTextParts(message.parts);
|
|
261
|
+
if (!text) {
|
|
262
|
+
return latest;
|
|
263
|
+
}
|
|
264
|
+
const created = message.info.time?.created ?? 0;
|
|
265
|
+
if (!latest || created >= latest.created) {
|
|
266
|
+
return { text, created };
|
|
267
|
+
}
|
|
268
|
+
return latest;
|
|
269
|
+
}, null);
|
|
270
|
+
return latestResponse?.text ?? null;
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
logger.error("[Messages] Error loading latest assistant response:", err);
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async function sendLatestAssistantResponse(api, chatId, sessionId, directory) {
|
|
278
|
+
const responseText = await loadLatestAssistantResponse(sessionId, directory);
|
|
279
|
+
if (!responseText) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const parts = renderAssistantFinalPartsSafe(responseText, TELEGRAM_MESSAGE_LIMIT);
|
|
283
|
+
for (const part of parts) {
|
|
284
|
+
await sendRenderedBotPart({
|
|
285
|
+
api,
|
|
286
|
+
chatId,
|
|
287
|
+
part,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function parseSelectIndex(data) {
|
|
292
|
+
if (!data.startsWith(MESSAGES_CALLBACK_SELECT_PREFIX)) {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
const rawIndex = data.slice(MESSAGES_CALLBACK_SELECT_PREFIX.length);
|
|
296
|
+
const index = Number(rawIndex);
|
|
297
|
+
if (!Number.isInteger(index) || index < 0) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
return index;
|
|
301
|
+
}
|
|
302
|
+
export async function messagesCommand(ctx) {
|
|
303
|
+
try {
|
|
304
|
+
const currentProject = getCurrentProject();
|
|
305
|
+
if (!currentProject) {
|
|
306
|
+
await ctx.reply(t("messages.project_not_selected"));
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const currentSession = getCurrentSession();
|
|
310
|
+
if (!currentSession) {
|
|
311
|
+
await ctx.reply(t("messages.session_not_selected"));
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (currentSession.directory !== currentProject.worktree) {
|
|
315
|
+
await ctx.reply(t("messages.session_project_mismatch"));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const messages = await loadUserMessages(currentSession.id, currentSession.directory);
|
|
319
|
+
if (messages.length === 0) {
|
|
320
|
+
await ctx.reply(t("messages.empty"));
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const pageSize = config.bot.messagesListLimit;
|
|
324
|
+
const keyboard = buildMessagesListKeyboard(messages, 0, pageSize);
|
|
325
|
+
const message = await ctx.reply(formatMessagesSelectText(0), {
|
|
326
|
+
reply_markup: keyboard,
|
|
327
|
+
});
|
|
328
|
+
interactionManager.start({
|
|
329
|
+
kind: "custom",
|
|
330
|
+
expectedInput: "callback",
|
|
331
|
+
metadata: {
|
|
332
|
+
flow: "messages",
|
|
333
|
+
stage: "list",
|
|
334
|
+
messageId: message.message_id,
|
|
335
|
+
projectDirectory: currentProject.worktree,
|
|
336
|
+
sessionId: currentSession.id,
|
|
337
|
+
messages,
|
|
338
|
+
page: 0,
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
logger.error("[Messages] Error fetching messages list:", error);
|
|
344
|
+
await ctx.reply(t("messages.fetch_error"));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
export async function handleMessagesCallback(ctx, deps) {
|
|
348
|
+
const data = ctx.callbackQuery?.data;
|
|
349
|
+
if (!data || !data.startsWith(MESSAGES_CALLBACK_PREFIX)) {
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
const metadata = parseMessagesMetadata(interactionManager.getSnapshot());
|
|
353
|
+
const callbackMessageId = getCallbackMessageId(ctx);
|
|
354
|
+
if (!metadata || callbackMessageId === null || metadata.messageId !== callbackMessageId) {
|
|
355
|
+
await ctx.answerCallbackQuery({ text: t("messages.inactive_callback"), show_alert: true });
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
if (data === MESSAGES_CALLBACK_REVERT) {
|
|
360
|
+
if (metadata.stage !== "detail") {
|
|
361
|
+
await ctx.answerCallbackQuery({ text: t("messages.inactive_callback"), show_alert: true });
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
const selectedMessage = metadata.messages[metadata.selectedIndex];
|
|
365
|
+
if (!selectedMessage) {
|
|
366
|
+
await ctx.answerCallbackQuery({ text: t("messages.fetch_error"), show_alert: true });
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
await ctx.answerCallbackQuery();
|
|
370
|
+
try {
|
|
371
|
+
await opencodeClient.session.revert({
|
|
372
|
+
sessionID: metadata.sessionId,
|
|
373
|
+
directory: metadata.projectDirectory,
|
|
374
|
+
messageID: selectedMessage.id,
|
|
375
|
+
});
|
|
376
|
+
const successText = t("messages.revert_success", { text: selectedMessage.text });
|
|
377
|
+
await ctx.editMessageText(truncateText(successText, TELEGRAM_MESSAGE_LIMIT), {
|
|
378
|
+
reply_markup: undefined,
|
|
379
|
+
});
|
|
380
|
+
clearMessagesInteraction("messages_revert_success");
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
logger.error("[Messages] Error reverting message:", error);
|
|
384
|
+
await ctx.editMessageText(t("messages.revert_error"), { reply_markup: undefined });
|
|
385
|
+
clearMessagesInteraction("messages_revert_error");
|
|
386
|
+
}
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
if (data === MESSAGES_CALLBACK_FORK) {
|
|
390
|
+
if (metadata.stage !== "detail") {
|
|
391
|
+
await ctx.answerCallbackQuery({ text: t("messages.inactive_callback"), show_alert: true });
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
const selectedMessage = metadata.messages[metadata.selectedIndex];
|
|
395
|
+
if (!selectedMessage) {
|
|
396
|
+
await ctx.answerCallbackQuery({ text: t("messages.fetch_error"), show_alert: true });
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
await ctx.answerCallbackQuery();
|
|
400
|
+
try {
|
|
401
|
+
const { data: forkedSession, error: forkError } = await opencodeClient.session.fork({
|
|
402
|
+
sessionID: metadata.sessionId,
|
|
403
|
+
messageID: selectedMessage.id,
|
|
404
|
+
directory: metadata.projectDirectory,
|
|
405
|
+
});
|
|
406
|
+
if (forkError || !forkedSession) {
|
|
407
|
+
throw forkError || new Error("No session data received from fork");
|
|
408
|
+
}
|
|
409
|
+
logger.info(`[Messages] Forked session: id=${forkedSession.id}, title="${forkedSession.title}", from message=${selectedMessage.id}`);
|
|
410
|
+
const sessionInfo = {
|
|
411
|
+
id: forkedSession.id,
|
|
412
|
+
title: forkedSession.title,
|
|
413
|
+
directory: metadata.projectDirectory,
|
|
414
|
+
};
|
|
415
|
+
setCurrentSession(sessionInfo);
|
|
416
|
+
clearAllInteractionState("session_forked");
|
|
417
|
+
await ingestSessionInfoForCache(forkedSession);
|
|
418
|
+
await attachToSession({
|
|
419
|
+
bot: deps.bot,
|
|
420
|
+
chatId: ctx.chat.id,
|
|
421
|
+
session: sessionInfo,
|
|
422
|
+
ensureEventSubscription: deps.ensureEventSubscription,
|
|
423
|
+
});
|
|
424
|
+
const successText = t("messages.fork_success", { text: selectedMessage.text });
|
|
425
|
+
await ctx.editMessageText(truncateText(successText, TELEGRAM_MESSAGE_LIMIT), {
|
|
426
|
+
reply_markup: undefined,
|
|
427
|
+
});
|
|
428
|
+
clearMessagesInteraction("messages_fork_success");
|
|
429
|
+
safeBackgroundTask({
|
|
430
|
+
taskName: "messages.sendLatestAssistantResponse",
|
|
431
|
+
task: () => sendLatestAssistantResponse(ctx.api, ctx.chat.id, forkedSession.id, metadata.projectDirectory),
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
logger.error("[Messages] Error forking session:", error);
|
|
436
|
+
await ctx.editMessageText(t("messages.fork_error"), { reply_markup: undefined });
|
|
437
|
+
clearMessagesInteraction("messages_fork_error");
|
|
438
|
+
}
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
if (data === MESSAGES_CALLBACK_BACK) {
|
|
442
|
+
if (metadata.stage !== "detail") {
|
|
443
|
+
await ctx.answerCallbackQuery({ text: t("messages.inactive_callback"), show_alert: true });
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
const pageSize = config.bot.messagesListLimit;
|
|
447
|
+
const { page: normalizedPage } = calculateMessagesPaginationRange(metadata.messages.length, metadata.page, pageSize);
|
|
448
|
+
await ctx.editMessageText(formatMessagesSelectText(normalizedPage), {
|
|
449
|
+
reply_markup: buildMessagesListKeyboard(metadata.messages, normalizedPage, pageSize),
|
|
450
|
+
});
|
|
451
|
+
await ctx.answerCallbackQuery();
|
|
452
|
+
interactionManager.transition({
|
|
453
|
+
expectedInput: "callback",
|
|
454
|
+
metadata: {
|
|
455
|
+
flow: "messages",
|
|
456
|
+
stage: "list",
|
|
457
|
+
messageId: metadata.messageId,
|
|
458
|
+
projectDirectory: metadata.projectDirectory,
|
|
459
|
+
sessionId: metadata.sessionId,
|
|
460
|
+
messages: metadata.messages,
|
|
461
|
+
page: normalizedPage,
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
return true;
|
|
465
|
+
}
|
|
466
|
+
if (data === MESSAGES_CALLBACK_CANCEL) {
|
|
467
|
+
clearMessagesInteraction("messages_cancelled");
|
|
468
|
+
await ctx.answerCallbackQuery({ text: t("messages.cancelled_callback") });
|
|
469
|
+
await ctx.deleteMessage().catch(() => { });
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
const page = parseMessagePageCallback(data);
|
|
473
|
+
if (page !== null) {
|
|
474
|
+
if (metadata.stage !== "list") {
|
|
475
|
+
await ctx.answerCallbackQuery({ text: t("callback.processing_error"), show_alert: true });
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
const pageSize = config.bot.messagesListLimit;
|
|
479
|
+
const { page: normalizedPage, totalPages } = calculateMessagesPaginationRange(metadata.messages.length, page, pageSize);
|
|
480
|
+
if (page >= totalPages || page < 0) {
|
|
481
|
+
await ctx.answerCallbackQuery({ text: t("messages.page_empty_callback") });
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
await ctx.editMessageText(formatMessagesSelectText(normalizedPage), {
|
|
485
|
+
reply_markup: buildMessagesListKeyboard(metadata.messages, normalizedPage, pageSize),
|
|
486
|
+
});
|
|
487
|
+
await ctx.answerCallbackQuery();
|
|
488
|
+
interactionManager.transition({
|
|
489
|
+
expectedInput: "callback",
|
|
490
|
+
metadata: {
|
|
491
|
+
flow: "messages",
|
|
492
|
+
stage: "list",
|
|
493
|
+
messageId: metadata.messageId,
|
|
494
|
+
projectDirectory: metadata.projectDirectory,
|
|
495
|
+
sessionId: metadata.sessionId,
|
|
496
|
+
messages: metadata.messages,
|
|
497
|
+
page: normalizedPage,
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
const messageIndex = parseSelectIndex(data);
|
|
503
|
+
if (messageIndex === null || metadata.stage !== "list") {
|
|
504
|
+
await ctx.answerCallbackQuery({ text: t("callback.processing_error"), show_alert: true });
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
const selectedMessage = metadata.messages[messageIndex];
|
|
508
|
+
if (!selectedMessage) {
|
|
509
|
+
await ctx.answerCallbackQuery({ text: t("messages.inactive_callback"), show_alert: true });
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
await ctx.editMessageText(formatMessageDetailText(selectedMessage), {
|
|
513
|
+
reply_markup: buildMessageDetailKeyboard(),
|
|
514
|
+
});
|
|
515
|
+
await ctx.answerCallbackQuery();
|
|
516
|
+
interactionManager.transition({
|
|
517
|
+
expectedInput: "callback",
|
|
518
|
+
metadata: {
|
|
519
|
+
flow: "messages",
|
|
520
|
+
stage: "detail",
|
|
521
|
+
messageId: metadata.messageId,
|
|
522
|
+
projectDirectory: metadata.projectDirectory,
|
|
523
|
+
sessionId: metadata.sessionId,
|
|
524
|
+
messages: metadata.messages,
|
|
525
|
+
page: metadata.page,
|
|
526
|
+
selectedIndex: messageIndex,
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
logger.error("[Messages] Error handling messages callback:", error);
|
|
533
|
+
clearMessagesInteraction("messages_callback_error");
|
|
534
|
+
await ctx.answerCallbackQuery({ text: t("callback.processing_error") }).catch(() => { });
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
}
|