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,205 @@
1
+ /**
2
+ * /mcp — inspect and control the Kiro agent's MCP servers from Telegram.
3
+ *
4
+ * • Lists every configured server with its enabled/disabled state, transport
5
+ * (stdio/http) and scope (global/workspace).
6
+ * • 🩺 Health-check runs a real MCP `initialize` handshake against each enabled
7
+ * server and reports which connected and which failed (and why).
8
+ * • 🔧 Enable/Disable toggles a server's `disabled` flag in its mcp.json. The
9
+ * change applies when the agent next loads servers, so a 🔄 Restart button
10
+ * is offered to apply it immediately.
11
+ */
12
+ import { type Bot, type Context, InlineKeyboard } from "grammy";
13
+ import type { BotDeps } from "../deps.js";
14
+ import { listMcpServers, setMcpDisabled } from "../../mcp/config.js";
15
+ import { probeAll } from "../../mcp/probe.js";
16
+ import type { McpProbeResult, McpServer } from "../../mcp/types.js";
17
+
18
+ const PAGE_SIZE = 10;
19
+
20
+ /** Per-chat snapshot of the last listed servers, for index-based callbacks. */
21
+ const snapshots = new Map<number, McpServer[]>();
22
+
23
+ function snapshot(chatId: number, deps: BotDeps): McpServer[] {
24
+ const cwd = deps.registry.get(chatId).cwd;
25
+ const list = listMcpServers(cwd);
26
+ snapshots.set(chatId, list);
27
+ return list;
28
+ }
29
+
30
+ function trunc(s: string, n: number): string {
31
+ return s.length > n ? s.slice(0, n - 1) + "\u2026" : s;
32
+ }
33
+
34
+ const TRANSPORT_ICON: Record<string, string> = { http: "\u{1F310}", stdio: "\u{1F5A5}\uFE0F", unknown: "\u2753" };
35
+
36
+ /** Build the main MCP panel text + keyboard. */
37
+ function mainPanel(list: McpServer[]): { text: string; kb: InlineKeyboard } {
38
+ const enabled = list.filter((s) => !s.disabled);
39
+ const disabled = list.filter((s) => s.disabled);
40
+ const lines = [`\u{1F9E9} MCP servers \u2014 ${list.length} total \u00B7 \u2705 ${enabled.length} enabled \u00B7 \u26D4 ${disabled.length} disabled`, ""];
41
+ if (list.length === 0) {
42
+ lines.push("No MCP servers configured in ~/.kiro/settings/mcp.json.");
43
+ } else {
44
+ const LIST_CAP = 60; // keep the message well under Telegram's 4096-char limit
45
+ for (const s of list.slice(0, LIST_CAP)) {
46
+ const mark = s.disabled ? "\u26D4" : "\u2705";
47
+ const ti = TRANSPORT_ICON[s.transport] ?? "";
48
+ const scope = s.scope === "workspace" ? " \u00B7 ws" : "";
49
+ lines.push(`${mark} ${ti} ${trunc(s.name, 28)}${scope}`);
50
+ }
51
+ if (list.length > LIST_CAP) lines.push(`\u2026and ${list.length - LIST_CAP} more (use \u{1F527} Enable/Disable to browse).`);
52
+ }
53
+ lines.push("", "\u{1F9EA} Health-check runs a live connection test on enabled servers.");
54
+ const kb = new InlineKeyboard()
55
+ .text("\u{1F9EA} Health-check", "mcp:health")
56
+ .text("\u{1F527} Enable/Disable", "mcp:tog:0")
57
+ .row()
58
+ .text("\u{1F504} Restart agent", "mcp:restart")
59
+ .text("\u{1F501} Refresh", "mcp:refresh")
60
+ .row()
61
+ .text("\u2716 Close", "mcp:close");
62
+ return { text: lines.join("\n"), kb };
63
+ }
64
+
65
+ /** Build a paginated enable/disable view. */
66
+ function togglePanel(list: McpServer[], page: number): { text: string; kb: InlineKeyboard } {
67
+ const pages = Math.max(1, Math.ceil(list.length / PAGE_SIZE));
68
+ const p = Math.min(Math.max(0, page), pages - 1);
69
+ const slice = list.slice(p * PAGE_SIZE, p * PAGE_SIZE + PAGE_SIZE);
70
+ const kb = new InlineKeyboard();
71
+ slice.forEach((s) => {
72
+ const idx = list.indexOf(s);
73
+ const label = s.disabled ? `\u2705 Enable ${trunc(s.name, 24)}` : `\u26D4 Disable ${trunc(s.name, 24)}`;
74
+ kb.text(label, `mcp:set:${idx}`).row();
75
+ });
76
+ if (pages > 1) {
77
+ if (p > 0) kb.text("\u25C0 Prev", `mcp:tog:${p - 1}`);
78
+ kb.text(`Page ${p + 1}/${pages}`, "mcp:noop");
79
+ if (p < pages - 1) kb.text("Next \u25B6", `mcp:tog:${p + 1}`);
80
+ kb.row();
81
+ }
82
+ kb.text("\u2B05 Back", "mcp:refresh");
83
+ const text = `\u{1F527} Enable/Disable MCP servers (${list.length})\nTap to toggle. Changes apply after \u{1F504} Restart agent.`;
84
+ return { text, kb };
85
+ }
86
+
87
+ export async function showMcp(ctx: Context, deps: BotDeps): Promise<void> {
88
+ const list = snapshot(ctx.chat!.id, deps);
89
+ const { text, kb } = mainPanel(list);
90
+ await ctx.reply(text, { reply_markup: kb });
91
+ }
92
+
93
+ function fmtProbe(r: McpProbeResult): string {
94
+ if (r.ok) {
95
+ const who = r.serverName ? ` \u00B7 ${trunc(r.serverName, 30)}` : "";
96
+ return `\u2705 ${trunc(r.name, 26)} ${r.ms ?? 0}ms${who}`;
97
+ }
98
+ return `\u274C ${trunc(r.name, 26)} ${trunc(r.error ?? "failed", 60)}`;
99
+ }
100
+
101
+ async function runHealthCheck(ctx: Context, deps: BotDeps): Promise<void> {
102
+ const list = snapshot(ctx.chat!.id, deps);
103
+ const enabled = list.filter((s) => !s.disabled);
104
+ if (enabled.length === 0) {
105
+ await ctx.editMessageText("No enabled MCP servers to check.", {
106
+ reply_markup: new InlineKeyboard().text("\u2B05 Back", "mcp:refresh"),
107
+ }).catch(() => {});
108
+ return;
109
+ }
110
+ const header = `\u{1F9EA} Health-check \u2014 probing ${enabled.length} enabled server(s)\u2026`;
111
+ await ctx.editMessageText(header).catch(() => {});
112
+
113
+ let lastEdit = 0;
114
+ const results = await probeAll(
115
+ enabled,
116
+ { timeoutMs: deps.cfg.mcpProbeTimeoutMs, concurrency: deps.cfg.mcpProbeConcurrency },
117
+ (_r, done, total) => {
118
+ const now = Date.now();
119
+ if (now - lastEdit < 1200 && done < total) return; // throttle progress edits
120
+ lastEdit = now;
121
+ void ctx.editMessageText(`${header}\n\nProgress: ${done}/${total}`).catch(() => {});
122
+ },
123
+ );
124
+
125
+ const ok = results.filter((r) => r.ok).length;
126
+ const bad = results.length - ok;
127
+ const body = results
128
+ .slice()
129
+ .sort((a, b) => Number(a.ok) - Number(b.ok) || a.name.localeCompare(b.name))
130
+ .map(fmtProbe)
131
+ .join("\n");
132
+ const text = `\u{1F9EA} Health-check \u2014 \u2705 ${ok} connected \u00B7 \u274C ${bad} failed\n\n${trunc(body, 3500)}`;
133
+ const kb = new InlineKeyboard().text("\u{1F501} Re-check", "mcp:health").row().text("\u2B05 Back", "mcp:refresh");
134
+ await ctx.editMessageText(text, { reply_markup: kb }).catch(() => {});
135
+ }
136
+
137
+ export function registerMcp(bot: Bot, deps: BotDeps): void {
138
+ bot.command("mcp", (ctx) => showMcp(ctx, deps));
139
+
140
+ bot.callbackQuery("mcp:noop", (ctx) => ctx.answerCallbackQuery());
141
+
142
+ bot.callbackQuery("mcp:close", async (ctx) => {
143
+ await ctx.answerCallbackQuery();
144
+ await ctx.deleteMessage().catch(() => {});
145
+ });
146
+
147
+ bot.callbackQuery("mcp:refresh", async (ctx) => {
148
+ await ctx.answerCallbackQuery();
149
+ const list = snapshot(ctx.chat!.id, deps);
150
+ const { text, kb } = mainPanel(list);
151
+ await ctx.editMessageText(text, { reply_markup: kb }).catch(() => {});
152
+ });
153
+
154
+ bot.callbackQuery("mcp:health", async (ctx) => {
155
+ await ctx.answerCallbackQuery({ text: "Checking\u2026" });
156
+ await runHealthCheck(ctx, deps);
157
+ });
158
+
159
+ bot.callbackQuery(/^mcp:tog:(\d+)$/, async (ctx) => {
160
+ await ctx.answerCallbackQuery();
161
+ const list = snapshot(ctx.chat!.id, deps);
162
+ const { text, kb } = togglePanel(list, Number(ctx.match![1]));
163
+ await ctx.editMessageText(text, { reply_markup: kb }).catch(() => {});
164
+ });
165
+
166
+ bot.callbackQuery(/^mcp:set:(\d+)$/, async (ctx) => {
167
+ const idx = Number(ctx.match![1]);
168
+ const cached = snapshots.get(ctx.chat!.id);
169
+ const server = cached?.[idx];
170
+ if (!server) {
171
+ await ctx.answerCallbackQuery({ text: "Expired \u2014 reopen /mcp." });
172
+ return;
173
+ }
174
+ const res = setMcpDisabled(server, !server.disabled);
175
+ if (!res.ok) {
176
+ await ctx.answerCallbackQuery({ text: `Failed: ${res.error}`, show_alert: true });
177
+ return;
178
+ }
179
+ await ctx.answerCallbackQuery({ text: res.disabled ? `Disabled ${server.name}` : `Enabled ${server.name}` });
180
+ const page = Math.floor(idx / PAGE_SIZE);
181
+ const list = snapshot(ctx.chat!.id, deps); // re-list to reflect the change
182
+ const { text, kb } = togglePanel(list, page);
183
+ await ctx.editMessageText(`${text}\n\n\u26A0\uFE0F Tap \u{1F504} Restart agent (on the main panel) to apply.`, {
184
+ reply_markup: kb,
185
+ }).catch(() => {});
186
+ });
187
+
188
+ bot.callbackQuery("mcp:restart", async (ctx) => {
189
+ await ctx.answerCallbackQuery({ text: "Restarting agent\u2026" });
190
+ await ctx.editMessageText("\u{1F504} Restarting the Kiro agent to apply MCP changes\u2026").catch(() => {});
191
+ try {
192
+ await deps.acp.restart();
193
+ const list = snapshot(ctx.chat!.id, deps);
194
+ const { text, kb } = mainPanel(list);
195
+ await ctx.editMessageText(`\u2705 Agent restarted \u2014 MCP changes applied.\n\n${text}`, {
196
+ reply_markup: kb,
197
+ }).catch(() => {});
198
+ } catch (err) {
199
+ await ctx.editMessageText(`\u274C Restart failed: ${(err as Error).message}`, {
200
+ reply_markup: new InlineKeyboard().text("\u2B05 Back", "mcp:refresh"),
201
+ }).catch(() => {});
202
+ }
203
+ });
204
+ }
205
+
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Menu handler — maps the persistent reply-keyboard buttons (matched by emoji
3
+ * prefix for stateful ones) to actions, and provides inline submenus for Agent
4
+ * (real Kiro modes), Reasoning, and Model. Changing a value re-renders the
5
+ * keyboard so its labels always reflect the current state.
6
+ */
7
+ import { type Bot, type Context, InlineKeyboard } from "grammy";
8
+ import { reasoningLabel } from "../../app/reasoning.js";
9
+ import { REASONING_LEVELS, type ReasoningEffort } from "../../app/types.js";
10
+ import type { BotDeps } from "../deps.js";
11
+ import { BAR_LABELS, compactKeyboard, mainMenuInline, MENU_BTN, RUNNING_BTN, STOP_BTN } from "../menu/keyboard.js";
12
+ import { refreshMenu } from "../menu/refresh.js";
13
+ import { showKillConfirm } from "./kill.js";
14
+ import { showMcp } from "./mcp.js";
15
+ import { showProjects } from "./projects.js";
16
+ import { showRunning } from "./running.js";
17
+ import { showSessions } from "./sessions.js";
18
+ import { showTasks } from "./tasks.js";
19
+ import { showUsage } from "./usage.js";
20
+
21
+ /** Open the full inline menu, showing the current agent/model/reasoning. */
22
+ export async function openMainMenu(ctx: Context, deps: BotDeps): Promise<void> {
23
+ const rt = deps.registry.get(ctx.chat!.id);
24
+ await ctx.reply("\u2699\uFE0F Menu", {
25
+ reply_markup: mainMenuInline({
26
+ agent: rt.agent || "default",
27
+ model: rt.model || "default",
28
+ reasoning: reasoningLabel(rt.reasoning),
29
+ }),
30
+ });
31
+ }
32
+
33
+ export function registerMenu(bot: Bot, deps: BotDeps): void {
34
+ // Compact persistent bar.
35
+ bot.hears(BAR_LABELS, async (ctx) => {
36
+ deps.wizard.abort(ctx.chat.id);
37
+ switch (ctx.message?.text) {
38
+ case MENU_BTN:
39
+ return openMainMenu(ctx, deps);
40
+ case RUNNING_BTN:
41
+ return showRunning(ctx, deps);
42
+ case STOP_BTN: {
43
+ const rt = deps.registry.get(ctx.chat.id);
44
+ return void ctx.reply((await rt.cancel()) ? "\u23F9 Cancelling\u2026" : "Nothing is running.");
45
+ }
46
+ }
47
+ });
48
+
49
+ // Inline menu actions.
50
+ bot.callbackQuery(/^m:(\w+)$/, (ctx) => dispatchMenu(ctx, deps, ctx.match![1]!));
51
+
52
+ // ── Agent (real Kiro modes) ─────────────────────────────────────────────
53
+ bot.callbackQuery(/^agent:set:(\d+)$/, async (ctx) => {
54
+ const mode = deps.acp.availableModes[Number(ctx.match![1])];
55
+ if (!mode) return void ctx.answerCallbackQuery({ text: "Expired, tap Agent again." });
56
+ await deps.registry.get(ctx.chat!.id).setAgentPref(mode.id);
57
+ await confirm(ctx, deps, `\u{1F916} Agent: ${mode.name}`);
58
+ });
59
+
60
+ // ── Reasoning ──────────────────────────────────────────────────────────────
61
+ bot.callbackQuery(/^reason:(minimal|low|medium|high|max)$/, async (ctx) => {
62
+ const level = ctx.match![1] as ReasoningEffort;
63
+ deps.registry.get(ctx.chat!.id).setReasoningPref(level);
64
+ await confirm(ctx, deps, `\u{1F9E0} Reasoning: ${reasoningLabel(level)}`);
65
+ });
66
+
67
+ // ── Model ────────────────────────────────────────────────────────────────
68
+ bot.callbackQuery(/^model:set:(\d+)$/, async (ctx) => {
69
+ const entry = deps.acp.availableModels[Number(ctx.match![1])];
70
+ if (!entry) return void ctx.answerCallbackQuery({ text: "Expired, tap Model again." });
71
+ const res = await deps.registry.get(ctx.chat!.id).setModelPref(entry.modelId);
72
+ await confirm(ctx, deps, res.ok ? `\u{1F9E9} Model: ${entry.name}` : `\u26A0\uFE0F Model set failed: ${res.error}`);
73
+ });
74
+ bot.callbackQuery("model:clear", async (ctx) => {
75
+ await deps.registry.get(ctx.chat!.id).setModelPref("");
76
+ await confirm(ctx, deps, "\u{1F9E9} Model: default");
77
+ });
78
+ }
79
+
80
+ /** Dispatch an inline-menu action (`m:<action>`). */
81
+ async function dispatchMenu(ctx: Context, deps: BotDeps, action: string): Promise<void> {
82
+ const chatId = ctx.chat!.id;
83
+ const rt = deps.registry.get(chatId);
84
+ switch (action) {
85
+ case "close":
86
+ await ctx.answerCallbackQuery();
87
+ return void ctx.deleteMessage().catch(() => {});
88
+ case "hidebar":
89
+ await ctx.answerCallbackQuery();
90
+ await ctx.deleteMessage().catch(() => {});
91
+ return void ctx.reply("\u{1F648} Bar hidden \u2014 send /menu to bring it back.", {
92
+ reply_markup: { remove_keyboard: true },
93
+ });
94
+ case "showbar":
95
+ await ctx.answerCallbackQuery();
96
+ return void ctx.reply("\u2328\uFE0F Bar restored.", { reply_markup: compactKeyboard() });
97
+ case "project":
98
+ await ctx.answerCallbackQuery();
99
+ return showProjects(ctx, deps);
100
+ case "running":
101
+ await ctx.answerCallbackQuery();
102
+ return showRunning(ctx, deps);
103
+ case "sessions":
104
+ await ctx.answerCallbackQuery();
105
+ return showSessions(ctx, deps);
106
+ case "tasks":
107
+ await ctx.answerCallbackQuery();
108
+ return showTasks(ctx, deps);
109
+ case "agent":
110
+ await ctx.answerCallbackQuery();
111
+ return showAgentMenu(ctx, deps);
112
+ case "model":
113
+ await ctx.answerCallbackQuery();
114
+ return showModelMenu(ctx, deps);
115
+ case "reasoning":
116
+ await ctx.answerCallbackQuery();
117
+ return showReasoningMenu(ctx, deps);
118
+ case "status":
119
+ await ctx.answerCallbackQuery();
120
+ await deps.statusPanel.refresh(chatId);
121
+ return void ctx.reply(deps.statusPanel.render(chatId));
122
+ case "usage":
123
+ await ctx.answerCallbackQuery();
124
+ return showUsage(ctx, deps);
125
+ case "mcp":
126
+ await ctx.answerCallbackQuery();
127
+ return showMcp(ctx, deps);
128
+ case "killall":
129
+ await ctx.answerCallbackQuery();
130
+ return showKillConfirm(ctx, deps);
131
+ case "new":
132
+ await ctx.answerCallbackQuery();
133
+ try {
134
+ await deps.registry.controller(chatId).addNew(rt.cwd, rt.projectName);
135
+ return refreshMenu(ctx, deps, `\u2728 New session in ${rt.projectName ?? rt.cwd}`);
136
+ } catch (e) {
137
+ return void ctx.reply(`\u274C ${(e as Error).message}`);
138
+ }
139
+ case "stop":
140
+ return void ctx.answerCallbackQuery({ text: (await rt.cancel()) ? "Cancelling\u2026" : "Nothing is running" });
141
+ default:
142
+ return void ctx.answerCallbackQuery();
143
+ }
144
+ }
145
+
146
+ async function confirm(ctx: Context, deps: BotDeps, text: string): Promise<void> {
147
+ await ctx.answerCallbackQuery({ text });
148
+ try {
149
+ await ctx.deleteMessage();
150
+ } catch {
151
+ /* ignore */
152
+ }
153
+ await deps.statusPanel.refresh(ctx.chat!.id);
154
+ await openMainMenu(ctx, deps); // reopen so the new value is visible
155
+ }
156
+
157
+ async function showAgentMenu(ctx: Context, deps: BotDeps): Promise<void> {
158
+ const rt = deps.registry.get(ctx.chat!.id);
159
+ await ensureReady(ctx, rt);
160
+ const modes = deps.acp.availableModes.slice(0, 60);
161
+ if (modes.length === 0) {
162
+ await ctx.reply(`Current agent: ${rt.agent || "default"}\n(No selectable agents reported by Kiro.)`);
163
+ return;
164
+ }
165
+ const kb = new InlineKeyboard();
166
+ modes.forEach((m, i) => kb.text(`${m.id === rt.agent ? "\u2713 " : ""}${m.name}`, `agent:set:${i}`).row());
167
+ await ctx.reply(`Current agent: ${rt.agent || "default"}\nChoose an agent:`, { reply_markup: kb });
168
+ }
169
+
170
+ async function showReasoningMenu(ctx: Context, deps: BotDeps): Promise<void> {
171
+ const rt = deps.registry.get(ctx.chat!.id);
172
+ const kb = new InlineKeyboard();
173
+ REASONING_LEVELS.forEach((l) => kb.text(`${l === rt.reasoning ? "\u2713 " : ""}${reasoningLabel(l)}`, `reason:${l}`));
174
+ await ctx.reply(`Current reasoning: ${reasoningLabel(rt.reasoning)}\nChoose effort:`, { reply_markup: kb });
175
+ }
176
+
177
+ async function showModelMenu(ctx: Context, deps: BotDeps): Promise<void> {
178
+ const rt = deps.registry.get(ctx.chat!.id);
179
+ await ensureReady(ctx, rt);
180
+ const models = deps.acp.availableModels;
181
+ if (models.length === 0) {
182
+ await ctx.reply("No selectable models reported by Kiro yet \u2014 send a message first, then try again.");
183
+ return;
184
+ }
185
+ const current = rt.model || deps.acp.currentModelId;
186
+ const kb = new InlineKeyboard();
187
+ models.forEach((m, i) => kb.text(`${m.modelId === current ? "\u2713 " : ""}${m.name}`, `model:set:${i}`).row());
188
+ kb.text("Default (agent's model)", "model:clear");
189
+ await ctx.reply(`Current model: ${rt.model || "default"}\nChoose a model:`, { reply_markup: kb });
190
+ }
191
+
192
+ /** Ensure a session is live so models/modes are populated; show typing meanwhile. */
193
+ async function ensureReady(ctx: Context, rt: { prepare: () => Promise<void> }): Promise<void> {
194
+ try {
195
+ await ctx.replyWithChatAction("typing");
196
+ } catch {
197
+ /* ignore */
198
+ }
199
+ try {
200
+ await rt.prepare();
201
+ } catch {
202
+ /* menu will show whatever is available */
203
+ }
204
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Plain text messages -> Kiro prompts.
3
+ *
4
+ * Telegram caps a single message at 4096 characters, so a long paste is
5
+ * delivered to the bot as several back-to-back messages. Naively, each part
6
+ * became its own queued turn ("Queued position 1…4") and a part that happened
7
+ * to start with "/" was misread as an "Unknown command". We therefore COALESCE
8
+ * rapid consecutive text messages per chat within a short debounce window
9
+ * (`MESSAGE_BATCH_MS`) into a single prompt — one submission, one confirmation.
10
+ *
11
+ * While a turn is running, the combined message is queued and runs
12
+ * automatically when the current turn finishes.
13
+ * (Wizard input and menu-button text are intercepted by earlier handlers.)
14
+ */
15
+ import type { Bot } from "grammy";
16
+ import { textPrompt } from "../../app/types.js";
17
+ import { createLogger } from "../../logger.js";
18
+ import type { BotDeps } from "../deps.js";
19
+
20
+ const log = createLogger("message");
21
+
22
+ /** A pending burst of text messages from one chat, awaiting coalescing. */
23
+ interface TextBatch {
24
+ parts: string[];
25
+ timer: NodeJS.Timeout;
26
+ }
27
+
28
+ export function registerMessages(bot: Bot, deps: BotDeps): void {
29
+ const batches = new Map<number, TextBatch>();
30
+ const windowMs = deps.cfg.messageBatchMs;
31
+
32
+ const arm = (chatId: number): NodeJS.Timeout =>
33
+ setTimeout(() => void flush(deps, batches, chatId), windowMs);
34
+
35
+ bot.on("message:text", async (ctx) => {
36
+ const text = ctx.message.text;
37
+ if (!text.trim()) return;
38
+ const chatId = ctx.chat.id;
39
+
40
+ const batch = batches.get(chatId);
41
+ if (batch) {
42
+ clearTimeout(batch.timer);
43
+ batch.parts.push(text);
44
+ batch.timer = arm(chatId);
45
+ return;
46
+ }
47
+ batches.set(chatId, { parts: [text], timer: arm(chatId) });
48
+ });
49
+ }
50
+
51
+ /** Coalesce a chat's buffered parts into one prompt and submit it once. */
52
+ async function flush(deps: BotDeps, batches: Map<number, TextBatch>, chatId: number): Promise<void> {
53
+ const batch = batches.get(chatId);
54
+ if (!batch) return;
55
+ batches.delete(chatId);
56
+
57
+ // Telegram splits at 4096 chars, almost always on a line boundary, so
58
+ // rejoining with a newline reconstructs the original text faithfully.
59
+ const combined = batch.parts.join("\n").trim();
60
+ if (!combined) return;
61
+
62
+ // A lone, single-line "/something" is an unknown-command typo — guide the
63
+ // user instead of forwarding it to the agent. Split content never trips
64
+ // this: it arrives as multiple parts, and multi-line text is never a command.
65
+ if (batch.parts.length === 1 && !combined.includes("\n") && combined.startsWith("/")) {
66
+ await send(deps, chatId, "Unknown command. Type /help to see what I can do.");
67
+ return;
68
+ }
69
+
70
+ const rt = deps.registry.get(chatId);
71
+ const note = batch.parts.length > 1 ? ` (combined ${batch.parts.length} messages)` : "";
72
+ try {
73
+ const outcome = await rt.submit(textPrompt(combined));
74
+ if (outcome === "queued") {
75
+ await send(
76
+ deps,
77
+ chatId,
78
+ `\u{1F4E5} Queued (position ${rt.queueLength})${note} \u2014 I'm still working on the previous task. It'll run next.`,
79
+ );
80
+ }
81
+ } catch (err) {
82
+ log.warn(`submit failed for chat ${chatId}: ${(err as Error).message}`);
83
+ await send(deps, chatId, `\u274C Couldn't start your message: ${(err as Error).message}`);
84
+ }
85
+ }
86
+
87
+ async function send(deps: BotDeps, chatId: number, text: string): Promise<void> {
88
+ try {
89
+ await deps.api.sendMessage(chatId, text);
90
+ } catch {
91
+ /* non-fatal */
92
+ }
93
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Photo & image-document handler. Downloads images (including multi-image
3
+ * albums / media groups) and submits them to Kiro as ACP image content blocks
4
+ * alongside the caption text.
5
+ */
6
+ import type { Bot, Context } from "grammy";
7
+ import type { PromptImage } from "../../app/types.js";
8
+ import { createLogger } from "../../logger.js";
9
+ import type { BotDeps } from "../deps.js";
10
+
11
+ const log = createLogger("photo");
12
+ const GROUP_DEBOUNCE_MS = 900;
13
+
14
+ interface GroupBuffer {
15
+ chatId: number;
16
+ caption: string;
17
+ images: PromptImage[];
18
+ timer: NodeJS.Timeout;
19
+ }
20
+
21
+ export function registerPhotos(bot: Bot, deps: BotDeps): void {
22
+ const groups = new Map<string, GroupBuffer>();
23
+
24
+ const onMedia = async (ctx: Context, image: PromptImage | undefined, caption: string): Promise<void> => {
25
+ if (!image) return;
26
+ const chatId = ctx.chat!.id;
27
+
28
+ // Don't hijack the task wizard.
29
+ if (deps.wizard.isActive(chatId)) {
30
+ await ctx.reply("Finish or /cancel the current task wizard before sending images.");
31
+ return;
32
+ }
33
+
34
+ const groupId = ctx.message?.media_group_id;
35
+ if (!groupId) {
36
+ await submit(deps, chatId, caption, [image]);
37
+ return;
38
+ }
39
+
40
+ // Buffer album items and submit once the group settles.
41
+ const existing = groups.get(groupId);
42
+ if (existing) {
43
+ clearTimeout(existing.timer);
44
+ existing.images.push(image);
45
+ if (caption) existing.caption = caption;
46
+ existing.timer = setTimeout(() => flush(groups, groupId, deps), GROUP_DEBOUNCE_MS);
47
+ } else {
48
+ groups.set(groupId, {
49
+ chatId,
50
+ caption,
51
+ images: [image],
52
+ timer: setTimeout(() => flush(groups, groupId, deps), GROUP_DEBOUNCE_MS),
53
+ });
54
+ }
55
+ };
56
+
57
+ bot.on("message:photo", async (ctx) => {
58
+ const photos = ctx.message.photo;
59
+ const largest = photos[photos.length - 1];
60
+ const image = largest ? await download(ctx, largest.file_id, "image/jpeg", deps.cfg.token) : undefined;
61
+ await onMedia(ctx, image, ctx.message.caption ?? "");
62
+ });
63
+
64
+ bot.on("message:document", async (ctx, next) => {
65
+ const doc = ctx.message.document;
66
+ if (!doc.mime_type?.startsWith("image/")) return next(); // let document-handler logic pass
67
+ const image = await download(ctx, doc.file_id, doc.mime_type, deps.cfg.token);
68
+ await onMedia(ctx, image, ctx.message.caption ?? "");
69
+ });
70
+ }
71
+
72
+ async function flush(groups: Map<string, GroupBuffer>, groupId: string, deps: BotDeps): Promise<void> {
73
+ const buf = groups.get(groupId);
74
+ if (!buf) return;
75
+ groups.delete(groupId);
76
+ await submit(deps, buf.chatId, buf.caption, buf.images);
77
+ }
78
+
79
+ async function submit(deps: BotDeps, chatId: number, caption: string, images: PromptImage[]): Promise<void> {
80
+ const rt = deps.registry.get(chatId);
81
+ const outcome = await rt.submit({ text: caption, images });
82
+ if (outcome === "queued") {
83
+ await deps.api.sendMessage(
84
+ chatId,
85
+ `\u{1F4E5} Queued ${images.length} image${images.length > 1 ? "s" : ""} \u2014 will run after the current task.`,
86
+ );
87
+ }
88
+ }
89
+
90
+ async function download(
91
+ ctx: Context,
92
+ fileId: string,
93
+ mimeType: string,
94
+ token: string,
95
+ ): Promise<PromptImage | undefined> {
96
+ try {
97
+ const file = await ctx.api.getFile(fileId);
98
+ if (!file.file_path) return undefined;
99
+ const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
100
+ const res = await fetch(url);
101
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
102
+ const buf = Buffer.from(await res.arrayBuffer());
103
+ return { data: buf.toString("base64"), mimeType };
104
+ } catch (e) {
105
+ log.warn("image download failed:", (e as Error).message);
106
+ return undefined;
107
+ }
108
+ }