@grinev/opencode-telegram-bot 0.1.0-rc.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.
Files changed (66) hide show
  1. package/.env.example +34 -0
  2. package/LICENSE +21 -0
  3. package/README.md +72 -0
  4. package/dist/agent/manager.js +92 -0
  5. package/dist/agent/types.js +26 -0
  6. package/dist/app/start-bot-app.js +26 -0
  7. package/dist/bot/commands/agent.js +16 -0
  8. package/dist/bot/commands/definitions.js +20 -0
  9. package/dist/bot/commands/help.js +7 -0
  10. package/dist/bot/commands/model.js +16 -0
  11. package/dist/bot/commands/models.js +37 -0
  12. package/dist/bot/commands/new.js +58 -0
  13. package/dist/bot/commands/opencode-start.js +87 -0
  14. package/dist/bot/commands/opencode-stop.js +46 -0
  15. package/dist/bot/commands/projects.js +104 -0
  16. package/dist/bot/commands/server-restart.js +23 -0
  17. package/dist/bot/commands/server-start.js +23 -0
  18. package/dist/bot/commands/sessions.js +240 -0
  19. package/dist/bot/commands/start.js +40 -0
  20. package/dist/bot/commands/status.js +63 -0
  21. package/dist/bot/commands/stop.js +92 -0
  22. package/dist/bot/handlers/agent.js +96 -0
  23. package/dist/bot/handlers/context.js +112 -0
  24. package/dist/bot/handlers/model.js +115 -0
  25. package/dist/bot/handlers/permission.js +158 -0
  26. package/dist/bot/handlers/question.js +294 -0
  27. package/dist/bot/handlers/variant.js +126 -0
  28. package/dist/bot/index.js +573 -0
  29. package/dist/bot/middleware/auth.js +30 -0
  30. package/dist/bot/utils/keyboard.js +66 -0
  31. package/dist/cli/args.js +97 -0
  32. package/dist/cli.js +90 -0
  33. package/dist/config.js +46 -0
  34. package/dist/index.js +26 -0
  35. package/dist/keyboard/manager.js +171 -0
  36. package/dist/keyboard/types.js +1 -0
  37. package/dist/model/manager.js +123 -0
  38. package/dist/model/types.js +26 -0
  39. package/dist/opencode/client.js +13 -0
  40. package/dist/opencode/events.js +79 -0
  41. package/dist/opencode/server.js +104 -0
  42. package/dist/permission/manager.js +78 -0
  43. package/dist/permission/types.js +1 -0
  44. package/dist/pinned/manager.js +610 -0
  45. package/dist/pinned/types.js +1 -0
  46. package/dist/pinned-message/service.js +54 -0
  47. package/dist/process/manager.js +273 -0
  48. package/dist/process/types.js +1 -0
  49. package/dist/project/manager.js +28 -0
  50. package/dist/question/manager.js +143 -0
  51. package/dist/question/types.js +1 -0
  52. package/dist/runtime/bootstrap.js +278 -0
  53. package/dist/runtime/mode.js +74 -0
  54. package/dist/runtime/paths.js +37 -0
  55. package/dist/session/manager.js +10 -0
  56. package/dist/session/state.js +24 -0
  57. package/dist/settings/manager.js +99 -0
  58. package/dist/status/formatter.js +44 -0
  59. package/dist/summary/aggregator.js +427 -0
  60. package/dist/summary/formatter.js +226 -0
  61. package/dist/utils/formatting.js +237 -0
  62. package/dist/utils/logger.js +59 -0
  63. package/dist/utils/safe-background-task.js +33 -0
  64. package/dist/variant/manager.js +103 -0
  65. package/dist/variant/types.js +1 -0
  66. package/package.json +63 -0
