@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,97 @@
1
+ const SUPPORTED_COMMANDS = ["start", "status", "stop", "config"];
2
+ function isCliCommand(value) {
3
+ return SUPPORTED_COMMANDS.includes(value);
4
+ }
5
+ function normalizeMode(value) {
6
+ if (value === "installed") {
7
+ return "installed";
8
+ }
9
+ if (value === "sources") {
10
+ return "sources";
11
+ }
12
+ return null;
13
+ }
14
+ export function parseCliArgs(argv) {
15
+ const args = [...argv];
16
+ let command = "start";
17
+ let mode;
18
+ let showHelp = false;
19
+ let currentIndex = 0;
20
+ const firstArg = args[0];
21
+ if (firstArg && !firstArg.startsWith("-")) {
22
+ if (!isCliCommand(firstArg)) {
23
+ return {
24
+ command,
25
+ showHelp: true,
26
+ error: `Unknown command: ${firstArg}`,
27
+ };
28
+ }
29
+ command = firstArg;
30
+ currentIndex = 1;
31
+ }
32
+ while (currentIndex < args.length) {
33
+ const token = args[currentIndex];
34
+ if (token === "--help" || token === "-h") {
35
+ showHelp = true;
36
+ currentIndex += 1;
37
+ continue;
38
+ }
39
+ if (token === "--mode") {
40
+ const modeValue = args[currentIndex + 1];
41
+ if (!modeValue || modeValue.startsWith("-")) {
42
+ return {
43
+ command,
44
+ mode,
45
+ showHelp: true,
46
+ error: "Option --mode requires a value: sources|installed",
47
+ };
48
+ }
49
+ const parsedMode = normalizeMode(modeValue);
50
+ if (!parsedMode) {
51
+ return {
52
+ command,
53
+ mode,
54
+ showHelp: true,
55
+ error: `Invalid mode value: ${modeValue}. Expected sources|installed`,
56
+ };
57
+ }
58
+ mode = parsedMode;
59
+ currentIndex += 2;
60
+ continue;
61
+ }
62
+ if (token.startsWith("--mode=")) {
63
+ const modeValue = token.slice("--mode=".length);
64
+ const parsedMode = normalizeMode(modeValue);
65
+ if (!parsedMode) {
66
+ return {
67
+ command,
68
+ mode,
69
+ showHelp: true,
70
+ error: `Invalid mode value: ${modeValue}. Expected sources|installed`,
71
+ };
72
+ }
73
+ mode = parsedMode;
74
+ currentIndex += 1;
75
+ continue;
76
+ }
77
+ return {
78
+ command,
79
+ mode,
80
+ showHelp: true,
81
+ error: `Unknown option: ${token}`,
82
+ };
83
+ }
84
+ if (command !== "start" && mode) {
85
+ return {
86
+ command,
87
+ mode,
88
+ showHelp: true,
89
+ error: "Option --mode is supported only for the start command",
90
+ };
91
+ }
92
+ return {
93
+ command,
94
+ mode,
95
+ showHelp,
96
+ };
97
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ import { parseCliArgs } from "./cli/args.js";
3
+ import { resolveRuntimeMode, setRuntimeMode } from "./runtime/mode.js";
4
+ const EXIT_SUCCESS = 0;
5
+ const EXIT_RUNTIME_ERROR = 1;
6
+ const EXIT_INVALID_ARGS = 2;
7
+ const USAGE_TEXT = [
8
+ "Usage:",
9
+ " opencode-telegram [start] [--mode sources|installed]",
10
+ " opencode-telegram status",
11
+ " opencode-telegram stop",
12
+ " opencode-telegram config",
13
+ "",
14
+ "Notes:",
15
+ " - No command defaults to `start`",
16
+ " - `--mode` is currently supported for `start` only",
17
+ ].join("\n");
18
+ function writeStdout(message) {
19
+ process.stdout.write(`${message}\n`);
20
+ }
21
+ function writeStderr(message) {
22
+ process.stderr.write(`${message}\n`);
23
+ }
24
+ function printUsage() {
25
+ writeStdout(USAGE_TEXT);
26
+ }
27
+ function getPlaceholderMessage(command) {
28
+ if (command === "status") {
29
+ return "Команда `status` пока работает как заглушка. Реальная проверка статуса появится на этапе service-слоя (Этап 5).";
30
+ }
31
+ if (command === "stop") {
32
+ return "Команда `stop` пока работает как заглушка. Реальная остановка фонового процесса появится на этапе service-слоя (Этап 5).";
33
+ }
34
+ return "Команда недоступна.";
35
+ }
36
+ async function runStartCommand(mode) {
37
+ const modeResult = resolveRuntimeMode({
38
+ defaultMode: "installed",
39
+ explicitMode: mode,
40
+ });
41
+ if (modeResult.error) {
42
+ throw new Error(modeResult.error);
43
+ }
44
+ setRuntimeMode(modeResult.mode);
45
+ const { ensureRuntimeConfigForStart } = await import("./runtime/bootstrap.js");
46
+ await ensureRuntimeConfigForStart();
47
+ const { startBotApp } = await import("./app/start-bot-app.js");
48
+ await startBotApp();
49
+ return EXIT_SUCCESS;
50
+ }
51
+ async function runConfigCommand() {
52
+ setRuntimeMode("installed");
53
+ const { runConfigWizardCommand } = await import("./runtime/bootstrap.js");
54
+ await runConfigWizardCommand();
55
+ return EXIT_SUCCESS;
56
+ }
57
+ async function runPlaceholderCommand(command) {
58
+ writeStdout(getPlaceholderMessage(command));
59
+ return EXIT_SUCCESS;
60
+ }
61
+ async function runCli(argv) {
62
+ const parsedArgs = parseCliArgs(argv);
63
+ if (parsedArgs.error) {
64
+ writeStderr(parsedArgs.error);
65
+ }
66
+ if (parsedArgs.showHelp) {
67
+ printUsage();
68
+ return parsedArgs.error ? EXIT_INVALID_ARGS : EXIT_SUCCESS;
69
+ }
70
+ if (parsedArgs.command === "start") {
71
+ return runStartCommand(parsedArgs.mode);
72
+ }
73
+ if (parsedArgs.command === "config") {
74
+ return runConfigCommand();
75
+ }
76
+ return runPlaceholderCommand(parsedArgs.command);
77
+ }
78
+ void runCli(process.argv.slice(2))
79
+ .then((exitCode) => {
80
+ process.exitCode = exitCode;
81
+ })
82
+ .catch((error) => {
83
+ if (error instanceof Error) {
84
+ writeStderr(`CLI error: ${error.message}`);
85
+ }
86
+ else {
87
+ writeStderr(`CLI error: ${String(error)}`);
88
+ }
89
+ process.exitCode = EXIT_RUNTIME_ERROR;
90
+ });
package/dist/config.js ADDED
@@ -0,0 +1,46 @@
1
+ import dotenv from "dotenv";
2
+ import { getRuntimePaths } from "./runtime/paths.js";
3
+ const runtimePaths = getRuntimePaths();
4
+ dotenv.config({ path: runtimePaths.envFilePath });
5
+ function getEnvVar(key, required = true) {
6
+ const value = process.env[key];
7
+ if (required && !value) {
8
+ throw new Error(`Missing required environment variable: ${key} (expected in ${runtimePaths.envFilePath})`);
9
+ }
10
+ return value || "";
11
+ }
12
+ function getOptionalPositiveIntEnvVar(key, defaultValue) {
13
+ const value = getEnvVar(key, false);
14
+ if (!value) {
15
+ return defaultValue;
16
+ }
17
+ const parsedValue = Number.parseInt(value, 10);
18
+ if (Number.isNaN(parsedValue) || parsedValue <= 0) {
19
+ return defaultValue;
20
+ }
21
+ return parsedValue;
22
+ }
23
+ export const config = {
24
+ telegram: {
25
+ token: getEnvVar("TELEGRAM_BOT_TOKEN"),
26
+ allowedUserId: parseInt(getEnvVar("TELEGRAM_ALLOWED_USER_ID"), 10),
27
+ },
28
+ opencode: {
29
+ apiUrl: getEnvVar("OPENCODE_API_URL", false) || "http://localhost:4096",
30
+ username: getEnvVar("OPENCODE_SERVER_USERNAME", false) || "opencode",
31
+ password: getEnvVar("OPENCODE_SERVER_PASSWORD", false),
32
+ model: {
33
+ provider: getEnvVar("OPENCODE_MODEL_PROVIDER", true), // Required
34
+ modelId: getEnvVar("OPENCODE_MODEL_ID", true), // Required
35
+ },
36
+ },
37
+ server: {
38
+ logLevel: getEnvVar("LOG_LEVEL", false) || "info",
39
+ },
40
+ bot: {
41
+ sessionsListLimit: getOptionalPositiveIntEnvVar("SESSIONS_LIST_LIMIT", 10),
42
+ },
43
+ files: {
44
+ maxFileSizeKb: parseInt(getEnvVar("CODE_FILE_MAX_SIZE_KB", false) || "100", 10),
45
+ },
46
+ };
package/dist/index.js ADDED
@@ -0,0 +1,26 @@
1
+ import { resolveRuntimeMode, setRuntimeMode } from "./runtime/mode.js";
2
+ const EXIT_RUNTIME_ERROR = 1;
3
+ const EXIT_INVALID_ARGS = 2;
4
+ async function main() {
5
+ const modeResult = resolveRuntimeMode({
6
+ defaultMode: "sources",
7
+ argv: process.argv.slice(2),
8
+ });
9
+ if (modeResult.error) {
10
+ process.stderr.write(`${modeResult.error}\n`);
11
+ process.exit(EXIT_INVALID_ARGS);
12
+ return;
13
+ }
14
+ setRuntimeMode(modeResult.mode);
15
+ const { startBotApp } = await import("./app/start-bot-app.js");
16
+ await startBotApp();
17
+ }
18
+ void main().catch((error) => {
19
+ if (error instanceof Error) {
20
+ process.stderr.write(`Failed to start bot: ${error.message}\n`);
21
+ }
22
+ else {
23
+ process.stderr.write(`Failed to start bot: ${String(error)}\n`);
24
+ }
25
+ process.exit(EXIT_RUNTIME_ERROR);
26
+ });
@@ -0,0 +1,171 @@
1
+ import { createMainKeyboard } from "../bot/utils/keyboard.js";
2
+ import { getStoredAgent } from "../agent/manager.js";
3
+ import { getStoredModel } from "../model/manager.js";
4
+ import { formatVariantForButton } from "../variant/manager.js";
5
+ import { logger } from "../utils/logger.js";
6
+ /**
7
+ * Keyboard Manager - manages Reply Keyboard state and updates
8
+ * Singleton pattern
9
+ */
10
+ class KeyboardManager {
11
+ state = null;
12
+ api = null;
13
+ chatId = null;
14
+ lastUpdateTime = 0;
15
+ UPDATE_DEBOUNCE_MS = 2000; // Don't update more than once per 2 seconds
16
+ /**
17
+ * Initialize the keyboard manager with Telegram API and chat ID
18
+ * Loads initial state from settings/config
19
+ */
20
+ initialize(api, chatId) {
21
+ this.api = api;
22
+ this.chatId = chatId;
23
+ // Initialize state from settings/config on first call
24
+ if (!this.state) {
25
+ const currentModel = getStoredModel();
26
+ this.state = {
27
+ currentAgent: getStoredAgent(),
28
+ currentModel: currentModel,
29
+ contextInfo: null,
30
+ variantName: formatVariantForButton(currentModel.variant || "default"),
31
+ };
32
+ logger.debug(`[KeyboardManager] Initialized with agent="${this.state.currentAgent}", model="${this.state.currentModel.providerID}/${this.state.currentModel.modelID}", variant="${currentModel.variant || "default"}", chatId=${chatId}`);
33
+ }
34
+ else {
35
+ logger.debug("[KeyboardManager] Already initialized, updating chatId:", chatId);
36
+ }
37
+ }
38
+ /**
39
+ * Update current agent
40
+ */
41
+ updateAgent(agent) {
42
+ if (!this.state) {
43
+ logger.warn("[KeyboardManager] Cannot update agent: not initialized");
44
+ return;
45
+ }
46
+ this.state.currentAgent = agent;
47
+ logger.debug(`[KeyboardManager] Agent updated: ${agent}`);
48
+ }
49
+ /**
50
+ * Update current model
51
+ */
52
+ updateModel(model) {
53
+ if (!this.state) {
54
+ logger.warn("[KeyboardManager] Cannot update model: not initialized");
55
+ return;
56
+ }
57
+ this.state.currentModel = model;
58
+ this.state.variantName = formatVariantForButton(model.variant || "default");
59
+ logger.debug(`[KeyboardManager] Model updated: ${model.providerID}/${model.modelID}, variant: ${model.variant || "default"}`);
60
+ }
61
+ /**
62
+ * Update current variant
63
+ */
64
+ updateVariant(variantId) {
65
+ if (!this.state) {
66
+ logger.warn("[KeyboardManager] Cannot update variant: not initialized");
67
+ return;
68
+ }
69
+ this.state.variantName = formatVariantForButton(variantId);
70
+ logger.debug(`[KeyboardManager] Variant updated: ${variantId}`);
71
+ }
72
+ /**
73
+ * Update context information
74
+ */
75
+ updateContext(tokensUsed, tokensLimit) {
76
+ if (!this.state) {
77
+ logger.warn("[KeyboardManager] Cannot update context: not initialized");
78
+ return;
79
+ }
80
+ this.state.contextInfo = { tokensUsed, tokensLimit };
81
+ logger.debug(`[KeyboardManager] Context updated: ${tokensUsed}/${tokensLimit}`);
82
+ }
83
+ /**
84
+ * Clear context information
85
+ */
86
+ clearContext() {
87
+ if (!this.state) {
88
+ logger.warn("[KeyboardManager] Cannot clear context: not initialized");
89
+ return;
90
+ }
91
+ this.state.contextInfo = null;
92
+ logger.debug("[KeyboardManager] Context cleared");
93
+ }
94
+ /**
95
+ * Get current context info
96
+ */
97
+ getContextInfo() {
98
+ return this.state?.contextInfo ?? null;
99
+ }
100
+ /**
101
+ * Build keyboard with current state
102
+ */
103
+ buildKeyboard() {
104
+ if (!this.state) {
105
+ logger.warn("[KeyboardManager] Cannot build keyboard: not initialized");
106
+ // Return a minimal keyboard as fallback
107
+ return createMainKeyboard("build", { providerID: "", modelID: "" }, undefined);
108
+ }
109
+ return createMainKeyboard(this.state.currentAgent, this.state.currentModel, this.state.contextInfo ?? undefined, this.state.variantName);
110
+ }
111
+ /**
112
+ * Send keyboard update to user
113
+ * Implements debouncing to avoid rate limits
114
+ */
115
+ async sendKeyboardUpdate(chatId) {
116
+ if (!this.api) {
117
+ logger.warn("[KeyboardManager] API not initialized");
118
+ return;
119
+ }
120
+ const targetChatId = chatId ?? this.chatId;
121
+ if (!targetChatId) {
122
+ logger.warn("[KeyboardManager] No chatId available");
123
+ return;
124
+ }
125
+ // Debounce: don't update more frequently than UPDATE_DEBOUNCE_MS
126
+ const now = Date.now();
127
+ if (now - this.lastUpdateTime < this.UPDATE_DEBOUNCE_MS) {
128
+ logger.debug("[KeyboardManager] Update debounced");
129
+ return;
130
+ }
131
+ this.lastUpdateTime = now;
132
+ try {
133
+ const keyboard = this.buildKeyboard();
134
+ // Send a dummy message with updated keyboard
135
+ // This is needed because Reply Keyboard updates require a message
136
+ await this.api.sendMessage(targetChatId, "⌨️ Клавиатура обновлена", {
137
+ reply_markup: keyboard,
138
+ });
139
+ logger.debug("[KeyboardManager] Keyboard update sent");
140
+ }
141
+ catch (err) {
142
+ logger.error("[KeyboardManager] Failed to send keyboard update:", err);
143
+ }
144
+ }
145
+ /**
146
+ * Update keyboard without sending a message (for use in existing messages)
147
+ * Returns undefined if not initialized (caller should handle this)
148
+ */
149
+ getKeyboard() {
150
+ if (!this.state) {
151
+ logger.warn("[KeyboardManager] Cannot get keyboard: not initialized");
152
+ return undefined;
153
+ }
154
+ return this.buildKeyboard();
155
+ }
156
+ /**
157
+ * Get current keyboard state
158
+ * Returns undefined if not initialized
159
+ */
160
+ getState() {
161
+ return this.state ?? undefined;
162
+ }
163
+ /**
164
+ * Check if keyboard manager is initialized
165
+ */
166
+ isInitialized() {
167
+ return this.state !== null;
168
+ }
169
+ }
170
+ // Export singleton instance
171
+ export const keyboardManager = new KeyboardManager();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,123 @@
1
+ import { getCurrentModel, setCurrentModel } from "../settings/manager.js";
2
+ import { config } from "../config.js";
3
+ import { logger } from "../utils/logger.js";
4
+ import path from "node:path";
5
+ function getEnvDefaultModel() {
6
+ const providerID = config.opencode.model.provider;
7
+ const modelID = config.opencode.model.modelId;
8
+ if (!providerID || !modelID) {
9
+ return null;
10
+ }
11
+ return { providerID, modelID };
12
+ }
13
+ function dedupeModels(models) {
14
+ const unique = new Map();
15
+ for (const model of models) {
16
+ const key = `${model.providerID}/${model.modelID}`;
17
+ if (!unique.has(key)) {
18
+ unique.set(key, model);
19
+ }
20
+ }
21
+ return Array.from(unique.values());
22
+ }
23
+ function normalizeFavoriteModels(state) {
24
+ if (!Array.isArray(state.favorite)) {
25
+ return [];
26
+ }
27
+ return state.favorite
28
+ .filter((model) => typeof model?.providerID === "string" &&
29
+ model.providerID.length > 0 &&
30
+ typeof model.modelID === "string" &&
31
+ model.modelID.length > 0)
32
+ .map((model) => ({
33
+ providerID: model.providerID,
34
+ modelID: model.modelID,
35
+ }));
36
+ }
37
+ function getOpenCodeModelStatePath() {
38
+ const xdgStateHome = process.env.XDG_STATE_HOME;
39
+ if (xdgStateHome && xdgStateHome.trim().length > 0) {
40
+ return path.join(xdgStateHome, "opencode", "model.json");
41
+ }
42
+ const homeDir = process.env.HOME || process.env.USERPROFILE || "";
43
+ return path.join(homeDir, ".local", "state", "opencode", "model.json");
44
+ }
45
+ /**
46
+ * Get list of favorite models from OpenCode local state file
47
+ * Falls back to env default model if file is unavailable or empty
48
+ */
49
+ export async function getFavoriteModels() {
50
+ const envDefaultModel = getEnvDefaultModel();
51
+ try {
52
+ const fs = await import("fs/promises");
53
+ const stateFilePath = getOpenCodeModelStatePath();
54
+ const content = await fs.readFile(stateFilePath, "utf-8");
55
+ const state = JSON.parse(content);
56
+ const favorites = normalizeFavoriteModels(state);
57
+ if (favorites.length === 0) {
58
+ if (envDefaultModel) {
59
+ logger.info(`[ModelManager] No favorites in ${stateFilePath}, using env default model`);
60
+ return [envDefaultModel];
61
+ }
62
+ logger.warn(`[ModelManager] No favorites in ${stateFilePath}`);
63
+ return [];
64
+ }
65
+ const merged = envDefaultModel ? dedupeModels([...favorites, envDefaultModel]) : favorites;
66
+ logger.debug(`[ModelManager] Loaded ${merged.length} favorite models from ${stateFilePath}`);
67
+ return merged;
68
+ }
69
+ catch (err) {
70
+ if (envDefaultModel) {
71
+ logger.warn("[ModelManager] Failed to load OpenCode favorites, using env default model:", err);
72
+ return [envDefaultModel];
73
+ }
74
+ logger.error("[ModelManager] Failed to load OpenCode favorites:", err);
75
+ return [];
76
+ }
77
+ }
78
+ /**
79
+ * Get current model from settings or fallback to config
80
+ * @returns Current model info
81
+ */
82
+ export function fetchCurrentModel() {
83
+ return getStoredModel();
84
+ }
85
+ /**
86
+ * Select model and persist to settings
87
+ * @param modelInfo Model to select
88
+ */
89
+ export function selectModel(modelInfo) {
90
+ logger.info(`[ModelManager] Selected model: ${modelInfo.providerID}/${modelInfo.modelID}`);
91
+ setCurrentModel(modelInfo);
92
+ }
93
+ /**
94
+ * Get stored model from settings (synchronous)
95
+ * ALWAYS returns a model - fallback to config if not found
96
+ * @returns Current model info
97
+ */
98
+ export function getStoredModel() {
99
+ const storedModel = getCurrentModel();
100
+ if (storedModel) {
101
+ // Ensure variant is set (default to "default")
102
+ if (!storedModel.variant) {
103
+ storedModel.variant = "default";
104
+ }
105
+ return storedModel;
106
+ }
107
+ // Fallback to model from config (environment variables)
108
+ if (config.opencode.model.provider && config.opencode.model.modelId) {
109
+ logger.debug("[ModelManager] Using model from config");
110
+ return {
111
+ providerID: config.opencode.model.provider,
112
+ modelID: config.opencode.model.modelId,
113
+ variant: "default",
114
+ };
115
+ }
116
+ // This should not happen if config is properly set
117
+ logger.warn("[ModelManager] No model found in settings or config, returning empty model");
118
+ return {
119
+ providerID: "",
120
+ modelID: "",
121
+ variant: "default",
122
+ };
123
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Model types and formatting utilities
3
+ */
4
+ /**
5
+ * Format model for button display (compact format)
6
+ * @param providerID Provider ID
7
+ * @param modelID Model ID
8
+ * @returns Formatted string "providerID/modelID"
9
+ */
10
+ export function formatModelForButton(providerID, modelID) {
11
+ const formatted = `${providerID}/${modelID}`;
12
+ // Limit to ~30 characters for button width
13
+ if (formatted.length > 30) {
14
+ return formatted.substring(0, 27) + "...";
15
+ }
16
+ return formatted;
17
+ }
18
+ /**
19
+ * Format model for display in messages (full format)
20
+ * @param providerID Provider ID
21
+ * @param modelID Model ID
22
+ * @returns Formatted string "providerID / modelID"
23
+ */
24
+ export function formatModelForDisplay(providerID, modelID) {
25
+ return `${providerID} / ${modelID}`;
26
+ }
@@ -0,0 +1,13 @@
1
+ import { createOpencodeClient } from "@opencode-ai/sdk/v2";
2
+ import { config } from "../config.js";
3
+ const getAuth = () => {
4
+ if (!config.opencode.password) {
5
+ return undefined;
6
+ }
7
+ const credentials = `${config.opencode.username}:${config.opencode.password}`;
8
+ return `Basic ${Buffer.from(credentials).toString("base64")}`;
9
+ };
10
+ export const opencodeClient = createOpencodeClient({
11
+ baseUrl: config.opencode.apiUrl,
12
+ headers: config.opencode.password ? { Authorization: getAuth() } : undefined,
13
+ });
@@ -0,0 +1,79 @@
1
+ import { opencodeClient } from "./client.js";
2
+ import { logger } from "../utils/logger.js";
3
+ let eventStream = null;
4
+ let eventCallback = null;
5
+ let isListening = false;
6
+ let activeDirectory = null;
7
+ let streamAbortController = null;
8
+ export async function subscribeToEvents(directory, callback) {
9
+ if (isListening && activeDirectory === directory) {
10
+ logger.debug(`Event listener already running for ${directory}`);
11
+ return;
12
+ }
13
+ if (isListening && activeDirectory !== directory) {
14
+ logger.info(`Stopping event listener for ${activeDirectory}, starting for ${directory}`);
15
+ streamAbortController?.abort();
16
+ streamAbortController = null;
17
+ isListening = false;
18
+ activeDirectory = null;
19
+ }
20
+ const controller = new AbortController();
21
+ activeDirectory = directory;
22
+ eventCallback = callback;
23
+ isListening = true;
24
+ streamAbortController = controller;
25
+ try {
26
+ const result = await opencodeClient.event.subscribe({ directory }, { signal: controller.signal });
27
+ if (!result.stream) {
28
+ throw new Error("No stream returned from event subscription");
29
+ }
30
+ eventStream = result.stream;
31
+ for await (const event of eventStream) {
32
+ if (!isListening || activeDirectory !== directory) {
33
+ logger.debug(`Event listener stopped or changed directory, breaking loop`);
34
+ break;
35
+ }
36
+ // КРИТИЧНО: Принудительно отдаем управление event loop ПЕРЕД обработкой события
37
+ // Это позволяет grammY обрабатывать getUpdates между SSE событиями
38
+ await new Promise((resolve) => setImmediate(resolve));
39
+ if (eventCallback) {
40
+ // Используем setImmediate чтобы не блокировать event loop
41
+ // и дать grammY возможность обрабатывать incoming Telegram updates
42
+ const callback = eventCallback;
43
+ setImmediate(() => callback(event));
44
+ }
45
+ }
46
+ }
47
+ catch (error) {
48
+ if (controller.signal.aborted) {
49
+ logger.info("Event listener aborted");
50
+ return;
51
+ }
52
+ logger.error("Event stream error:", error);
53
+ isListening = false;
54
+ activeDirectory = null;
55
+ streamAbortController = null;
56
+ throw error;
57
+ }
58
+ finally {
59
+ if (streamAbortController === controller) {
60
+ if (isListening && activeDirectory === directory && !controller.signal.aborted) {
61
+ logger.warn(`Event stream ended for ${directory}, listener marked as disconnected`);
62
+ }
63
+ streamAbortController = null;
64
+ eventStream = null;
65
+ eventCallback = null;
66
+ isListening = false;
67
+ activeDirectory = null;
68
+ }
69
+ }
70
+ }
71
+ export function stopEventListening() {
72
+ streamAbortController?.abort();
73
+ streamAbortController = null;
74
+ isListening = false;
75
+ eventCallback = null;
76
+ eventStream = null;
77
+ activeDirectory = null;
78
+ logger.info("Event listener stopped");
79
+ }