pi-telegram-plus 0.0.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.
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Session Capture & Handler Patching
3
+ *
4
+ * When p-tui starts, InteractiveMode calls session.bindExtensions() with
5
+ * command-context handlers (newSession, fork, navigateTree, switchSession, reload)
6
+ * that contain TUI rendering side effects.
7
+ *
8
+ * These handlers are needed for their CORE LOGIC (session lifecycle via runtimeHost).
9
+ * The TUI rendering is harmless (just refreshes the terminal).
10
+ *
11
+ * After bindExtensions, we patch two things:
12
+ *
13
+ * 1. The shutdown handler is replaced to prevent exiting the TUI event loop
14
+ * from a Telegram command. Instead it just notifies.
15
+ *
16
+ * 2. Other handlers are left as-is. The TUI rendering they contain is harmless,
17
+ * and Telegram notifications are handled by the command handlers themselves
18
+ * using a captured ui reference (see below).
19
+ *
20
+ * IMPORTANT: rebindSession triggers a new bindExtensions() call, which re-runs
21
+ * our monkey-patch. Since originalBindExtensions sets FRESH handlers each time
22
+ * (from the new bindings), there is no accumulation of wrappers.
23
+ *
24
+ * IMPORTANT: /reload rebuilds the extension runtime without calling
25
+ * bindExtensions(). Therefore capture state must live on globalThis instead of
26
+ * module-local variables; otherwise the freshly loaded extension instance loses
27
+ * activeSession and Telegram appears disconnected after reload.
28
+ *
29
+ * IMPORTANT: After rebind, runner.uiContext is reset to the TUI UI context.
30
+ * This means ctx.ui (a getter reading runner.uiContext) would return the TUI UI
31
+ * instead of TelegramUi. Command handlers MUST capture ctx.ui at the start
32
+ * and use the captured reference for all Telegram notifications:
33
+ *
34
+ * handler: async (args, ctx) => {
35
+ * const ui = ctx.ui; ← capture before any rebind can occur
36
+ * ...
37
+ * ui.notify("✅ Done", "info"); ← always goes to Telegram
38
+ * };
39
+ */
40
+
41
+ import { AgentSession } from "@earendil-works/pi-coding-agent";
42
+ import type { CapturedAgentSession } from "./types.ts";
43
+
44
+ type SessionCaptureState = {
45
+ activeSession?: CapturedAgentSession;
46
+ installed: boolean;
47
+ };
48
+
49
+ const SESSION_CAPTURE_STATE = Symbol.for("pi-telegram-plus.session-capture-state");
50
+
51
+ function getState(): SessionCaptureState {
52
+ const g = globalThis as typeof globalThis & Record<symbol, SessionCaptureState | undefined>;
53
+ g[SESSION_CAPTURE_STATE] ??= { installed: false };
54
+ return g[SESSION_CAPTURE_STATE];
55
+ }
56
+
57
+ export function installAgentSessionCapture(): void {
58
+ const state = getState();
59
+ if (state.installed) return;
60
+ state.installed = true;
61
+
62
+ const proto = AgentSession.prototype as CapturedAgentSession & {
63
+ bindExtensions: AgentSession["bindExtensions"];
64
+ };
65
+ const originalBindExtensions = proto.bindExtensions;
66
+
67
+ proto.bindExtensions = async function patchedBindExtensions(this: CapturedAgentSession, bindings) {
68
+ // Make the replacing session visible before originalBindExtensions emits
69
+ // session_start. Otherwise session_start handlers briefly see the previous
70
+ // session, which is especially visible for lifecycle-driven integrations
71
+ // such as Telegram reconnect/status handling after /new.
72
+ state.activeSession = this;
73
+
74
+ const result = await originalBindExtensions.call(this, bindings);
75
+
76
+ const runner = this.extensionRunner;
77
+
78
+ // ── Replace shutdown handler ───────────────────────────────────────
79
+ // Original exits the TUI event loop — dangerous from a Telegram command.
80
+ // Replace with a safe notification.
81
+ (runner as unknown as { shutdownHandler: () => void }).shutdownHandler = () => {
82
+ const ui = runner.getUIContext();
83
+ ui.notify(
84
+ "⚠️ Shutdown requested. Use Ctrl+C in terminal to stop pi.",
85
+ "info",
86
+ );
87
+ };
88
+
89
+ return result;
90
+ };
91
+ }
92
+
93
+ export function getActiveSession(): CapturedAgentSession | undefined {
94
+ return getState().activeSession;
95
+ }
package/lib/status.ts ADDED
@@ -0,0 +1,46 @@
1
+ export const TELEGRAM_STATUS_KEY = "telegram-plus";
2
+
3
+ export type StatusLineTheme = {
4
+ fg(token: "accent" | "error" | "muted" | "warning" | "success", text: string): string;
5
+ };
6
+
7
+ export type StatusLineUi = {
8
+ theme: StatusLineTheme;
9
+ setStatus(key: string, text: string | undefined): void;
10
+ };
11
+
12
+ export function formatTelegramStatusLine(
13
+ theme: StatusLineTheme,
14
+ state: {
15
+ hasBotToken: boolean;
16
+ pollingActive: boolean;
17
+ paired: boolean;
18
+ processing?: boolean;
19
+ error?: string;
20
+ botUsername?: string;
21
+ },
22
+ ): string {
23
+ const label = theme.fg("accent", "telegram+");
24
+ if (state.error) {
25
+ return `${label} ${theme.fg("error", "error")} ${theme.fg("muted", state.error)}`;
26
+ }
27
+ if (!state.hasBotToken) {
28
+ return `${label} ${theme.fg("muted", "not configured")}`;
29
+ }
30
+ if (!state.pollingActive) {
31
+ return `${label} ${theme.fg("muted", "disconnected")}`;
32
+ }
33
+ if (!state.paired) {
34
+ return `${label} ${theme.fg("warning", "awaiting pairing")}`;
35
+ }
36
+ const bot = state.botUsername ? ` @${state.botUsername}` : "";
37
+ if (state.processing) {
38
+ return `${label} ${theme.fg("warning", "active")}${bot}`;
39
+ }
40
+ return `${label} ${theme.fg("success", "connected")}${bot}`;
41
+ }
42
+
43
+ export function clearTelegramStatus(ctx: { ui?: StatusLineUi }): void {
44
+ if (!ctx?.ui?.setStatus) return;
45
+ ctx.ui.setStatus(TELEGRAM_STATUS_KEY, undefined);
46
+ }
@@ -0,0 +1,327 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { basename, extname } from "node:path";
3
+ import type { TelegramButton, TelegramConfig, TelegramSentMessage, TelegramTransport, TelegramUpdate } from "./types.ts";
4
+ import { stripHtml, splitTelegramText } from "./text-split.ts";
5
+
6
+ type TelegramFileInfo = {
7
+ file_id: string;
8
+ file_unique_id?: string;
9
+ file_size?: number;
10
+ file_path: string;
11
+ };
12
+
13
+ type TelegramApiError = {
14
+ ok: boolean;
15
+ result?: unknown;
16
+ description?: string;
17
+ };
18
+
19
+ const TELEGRAM_CALLBACK_LIMIT = 64;
20
+
21
+ function inferMimeTypeFromPath(path: string): string | undefined {
22
+ const extension = extname(path).toLowerCase();
23
+ switch (extension) {
24
+ case ".png":
25
+ return "image/png";
26
+ case ".jpg":
27
+ case ".jpeg":
28
+ return "image/jpeg";
29
+ case ".webp":
30
+ return "image/webp";
31
+ case ".gif":
32
+ return "image/gif";
33
+ case ".txt":
34
+ return "text/plain";
35
+ case ".json":
36
+ return "application/json";
37
+ case ".md":
38
+ return "text/markdown";
39
+ case ".html":
40
+ case ".htm":
41
+ return "text/html";
42
+ default:
43
+ return undefined;
44
+ }
45
+ }
46
+
47
+ export async function telegramApi<T>(
48
+ token: string,
49
+ method: string,
50
+ body: Record<string, unknown>,
51
+ signal?: AbortSignal,
52
+ ): Promise<T> {
53
+ const response = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
54
+ method: "POST",
55
+ headers: { "content-type": "application/json" },
56
+ body: JSON.stringify(body),
57
+ signal,
58
+ });
59
+ const json = (await response.json()) as TelegramApiError & { result: T };
60
+ if (!json.ok) throw new Error(json.description ?? `${method} failed`);
61
+ return json.result;
62
+ }
63
+
64
+ export async function getTelegramFile(token: string, fileId: string, signal?: AbortSignal): Promise<TelegramFileInfo> {
65
+ return telegramApi<TelegramFileInfo>(
66
+ token,
67
+ "getFile",
68
+ { file_id: fileId },
69
+ signal,
70
+ );
71
+ }
72
+
73
+ export async function downloadTelegramFile(token: string, filePath: string, signal?: AbortSignal): Promise<Buffer> {
74
+ const encodedPath = filePath.split("/").map((segment) => encodeURIComponent(segment)).join("/");
75
+ const response = await fetch(`https://api.telegram.org/file/bot${token}/${encodedPath}`, {
76
+ method: "GET",
77
+ signal,
78
+ });
79
+ if (!response.ok) {
80
+ throw new Error(`Failed to download Telegram file: ${response.status}`);
81
+ }
82
+ const bytes = await response.arrayBuffer();
83
+ return Buffer.from(bytes);
84
+ }
85
+
86
+
87
+ const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
88
+
89
+ export function createTelegramTransport(getConfig: () => TelegramConfig): TelegramTransport {
90
+ const cfg = () => getConfig();
91
+
92
+ /** Call a Telegram API method with retry on transient failures. */
93
+ const callApi = async <T>(method: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<T> => {
94
+ const token = requireToken();
95
+ const maxRetries = cfg().retryCount ?? 3;
96
+ let lastError: unknown;
97
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
98
+ try {
99
+ return await telegramApi<T>(token, method, body, signal);
100
+ } catch (error) {
101
+ lastError = error;
102
+ if (attempt >= maxRetries || signal?.aborted) throw error;
103
+ // Exponential backoff: 500ms, 1s, 2s
104
+ await sleep(250 * Math.pow(2, attempt));
105
+ }
106
+ }
107
+ throw lastError;
108
+ };
109
+
110
+ /** Non-retrying API call (for idempotent fire-and-forget or catch-suppressed helpers). */
111
+ const callApiOnce = async <T>(method: string, body: Record<string, unknown>) => {
112
+ return telegramApi<T>(requireToken(), method, body);
113
+ };
114
+ const buildInlineKeyboard = (rows: TelegramButton[][]) => ({
115
+ inline_keyboard: rows.map((row: TelegramButton[]) =>
116
+ row.map((button) => {
117
+ if (Buffer.byteLength(button.value, "utf8") > TELEGRAM_CALLBACK_LIMIT) {
118
+ throw new Error(`Telegram callback_data exceeds ${TELEGRAM_CALLBACK_LIMIT} bytes: ${button.text}`);
119
+ }
120
+ return {
121
+ text: button.text,
122
+ callback_data: button.value,
123
+ };
124
+ }),
125
+ ),
126
+ });
127
+
128
+ const requireToken = () => {
129
+ const token = getConfig().botToken;
130
+ if (!token) throw new Error("Telegram bot token is not configured");
131
+ return token;
132
+ };
133
+
134
+ return {
135
+ async sendText(chatId, text) {
136
+ const sent: TelegramSentMessage[] = [];
137
+ for (const chunk of splitTelegramText(text)) {
138
+ const body = {
139
+ chat_id: chatId,
140
+ text: chunk,
141
+ parse_mode: "HTML",
142
+ };
143
+ const msg = await callApi<TelegramSentMessage>("sendMessage", body)
144
+ .catch(() => callApi<TelegramSentMessage>("sendMessage", {
145
+ chat_id: chatId,
146
+ text: stripHtml(chunk),
147
+ }));
148
+ sent.push(msg);
149
+ }
150
+ return sent;
151
+ },
152
+
153
+ async sendButtons(chatId, text, rows) {
154
+ // Button messages cannot be split without duplicating keyboards, so keep
155
+ // title text short. The UI layer already truncates button labels.
156
+ const reply_markup = buildInlineKeyboard(rows);
157
+ const first = splitTelegramText(text)[0];
158
+ return await callApi<TelegramSentMessage>("sendMessage", {
159
+ chat_id: chatId,
160
+ text: first,
161
+ parse_mode: "HTML",
162
+ reply_markup,
163
+ }).catch(() => callApi<TelegramSentMessage>("sendMessage", {
164
+ chat_id: chatId,
165
+ text: stripHtml(first),
166
+ reply_markup,
167
+ }));
168
+ },
169
+
170
+ async editText(chatId, messageId, text) {
171
+ const first = splitTelegramText(text)[0];
172
+ await callApi("editMessageText", {
173
+ chat_id: chatId,
174
+ message_id: messageId,
175
+ text: first,
176
+ parse_mode: "HTML",
177
+ }).catch(() => callApi("editMessageText", {
178
+ chat_id: chatId,
179
+ message_id: messageId,
180
+ text: stripHtml(first),
181
+ }).catch(() => undefined));
182
+ },
183
+
184
+ async editButtons(chatId, messageId, text, rows) {
185
+ const reply_markup = buildInlineKeyboard(rows);
186
+ const first = splitTelegramText(text)[0];
187
+ await callApi("editMessageText", {
188
+ chat_id: chatId,
189
+ message_id: messageId,
190
+ text: first,
191
+ parse_mode: "HTML",
192
+ reply_markup,
193
+ }).catch(() => callApi("editMessageText", {
194
+ chat_id: chatId,
195
+ message_id: messageId,
196
+ text: stripHtml(first),
197
+ reply_markup,
198
+ }).catch(() => undefined));
199
+ },
200
+
201
+ async answerCallbackQuery(callbackQueryId, text) {
202
+ await callApi("answerCallbackQuery", {
203
+ callback_query_id: callbackQueryId,
204
+ ...(text ? { text } : {}),
205
+ });
206
+ },
207
+
208
+ async removeInlineKeyboard(chatId, messageId) {
209
+ await callApi("editMessageReplyMarkup", {
210
+ chat_id: chatId,
211
+ message_id: messageId,
212
+ reply_markup: { inline_keyboard: [] },
213
+ }).catch(() => undefined);
214
+ },
215
+
216
+ async deleteMessage(chatId, messageId) {
217
+ await callApi("deleteMessage", {
218
+ chat_id: chatId,
219
+ message_id: messageId,
220
+ }).catch(() => undefined);
221
+ },
222
+
223
+ async sendDocument(chatId, path, caption, signal) {
224
+ const token = requireToken();
225
+ const maxRetries = cfg().retryCount ?? 3;
226
+ const data = await readFile(path);
227
+ let lastError: unknown;
228
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
229
+ try {
230
+ const form = new FormData();
231
+ form.set("chat_id", String(chatId));
232
+ if (caption) form.set("caption", caption);
233
+ const documentBlob = new Blob([data], {
234
+ type: inferMimeTypeFromPath(path) ?? "application/octet-stream",
235
+ });
236
+ form.set("document", documentBlob, basename(path));
237
+ const response = await fetch(`https://api.telegram.org/bot${token}/sendDocument`, {
238
+ method: "POST",
239
+ body: form,
240
+ signal,
241
+ });
242
+ const json = await response.json() as { ok: boolean; description?: string };
243
+ if (!json.ok) throw new Error(json.description ?? "sendDocument failed");
244
+ return;
245
+ } catch (error) {
246
+ lastError = error;
247
+ if (attempt >= maxRetries || signal?.aborted) throw error;
248
+ await sleep(250 * Math.pow(2, attempt));
249
+ }
250
+ }
251
+ throw lastError;
252
+ },
253
+
254
+ async sendPhoto(chatId, data, caption, isPath = false, signal) {
255
+ const token = requireToken();
256
+ const maxRetries = cfg().retryCount ?? 3;
257
+ let lastError: unknown;
258
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
259
+ try {
260
+ const form = new FormData();
261
+ form.set("chat_id", String(chatId));
262
+ if (caption) form.set("caption", caption);
263
+ if (isPath) {
264
+ const bytes = await readFile(data);
265
+ form.set("photo", new Blob([bytes], {
266
+ type: inferMimeTypeFromPath(data) ?? "image/jpeg",
267
+ }), basename(data));
268
+ } else {
269
+ const match = data.match(/^data:([^;]+);base64,(.*)$/);
270
+ const base64 = match ? match[2] : data;
271
+ const mime = match?.[1] ?? "image/png";
272
+ const bytes = Buffer.from(base64, "base64");
273
+ form.set("photo", new Blob([bytes], { type: mime }), `image.${mime.split("/")[1] ?? "png"}`);
274
+ }
275
+ const response = await fetch(`https://api.telegram.org/bot${token}/sendPhoto`, {
276
+ method: "POST",
277
+ body: form,
278
+ signal,
279
+ });
280
+ const json = await response.json() as { ok: boolean; description?: string };
281
+ if (!json.ok) throw new Error(json.description ?? "sendPhoto failed");
282
+ return;
283
+ } catch (error) {
284
+ lastError = error;
285
+ if (attempt >= maxRetries || signal?.aborted) throw error;
286
+ await sleep(250 * Math.pow(2, attempt));
287
+ }
288
+ }
289
+ throw lastError;
290
+ },
291
+
292
+ async sendChatAction(chatId, action) {
293
+ await telegramApi(requireToken(), "sendChatAction", {
294
+ chat_id: chatId,
295
+ action,
296
+ }).catch(() => undefined);
297
+ },
298
+ };
299
+ }
300
+
301
+ export async function getTelegramBotUsername(token: string): Promise<string | undefined> {
302
+ const result = await telegramApi<{ username?: string }>(token, "getMe", {});
303
+ return result.username;
304
+ }
305
+
306
+ export async function setTelegramMyCommands(token: string, commands: Array<{ command: string; description: string }>): Promise<void> {
307
+ await telegramApi(token, "setMyCommands", {
308
+ commands,
309
+ });
310
+ }
311
+
312
+ export async function getTelegramUpdates(
313
+ config: TelegramConfig,
314
+ signal: AbortSignal,
315
+ ): Promise<TelegramUpdate[]> {
316
+ if (!config.botToken) return [];
317
+ return telegramApi<TelegramUpdate[]>(
318
+ config.botToken,
319
+ "getUpdates",
320
+ {
321
+ offset: config.lastUpdateId === undefined ? undefined : config.lastUpdateId + 1,
322
+ timeout: 30,
323
+ allowed_updates: ["message", "callback_query"],
324
+ },
325
+ signal,
326
+ );
327
+ }
@@ -0,0 +1,208 @@
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ import { encodeUiCallback } from "./callback-protocol.ts";
3
+ import { escapeHtml } from "./html.ts";
4
+ import type { CapturedAgentSession, PendingInputResolver, TelegramTransport } from "./types.ts";
5
+
6
+ const MAX_BUTTON_TEXT = 60;
7
+ const PAGE_SIZE = 10;
8
+ const INPUT_TIMEOUT_MS = 10 * 60 * 1000;
9
+
10
+ type Pending = { flowId: string; resolve: PendingInputResolver; timer: NodeJS.Timeout; sensitive: boolean; acceptsText: boolean; promptMessageId?: number };
11
+
12
+ function truncateLabel(text: string): string { return text.length <= MAX_BUTTON_TEXT ? text : text.slice(0, MAX_BUTTON_TEXT - 1) + "…"; }
13
+
14
+ export type TelegramUiRuntime = {
15
+ create(chatId: number): ExtensionUIContext & { chatId: number; inputSecret?: (title: string, placeholder?: string) => Promise<string | undefined> };
16
+ resolveInput(chatId: number, value: string | boolean | undefined, replyToMessageId?: number, fromCallback?: boolean): { handled: boolean; promptMessageId?: number };
17
+ isSensitiveInput(chatId: number, replyToMessageId?: number): boolean;
18
+ hasPendingInput(chatId: number): boolean;
19
+ dispose(): void;
20
+ };
21
+
22
+ export function createTelegramUiRuntime(deps: {
23
+ getSession: () => CapturedAgentSession | undefined;
24
+ transport: TelegramTransport;
25
+ onPendingInputChange?: (chatId: number) => void;
26
+ }): TelegramUiRuntime {
27
+ const pendingByChat = new Map<number, Map<string, Pending>>();
28
+ // Per-flow replace targets prevent rapid callbacks from overwriting each other.
29
+ const replaceNextMessageByFlow = new Map<string, number>();
30
+ const latestTextFlow = new Map<number, string>();
31
+ const latestFlow = new Map<number, string>();
32
+ let nextFlowId = 1;
33
+
34
+ const flows = (chatId: number) => {
35
+ let map = pendingByChat.get(chatId);
36
+ if (!map) { map = new Map(); pendingByChat.set(chatId, map); }
37
+ return map;
38
+ };
39
+ const clearFlow = (chatId: number, flowId: string) => {
40
+ const map = pendingByChat.get(chatId); const pending = map?.get(flowId);
41
+ if (pending) clearTimeout(pending.timer);
42
+ map?.delete(flowId);
43
+ if (latestTextFlow.get(chatId) === flowId) latestTextFlow.delete(chatId);
44
+ if (latestFlow.get(chatId) === flowId) latestFlow.delete(chatId);
45
+ if (map && map.size === 0) pendingByChat.delete(chatId);
46
+ if (pending) deps.onPendingInputChange?.(chatId);
47
+ };
48
+ const beginFlow = () => String(nextFlowId++);
49
+ const waitInput = (chatId: number, flowId: string, sensitive = false, acceptsText = true, promptMessageId?: number) =>
50
+ new Promise<string | boolean | undefined>((resolve) => {
51
+ const timer = setTimeout(() => { if (flows(chatId).has(flowId)) { clearFlow(chatId, flowId); resolve(undefined); } }, INPUT_TIMEOUT_MS);
52
+ flows(chatId).set(flowId, { flowId, resolve, timer, sensitive, acceptsText, promptMessageId });
53
+ latestFlow.set(chatId, flowId);
54
+ if (acceptsText) latestTextFlow.set(chatId, flowId);
55
+ deps.onPendingInputChange?.(chatId);
56
+ });
57
+ const cb = (flowId: string, value: string) => encodeUiCallback(`f:${flowId}:${value}`);
58
+ const getReplaceIdForFlow = (chatId: number, flowId: string | undefined): number | undefined => {
59
+ if (!flowId) return undefined;
60
+ const id = replaceNextMessageByFlow.get(flowId);
61
+ if (id !== undefined) replaceNextMessageByFlow.delete(flowId);
62
+ return id;
63
+ };
64
+ const sendOrReplaceText = async (chatId: number, text: string, flowId?: string) => {
65
+ const replaceId = getReplaceIdForFlow(chatId, flowId);
66
+ if (replaceId !== undefined) {
67
+ await deps.transport.editText(chatId, replaceId, text);
68
+ return { message_id: replaceId };
69
+ }
70
+ const [sent] = await deps.transport.sendText(chatId, text);
71
+ return sent;
72
+ };
73
+ const sendOrReplaceButtons = async (chatId: number, text: string, rows: { text: string; value: string }[][], flowId?: string) => {
74
+ const replaceId = getReplaceIdForFlow(chatId, flowId);
75
+ if (replaceId !== undefined) {
76
+ await deps.transport.editButtons(chatId, replaceId, text, rows);
77
+ return { message_id: replaceId };
78
+ }
79
+ return deps.transport.sendButtons(chatId, text, rows);
80
+ };
81
+
82
+ /** Track the currently active flow for each chat (for sendOrReplace lookups). */
83
+ const activeFlowByChat = new Map<number, string>();
84
+
85
+ return {
86
+ create(chatId) {
87
+ const base = deps.getSession()?.extensionRunner.getUIContext?.();
88
+ return {
89
+ ...(base as ExtensionUIContext),
90
+ chatId,
91
+ notify: (message, level = "info") => {
92
+ const flowId = activeFlowByChat.get(chatId);
93
+ void sendOrReplaceText(chatId, `<b>${escapeHtml(String(level))}</b>\n${escapeHtml(message)}`, flowId);
94
+ },
95
+ confirm: async (title, message) => {
96
+ const flowId = beginFlow();
97
+ activeFlowByChat.set(chatId, flowId);
98
+ const sent = await sendOrReplaceButtons(chatId, `<b>${escapeHtml(title)}</b>\n${escapeHtml(message)}`, [[
99
+ { text: "Yes", value: cb(flowId, "yes") }, { text: "No", value: cb(flowId, "no") }, { text: "Cancel", value: cb(flowId, "cancel") },
100
+ ]], flowId);
101
+ const value = await waitInput(chatId, flowId, false, false, sent.message_id);
102
+ activeFlowByChat.delete(chatId);
103
+ return value === true || value === "yes";
104
+ },
105
+ input: async (title, placeholder) => {
106
+ const flowId = beginFlow();
107
+ activeFlowByChat.set(chatId, flowId);
108
+ const sent = await sendOrReplaceButtons(chatId, `<b>${escapeHtml(title)}</b>${placeholder ? `\n${escapeHtml(placeholder)}` : ""}`, [[{ text: "Cancel", value: cb(flowId, "cancel") }]], flowId);
109
+ const value = await waitInput(chatId, flowId, false, true, sent.message_id);
110
+ activeFlowByChat.delete(chatId);
111
+ return typeof value === "string" ? value : undefined;
112
+ },
113
+ inputSecret: async (title: string, placeholder?: string) => {
114
+ const flowId = beginFlow();
115
+ activeFlowByChat.set(chatId, flowId);
116
+ const sent = await sendOrReplaceButtons(chatId, `<b>${escapeHtml(title)}</b>${placeholder ? `\n${escapeHtml(placeholder)}` : ""}`, [[{ text: "Cancel", value: cb(flowId, "cancel") }]], flowId);
117
+ const value = await waitInput(chatId, flowId, true, true, sent.message_id);
118
+ activeFlowByChat.delete(chatId);
119
+ return typeof value === "string" ? value : undefined;
120
+ },
121
+ editor: async (title, prefill) => {
122
+ const flowId = beginFlow();
123
+ activeFlowByChat.set(chatId, flowId);
124
+ const sent = await sendOrReplaceButtons(chatId, `<b>${escapeHtml(title)}</b>${prefill ? `\n${escapeHtml(prefill)}` : ""}`, [[{ text: "Cancel", value: cb(flowId, "cancel") }]], flowId);
125
+ const value = await waitInput(chatId, flowId, false, true, sent.message_id);
126
+ activeFlowByChat.delete(chatId);
127
+ return typeof value === "string" ? value : undefined;
128
+ },
129
+ select: async (title, options) => {
130
+ if (options.length === 0) return undefined;
131
+ let page = 0; const pageCount = Math.ceil(options.length / PAGE_SIZE); const flowId = beginFlow();
132
+ activeFlowByChat.set(chatId, flowId);
133
+ while (true) {
134
+ const start = page * PAGE_SIZE; const pageOptions = options.slice(start, start + PAGE_SIZE);
135
+ const rows = pageOptions.map((label, i) => [{ text: truncateLabel(label), value: cb(flowId, `s:${start + i}`) }]);
136
+ const nav = [];
137
+ if (page > 0) nav.push({ text: "◀ Prev", value: cb(flowId, `p:${page - 1}`) });
138
+ if (page < pageCount - 1) nav.push({ text: "Next ▶", value: cb(flowId, `p:${page + 1}`) });
139
+ nav.push({ text: "Cancel", value: cb(flowId, "cancel") }); rows.push(nav);
140
+ const suffix = pageCount > 1 ? ` (${page + 1}/${pageCount})` : "";
141
+ const sent = await sendOrReplaceButtons(chatId, `<b>${escapeHtml(title + suffix)}</b>`, rows, flowId);
142
+ const value = await waitInput(chatId, flowId, false, false, sent.message_id);
143
+ if (typeof value !== "string") { activeFlowByChat.delete(chatId); return undefined; }
144
+ if (value === "cancel") { activeFlowByChat.delete(chatId); return undefined; }
145
+ if (value.startsWith("p:")) { const next = parseInt(value.slice(2), 10); if (next >= 0 && next < pageCount) page = next; continue; }
146
+ if (value.startsWith("s:")) { const idx = parseInt(value.slice(2), 10); activeFlowByChat.delete(chatId); return idx >= 0 && idx < options.length ? options[idx] : undefined; }
147
+ if (options.includes(value)) { activeFlowByChat.delete(chatId); return value; }
148
+ activeFlowByChat.delete(chatId);
149
+ return undefined;
150
+ }
151
+ },
152
+ };
153
+ },
154
+ resolveInput(chatId, raw, replyToMessageId, fromCallback = false) {
155
+ let flowId: string | undefined; let value = raw;
156
+ if (fromCallback && typeof raw === "string" && raw.startsWith("f:")) {
157
+ const [, id, ...rest] = raw.split(":"); flowId = id; const inner = rest.join(":");
158
+ value = inner === "yes" ? true : inner === "no" ? false : inner === "cancel" ? undefined : inner;
159
+ } else {
160
+ const map = pendingByChat.get(chatId);
161
+ const isCancel = raw === undefined;
162
+ if (replyToMessageId) {
163
+ flowId = map ? [...map.values()].find((p) =>
164
+ p.promptMessageId === replyToMessageId && (isCancel || p.acceptsText)
165
+ )?.flowId : undefined;
166
+ if (!flowId) return { handled: false };
167
+ } else {
168
+ flowId = isCancel ? latestFlow.get(chatId) : latestTextFlow.get(chatId);
169
+ }
170
+ }
171
+ if (!flowId) return { handled: false };
172
+ const pending = pendingByChat.get(chatId)?.get(flowId); if (!pending) return { handled: false };
173
+ if (fromCallback) {
174
+ if (replyToMessageId !== pending.promptMessageId) return { handled: false };
175
+ } else if (raw !== undefined && !pending.acceptsText) return { handled: false };
176
+ clearFlow(chatId, flowId);
177
+ // Store the per-flow replace target after clearing, so subsequent sendOrReplace* calls
178
+ // (e.g. pagination, notify) can edit the message instead of sending a new one.
179
+ if (fromCallback && replyToMessageId !== undefined) replaceNextMessageByFlow.set(flowId, replyToMessageId);
180
+ pending.resolve(value); return { handled: true, promptMessageId: pending.promptMessageId };
181
+ },
182
+ isSensitiveInput(chatId, replyToMessageId) {
183
+ const map = pendingByChat.get(chatId); if (!map) return false;
184
+ if (replyToMessageId) {
185
+ const exact = [...map.values()].find((p) => p.acceptsText && p.promptMessageId === replyToMessageId);
186
+ return exact?.sensitive === true;
187
+ }
188
+ const latest = latestTextFlow.get(chatId);
189
+ return latest ? map.get(latest)?.sensitive === true : false;
190
+ },
191
+ hasPendingInput(chatId) {
192
+ return (pendingByChat.get(chatId)?.size ?? 0) > 0;
193
+ },
194
+ dispose() {
195
+ for (const map of pendingByChat.values()) {
196
+ for (const pending of map.values()) {
197
+ clearTimeout(pending.timer);
198
+ pending.resolve(undefined);
199
+ }
200
+ }
201
+ pendingByChat.clear();
202
+ replaceNextMessageByFlow.clear();
203
+ latestTextFlow.clear();
204
+ latestFlow.clear();
205
+ activeFlowByChat.clear();
206
+ },
207
+ };
208
+ }