telecodex 0.1.0
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/LICENSE +21 -0
- package/README.md +149 -0
- package/dist/bot/auth.js +64 -0
- package/dist/bot/commandSupport.js +239 -0
- package/dist/bot/createBot.js +51 -0
- package/dist/bot/handlerDeps.js +1 -0
- package/dist/bot/handlers/messageHandlers.js +71 -0
- package/dist/bot/handlers/operationalHandlers.js +131 -0
- package/dist/bot/handlers/projectHandlers.js +192 -0
- package/dist/bot/handlers/sessionConfigHandlers.js +319 -0
- package/dist/bot/inputService.js +372 -0
- package/dist/bot/registerHandlers.js +10 -0
- package/dist/bot/session.js +22 -0
- package/dist/bot/sessionFlow.js +51 -0
- package/dist/cli.js +14 -0
- package/dist/codex/sdkRuntime.js +165 -0
- package/dist/config.js +69 -0
- package/dist/runtime/appPaths.js +14 -0
- package/dist/runtime/bootstrap.js +213 -0
- package/dist/runtime/instanceLock.js +89 -0
- package/dist/runtime/logger.js +75 -0
- package/dist/runtime/secrets.js +45 -0
- package/dist/runtime/sessionRuntime.js +53 -0
- package/dist/runtime/startTelecodex.js +118 -0
- package/dist/store/db.js +267 -0
- package/dist/store/projects.js +47 -0
- package/dist/store/sessions.js +328 -0
- package/dist/telegram/attachments.js +67 -0
- package/dist/telegram/delivery.js +140 -0
- package/dist/telegram/messageBuffer.js +272 -0
- package/dist/telegram/renderer.js +146 -0
- package/dist/telegram/splitMessage.js +141 -0
- package/package.json +66 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
export const SANDBOX_MODES = ["read-only", "workspace-write", "danger-full-access"];
|
|
3
|
+
export const APPROVAL_POLICIES = ["on-request", "on-failure", "never"];
|
|
4
|
+
export const MODE_PRESETS = ["read", "write", "danger", "yolo"];
|
|
5
|
+
export const REASONING_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"];
|
|
6
|
+
export const WEB_SEARCH_MODES = ["disabled", "cached", "live"];
|
|
7
|
+
export const DEFAULT_SESSION_PROFILE = {
|
|
8
|
+
sandboxMode: "read-only",
|
|
9
|
+
approvalPolicy: "on-request",
|
|
10
|
+
};
|
|
11
|
+
export function buildConfig(input) {
|
|
12
|
+
const defaultCwd = path.resolve(input.defaultCwd ?? process.cwd());
|
|
13
|
+
return {
|
|
14
|
+
telegramBotToken: input.telegramBotToken,
|
|
15
|
+
defaultCwd,
|
|
16
|
+
defaultModel: input.defaultModel?.trim() || "gpt-5.4",
|
|
17
|
+
dbPath: path.resolve(input.dbPath),
|
|
18
|
+
codexBin: input.codexBin,
|
|
19
|
+
updateIntervalMs: input.updateIntervalMs ?? 700,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function isSessionSandboxMode(value) {
|
|
23
|
+
return SANDBOX_MODES.includes(value);
|
|
24
|
+
}
|
|
25
|
+
export function isSessionApprovalPolicy(value) {
|
|
26
|
+
return APPROVAL_POLICIES.includes(value);
|
|
27
|
+
}
|
|
28
|
+
export function isSessionModePreset(value) {
|
|
29
|
+
return MODE_PRESETS.includes(value);
|
|
30
|
+
}
|
|
31
|
+
export function isSessionReasoningEffort(value) {
|
|
32
|
+
return REASONING_EFFORTS.includes(value);
|
|
33
|
+
}
|
|
34
|
+
export function isSessionWebSearchMode(value) {
|
|
35
|
+
return WEB_SEARCH_MODES.includes(value);
|
|
36
|
+
}
|
|
37
|
+
export function profileFromPreset(preset) {
|
|
38
|
+
switch (preset) {
|
|
39
|
+
case "read":
|
|
40
|
+
return {
|
|
41
|
+
sandboxMode: "read-only",
|
|
42
|
+
approvalPolicy: "on-request",
|
|
43
|
+
};
|
|
44
|
+
case "write":
|
|
45
|
+
return {
|
|
46
|
+
sandboxMode: "workspace-write",
|
|
47
|
+
approvalPolicy: "on-request",
|
|
48
|
+
};
|
|
49
|
+
case "danger":
|
|
50
|
+
return {
|
|
51
|
+
sandboxMode: "danger-full-access",
|
|
52
|
+
approvalPolicy: "on-request",
|
|
53
|
+
};
|
|
54
|
+
case "yolo":
|
|
55
|
+
return {
|
|
56
|
+
sandboxMode: "danger-full-access",
|
|
57
|
+
approvalPolicy: "never",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function presetFromProfile(profile) {
|
|
62
|
+
for (const preset of MODE_PRESETS) {
|
|
63
|
+
const candidate = profileFromPreset(preset);
|
|
64
|
+
if (candidate.sandboxMode === profile.sandboxMode && candidate.approvalPolicy === profile.approvalPolicy) {
|
|
65
|
+
return preset;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return "custom";
|
|
69
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export function getAppHome() {
|
|
4
|
+
return path.join(homedir(), ".telecodex");
|
|
5
|
+
}
|
|
6
|
+
export function getStateDbPath() {
|
|
7
|
+
return path.join(getAppHome(), "state.sqlite");
|
|
8
|
+
}
|
|
9
|
+
export function getLogsDir() {
|
|
10
|
+
return path.join(getAppHome(), "logs");
|
|
11
|
+
}
|
|
12
|
+
export function getLogFilePath() {
|
|
13
|
+
return path.join(getLogsDir(), "telecodex.log");
|
|
14
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { cancel, confirm, intro, isCancel, note, password, spinner, text, } from "@clack/prompts";
|
|
2
|
+
import clipboard from "clipboardy";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { Bot, GrammyError, HttpError } from "grammy";
|
|
8
|
+
import { buildConfig } from "../config.js";
|
|
9
|
+
import { openDatabase } from "../store/db.js";
|
|
10
|
+
import { ProjectStore } from "../store/projects.js";
|
|
11
|
+
import { SessionStore } from "../store/sessions.js";
|
|
12
|
+
import { getStateDbPath } from "./appPaths.js";
|
|
13
|
+
import { PLAINTEXT_TOKEN_FALLBACK_ENV, SecretStore, } from "./secrets.js";
|
|
14
|
+
const MAC_CODEX_BIN = "/Applications/Codex.app/Contents/Resources/codex";
|
|
15
|
+
export async function bootstrapRuntime() {
|
|
16
|
+
intro("telecodex");
|
|
17
|
+
const dbPath = getStateDbPath();
|
|
18
|
+
const db = openDatabase(dbPath);
|
|
19
|
+
const store = new SessionStore(db);
|
|
20
|
+
const projects = new ProjectStore(db);
|
|
21
|
+
const secrets = new SecretStore(store, {
|
|
22
|
+
allowPlaintextFallback: process.env[PLAINTEXT_TOKEN_FALLBACK_ENV] === "1",
|
|
23
|
+
});
|
|
24
|
+
const codexBin = await ensureCodexBin(store);
|
|
25
|
+
await ensureCodexLogin(codexBin);
|
|
26
|
+
const { token, botUsername, storageMode } = await ensureTelegramBotToken(secrets);
|
|
27
|
+
if (storageMode === "plaintext-fallback") {
|
|
28
|
+
note("System keychain unavailable. Telegram bot token fell back to local state storage.", "Token Storage");
|
|
29
|
+
}
|
|
30
|
+
const config = buildConfig({
|
|
31
|
+
telegramBotToken: token,
|
|
32
|
+
defaultCwd: process.cwd(),
|
|
33
|
+
dbPath,
|
|
34
|
+
codexBin,
|
|
35
|
+
});
|
|
36
|
+
let bootstrapCode = null;
|
|
37
|
+
if (store.getAuthorizedUserId() == null) {
|
|
38
|
+
bootstrapCode = store.getBootstrapCode();
|
|
39
|
+
if (!bootstrapCode) {
|
|
40
|
+
bootstrapCode = generateBootstrapCode();
|
|
41
|
+
store.setBootstrapCode(bootstrapCode);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (bootstrapCode) {
|
|
45
|
+
const copied = await copyBootstrapCode(bootstrapCode);
|
|
46
|
+
note([
|
|
47
|
+
`Bot: ${botUsername ? `@${botUsername}` : "unknown"}`,
|
|
48
|
+
`Workspace: ${config.defaultCwd}`,
|
|
49
|
+
copied ? "Binding code copied to the clipboard." : "Failed to copy the binding code. Copy it manually.",
|
|
50
|
+
"This binding code stays valid until an admin account successfully claims it.",
|
|
51
|
+
"",
|
|
52
|
+
bootstrapCode,
|
|
53
|
+
].join("\n"), "Admin Binding");
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
config,
|
|
57
|
+
store,
|
|
58
|
+
projects,
|
|
59
|
+
bootstrapCode,
|
|
60
|
+
botUsername,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async function ensureTelegramBotToken(secrets) {
|
|
64
|
+
const existing = secrets.getTelegramBotToken();
|
|
65
|
+
if (existing) {
|
|
66
|
+
const validated = await validateTelegramBotToken(existing);
|
|
67
|
+
if (validated) {
|
|
68
|
+
return {
|
|
69
|
+
token: existing,
|
|
70
|
+
botUsername: validated.username,
|
|
71
|
+
storageMode: "existing",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
note("The saved Telegram bot token failed validation. Enter it again.", "Telegram");
|
|
75
|
+
}
|
|
76
|
+
while (true) {
|
|
77
|
+
const raw = await password({
|
|
78
|
+
message: "Paste Telegram bot token",
|
|
79
|
+
mask: "*",
|
|
80
|
+
});
|
|
81
|
+
const token = requirePromptValue(raw).trim();
|
|
82
|
+
if (!token) {
|
|
83
|
+
note("Bot token cannot be empty.", "Telegram");
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const validating = spinner();
|
|
87
|
+
validating.start("Validating Telegram bot token");
|
|
88
|
+
const validated = await validateTelegramBotToken(token);
|
|
89
|
+
if (!validated) {
|
|
90
|
+
validating.stop("Telegram bot token validation failed");
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const storageMode = secrets.setTelegramBotToken(token);
|
|
94
|
+
validating.stop(`Telegram bot verified: @${validated.username ?? "unknown"}`);
|
|
95
|
+
return {
|
|
96
|
+
token,
|
|
97
|
+
botUsername: validated.username,
|
|
98
|
+
storageMode,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function validateTelegramBotToken(token) {
|
|
103
|
+
try {
|
|
104
|
+
const bot = new Bot(token);
|
|
105
|
+
const me = await bot.api.getMe();
|
|
106
|
+
return { username: me.username ?? null };
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (error instanceof GrammyError) {
|
|
110
|
+
note(`Telegram returned an error: ${error.description}`, "Telegram");
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
if (error instanceof HttpError) {
|
|
114
|
+
note(`Unable to reach Telegram: ${error.message}`, "Telegram");
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
note(error instanceof Error ? error.message : String(error), "Telegram");
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async function ensureCodexBin(store) {
|
|
122
|
+
const saved = store.getAppState("codex_bin");
|
|
123
|
+
for (const candidate of [saved, MAC_CODEX_BIN, "codex"]) {
|
|
124
|
+
if (!candidate)
|
|
125
|
+
continue;
|
|
126
|
+
if (isWorkingCodexBin(candidate)) {
|
|
127
|
+
store.setAppState("codex_bin", candidate);
|
|
128
|
+
return candidate;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
note("Could not automatically find a working Codex binary.", "Codex");
|
|
132
|
+
while (true) {
|
|
133
|
+
const raw = await text({
|
|
134
|
+
message: "Path to codex binary",
|
|
135
|
+
placeholder: MAC_CODEX_BIN,
|
|
136
|
+
});
|
|
137
|
+
const candidate = path.resolve(requirePromptValue(raw).trim());
|
|
138
|
+
if (!candidate) {
|
|
139
|
+
note("Codex path cannot be empty.", "Codex");
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (!isWorkingCodexBin(candidate)) {
|
|
143
|
+
note("That path is not an executable Codex binary.", "Codex");
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
store.setAppState("codex_bin", candidate);
|
|
147
|
+
return candidate;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function isWorkingCodexBin(candidate) {
|
|
151
|
+
if (candidate !== "codex" && !existsSync(candidate))
|
|
152
|
+
return false;
|
|
153
|
+
const result = spawnSync(candidate, ["--version"], {
|
|
154
|
+
encoding: "utf8",
|
|
155
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
156
|
+
});
|
|
157
|
+
return !result.error && result.status === 0;
|
|
158
|
+
}
|
|
159
|
+
async function ensureCodexLogin(codexBin) {
|
|
160
|
+
while (true) {
|
|
161
|
+
const status = readCodexLoginStatus(codexBin);
|
|
162
|
+
if (status.loggedIn)
|
|
163
|
+
return;
|
|
164
|
+
note(status.message || "Codex is not logged in.", "Codex Login");
|
|
165
|
+
const shouldLogin = await confirm({
|
|
166
|
+
message: "Run `codex login` now?",
|
|
167
|
+
initialValue: true,
|
|
168
|
+
});
|
|
169
|
+
if (isCancel(shouldLogin))
|
|
170
|
+
exitCancelled();
|
|
171
|
+
if (!shouldLogin) {
|
|
172
|
+
throw new Error("Codex login is required before starting telecodex.");
|
|
173
|
+
}
|
|
174
|
+
const result = spawnSync(codexBin, ["login"], {
|
|
175
|
+
stdio: "inherit",
|
|
176
|
+
});
|
|
177
|
+
if (result.error) {
|
|
178
|
+
throw new Error(`Failed to run codex login: ${result.error.message}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function readCodexLoginStatus(codexBin) {
|
|
183
|
+
const result = spawnSync(codexBin, ["login", "status"], {
|
|
184
|
+
encoding: "utf8",
|
|
185
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
186
|
+
});
|
|
187
|
+
const message = [result.stdout, result.stderr].map((value) => value.trim()).filter(Boolean).join(" | ");
|
|
188
|
+
return {
|
|
189
|
+
loggedIn: result.status === 0 && /logged in/i.test(message),
|
|
190
|
+
message,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
async function copyBootstrapCode(code) {
|
|
194
|
+
try {
|
|
195
|
+
await clipboard.write(code);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function requirePromptValue(value) {
|
|
203
|
+
if (isCancel(value))
|
|
204
|
+
exitCancelled();
|
|
205
|
+
return String(value);
|
|
206
|
+
}
|
|
207
|
+
function exitCancelled() {
|
|
208
|
+
cancel("Cancelled");
|
|
209
|
+
process.exit(0);
|
|
210
|
+
}
|
|
211
|
+
function generateBootstrapCode() {
|
|
212
|
+
return `bind-${randomBytes(6).toString("base64url")}`;
|
|
213
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getAppHome } from "./appPaths.js";
|
|
4
|
+
export function acquireInstanceLock(input) {
|
|
5
|
+
const logger = input?.logger;
|
|
6
|
+
const lockPath = input?.lockPath ?? path.join(getAppHome(), "telecodex.lock");
|
|
7
|
+
mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
8
|
+
while (true) {
|
|
9
|
+
try {
|
|
10
|
+
const fd = openSync(lockPath, "wx");
|
|
11
|
+
try {
|
|
12
|
+
writeFileSync(fd, `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`);
|
|
13
|
+
}
|
|
14
|
+
finally {
|
|
15
|
+
closeSync(fd);
|
|
16
|
+
}
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (!isNodeError(error) || error.code !== "EEXIST") {
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
const existing = readExistingLock(lockPath);
|
|
24
|
+
if (existing?.pid != null && isProcessAlive(existing.pid)) {
|
|
25
|
+
throw new Error(`telecodex is already running (pid ${existing.pid})`);
|
|
26
|
+
}
|
|
27
|
+
logger?.warn("removing stale telecodex instance lock", {
|
|
28
|
+
lockPath,
|
|
29
|
+
existingPid: existing?.pid ?? null,
|
|
30
|
+
});
|
|
31
|
+
try {
|
|
32
|
+
unlinkSync(lockPath);
|
|
33
|
+
}
|
|
34
|
+
catch (unlinkError) {
|
|
35
|
+
if (!isNodeError(unlinkError) || unlinkError.code !== "ENOENT") {
|
|
36
|
+
throw unlinkError;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
let released = false;
|
|
42
|
+
const release = () => {
|
|
43
|
+
if (released)
|
|
44
|
+
return;
|
|
45
|
+
released = true;
|
|
46
|
+
try {
|
|
47
|
+
unlinkSync(lockPath);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (!isNodeError(error) || error.code !== "ENOENT") {
|
|
51
|
+
logger?.warn("failed to release telecodex instance lock", {
|
|
52
|
+
lockPath,
|
|
53
|
+
error,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
process.once("exit", release);
|
|
59
|
+
return {
|
|
60
|
+
path: lockPath,
|
|
61
|
+
release,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function readExistingLock(lockPath) {
|
|
65
|
+
try {
|
|
66
|
+
const raw = readFileSync(lockPath, "utf8").trim();
|
|
67
|
+
if (!raw)
|
|
68
|
+
return null;
|
|
69
|
+
const parsed = JSON.parse(raw);
|
|
70
|
+
const pid = typeof parsed.pid === "number" && Number.isInteger(parsed.pid) ? parsed.pid : null;
|
|
71
|
+
const createdAt = typeof parsed.createdAt === "string" ? parsed.createdAt : null;
|
|
72
|
+
return { pid, createdAt };
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function isProcessAlive(pid) {
|
|
79
|
+
try {
|
|
80
|
+
process.kill(pid, 0);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
return isNodeError(error) && error.code === "EPERM";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function isNodeError(error) {
|
|
88
|
+
return error instanceof Error;
|
|
89
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import pino from "pino";
|
|
2
|
+
import { getLogFilePath } from "./appPaths.js";
|
|
3
|
+
class PinoLoggerAdapter {
|
|
4
|
+
base;
|
|
5
|
+
destination;
|
|
6
|
+
scope;
|
|
7
|
+
filePath;
|
|
8
|
+
constructor(base, destination, scope, filePath) {
|
|
9
|
+
this.base = base;
|
|
10
|
+
this.destination = destination;
|
|
11
|
+
this.scope = scope;
|
|
12
|
+
this.filePath = filePath;
|
|
13
|
+
}
|
|
14
|
+
child(scope) {
|
|
15
|
+
return new PinoLoggerAdapter(this.base, this.destination, `${this.scope}/${scope}`, this.filePath);
|
|
16
|
+
}
|
|
17
|
+
debug(message, details) {
|
|
18
|
+
logWithDetails(this.base.debug.bind(this.base), this.scope, message, details);
|
|
19
|
+
}
|
|
20
|
+
info(message, details) {
|
|
21
|
+
logWithDetails(this.base.info.bind(this.base), this.scope, message, details);
|
|
22
|
+
}
|
|
23
|
+
warn(message, details) {
|
|
24
|
+
logWithDetails(this.base.warn.bind(this.base), this.scope, message, details);
|
|
25
|
+
}
|
|
26
|
+
error(message, details) {
|
|
27
|
+
logWithDetails(this.base.error.bind(this.base), this.scope, message, details);
|
|
28
|
+
}
|
|
29
|
+
flush() {
|
|
30
|
+
try {
|
|
31
|
+
this.destination.flushSync();
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Ignore flush races during early startup failure and process teardown.
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function createLogger(filePath = getLogFilePath()) {
|
|
39
|
+
const destination = pino.destination({
|
|
40
|
+
dest: filePath,
|
|
41
|
+
mkdir: true,
|
|
42
|
+
sync: false,
|
|
43
|
+
});
|
|
44
|
+
const base = pino({
|
|
45
|
+
level: process.env.TELECODEX_LOG_LEVEL ?? "info",
|
|
46
|
+
base: {
|
|
47
|
+
service: "telecodex",
|
|
48
|
+
pid: process.pid,
|
|
49
|
+
},
|
|
50
|
+
timestamp: pino.stdTimeFunctions.isoTime,
|
|
51
|
+
serializers: {
|
|
52
|
+
err: pino.stdSerializers.err,
|
|
53
|
+
error: pino.stdSerializers.err,
|
|
54
|
+
},
|
|
55
|
+
}, destination);
|
|
56
|
+
return new PinoLoggerAdapter(base, destination, "telecodex", filePath);
|
|
57
|
+
}
|
|
58
|
+
function logWithDetails(log, scope, message, details) {
|
|
59
|
+
if (details === undefined) {
|
|
60
|
+
log({ scope }, message);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (details instanceof Error) {
|
|
64
|
+
log({ scope, err: details }, message);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (isRecord(details)) {
|
|
68
|
+
log({ ...details, scope }, message);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
log({ scope, value: details }, message);
|
|
72
|
+
}
|
|
73
|
+
function isRecord(value) {
|
|
74
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
75
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Entry } from "@napi-rs/keyring";
|
|
2
|
+
const SERVICE = "telecodex";
|
|
3
|
+
const ACCOUNT = "telegram-bot-token";
|
|
4
|
+
const FALLBACK_KEY = "telegram_bot_token";
|
|
5
|
+
export const PLAINTEXT_TOKEN_FALLBACK_ENV = "TELECODEX_ALLOW_PLAINTEXT_TOKEN_FALLBACK";
|
|
6
|
+
export class SecretStore {
|
|
7
|
+
store;
|
|
8
|
+
options;
|
|
9
|
+
entry = new Entry(SERVICE, ACCOUNT);
|
|
10
|
+
constructor(store, options) {
|
|
11
|
+
this.store = store;
|
|
12
|
+
this.options = options;
|
|
13
|
+
}
|
|
14
|
+
getTelegramBotToken() {
|
|
15
|
+
try {
|
|
16
|
+
const token = this.entry.getPassword();
|
|
17
|
+
if (token)
|
|
18
|
+
return token;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Fallback below.
|
|
22
|
+
}
|
|
23
|
+
if (!this.options?.allowPlaintextFallback) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return this.store.getAppState(FALLBACK_KEY);
|
|
27
|
+
}
|
|
28
|
+
setTelegramBotToken(token) {
|
|
29
|
+
try {
|
|
30
|
+
this.entry.setPassword(token);
|
|
31
|
+
this.store.deleteAppState(FALLBACK_KEY);
|
|
32
|
+
return "keyring";
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
if (!this.options?.allowPlaintextFallback) {
|
|
36
|
+
throw new Error([
|
|
37
|
+
"System keychain is unavailable, and plaintext Telegram bot token fallback is disabled.",
|
|
38
|
+
`If you accept storing the token unencrypted in local state, set ${PLAINTEXT_TOKEN_FALLBACK_ENV}=1 and run telecodex again.`,
|
|
39
|
+
].join(" "));
|
|
40
|
+
}
|
|
41
|
+
this.store.setAppState(FALLBACK_KEY, token);
|
|
42
|
+
return "plaintext-fallback";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export async function applySessionRuntimeEvent(input) {
|
|
2
|
+
const session = input.store.get(input.sessionKey);
|
|
3
|
+
if (!session)
|
|
4
|
+
return null;
|
|
5
|
+
const next = reduceSessionRuntimeState(session, input.event);
|
|
6
|
+
input.store.setRuntimeState(session.sessionKey, next);
|
|
7
|
+
return input.store.get(session.sessionKey);
|
|
8
|
+
}
|
|
9
|
+
export function reduceSessionRuntimeState(session, event, updatedAt = new Date().toISOString()) {
|
|
10
|
+
switch (event.type) {
|
|
11
|
+
case "turn.preparing":
|
|
12
|
+
return {
|
|
13
|
+
status: "preparing",
|
|
14
|
+
detail: event.detail ?? null,
|
|
15
|
+
updatedAt,
|
|
16
|
+
activeTurnId: null,
|
|
17
|
+
};
|
|
18
|
+
case "turn.started":
|
|
19
|
+
return {
|
|
20
|
+
status: "running",
|
|
21
|
+
detail: null,
|
|
22
|
+
updatedAt,
|
|
23
|
+
activeTurnId: event.turnId,
|
|
24
|
+
};
|
|
25
|
+
case "turn.completed":
|
|
26
|
+
case "turn.interrupted":
|
|
27
|
+
return {
|
|
28
|
+
status: "idle",
|
|
29
|
+
detail: null,
|
|
30
|
+
updatedAt,
|
|
31
|
+
activeTurnId: null,
|
|
32
|
+
};
|
|
33
|
+
case "turn.failed":
|
|
34
|
+
return {
|
|
35
|
+
status: "failed",
|
|
36
|
+
detail: event.message?.trim() || null,
|
|
37
|
+
updatedAt,
|
|
38
|
+
activeTurnId: null,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function formatSessionRuntimeStatus(status) {
|
|
43
|
+
switch (status) {
|
|
44
|
+
case "idle":
|
|
45
|
+
return "idle";
|
|
46
|
+
case "running":
|
|
47
|
+
return "running";
|
|
48
|
+
case "preparing":
|
|
49
|
+
return "preparing";
|
|
50
|
+
case "failed":
|
|
51
|
+
return "failed";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { run } from "@grammyjs/runner";
|
|
2
|
+
import { createBot } from "../bot/createBot.js";
|
|
3
|
+
import { CodexSdkRuntime } from "../codex/sdkRuntime.js";
|
|
4
|
+
import { bootstrapRuntime } from "./bootstrap.js";
|
|
5
|
+
import { acquireInstanceLock } from "./instanceLock.js";
|
|
6
|
+
import { createLogger } from "./logger.js";
|
|
7
|
+
let processHandlersInstalled = false;
|
|
8
|
+
export async function startTelecodex() {
|
|
9
|
+
const logger = createLogger();
|
|
10
|
+
let instanceLock = null;
|
|
11
|
+
installProcessErrorHandlers(logger);
|
|
12
|
+
logger.info("telecodex startup requested", {
|
|
13
|
+
pid: process.pid,
|
|
14
|
+
cwd: process.cwd(),
|
|
15
|
+
logFile: logger.filePath,
|
|
16
|
+
});
|
|
17
|
+
try {
|
|
18
|
+
instanceLock = acquireInstanceLock({
|
|
19
|
+
logger: logger.child("instance-lock"),
|
|
20
|
+
});
|
|
21
|
+
logger.info("telecodex instance lock acquired", {
|
|
22
|
+
lockPath: instanceLock.path,
|
|
23
|
+
pid: process.pid,
|
|
24
|
+
});
|
|
25
|
+
const { config, store, projects, bootstrapCode, botUsername } = await bootstrapRuntime();
|
|
26
|
+
const configOverrides = parseCodexConfigOverrides(store.getAppState("codex_config_overrides"));
|
|
27
|
+
const codex = new CodexSdkRuntime({
|
|
28
|
+
codexBin: config.codexBin,
|
|
29
|
+
logger: logger.child("codex-sdk"),
|
|
30
|
+
...(configOverrides ? { configOverrides } : {}),
|
|
31
|
+
});
|
|
32
|
+
const bot = createBot({
|
|
33
|
+
config,
|
|
34
|
+
store,
|
|
35
|
+
projects,
|
|
36
|
+
codex,
|
|
37
|
+
bootstrapCode,
|
|
38
|
+
logger: logger.child("bot"),
|
|
39
|
+
onAdminBound: () => {
|
|
40
|
+
logger.info("telegram admin bound");
|
|
41
|
+
console.log("telegram admin binding completed");
|
|
42
|
+
console.log("telecodex is now ready to accept commands from the bound Telegram account");
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
const botProfile = await bot.api.getMe();
|
|
46
|
+
if (!botProfile.can_read_all_group_messages) {
|
|
47
|
+
logger.warn("telegram bot privacy mode is enabled; plain topic messages will not reach telecodex", {
|
|
48
|
+
botUsername: botProfile.username ?? botUsername,
|
|
49
|
+
});
|
|
50
|
+
console.warn("warning: this bot cannot read all group messages yet");
|
|
51
|
+
console.warn("disable privacy mode in @BotFather with /setprivacy, then choose this bot and Disable");
|
|
52
|
+
console.warn("Telegram may take a few minutes to apply the change");
|
|
53
|
+
}
|
|
54
|
+
const runner = run(bot);
|
|
55
|
+
const stopRuntime = (signal) => {
|
|
56
|
+
logger.info("received shutdown signal", { signal });
|
|
57
|
+
codex.interruptAll();
|
|
58
|
+
runner.stop();
|
|
59
|
+
instanceLock?.release();
|
|
60
|
+
instanceLock = null;
|
|
61
|
+
logger.flush();
|
|
62
|
+
process.exit(0);
|
|
63
|
+
};
|
|
64
|
+
process.once("SIGINT", () => stopRuntime("SIGINT"));
|
|
65
|
+
process.once("SIGTERM", () => stopRuntime("SIGTERM"));
|
|
66
|
+
console.log(`telecodex started as @${botUsername ?? "unknown"}`);
|
|
67
|
+
console.log(`workspace: ${config.defaultCwd}`);
|
|
68
|
+
console.log(`codex: ${config.codexBin}`);
|
|
69
|
+
console.log(`logs: ${logger.filePath}`);
|
|
70
|
+
if (bootstrapCode) {
|
|
71
|
+
console.log("telegram admin is not bound yet");
|
|
72
|
+
console.log("waiting for admin binding from Telegram private chat...");
|
|
73
|
+
console.log("bootstrap code was shown during setup and copied to the clipboard when possible");
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
console.log(`authorized telegram user id: ${store.getAuthorizedUserId()}`);
|
|
77
|
+
}
|
|
78
|
+
logger.info("telecodex started", {
|
|
79
|
+
botUsername,
|
|
80
|
+
workspace: config.defaultCwd,
|
|
81
|
+
codexBin: config.codexBin,
|
|
82
|
+
bootstrapPending: bootstrapCode != null,
|
|
83
|
+
authorizedUserId: store.getAuthorizedUserId(),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
instanceLock?.release();
|
|
88
|
+
logger.error("telecodex startup failed", error);
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function parseCodexConfigOverrides(value) {
|
|
93
|
+
if (!value)
|
|
94
|
+
return undefined;
|
|
95
|
+
try {
|
|
96
|
+
const parsed = JSON.parse(value);
|
|
97
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
98
|
+
? parsed
|
|
99
|
+
: undefined;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function installProcessErrorHandlers(logger) {
|
|
106
|
+
if (processHandlersInstalled)
|
|
107
|
+
return;
|
|
108
|
+
processHandlersInstalled = true;
|
|
109
|
+
process.on("unhandledRejection", (reason) => {
|
|
110
|
+
logger.error("unhandled rejection", reason);
|
|
111
|
+
logger.flush();
|
|
112
|
+
});
|
|
113
|
+
process.on("uncaughtException", (error) => {
|
|
114
|
+
logger.error("uncaught exception", error);
|
|
115
|
+
logger.flush();
|
|
116
|
+
process.exit(1);
|
|
117
|
+
});
|
|
118
|
+
}
|