@grinev/opencode-telegram-bot 0.11.4 → 0.12.1
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 +3 -0
- package/README.md +16 -0
- package/dist/app/start-bot-app.js +2 -0
- package/dist/bot/commands/abort.js +2 -0
- package/dist/bot/commands/commands.js +5 -0
- package/dist/bot/commands/definitions.js +2 -0
- package/dist/bot/commands/start.js +2 -0
- package/dist/bot/commands/task.js +399 -0
- package/dist/bot/commands/tasklist.js +220 -0
- package/dist/bot/handlers/prompt.js +8 -0
- package/dist/bot/index.js +50 -3
- package/dist/bot/middleware/interaction-guard.js +11 -0
- package/dist/config.js +1 -0
- package/dist/i18n/de.js +39 -1
- package/dist/i18n/en.js +39 -1
- package/dist/i18n/es.js +39 -1
- package/dist/i18n/fr.js +39 -1
- package/dist/i18n/ru.js +39 -1
- package/dist/i18n/zh.js +39 -1
- package/dist/interaction/cleanup.js +10 -2
- package/dist/interaction/guard.js +7 -0
- package/dist/pinned/manager.js +23 -3
- package/dist/scheduled-task/creation-manager.js +113 -0
- package/dist/scheduled-task/display.js +239 -0
- package/dist/scheduled-task/executor.js +87 -0
- package/dist/scheduled-task/foreground-state.js +32 -0
- package/dist/scheduled-task/next-run.js +207 -0
- package/dist/scheduled-task/runtime.js +362 -0
- package/dist/scheduled-task/schedule-parser.js +169 -0
- package/dist/scheduled-task/store.js +65 -0
- package/dist/scheduled-task/types.js +19 -0
- package/dist/settings/manager.js +12 -0
- package/dist/summary/aggregator.js +9 -0
- package/package.json +1 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { InlineKeyboard } from "grammy";
|
|
2
|
+
import { getDateLocale, t } from "../../i18n/index.js";
|
|
3
|
+
import { interactionManager } from "../../interaction/manager.js";
|
|
4
|
+
import { formatTaskListBadge } from "../../scheduled-task/display.js";
|
|
5
|
+
import { scheduledTaskRuntime } from "../../scheduled-task/runtime.js";
|
|
6
|
+
import { getScheduledTask, listScheduledTasks, removeScheduledTask, } from "../../scheduled-task/store.js";
|
|
7
|
+
import { logger } from "../../utils/logger.js";
|
|
8
|
+
const TASKLIST_CALLBACK_PREFIX = "tasklist:";
|
|
9
|
+
const TASKLIST_OPEN_PREFIX = `${TASKLIST_CALLBACK_PREFIX}open:`;
|
|
10
|
+
const TASKLIST_DELETE_PREFIX = `${TASKLIST_CALLBACK_PREFIX}delete:`;
|
|
11
|
+
const TASKLIST_CANCEL_CALLBACK = `${TASKLIST_CALLBACK_PREFIX}cancel`;
|
|
12
|
+
const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
|
|
13
|
+
function getCallbackMessageId(ctx) {
|
|
14
|
+
const message = ctx.callbackQuery?.message;
|
|
15
|
+
if (!message || !("message_id" in message)) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const messageId = message.message_id;
|
|
19
|
+
return typeof messageId === "number" ? messageId : null;
|
|
20
|
+
}
|
|
21
|
+
function parseTaskListMetadata(state) {
|
|
22
|
+
if (!state || state.kind !== "custom") {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const flow = state.metadata.flow;
|
|
26
|
+
const stage = state.metadata.stage;
|
|
27
|
+
const messageId = state.metadata.messageId;
|
|
28
|
+
if (flow !== "tasklist" || typeof messageId !== "number") {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
if (stage === "list") {
|
|
32
|
+
return {
|
|
33
|
+
flow,
|
|
34
|
+
stage,
|
|
35
|
+
messageId,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (stage === "detail") {
|
|
39
|
+
const taskId = state.metadata.taskId;
|
|
40
|
+
if (typeof taskId !== "string" || !taskId) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
flow,
|
|
45
|
+
stage,
|
|
46
|
+
messageId,
|
|
47
|
+
taskId,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
function clearTaskListInteraction(reason) {
|
|
53
|
+
const metadata = parseTaskListMetadata(interactionManager.getSnapshot());
|
|
54
|
+
if (metadata) {
|
|
55
|
+
interactionManager.clear(reason);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function truncateText(text, maxLength) {
|
|
59
|
+
if (text.length <= maxLength) {
|
|
60
|
+
return text;
|
|
61
|
+
}
|
|
62
|
+
return `${text.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
|
|
63
|
+
}
|
|
64
|
+
function formatDateTime(dateIso, timezone) {
|
|
65
|
+
if (!dateIso) {
|
|
66
|
+
return "-";
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
return new Intl.DateTimeFormat(getDateLocale(), {
|
|
70
|
+
dateStyle: "medium",
|
|
71
|
+
timeStyle: "short",
|
|
72
|
+
timeZone: timezone,
|
|
73
|
+
}).format(new Date(dateIso));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return dateIso;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function formatTaskButtonPrefix(task) {
|
|
80
|
+
return formatTaskListBadge(task);
|
|
81
|
+
}
|
|
82
|
+
function formatTaskButtonLabel(task) {
|
|
83
|
+
const prefix = `[${formatTaskButtonPrefix(task)}]`;
|
|
84
|
+
const prompt = task.prompt.replace(/\s+/g, " ").trim();
|
|
85
|
+
return truncateText(`${prefix} ${prompt}`, MAX_INLINE_BUTTON_LABEL_LENGTH);
|
|
86
|
+
}
|
|
87
|
+
function buildTaskListKeyboard(tasks) {
|
|
88
|
+
const keyboard = new InlineKeyboard();
|
|
89
|
+
tasks.forEach((task) => {
|
|
90
|
+
keyboard.text(formatTaskButtonLabel(task), `${TASKLIST_OPEN_PREFIX}${task.id}`).row();
|
|
91
|
+
});
|
|
92
|
+
keyboard.text(t("tasklist.button.cancel"), TASKLIST_CANCEL_CALLBACK);
|
|
93
|
+
return keyboard;
|
|
94
|
+
}
|
|
95
|
+
function buildTaskDetailsKeyboard(taskId) {
|
|
96
|
+
return new InlineKeyboard()
|
|
97
|
+
.text(t("tasklist.button.delete"), `${TASKLIST_DELETE_PREFIX}${taskId}`)
|
|
98
|
+
.text(t("tasklist.button.cancel"), TASKLIST_CANCEL_CALLBACK);
|
|
99
|
+
}
|
|
100
|
+
function sortTasks(tasks) {
|
|
101
|
+
return [...tasks].sort((left, right) => {
|
|
102
|
+
const leftNextRun = left.nextRunAt ? Date.parse(left.nextRunAt) : Number.POSITIVE_INFINITY;
|
|
103
|
+
const rightNextRun = right.nextRunAt ? Date.parse(right.nextRunAt) : Number.POSITIVE_INFINITY;
|
|
104
|
+
if (leftNextRun !== rightNextRun) {
|
|
105
|
+
return leftNextRun - rightNextRun;
|
|
106
|
+
}
|
|
107
|
+
return left.createdAt.localeCompare(right.createdAt);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
function formatTaskDetails(task) {
|
|
111
|
+
const cronLine = task.kind === "cron" ? `${t("tasklist.details.cron", { cron: task.cron })}\n` : "";
|
|
112
|
+
return t("tasklist.details", {
|
|
113
|
+
prompt: task.prompt,
|
|
114
|
+
project: task.projectWorktree,
|
|
115
|
+
schedule: task.scheduleSummary,
|
|
116
|
+
cronLine,
|
|
117
|
+
timezone: task.timezone,
|
|
118
|
+
nextRunAt: formatDateTime(task.nextRunAt, task.timezone),
|
|
119
|
+
lastRunAt: formatDateTime(task.lastRunAt, task.timezone),
|
|
120
|
+
runCount: String(task.runCount),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
export async function taskListCommand(ctx) {
|
|
124
|
+
try {
|
|
125
|
+
const tasks = sortTasks(listScheduledTasks());
|
|
126
|
+
if (tasks.length === 0) {
|
|
127
|
+
await ctx.reply(t("tasklist.empty"));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const message = await ctx.reply(t("tasklist.select"), {
|
|
131
|
+
reply_markup: buildTaskListKeyboard(tasks),
|
|
132
|
+
});
|
|
133
|
+
interactionManager.start({
|
|
134
|
+
kind: "custom",
|
|
135
|
+
expectedInput: "callback",
|
|
136
|
+
metadata: {
|
|
137
|
+
flow: "tasklist",
|
|
138
|
+
stage: "list",
|
|
139
|
+
messageId: message.message_id,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
logger.error("[TaskList] Failed to open task list", error);
|
|
145
|
+
await ctx.reply(t("tasklist.load_error"));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export async function handleTaskListCallback(ctx) {
|
|
149
|
+
const data = ctx.callbackQuery?.data;
|
|
150
|
+
if (!data || !data.startsWith(TASKLIST_CALLBACK_PREFIX)) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
const metadata = parseTaskListMetadata(interactionManager.getSnapshot());
|
|
154
|
+
const callbackMessageId = getCallbackMessageId(ctx);
|
|
155
|
+
if (!metadata || callbackMessageId === null || metadata.messageId !== callbackMessageId) {
|
|
156
|
+
await ctx.answerCallbackQuery({ text: t("tasklist.inactive_callback"), show_alert: true });
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
if (data === TASKLIST_CANCEL_CALLBACK) {
|
|
161
|
+
clearTaskListInteraction("tasklist_cancelled");
|
|
162
|
+
await ctx.answerCallbackQuery({ text: t("tasklist.cancelled_callback") });
|
|
163
|
+
await ctx.deleteMessage().catch(() => { });
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
if (data.startsWith(TASKLIST_OPEN_PREFIX)) {
|
|
167
|
+
if (metadata.stage !== "list") {
|
|
168
|
+
await ctx.answerCallbackQuery({ text: t("tasklist.inactive_callback"), show_alert: true });
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
const taskId = data.slice(TASKLIST_OPEN_PREFIX.length);
|
|
172
|
+
const task = getScheduledTask(taskId);
|
|
173
|
+
if (!task) {
|
|
174
|
+
clearTaskListInteraction("tasklist_selected_task_missing");
|
|
175
|
+
await ctx.answerCallbackQuery({ text: t("tasklist.inactive_callback"), show_alert: true });
|
|
176
|
+
await ctx.deleteMessage().catch(() => { });
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
await ctx.answerCallbackQuery();
|
|
180
|
+
await ctx.editMessageText(formatTaskDetails(task), {
|
|
181
|
+
reply_markup: buildTaskDetailsKeyboard(task.id),
|
|
182
|
+
});
|
|
183
|
+
interactionManager.transition({
|
|
184
|
+
expectedInput: "callback",
|
|
185
|
+
metadata: {
|
|
186
|
+
flow: "tasklist",
|
|
187
|
+
stage: "detail",
|
|
188
|
+
messageId: metadata.messageId,
|
|
189
|
+
taskId: task.id,
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
if (data.startsWith(TASKLIST_DELETE_PREFIX)) {
|
|
195
|
+
if (metadata.stage !== "detail") {
|
|
196
|
+
await ctx.answerCallbackQuery({ text: t("tasklist.inactive_callback"), show_alert: true });
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
const taskId = data.slice(TASKLIST_DELETE_PREFIX.length);
|
|
200
|
+
if (taskId !== metadata.taskId) {
|
|
201
|
+
await ctx.answerCallbackQuery({ text: t("tasklist.inactive_callback"), show_alert: true });
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
await removeScheduledTask(taskId);
|
|
205
|
+
scheduledTaskRuntime.removeTask(taskId);
|
|
206
|
+
clearTaskListInteraction("tasklist_deleted");
|
|
207
|
+
await ctx.answerCallbackQuery({ text: t("tasklist.deleted_callback") });
|
|
208
|
+
await ctx.deleteMessage().catch(() => { });
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
await ctx.answerCallbackQuery({ text: t("callback.processing_error"), show_alert: true });
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
logger.error("[TaskList] Failed to handle task list callback", error);
|
|
216
|
+
clearTaskListInteraction("tasklist_callback_error");
|
|
217
|
+
await ctx.answerCallbackQuery({ text: t("callback.processing_error") }).catch(() => { });
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -16,6 +16,7 @@ import { safeBackgroundTask } from "../../utils/safe-background-task.js";
|
|
|
16
16
|
import { formatErrorDetails } from "../../utils/error-format.js";
|
|
17
17
|
import { logger } from "../../utils/logger.js";
|
|
18
18
|
import { t } from "../../i18n/index.js";
|
|
19
|
+
import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
|
|
19
20
|
/** Module-level references for async callbacks that don't have ctx. */
|
|
20
21
|
let botInstance = null;
|
|
21
22
|
let chatIdInstance = null;
|
|
@@ -47,6 +48,7 @@ async function isSessionBusy(sessionId, directory) {
|
|
|
47
48
|
async function resetMismatchedSessionContext() {
|
|
48
49
|
stopEventListening();
|
|
49
50
|
summaryAggregator.clear();
|
|
51
|
+
foregroundSessionState.clearAll("session_mismatch_reset");
|
|
50
52
|
clearAllInteractionState("session_mismatch_reset");
|
|
51
53
|
clearSession();
|
|
52
54
|
keyboardManager.clearContext();
|
|
@@ -192,6 +194,7 @@ export async function processUserPrompt(ctx, text, deps, fileParts = []) {
|
|
|
192
194
|
fileCount: fileParts.length,
|
|
193
195
|
};
|
|
194
196
|
logger.info(`[Bot] Calling session.prompt (fire-and-forget) with agent=${currentAgent}, fileCount=${fileParts.length}...`);
|
|
197
|
+
foregroundSessionState.markBusy(currentSession.id);
|
|
195
198
|
// CRITICAL: DO NOT wait for session.prompt to complete.
|
|
196
199
|
// If we wait, the handler will not finish and grammY will not call getUpdates,
|
|
197
200
|
// which blocks receiving button callback_query updates.
|
|
@@ -201,6 +204,7 @@ export async function processUserPrompt(ctx, text, deps, fileParts = []) {
|
|
|
201
204
|
task: () => opencodeClient.session.prompt(promptOptions),
|
|
202
205
|
onSuccess: ({ error }) => {
|
|
203
206
|
if (error) {
|
|
207
|
+
foregroundSessionState.markIdle(currentSession.id);
|
|
204
208
|
const details = formatErrorDetails(error, 6000);
|
|
205
209
|
logger.error("[Bot] OpenCode API returned an error for session.prompt", promptErrorLogContext);
|
|
206
210
|
logger.error("[Bot] session.prompt error details:", details);
|
|
@@ -212,6 +216,7 @@ export async function processUserPrompt(ctx, text, deps, fileParts = []) {
|
|
|
212
216
|
logger.info("[Bot] session.prompt completed");
|
|
213
217
|
},
|
|
214
218
|
onError: (error) => {
|
|
219
|
+
foregroundSessionState.markIdle(currentSession.id);
|
|
215
220
|
const details = formatErrorDetails(error, 6000);
|
|
216
221
|
logger.error("[Bot] session.prompt background task failed", promptErrorLogContext);
|
|
217
222
|
logger.error("[Bot] session.prompt background failure details:", details);
|
|
@@ -222,6 +227,9 @@ export async function processUserPrompt(ctx, text, deps, fileParts = []) {
|
|
|
222
227
|
return true;
|
|
223
228
|
}
|
|
224
229
|
catch (err) {
|
|
230
|
+
if (currentSession) {
|
|
231
|
+
foregroundSessionState.markIdle(currentSession.id);
|
|
232
|
+
}
|
|
225
233
|
logger.error("Error in prompt handler:", err);
|
|
226
234
|
if (interactionManager.getSnapshot()) {
|
|
227
235
|
clearAllInteractionState("message_handler_error");
|
package/dist/bot/index.js
CHANGED
|
@@ -20,6 +20,8 @@ import { abortCommand } from "./commands/abort.js";
|
|
|
20
20
|
import { opencodeStartCommand } from "./commands/opencode-start.js";
|
|
21
21
|
import { opencodeStopCommand } from "./commands/opencode-stop.js";
|
|
22
22
|
import { renameCommand, handleRenameCancel, handleRenameTextAnswer } from "./commands/rename.js";
|
|
23
|
+
import { handleTaskCallback, handleTaskTextInput, taskCommand } from "./commands/task.js";
|
|
24
|
+
import { handleTaskListCallback, taskListCommand } from "./commands/tasklist.js";
|
|
23
25
|
import { commandsCommand, handleCommandsCallback, handleCommandTextArguments, } from "./commands/commands.js";
|
|
24
26
|
import { handleQuestionCallback, showCurrentQuestion, handleQuestionTextAnswer, } from "./handlers/question.js";
|
|
25
27
|
import { handlePermissionCallback, showPermissionRequest } from "./handlers/permission.js";
|
|
@@ -49,6 +51,8 @@ import { downloadTelegramFile, toDataUri } from "./utils/file-download.js";
|
|
|
49
51
|
import { sendBotText } from "./utils/telegram-text.js";
|
|
50
52
|
import { getModelCapabilities, supportsInput } from "../model/capabilities.js";
|
|
51
53
|
import { getStoredModel } from "../model/manager.js";
|
|
54
|
+
import { foregroundSessionState } from "../scheduled-task/foreground-state.js";
|
|
55
|
+
import { scheduledTaskRuntime } from "../scheduled-task/runtime.js";
|
|
52
56
|
let botInstance = null;
|
|
53
57
|
let chatIdInstance = null;
|
|
54
58
|
let commandsInitialized = false;
|
|
@@ -57,6 +61,12 @@ const SESSION_RETRY_PREFIX = "🔁";
|
|
|
57
61
|
const __filename = fileURLToPath(import.meta.url);
|
|
58
62
|
const __dirname = path.dirname(__filename);
|
|
59
63
|
const TEMP_DIR = path.join(__dirname, "..", ".tmp");
|
|
64
|
+
function getCurrentReplyKeyboard() {
|
|
65
|
+
if (!keyboardManager.isInitialized()) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
return keyboardManager.getKeyboard();
|
|
69
|
+
}
|
|
60
70
|
function prepareDocumentCaption(caption) {
|
|
61
71
|
const normalizedCaption = caption.trim();
|
|
62
72
|
if (!normalizedCaption) {
|
|
@@ -77,8 +87,10 @@ const toolMessageBatcher = new ToolMessageBatcher({
|
|
|
77
87
|
if (!currentSession || currentSession.id !== sessionId) {
|
|
78
88
|
return;
|
|
79
89
|
}
|
|
90
|
+
const keyboard = getCurrentReplyKeyboard();
|
|
80
91
|
await botInstance.api.sendMessage(chatIdInstance, text, {
|
|
81
92
|
disable_notification: true,
|
|
93
|
+
...(keyboard ? { reply_markup: keyboard } : {}),
|
|
82
94
|
});
|
|
83
95
|
},
|
|
84
96
|
sendFile: async (sessionId, fileData) => {
|
|
@@ -94,9 +106,11 @@ const toolMessageBatcher = new ToolMessageBatcher({
|
|
|
94
106
|
logger.debug(`[Bot] Sending code file: ${fileData.filename} (${fileData.buffer.length} bytes, session=${sessionId})`);
|
|
95
107
|
await fs.mkdir(TEMP_DIR, { recursive: true });
|
|
96
108
|
await fs.writeFile(tempFilePath, fileData.buffer);
|
|
109
|
+
const keyboard = getCurrentReplyKeyboard();
|
|
97
110
|
await botInstance.api.sendDocument(chatIdInstance, new InputFile(tempFilePath), {
|
|
98
111
|
caption: fileData.caption,
|
|
99
112
|
disable_notification: true,
|
|
113
|
+
...(keyboard ? { reply_markup: keyboard } : {}),
|
|
100
114
|
});
|
|
101
115
|
}
|
|
102
116
|
finally {
|
|
@@ -141,10 +155,13 @@ async function ensureEventSubscription(directory) {
|
|
|
141
155
|
summaryAggregator.setOnComplete(async (sessionId, messageText) => {
|
|
142
156
|
if (!botInstance || !chatIdInstance) {
|
|
143
157
|
logger.error("Bot or chat ID not available for sending message");
|
|
158
|
+
foregroundSessionState.markIdle(sessionId);
|
|
144
159
|
return;
|
|
145
160
|
}
|
|
146
161
|
const currentSession = getCurrentSession();
|
|
147
162
|
if (currentSession?.id !== sessionId) {
|
|
163
|
+
foregroundSessionState.markIdle(sessionId);
|
|
164
|
+
await scheduledTaskRuntime.flushDeferredDeliveries();
|
|
148
165
|
return;
|
|
149
166
|
}
|
|
150
167
|
await toolMessageBatcher.flushSession(sessionId, "assistant_message_completed");
|
|
@@ -154,8 +171,7 @@ async function ensureEventSubscription(directory) {
|
|
|
154
171
|
const assistantMessageFormat = assistantParseMode === "MarkdownV2" ? "markdown_v2" : "raw";
|
|
155
172
|
logger.debug(`[Bot] Sending completed message to Telegram (chatId=${chatIdInstance}, parts=${parts.length})`);
|
|
156
173
|
for (let i = 0; i < parts.length; i++) {
|
|
157
|
-
const
|
|
158
|
-
const keyboard = isLastPart && keyboardManager.isInitialized() ? keyboardManager.getKeyboard() : undefined;
|
|
174
|
+
const keyboard = getCurrentReplyKeyboard();
|
|
159
175
|
const options = keyboard ? { reply_markup: keyboard } : undefined;
|
|
160
176
|
await sendBotText({
|
|
161
177
|
api: botInstance.api,
|
|
@@ -172,6 +188,10 @@ async function ensureEventSubscription(directory) {
|
|
|
172
188
|
logger.error("[Bot] CRITICAL: Stopping event processing due to error");
|
|
173
189
|
summaryAggregator.clear();
|
|
174
190
|
}
|
|
191
|
+
finally {
|
|
192
|
+
foregroundSessionState.markIdle(sessionId);
|
|
193
|
+
await scheduledTaskRuntime.flushDeferredDeliveries();
|
|
194
|
+
}
|
|
175
195
|
});
|
|
176
196
|
summaryAggregator.setOnTool(async (toolInfo) => {
|
|
177
197
|
if (!botInstance || !chatIdInstance) {
|
|
@@ -294,6 +314,18 @@ async function ensureEventSubscription(directory) {
|
|
|
294
314
|
logger.error("[Bot] Error updating pinned message with tokens:", err);
|
|
295
315
|
}
|
|
296
316
|
});
|
|
317
|
+
summaryAggregator.setOnCost(async (cost) => {
|
|
318
|
+
if (!pinnedMessageManager.isInitialized()) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
logger.debug(`[Bot] Cost update: $${cost.toFixed(2)}`);
|
|
323
|
+
await pinnedMessageManager.onCostUpdate(cost);
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
logger.error("[Bot] Error updating cost:", err);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
297
329
|
summaryAggregator.setOnSessionCompacted(async (sessionId, directory) => {
|
|
298
330
|
if (!pinnedMessageManager.isInitialized()) {
|
|
299
331
|
return;
|
|
@@ -308,10 +340,13 @@ async function ensureEventSubscription(directory) {
|
|
|
308
340
|
});
|
|
309
341
|
summaryAggregator.setOnSessionError(async (sessionId, message) => {
|
|
310
342
|
if (!botInstance || !chatIdInstance) {
|
|
343
|
+
foregroundSessionState.markIdle(sessionId);
|
|
311
344
|
return;
|
|
312
345
|
}
|
|
313
346
|
const currentSession = getCurrentSession();
|
|
314
347
|
if (!currentSession || currentSession.id !== sessionId) {
|
|
348
|
+
foregroundSessionState.markIdle(sessionId);
|
|
349
|
+
await scheduledTaskRuntime.flushDeferredDeliveries();
|
|
315
350
|
return;
|
|
316
351
|
}
|
|
317
352
|
await toolMessageBatcher.flushSession(sessionId, "session_error");
|
|
@@ -324,6 +359,8 @@ async function ensureEventSubscription(directory) {
|
|
|
324
359
|
.catch((err) => {
|
|
325
360
|
logger.error("[Bot] Failed to send session.error message:", err);
|
|
326
361
|
});
|
|
362
|
+
foregroundSessionState.markIdle(sessionId);
|
|
363
|
+
await scheduledTaskRuntime.flushDeferredDeliveries();
|
|
327
364
|
});
|
|
328
365
|
summaryAggregator.setOnSessionRetry(async ({ sessionId, message }) => {
|
|
329
366
|
if (!botInstance || !chatIdInstance) {
|
|
@@ -458,6 +495,8 @@ export function createBot() {
|
|
|
458
495
|
bot.command("sessions", sessionsCommand);
|
|
459
496
|
bot.command("new", newCommand);
|
|
460
497
|
bot.command("abort", abortCommand);
|
|
498
|
+
bot.command("task", taskCommand);
|
|
499
|
+
bot.command("tasklist", taskListCommand);
|
|
461
500
|
bot.command("rename", renameCommand);
|
|
462
501
|
bot.command("commands", commandsCommand);
|
|
463
502
|
bot.on("message:text", unknownCommandMiddleware);
|
|
@@ -478,9 +517,11 @@ export function createBot() {
|
|
|
478
517
|
const handledModel = await handleModelSelect(ctx);
|
|
479
518
|
const handledVariant = await handleVariantSelect(ctx);
|
|
480
519
|
const handledCompactConfirm = await handleCompactConfirm(ctx);
|
|
520
|
+
const handledTask = await handleTaskCallback(ctx);
|
|
521
|
+
const handledTaskList = await handleTaskListCallback(ctx);
|
|
481
522
|
const handledRenameCancel = await handleRenameCancel(ctx);
|
|
482
523
|
const handledCommands = await handleCommandsCallback(ctx, { bot, ensureEventSubscription });
|
|
483
|
-
logger.debug(`[Bot] Callback handled: inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, rename=${handledRenameCancel}, commands=${handledCommands}`);
|
|
524
|
+
logger.debug(`[Bot] Callback handled: inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}`);
|
|
484
525
|
if (!handledInlineCancel &&
|
|
485
526
|
!handledSession &&
|
|
486
527
|
!handledProject &&
|
|
@@ -490,6 +531,8 @@ export function createBot() {
|
|
|
490
531
|
!handledModel &&
|
|
491
532
|
!handledVariant &&
|
|
492
533
|
!handledCompactConfirm &&
|
|
534
|
+
!handledTask &&
|
|
535
|
+
!handledTaskList &&
|
|
493
536
|
!handledRenameCancel &&
|
|
494
537
|
!handledCommands) {
|
|
495
538
|
logger.debug("Unknown callback query:", ctx.callbackQuery?.data);
|
|
@@ -677,6 +720,10 @@ export function createBot() {
|
|
|
677
720
|
await handleQuestionTextAnswer(ctx);
|
|
678
721
|
return;
|
|
679
722
|
}
|
|
723
|
+
const handledTask = await handleTaskTextInput(ctx);
|
|
724
|
+
if (handledTask) {
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
680
727
|
const handledRename = await handleRenameTextAnswer(ctx);
|
|
681
728
|
if (handledRename) {
|
|
682
729
|
return;
|
|
@@ -46,6 +46,17 @@ function getInteractionBlockedMessage(reason, interactionKind) {
|
|
|
46
46
|
return t("rename.blocked.expected_name");
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
|
+
if (interactionKind === "task") {
|
|
50
|
+
switch (reason) {
|
|
51
|
+
case "command_not_allowed":
|
|
52
|
+
return t("task.blocked.command_not_allowed");
|
|
53
|
+
case "expected_callback":
|
|
54
|
+
case "expected_command":
|
|
55
|
+
case "expected_text":
|
|
56
|
+
default:
|
|
57
|
+
return t("task.blocked.expected_input");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
49
60
|
switch (reason) {
|
|
50
61
|
case "expired":
|
|
51
62
|
return t("interaction.blocked.expired");
|
package/dist/config.js
CHANGED
|
@@ -85,6 +85,7 @@ export const config = {
|
|
|
85
85
|
bot: {
|
|
86
86
|
sessionsListLimit: getOptionalPositiveIntEnvVar("SESSIONS_LIST_LIMIT", 10),
|
|
87
87
|
projectsListLimit: getOptionalPositiveIntEnvVar("PROJECTS_LIST_LIMIT", 10),
|
|
88
|
+
taskLimit: getOptionalPositiveIntEnvVar("TASK_LIMIT", 10),
|
|
88
89
|
locale: getOptionalLocaleEnvVar("BOT_LOCALE", "en"),
|
|
89
90
|
serviceMessagesIntervalSec: getOptionalNonNegativeIntEnvVarFromKeys(["SERVICE_MESSAGES_INTERVAL_SEC", "TOOL_MESSAGES_INTERVAL_SEC"], 5),
|
|
90
91
|
hideThinkingMessages: getOptionalBooleanEnvVar("HIDE_THINKING_MESSAGES", false),
|
package/dist/i18n/de.js
CHANGED
|
@@ -4,6 +4,8 @@ export const de = {
|
|
|
4
4
|
"cmd.description.stop": "Aktuelle Aktion stoppen",
|
|
5
5
|
"cmd.description.sessions": "Sitzungen auflisten",
|
|
6
6
|
"cmd.description.projects": "Projekte auflisten",
|
|
7
|
+
"cmd.description.task": "Geplante Aufgabe erstellen",
|
|
8
|
+
"cmd.description.tasklist": "Geplante Aufgaben anzeigen",
|
|
7
9
|
"cmd.description.commands": "Benutzerdefinierte Befehle",
|
|
8
10
|
"cmd.description.opencode_start": "OpenCode-Server starten",
|
|
9
11
|
"cmd.description.opencode_stop": "OpenCode-Server stoppen",
|
|
@@ -30,7 +32,7 @@ export const de = {
|
|
|
30
32
|
"inline.cancelled_callback": "Abgebrochen",
|
|
31
33
|
"common.unknown": "unbekannt",
|
|
32
34
|
"common.unknown_error": "unbekannter Fehler",
|
|
33
|
-
"start.welcome": "👋 Willkommen beim OpenCode Telegram Bot!\n\nNutze Befehle:\n/projects — Projekt auswählen\n/sessions — Sitzungsliste\n/new — neue Sitzung\n/status — Status\n/help — Hilfe\n\nNutze die unteren Buttons, um Modus, Modell und Variante zu wählen.",
|
|
35
|
+
"start.welcome": "👋 Willkommen beim OpenCode Telegram Bot!\n\nNutze Befehle:\n/projects — Projekt auswählen\n/sessions — Sitzungsliste\n/new — neue Sitzung\n/task — geplante Aufgabe\n/tasklist — geplante Aufgaben\n/status — Status\n/help — Hilfe\n\nNutze die unteren Buttons, um Modus, Modell und Variante zu wählen.",
|
|
34
36
|
"help.keyboard_hint": "💡 Nutze die unteren Buttons für Modus, Modell, Variante und Kontextaktionen.",
|
|
35
37
|
"help.text": "📖 **Hilfe**\n\n/status - Serverstatus prüfen\n/sessions - Sitzungsliste\n/new - Neue Sitzung erstellen\n/help - Hilfe",
|
|
36
38
|
"bot.thinking": "💭 Denke...",
|
|
@@ -214,6 +216,7 @@ export const de = {
|
|
|
214
216
|
"pinned.line.project": "Projekt: {project}",
|
|
215
217
|
"pinned.line.model": "Modell: {model}",
|
|
216
218
|
"pinned.line.context": "Kontext: {used} / {limit} ({percent}%)",
|
|
219
|
+
"pinned.line.cost": "Kosten: {cost} ausgegeben",
|
|
217
220
|
"pinned.files.title": "Dateien ({count}):",
|
|
218
221
|
"pinned.files.item": " {path}{diff}",
|
|
219
222
|
"pinned.files.more": " ... und {count} mehr",
|
|
@@ -247,6 +250,41 @@ export const de = {
|
|
|
247
250
|
"rename.blocked.expected_name": "⚠️ Sende den neuen Sitzungsnamen als Text oder tippe in der Umbenennen-Nachricht auf Abbrechen.",
|
|
248
251
|
"rename.blocked.command_not_allowed": "⚠️ Dieser Befehl ist nicht verfügbar, solange beim Umbenennen auf einen neuen Namen gewartet wird.",
|
|
249
252
|
"rename.button.cancel": "❌ Abbrechen",
|
|
253
|
+
"task.prompt.schedule": "⏰ Sende den Zeitplan der Aufgabe in natürlicher Sprache.\n\nBeispiele:\n- alle 5 Minuten\n- jeden Tag um 17:00\n- morgen um 12:00",
|
|
254
|
+
"task.schedule_empty": "⚠️ Der Zeitplan darf nicht leer sein.",
|
|
255
|
+
"task.parse.in_progress": "⏳ Zeitplan wird verarbeitet...",
|
|
256
|
+
"task.parse_error": "🔴 Zeitplan konnte nicht erkannt werden.\n\n{message}\n\nSende den Zeitraum bitte noch einmal klarer formuliert.",
|
|
257
|
+
"task.schedule_preview": "✅ Zeitplan erkannt\n\nVerstanden als: {summary}\n{cronLine}Zeitzone: {timezone}\nTyp: {kind}\nNächster Lauf: {nextRunAt}",
|
|
258
|
+
"task.schedule_preview.cron": "Cron: {cron}",
|
|
259
|
+
"task.prompt.body": "📝 Sende jetzt, was der Bot nach Zeitplan tun soll.",
|
|
260
|
+
"task.prompt_empty": "⚠️ Der Aufgabentext darf nicht leer sein.",
|
|
261
|
+
"task.created": "✅ Geplante Aufgabe erstellt\n\nAufgabe: {description}\nProjekt: {project}\nModell: {model}\nZeitplan: {schedule}\n{cronLine}Nächster Lauf: {nextRunAt}",
|
|
262
|
+
"task.created.cron": "Cron: {cron}",
|
|
263
|
+
"task.button.retry_schedule": "🔁 Zeitplan neu eingeben",
|
|
264
|
+
"task.button.cancel": "❌ Abbrechen",
|
|
265
|
+
"task.retry_schedule_callback": "Zeitplaneingabe wird zurückgesetzt...",
|
|
266
|
+
"task.cancel_callback": "Abbruch...",
|
|
267
|
+
"task.cancelled": "❌ Erstellung der geplanten Aufgabe abgebrochen.",
|
|
268
|
+
"task.inactive_callback": "Dieser Ablauf für geplante Aufgaben ist nicht mehr aktiv",
|
|
269
|
+
"task.inactive": "⚠️ Die Erstellung geplanter Aufgaben ist nicht aktiv. Starte /task erneut.",
|
|
270
|
+
"task.blocked.expected_input": "⚠️ Schließe zuerst die aktuelle geplante Aufgabe ab: Sende Text oder nutze die Schaltfläche in der Zeitplan-Nachricht.",
|
|
271
|
+
"task.blocked.command_not_allowed": "⚠️ Dieser Befehl ist nicht verfügbar, solange die Erstellung einer geplanten Aufgabe aktiv ist.",
|
|
272
|
+
"task.limit_reached": "⚠️ Aufgabenlimit erreicht ({limit}). Lösche zuerst eine bestehende geplante Aufgabe.",
|
|
273
|
+
"task.schedule_too_frequent": "Der wiederkehrende Zeitplan ist zu häufig. Das minimale erlaubte Intervall ist einmal alle 5 Minuten.",
|
|
274
|
+
"task.kind.cron": "wiederkehrend",
|
|
275
|
+
"task.kind.once": "einmalig",
|
|
276
|
+
"task.run.success": "⏰ Geplante Aufgabe abgeschlossen: {description}\n\n{result}",
|
|
277
|
+
"task.run.error": "🔴 Geplante Aufgabe fehlgeschlagen: {description}\n\nFehler: {error}",
|
|
278
|
+
"tasklist.empty": "📭 Noch keine geplanten Aufgaben.",
|
|
279
|
+
"tasklist.select": "Wähle eine geplante Aufgabe:",
|
|
280
|
+
"tasklist.details": "⏰ Geplante Aufgabe\n\nAufgabe: {prompt}\nProjekt: {project}\nZeitplan: {schedule}\n{cronLine}Zeitzone: {timezone}\nNächster Lauf: {nextRunAt}\nLetzter Lauf: {lastRunAt}\nAnzahl Läufe: {runCount}",
|
|
281
|
+
"tasklist.details.cron": "Cron: {cron}",
|
|
282
|
+
"tasklist.button.delete": "🗑 Löschen",
|
|
283
|
+
"tasklist.button.cancel": "❌ Abbrechen",
|
|
284
|
+
"tasklist.deleted_callback": "Gelöscht",
|
|
285
|
+
"tasklist.cancelled_callback": "Abgebrochen",
|
|
286
|
+
"tasklist.inactive_callback": "Dieses Menü für geplante Aufgaben ist inaktiv",
|
|
287
|
+
"tasklist.load_error": "🔴 Geplante Aufgaben konnten nicht geladen werden.",
|
|
250
288
|
"commands.select": "Wähle einen OpenCode-Befehl:",
|
|
251
289
|
"commands.empty": "📭 Für dieses Projekt sind keine OpenCode-Befehle verfügbar.",
|
|
252
290
|
"commands.fetch_error": "🔴 OpenCode-Befehle konnten nicht geladen werden.",
|
package/dist/i18n/en.js
CHANGED
|
@@ -4,6 +4,8 @@ export const en = {
|
|
|
4
4
|
"cmd.description.stop": "Stop current action",
|
|
5
5
|
"cmd.description.sessions": "List sessions",
|
|
6
6
|
"cmd.description.projects": "List projects",
|
|
7
|
+
"cmd.description.task": "Create a scheduled task",
|
|
8
|
+
"cmd.description.tasklist": "List scheduled tasks",
|
|
7
9
|
"cmd.description.commands": "Custom commands",
|
|
8
10
|
"cmd.description.opencode_start": "Start OpenCode server",
|
|
9
11
|
"cmd.description.opencode_stop": "Stop OpenCode server",
|
|
@@ -30,7 +32,7 @@ export const en = {
|
|
|
30
32
|
"inline.cancelled_callback": "Cancelled",
|
|
31
33
|
"common.unknown": "unknown",
|
|
32
34
|
"common.unknown_error": "unknown error",
|
|
33
|
-
"start.welcome": "👋 Welcome to OpenCode Telegram Bot!\n\nUse commands:\n/projects — select project\n/sessions — session list\n/new — new session\n/status — status\n/help — help\n\nUse the bottom buttons to select agent mode, model, and variant.",
|
|
35
|
+
"start.welcome": "👋 Welcome to OpenCode Telegram Bot!\n\nUse commands:\n/projects — select project\n/sessions — session list\n/new — new session\n/task — scheduled task\n/tasklist — scheduled tasks\n/status — status\n/help — help\n\nUse the bottom buttons to select agent mode, model, and variant.",
|
|
34
36
|
"help.keyboard_hint": "💡 Use the bottom keyboard buttons for agent mode, model, variant, and context actions.",
|
|
35
37
|
"help.text": "📖 **Help**\n\n/status - Check server status\n/sessions - Session list\n/new - Create new session\n/help - Help",
|
|
36
38
|
"bot.thinking": "💭 Thinking...",
|
|
@@ -214,6 +216,7 @@ export const en = {
|
|
|
214
216
|
"pinned.line.project": "Project: {project}",
|
|
215
217
|
"pinned.line.model": "Model: {model}",
|
|
216
218
|
"pinned.line.context": "Context: {used} / {limit} ({percent}%)",
|
|
219
|
+
"pinned.line.cost": "Cost: {cost} spent",
|
|
217
220
|
"pinned.files.title": "Files ({count}):",
|
|
218
221
|
"pinned.files.item": " {path}{diff}",
|
|
219
222
|
"pinned.files.more": " ... and {count} more",
|
|
@@ -247,6 +250,41 @@ export const en = {
|
|
|
247
250
|
"rename.blocked.expected_name": "⚠️ Enter a new session name as text or tap Cancel in rename message.",
|
|
248
251
|
"rename.blocked.command_not_allowed": "⚠️ This command is not available while rename is waiting for a new name.",
|
|
249
252
|
"rename.button.cancel": "❌ Cancel",
|
|
253
|
+
"task.prompt.schedule": "⏰ Send the task schedule in natural language.\n\nExamples:\n- every 5 minutes\n- every day at 17:00\n- tomorrow at 12:00",
|
|
254
|
+
"task.schedule_empty": "⚠️ Schedule cannot be empty.",
|
|
255
|
+
"task.parse.in_progress": "⏳ Parsing schedule...",
|
|
256
|
+
"task.parse_error": "🔴 Failed to parse schedule.\n\n{message}\n\nSend the schedule again in a clearer form.",
|
|
257
|
+
"task.schedule_preview": "✅ Schedule parsed\n\nHow I understood it: {summary}\n{cronLine}Timezone: {timezone}\nType: {kind}\nNext run: {nextRunAt}",
|
|
258
|
+
"task.schedule_preview.cron": "Cron: {cron}",
|
|
259
|
+
"task.prompt.body": "📝 Now send what the bot should do on schedule.",
|
|
260
|
+
"task.prompt_empty": "⚠️ Task text cannot be empty.",
|
|
261
|
+
"task.created": "✅ Scheduled task created\n\nTask: {description}\nProject: {project}\nModel: {model}\nSchedule: {schedule}\n{cronLine}Next run: {nextRunAt}",
|
|
262
|
+
"task.created.cron": "Cron: {cron}",
|
|
263
|
+
"task.button.retry_schedule": "🔁 Re-enter schedule",
|
|
264
|
+
"task.button.cancel": "❌ Cancel",
|
|
265
|
+
"task.retry_schedule_callback": "Re-entering schedule...",
|
|
266
|
+
"task.cancel_callback": "Cancelling...",
|
|
267
|
+
"task.cancelled": "❌ Scheduled task creation cancelled.",
|
|
268
|
+
"task.inactive_callback": "This scheduled task flow is inactive",
|
|
269
|
+
"task.inactive": "⚠️ Scheduled task creation is not active. Run /task again.",
|
|
270
|
+
"task.blocked.expected_input": "⚠️ Finish the current scheduled task setup first by sending text or using the button in the schedule message.",
|
|
271
|
+
"task.blocked.command_not_allowed": "⚠️ This command is not available while scheduled task creation is active.",
|
|
272
|
+
"task.limit_reached": "⚠️ Task limit reached ({limit}). Delete an existing scheduled task first.",
|
|
273
|
+
"task.schedule_too_frequent": "Recurring schedule is too frequent. The minimum allowed interval is once every 5 minutes.",
|
|
274
|
+
"task.kind.cron": "recurring",
|
|
275
|
+
"task.kind.once": "one-time",
|
|
276
|
+
"task.run.success": "⏰ Scheduled task completed: {description}\n\n{result}",
|
|
277
|
+
"task.run.error": "🔴 Scheduled task failed: {description}\n\nError: {error}",
|
|
278
|
+
"tasklist.empty": "📭 No scheduled tasks yet.",
|
|
279
|
+
"tasklist.select": "Select a scheduled task:",
|
|
280
|
+
"tasklist.details": "⏰ Scheduled task\n\nTask: {prompt}\nProject: {project}\nSchedule: {schedule}\n{cronLine}Timezone: {timezone}\nNext run: {nextRunAt}\nLast run: {lastRunAt}\nRun count: {runCount}",
|
|
281
|
+
"tasklist.details.cron": "Cron: {cron}",
|
|
282
|
+
"tasklist.button.delete": "🗑 Delete",
|
|
283
|
+
"tasklist.button.cancel": "❌ Cancel",
|
|
284
|
+
"tasklist.deleted_callback": "Deleted",
|
|
285
|
+
"tasklist.cancelled_callback": "Cancelled",
|
|
286
|
+
"tasklist.inactive_callback": "This scheduled task menu is inactive",
|
|
287
|
+
"tasklist.load_error": "🔴 Failed to load scheduled tasks.",
|
|
250
288
|
"commands.select": "Choose an OpenCode command:",
|
|
251
289
|
"commands.empty": "📭 No OpenCode commands are available for this project.",
|
|
252
290
|
"commands.fetch_error": "🔴 Failed to load OpenCode commands.",
|