@smart-tinker/kayla 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.
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.getOrCreateSession = getOrCreateSession;
7
+ exports.createNewSession = createNewSession;
8
+ exports.resetSession = resetSession;
9
+ const node_fs_1 = __importDefault(require("node:fs"));
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ function ensureDir(p) {
12
+ node_fs_1.default.mkdirSync(p, { recursive: true });
13
+ }
14
+ function safeWorkspaceName(chatId) {
15
+ // Telegram chat_id may be negative; keep as-is but avoid path separators.
16
+ return chatId.split("/").join("_");
17
+ }
18
+ function defaultWorkspacePath(cfg, chatId) {
19
+ return node_path_1.default.join(cfg.runtime.workspaces_dir, safeWorkspaceName(chatId));
20
+ }
21
+ function newWorkspacePath(cfg, chatId) {
22
+ return node_path_1.default.join(cfg.runtime.workspaces_dir, `${safeWorkspaceName(chatId)}-${Date.now()}`);
23
+ }
24
+ function getOrCreateSession(storage, cfg, chatIdRaw, userIdRaw) {
25
+ const chatId = String(chatIdRaw);
26
+ const userId = String(userIdRaw);
27
+ const existing = storage.getChat(chatId);
28
+ if (existing) {
29
+ ensureDir(existing.workspace_path);
30
+ return { chatId, userId: existing.user_id, workspacePath: existing.workspace_path };
31
+ }
32
+ const workspacePath = defaultWorkspacePath(cfg, chatId);
33
+ ensureDir(workspacePath);
34
+ const inserted = storage.upsertChat({ chatId, userId, workspacePath, settings: {} });
35
+ return { chatId, userId: inserted.user_id, workspacePath: inserted.workspace_path };
36
+ }
37
+ function createNewSession(storage, cfg, chatIdRaw, userIdRaw) {
38
+ const chatId = String(chatIdRaw);
39
+ const existing = storage.getChat(chatId);
40
+ if (!existing) {
41
+ // If no chat yet, "new" is equivalent to create.
42
+ return getOrCreateSession(storage, cfg, chatIdRaw, userIdRaw);
43
+ }
44
+ let workspacePath = newWorkspacePath(cfg, chatId);
45
+ // Extremely unlikely, but avoid collisions in fast loops.
46
+ for (let i = 0; i < 5 && node_fs_1.default.existsSync(workspacePath); i++) {
47
+ workspacePath = newWorkspacePath(cfg, chatId);
48
+ }
49
+ ensureDir(workspacePath);
50
+ const updated = storage.updateChatWorkspace(chatId, workspacePath);
51
+ return { chatId, userId: updated.user_id, workspacePath: updated.workspace_path };
52
+ }
53
+ function resetSession(storage, cfg, chatIdRaw, userIdRaw) {
54
+ const session = getOrCreateSession(storage, cfg, chatIdRaw, userIdRaw);
55
+ node_fs_1.default.rmSync(session.workspacePath, { recursive: true, force: true });
56
+ ensureDir(session.workspacePath);
57
+ // Workspace path remains the same; just return current mapping.
58
+ return getOrCreateSession(storage, cfg, chatIdRaw, userIdRaw);
59
+ }
@@ -0,0 +1,221 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.computeDefaultSetupPaths = computeDefaultSetupPaths;
7
+ exports.ensureDir = ensureDir;
8
+ exports.ensureXdgDirs = ensureXdgDirs;
9
+ exports.parseCsvNumbersStrict = parseCsvNumbersStrict;
10
+ exports.renderSystemdTemplate = renderSystemdTemplate;
11
+ exports.buildConfigFromBootstrapInputs = buildConfigFromBootstrapInputs;
12
+ exports.parseBootstrapInputsFromEnv = parseBootstrapInputsFromEnv;
13
+ exports.buildConfigFromEnv = buildConfigFromEnv;
14
+ exports.promptBootstrapInputs = promptBootstrapInputs;
15
+ exports.nonInteractiveSetupError = nonInteractiveSetupError;
16
+ exports.bootstrapConfig = bootstrapConfig;
17
+ exports.writeConfigIfMissing = writeConfigIfMissing;
18
+ exports.ensureConfigValidOrThrow = ensureConfigValidOrThrow;
19
+ exports.computeUnitEnvPath = computeUnitEnvPath;
20
+ exports.ensurePathHasLocalBin = ensurePathHasLocalBin;
21
+ const node_fs_1 = __importDefault(require("node:fs"));
22
+ const node_os_1 = __importDefault(require("node:os"));
23
+ const node_path_1 = __importDefault(require("node:path"));
24
+ const yaml_1 = __importDefault(require("yaml"));
25
+ function computeDefaultSetupPaths(homeDir = node_os_1.default.homedir()) {
26
+ const configDir = node_path_1.default.join(homeDir, ".config", "kayla");
27
+ const configPath = node_path_1.default.join(configDir, "config.yaml");
28
+ const dataDir = node_path_1.default.join(homeDir, ".local", "share", "kayla");
29
+ const workspacesDir = node_path_1.default.join(dataDir, "workspaces");
30
+ const uploadsDir = node_path_1.default.join(dataDir, "uploads");
31
+ return { homeDir, configDir, configPath, dataDir, workspacesDir, uploadsDir };
32
+ }
33
+ function ensureDir(p) {
34
+ node_fs_1.default.mkdirSync(p, { recursive: true });
35
+ }
36
+ function ensureXdgDirs(paths) {
37
+ ensureDir(paths.configDir);
38
+ ensureDir(paths.dataDir);
39
+ ensureDir(paths.workspacesDir);
40
+ ensureDir(paths.uploadsDir);
41
+ }
42
+ function parseCsvNumbersStrict(v) {
43
+ const raw = v
44
+ .split(",")
45
+ .map((x) => x.trim())
46
+ .filter((x) => x.length > 0);
47
+ if (raw.length === 0)
48
+ return [];
49
+ return raw.map((x) => {
50
+ const n = Number(x);
51
+ if (!Number.isFinite(n))
52
+ throw new Error(`Invalid number in CSV: ${x}`);
53
+ return n;
54
+ });
55
+ }
56
+ function renderSystemdTemplate(templateText, vars) {
57
+ let out = templateText;
58
+ for (const [k, v] of Object.entries(vars)) {
59
+ out = out.split(`@${k}@`).join(v);
60
+ }
61
+ return out;
62
+ }
63
+ function buildConfigFromBootstrapInputs(paths, inputs) {
64
+ const token = inputs.telegramToken.trim();
65
+ if (!token)
66
+ throw new Error("Missing telegram token");
67
+ const adminIds = inputs.telegramAdminUserIds;
68
+ if (adminIds.length === 0)
69
+ throw new Error("Missing telegram admin user ids");
70
+ const allowIds = inputs.telegramAllowlistUserIds;
71
+ return {
72
+ telegram: {
73
+ token,
74
+ mode: "polling",
75
+ admin_user_ids: adminIds,
76
+ allowlist: { user_ids: allowIds }
77
+ },
78
+ runtime: {
79
+ data_dir: paths.dataDir,
80
+ workspaces_dir: paths.workspacesDir,
81
+ uploads_dir: paths.uploadsDir
82
+ },
83
+ claude: {
84
+ binary: "claude",
85
+ default_model: "",
86
+ max_turns: 10,
87
+ timeout_seconds: 900,
88
+ streaming: true,
89
+ output_format: "stream-json",
90
+ tools: "default",
91
+ allowed_tools: [],
92
+ disallowed_tools: [],
93
+ mcp: { strict: false }
94
+ },
95
+ jobs: {
96
+ per_chat_concurrency: 1,
97
+ global_concurrency: 2,
98
+ queue_size_per_chat: 20
99
+ },
100
+ logging: {
101
+ level: "info",
102
+ json: true
103
+ }
104
+ };
105
+ }
106
+ function parseBootstrapInputsFromEnv(env) {
107
+ const token = (env.KAYLA_TELEGRAM_TOKEN ?? "").trim();
108
+ if (!token)
109
+ throw new Error("Missing KAYLA_TELEGRAM_TOKEN (required to create config)");
110
+ const adminCsv = (env.KAYLA_TELEGRAM_ADMIN_USER_IDS ?? "").trim();
111
+ if (!adminCsv)
112
+ throw new Error("Missing KAYLA_TELEGRAM_ADMIN_USER_IDS (required to create config)");
113
+ const adminIds = parseCsvNumbersStrict(adminCsv);
114
+ if (adminIds.length === 0)
115
+ throw new Error("Invalid KAYLA_TELEGRAM_ADMIN_USER_IDS: empty list");
116
+ const allowCsv = (env.KAYLA_TELEGRAM_ALLOWLIST_USER_IDS ?? "").trim();
117
+ const allowIds = allowCsv ? parseCsvNumbersStrict(allowCsv) : [];
118
+ return { telegramToken: token, telegramAdminUserIds: adminIds, telegramAllowlistUserIds: allowIds };
119
+ }
120
+ function buildConfigFromEnv(paths, env) {
121
+ const inputs = parseBootstrapInputsFromEnv(env);
122
+ return buildConfigFromBootstrapInputs(paths, inputs);
123
+ }
124
+ async function promptBootstrapInputs(prompt) {
125
+ const token = (await prompt({ kind: "secret", message: "Telegram bot token: " })).trim();
126
+ if (!token)
127
+ throw new Error("Telegram bot token is required");
128
+ const adminIds = await (async () => {
129
+ while (true) {
130
+ const raw = (await prompt({ kind: "text", message: "Telegram admin user ids (CSV): " })).trim();
131
+ try {
132
+ const out = parseCsvNumbersStrict(raw);
133
+ if (out.length === 0)
134
+ throw new Error("Empty list");
135
+ return out;
136
+ }
137
+ catch {
138
+ // Re-prompt; keep message minimal.
139
+ }
140
+ }
141
+ })();
142
+ const allowIds = await (async () => {
143
+ while (true) {
144
+ const raw = (await prompt({ kind: "text", message: "Optional allowlist user ids (CSV, blank = none): " })).trim();
145
+ if (!raw)
146
+ return [];
147
+ try {
148
+ return parseCsvNumbersStrict(raw);
149
+ }
150
+ catch {
151
+ // Re-prompt
152
+ }
153
+ }
154
+ })();
155
+ return { telegramToken: token, telegramAdminUserIds: adminIds, telegramAllowlistUserIds: allowIds };
156
+ }
157
+ function nonInteractiveSetupError(configPath) {
158
+ return [
159
+ `Missing config file: ${configPath}`,
160
+ "",
161
+ "To bootstrap config non-interactively, provide:",
162
+ "- KAYLA_TELEGRAM_TOKEN",
163
+ "- KAYLA_TELEGRAM_ADMIN_USER_IDS (CSV)",
164
+ "",
165
+ "Or create/edit the config file manually.",
166
+ "",
167
+ "If you run in an interactive terminal (TTY), `kayla setup` can prompt for these values."
168
+ ].join("\n");
169
+ }
170
+ async function bootstrapConfig(paths, env, opts) {
171
+ try {
172
+ const cfg = buildConfigFromEnv(paths, env);
173
+ return { source: "env", config: cfg };
174
+ }
175
+ catch {
176
+ // fallthrough
177
+ }
178
+ if (!opts.isTty || !opts.prompt)
179
+ throw new Error(nonInteractiveSetupError(paths.configPath));
180
+ const inputs = await promptBootstrapInputs(opts.prompt);
181
+ const cfg = buildConfigFromBootstrapInputs(paths, inputs);
182
+ return { source: "prompt", config: cfg };
183
+ }
184
+ function writeConfigIfMissing(configPath, cfg) {
185
+ if (node_fs_1.default.existsSync(configPath))
186
+ return { created: false };
187
+ const raw = yaml_1.default.stringify(cfg);
188
+ // 0600: token is a secret.
189
+ node_fs_1.default.writeFileSync(configPath, raw, { mode: 0o600 });
190
+ return { created: true };
191
+ }
192
+ function ensureConfigValidOrThrow(configPath) {
193
+ if (!node_fs_1.default.existsSync(configPath))
194
+ throw new Error(`Missing config file: ${configPath}`);
195
+ const doc = yaml_1.default.parse(node_fs_1.default.readFileSync(configPath, "utf8"));
196
+ if (!doc || typeof doc !== "object")
197
+ throw new Error("Invalid config file: expected YAML object");
198
+ // Minimal checks so setup does not need full loadConfig validation semantics.
199
+ const t = doc.telegram;
200
+ if (!t || typeof t !== "object")
201
+ throw new Error("Invalid config: missing telegram section");
202
+ const token = typeof t.token === "string" ? t.token.trim() : "";
203
+ if (!token)
204
+ throw new Error("Invalid config: missing telegram.token");
205
+ const admins = Array.isArray(t.admin_user_ids) ? t.admin_user_ids : [];
206
+ const allow = t.allowlist && Array.isArray(t.allowlist.user_ids) ? t.allowlist.user_ids : [];
207
+ if ((admins.length + allow.length) === 0)
208
+ throw new Error("Invalid config: missing bootstrap allowlist (telegram.admin_user_ids or telegram.allowlist.user_ids)");
209
+ }
210
+ function computeUnitEnvPath(homeDir) {
211
+ // systemd does not expand ~, so we generate absolute paths.
212
+ return node_path_1.default.join(homeDir, ".config", "kayla", "config.yaml");
213
+ }
214
+ function ensurePathHasLocalBin(homeDir, currentPath) {
215
+ const base = (currentPath ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin").trim();
216
+ const localBin = node_path_1.default.join(homeDir, ".local", "bin");
217
+ const parts = base.split(":").filter((p) => p.length > 0);
218
+ if (!parts.includes(localBin))
219
+ parts.push(localBin);
220
+ return parts.join(":");
221
+ }
@@ -0,0 +1,259 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.Storage = void 0;
7
+ exports.openStorage = openStorage;
8
+ const node_fs_1 = __importDefault(require("node:fs"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
11
+ const node_crypto_1 = __importDefault(require("node:crypto"));
12
+ const uuid_1 = require("uuid");
13
+ function nowMs() {
14
+ return Date.now();
15
+ }
16
+ function ensureDir(p) {
17
+ node_fs_1.default.mkdirSync(p, { recursive: true });
18
+ }
19
+ function parseSettings(settingsJson) {
20
+ try {
21
+ const v = JSON.parse(settingsJson);
22
+ if (!v || typeof v !== "object")
23
+ return {};
24
+ return v;
25
+ }
26
+ catch {
27
+ return {};
28
+ }
29
+ }
30
+ function stringifySettings(settings) {
31
+ return JSON.stringify(settings ?? {});
32
+ }
33
+ class Storage {
34
+ constructor(db) {
35
+ this.db = db;
36
+ this.migrate();
37
+ }
38
+ migrate() {
39
+ this.db.exec(`
40
+ CREATE TABLE IF NOT EXISTS chats (
41
+ chat_id TEXT PRIMARY KEY,
42
+ user_id TEXT NOT NULL,
43
+ workspace_path TEXT NOT NULL,
44
+ created_at INTEGER NOT NULL,
45
+ updated_at INTEGER NOT NULL,
46
+ settings_json TEXT NOT NULL
47
+ );
48
+
49
+ CREATE TABLE IF NOT EXISTS jobs (
50
+ job_id TEXT PRIMARY KEY,
51
+ chat_id TEXT NOT NULL,
52
+ status TEXT NOT NULL,
53
+ created_at INTEGER NOT NULL,
54
+ started_at INTEGER,
55
+ finished_at INTEGER,
56
+ telegram_message_id INTEGER,
57
+ exit_code INTEGER,
58
+ error TEXT,
59
+ request_text TEXT NOT NULL,
60
+ response_text TEXT,
61
+ meta_json TEXT
62
+ );
63
+
64
+ CREATE TABLE IF NOT EXISTS allowed_users (
65
+ channel TEXT NOT NULL,
66
+ user_id TEXT NOT NULL,
67
+ created_at INTEGER NOT NULL,
68
+ added_by TEXT,
69
+ meta_json TEXT,
70
+ PRIMARY KEY (channel, user_id)
71
+ );
72
+
73
+ CREATE TABLE IF NOT EXISTS onboarding_codes (
74
+ channel TEXT NOT NULL,
75
+ code TEXT NOT NULL,
76
+ user_id TEXT NOT NULL,
77
+ chat_id TEXT NOT NULL,
78
+ created_at INTEGER NOT NULL,
79
+ expires_at INTEGER NOT NULL,
80
+ used_at INTEGER,
81
+ used_by TEXT,
82
+ PRIMARY KEY (channel, code)
83
+ );
84
+ `);
85
+ }
86
+ getChat(chatId) {
87
+ const row = this.db.prepare("SELECT * FROM chats WHERE chat_id = ?").get(chatId);
88
+ if (!row)
89
+ return undefined;
90
+ return { ...row, settings: parseSettings(row.settings_json) };
91
+ }
92
+ upsertChat(opts) {
93
+ const now = nowMs();
94
+ const settingsJson = stringifySettings(opts.settings ?? {});
95
+ this.db
96
+ .prepare(`
97
+ INSERT INTO chats (chat_id, user_id, workspace_path, created_at, updated_at, settings_json)
98
+ VALUES (?, ?, ?, ?, ?, ?)
99
+ ON CONFLICT(chat_id) DO UPDATE SET
100
+ user_id = excluded.user_id,
101
+ workspace_path = excluded.workspace_path,
102
+ updated_at = excluded.updated_at,
103
+ settings_json = excluded.settings_json
104
+ `)
105
+ .run(opts.chatId, opts.userId, opts.workspacePath, now, now, settingsJson);
106
+ return this.getChat(opts.chatId);
107
+ }
108
+ updateChatWorkspace(chatId, workspacePath) {
109
+ this.db.prepare("UPDATE chats SET workspace_path = ?, updated_at = ? WHERE chat_id = ?").run(workspacePath, nowMs(), chatId);
110
+ return this.getChat(chatId);
111
+ }
112
+ insertJob(opts) {
113
+ const jobId = (0, uuid_1.v4)();
114
+ const createdAt = nowMs();
115
+ const telegramMessageId = opts.telegramMessageId ?? null;
116
+ this.db
117
+ .prepare(`
118
+ INSERT INTO jobs (job_id, chat_id, status, created_at, started_at, finished_at, telegram_message_id, exit_code, error, request_text, response_text, meta_json)
119
+ VALUES (?, ?, 'queued', ?, NULL, NULL, ?, NULL, NULL, ?, NULL, NULL)
120
+ `)
121
+ .run(jobId, opts.chatId, createdAt, telegramMessageId, opts.requestText);
122
+ return this.getJob(jobId);
123
+ }
124
+ getJob(jobId) {
125
+ return this.db.prepare("SELECT * FROM jobs WHERE job_id = ?").get(jobId);
126
+ }
127
+ setJobRunning(jobId) {
128
+ this.db.prepare("UPDATE jobs SET status = 'running', started_at = ? WHERE job_id = ?").run(nowMs(), jobId);
129
+ }
130
+ setJobFinished(opts) {
131
+ this.db
132
+ .prepare("UPDATE jobs SET status = ?, finished_at = ?, response_text = ?, error = ?, exit_code = ? WHERE job_id = ?")
133
+ .run(opts.status, nowMs(), opts.responseText ?? null, opts.error ?? null, opts.exitCode ?? null, opts.jobId);
134
+ }
135
+ setJobTelegramMessageId(jobId, messageId) {
136
+ this.db.prepare("UPDATE jobs SET telegram_message_id = ? WHERE job_id = ?").run(messageId, jobId);
137
+ }
138
+ getRunningJob(chatId) {
139
+ return this.db.prepare("SELECT * FROM jobs WHERE chat_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1").get(chatId);
140
+ }
141
+ countQueuedJobs(chatId) {
142
+ const row = this.db.prepare("SELECT COUNT(1) AS cnt FROM jobs WHERE chat_id = ? AND status = 'queued'").get(chatId);
143
+ return row?.cnt ?? 0;
144
+ }
145
+ isAllowedUser(channel, userId) {
146
+ const row = this.db.prepare("SELECT 1 AS ok FROM allowed_users WHERE channel = ? AND user_id = ? LIMIT 1").get(channel, userId);
147
+ return !!row;
148
+ }
149
+ bootstrapAllowedUsers(channel, userIds, addedBy = "bootstrap") {
150
+ const now = nowMs();
151
+ const stmt = this.db.prepare("INSERT OR IGNORE INTO allowed_users (channel, user_id, created_at, added_by, meta_json) VALUES (?, ?, ?, ?, NULL)");
152
+ const tx = this.db.transaction(() => {
153
+ for (const id of userIds) {
154
+ stmt.run(channel, String(id), now, addedBy);
155
+ }
156
+ });
157
+ tx();
158
+ }
159
+ generateSixDigitCode() {
160
+ const n = node_crypto_1.default.randomInt(0, 1000000);
161
+ return String(n).padStart(6, "0");
162
+ }
163
+ getOrCreateOnboardingCode(channel, chatId, userId, ttlMs = 24 * 60 * 60 * 1000) {
164
+ const now = nowMs();
165
+ const existing = this.db
166
+ .prepare(`
167
+ SELECT code, expires_at
168
+ FROM onboarding_codes
169
+ WHERE channel = ? AND user_id = ? AND used_at IS NULL AND expires_at > ?
170
+ ORDER BY created_at DESC
171
+ LIMIT 1
172
+ `)
173
+ .get(channel, userId, now);
174
+ if (existing)
175
+ return { code: existing.code, expiresAt: existing.expires_at };
176
+ const insert = this.db.prepare(`
177
+ INSERT INTO onboarding_codes (channel, code, user_id, chat_id, created_at, expires_at, used_at, used_by)
178
+ VALUES (?, ?, ?, ?, ?, ?, NULL, NULL)
179
+ `);
180
+ // Retry on collision (very unlikely).
181
+ for (let i = 0; i < 20; i++) {
182
+ const code = this.generateSixDigitCode();
183
+ const expiresAt = now + ttlMs;
184
+ try {
185
+ insert.run(channel, code, userId, chatId, now, expiresAt);
186
+ return { code, expiresAt };
187
+ }
188
+ catch {
189
+ // collision on PRIMARY KEY(channel, code) or other transient insert errors
190
+ }
191
+ }
192
+ throw new Error("Failed to allocate onboarding code (too many collisions)");
193
+ }
194
+ approveOnboardingCode(channel, code, usedBy = "cli") {
195
+ const now = nowMs();
196
+ const row = this.db
197
+ .prepare(`
198
+ SELECT user_id, chat_id, expires_at, used_at
199
+ FROM onboarding_codes
200
+ WHERE channel = ? AND code = ?
201
+ LIMIT 1
202
+ `)
203
+ .get(channel, code);
204
+ if (!row)
205
+ throw new Error("Code not found");
206
+ if (row.used_at !== null)
207
+ throw new Error("Code already used");
208
+ if (row.expires_at <= now)
209
+ throw new Error("Code expired");
210
+ const tx = this.db.transaction(() => {
211
+ this.db.prepare("UPDATE onboarding_codes SET used_at = ?, used_by = ? WHERE channel = ? AND code = ?").run(now, usedBy, channel, code);
212
+ this.db
213
+ .prepare("INSERT OR IGNORE INTO allowed_users (channel, user_id, created_at, added_by, meta_json) VALUES (?, ?, ?, ?, NULL)")
214
+ .run(channel, row.user_id, now, usedBy);
215
+ });
216
+ tx();
217
+ return { userId: row.user_id, chatId: row.chat_id };
218
+ }
219
+ listPendingCodes(channel) {
220
+ const now = nowMs();
221
+ const rows = this.db
222
+ .prepare(`
223
+ SELECT code, user_id, chat_id, expires_at
224
+ FROM onboarding_codes
225
+ WHERE channel = ? AND used_at IS NULL AND expires_at > ?
226
+ ORDER BY expires_at ASC
227
+ `)
228
+ .all(channel, now);
229
+ return rows.map((r) => ({ code: r.code, userId: r.user_id, chatId: r.chat_id, expiresAt: r.expires_at }));
230
+ }
231
+ listAllowedUsers(channel) {
232
+ const rows = this.db
233
+ .prepare(`
234
+ SELECT user_id, created_at, added_by
235
+ FROM allowed_users
236
+ WHERE channel = ?
237
+ ORDER BY created_at ASC
238
+ `)
239
+ .all(channel);
240
+ return rows.map((r) => ({ userId: r.user_id, createdAt: r.created_at, addedBy: r.added_by }));
241
+ }
242
+ dbPath() {
243
+ // best-effort: better-sqlite3 exposes name for file db.
244
+ try {
245
+ const candidate = this.db;
246
+ return typeof candidate.name === "string" ? candidate.name : undefined;
247
+ }
248
+ catch {
249
+ return undefined;
250
+ }
251
+ }
252
+ }
253
+ exports.Storage = Storage;
254
+ function openStorage(dataDir) {
255
+ ensureDir(dataDir);
256
+ const dbPath = node_path_1.default.join(dataDir, "kayla.sqlite");
257
+ const db = new better_sqlite3_1.default(dbPath);
258
+ return new Storage(db);
259
+ }
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ThrottledTelegramEditor = void 0;
4
+ exports.splitTelegramText = splitTelegramText;
5
+ exports.truncateForEdit = truncateForEdit;
6
+ const TELEGRAM_TEXT_LIMIT = 4096;
7
+ function splitTelegramText(text, limit = TELEGRAM_TEXT_LIMIT) {
8
+ if (text.length <= limit)
9
+ return [text];
10
+ const parts = [];
11
+ let remaining = text;
12
+ while (remaining.length > limit) {
13
+ // Prefer splitting on newline within limit.
14
+ const slice = remaining.slice(0, limit);
15
+ const nl = slice.lastIndexOf("\n");
16
+ if (nl > 0 && nl >= limit * 0.5) {
17
+ parts.push(remaining.slice(0, nl));
18
+ remaining = remaining.slice(nl + 1);
19
+ continue;
20
+ }
21
+ parts.push(remaining.slice(0, limit));
22
+ remaining = remaining.slice(limit);
23
+ }
24
+ if (remaining.length)
25
+ parts.push(remaining);
26
+ return parts;
27
+ }
28
+ function truncateForEdit(text, limit = TELEGRAM_TEXT_LIMIT) {
29
+ if (text.length <= limit)
30
+ return text;
31
+ if (limit <= 3)
32
+ return text.slice(0, limit);
33
+ // For streaming, showing the tail is often more useful (latest tokens).
34
+ return `...${text.slice(text.length - (limit - 3))}`;
35
+ }
36
+ class ThrottledTelegramEditor {
37
+ constructor(api, chatId, messageId, minIntervalMs, logger) {
38
+ this.api = api;
39
+ this.chatId = chatId;
40
+ this.messageId = messageId;
41
+ this.minIntervalMs = minIntervalMs;
42
+ this.logger = logger;
43
+ this.lastEditAtMs = 0;
44
+ }
45
+ request(text) {
46
+ this.pendingText = text;
47
+ this.schedule();
48
+ }
49
+ async flush() {
50
+ // Ensure any pending delayed edit is applied before returning.
51
+ while (this.pendingText !== undefined || this.timer !== undefined) {
52
+ if (this.timer) {
53
+ clearTimeout(this.timer);
54
+ this.timer = undefined;
55
+ this.inFlight = this.doEdit().catch((err) => {
56
+ this.logger?.warn({ err, msg: "editMessageText failed" });
57
+ });
58
+ }
59
+ await this.inFlight;
60
+ }
61
+ }
62
+ schedule() {
63
+ if (this.timer)
64
+ return;
65
+ const delay = Math.max(0, this.lastEditAtMs + this.minIntervalMs - Date.now());
66
+ this.timer = setTimeout(() => {
67
+ this.timer = undefined;
68
+ this.inFlight = this.doEdit().catch((err) => {
69
+ this.logger?.warn({ err, msg: "editMessageText failed" });
70
+ });
71
+ }, delay);
72
+ }
73
+ async doEdit() {
74
+ const text = this.pendingText ?? "";
75
+ this.pendingText = undefined;
76
+ try {
77
+ await this.api.editMessageText(this.chatId, this.messageId, truncateForEdit(text));
78
+ }
79
+ catch (err) {
80
+ // Telegram may throw on "message is not modified" or similar; treat as non-fatal for streaming.
81
+ this.logger?.debug({ err, msg: "editMessageText error (ignored)" });
82
+ }
83
+ this.lastEditAtMs = Date.now();
84
+ // If new text arrived while editing, schedule again.
85
+ if (this.pendingText !== undefined)
86
+ this.schedule();
87
+ }
88
+ }
89
+ exports.ThrottledTelegramEditor = ThrottledTelegramEditor;
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const service_1 = require("./service");
4
+ (0, service_1.runService)().catch((err) => {
5
+ console.error(err);
6
+ process.exitCode = 1;
7
+ });
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runService = runService;
4
+ const bot_1 = require("./bot/bot");
5
+ const config_1 = require("./core/config");
6
+ const logging_1 = require("./core/logging");
7
+ const storage_1 = require("./core/storage");
8
+ async function runService() {
9
+ const config = (0, config_1.loadConfig)();
10
+ const logger = (0, logging_1.createLogger)(config.logging);
11
+ logger.info({ msg: "starting", mode: config.telegram.mode });
12
+ const storage = (0, storage_1.openStorage)(config.runtime.data_dir);
13
+ // Bootstrap allowlist into DB (required for first admin).
14
+ storage.bootstrapAllowedUsers("telegram", [...config.telegram.admin_user_ids, ...config.telegram.allowlist.user_ids]);
15
+ const bot = (0, bot_1.buildBot)({ config, logger, storage });
16
+ // MVP: polling only.
17
+ if (config.telegram.mode !== "polling") {
18
+ throw new Error(`telegram.mode=${config.telegram.mode} is not supported yet (MVP supports polling only)`);
19
+ }
20
+ await bot.start();
21
+ logger.info({ msg: "bot started" });
22
+ }