kiro-telegram-bot 1.5.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 (83) hide show
  1. package/.env.example +104 -0
  2. package/LICENSE +21 -0
  3. package/README.md +517 -0
  4. package/bin/kiro-tg.mjs +21 -0
  5. package/docs/INSTALL.md +143 -0
  6. package/docs/ops/RELEASE_CHECKLIST.md +39 -0
  7. package/package.json +70 -0
  8. package/scripts/mq.ts +25 -0
  9. package/scripts/setup.mjs +78 -0
  10. package/src/acp/client.ts +456 -0
  11. package/src/acp/server-handlers.ts +85 -0
  12. package/src/acp/transport.ts +50 -0
  13. package/src/acp/types.ts +136 -0
  14. package/src/agents/catalog.ts +44 -0
  15. package/src/app/json-store.ts +54 -0
  16. package/src/app/reasoning.ts +30 -0
  17. package/src/app/settings-store.ts +31 -0
  18. package/src/app/stt.ts +53 -0
  19. package/src/app/types.ts +48 -0
  20. package/src/app/usage.ts +32 -0
  21. package/src/bot/auth.ts +27 -0
  22. package/src/bot/bot.ts +154 -0
  23. package/src/bot/chat-controller.ts +251 -0
  24. package/src/bot/commands.ts +48 -0
  25. package/src/bot/deps.ts +47 -0
  26. package/src/bot/handlers/control.ts +94 -0
  27. package/src/bot/handlers/history.ts +58 -0
  28. package/src/bot/handlers/kill.ts +69 -0
  29. package/src/bot/handlers/mcp.ts +205 -0
  30. package/src/bot/handlers/menu.ts +204 -0
  31. package/src/bot/handlers/message.ts +93 -0
  32. package/src/bot/handlers/photo.ts +108 -0
  33. package/src/bot/handlers/projects.ts +83 -0
  34. package/src/bot/handlers/running.ts +104 -0
  35. package/src/bot/handlers/session-card.ts +65 -0
  36. package/src/bot/handlers/sessions.ts +131 -0
  37. package/src/bot/handlers/system.ts +51 -0
  38. package/src/bot/handlers/tasks.ts +223 -0
  39. package/src/bot/handlers/usage.ts +33 -0
  40. package/src/bot/handlers/voice.ts +53 -0
  41. package/src/bot/image-return.ts +69 -0
  42. package/src/bot/menu/keyboard.ts +47 -0
  43. package/src/bot/menu/refresh.ts +13 -0
  44. package/src/bot/menu/status-panel.ts +78 -0
  45. package/src/bot/permission-service.ts +149 -0
  46. package/src/bot/prompt-content.ts +49 -0
  47. package/src/bot/prompt-retry.ts +70 -0
  48. package/src/bot/registry.ts +178 -0
  49. package/src/bot/session-runtime.ts +670 -0
  50. package/src/bot/telegram-io.ts +109 -0
  51. package/src/bot/typing.ts +35 -0
  52. package/src/bot/wizard/task-wizard.ts +214 -0
  53. package/src/cli.ts +125 -0
  54. package/src/config.ts +190 -0
  55. package/src/index.ts +74 -0
  56. package/src/logger.ts +78 -0
  57. package/src/mcp/config.ts +103 -0
  58. package/src/mcp/probe.ts +218 -0
  59. package/src/mcp/types.ts +68 -0
  60. package/src/projects/manager.ts +88 -0
  61. package/src/render/chunk.ts +57 -0
  62. package/src/render/diff.ts +48 -0
  63. package/src/render/escape.ts +22 -0
  64. package/src/render/markdown.ts +126 -0
  65. package/src/render/subagent.ts +75 -0
  66. package/src/render/tool-call.ts +102 -0
  67. package/src/service/index.ts +24 -0
  68. package/src/service/linux.ts +83 -0
  69. package/src/service/macos.ts +91 -0
  70. package/src/service/platform.ts +59 -0
  71. package/src/service/types.ts +34 -0
  72. package/src/service/windows.ts +103 -0
  73. package/src/sessions/history.ts +181 -0
  74. package/src/sessions/store.ts +133 -0
  75. package/src/sessions/tail.ts +86 -0
  76. package/src/sessions/types.ts +26 -0
  77. package/src/stream/streamer.ts +167 -0
  78. package/src/tasks/runner.ts +82 -0
  79. package/src/tasks/schedule.ts +142 -0
  80. package/src/tasks/scheduler.ts +53 -0
  81. package/src/tasks/store.ts +80 -0
  82. package/src/tasks/types.ts +33 -0
  83. package/tsconfig.json +19 -0
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Safe Telegram I/O: send/edit messages with MarkdownV2, automatically falling
3
+ * back to plain text on parse errors and retrying on rate limits (429).
4
+ */
5
+ import { type Api, GrammyError } from "grammy";
6
+ import { createLogger } from "../logger.js";
7
+ import { chunkMarkdown } from "../render/chunk.js";
8
+ import { toTelegramMarkdown } from "../render/markdown.js";
9
+
10
+ const log = createLogger("tg:io");
11
+ const MAX_RETRIES = 3;
12
+
13
+ async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
14
+ let attempt = 0;
15
+ for (;;) {
16
+ try {
17
+ return await fn();
18
+ } catch (err) {
19
+ if (err instanceof GrammyError && err.error_code === 429) {
20
+ const wait = (err.parameters?.retry_after ?? 1) * 1000 + 250;
21
+ if (attempt++ < MAX_RETRIES) {
22
+ log.debug(`429 rate limited, waiting ${wait}ms`);
23
+ await sleep(wait);
24
+ continue;
25
+ }
26
+ }
27
+ throw err;
28
+ }
29
+ }
30
+ }
31
+
32
+ /** Send a message as MarkdownV2, falling back to plain text on parse errors. */
33
+ export async function safeSend(
34
+ api: Api,
35
+ chatId: number,
36
+ markdownV2: string,
37
+ plain: string,
38
+ extra: Record<string, unknown> = {},
39
+ ): Promise<number | undefined> {
40
+ try {
41
+ const msg = await withRetry(() =>
42
+ api.sendMessage(chatId, markdownV2, { parse_mode: "MarkdownV2", ...extra }),
43
+ );
44
+ return msg.message_id;
45
+ } catch (err) {
46
+ if (isParseError(err)) {
47
+ const msg = await withRetry(() => api.sendMessage(chatId, plain, extra));
48
+ return msg.message_id;
49
+ }
50
+ log.warn("sendMessage failed:", (err as Error).message);
51
+ return undefined;
52
+ }
53
+ }
54
+
55
+ /** Edit a message as MarkdownV2, falling back to plain text on parse errors. */
56
+ export async function safeEdit(
57
+ api: Api,
58
+ chatId: number,
59
+ messageId: number,
60
+ markdownV2: string,
61
+ plain: string,
62
+ ): Promise<void> {
63
+ try {
64
+ await withRetry(() =>
65
+ api.editMessageText(chatId, messageId, markdownV2, { parse_mode: "MarkdownV2" }),
66
+ );
67
+ } catch (err) {
68
+ if (isNotModified(err)) return;
69
+ if (isParseError(err)) {
70
+ try {
71
+ await withRetry(() => api.editMessageText(chatId, messageId, plain));
72
+ } catch (e2) {
73
+ if (!isNotModified(e2)) log.debug("plain edit failed:", (e2 as Error).message);
74
+ }
75
+ return;
76
+ }
77
+ log.debug("editMessageText failed:", (err as Error).message);
78
+ }
79
+ }
80
+
81
+ function isParseError(err: unknown): boolean {
82
+ return err instanceof GrammyError && /can't parse entities|parse entities/i.test(err.description);
83
+ }
84
+ function isNotModified(err: unknown): boolean {
85
+ return err instanceof GrammyError && /message is not modified/i.test(err.description);
86
+ }
87
+
88
+ function sleep(ms: number): Promise<void> {
89
+ return new Promise((r) => setTimeout(r, ms));
90
+ }
91
+
92
+ /**
93
+ * Convert a raw Markdown document to MarkdownV2, split it into Telegram-sized
94
+ * chunks, and send each — with plain-text fallback per chunk.
95
+ */
96
+ export async function sendMarkdownDoc(
97
+ api: Api,
98
+ chatId: number,
99
+ rawMarkdown: string,
100
+ opts?: { loud?: boolean },
101
+ ): Promise<void> {
102
+ const extra = opts?.loud ? { disable_notification: false } : {};
103
+ const rendered = toTelegramMarkdown(rawMarkdown);
104
+ const mdChunks = chunkMarkdown(rendered);
105
+ const plainChunks = chunkMarkdown(rawMarkdown);
106
+ for (let i = 0; i < mdChunks.length; i++) {
107
+ await safeSend(api, chatId, mdChunks[i]!, plainChunks[i] ?? mdChunks[i]!, extra);
108
+ }
109
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Keeps the Telegram "typing…" chat action alive while the agent works.
3
+ * Telegram clears the action after ~5s, so we re-send every 4s.
4
+ */
5
+ import type { Api } from "grammy";
6
+
7
+ export class TypingIndicator {
8
+ private timer: NodeJS.Timeout | undefined;
9
+
10
+ constructor(
11
+ private readonly api: Api,
12
+ private readonly chatId: number,
13
+ ) {}
14
+
15
+ start(): void {
16
+ if (this.timer) return;
17
+ void this.ping();
18
+ this.timer = setInterval(() => void this.ping(), 4000);
19
+ }
20
+
21
+ stop(): void {
22
+ if (this.timer) {
23
+ clearInterval(this.timer);
24
+ this.timer = undefined;
25
+ }
26
+ }
27
+
28
+ private async ping(): Promise<void> {
29
+ try {
30
+ await this.api.sendChatAction(this.chatId, "typing");
31
+ } catch {
32
+ /* non-fatal */
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Task wizard — a small per-chat state machine that guides task creation and
3
+ * editing through text and inline steps. Decoupled from Telegram: it returns
4
+ * WizardPrompt descriptors that the handler renders.
5
+ */
6
+ import { describeSchedule } from "../../tasks/schedule.js";
7
+ import { parseScheduleDetail } from "../../tasks/schedule.js";
8
+ import type { TaskStore } from "../../tasks/store.js";
9
+ import type { Schedule, ScheduleType } from "../../tasks/types.js";
10
+
11
+ export type WizStep = "name" | "prompt" | "project" | "scheduleType" | "detail" | "confirm";
12
+ export type WizKind = "text" | "project" | "scheduleType" | "confirm" | "done" | "aborted";
13
+
14
+ export interface WizardPrompt {
15
+ kind: WizKind;
16
+ text: string;
17
+ error?: boolean;
18
+ }
19
+
20
+ interface Draft {
21
+ name?: string;
22
+ prompt?: string;
23
+ projectPath?: string;
24
+ projectName?: string;
25
+ type?: ScheduleType;
26
+ schedule?: Schedule;
27
+ }
28
+
29
+ interface WizardState {
30
+ mode: "create" | "edit";
31
+ taskId?: string;
32
+ steps: WizStep[];
33
+ index: number;
34
+ draft: Draft;
35
+ }
36
+
37
+ const CREATE_STEPS: WizStep[] = ["name", "prompt", "project", "scheduleType", "detail", "confirm"];
38
+
39
+ export class TaskWizard {
40
+ private readonly states = new Map<number, WizardState>();
41
+
42
+ constructor(private readonly store: TaskStore) {}
43
+
44
+ isActive(chatId: number): boolean {
45
+ return this.states.has(chatId);
46
+ }
47
+
48
+ abort(chatId: number): void {
49
+ this.states.delete(chatId);
50
+ }
51
+
52
+ startCreate(chatId: number): WizardPrompt {
53
+ this.states.set(chatId, { mode: "create", steps: CREATE_STEPS, index: 0, draft: {} });
54
+ return this.promptFor(chatId)!;
55
+ }
56
+
57
+ /** Start an edit flow for a single aspect of an existing task. */
58
+ startEdit(chatId: number, taskId: string, field: "name" | "prompt" | "project" | "schedule"): WizardPrompt | undefined {
59
+ const task = this.store.get(taskId);
60
+ if (!task) return undefined;
61
+ const steps: Record<string, WizStep[]> = {
62
+ name: ["name"],
63
+ prompt: ["prompt"],
64
+ project: ["project"],
65
+ schedule: ["scheduleType", "detail"],
66
+ };
67
+ this.states.set(chatId, {
68
+ mode: "edit",
69
+ taskId,
70
+ steps: steps[field]!,
71
+ index: 0,
72
+ draft: {
73
+ name: task.name,
74
+ prompt: task.prompt,
75
+ projectPath: task.projectPath,
76
+ projectName: task.projectName,
77
+ type: task.schedule.type,
78
+ schedule: task.schedule,
79
+ },
80
+ });
81
+ return this.promptFor(chatId);
82
+ }
83
+
84
+ currentKind(chatId: number): WizKind | undefined {
85
+ const s = this.states.get(chatId);
86
+ if (!s) return undefined;
87
+ return kindOf(s.steps[s.index]!);
88
+ }
89
+
90
+ handleText(chatId: number, text: string): WizardPrompt | undefined {
91
+ const s = this.states.get(chatId);
92
+ if (!s) return undefined;
93
+ const step = s.steps[s.index]!;
94
+ if (step === "name") s.draft.name = text.trim();
95
+ else if (step === "prompt") s.draft.prompt = text.trim();
96
+ else if (step === "detail") {
97
+ const { schedule, error } = parseScheduleDetail(s.draft.type!, text);
98
+ if (error) return { kind: "text", text: `\u26A0\uFE0F ${error}`, error: true };
99
+ s.draft.schedule = schedule;
100
+ } else {
101
+ return { kind: kindOf(step), text: "Please use the buttons above to continue.", error: true };
102
+ }
103
+ return this.advance(chatId);
104
+ }
105
+
106
+ setProject(chatId: number, path: string, name: string): WizardPrompt | undefined {
107
+ const s = this.states.get(chatId);
108
+ if (!s || s.steps[s.index] !== "project") return undefined;
109
+ s.draft.projectPath = path;
110
+ s.draft.projectName = name;
111
+ return this.advance(chatId);
112
+ }
113
+
114
+ setScheduleType(chatId: number, type: ScheduleType): WizardPrompt | undefined {
115
+ const s = this.states.get(chatId);
116
+ if (!s || s.steps[s.index] !== "scheduleType") return undefined;
117
+ s.draft.type = type;
118
+ s.draft.schedule = { type };
119
+ return this.advance(chatId);
120
+ }
121
+
122
+ confirm(chatId: number): WizardPrompt | undefined {
123
+ const s = this.states.get(chatId);
124
+ if (!s || s.steps[s.index] !== "confirm") return undefined;
125
+ return this.finalize(chatId);
126
+ }
127
+
128
+ private advance(chatId: number): WizardPrompt {
129
+ const s = this.states.get(chatId)!;
130
+ s.index += 1;
131
+ if (s.index >= s.steps.length) return this.finalize(chatId);
132
+ return this.promptFor(chatId)!;
133
+ }
134
+
135
+ private finalize(chatId: number): WizardPrompt {
136
+ const s = this.states.get(chatId)!;
137
+ const d = s.draft;
138
+ this.states.delete(chatId);
139
+ if (s.mode === "create") {
140
+ const task = this.store.create({
141
+ chatId,
142
+ name: d.name ?? "Task",
143
+ prompt: d.prompt ?? "",
144
+ projectPath: d.projectPath ?? "",
145
+ projectName: d.projectName,
146
+ schedule: d.schedule!,
147
+ });
148
+ return { kind: "done", text: `\u2705 Task "${task.name}" created \u2014 ${describeSchedule(task.schedule)}.` };
149
+ }
150
+ this.store.update(s.taskId!, {
151
+ name: d.name,
152
+ prompt: d.prompt,
153
+ projectPath: d.projectPath,
154
+ projectName: d.projectName,
155
+ schedule: d.schedule,
156
+ });
157
+ return { kind: "done", text: "\u2705 Task updated." };
158
+ }
159
+
160
+ private promptFor(chatId: number): WizardPrompt | undefined {
161
+ const s = this.states.get(chatId);
162
+ if (!s) return undefined;
163
+ const step = s.steps[s.index]!;
164
+ switch (step) {
165
+ case "name":
166
+ return { kind: "text", text: "\u{1F4DD} Send a name for the task." };
167
+ case "prompt":
168
+ return { kind: "text", text: "\u{1F4AC} Send the prompt Kiro should run when it fires." };
169
+ case "project":
170
+ return { kind: "project", text: "\u{1F4C1} Pick the project to run it in:" };
171
+ case "scheduleType":
172
+ return { kind: "scheduleType", text: "\u{1F5D3} How often should it run?" };
173
+ case "detail":
174
+ return { kind: "text", text: detailQuestion(s.draft.type!) };
175
+ case "confirm":
176
+ return { kind: "confirm", text: this.summary(s.draft) };
177
+ }
178
+ }
179
+
180
+ private summary(d: Draft): string {
181
+ const p = d.prompt && d.prompt.length > 120 ? d.prompt.slice(0, 120) + "…" : d.prompt;
182
+ return [
183
+ "\u{1F4CB} Review the task:",
184
+ `\u2022 Name: ${d.name}`,
185
+ `\u2022 Project: ${d.projectName ?? d.projectPath}`,
186
+ `\u2022 Schedule: ${d.schedule ? describeSchedule(d.schedule) : "?"}`,
187
+ `\u2022 Prompt: ${p}`,
188
+ "",
189
+ "Save this task?",
190
+ ].join("\n");
191
+ }
192
+ }
193
+
194
+ function kindOf(step: WizStep): WizKind {
195
+ if (step === "project") return "project";
196
+ if (step === "scheduleType") return "scheduleType";
197
+ if (step === "confirm") return "confirm";
198
+ return "text";
199
+ }
200
+
201
+ function detailQuestion(type: ScheduleType): string {
202
+ switch (type) {
203
+ case "once":
204
+ return "\u{1F5D3} Enter date & time: YYYY-MM-DD HH:MM";
205
+ case "daily":
206
+ return "\u{1F550} Enter time (24h): HH:MM";
207
+ case "weekly":
208
+ return "\u{1F5D3} Enter day & time: e.g. Mon 09:00";
209
+ case "monthly":
210
+ return "\u{1F5D3} Enter day-of-month & time: e.g. 15 09:00";
211
+ case "interval":
212
+ return "\u23F1 Run every how many minutes? e.g. 90";
213
+ }
214
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Command-line interface for the Kiro Telegram Bot.
3
+ *
4
+ * kiro-tg run Run in the foreground (same as `npm start`)
5
+ * kiro-tg install Install as a background service that starts on boot
6
+ * kiro-tg uninstall Remove the background service
7
+ * kiro-tg start|stop|restart|status
8
+ * kiro-tg logs [n] Show the last n log lines (default 100)
9
+ */
10
+ import { spawnSync } from "node:child_process";
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { INSTANCE_DIR, PROJECT_ROOT } from "./config.js";
14
+ import { buildLaunchSpec, getController } from "./service/index.js";
15
+
16
+ const HELP = `Kiro Telegram Bot — CLI
17
+
18
+ Usage: kiro-tg <command>
19
+
20
+ run Run in the foreground
21
+ setup Create/update .env in this folder (token + auto-detect)
22
+ install Install + start a background service (autostart on boot)
23
+ uninstall Stop + remove the background service
24
+ start Start the service
25
+ stop Stop the service
26
+ restart Restart the service
27
+ status Show install + running status
28
+ logs [n] Show the last n log lines (default 100)
29
+ help Show this help
30
+ `;
31
+
32
+ async function main(): Promise<void> {
33
+ const args = process.argv.slice(2);
34
+ const [cmd, arg] = args;
35
+
36
+ switch (cmd) {
37
+ case "run":
38
+ case undefined:
39
+ await import("./index.js");
40
+ return;
41
+
42
+ case "setup":
43
+ case "config": {
44
+ // Run the plain-node setup script, targeting this folder (.env lives in
45
+ // the instance dir). Pass through optional <token> [userId] args.
46
+ const script = join(PROJECT_ROOT, "scripts", "setup.mjs");
47
+ const r = spawnSync(process.execPath, [script, ...args.slice(1)], {
48
+ stdio: "inherit",
49
+ env: { ...process.env, KIRO_TG_CWD: INSTANCE_DIR },
50
+ });
51
+ process.exit(r.status ?? 0);
52
+ break;
53
+ }
54
+
55
+ case "install": {
56
+ preflight();
57
+ const r = await getController().install(buildLaunchSpec());
58
+ console.log(r.ok ? `✓ ${r.message}` : `✗ ${r.message}`);
59
+ if (r.ok) console.log("\nManage it with: kiro-tg status | stop | restart | logs");
60
+ process.exit(r.ok ? 0 : 1);
61
+ break;
62
+ }
63
+
64
+ case "uninstall":
65
+ case "start":
66
+ case "stop":
67
+ case "restart":
68
+ case "status": {
69
+ const ctrl = getController();
70
+ const spec = buildLaunchSpec();
71
+ let result;
72
+ if (cmd === "restart") {
73
+ await ctrl.stop(spec);
74
+ result = await ctrl.start(spec);
75
+ } else {
76
+ result = await ctrl[cmd](spec);
77
+ }
78
+ console.log(result.ok ? result.message : `✗ ${result.message}`);
79
+ process.exit(result.ok ? 0 : 1);
80
+ break;
81
+ }
82
+
83
+ case "logs":
84
+ printLogs(arg ? Number(arg) || 100 : 100);
85
+ break;
86
+
87
+ case "help":
88
+ case "--help":
89
+ case "-h":
90
+ console.log(HELP);
91
+ break;
92
+
93
+ default:
94
+ console.error(`Unknown command: ${cmd}\n`);
95
+ console.log(HELP);
96
+ process.exit(1);
97
+ }
98
+ }
99
+
100
+ function preflight(): void {
101
+ const envPath = join(INSTANCE_DIR, ".env");
102
+ if (!existsSync(envPath)) {
103
+ console.warn("⚠ No .env found here. Run `kiro-tg setup` and set TELEGRAM_BOT_TOKEN first.");
104
+ return;
105
+ }
106
+ const env = readFileSync(envPath, "utf-8");
107
+ if (!/^TELEGRAM_BOT_TOKEN=.+/m.test(env)) {
108
+ console.warn("⚠ TELEGRAM_BOT_TOKEN is not set in .env — the service will fail to start.");
109
+ }
110
+ }
111
+
112
+ function printLogs(n: number): void {
113
+ const file = buildLaunchSpec().logFile;
114
+ if (!existsSync(file)) {
115
+ console.log(`No log file yet at ${file}`);
116
+ return;
117
+ }
118
+ const lines = readFileSync(file, "utf-8").split("\n");
119
+ console.log(lines.slice(-n).join("\n"));
120
+ }
121
+
122
+ main().catch((err) => {
123
+ console.error("Fatal:", err instanceof Error ? err.message : err);
124
+ process.exit(1);
125
+ });
package/src/config.ts ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Configuration: loads .env, validates required values, resolves paths.
3
+ */
4
+ import { config as loadDotenv } from "dotenv";
5
+ import { existsSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { dirname, isAbsolute, join, resolve } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ loadDotenv();
11
+
12
+ /** Absolute path to the installed bot code (one level above src/). For a global
13
+ * npm install this lives inside node_modules — code lives here, never user data. */
14
+ export const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
15
+
16
+ /**
17
+ * Directory holding THIS instance's `.env`, `logs/` and `data/`. Resolution:
18
+ * 1. `--instance <dir>` argv — set by the installed background service,
19
+ * 2. `KIRO_TG_CWD` env — set by the `kiro-tg` launcher to the user's cwd,
20
+ * 3. the current working directory.
21
+ * For a cloned/zip checkout run in place this equals PROJECT_ROOT (so behaviour
22
+ * is unchanged), while a global `npm i -g` install keeps user data in the
23
+ * user's folder rather than inside node_modules.
24
+ */
25
+ export const INSTANCE_DIR = resolveInstanceDir();
26
+
27
+ function resolveInstanceDir(): string {
28
+ const flag = process.argv.indexOf("--instance");
29
+ if (flag !== -1 && process.argv[flag + 1]) return resolve(process.argv[flag + 1]!);
30
+ const env = process.env.KIRO_TG_CWD?.trim();
31
+ if (env) return resolve(expandHome(env));
32
+ return process.cwd();
33
+ }
34
+
35
+ // Re-load .env from the instance directory (the first call above also primed
36
+ // process.env from cwd, which is the same place for an in-place checkout).
37
+ loadDotenv({ path: join(INSTANCE_DIR, ".env") });
38
+
39
+ function expandHome(p: string): string {
40
+ if (p === "~") return homedir();
41
+ if (p.startsWith("~/") || p.startsWith("~\\")) return join(homedir(), p.slice(2));
42
+ return p;
43
+ }
44
+
45
+ function bool(v: string | undefined, def: boolean): boolean {
46
+ if (v === undefined || v === "") return def;
47
+ return ["1", "true", "yes", "on"].includes(v.toLowerCase());
48
+ }
49
+
50
+ function num(v: string | undefined, def: number): number {
51
+ const n = Number(v);
52
+ return Number.isFinite(n) && n > 0 ? n : def;
53
+ }
54
+
55
+ /** Like num() but allows 0 (e.g. to disable retries). Rejects negatives. */
56
+ function nonNegNum(v: string | undefined, def: number): number {
57
+ if (v === undefined || v === "") return def;
58
+ const n = Number(v);
59
+ return Number.isFinite(n) && n >= 0 ? n : def;
60
+ }
61
+
62
+ function list(v: string | undefined): string[] {
63
+ return (v || "")
64
+ .split(",")
65
+ .map((s) => s.trim())
66
+ .filter(Boolean);
67
+ }
68
+
69
+ export interface AppConfig {
70
+ token: string;
71
+ allowedUsers: Set<string>;
72
+ kiroCliPath: string;
73
+ workspace: string;
74
+ agent?: string;
75
+ trustAllTools: boolean;
76
+ projectRoots: string[];
77
+ streamThrottleMs: number;
78
+ /** Debounce window (ms) for coalescing rapid consecutive text messages
79
+ * (e.g. a long message Telegram split at 4096 chars) into one prompt. */
80
+ messageBatchMs: number;
81
+ showToolCalls: boolean;
82
+ showEditDiffs: boolean;
83
+ diffMaxLines: number;
84
+ sendAgentImages: boolean;
85
+ agentImagesMax: number;
86
+ logLevel: string;
87
+ sessionsDir: string;
88
+ projectRoot: string;
89
+ logsDir: string;
90
+ logFile: string;
91
+ acpAutoRestart: boolean;
92
+ dataDir: string;
93
+ promptIdleMs: number;
94
+ quietNotifications: boolean;
95
+ promptRetryAttempts: number;
96
+ sttApiUrl?: string;
97
+ sttApiKey?: string;
98
+ sttModel: string;
99
+ sttLanguage?: string;
100
+ /** Per-server timeout for the /mcp live health probe. */
101
+ mcpProbeTimeoutMs: number;
102
+ /** How many MCP health probes run concurrently. */
103
+ mcpProbeConcurrency: number;
104
+ /** Show subagent (crew) activity while the main agent waits on them. */
105
+ showSubagents: boolean;
106
+ }
107
+
108
+ export function loadConfig(): AppConfig {
109
+ const token = (process.env.TELEGRAM_BOT_TOKEN || "").trim();
110
+ if (!token) {
111
+ throw new Error(
112
+ "TELEGRAM_BOT_TOKEN is missing. Copy .env.example to .env and set it (run `npm run setup`).",
113
+ );
114
+ }
115
+
116
+ const workspaceRaw = process.env.KIRO_WORKSPACE?.trim() || process.cwd();
117
+ const workspace = resolve(expandHome(workspaceRaw));
118
+
119
+ // Default project roots: the workspace parent + home directory.
120
+ const roots = list(process.env.PROJECT_ROOTS).map((p) => resolve(expandHome(p)));
121
+ if (roots.length === 0) {
122
+ roots.push(dirname(workspace), homedir());
123
+ }
124
+
125
+ const sessionsDir = join(homedir(), ".kiro", "sessions", "cli");
126
+ const logsDir = process.env.LOG_DIR?.trim()
127
+ ? resolve(expandHome(process.env.LOG_DIR.trim()))
128
+ : join(INSTANCE_DIR, "logs");
129
+ const logFile = process.env.LOG_FILE?.trim()
130
+ ? resolve(expandHome(process.env.LOG_FILE.trim()))
131
+ : join(logsDir, "kiro-telegram-bot.log");
132
+
133
+ const cfg: AppConfig = {
134
+ token,
135
+ allowedUsers: new Set(list(process.env.ALLOWED_USERS)),
136
+ kiroCliPath: resolveKiroPath(process.env.KIRO_CLI_PATH?.trim()),
137
+ workspace,
138
+ agent: process.env.KIRO_AGENT?.trim() || undefined,
139
+ trustAllTools: bool(process.env.KIRO_TRUST_ALL_TOOLS, true),
140
+ projectRoots: [...new Set(roots)],
141
+ streamThrottleMs: num(process.env.STREAM_THROTTLE_MS, 1500),
142
+ messageBatchMs: nonNegNum(process.env.MESSAGE_BATCH_MS, 800),
143
+ showToolCalls: bool(process.env.SHOW_TOOL_CALLS, true),
144
+ showEditDiffs: bool(process.env.SHOW_EDIT_DIFFS, true),
145
+ diffMaxLines: num(process.env.DIFF_MAX_LINES, 120),
146
+ sendAgentImages: bool(process.env.SEND_AGENT_IMAGES, true),
147
+ agentImagesMax: num(process.env.AGENT_IMAGES_MAX, 8),
148
+ logLevel: process.env.LOG_LEVEL?.trim() || "info",
149
+ sessionsDir,
150
+ projectRoot: PROJECT_ROOT,
151
+ logsDir,
152
+ logFile,
153
+ acpAutoRestart: bool(process.env.ACP_AUTO_RESTART, true),
154
+ promptIdleMs: num(process.env.PROMPT_IDLE_TIMEOUT_MS, 900_000),
155
+ quietNotifications: bool(process.env.QUIET_NOTIFICATIONS, true),
156
+ promptRetryAttempts: nonNegNum(process.env.PROMPT_RETRY_ATTEMPTS, 5),
157
+ dataDir: process.env.DATA_DIR?.trim()
158
+ ? resolve(expandHome(process.env.DATA_DIR.trim()))
159
+ : join(INSTANCE_DIR, "data"),
160
+ sttApiUrl: process.env.STT_API_URL?.trim() || undefined,
161
+ sttApiKey: process.env.STT_API_KEY?.trim() || undefined,
162
+ sttModel: process.env.STT_MODEL?.trim() || "whisper-1",
163
+ sttLanguage: process.env.STT_LANGUAGE?.trim() || undefined,
164
+ mcpProbeTimeoutMs: num(process.env.MCP_PROBE_TIMEOUT_MS, 8000),
165
+ mcpProbeConcurrency: num(process.env.MCP_PROBE_CONCURRENCY, 6),
166
+ showSubagents: bool(process.env.SHOW_SUBAGENTS, true),
167
+ };
168
+
169
+ return cfg;
170
+ }
171
+
172
+ /** Resolve the kiro-cli binary path, trying common Windows install dirs. */
173
+ function resolveKiroPath(explicit?: string): string {
174
+ if (explicit) return expandHome(explicit);
175
+
176
+ const candidates = [
177
+ join(homedir(), "AppData", "Local", "Kiro-Cli", "kiro-cli.exe"),
178
+ join(homedir(), ".local", "bin", "kiro-cli"),
179
+ "/usr/local/bin/kiro-cli",
180
+ ];
181
+ for (const c of candidates) {
182
+ if (existsSync(c)) return c;
183
+ }
184
+ // Fall back to PATH lookup.
185
+ return "kiro-cli";
186
+ }
187
+
188
+ export function isAbsolutePath(p: string): boolean {
189
+ return isAbsolute(p);
190
+ }