@grinev/opencode-telegram-bot 0.10.1 → 0.11.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/README.md CHANGED
@@ -26,14 +26,17 @@ Languages: English (`en`), Deutsch (`de`), Español (`es`), Русский (`ru`
26
26
  - **Live status** — pinned message with current project, model, context usage, and changed files list, updated in real time
27
27
  - **Model switching** — pick models from OpenCode favorites and recent history directly in the chat (favorites are shown first)
28
28
  - **Agent modes** — switch between Plan and Build modes on the fly
29
+ - **Custom Commands** — run OpenCode custom commands (and built-ins like `init`/`review`) from an inline menu with confirmation
29
30
  - **Interactive Q&A** — answer agent questions and approve permissions via inline buttons
30
31
  - **Voice prompts** — send voice/audio messages, transcribe them via a Whisper-compatible API, then forward recognized text to OpenCode
32
+ - **File attachments** — send images, PDF documents, and any text-based files to OpenCode (code, logs, configs etc.)
31
33
  - **Context control** — compact context when it gets too large, right from the chat
32
34
  - **Input flow control** — when an interactive flow is active, the bot accepts only relevant input to keep context consistent and avoid accidental actions
33
- - **Configurable reply formatting** — assistant replies use Telegram MarkdownV2 by default, with optional raw mode (`MESSAGE_FORMAT_MODE=markdown|raw`)
34
35
  - **Security** — strict user ID whitelist; no one else can access your bot, even if they find it
35
36
  - **Localization** — UI localization is supported for multiple languages (`BOT_LOCALE`)
36
37
 
38
+ Planned features currently in development are listed in [Current Task List](PRODUCT.md#current-task-list).
39
+
37
40
  ## Prerequisites
38
41
 
39
42
  - **Node.js 20+** — [download](https://nodejs.org)
@@ -70,7 +73,7 @@ npx @grinev/opencode-telegram-bot
70
73
 
71
74
  > Quick start is for npm usage. You do not need to clone this repository. If you run this command from the source directory (repository root), it may fail with `opencode-telegram: not found`. To run from sources, use the [Development](#development) section.
72
75
 
73
- On first launch, an interactive wizard will guide you through the configuration — it asks for interface language first, then your bot token, user ID, and OpenCode API URL. After that, you're ready to go. Open your bot in Telegram and start sending tasks.
76
+ On first launch, an interactive wizard will guide you through the configuration — it asks for interface language first, then your bot token, user ID, OpenCode API URL, and optional OpenCode server credentials (username/password). After that, you're ready to go. Open your bot in Telegram and start sending tasks.
74
77
 
75
78
  #### Alternative: Global Install
76
79
 
@@ -103,6 +106,7 @@ opencode-telegram config
103
106
  | `/sessions` | Browse and switch between recent sessions |
104
107
  | `/projects` | Switch between OpenCode projects |
105
108
  | `/rename` | Rename the current session |
109
+ | `/commands` | Browse and run custom commands |
106
110
  | `/opencode_start` | Start the OpenCode server remotely |
107
111
  | `/opencode_stop` | Stop the OpenCode server remotely |
108
112
  | `/help` | Show available commands |
@@ -5,6 +5,7 @@ import { loadSettings } from "../settings/manager.js";
5
5
  import { processManager } from "../process/manager.js";
6
6
  import { warmupSessionDirectoryCache } from "../session/cache-manager.js";
7
7
  import { getRuntimeMode } from "../runtime/mode.js";
8
+ import { getRuntimePaths } from "../runtime/paths.js";
8
9
  import { logger } from "../utils/logger.js";
9
10
  async function getBotVersion() {
10
11
  try {
@@ -20,8 +21,10 @@ async function getBotVersion() {
20
21
  }
21
22
  export async function startBotApp() {
22
23
  const mode = getRuntimeMode();
24
+ const runtimePaths = getRuntimePaths();
23
25
  const version = await getBotVersion();
24
26
  logger.info(`Starting OpenCode Telegram Bot v${version}...`);
27
+ logger.info(`Config loaded from ${runtimePaths.envFilePath}`);
25
28
  logger.info(`Allowed User ID: ${config.telegram.allowedUserId}`);
26
29
  logger.debug(`[Runtime] Application start mode: ${mode}`);
27
30
  await loadSettings();
@@ -0,0 +1,379 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { opencodeClient } from "../../opencode/client.js";
3
+ import { getCurrentProject } from "../../settings/manager.js";
4
+ import { clearSession, getCurrentSession, setCurrentSession, } from "../../session/manager.js";
5
+ import { ingestSessionInfoForCache } from "../../session/cache-manager.js";
6
+ import { interactionManager } from "../../interaction/manager.js";
7
+ import { summaryAggregator } from "../../summary/aggregator.js";
8
+ import { getStoredAgent } from "../../agent/manager.js";
9
+ import { getStoredModel } from "../../model/manager.js";
10
+ import { safeBackgroundTask } from "../../utils/safe-background-task.js";
11
+ import { logger } from "../../utils/logger.js";
12
+ import { t } from "../../i18n/index.js";
13
+ const COMMANDS_CALLBACK_PREFIX = "commands:";
14
+ const COMMANDS_CALLBACK_SELECT_PREFIX = `${COMMANDS_CALLBACK_PREFIX}select:`;
15
+ const COMMANDS_CALLBACK_CANCEL = `${COMMANDS_CALLBACK_PREFIX}cancel`;
16
+ const COMMANDS_CALLBACK_EXECUTE = `${COMMANDS_CALLBACK_PREFIX}execute`;
17
+ const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
18
+ function formatExecutingCommandMessage(commandName, args) {
19
+ const commandText = `/${commandName}`;
20
+ const argsSuffix = args ? ` ${args}` : "";
21
+ return `${t("commands.executing_prefix")}\n${commandText}${argsSuffix}`;
22
+ }
23
+ function normalizeDirectoryForCommandApi(directory) {
24
+ return directory.replace(/\\/g, "/");
25
+ }
26
+ function getCallbackMessageId(ctx) {
27
+ const message = ctx.callbackQuery?.message;
28
+ if (!message || !("message_id" in message)) {
29
+ return null;
30
+ }
31
+ const messageId = message.message_id;
32
+ return typeof messageId === "number" ? messageId : null;
33
+ }
34
+ function formatCommandButtonLabel(command) {
35
+ const description = command.description?.trim() || t("commands.no_description");
36
+ const rawLabel = `/${command.name} - ${description}`;
37
+ if (rawLabel.length <= MAX_INLINE_BUTTON_LABEL_LENGTH) {
38
+ return rawLabel;
39
+ }
40
+ return `${rawLabel.slice(0, MAX_INLINE_BUTTON_LABEL_LENGTH - 3)}...`;
41
+ }
42
+ function buildCommandsListKeyboard(commands) {
43
+ const keyboard = new InlineKeyboard();
44
+ commands.forEach((command, index) => {
45
+ keyboard
46
+ .text(formatCommandButtonLabel(command), `${COMMANDS_CALLBACK_SELECT_PREFIX}${index}`)
47
+ .row();
48
+ });
49
+ keyboard.text(t("commands.button.cancel"), COMMANDS_CALLBACK_CANCEL);
50
+ return keyboard;
51
+ }
52
+ function buildCommandsConfirmKeyboard() {
53
+ return new InlineKeyboard()
54
+ .text(t("commands.button.execute"), COMMANDS_CALLBACK_EXECUTE)
55
+ .text(t("commands.button.cancel"), COMMANDS_CALLBACK_CANCEL);
56
+ }
57
+ function parseCommandItems(value) {
58
+ if (!Array.isArray(value)) {
59
+ return null;
60
+ }
61
+ const commands = [];
62
+ for (const item of value) {
63
+ if (!item || typeof item !== "object") {
64
+ return null;
65
+ }
66
+ const commandName = item.name;
67
+ if (typeof commandName !== "string" || !commandName.trim()) {
68
+ return null;
69
+ }
70
+ const description = item.description;
71
+ commands.push({
72
+ name: commandName,
73
+ description: typeof description === "string" ? description : undefined,
74
+ });
75
+ }
76
+ return commands;
77
+ }
78
+ function parseCommandsMetadata(state) {
79
+ if (!state || state.kind !== "custom") {
80
+ return null;
81
+ }
82
+ const flow = state.metadata.flow;
83
+ const stage = state.metadata.stage;
84
+ const messageId = state.metadata.messageId;
85
+ const projectDirectory = state.metadata.projectDirectory;
86
+ if (flow !== "commands" ||
87
+ typeof messageId !== "number" ||
88
+ typeof projectDirectory !== "string") {
89
+ return null;
90
+ }
91
+ if (stage === "list") {
92
+ const commands = parseCommandItems(state.metadata.commands);
93
+ if (!commands) {
94
+ return null;
95
+ }
96
+ return {
97
+ flow,
98
+ stage,
99
+ messageId,
100
+ projectDirectory,
101
+ commands,
102
+ };
103
+ }
104
+ if (stage === "confirm") {
105
+ const commandName = state.metadata.commandName;
106
+ if (typeof commandName !== "string" || !commandName.trim()) {
107
+ return null;
108
+ }
109
+ return {
110
+ flow,
111
+ stage,
112
+ messageId,
113
+ projectDirectory,
114
+ commandName,
115
+ };
116
+ }
117
+ return null;
118
+ }
119
+ function clearCommandsInteraction(reason) {
120
+ const metadata = parseCommandsMetadata(interactionManager.getSnapshot());
121
+ if (metadata) {
122
+ interactionManager.clear(reason);
123
+ }
124
+ }
125
+ async function getCommandList(projectDirectory) {
126
+ const { data, error } = await opencodeClient.command.list({
127
+ directory: normalizeDirectoryForCommandApi(projectDirectory),
128
+ });
129
+ if (error || !data) {
130
+ throw error || new Error("No command data received");
131
+ }
132
+ return data
133
+ .filter((command) => typeof command.name === "string" && command.name.trim().length > 0)
134
+ .map((command) => ({
135
+ name: command.name,
136
+ description: command.description,
137
+ }));
138
+ }
139
+ function parseSelectIndex(data) {
140
+ if (!data.startsWith(COMMANDS_CALLBACK_SELECT_PREFIX)) {
141
+ return null;
142
+ }
143
+ const rawIndex = data.slice(COMMANDS_CALLBACK_SELECT_PREFIX.length);
144
+ const index = Number(rawIndex);
145
+ if (!Number.isInteger(index) || index < 0) {
146
+ return null;
147
+ }
148
+ return index;
149
+ }
150
+ async function isSessionBusy(sessionId, directory) {
151
+ try {
152
+ const { data, error } = await opencodeClient.session.status({ directory });
153
+ if (error || !data) {
154
+ logger.warn("[Commands] Failed to check session status before command:", error);
155
+ return false;
156
+ }
157
+ const sessionStatus = data[sessionId];
158
+ if (!sessionStatus) {
159
+ return false;
160
+ }
161
+ return sessionStatus.type === "busy";
162
+ }
163
+ catch (err) {
164
+ logger.warn("[Commands] Error checking session status before command:", err);
165
+ return false;
166
+ }
167
+ }
168
+ async function ensureSessionForProject(ctx, projectDirectory) {
169
+ let currentSession = getCurrentSession();
170
+ if (currentSession && currentSession.directory !== projectDirectory) {
171
+ logger.warn(`[Commands] Session/project mismatch detected. sessionDirectory=${currentSession.directory}, projectDirectory=${projectDirectory}. Resetting session context.`);
172
+ clearSession();
173
+ summaryAggregator.clear();
174
+ await ctx.reply(t("bot.session_reset_project_mismatch"));
175
+ currentSession = null;
176
+ }
177
+ if (currentSession) {
178
+ return currentSession;
179
+ }
180
+ await ctx.reply(t("bot.creating_session"));
181
+ const { data: session, error } = await opencodeClient.session.create({
182
+ directory: projectDirectory,
183
+ });
184
+ if (error || !session) {
185
+ await ctx.reply(t("bot.create_session_error"));
186
+ return null;
187
+ }
188
+ const sessionInfo = {
189
+ id: session.id,
190
+ title: session.title,
191
+ directory: projectDirectory,
192
+ };
193
+ setCurrentSession(sessionInfo);
194
+ await ingestSessionInfoForCache(session);
195
+ await ctx.reply(t("bot.session_created", { title: session.title }));
196
+ return sessionInfo;
197
+ }
198
+ async function executeCommand(ctx, deps, params) {
199
+ if (!ctx.chat) {
200
+ return;
201
+ }
202
+ const args = params.argumentsText.trim();
203
+ await ctx.reply(formatExecutingCommandMessage(params.commandName, args));
204
+ const session = await ensureSessionForProject(ctx, params.projectDirectory);
205
+ if (!session) {
206
+ return;
207
+ }
208
+ await deps.ensureEventSubscription(session.directory);
209
+ summaryAggregator.setSession(session.id);
210
+ summaryAggregator.setBotAndChatId(deps.bot, ctx.chat.id);
211
+ const sessionIsBusy = await isSessionBusy(session.id, session.directory);
212
+ if (sessionIsBusy) {
213
+ await ctx.reply(t("bot.session_busy"));
214
+ return;
215
+ }
216
+ const currentAgent = getStoredAgent();
217
+ const storedModel = getStoredModel();
218
+ const model = storedModel.providerID && storedModel.modelID
219
+ ? `${storedModel.providerID}/${storedModel.modelID}`
220
+ : undefined;
221
+ safeBackgroundTask({
222
+ taskName: "session.command",
223
+ task: () => opencodeClient.session.command({
224
+ sessionID: session.id,
225
+ directory: session.directory,
226
+ command: params.commandName,
227
+ arguments: args,
228
+ agent: currentAgent,
229
+ model,
230
+ variant: storedModel.variant,
231
+ }),
232
+ onSuccess: ({ error }) => {
233
+ if (error) {
234
+ logger.error("[Commands] OpenCode API returned an error for session.command", {
235
+ sessionId: session.id,
236
+ command: params.commandName,
237
+ args,
238
+ });
239
+ logger.error("[Commands] session.command error details:", error);
240
+ void ctx.api.sendMessage(ctx.chat.id, t("commands.execute_error")).catch(() => { });
241
+ return;
242
+ }
243
+ logger.info(`[Commands] session.command completed: session=${session.id}, command=/${params.commandName}`);
244
+ },
245
+ onError: (error) => {
246
+ logger.error("[Commands] session.command background task failed", {
247
+ sessionId: session.id,
248
+ command: params.commandName,
249
+ args,
250
+ });
251
+ logger.error("[Commands] session.command background failure details:", error);
252
+ void ctx.api.sendMessage(ctx.chat.id, t("commands.execute_error")).catch(() => { });
253
+ },
254
+ });
255
+ }
256
+ export async function commandsCommand(ctx) {
257
+ try {
258
+ const currentProject = getCurrentProject();
259
+ if (!currentProject) {
260
+ await ctx.reply(t("bot.project_not_selected"));
261
+ return;
262
+ }
263
+ const commands = await getCommandList(currentProject.worktree);
264
+ if (commands.length === 0) {
265
+ await ctx.reply(t("commands.empty"));
266
+ return;
267
+ }
268
+ const keyboard = buildCommandsListKeyboard(commands);
269
+ const message = await ctx.reply(t("commands.select"), {
270
+ reply_markup: keyboard,
271
+ });
272
+ interactionManager.start({
273
+ kind: "custom",
274
+ expectedInput: "callback",
275
+ metadata: {
276
+ flow: "commands",
277
+ stage: "list",
278
+ messageId: message.message_id,
279
+ projectDirectory: currentProject.worktree,
280
+ commands,
281
+ },
282
+ });
283
+ }
284
+ catch (error) {
285
+ logger.error("[Commands] Error fetching commands list:", error);
286
+ await ctx.reply(t("commands.fetch_error"));
287
+ }
288
+ }
289
+ export async function handleCommandsCallback(ctx, deps) {
290
+ const data = ctx.callbackQuery?.data;
291
+ if (!data || !data.startsWith(COMMANDS_CALLBACK_PREFIX)) {
292
+ return false;
293
+ }
294
+ const metadata = parseCommandsMetadata(interactionManager.getSnapshot());
295
+ const callbackMessageId = getCallbackMessageId(ctx);
296
+ if (!metadata || callbackMessageId === null || metadata.messageId !== callbackMessageId) {
297
+ await ctx.answerCallbackQuery({ text: t("commands.inactive_callback"), show_alert: true });
298
+ return true;
299
+ }
300
+ try {
301
+ if (data === COMMANDS_CALLBACK_CANCEL) {
302
+ clearCommandsInteraction("commands_cancelled");
303
+ await ctx.answerCallbackQuery({ text: t("commands.cancelled_callback") });
304
+ await ctx.deleteMessage().catch(() => { });
305
+ return true;
306
+ }
307
+ if (data === COMMANDS_CALLBACK_EXECUTE) {
308
+ if (metadata.stage !== "confirm") {
309
+ await ctx.answerCallbackQuery({ text: t("commands.inactive_callback"), show_alert: true });
310
+ return true;
311
+ }
312
+ clearCommandsInteraction("commands_execute_clicked");
313
+ await ctx.answerCallbackQuery({ text: t("commands.execute_callback") });
314
+ await ctx.deleteMessage().catch(() => { });
315
+ await executeCommand(ctx, deps, {
316
+ projectDirectory: metadata.projectDirectory,
317
+ commandName: metadata.commandName,
318
+ argumentsText: "",
319
+ });
320
+ return true;
321
+ }
322
+ const commandIndex = parseSelectIndex(data);
323
+ if (commandIndex === null || metadata.stage !== "list") {
324
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error"), show_alert: true });
325
+ return true;
326
+ }
327
+ const selectedCommand = metadata.commands[commandIndex];
328
+ if (!selectedCommand) {
329
+ await ctx.answerCallbackQuery({ text: t("commands.inactive_callback"), show_alert: true });
330
+ return true;
331
+ }
332
+ await ctx.answerCallbackQuery();
333
+ await ctx.editMessageText(t("commands.confirm", { command: `/${selectedCommand.name}` }), {
334
+ reply_markup: buildCommandsConfirmKeyboard(),
335
+ });
336
+ interactionManager.transition({
337
+ expectedInput: "mixed",
338
+ metadata: {
339
+ flow: "commands",
340
+ stage: "confirm",
341
+ messageId: metadata.messageId,
342
+ projectDirectory: metadata.projectDirectory,
343
+ commandName: selectedCommand.name,
344
+ },
345
+ });
346
+ return true;
347
+ }
348
+ catch (error) {
349
+ logger.error("[Commands] Error handling command callback:", error);
350
+ clearCommandsInteraction("commands_callback_error");
351
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error") }).catch(() => { });
352
+ return true;
353
+ }
354
+ }
355
+ export async function handleCommandTextArguments(ctx, deps) {
356
+ const text = ctx.message?.text;
357
+ if (!text || text.startsWith("/")) {
358
+ return false;
359
+ }
360
+ const metadata = parseCommandsMetadata(interactionManager.getSnapshot());
361
+ if (!metadata || metadata.stage !== "confirm") {
362
+ return false;
363
+ }
364
+ const argumentsText = text.trim();
365
+ if (!argumentsText) {
366
+ await ctx.reply(t("commands.arguments_empty"));
367
+ return true;
368
+ }
369
+ clearCommandsInteraction("commands_arguments_submitted");
370
+ if (ctx.chat) {
371
+ await ctx.api.deleteMessage(ctx.chat.id, metadata.messageId).catch(() => { });
372
+ }
373
+ await executeCommand(ctx, deps, {
374
+ projectDirectory: metadata.projectDirectory,
375
+ commandName: metadata.commandName,
376
+ argumentsText,
377
+ });
378
+ return true;
379
+ }
@@ -10,6 +10,7 @@ const COMMAND_DEFINITIONS = [
10
10
  { command: "sessions", descriptionKey: "cmd.description.sessions" },
11
11
  { command: "projects", descriptionKey: "cmd.description.projects" },
12
12
  { command: "rename", descriptionKey: "cmd.description.rename" },
13
+ { command: "commands", descriptionKey: "cmd.description.commands" },
13
14
  { command: "opencode_start", descriptionKey: "cmd.description.opencode_start" },
14
15
  { command: "opencode_stop", descriptionKey: "cmd.description.opencode_stop" },
15
16
  { command: "help", descriptionKey: "cmd.description.help" },
@@ -4,7 +4,6 @@ import { getCurrentProject } from "../../settings/manager.js";
4
4
  import { fetchCurrentAgent } from "../../agent/manager.js";
5
5
  import { getAgentDisplayName } from "../../agent/types.js";
6
6
  import { fetchCurrentModel } from "../../model/manager.js";
7
- import { formatModelForDisplay } from "../../model/types.js";
8
7
  import { processManager } from "../../process/manager.js";
9
8
  import { keyboardManager } from "../../keyboard/manager.js";
10
9
  import { pinnedMessageManager } from "../../pinned/manager.js";
@@ -42,7 +41,7 @@ export async function statusCommand(ctx) {
42
41
  message += `${t("status.line.mode", { mode: agentDisplay })}\n`;
43
42
  // Add model information
44
43
  const currentModel = fetchCurrentModel();
45
- const modelDisplay = formatModelForDisplay(currentModel.providerID, currentModel.modelID);
44
+ const modelDisplay = `🤖 ${currentModel.providerID}/${currentModel.modelID}`;
46
45
  message += `${t("status.line.model", { model: modelDisplay })}\n`;
47
46
  const currentProject = getCurrentProject();
48
47
  if (currentProject) {
@@ -0,0 +1,65 @@
1
+ import { config } from "../../config.js";
2
+ import { processUserPrompt } from "./prompt.js";
3
+ import { downloadTelegramFile, toDataUri, isTextMimeType, isFileSizeAllowed, } from "../utils/file-download.js";
4
+ import { getModelCapabilities, supportsInput } from "../../model/capabilities.js";
5
+ import { getStoredModel } from "../../model/manager.js";
6
+ import { logger } from "../../utils/logger.js";
7
+ import { t } from "../../i18n/index.js";
8
+ export async function handleDocumentMessage(ctx, deps) {
9
+ const downloadFile = deps.downloadFile ?? downloadTelegramFile;
10
+ const getCapabilities = deps.getModelCapabilities ?? getModelCapabilities;
11
+ const getStored = deps.getStoredModel ?? getStoredModel;
12
+ const processPrompt = deps.processPrompt ?? processUserPrompt;
13
+ const doc = ctx.message?.document;
14
+ if (!doc) {
15
+ return;
16
+ }
17
+ const caption = ctx.message.caption || "";
18
+ const mimeType = doc.mime_type || "";
19
+ const filename = doc.file_name || "document";
20
+ try {
21
+ if (isTextMimeType(mimeType)) {
22
+ if (!isFileSizeAllowed(doc.file_size, config.files.maxFileSizeKb)) {
23
+ logger.warn(`[Document] Text file too large: ${filename} (${doc.file_size} bytes > ${config.files.maxFileSizeKb}KB)`);
24
+ await ctx.reply(t("bot.text_file_too_large", { maxSizeKb: String(config.files.maxFileSizeKb) }));
25
+ return;
26
+ }
27
+ await ctx.reply(t("bot.file_downloading"));
28
+ const downloadedFile = await downloadFile(ctx.api, doc.file_id);
29
+ const textContent = downloadedFile.buffer.toString("utf-8");
30
+ const promptWithFile = `--- Content of ${filename} ---\n${textContent}\n--- End of file ---\n\n${caption}`;
31
+ logger.info(`[Document] Sending text file (${downloadedFile.buffer.length} bytes, ${filename}) as prompt`);
32
+ await processPrompt(ctx, promptWithFile, deps);
33
+ return;
34
+ }
35
+ if (mimeType === "application/pdf") {
36
+ const storedModel = getStored();
37
+ const capabilities = await getCapabilities(storedModel.providerID, storedModel.modelID);
38
+ if (!supportsInput(capabilities, "pdf")) {
39
+ logger.warn(`[Document] Model ${storedModel.providerID}/${storedModel.modelID} doesn't support PDF input`);
40
+ await ctx.reply(t("bot.model_no_pdf"));
41
+ if (caption.trim().length > 0) {
42
+ await processPrompt(ctx, caption, deps);
43
+ }
44
+ return;
45
+ }
46
+ await ctx.reply(t("bot.file_downloading"));
47
+ const downloadedFile = await downloadFile(ctx.api, doc.file_id);
48
+ const dataUri = toDataUri(downloadedFile.buffer, mimeType);
49
+ const filePart = {
50
+ type: "file",
51
+ mime: mimeType,
52
+ filename: filename,
53
+ url: dataUri,
54
+ };
55
+ logger.info(`[Document] Sending PDF (${downloadedFile.buffer.length} bytes, ${filename}) with prompt`);
56
+ await processPrompt(ctx, caption, deps, [filePart]);
57
+ return;
58
+ }
59
+ logger.debug(`[Document] Unsupported document MIME type: ${mimeType}, ignoring`);
60
+ }
61
+ catch (err) {
62
+ logger.error("[Document] Error handling document message:", err);
63
+ await ctx.reply(t("bot.file_download_error"));
64
+ }
65
+ }
package/dist/bot/index.js CHANGED
@@ -12,7 +12,7 @@ import { BOT_COMMANDS } from "./commands/definitions.js";
12
12
  import { startCommand } from "./commands/start.js";
13
13
  import { helpCommand } from "./commands/help.js";
14
14
  import { statusCommand } from "./commands/status.js";
15
- import { MODEL_BUTTON_TEXT_PATTERN, VARIANT_BUTTON_TEXT_PATTERN } from "./message-patterns.js";
15
+ import { AGENT_MODE_BUTTON_TEXT_PATTERN, MODEL_BUTTON_TEXT_PATTERN, VARIANT_BUTTON_TEXT_PATTERN, } from "./message-patterns.js";
16
16
  import { sessionsCommand, handleSessionSelect } from "./commands/sessions.js";
17
17
  import { newCommand } from "./commands/new.js";
18
18
  import { projectsCommand, handleProjectSelect } from "./commands/projects.js";
@@ -20,6 +20,7 @@ import { stopCommand } from "./commands/stop.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 { commandsCommand, handleCommandsCallback, handleCommandTextArguments, } from "./commands/commands.js";
23
24
  import { handleQuestionCallback, showCurrentQuestion, handleQuestionTextAnswer, } from "./handlers/question.js";
24
25
  import { handlePermissionCallback, showPermissionRequest } from "./handlers/permission.js";
25
26
  import { handleAgentSelect, showAgentSelectionMenu } from "./handlers/agent.js";
@@ -43,6 +44,7 @@ import { pinnedMessageManager } from "../pinned/manager.js";
43
44
  import { t } from "../i18n/index.js";
44
45
  import { processUserPrompt } from "./handlers/prompt.js";
45
46
  import { handleVoiceMessage } from "./handlers/voice.js";
47
+ import { handleDocumentMessage } from "./handlers/document.js";
46
48
  import { downloadTelegramFile, toDataUri } from "./utils/file-download.js";
47
49
  import { sendMessageWithMarkdownFallback } from "./utils/send-with-markdown-fallback.js";
48
50
  import { getModelCapabilities, supportsInput } from "../model/capabilities.js";
@@ -120,7 +122,7 @@ async function ensureCommandsInitialized(ctx, next) {
120
122
  },
121
123
  });
122
124
  commandsInitialized = true;
123
- logger.info(`[Bot] Commands initialized for authorized user (chat_id=${ctx.chat.id})`);
125
+ logger.debug(`[Bot] Commands initialized for authorized user (chat_id=${ctx.chat.id})`);
124
126
  }
125
127
  catch (err) {
126
128
  logger.error("[Bot] Failed to set commands:", err);
@@ -383,7 +385,7 @@ async function ensureEventSubscription(directory) {
383
385
  export function createBot() {
384
386
  clearAllInteractionState("bot_startup");
385
387
  toolMessageBatcher.setIntervalSeconds(config.bot.serviceMessagesIntervalSec);
386
- logger.info(`[ToolBatcher] Service messages interval: ${config.bot.serviceMessagesIntervalSec}s`);
388
+ logger.debug(`[ToolBatcher] Service messages interval: ${config.bot.serviceMessagesIntervalSec}s`);
387
389
  const botOptions = {};
388
390
  if (config.telegram.proxyUrl) {
389
391
  const proxyUrl = config.telegram.proxyUrl;
@@ -456,10 +458,15 @@ export function createBot() {
456
458
  bot.command("new", newCommand);
457
459
  bot.command("stop", stopCommand);
458
460
  bot.command("rename", renameCommand);
461
+ bot.command("commands", commandsCommand);
459
462
  bot.on("message:text", unknownCommandMiddleware);
460
463
  bot.on("callback_query:data", async (ctx) => {
461
464
  logger.debug(`[Bot] Received callback_query:data: ${ctx.callbackQuery?.data}`);
462
465
  logger.debug(`[Bot] Callback context: from=${ctx.from?.id}, chat=${ctx.chat?.id}`);
466
+ if (ctx.chat) {
467
+ botInstance = bot;
468
+ chatIdInstance = ctx.chat.id;
469
+ }
463
470
  try {
464
471
  const handledInlineCancel = await handleInlineMenuCancel(ctx);
465
472
  const handledSession = await handleSessionSelect(ctx);
@@ -471,7 +478,8 @@ export function createBot() {
471
478
  const handledVariant = await handleVariantSelect(ctx);
472
479
  const handledCompactConfirm = await handleCompactConfirm(ctx);
473
480
  const handledRenameCancel = await handleRenameCancel(ctx);
474
- 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}`);
481
+ const handledCommands = await handleCommandsCallback(ctx, { bot, ensureEventSubscription });
482
+ 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}`);
475
483
  if (!handledInlineCancel &&
476
484
  !handledSession &&
477
485
  !handledProject &&
@@ -481,7 +489,8 @@ export function createBot() {
481
489
  !handledModel &&
482
490
  !handledVariant &&
483
491
  !handledCompactConfirm &&
484
- !handledRenameCancel) {
492
+ !handledRenameCancel &&
493
+ !handledCommands) {
485
494
  logger.debug("Unknown callback query:", ctx.callbackQuery?.data);
486
495
  await ctx.answerCallbackQuery({ text: t("callback.unknown_command") });
487
496
  }
@@ -493,7 +502,7 @@ export function createBot() {
493
502
  }
494
503
  });
495
504
  // Handle Reply Keyboard button press (agent mode indicator)
496
- bot.hears(/^(📋|🛠️|💬|🔍|📝|📄|📦|🤖) \w+ Mode$/, async (ctx) => {
505
+ bot.hears(AGENT_MODE_BUTTON_TEXT_PATTERN, async (ctx) => {
497
506
  logger.debug(`[Bot] Agent mode button pressed: ${ctx.message?.text}`);
498
507
  try {
499
508
  if (await blockMenuWhileInteractionActive(ctx)) {
@@ -575,7 +584,7 @@ export function createBot() {
575
584
  },
576
585
  onSuccess: (result) => {
577
586
  if (result.success) {
578
- logger.info("[Bot] Cleared global commands (default and all_private_chats scopes)");
587
+ logger.debug("[Bot] Cleared global commands (default and all_private_chats scopes)");
579
588
  return;
580
589
  }
581
590
  logger.warn("[Bot] Could not clear global commands:", result.error);
@@ -645,11 +654,21 @@ export function createBot() {
645
654
  await ctx.reply(t("bot.photo_download_error"));
646
655
  }
647
656
  });
657
+ // Document message handler (PDF and text files)
658
+ bot.on("message:document", async (ctx) => {
659
+ logger.debug(`[Bot] Received document message, chatId=${ctx.chat.id}`);
660
+ botInstance = bot;
661
+ chatIdInstance = ctx.chat.id;
662
+ const deps = { bot, ensureEventSubscription };
663
+ await handleDocumentMessage(ctx, deps);
664
+ });
648
665
  bot.on("message:text", async (ctx) => {
649
666
  const text = ctx.message?.text;
650
667
  if (!text) {
651
668
  return;
652
669
  }
670
+ botInstance = bot;
671
+ chatIdInstance = ctx.chat.id;
653
672
  if (text.startsWith("/")) {
654
673
  return;
655
674
  }
@@ -661,9 +680,11 @@ export function createBot() {
661
680
  if (handledRename) {
662
681
  return;
663
682
  }
664
- botInstance = bot;
665
- chatIdInstance = ctx.chat.id;
666
683
  const promptDeps = { bot, ensureEventSubscription };
684
+ const handledCommandArgs = await handleCommandTextArguments(ctx, promptDeps);
685
+ if (handledCommandArgs) {
686
+ return;
687
+ }
667
688
  await processUserPrompt(ctx, text, promptDeps);
668
689
  logger.debug("[Bot] message:text handler completed (prompt sent in background)");
669
690
  });
@@ -1,3 +1,4 @@
1
- export const MODEL_BUTTON_TEXT_PATTERN = /^🤖\s[\s\S]+$/;
1
+ export const AGENT_MODE_BUTTON_TEXT_PATTERN = /^(📋|🛠️|💬|🔍|📝|📄|📦|🤖)\s.+\sMode$/;
2
+ export const MODEL_BUTTON_TEXT_PATTERN = /^🤖\s(?!.*\sMode$)[\s\S]+$/;
2
3
  // Keep support for both legacy "💭" and current "💡" prefix.
3
4
  export const VARIANT_BUTTON_TEXT_PATTERN = /^(💡|💭)\s.+$/;
@@ -73,3 +73,19 @@ export function formatFileSize(bytes) {
73
73
  }
74
74
  return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
75
75
  }
76
+ const APPLICATION_TEXT_MIME_TYPES = new Set([
77
+ "application/json",
78
+ "application/xml",
79
+ "application/javascript",
80
+ "application/x-yaml",
81
+ "application/sql",
82
+ ]);
83
+ export function isTextMimeType(mimeType) {
84
+ if (!mimeType) {
85
+ return false;
86
+ }
87
+ if (mimeType.startsWith("text/")) {
88
+ return true;
89
+ }
90
+ return APPLICATION_TEXT_MIME_TYPES.has(mimeType);
91
+ }
package/dist/config.js CHANGED
@@ -2,7 +2,7 @@ import dotenv from "dotenv";
2
2
  import { getRuntimePaths } from "./runtime/paths.js";
3
3
  import { normalizeLocale } from "./i18n/index.js";
4
4
  const runtimePaths = getRuntimePaths();
5
- dotenv.config({ path: runtimePaths.envFilePath });
5
+ dotenv.config({ path: runtimePaths.envFilePath, quiet: true });
6
6
  function getEnvVar(key, required = true) {
7
7
  const value = process.env[key];
8
8
  if (required && !value) {
package/dist/i18n/de.js CHANGED
@@ -4,6 +4,7 @@ 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.commands": "Benutzerdefinierte Befehle",
7
8
  "cmd.description.opencode_start": "OpenCode-Server starten",
8
9
  "cmd.description.opencode_stop": "OpenCode-Server stoppen",
9
10
  "cmd.description.help": "Hilfe",
@@ -48,23 +49,28 @@ export const de = {
48
49
  "bot.photo_model_no_image": "⚠️ Das aktuelle Modell unterstützt keine Bildeingabe. Sende nur Text.",
49
50
  "bot.photo_download_error": "🔴 Foto konnte nicht heruntergeladen werden",
50
51
  "bot.photo_no_caption": "💡 Tipp: Füge eine Bildunterschrift hinzu, um zu beschreiben, was du mit diesem Foto tun möchtest.",
52
+ "bot.file_downloading": "⏳ Lade Datei herunter...",
53
+ "bot.file_too_large": "⚠️ Datei ist zu groß (max. {maxSizeMb}MB)",
54
+ "bot.file_download_error": "🔴 Datei konnte nicht heruntergeladen werden",
55
+ "bot.model_no_pdf": "⚠️ Das aktuelle Modell unterstützt keine PDF-Eingabe. Sende nur Text.",
56
+ "bot.text_file_too_large": "⚠️ Textdatei ist zu groß (max. {maxSizeKb}KB)",
51
57
  "status.header_running": "🟢 **OpenCode-Server läuft**",
52
58
  "status.health.healthy": "OK",
53
59
  "status.health.unhealthy": "Nicht OK",
54
60
  "status.line.health": "Status: {health}",
55
61
  "status.line.version": "Version: {version}",
56
- "status.line.managed_yes": "Vom Bot verwaltet: Ja",
57
- "status.line.managed_no": "Vom Bot verwaltet: Nein",
62
+ "status.line.managed_yes": "Vom Bot gestartet: Ja",
63
+ "status.line.managed_no": "Vom Bot gestartet: Nein",
58
64
  "status.line.pid": "PID: {pid}",
59
65
  "status.line.uptime_sec": "Betriebszeit: {seconds} s",
60
66
  "status.line.mode": "Modus: {mode}",
61
67
  "status.line.model": "Modell: {model}",
62
68
  "status.agent_not_set": "nicht gesetzt",
63
- "status.project_selected": "🏗 Projekt: {project}",
64
- "status.project_not_selected": "🏗 Projekt: nicht ausgewählt",
69
+ "status.project_selected": "Projekt: {project}",
70
+ "status.project_not_selected": "Projekt: nicht ausgewählt",
65
71
  "status.project_hint": "Nutze /projects, um ein Projekt auszuwahlen",
66
- "status.session_selected": "📋 Aktuelle Sitzung: {title}",
67
- "status.session_not_selected": "📋 Aktuelle Sitzung: nicht ausgewählt",
72
+ "status.session_selected": "Aktuelle Sitzung: {title}",
73
+ "status.session_not_selected": "Aktuelle Sitzung: nicht ausgewählt",
68
74
  "status.session_hint": "Nutze /sessions zur Auswahl oder /new zum Erstellen",
69
75
  "status.server_unavailable": "🔴 OpenCode-Server ist nicht verfügbar\n\nNutze /opencode_start, um den Server zu starten.",
70
76
  "projects.empty": "📭 Keine Projekte gefunden.\n\nÖffne ein Verzeichnis in OpenCode und erstelle mindestens eine Sitzung, dann erscheint es hier.",
@@ -222,6 +228,8 @@ export const de = {
222
228
  "runtime.wizard.ask_user_id": "Gib deine Telegram User ID ein (du bekommst sie bei @userinfobot).\n> ",
223
229
  "runtime.wizard.user_id_invalid": "Gib eine positive ganze Zahl ein (> 0).\n",
224
230
  "runtime.wizard.ask_api_url": "OpenCode API URL eingeben (optional).\nEnter drücken für Standard: {defaultUrl}\n> ",
231
+ "runtime.wizard.ask_server_username": "OpenCode-Server-Benutzername eingeben (optional).\nEnter drücken für Standard: {defaultUsername}\n> ",
232
+ "runtime.wizard.ask_server_password": "OpenCode-Server-Passwort eingeben (optional).\nEnter drücken, um es leer zu lassen.\n> ",
225
233
  "runtime.wizard.api_url_invalid": "Gib eine gültige URL (http/https) ein oder drücke Enter für Standard.\n",
226
234
  "runtime.wizard.start": "OpenCode Telegram Bot Einrichtung.\n",
227
235
  "runtime.wizard.saved": "Konfiguration gespeichert:\n- {envPath}\n- {settingsPath}\n",
@@ -238,6 +246,19 @@ export const de = {
238
246
  "rename.blocked.expected_name": "⚠️ Sende den neuen Sitzungsnamen als Text oder tippe in der Umbenennen-Nachricht auf Abbrechen.",
239
247
  "rename.blocked.command_not_allowed": "⚠️ Dieser Befehl ist nicht verfügbar, solange beim Umbenennen auf einen neuen Namen gewartet wird.",
240
248
  "rename.button.cancel": "❌ Abbrechen",
249
+ "commands.select": "Wähle einen OpenCode-Befehl:",
250
+ "commands.empty": "📭 Für dieses Projekt sind keine OpenCode-Befehle verfügbar.",
251
+ "commands.fetch_error": "🔴 OpenCode-Befehle konnten nicht geladen werden.",
252
+ "commands.no_description": "Keine Beschreibung",
253
+ "commands.button.execute": "✅ Ausführen",
254
+ "commands.button.cancel": "❌ Abbrechen",
255
+ "commands.confirm": "Bestätige die Ausführung des Befehls {command}. Für die Ausführung mit Argumenten sende die Argumente als Nachricht.",
256
+ "commands.inactive_callback": "Dieses Befehlsmenü ist inaktiv",
257
+ "commands.cancelled_callback": "Abgebrochen",
258
+ "commands.execute_callback": "Befehl wird ausgeführt...",
259
+ "commands.executing_prefix": "⚡ Befehl wird ausgeführt:",
260
+ "commands.arguments_empty": "⚠️ Argumente dürfen nicht leer sein. Sende Text oder tippe auf Ausführen.",
261
+ "commands.execute_error": "🔴 OpenCode-Befehl konnte nicht ausgeführt werden.",
241
262
  "cmd.description.rename": "Aktuelle Sitzung umbenennen",
242
263
  "cli.usage": "Verwendung:\n opencode-telegram [start] [--mode sources|installed]\n opencode-telegram status\n opencode-telegram stop\n opencode-telegram config\n\nHinweise:\n - Ohne Befehl wird standardmäßig `start` verwendet\n - `--mode` wird derzeit nur für `start` unterstützt",
243
264
  "cli.placeholder.status": "Befehl `status` ist derzeit ein Platzhalter. Echte Statusprüfungen werden in der Service-Schicht hinzugefügt (Phase 5).",
package/dist/i18n/en.js CHANGED
@@ -4,6 +4,7 @@ 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.commands": "Custom commands",
7
8
  "cmd.description.opencode_start": "Start OpenCode server",
8
9
  "cmd.description.opencode_stop": "Stop OpenCode server",
9
10
  "cmd.description.help": "Help",
@@ -48,23 +49,28 @@ export const en = {
48
49
  "bot.photo_model_no_image": "⚠️ Current model doesn't support image input. Sending text only.",
49
50
  "bot.photo_download_error": "🔴 Failed to download photo",
50
51
  "bot.photo_no_caption": "💡 Tip: Add a caption to describe what you want to do with this photo.",
52
+ "bot.file_downloading": "⏳ Downloading file...",
53
+ "bot.file_too_large": "⚠️ File is too large (max {maxSizeMb}MB)",
54
+ "bot.file_download_error": "🔴 Failed to download file",
55
+ "bot.model_no_pdf": "⚠️ Current model doesn't support PDF input. Sending text only.",
56
+ "bot.text_file_too_large": "⚠️ Text file is too large (max {maxSizeKb}KB)",
51
57
  "status.header_running": "🟢 **OpenCode Server is running**",
52
58
  "status.health.healthy": "Healthy",
53
59
  "status.health.unhealthy": "Unhealthy",
54
60
  "status.line.health": "Status: {health}",
55
61
  "status.line.version": "Version: {version}",
56
- "status.line.managed_yes": "Managed by bot: Yes",
57
- "status.line.managed_no": "Managed by bot: No",
62
+ "status.line.managed_yes": "Started by bot: Yes",
63
+ "status.line.managed_no": "Started by bot: No",
58
64
  "status.line.pid": "PID: {pid}",
59
65
  "status.line.uptime_sec": "Uptime: {seconds} sec",
60
66
  "status.line.mode": "Mode: {mode}",
61
67
  "status.line.model": "Model: {model}",
62
68
  "status.agent_not_set": "not set",
63
- "status.project_selected": "🏗 Project: {project}",
64
- "status.project_not_selected": "🏗 Project: not selected",
69
+ "status.project_selected": "Project: {project}",
70
+ "status.project_not_selected": "Project: not selected",
65
71
  "status.project_hint": "Use /projects to select a project",
66
- "status.session_selected": "📋 Current session: {title}",
67
- "status.session_not_selected": "📋 Current session: not selected",
72
+ "status.session_selected": "Current session: {title}",
73
+ "status.session_not_selected": "Current session: not selected",
68
74
  "status.session_hint": "Use /sessions to select one or /new to create one",
69
75
  "status.server_unavailable": "🔴 OpenCode Server is unavailable\n\nUse /opencode_start to start the server.",
70
76
  "projects.empty": "📭 No projects found.\n\nOpen a directory in OpenCode and create at least one session, then it will appear here.",
@@ -222,6 +228,8 @@ export const en = {
222
228
  "runtime.wizard.ask_user_id": "Enter your Telegram User ID (you can get it from @userinfobot).\n> ",
223
229
  "runtime.wizard.user_id_invalid": "Enter a positive integer (> 0).\n",
224
230
  "runtime.wizard.ask_api_url": "Enter OpenCode API URL (optional).\nPress Enter to use default: {defaultUrl}\n> ",
231
+ "runtime.wizard.ask_server_username": "Enter OpenCode server username (optional).\nPress Enter to use default: {defaultUsername}\n> ",
232
+ "runtime.wizard.ask_server_password": "Enter OpenCode server password (optional).\nPress Enter to keep it empty.\n> ",
225
233
  "runtime.wizard.api_url_invalid": "Enter a valid URL (http/https) or press Enter for default.\n",
226
234
  "runtime.wizard.start": "OpenCode Telegram Bot setup.\n",
227
235
  "runtime.wizard.saved": "Configuration saved:\n- {envPath}\n- {settingsPath}\n",
@@ -238,6 +246,19 @@ export const en = {
238
246
  "rename.blocked.expected_name": "⚠️ Enter a new session name as text or tap Cancel in rename message.",
239
247
  "rename.blocked.command_not_allowed": "⚠️ This command is not available while rename is waiting for a new name.",
240
248
  "rename.button.cancel": "❌ Cancel",
249
+ "commands.select": "Choose an OpenCode command:",
250
+ "commands.empty": "📭 No OpenCode commands are available for this project.",
251
+ "commands.fetch_error": "🔴 Failed to load OpenCode commands.",
252
+ "commands.no_description": "No description",
253
+ "commands.button.execute": "✅ Execute",
254
+ "commands.button.cancel": "❌ Cancel",
255
+ "commands.confirm": "Confirm execution of command {command}. To run it with arguments, send the arguments as a message.",
256
+ "commands.inactive_callback": "This command menu is inactive",
257
+ "commands.cancelled_callback": "Cancelled",
258
+ "commands.execute_callback": "Executing command...",
259
+ "commands.executing_prefix": "⚡ Executing command:",
260
+ "commands.arguments_empty": "⚠️ Arguments cannot be empty. Send text or tap Execute.",
261
+ "commands.execute_error": "🔴 Failed to execute OpenCode command.",
241
262
  "cmd.description.rename": "Rename current session",
242
263
  "cli.usage": "Usage:\n opencode-telegram [start] [--mode sources|installed]\n opencode-telegram status\n opencode-telegram stop\n opencode-telegram config\n\nNotes:\n - No command defaults to `start`\n - `--mode` is currently supported for `start` only",
243
264
  "cli.placeholder.status": "Command `status` is currently a placeholder. Real status checks will be added in service layer (Phase 5).",
package/dist/i18n/es.js CHANGED
@@ -4,6 +4,7 @@ export const es = {
4
4
  "cmd.description.stop": "Detener la acción actual",
5
5
  "cmd.description.sessions": "Listar sesiones",
6
6
  "cmd.description.projects": "Listar proyectos",
7
+ "cmd.description.commands": "Comandos personalizados",
7
8
  "cmd.description.opencode_start": "Iniciar servidor OpenCode",
8
9
  "cmd.description.opencode_stop": "Detener servidor OpenCode",
9
10
  "cmd.description.help": "Ayuda",
@@ -48,23 +49,28 @@ export const es = {
48
49
  "bot.photo_model_no_image": "⚠️ El modelo actual no admite entrada de imagen. Enviaré solo texto.",
49
50
  "bot.photo_download_error": "🔴 No se pudo descargar la foto",
50
51
  "bot.photo_no_caption": "💡 Consejo: agrega un pie de foto para describir que quieres hacer con esta foto.",
52
+ "bot.file_downloading": "⏳ Descargando archivo...",
53
+ "bot.file_too_large": "⚠️ El archivo es demasiado grande (max {maxSizeMb}MB)",
54
+ "bot.file_download_error": "🔴 No se pudo descargar el archivo",
55
+ "bot.model_no_pdf": "⚠️ El modelo actual no admite entrada PDF. Enviaré solo texto.",
56
+ "bot.text_file_too_large": "⚠️ El archivo de texto es demasiado grande (max {maxSizeKb}KB)",
51
57
  "status.header_running": "🟢 **OpenCode Server está en ejecución**",
52
58
  "status.health.healthy": "Saludable",
53
59
  "status.health.unhealthy": "No saludable",
54
60
  "status.line.health": "Estado: {health}",
55
61
  "status.line.version": "Versión: {version}",
56
- "status.line.managed_yes": "Administrado por el bot: Sí",
57
- "status.line.managed_no": "Administrado por el bot: No",
62
+ "status.line.managed_yes": "Iniciado por el bot: Sí",
63
+ "status.line.managed_no": "Iniciado por el bot: No",
58
64
  "status.line.pid": "PID: {pid}",
59
65
  "status.line.uptime_sec": "Tiempo activo: {seconds} s",
60
66
  "status.line.mode": "Modo: {mode}",
61
67
  "status.line.model": "Modelo: {model}",
62
68
  "status.agent_not_set": "no configurado",
63
- "status.project_selected": "🏗 Proyecto: {project}",
64
- "status.project_not_selected": "🏗 Proyecto: no seleccionado",
69
+ "status.project_selected": "Proyecto: {project}",
70
+ "status.project_not_selected": "Proyecto: no seleccionado",
65
71
  "status.project_hint": "Usa /projects para seleccionar un proyecto",
66
- "status.session_selected": "📋 Sesión actual: {title}",
67
- "status.session_not_selected": "📋 Sesión actual: no seleccionada",
72
+ "status.session_selected": "Sesión actual: {title}",
73
+ "status.session_not_selected": "Sesión actual: no seleccionada",
68
74
  "status.session_hint": "Usa /sessions para elegir una o /new para crear una",
69
75
  "status.server_unavailable": "🔴 OpenCode Server no está disponible\n\nUsa /opencode_start para iniciar el servidor.",
70
76
  "projects.empty": "📭 No se encontraron proyectos.\n\nAbre un directorio en OpenCode y crea al menos una sesión; entonces aparecerá aquí.",
@@ -222,6 +228,8 @@ export const es = {
222
228
  "runtime.wizard.ask_user_id": "Introduce tu Telegram User ID (puedes obtenerlo de @userinfobot).\n> ",
223
229
  "runtime.wizard.user_id_invalid": "Introduce un entero positivo (> 0).\n",
224
230
  "runtime.wizard.ask_api_url": "Introduce la URL de la API de OpenCode (opcional).\nPulsa Enter para usar el valor por defecto: {defaultUrl}\n> ",
231
+ "runtime.wizard.ask_server_username": "Introduce el nombre de usuario del servidor OpenCode (opcional).\nPulsa Enter para usar el valor por defecto: {defaultUsername}\n> ",
232
+ "runtime.wizard.ask_server_password": "Introduce la contrasena del servidor OpenCode (opcional).\nPulsa Enter para dejarla vacia.\n> ",
225
233
  "runtime.wizard.api_url_invalid": "Introduce una URL válida (http/https) o pulsa Enter para usar el valor por defecto.\n",
226
234
  "runtime.wizard.start": "Configuración de OpenCode Telegram Bot.\n",
227
235
  "runtime.wizard.saved": "Configuración guardada:\n- {envPath}\n- {settingsPath}\n",
@@ -238,6 +246,19 @@ export const es = {
238
246
  "rename.blocked.expected_name": "⚠️ Introduce el nuevo nombre de la sesión como texto o toca Cancelar en el mensaje de cambio de nombre.",
239
247
  "rename.blocked.command_not_allowed": "⚠️ Este comando no está disponible mientras el cambio de nombre espera un nuevo nombre.",
240
248
  "rename.button.cancel": "❌ Cancelar",
249
+ "commands.select": "Elige un comando de OpenCode:",
250
+ "commands.empty": "📭 No hay comandos de OpenCode disponibles para este proyecto.",
251
+ "commands.fetch_error": "🔴 No se pudieron cargar los comandos de OpenCode.",
252
+ "commands.no_description": "Sin descripción",
253
+ "commands.button.execute": "✅ Ejecutar",
254
+ "commands.button.cancel": "❌ Cancelar",
255
+ "commands.confirm": "Confirma la ejecución del comando {command}. Para ejecutarlo con argumentos, envía los argumentos como mensaje.",
256
+ "commands.inactive_callback": "Este menú de comandos está inactivo",
257
+ "commands.cancelled_callback": "Cancelado",
258
+ "commands.execute_callback": "Ejecutando comando...",
259
+ "commands.executing_prefix": "⚡ Ejecutando comando:",
260
+ "commands.arguments_empty": "⚠️ Los argumentos no pueden estar vacíos. Envía texto o toca Ejecutar.",
261
+ "commands.execute_error": "🔴 No se pudo ejecutar el comando de OpenCode.",
241
262
  "cmd.description.rename": "Renombrar la sesión actual",
242
263
  "cli.usage": "Uso:\n opencode-telegram [start] [--mode sources|installed]\n opencode-telegram status\n opencode-telegram stop\n opencode-telegram config\n\nNotas:\n - Sin comando, el valor por defecto es `start`\n - `--mode` actualmente solo se admite para `start`",
243
264
  "cli.placeholder.status": "El comando `status` es actualmente un marcador de posición. Las comprobaciones reales de estado se agregarán en la capa de servicio (Fase 5).",
package/dist/i18n/ru.js CHANGED
@@ -4,6 +4,7 @@ export const ru = {
4
4
  "cmd.description.stop": "Прервать текущее действие",
5
5
  "cmd.description.sessions": "Список сессий",
6
6
  "cmd.description.projects": "Список проектов",
7
+ "cmd.description.commands": "Пользовательские команды",
7
8
  "cmd.description.opencode_start": "Запустить OpenCode сервер",
8
9
  "cmd.description.opencode_stop": "Остановить OpenCode сервер",
9
10
  "cmd.description.help": "Справка",
@@ -48,23 +49,28 @@ export const ru = {
48
49
  "bot.photo_model_no_image": "⚠️ Текущая модель не поддерживает изображения. Отправляю только текст.",
49
50
  "bot.photo_download_error": "🔴 Не удалось скачать фото",
50
51
  "bot.photo_no_caption": "💡 Совет: Добавьте подпись, чтобы описать, что делать с этим фото.",
52
+ "bot.file_downloading": "⏳ Скачиваю файл...",
53
+ "bot.file_too_large": "⚠️ Файл слишком большой (макс. {maxSizeMb}МБ)",
54
+ "bot.file_download_error": "🔴 Не удалось скачать файл",
55
+ "bot.model_no_pdf": "⚠️ Текущая модель не поддерживает PDF. Отправляю только текст.",
56
+ "bot.text_file_too_large": "⚠️ Текстовый файл слишком большой (макс. {maxSizeKb}КБ)",
51
57
  "status.header_running": "🟢 **OpenCode Server запущен**",
52
58
  "status.health.healthy": "Healthy",
53
59
  "status.health.unhealthy": "Unhealthy",
54
60
  "status.line.health": "Статус: {health}",
55
61
  "status.line.version": "Версия: {version}",
56
- "status.line.managed_yes": "Управляется ботом: Да",
57
- "status.line.managed_no": "Управляется ботом: Нет",
62
+ "status.line.managed_yes": "Запущен ботом: Да",
63
+ "status.line.managed_no": "Запущен ботом: Нет",
58
64
  "status.line.pid": "PID: {pid}",
59
65
  "status.line.uptime_sec": "Uptime: {seconds} сек",
60
66
  "status.line.mode": "Режим: {mode}",
61
67
  "status.line.model": "Модель: {model}",
62
68
  "status.agent_not_set": "не установлен",
63
- "status.project_selected": "🏗 Проект: {project}",
64
- "status.project_not_selected": "🏗 Проект: не выбран",
69
+ "status.project_selected": "Проект: {project}",
70
+ "status.project_not_selected": "Проект: не выбран",
65
71
  "status.project_hint": "Используйте /projects для выбора проекта",
66
- "status.session_selected": "📋 Текущая сессия: {title}",
67
- "status.session_not_selected": "📋 Текущая сессия: не выбрана",
72
+ "status.session_selected": "Текущая сессия: {title}",
73
+ "status.session_not_selected": "Текущая сессия: не выбрана",
68
74
  "status.session_hint": "Используйте /sessions для выбора или /new для создания",
69
75
  "status.server_unavailable": "🔴 OpenCode Server недоступен\n\nИспользуйте /opencode_start для запуска сервера.",
70
76
  "projects.empty": "📭 Проектов нет.\n\nОткройте директорию в OpenCode и создайте хотя бы одну сессию, после этого она появится здесь.",
@@ -202,14 +208,14 @@ export const ru = {
202
208
  "keyboard.variant": "💭 {name}",
203
209
  "keyboard.variant_default": "💡 Default",
204
210
  "keyboard.updated": "⌨️ Клавиатура обновлена",
205
- "pinned.default_session_title": "new session",
206
- "pinned.unknown": "Unknown",
207
- "pinned.line.project": "Project: {project}",
208
- "pinned.line.model": "Model: {model}",
209
- "pinned.line.context": "Context: {used} / {limit} ({percent}%)",
210
- "pinned.files.title": "Files ({count}):",
211
+ "pinned.default_session_title": "новая сессия",
212
+ "pinned.unknown": "Неизвестно",
213
+ "pinned.line.project": "Проект: {project}",
214
+ "pinned.line.model": "Модель: {model}",
215
+ "pinned.line.context": "Контекст: {used} / {limit} ({percent}%)",
216
+ "pinned.files.title": "Файлы ({count}):",
211
217
  "pinned.files.item": " {path}{diff}",
212
- "pinned.files.more": " ... and {count} more",
218
+ "pinned.files.more": " ... и еще {count}",
213
219
  "tool.todo.overflow": "*(ещё {count} задач)*",
214
220
  "tool.file_header.write": "Write File/Path: {path}\n============================================================\n\n",
215
221
  "tool.file_header.edit": "Edit File/Path: {path}\n============================================================\n\n",
@@ -222,6 +228,8 @@ export const ru = {
222
228
  "runtime.wizard.ask_user_id": "Введите ваш Telegram User ID (можно узнать у @userinfobot).\n> ",
223
229
  "runtime.wizard.user_id_invalid": "Введите положительное целое число (> 0).\n",
224
230
  "runtime.wizard.ask_api_url": "Введите URL OpenCode API (опционально).\nНажмите Enter для значения по умолчанию: {defaultUrl}\n> ",
231
+ "runtime.wizard.ask_server_username": "Введите логин OpenCode сервера (опционально).\nНажмите Enter для значения по умолчанию: {defaultUsername}\n> ",
232
+ "runtime.wizard.ask_server_password": "Введите пароль OpenCode сервера (опционально).\nНажмите Enter, чтобы оставить пустым.\n> ",
225
233
  "runtime.wizard.api_url_invalid": "Введите корректный URL (http/https) или нажмите Enter для значения по умолчанию.\n",
226
234
  "runtime.wizard.start": "Настройка OpenCode Telegram Bot.\n",
227
235
  "runtime.wizard.saved": "Конфигурация сохранена:\n- {envPath}\n- {settingsPath}\n",
@@ -238,6 +246,19 @@ export const ru = {
238
246
  "rename.blocked.expected_name": "⚠️ Введите новое название текстом или нажмите Отмена в сообщении переименования.",
239
247
  "rename.blocked.command_not_allowed": "⚠️ Эта команда недоступна, пока ожидается новое название сессии.",
240
248
  "rename.button.cancel": "❌ Отмена",
249
+ "commands.select": "Выберите команду OpenCode:",
250
+ "commands.empty": "📭 Для этого проекта нет доступных команд OpenCode.",
251
+ "commands.fetch_error": "🔴 Не удалось загрузить список команд OpenCode.",
252
+ "commands.no_description": "Без описания",
253
+ "commands.button.execute": "✅ Выполнить",
254
+ "commands.button.cancel": "❌ Отмена",
255
+ "commands.confirm": "Подтвердите выполнение команды {command}. Для выполнения с аргументами отправьте аргументы отдельным сообщением.",
256
+ "commands.inactive_callback": "Это меню команд уже неактивно",
257
+ "commands.cancelled_callback": "Отменено",
258
+ "commands.execute_callback": "Запускаю команду...",
259
+ "commands.executing_prefix": "⚡ Выполнение команды:",
260
+ "commands.arguments_empty": "⚠️ Аргументы не могут быть пустыми. Отправьте текст или нажмите Выполнить.",
261
+ "commands.execute_error": "🔴 Не удалось выполнить команду OpenCode.",
241
262
  "cmd.description.rename": "Переименовать текущую сессию",
242
263
  "cli.usage": "Использование:\n opencode-telegram [start] [--mode sources|installed]\n opencode-telegram status\n opencode-telegram stop\n opencode-telegram config\n\nЗаметки:\n - Без команды по умолчанию используется `start`\n - `--mode` сейчас поддерживается только для `start`",
243
264
  "cli.placeholder.status": "Команда `status` пока работает как заглушка. Реальная проверка статуса появится на этапе service-слоя (Этап 5).",
package/dist/i18n/zh.js CHANGED
@@ -4,6 +4,7 @@ export const zh = {
4
4
  "cmd.description.stop": "停止当前操作",
5
5
  "cmd.description.sessions": "列出会话",
6
6
  "cmd.description.projects": "列出项目",
7
+ "cmd.description.commands": "自定义命令",
7
8
  "cmd.description.opencode_start": "启动 OpenCode 服务器",
8
9
  "cmd.description.opencode_stop": "停止 OpenCode 服务器",
9
10
  "cmd.description.help": "帮助",
@@ -48,23 +49,28 @@ export const zh = {
48
49
  "bot.photo_model_no_image": "⚠️ 当前模型不支持图像输入。将仅发送文本。",
49
50
  "bot.photo_download_error": "🔴 下载照片失败",
50
51
  "bot.photo_no_caption": "💡 提示:添加说明文字以描述你希望对这张照片做什么。",
52
+ "bot.file_downloading": "⏳ 正在下载文件...",
53
+ "bot.file_too_large": "⚠️ 文件过大(最大 {maxSizeMb}MB)",
54
+ "bot.file_download_error": "🔴 下载文件失败",
55
+ "bot.model_no_pdf": "⚠️ 当前模型不支持PDF输入。将仅发送文本。",
56
+ "bot.text_file_too_large": "⚠️ 文本文件过大(最大 {maxSizeKb}KB)",
51
57
  "status.header_running": "🟢 **OpenCode 服务器正在运行**",
52
58
  "status.health.healthy": "健康",
53
59
  "status.health.unhealthy": "不健康",
54
60
  "status.line.health": "状态:{health}",
55
61
  "status.line.version": "版本:{version}",
56
- "status.line.managed_yes": "由机器人管理:是",
57
- "status.line.managed_no": "由机器人管理:否",
62
+ "status.line.managed_yes": "由机器人启动:是",
63
+ "status.line.managed_no": "由机器人启动:否",
58
64
  "status.line.pid": "PID:{pid}",
59
65
  "status.line.uptime_sec": "运行时间:{seconds} 秒",
60
66
  "status.line.mode": "模式:{mode}",
61
67
  "status.line.model": "模型:{model}",
62
68
  "status.agent_not_set": "未设置",
63
- "status.project_selected": "🏗 项目:{project}",
64
- "status.project_not_selected": "🏗 项目:未选择",
69
+ "status.project_selected": "项目:{project}",
70
+ "status.project_not_selected": "项目:未选择",
65
71
  "status.project_hint": "使用 /projects 选择项目",
66
- "status.session_selected": "📋 当前会话:{title}",
67
- "status.session_not_selected": "📋 当前会话:未选择",
72
+ "status.session_selected": "当前会话:{title}",
73
+ "status.session_not_selected": "当前会话:未选择",
68
74
  "status.session_hint": "使用 /sessions 选择一个会话,或 /new 创建",
69
75
  "status.server_unavailable": "🔴 OpenCode 服务器不可用\n\n使用 /opencode_start 启动服务器。",
70
76
  "projects.empty": "📭 未找到项目。\n\n在 OpenCode 中打开一个目录并至少创建一个会话,然后它会出现在这里。",
@@ -222,6 +228,8 @@ export const zh = {
222
228
  "runtime.wizard.ask_user_id": "请输入你的 Telegram User ID(可从 @userinfobot 获取)。\n> ",
223
229
  "runtime.wizard.user_id_invalid": "请输入一个正整数(> 0)。\n",
224
230
  "runtime.wizard.ask_api_url": "请输入 OpenCode API URL(可选)。\n按 Enter 使用默认值:{defaultUrl}\n> ",
231
+ "runtime.wizard.ask_server_username": "请输入 OpenCode 服务器用户名(可选)。\n按 Enter 使用默认值:{defaultUsername}\n> ",
232
+ "runtime.wizard.ask_server_password": "请输入 OpenCode 服务器密码(可选)。\n按 Enter 保持为空。\n> ",
225
233
  "runtime.wizard.api_url_invalid": "请输入有效 URL(http/https),或按 Enter 使用默认值。\n",
226
234
  "runtime.wizard.start": "OpenCode Telegram Bot 设置。\n",
227
235
  "runtime.wizard.saved": "配置已保存:\n- {envPath}\n- {settingsPath}\n",
@@ -238,6 +246,19 @@ export const zh = {
238
246
  "rename.blocked.expected_name": "⚠️ 请以文本输入新会话名称,或在重命名消息中点击取消。",
239
247
  "rename.blocked.command_not_allowed": "⚠️ 重命名等待新名称期间不可用此命令。",
240
248
  "rename.button.cancel": "❌ 取消",
249
+ "commands.select": "请选择一个 OpenCode 命令:",
250
+ "commands.empty": "📭 当前项目没有可用的 OpenCode 命令。",
251
+ "commands.fetch_error": "🔴 加载 OpenCode 命令失败。",
252
+ "commands.no_description": "无描述",
253
+ "commands.button.execute": "✅ 执行",
254
+ "commands.button.cancel": "❌ 取消",
255
+ "commands.confirm": "请确认执行命令 {command}。若需带参数执行,请发送一条包含参数的消息。",
256
+ "commands.inactive_callback": "该命令菜单已失效",
257
+ "commands.cancelled_callback": "已取消",
258
+ "commands.execute_callback": "正在执行命令...",
259
+ "commands.executing_prefix": "⚡ 执行命令:",
260
+ "commands.arguments_empty": "⚠️ 参数不能为空。请发送文本或点击执行。",
261
+ "commands.execute_error": "🔴 执行 OpenCode 命令失败。",
241
262
  "cmd.description.rename": "重命名当前会话",
242
263
  "cli.usage": "用法:\n opencode-telegram [start] [--mode sources|installed]\n opencode-telegram status\n opencode-telegram stop\n opencode-telegram config\n\n说明:\n - 不带命令时默认执行 `start`\n - `--mode` 目前仅支持 `start` 命令",
243
264
  "cli.placeholder.status": "命令 `status` 目前是占位符。真实状态检查将会在 service 层(阶段 5)添加。",
@@ -7,6 +7,7 @@ import dotenv from "dotenv";
7
7
  import { getRuntimePaths } from "./paths.js";
8
8
  import { getLocale, getLocaleOptions, resolveSupportedLocale, setRuntimeLocale, t, } from "../i18n/index.js";
9
9
  const DEFAULT_API_URL = "http://localhost:4096";
10
+ const DEFAULT_SERVER_USERNAME = "opencode";
10
11
  const FALLBACK_MODEL_PROVIDER = "opencode";
11
12
  const FALLBACK_MODEL_ID = "big-pickle";
12
13
  function isPositiveInteger(value) {
@@ -64,6 +65,8 @@ export function buildEnvFileContent(existingContent, values) {
64
65
  ["TELEGRAM_BOT_TOKEN", values.TELEGRAM_BOT_TOKEN],
65
66
  ["TELEGRAM_ALLOWED_USER_ID", values.TELEGRAM_ALLOWED_USER_ID],
66
67
  ["OPENCODE_API_URL", values.OPENCODE_API_URL],
68
+ ["OPENCODE_SERVER_USERNAME", values.OPENCODE_SERVER_USERNAME],
69
+ ["OPENCODE_SERVER_PASSWORD", values.OPENCODE_SERVER_PASSWORD],
67
70
  ["OPENCODE_MODEL_PROVIDER", values.OPENCODE_MODEL_PROVIDER],
68
71
  ["OPENCODE_MODEL_ID", values.OPENCODE_MODEL_ID],
69
72
  ];
@@ -242,6 +245,23 @@ async function askApiUrl() {
242
245
  return apiUrl;
243
246
  }
244
247
  }
248
+ async function askServerUsername() {
249
+ const prompt = t("runtime.wizard.ask_server_username", {
250
+ defaultUsername: DEFAULT_SERVER_USERNAME,
251
+ });
252
+ const username = await askVisible(prompt);
253
+ if (!username) {
254
+ return DEFAULT_SERVER_USERNAME;
255
+ }
256
+ return username;
257
+ }
258
+ async function askServerPassword() {
259
+ const password = await askHidden(t("runtime.wizard.ask_server_password"));
260
+ if (!password) {
261
+ return undefined;
262
+ }
263
+ return password;
264
+ }
245
265
  async function collectWizardValues() {
246
266
  const locale = await askLocale();
247
267
  setRuntimeLocale(locale);
@@ -258,12 +278,16 @@ async function collectWizardValues() {
258
278
  const token = await askToken();
259
279
  const allowedUserId = await askAllowedUserId();
260
280
  const apiUrl = await askApiUrl();
281
+ const serverUsername = await askServerUsername();
282
+ const serverPassword = await askServerPassword();
261
283
  process.stdout.write("\n");
262
284
  return {
263
285
  locale,
264
286
  token,
265
287
  allowedUserId,
266
288
  apiUrl,
289
+ serverUsername,
290
+ serverPassword,
267
291
  };
268
292
  }
269
293
  function ensureInteractiveTty() {
@@ -294,6 +318,8 @@ async function runWizardAndPersist(runtimePaths) {
294
318
  TELEGRAM_BOT_TOKEN: wizardValues.token,
295
319
  TELEGRAM_ALLOWED_USER_ID: wizardValues.allowedUserId,
296
320
  OPENCODE_API_URL: wizardValues.apiUrl,
321
+ OPENCODE_SERVER_USERNAME: wizardValues.serverUsername,
322
+ OPENCODE_SERVER_PASSWORD: wizardValues.serverPassword,
297
323
  OPENCODE_MODEL_PROVIDER: provider,
298
324
  OPENCODE_MODEL_ID: modelId,
299
325
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.10.1",
3
+ "version": "0.11.1",
4
4
  "description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",