@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.
- package/.env.example +34 -0
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/dist/agent/manager.js +92 -0
- package/dist/agent/types.js +26 -0
- package/dist/app/start-bot-app.js +26 -0
- package/dist/bot/commands/agent.js +16 -0
- package/dist/bot/commands/definitions.js +20 -0
- package/dist/bot/commands/help.js +7 -0
- package/dist/bot/commands/model.js +16 -0
- package/dist/bot/commands/models.js +37 -0
- package/dist/bot/commands/new.js +58 -0
- package/dist/bot/commands/opencode-start.js +87 -0
- package/dist/bot/commands/opencode-stop.js +46 -0
- package/dist/bot/commands/projects.js +104 -0
- package/dist/bot/commands/server-restart.js +23 -0
- package/dist/bot/commands/server-start.js +23 -0
- package/dist/bot/commands/sessions.js +240 -0
- package/dist/bot/commands/start.js +40 -0
- package/dist/bot/commands/status.js +63 -0
- package/dist/bot/commands/stop.js +92 -0
- package/dist/bot/handlers/agent.js +96 -0
- package/dist/bot/handlers/context.js +112 -0
- package/dist/bot/handlers/model.js +115 -0
- package/dist/bot/handlers/permission.js +158 -0
- package/dist/bot/handlers/question.js +294 -0
- package/dist/bot/handlers/variant.js +126 -0
- package/dist/bot/index.js +573 -0
- package/dist/bot/middleware/auth.js +30 -0
- package/dist/bot/utils/keyboard.js +66 -0
- package/dist/cli/args.js +97 -0
- package/dist/cli.js +90 -0
- package/dist/config.js +46 -0
- package/dist/index.js +26 -0
- package/dist/keyboard/manager.js +171 -0
- package/dist/keyboard/types.js +1 -0
- package/dist/model/manager.js +123 -0
- package/dist/model/types.js +26 -0
- package/dist/opencode/client.js +13 -0
- package/dist/opencode/events.js +79 -0
- package/dist/opencode/server.js +104 -0
- package/dist/permission/manager.js +78 -0
- package/dist/permission/types.js +1 -0
- package/dist/pinned/manager.js +610 -0
- package/dist/pinned/types.js +1 -0
- package/dist/pinned-message/service.js +54 -0
- package/dist/process/manager.js +273 -0
- package/dist/process/types.js +1 -0
- package/dist/project/manager.js +28 -0
- package/dist/question/manager.js +143 -0
- package/dist/question/types.js +1 -0
- package/dist/runtime/bootstrap.js +278 -0
- package/dist/runtime/mode.js +74 -0
- package/dist/runtime/paths.js +37 -0
- package/dist/session/manager.js +10 -0
- package/dist/session/state.js +24 -0
- package/dist/settings/manager.js +99 -0
- package/dist/status/formatter.js +44 -0
- package/dist/summary/aggregator.js +427 -0
- package/dist/summary/formatter.js +226 -0
- package/dist/utils/formatting.js +237 -0
- package/dist/utils/logger.js +59 -0
- package/dist/utils/safe-background-task.js +33 -0
- package/dist/variant/manager.js +103 -0
- package/dist/variant/types.js +1 -0
- package/package.json +63 -0
package/dist/cli/args.js
ADDED
|
@@ -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
|
+
}
|