diff --git a/bun.lock b/bun.lock index 1a0462a..5d9b51d 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "wilson", "dependencies": { - "@shetty4l/core": "^0.1.34", + "@shetty4l/core": "^0.1.35", }, "devDependencies": { "@biomejs/biome": "^2.4.2", @@ -73,7 +73,7 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.49.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VteIelt78kwzSglOozaQcs6BCS4Lk0j+QA+hGV0W8UeyaqQ3XpbZRhDU55NW1PPvCy1tg4VXsTlEaPovqto7nQ=="], - "@shetty4l/core": ["@shetty4l/core@0.1.34", "", { "bin": { "version-bump": "bin/version-bump.js" } }, "sha512-9WJe73ROAgFbVwCMM5GorEfA+SKhYIo5LAsCtgnVC2gGOabR8J+T7OsImTV1gs/qTy+PE0/ov/LwA2jO4Ad9dw=="], + "@shetty4l/core": ["@shetty4l/core@0.1.35", "", { "bin": { "version-bump": "bin/version-bump.js" } }, "sha512-gYNsZ98oWQXi0jiKgWecA9dVoK74aQat+r5WQETEsyrUhTk+DLFOTyqmq3xK4lCmGJQamAAUQq7Ew1yx5YcigA=="], "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], diff --git a/package.json b/package.json index 54f57eb..98075c6 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "prepare": "[ -d .git ] && husky || true" }, "dependencies": { - "@shetty4l/core": "^0.1.34" + "@shetty4l/core": "^0.1.35" }, "devDependencies": { "@biomejs/biome": "^2.4.2", diff --git a/src/channels/telegram/api.ts b/src/channels/telegram/api.ts index d97e058..41b997f 100644 --- a/src/channels/telegram/api.ts +++ b/src/channels/telegram/api.ts @@ -21,14 +21,43 @@ export interface TelegramMessage { message_thread_id?: number; } +export interface CallbackQuery { + id: string; + from: { + id: number; + first_name?: string; + username?: string; + }; + message?: { + message_id: number; + chat: { + id: number; + }; + message_thread_id?: number; + text?: string; + }; + data?: string; +} + export interface TelegramUpdate { update_id: number; message?: TelegramMessage; + callback_query?: CallbackQuery; +} + +export interface InlineKeyboardButton { + text: string; + callback_data: string; +} + +export interface InlineKeyboardMarkup { + inline_keyboard: InlineKeyboardButton[][]; } export interface SendMessageOptions { threadId?: number; parseMode?: string; + replyMarkup?: InlineKeyboardMarkup; } export class TelegramApiError extends Error { @@ -195,7 +224,7 @@ export async function getUpdates( ): Promise { const payload: Record = { timeout: timeoutSec, - allowed_updates: ["message"], + allowed_updates: ["message", "callback_query"], }; if (offset !== undefined) { payload.offset = offset; @@ -227,6 +256,9 @@ export async function sendMessage( if (opts.parseMode !== undefined) { payload.parse_mode = opts.parseMode; } + if (opts.replyMarkup !== undefined) { + payload.reply_markup = opts.replyMarkup; + } return callTelegramApi( botToken, @@ -235,3 +267,47 @@ export async function sendMessage( 15000, ); } + +export async function answerCallbackQuery( + botToken: string, + callbackQueryId: string, + text?: string, +): Promise { + const payload: Record = { + callback_query_id: callbackQueryId, + }; + if (text !== undefined) { + payload.text = text; + } + + return callTelegramApi( + botToken, + "answerCallbackQuery", + payload, + 15000, + ); +} + +export async function editMessageReplyMarkup( + botToken: string, + chatId: number, + messageId: number, + replyMarkup: InlineKeyboardMarkup | null, +): Promise { + const payload: Record = { + chat_id: chatId, + message_id: messageId, + }; + if (replyMarkup !== null) { + payload.reply_markup = replyMarkup; + } else { + payload.reply_markup = { inline_keyboard: [] }; + } + + return callTelegramApi( + botToken, + "editMessageReplyMarkup", + payload, + 15000, + ); +} diff --git a/src/channels/telegram/index.ts b/src/channels/telegram/index.ts index 56aee51..9f9a233 100644 --- a/src/channels/telegram/index.ts +++ b/src/channels/telegram/index.ts @@ -12,9 +12,18 @@ import { createLogger } from "@shetty4l/core/log"; import type { StateLoader } from "@shetty4l/core/state"; import type { TelegramChannelConfig } from "../../config"; import { TelegramChannelState } from "../../state/telegram"; +import { TelegramCallback } from "../../state/telegram-callback"; import type { CortexClient, OutboxMessage } from "../cortex-client"; import type { Channel, ChannelStats } from "../index"; -import { getUpdates, parseTelegramTopicKey, sendMessage } from "./api"; +import { + answerCallbackQuery, + type CallbackQuery, + editMessageReplyMarkup, + getUpdates, + type InlineKeyboardMarkup, + parseTelegramTopicKey, + sendMessage, +} from "./api"; import { chunkMarkdownV2 } from "./chunker"; import { formatForTelegram } from "./format"; @@ -161,6 +170,13 @@ export class TelegramChannel implements Channel { for (const update of updates) { if (!this.running) break; + // Handle callback queries (inline button clicks) + if (update.callback_query) { + await this.handleCallbackQuery(update.callback_query); + s.updateOffset = update.update_id + 1; + continue; + } + // Filter by allowed user IDs const userId = update.message?.from?.id; if (!userId || !this.config.allowedUserIds.includes(userId)) { @@ -218,6 +234,96 @@ export class TelegramChannel implements Channel { } } + private async handleCallbackQuery(query: CallbackQuery): Promise { + const s = this.state ?? this.memoryState; + + // Check for duplicate via exists() + if (this.stateLoader) { + const exists = await this.stateLoader.exists(TelegramCallback, query.id); + if (exists) { + log(`duplicate callback query ${query.id}, skipping`); + return; + } + } + + // Record callback for deduplication + if (this.stateLoader) { + const callback = this.stateLoader.load(TelegramCallback, query.id); + callback.callbackQueryId = query.id; + callback.chatId = query.message?.chat.id ?? 0; + callback.messageId = query.message?.message_id ?? 0; + callback.userId = query.from.id; + callback.data = query.data ?? ""; + callback.processedAt = new Date(); + await this.stateLoader.flush(); + } + + // Answer the callback to dismiss the loading spinner + try { + await answerCallbackQuery(this.config.botToken, query.id); + } catch (e) { + log(`failed to answer callback query ${query.id}: ${e}`); + // Continue processing - answering is not critical + } + + // Remove buttons from the message + if (query.message) { + try { + await editMessageReplyMarkup( + this.config.botToken, + query.message.chat.id, + query.message.message_id, + null, + ); + } catch (e) { + log(`failed to remove buttons from message: ${e}`); + // Continue processing - button removal is not critical + } + } + + // Filter by allowed user IDs + const userId = query.from.id; + if (!this.config.allowedUserIds.includes(userId)) { + log(`ignoring callback from unauthorized user: ${userId}`); + return; + } + + // Derive topic key + const chatId = query.message?.chat.id ?? 0; + const threadId = query.message?.message_thread_id; + const topicKey = threadId ? `${chatId}:${threadId}` : `${chatId}`; + + // Post to Cortex + const result = await this.cortex.receive({ + channel: "telegram", + externalId: `callback:${query.id}`, + data: { + type: "button_callback", + callbackData: query.data, + originalMessageId: query.message?.message_id, + originalMessageText: query.message?.text, + userId, + chatId, + threadId, + topicKey, + }, + occurredAt: new Date().toISOString(), + mode: "realtime", + metadata: { topicKey }, + }); + + if (result.ok) { + log(`posted callback ${query.id} to cortex (topic: ${topicKey})`); + s.lastPostAt = new Date(); + s.status = "healthy"; + s.error = null; + s.consecutiveFailures = 0; + } else { + log(`cortex error for callback ${query.id}: ${result.error}`); + throw new Error(`Cortex error: ${result.error}`); + } + } + // --- Delivery Loop --- private async runDeliveryLoop(): Promise { @@ -305,11 +411,25 @@ export class TelegramChannel implements Channel { const formatted = formatForTelegram(msg.text); const chunks = chunkMarkdownV2(formatted); - // Send each chunk - for (const chunk of chunks) { - await sendMessage(this.config.botToken, topic.chatId, chunk, { + // Build inline keyboard from payload.buttons if present + const buttons = msg.payload?.buttons as + | Array<{ label: string; data: string }> + | undefined; + const replyMarkup: InlineKeyboardMarkup | undefined = buttons?.length + ? { + inline_keyboard: [ + buttons.map((b) => ({ text: b.label, callback_data: b.data })), + ], + } + : undefined; + + // Send each chunk (only last chunk gets buttons) + for (let i = 0; i < chunks.length; i++) { + const isLastChunk = i === chunks.length - 1; + await sendMessage(this.config.botToken, topic.chatId, chunks[i], { threadId: topic.threadId, parseMode: "MarkdownV2", + replyMarkup: isLastChunk ? replyMarkup : undefined, }); } diff --git a/src/state/telegram-callback.ts b/src/state/telegram-callback.ts new file mode 100644 index 0000000..3c34a02 --- /dev/null +++ b/src/state/telegram-callback.ts @@ -0,0 +1,29 @@ +/** + * Persisted state for Telegram callback queries (inline button clicks). + * + * Used for deduplication — each callback is stored with its ID to prevent + * duplicate processing on retries or redeliveries. + */ + +import { Field, Persisted } from "@shetty4l/core/state"; + +@Persisted("telegram_callbacks") +export class TelegramCallback { + /** Unique callback query ID from Telegram. */ + @Field("string") callbackQueryId: string = ""; + + /** Chat ID where the callback originated. */ + @Field("number") chatId: number = 0; + + /** Message ID that contained the inline keyboard. */ + @Field("number") messageId: number = 0; + + /** User ID who clicked the button. */ + @Field("number") userId: number = 0; + + /** Callback data string from the button. */ + @Field("string") data: string = ""; + + /** When this callback was processed. */ + @Field("date") processedAt: Date | null = null; +} diff --git a/test/telegram-callbacks.test.ts b/test/telegram-callbacks.test.ts new file mode 100644 index 0000000..b4c44fd --- /dev/null +++ b/test/telegram-callbacks.test.ts @@ -0,0 +1,674 @@ +/** + * Tests for Telegram callback query (inline button) handling. + */ + +import { Database } from "bun:sqlite"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { ok, type Result } from "@shetty4l/core/result"; +import { StateLoader } from "@shetty4l/core/state"; +import type { + CortexClient, + OutboxMessage, + ReceivePayload, + ReceiveResponse, +} from "../src/channels/cortex-client"; +import * as telegramApi from "../src/channels/telegram/api"; +import { TelegramChannel } from "../src/channels/telegram/index"; +import type { TelegramChannelConfig } from "../src/config"; +import { TelegramCallback } from "../src/state/telegram-callback"; + +// --- Mock CortexClient --- + +interface MockCortexClient extends CortexClient { + receiveCalls: ReceivePayload[]; + pollCalls: { channel: string; opts?: Record }[]; + ackCalls: { messageId: string; leaseToken: string }[]; + pendingMessages: OutboxMessage[]; +} + +function makeMockCortex(): MockCortexClient { + const receiveCalls: ReceivePayload[] = []; + const pollCalls: { channel: string; opts?: Record }[] = []; + const ackCalls: { messageId: string; leaseToken: string }[] = []; + const pendingMessages: OutboxMessage[] = []; + + return { + receiveCalls, + pollCalls, + ackCalls, + pendingMessages, + receive: async ( + payload: ReceivePayload, + ): Promise> => { + receiveCalls.push(payload); + return ok({ eventId: "evt-1", status: "queued" as const }); + }, + pollOutbox: async (channel: string, opts?: Record) => { + pollCalls.push({ channel, opts }); + const msgs = [...pendingMessages]; + pendingMessages.length = 0; + return ok(msgs); + }, + ackOutbox: async (messageId: string, leaseToken: string) => { + ackCalls.push({ messageId, leaseToken }); + return ok(undefined); + }, + } as MockCortexClient; +} + +// --- Default config --- + +const DEFAULT_CONFIG: TelegramChannelConfig = { + enabled: true, + botToken: "test-bot-token", + allowedUserIds: [123456, 789012], + pollIntervalMs: 50, + deliveryMaxBatch: 5, + deliveryLeaseSeconds: 30, +}; + +// --- TelegramCallback State Tests --- + +describe("TelegramCallback state class", () => { + let db: Database; + + afterEach(() => { + if (db) { + db.close(); + } + }); + + test("initializes with defaults", () => { + db = new Database(":memory:"); + const loader = new StateLoader(db); + + const callback = loader.load(TelegramCallback, "test-callback-id"); + + expect(callback.callbackQueryId).toBe(""); + expect(callback.chatId).toBe(0); + expect(callback.messageId).toBe(0); + expect(callback.userId).toBe(0); + expect(callback.data).toBe(""); + expect(callback.processedAt).toBe(null); + }); + + test("persists and restores all fields", async () => { + db = new Database(":memory:"); + const loader = new StateLoader(db); + + // First load - set values + const callback1 = loader.load(TelegramCallback, "cb-123"); + callback1.callbackQueryId = "cb-123"; + callback1.chatId = 12345; + callback1.messageId = 67890; + callback1.userId = 111222; + callback1.data = "approve:request-1"; + callback1.processedAt = new Date("2026-02-24T10:00:00Z"); + await loader.flush(); + + // Second load - verify restored + const loader2 = new StateLoader(db); + const callback2 = loader2.load(TelegramCallback, "cb-123"); + expect(callback2.callbackQueryId).toBe("cb-123"); + expect(callback2.chatId).toBe(12345); + expect(callback2.messageId).toBe(67890); + expect(callback2.userId).toBe(111222); + expect(callback2.data).toBe("approve:request-1"); + expect(callback2.processedAt?.toISOString()).toBe( + "2026-02-24T10:00:00.000Z", + ); + }); + + test("exists() returns true for existing callback", async () => { + db = new Database(":memory:"); + const loader = new StateLoader(db); + + // Create a callback + const callback = loader.load(TelegramCallback, "cb-exists"); + callback.callbackQueryId = "cb-exists"; + callback.data = "test-data"; + await loader.flush(); + + // Check exists + const loader2 = new StateLoader(db); + const exists = await loader2.exists(TelegramCallback, "cb-exists"); + expect(exists).toBe(true); + }); + + test("exists() returns false for non-existing callback", async () => { + db = new Database(":memory:"); + const loader = new StateLoader(db); + + const exists = await loader.exists(TelegramCallback, "cb-does-not-exist"); + expect(exists).toBe(false); + }); +}); + +// --- Callback Query Handling Tests --- +// These tests verify callback query processing including deduplication, +// answering callbacks, removing buttons, and cortex payload structure. + +describe("TelegramChannel callback query handling", () => { + test("duplicate callback rejected when exists() returns true", async () => { + const db = new Database(":memory:"); + const loader = new StateLoader(db); + const cortex = makeMockCortex(); + + // Pre-create a callback to simulate duplicate + const existingCallback = loader.load(TelegramCallback, "dup-callback-123"); + existingCallback.callbackQueryId = "dup-callback-123"; + existingCallback.data = "already-processed"; + await loader.flush(); + + // Mock getUpdates to return a callback with the same ID + const mockUpdate: telegramApi.TelegramUpdate = { + update_id: 500, + callback_query: { + id: "dup-callback-123", + from: { id: 123456 }, + message: { + message_id: 100, + chat: { id: 123456 }, + }, + data: "approve:request-1", + }, + }; + + let getUpdatesCalls = 0; + const getUpdatesSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + getUpdatesCalls++; + if (getUpdatesCalls === 1) { + return [mockUpdate]; + } + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + const answerSpy = spyOn( + telegramApi, + "answerCallbackQuery", + ).mockImplementation(async () => true); + + const editSpy = spyOn( + telegramApi, + "editMessageReplyMarkup", + ).mockImplementation(async () => true); + + try { + const channel = new TelegramChannel(cortex, DEFAULT_CONFIG, loader); + await channel.start(); + await new Promise((r) => setTimeout(r, 150)); + await channel.stop(); + // Wait for async operations to settle + await new Promise((r) => setTimeout(r, 100)); + + // Cortex receive should NOT be called for duplicate + expect(cortex.receiveCalls.length).toBe(0); + } finally { + getUpdatesSpy.mockRestore(); + answerSpy.mockRestore(); + editSpy.mockRestore(); + // Wait for any pending async operations before closing db + await new Promise((r) => setTimeout(r, 100)); + db.close(); + } + }); + + test("answerCallbackQuery called with correct query ID", async () => { + const cortex = makeMockCortex(); + let answerCalledWithId = ""; + + const mockUpdate: telegramApi.TelegramUpdate = { + update_id: 600, + callback_query: { + id: "callback-answer-test-unique", + from: { id: 123456 }, + message: { + message_id: 200, + chat: { id: 123456 }, + }, + data: "test-data", + }, + }; + + let getUpdatesCalls = 0; + const getUpdatesSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + getUpdatesCalls++; + if (getUpdatesCalls === 1) { + return [mockUpdate]; + } + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + const answerSpy = spyOn( + telegramApi, + "answerCallbackQuery", + ).mockImplementation(async (_token, queryId) => { + answerCalledWithId = queryId; + return true; + }); + + const editSpy = spyOn( + telegramApi, + "editMessageReplyMarkup", + ).mockImplementation(async () => true); + + try { + const channel = new TelegramChannel(cortex, DEFAULT_CONFIG); + await channel.start(); + await new Promise((r) => setTimeout(r, 150)); + await channel.stop(); + + // Verify answerCallbackQuery was called with correct ID + expect(answerCalledWithId).toBe("callback-answer-test-unique"); + } finally { + getUpdatesSpy.mockRestore(); + answerSpy.mockRestore(); + editSpy.mockRestore(); + } + }); + + test("editMessageReplyMarkup called with null to remove buttons", async () => { + const cortex = makeMockCortex(); + let editCalledWith: { + chatId: number; + messageId: number; + markup: telegramApi.InlineKeyboardMarkup | null; + } | null = null; + + const mockUpdate: telegramApi.TelegramUpdate = { + update_id: 700, + callback_query: { + id: "callback-remove-buttons-unique", + from: { id: 123456 }, + message: { + message_id: 300, + chat: { id: 789456 }, + }, + data: "remove-test", + }, + }; + + let getUpdatesCalls = 0; + const getUpdatesSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + getUpdatesCalls++; + if (getUpdatesCalls === 1) { + return [mockUpdate]; + } + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + const answerSpy = spyOn( + telegramApi, + "answerCallbackQuery", + ).mockImplementation(async () => true); + + const editSpy = spyOn( + telegramApi, + "editMessageReplyMarkup", + ).mockImplementation(async (_token, chatId, messageId, markup) => { + editCalledWith = { chatId, messageId, markup }; + return true; + }); + + try { + const channel = new TelegramChannel(cortex, DEFAULT_CONFIG); + await channel.start(); + await new Promise((r) => setTimeout(r, 150)); + await channel.stop(); + + // Verify editMessageReplyMarkup was called with null + expect(editCalledWith).toBeTruthy(); + expect(editCalledWith!.chatId).toBe(789456); + expect(editCalledWith!.messageId).toBe(300); + expect(editCalledWith!.markup).toBe(null); + } finally { + getUpdatesSpy.mockRestore(); + answerSpy.mockRestore(); + editSpy.mockRestore(); + } + }); + + test("inbox payload has correct structure with type: button_callback", async () => { + const cortex = makeMockCortex(); + + const mockUpdate: telegramApi.TelegramUpdate = { + update_id: 800, + callback_query: { + id: "callback-payload-test-unique", + from: { id: 123456 }, + message: { + message_id: 400, + chat: { id: 123456 }, + message_thread_id: 789, + text: "Original message text", + }, + data: "approve:request-42", + }, + }; + + let getUpdatesCalls = 0; + const getUpdatesSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + getUpdatesCalls++; + if (getUpdatesCalls === 1) { + return [mockUpdate]; + } + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + const answerSpy = spyOn( + telegramApi, + "answerCallbackQuery", + ).mockImplementation(async () => true); + + const editSpy = spyOn( + telegramApi, + "editMessageReplyMarkup", + ).mockImplementation(async () => true); + + try { + const channel = new TelegramChannel(cortex, DEFAULT_CONFIG); + await channel.start(); + await new Promise((r) => setTimeout(r, 150)); + await channel.stop(); + + // Verify receive was called with correct payload + expect(cortex.receiveCalls.length).toBe(1); + const payload = cortex.receiveCalls[0]; + + expect(payload.channel).toBe("telegram"); + expect(payload.externalId).toBe("callback:callback-payload-test-unique"); + expect(payload.mode).toBe("realtime"); + + const data = payload.data as { + type: string; + callbackData: string; + originalMessageId: number; + originalMessageText: string; + userId: number; + chatId: number; + threadId: number; + topicKey: string; + }; + + expect(data.type).toBe("button_callback"); + expect(data.callbackData).toBe("approve:request-42"); + expect(data.originalMessageId).toBe(400); + expect(data.originalMessageText).toBe("Original message text"); + expect(data.userId).toBe(123456); + expect(data.chatId).toBe(123456); + expect(data.threadId).toBe(789); + expect(data.topicKey).toBe("123456:789"); + } finally { + getUpdatesSpy.mockRestore(); + answerSpy.mockRestore(); + editSpy.mockRestore(); + } + }); + + test("filters unauthorized users for callback queries", async () => { + const cortex = makeMockCortex(); + let answerCalled = false; + let editCalled = false; + + const mockUpdate: telegramApi.TelegramUpdate = { + update_id: 850, + callback_query: { + id: "callback-unauth-unique", + from: { id: 999999 }, // Not in allowedUserIds + message: { + message_id: 450, + chat: { id: 999999 }, + }, + data: "unauthorized-data", + }, + }; + + let getUpdatesCalls = 0; + const getUpdatesSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + getUpdatesCalls++; + if (getUpdatesCalls === 1) { + return [mockUpdate]; + } + await new Promise((r) => setTimeout(r, 100)); + return []; + }, + ); + + const answerSpy = spyOn( + telegramApi, + "answerCallbackQuery", + ).mockImplementation(async () => { + answerCalled = true; + return true; + }); + + const editSpy = spyOn( + telegramApi, + "editMessageReplyMarkup", + ).mockImplementation(async () => { + editCalled = true; + return true; + }); + + try { + const channel = new TelegramChannel(cortex, DEFAULT_CONFIG); + await channel.start(); + await new Promise((r) => setTimeout(r, 150)); + await channel.stop(); + + // answerCallbackQuery should still be called (to dismiss loading) + expect(answerCalled).toBe(true); + // editMessageReplyMarkup should still be called (to remove buttons) + expect(editCalled).toBe(true); + // But cortex receive should NOT be called for unauthorized users + expect(cortex.receiveCalls.length).toBe(0); + } finally { + getUpdatesSpy.mockRestore(); + answerSpy.mockRestore(); + editSpy.mockRestore(); + } + }); +}); + +// --- Button Transformation Tests --- +// These tests verify that buttons in outbox messages are correctly +// transformed to Telegram's inline_keyboard format. + +describe("TelegramChannel button delivery", () => { + test("buttons array transforms to inline_keyboard correctly", async () => { + const cortex = makeMockCortex(); + let receivedReplyMarkup: telegramApi.InlineKeyboardMarkup | undefined; + + // Add a message with buttons to the outbox + cortex.pendingMessages.push({ + messageId: "msg-with-buttons", + topicKey: "123456:789", + text: "Do you approve?", + leaseToken: "lease-1", + payload: { + buttons: [ + { label: "Approve", data: "approve:req-1" }, + { label: "Reject", data: "reject:req-1" }, + ], + }, + }); + + const sendMessageSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async ( + _token, + _chatId, + _text, + opts, + ): Promise => { + receivedReplyMarkup = opts?.replyMarkup; + return { + message_id: 42, + date: Math.floor(Date.now() / 1000), + chat: { id: 123456 }, + text: "Do you approve?", + }; + }, + ); + + const getUpdatesSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 50)); + return []; + }, + ); + + try { + const channel = new TelegramChannel(cortex, { + ...DEFAULT_CONFIG, + pollIntervalMs: 10, + }); + await channel.start(); + await new Promise((r) => setTimeout(r, 200)); + await channel.stop(); + + // Verify sendMessage was called with correct reply_markup + expect(receivedReplyMarkup).toEqual({ + inline_keyboard: [ + [ + { text: "Approve", callback_data: "approve:req-1" }, + { text: "Reject", callback_data: "reject:req-1" }, + ], + ], + }); + } finally { + sendMessageSpy.mockRestore(); + getUpdatesSpy.mockRestore(); + } + }); + + test("messages without buttons have no reply_markup", async () => { + const cortex = makeMockCortex(); + let receivedReplyMarkup: telegramApi.InlineKeyboardMarkup | undefined = + undefined; + let sendMessageCalled = false; + + // Add a message without buttons + cortex.pendingMessages.push({ + messageId: "msg-no-buttons", + topicKey: "123456", + text: "Simple message", + leaseToken: "lease-2", + payload: null, + }); + + const sendMessageSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async ( + _token, + _chatId, + _text, + opts, + ): Promise => { + sendMessageCalled = true; + receivedReplyMarkup = opts?.replyMarkup; + return { + message_id: 43, + date: Math.floor(Date.now() / 1000), + chat: { id: 123456 }, + text: "Simple message", + }; + }, + ); + + const getUpdatesSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 50)); + return []; + }, + ); + + try { + const channel = new TelegramChannel(cortex, { + ...DEFAULT_CONFIG, + pollIntervalMs: 10, + }); + await channel.start(); + await new Promise((r) => setTimeout(r, 200)); + await channel.stop(); + + // Verify sendMessage was called without reply_markup + expect(sendMessageCalled).toBe(true); + expect(receivedReplyMarkup).toBeUndefined(); + } finally { + sendMessageSpy.mockRestore(); + getUpdatesSpy.mockRestore(); + } + }); + + test("empty buttons array results in no reply_markup", async () => { + const cortex = makeMockCortex(); + let receivedReplyMarkup: telegramApi.InlineKeyboardMarkup | undefined = + undefined; + let sendMessageCalled = false; + + // Add a message with empty buttons array + cortex.pendingMessages.push({ + messageId: "msg-empty-buttons", + topicKey: "123456", + text: "Message with empty buttons", + leaseToken: "lease-3", + payload: { + buttons: [], + }, + }); + + const sendMessageSpy = spyOn(telegramApi, "sendMessage").mockImplementation( + async ( + _token, + _chatId, + _text, + opts, + ): Promise => { + sendMessageCalled = true; + receivedReplyMarkup = opts?.replyMarkup; + return { + message_id: 44, + date: Math.floor(Date.now() / 1000), + chat: { id: 123456 }, + text: "Message with empty buttons", + }; + }, + ); + + const getUpdatesSpy = spyOn(telegramApi, "getUpdates").mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 50)); + return []; + }, + ); + + try { + const channel = new TelegramChannel(cortex, { + ...DEFAULT_CONFIG, + pollIntervalMs: 10, + }); + await channel.start(); + await new Promise((r) => setTimeout(r, 200)); + await channel.stop(); + + // Verify sendMessage was called without reply_markup (empty array = no markup) + expect(sendMessageCalled).toBe(true); + expect(receivedReplyMarkup).toBeUndefined(); + } finally { + sendMessageSpy.mockRestore(); + getUpdatesSpy.mockRestore(); + } + }); +});