@@ -0,0 +1,237 @@
1
+ import { config } from "../config.js";
2
+ import { logger } from "./logger.js";
3
+ function normalizeParseMode(value) {
4
+ if (typeof value !== "string" || value.trim().length === 0) {
5
+ return "MarkdownV2";
6
+ }
7
+ const raw = value.trim();
8
+ const normalized = raw.toLowerCase();
9
+ switch (normalized) {
10
+ case "markdownv2":
11
+ return "MarkdownV2";
12
+ case "markdown":
13
+ return "Markdown";
14
+ case "html":
15
+ return "HTML";
16
+ case "none":
17
+ return "None";
18
+ default:
19
+ logger.warn(`[Formatting] Invalid TELEGRAM_PARSE_MODE="${raw}", falling back to MarkdownV2`);
20
+ return "MarkdownV2";
21
+ }
22
+ }
23
+ export function getParseMode() {
24
+ return normalizeParseMode(config.telegram.parseMode);
25
+ }
26
+ export function sendMessageOptions() {
27
+ const mode = getParseMode();
28
+ if (mode === "None") {
29
+ return {};
30
+ }
31
+ return { parse_mode: mode };
32
+ }
33
+ function escapeHtml(text) {
34
+ return text
35
+ .replaceAll("&", "&")
36
+ .replaceAll("<", "&lt;")
37
+ .replaceAll(">", "&gt;")
38
+ .replaceAll('"', "&quot;")
39
+ .replaceAll("'", "&#39;");
40
+ }
41
+ function escapeMarkdownV2Text(text) {
42
+ let result = text.replaceAll("\\", "\\\\");
43
+ const specialChars = [
44
+ "_",
45
+ "*",
46
+ "[",
47
+ "]",
48
+ "(",
49
+ ")",
50
+ "~",
51
+ "`",
52
+ ">",
53
+ "#",
54
+ "+",
55
+ "-",
56
+ "=",
57
+ "|",
58
+ "{",
59
+ "}",
60
+ ".",
61
+ "!",
62
+ ];
63
+ for (const char of specialChars) {
64
+ result = result.split(char).join(`\\${char}`);
65
+ }
66
+ return result;
67
+ }
68
+ function escapeMarkdownText(text) {
69
+ let result = text.replaceAll("\\", "\\\\");
70
+ const specialChars = ["_", "*", "`", "[", "]"];
71
+ for (const char of specialChars) {
72
+ result = result.split(char).join(`\\${char}`);
73
+ }
74
+ return result;
75
+ }
76
+ function escapeMarkdownV2Code(text) {
77
+ return text.replaceAll("\\", "\\\\").replaceAll("`", "\\`");
78
+ }
79
+ function escapeMarkdownCode(text) {
80
+ return text.replaceAll("\\", "\\\\").replaceAll("`", "\\`");
81
+ }
82
+ export function escapeText(text) {
83
+ const mode = getParseMode();
84
+ switch (mode) {
85
+ case "MarkdownV2":
86
+ return escapeMarkdownV2Text(text);
87
+ case "Markdown":
88
+ return escapeMarkdownText(text);
89
+ case "HTML":
90
+ return escapeHtml(text);
91
+ case "None":
92
+ return text;
93
+ default:
94
+ return text;
95
+ }
96
+ }
97
+ export function formatBold(text) {
98
+ const mode = getParseMode();
99
+ switch (mode) {
100
+ case "MarkdownV2":
101
+ return `*${escapeMarkdownV2Text(text)}*`;
102
+ case "Markdown":
103
+ return `*${escapeMarkdownText(text)}*`;
104
+ case "HTML":
105
+ return `<b>${escapeHtml(text)}</b>`;
106
+ case "None":
107
+ return text;
108
+ default:
109
+ return text;
110
+ }
111
+ }
112
+ export function formatItalic(text) {
113
+ const mode = getParseMode();
114
+ switch (mode) {
115
+ case "MarkdownV2":
116
+ return `_${escapeMarkdownV2Text(text)}_`;
117
+ case "Markdown":
118
+ return `_${escapeMarkdownText(text)}_`;
119
+ case "HTML":
120
+ return `<i>${escapeHtml(text)}</i>`;
121
+ case "None":
122
+ return text;
123
+ default:
124
+ return text;
125
+ }
126
+ }
127
+ export function formatCode(text) {
128
+ const mode = getParseMode();
129
+ switch (mode) {
130
+ case "MarkdownV2":
131
+ return `\`${escapeMarkdownV2Code(text)}\``;
132
+ case "Markdown":
133
+ return `\`${escapeMarkdownCode(text)}\``;
134
+ case "HTML":
135
+ return `<code>${escapeHtml(text)}</code>`;
136
+ case "None":
137
+ return text;
138
+ default:
139
+ return text;
140
+ }
141
+ }
142
+ export function formatCodeBlock(text) {
143
+ const mode = getParseMode();
144
+ switch (mode) {
145
+ case "MarkdownV2":
146
+ return `\`\`\`\n${text}\n\`\`\``;
147
+ case "Markdown":
148
+ return `\`\`\`\n${text}\n\`\`\``;
149
+ case "HTML":
150
+ return `<pre><code>${escapeHtml(text)}</code></pre>`;
151
+ case "None":
152
+ return text;
153
+ default:
154
+ return text;
155
+ }
156
+ }
157
+ function formatInlineMarkdownLike(text) {
158
+ const mode = getParseMode();
159
+ if (mode === "None") {
160
+ return text;
161
+ }
162
+ const boldRe = /\*\*([^*]+)\*\*/g;
163
+ const codeRe = /`([^`]+)`/g;
164
+ let i = 0;
165
+ let out = "";
166
+ while (i < text.length) {
167
+ const boldMatch = boldRe.exec(text);
168
+ const codeMatch = codeRe.exec(text);
169
+ const nextBoldStart = boldMatch ? boldMatch.index : Number.POSITIVE_INFINITY;
170
+ const nextCodeStart = codeMatch ? codeMatch.index : Number.POSITIVE_INFINITY;
171
+ const nextStart = Math.min(nextBoldStart, nextCodeStart);
172
+ if (nextStart === Number.POSITIVE_INFINITY) {
173
+ out += escapeText(text.slice(i));
174
+ break;
175
+ }
176
+ if (nextStart > i) {
177
+ out += escapeText(text.slice(i, nextStart));
178
+ i = nextStart;
179
+ }
180
+ if (nextStart === nextCodeStart && codeMatch) {
181
+ out += formatCode(codeMatch[1]);
182
+ i = codeMatch.index + codeMatch[0].length;
183
+ boldRe.lastIndex = i;
184
+ codeRe.lastIndex = i;
185
+ continue;
186
+ }
187
+ if (nextStart === nextBoldStart && boldMatch) {
188
+ out += formatBold(boldMatch[1]);
189
+ i = boldMatch.index + boldMatch[0].length;
190
+ boldRe.lastIndex = i;
191
+ codeRe.lastIndex = i;
192
+ continue;
193
+ }
194
+ }
195
+ return out;
196
+ }
197
+ /**
198
+ * Converts common Markdown (LLM/GitHub-like) into Telegram-compatible formatting.
199
+ * This is not a full Markdown parser; it focuses on a safe subset:
200
+ * - Headings (#, ##, ...) -> bold line
201
+ * - **bold** -> bold
202
+ * - `code` -> inline code
203
+ * - ```code blocks``` -> code block
204
+ */
205
+ export function formatMarkdownLike(text) {
206
+ const mode = getParseMode();
207
+ if (mode === "None") {
208
+ return text;
209
+ }
210
+ const fenceRe = /```[^\n]*\n([\s\S]*?)```/g;
211
+ let lastIndex = 0;
212
+ let out = "";
213
+ let match;
214
+ while ((match = fenceRe.exec(text)) !== null) {
215
+ const before = text.slice(lastIndex, match.index);
216
+ out += formatMarkdownLikePlain(before);
217
+ const code = match[1] ?? "";
218
+ out += formatCodeBlock(code.replace(/\n$/, ""));
219
+ lastIndex = match.index + match[0].length;
220
+ }
221
+ out += formatMarkdownLikePlain(text.slice(lastIndex));
222
+ return out;
223
+ }
224
+ function formatMarkdownLikePlain(text) {
225
+ const lines = text.split("\n");
226
+ const outLines = [];
227
+ for (const line of lines) {
228
+ const headingMatch = /^(#{1,6})\s+(.*)$/.exec(line);
229
+ if (headingMatch) {
230
+ const title = headingMatch[2]?.trim() ?? "";
231
+ outLines.push(title ? formatBold(title) : "");
232
+ continue;
233
+ }
234
+ outLines.push(formatInlineMarkdownLike(line));
235
+ }
236
+ return outLines.join("\n");
237
+ }
@@ -0,0 +1,59 @@
1
+ import { config } from "../config.js";
2
+ const LOG_LEVELS = {
3
+ debug: 0,
4
+ info: 1,
5
+ warn: 2,
6
+ error: 3,
7
+ };
8
+ function normalizeLogLevel(value) {
9
+ if (value in LOG_LEVELS) {
10
+ return value;
11
+ }
12
+ return "info";
13
+ }
14
+ function formatPrefix(level) {
15
+ return `[${new Date().toISOString()}] [${level.toUpperCase()}]`;
16
+ }
17
+ function formatArg(arg) {
18
+ if (arg instanceof Error) {
19
+ return arg.stack ?? `${arg.name}: ${arg.message}`;
20
+ }
21
+ return arg;
22
+ }
23
+ function withPrefix(level, args) {
24
+ const formattedArgs = args.map((arg) => formatArg(arg));
25
+ const prefix = formatPrefix(level);
26
+ if (formattedArgs.length === 0) {
27
+ return [prefix];
28
+ }
29
+ if (typeof formattedArgs[0] === "string") {
30
+ return [`${prefix} ${formattedArgs[0]}`, ...formattedArgs.slice(1)];
31
+ }
32
+ return [prefix, ...formattedArgs];
33
+ }
34
+ function shouldLog(level) {
35
+ const configLevel = normalizeLogLevel(config.server.logLevel);
36
+ return LOG_LEVELS[level] >= LOG_LEVELS[configLevel];
37
+ }
38
+ export const logger = {
39
+ debug: (...args) => {
40
+ if (shouldLog("debug")) {
41
+ console.log(...withPrefix("debug", args));
42
+ }
43
+ },
44
+ info: (...args) => {
45
+ if (shouldLog("info")) {
46
+ console.log(...withPrefix("info", args));
47
+ }
48
+ },
49
+ warn: (...args) => {
50
+ if (shouldLog("warn")) {
51
+ console.warn(...withPrefix("warn", args));
52
+ }
53
+ },
54
+ error: (...args) => {
55
+ if (shouldLog("error")) {
56
+ console.error(...withPrefix("error", args));
57
+ }
58
+ },
59
+ };
@@ -0,0 +1,33 @@
1
+ import { logger } from "./logger.js";
2
+ function runHookSafely(taskName, hookName, hook, value) {
3
+ if (!hook) {
4
+ return;
5
+ }
6
+ try {
7
+ void Promise.resolve(hook(value)).catch((hookError) => {
8
+ logger.error(`[safeBackgroundTask] ${taskName}: ${hookName} failed:`, hookError);
9
+ });
10
+ }
11
+ catch (hookError) {
12
+ logger.error(`[safeBackgroundTask] ${taskName}: ${hookName} failed:`, hookError);
13
+ }
14
+ }
15
+ export function safeBackgroundTask({ taskName, task, onSuccess, onError, }) {
16
+ const handleError = (error) => {
17
+ logger.error(`[safeBackgroundTask] ${taskName} failed:`, error);
18
+ runHookSafely(taskName, "onError", onError, error);
19
+ };
20
+ try {
21
+ const taskPromise = task();
22
+ void taskPromise
23
+ .then((result) => {
24
+ runHookSafely(taskName, "onSuccess", onSuccess, result);
25
+ })
26
+ .catch((error) => {
27
+ handleError(error);
28
+ });
29
+ }
30
+ catch (error) {
31
+ handleError(error);
32
+ }
33
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Variant Manager - manages model variants (reasoning modes)
3
+ */
4
+ import { opencodeClient } from "../opencode/client.js";
5
+ import { getCurrentModel, setCurrentModel } from "../settings/manager.js";
6
+ import { logger } from "../utils/logger.js";
7
+ /**
8
+ * Get available variants for a model from OpenCode API
9
+ * @param providerID Provider ID
10
+ * @param modelID Model ID
11
+ * @returns Array of available variants
12
+ */
13
+ export async function getAvailableVariants(providerID, modelID) {
14
+ try {
15
+ const { data, error } = await opencodeClient.config.providers();
16
+ if (error || !data) {
17
+ logger.warn("[VariantManager] Failed to fetch providers:", error);
18
+ return [{ id: "default" }];
19
+ }
20
+ const provider = data.providers.find((p) => p.id === providerID);
21
+ if (!provider) {
22
+ logger.warn(`[VariantManager] Provider ${providerID} not found`);
23
+ return [{ id: "default" }];
24
+ }
25
+ const model = provider.models[modelID];
26
+ if (!model) {
27
+ logger.warn(`[VariantManager] Model ${modelID} not found in provider ${providerID}`);
28
+ return [{ id: "default" }];
29
+ }
30
+ // Start with default variant (always present)
31
+ const variants = [{ id: "default" }];
32
+ if (model.variants) {
33
+ // Add other variants from API (excluding default if it's already there)
34
+ const apiVariants = Object.entries(model.variants)
35
+ .filter(([id]) => id !== "default")
36
+ .map(([id, info]) => ({
37
+ id,
38
+ disabled: info.disabled,
39
+ }));
40
+ variants.push(...apiVariants);
41
+ logger.debug(`[VariantManager] Found ${variants.length} variants for ${providerID}/${modelID} (including default)`);
42
+ }
43
+ else {
44
+ logger.debug(`[VariantManager] No variants found for ${providerID}/${modelID}, using default only`);
45
+ }
46
+ return variants;
47
+ }
48
+ catch (err) {
49
+ logger.error("[VariantManager] Error fetching variants:", err);
50
+ return [{ id: "default" }];
51
+ }
52
+ }
53
+ /**
54
+ * Get current variant from settings
55
+ * @returns Current variant ID (defaults to "default")
56
+ */
57
+ export function getCurrentVariant() {
58
+ const currentModel = getCurrentModel();
59
+ return currentModel?.variant || "default";
60
+ }
61
+ /**
62
+ * Set current variant in settings
63
+ * @param variantId Variant ID to set
64
+ */
65
+ export function setCurrentVariant(variantId) {
66
+ const currentModel = getCurrentModel();
67
+ if (!currentModel) {
68
+ logger.warn("[VariantManager] Cannot set variant: no current model");
69
+ return;
70
+ }
71
+ currentModel.variant = variantId;
72
+ setCurrentModel(currentModel);
73
+ logger.info(`[VariantManager] Variant set to: ${variantId}`);
74
+ }
75
+ /**
76
+ * Format variant for button display
77
+ * @param variantId Variant ID (e.g., "default", "low", "high")
78
+ * @returns Formatted string "💭 Default", "💭 Low", etc.
79
+ */
80
+ export function formatVariantForButton(variantId) {
81
+ const capitalized = variantId.charAt(0).toUpperCase() + variantId.slice(1);
82
+ return `💭 ${capitalized}`;
83
+ }
84
+ /**
85
+ * Format variant for display in messages
86
+ * @param variantId Variant ID
87
+ * @returns Formatted string with capitalized first letter
88
+ */
89
+ export function formatVariantForDisplay(variantId) {
90
+ return variantId.charAt(0).toUpperCase() + variantId.slice(1);
91
+ }
92
+ /**
93
+ * Validate if a model supports a specific variant
94
+ * @param providerID Provider ID
95
+ * @param modelID Model ID
96
+ * @param variantId Variant ID to validate
97
+ * @returns true if variant is supported, false otherwise
98
+ */
99
+ export async function validateVariantForModel(providerID, modelID, variantId) {
100
+ const variants = await getAvailableVariants(providerID, modelID);
101
+ const found = variants.find((v) => v.id === variantId && !v.disabled);
102
+ return found !== undefined;
103
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@grinev/opencode-telegram-bot",
3
+ "version": "0.1.0-rc.1",
4
+ "description": "",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "opencode-telegram": "dist/cli.js"
9
+ },
10
+ "exports": {
11
+ ".": "./dist/index.js"
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "README.md",
16
+ "LICENSE",
17
+ ".env.example"
18
+ ],
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "scripts": {
23
+ "build": "tsc",
24
+ "start": "node dist/index.js",
25
+ "dev": "npm run build && npm start",
26
+ "list-providers": "tsx scripts/list-providers.ts",
27
+ "lint": "eslint src --ext .ts --max-warnings=0",
28
+ "format": "prettier --write \"src/**/*.ts\"",
29
+ "test": "vitest run",
30
+ "test:coverage": "vitest run --coverage"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/grinev/my-telegram-opencode-bot.git"
35
+ },
36
+ "keywords": [],
37
+ "author": "",
38
+ "license": "MIT",
39
+ "bugs": {
40
+ "url": "https://github.com/grinev/my-telegram-opencode-bot/issues"
41
+ },
42
+ "homepage": "https://github.com/grinev/my-telegram-opencode-bot#readme",
43
+ "dependencies": {
44
+ "@grammyjs/menu": "^1.3.1",
45
+ "@opencode-ai/sdk": "^1.1.21",
46
+ "dotenv": "^17.2.3",
47
+ "grammy": "^1.39.2",
48
+ "pino": "^10.2.0"
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^25.0.8",
52
+ "@typescript-eslint/eslint-plugin": "^8.53.0",
53
+ "@typescript-eslint/parser": "^8.53.0",
54
+ "@vitest/coverage-v8": "^3.2.4",
55
+ "eslint": "^8.57.1",
56
+ "eslint-config-prettier": "^10.1.8",
57
+ "pino-pretty": "^13.1.3",
58
+ "prettier": "^3.8.0",
59
+ "tsx": "^4.21.0",
60
+ "typescript": "^5.9.3",
61
+ "vitest": "^3.2.4"
62
+ }
63
+ }