diff --git a/extensions/telegram/miniapp-api.ts b/extensions/telegram/miniapp-api.ts index add54e852dd8..eeb682db76c3 100644 --- a/extensions/telegram/miniapp-api.ts +++ b/extensions/telegram/miniapp-api.ts @@ -1,9 +1,10 @@ -// Telegram Mini App registerFull entrypoint. import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { registerTelegramMiniAppCommand } from "./src/miniapp/command.js"; +import { createTelegramMiniAppLaunchTickets } from "./src/miniapp/launch-ticket.js"; import { registerTelegramMiniAppRoutes } from "./src/miniapp/routes.js"; export function registerTelegramMiniApp(api: OpenClawPluginApi): void { - registerTelegramMiniAppRoutes(api); - registerTelegramMiniAppCommand(api); + const launchTickets = createTelegramMiniAppLaunchTickets(); + registerTelegramMiniAppRoutes(api, launchTickets); + registerTelegramMiniAppCommand(api, launchTickets); } diff --git a/extensions/telegram/src/miniapp/command.test.ts b/extensions/telegram/src/miniapp/command.test.ts index 292f65097b26..ef47db749f03 100644 --- a/extensions/telegram/src/miniapp/command.test.ts +++ b/extensions/telegram/src/miniapp/command.test.ts @@ -4,9 +4,13 @@ import type { PluginCommandContext, } from "openclaw/plugin-sdk/plugin-entry"; import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const resolveTelegramMiniAppUrls = vi.hoisted(() => vi.fn()); +const launchTickets = { + issue: vi.fn(() => "launch-ticket"), + consume: vi.fn(() => false), +}; vi.mock("./url.js", async (importOriginal) => ({ ...(await importOriginal()), @@ -19,10 +23,13 @@ function registerDashboardCommand( api: Parameters[0], ): OpenClawPluginCommandDefinition { const commands: OpenClawPluginCommandDefinition[] = []; - registerTelegramMiniAppCommand({ - ...api, - registerCommand: (command) => commands.push(command), - }); + registerTelegramMiniAppCommand( + { + ...api, + registerCommand: (command) => commands.push(command), + }, + launchTickets, + ); return expectDefined(commands[0], "registered Telegram dashboard command"); } @@ -40,6 +47,10 @@ function commandContext(overrides: Partial): PluginCommand } describe("registerTelegramMiniAppCommand", () => { + beforeEach(() => { + launchTickets.issue.mockClear(); + }); + it("returns a DM-only message for group invocations", async () => { const command = registerDashboardCommand( createTestPluginApi({ @@ -65,6 +76,7 @@ describe("registerTelegramMiniAppCommand", () => { ), ).resolves.toEqual({ text: "open this in a DM with the bot" }); expect(resolveTelegramMiniAppUrls).not.toHaveBeenCalled(); + expect(launchTickets.issue).not.toHaveBeenCalled(); }); it("returns a web app button for owner DM invocations", async () => { @@ -104,11 +116,12 @@ describe("registerTelegramMiniAppCommand", () => { { label: "Open dashboard", webApp: { - url: "https://host.tailnet.ts.net/__openclaw_tg_miniapp/?accountId=ops", + url: "https://host.tailnet.ts.net/__openclaw_tg_miniapp/?accountId=ops#launchTicket=launch-ticket", }, }, ], }, ]); + expect(launchTickets.issue).toHaveBeenCalledWith({ accountId: "ops", userId: "123" }); }); }); diff --git a/extensions/telegram/src/miniapp/command.ts b/extensions/telegram/src/miniapp/command.ts index 506811892dc3..43a157c98759 100644 --- a/extensions/telegram/src/miniapp/command.ts +++ b/extensions/telegram/src/miniapp/command.ts @@ -1,4 +1,3 @@ -// Telegram Mini App /dashboard command. import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { @@ -6,15 +5,20 @@ import type { OpenClawPluginCommandDefinition, PluginCommandContext, } from "openclaw/plugin-sdk/plugin-entry"; +import type { TelegramMiniAppLaunchTickets } from "./launch-ticket.js"; import { isTelegramMiniAppOwner } from "./owner.js"; import { resolveTelegramMiniAppUrls, TELEGRAM_MINIAPP_URL_ERROR } from "./url.js"; -export function registerTelegramMiniAppCommand(api: OpenClawPluginApi): void { - api.registerCommand(createTelegramMiniAppDashboardCommand(api)); +export function registerTelegramMiniAppCommand( + api: OpenClawPluginApi, + launchTickets: TelegramMiniAppLaunchTickets, +): void { + api.registerCommand(createTelegramMiniAppDashboardCommand(api, launchTickets)); } function createTelegramMiniAppDashboardCommand( api: OpenClawPluginApi, + launchTickets: TelegramMiniAppLaunchTickets, ): OpenClawPluginCommandDefinition { return { name: "dashboard", @@ -39,6 +43,9 @@ function createTelegramMiniAppDashboardCommand( return { text: TELEGRAM_MINIAPP_URL_ERROR }; } pageUrl.searchParams.set("accountId", accountId); + pageUrl.hash = new URLSearchParams({ + launchTicket: launchTickets.issue({ accountId, userId }), + }).toString(); return { text: "Open OpenClaw dashboard.", presentation: { @@ -59,7 +66,6 @@ function currentConfig(api: OpenClawPluginApi): OpenClawConfig { } function isTelegramDirectCommand(ctx: PluginCommandContext): boolean { - // Parses OpenClaw's canonical telegram: / telegram:group: from/sessionKey encoding. // DM-only because Telegram permits web_app inline buttons only in private chats. const from = ctx.from?.trim() ?? ""; const sessionKey = ctx.sessionKey?.trim() ?? ""; diff --git a/extensions/telegram/src/miniapp/launch-ticket.test.ts b/extensions/telegram/src/miniapp/launch-ticket.test.ts new file mode 100644 index 000000000000..23772a882af5 --- /dev/null +++ b/extensions/telegram/src/miniapp/launch-ticket.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createTelegramMiniAppLaunchTickets } from "./launch-ticket.js"; + +describe("Telegram Mini App launch tickets", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("binds one use to the issuing account and owner", () => { + const tickets = createTelegramMiniAppLaunchTickets(); + const ticket = tickets.issue({ accountId: "ops", userId: "123" }); + + expect(tickets.consume({ ticket, accountId: "default", userId: "123" })).toBe(false); + expect(tickets.consume({ ticket, accountId: "ops", userId: "999" })).toBe(false); + expect(tickets.consume({ ticket, accountId: "ops", userId: "123" })).toBe(true); + expect(tickets.consume({ ticket, accountId: "ops", userId: "123" })).toBe(false); + }); + + it("expires after five minutes", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-19T12:00:00Z")); + const tickets = createTelegramMiniAppLaunchTickets(); + const ticket = tickets.issue({ accountId: "default", userId: "123" }); + + vi.advanceTimersByTime(5 * 60_000); + + expect(tickets.consume({ ticket, accountId: "default", userId: "123" })).toBe(false); + }); + + it("evicts the oldest ticket at the capacity limit", () => { + const tickets = createTelegramMiniAppLaunchTickets(); + const oldest = tickets.issue({ accountId: "default", userId: "123" }); + for (let index = 0; index < 1000; index += 1) { + tickets.issue({ accountId: "default", userId: "123" }); + } + + expect(tickets.consume({ ticket: oldest, accountId: "default", userId: "123" })).toBe(false); + }); + + it("invalidates tickets when the plugin lifecycle restarts", () => { + const firstLifecycle = createTelegramMiniAppLaunchTickets(); + const ticket = firstLifecycle.issue({ accountId: "default", userId: "123" }); + const nextLifecycle = createTelegramMiniAppLaunchTickets(); + + expect(nextLifecycle.consume({ ticket, accountId: "default", userId: "123" })).toBe(false); + }); +}); diff --git a/extensions/telegram/src/miniapp/launch-ticket.ts b/extensions/telegram/src/miniapp/launch-ticket.ts new file mode 100644 index 000000000000..6ee47484cbfd --- /dev/null +++ b/extensions/telegram/src/miniapp/launch-ticket.ts @@ -0,0 +1,57 @@ +import crypto from "node:crypto"; + +const LAUNCH_TICKET_TTL_MS = 5 * 60_000; +const LAUNCH_TICKET_LIMIT = 1000; + +type LaunchTicket = { + accountId: string; + userId: string; + expiresAtMs: number; +}; + +export type TelegramMiniAppLaunchTickets = { + issue: (params: { accountId: string; userId: string }) => string; + consume: (params: { ticket: string; accountId: string; userId: string }) => boolean; +}; + +export function createTelegramMiniAppLaunchTickets(): TelegramMiniAppLaunchTickets { + const tickets = new Map(); + + function prune(): void { + const now = Date.now(); + for (const [ticket, launch] of tickets) { + if (launch.expiresAtMs <= now) { + tickets.delete(ticket); + } + } + } + + return { + issue({ accountId, userId }) { + prune(); + const ticket = crypto.randomBytes(32).toString("base64url"); + tickets.set(ticket, { + accountId, + userId, + expiresAtMs: Date.now() + LAUNCH_TICKET_TTL_MS, + }); + while (tickets.size > LAUNCH_TICKET_LIMIT) { + const oldest = tickets.keys().next().value; + if (!oldest) { + break; + } + tickets.delete(oldest); + } + return ticket; + }, + consume({ ticket, accountId, userId }) { + prune(); + const launch = tickets.get(ticket); + if (!launch || launch.accountId !== accountId || launch.userId !== userId) { + return false; + } + tickets.delete(ticket); + return true; + }, + }; +} diff --git a/extensions/telegram/src/miniapp/page.auth-timeout.test.ts b/extensions/telegram/src/miniapp/page.auth-timeout.test.ts index a727669feb59..2b01d517c9a9 100644 --- a/extensions/telegram/src/miniapp/page.auth-timeout.test.ts +++ b/extensions/telegram/src/miniapp/page.auth-timeout.test.ts @@ -3,12 +3,13 @@ import { describe, expect, it, vi } from "vitest"; import { renderTelegramMiniAppPage, TELEGRAM_MINIAPP_EXPIRED_MESSAGE } from "./page.js"; -describe("telegram miniapp auth timeout", () => { +describe("telegram miniapp page bootstrap", () => { it("executes the generated page and expires a hung auth request", async () => { let scheduledTimeout: { callback: () => void; delayMs: number; id: number } | undefined; const clearTimeoutSpy = vi.fn(); const ready = vi.fn(); const fetchMock = vi.fn(); + const location = { hash: "#launchTicket=launch-ticket", replace: vi.fn() }; const rendered = new DOMParser().parseFromString( renderTelegramMiniAppPage({ accountId: "ops", scriptNonce: "test-nonce" }), @@ -47,14 +48,20 @@ describe("telegram miniapp auth timeout", () => { "setTimeout", "clearTimeout", "fetch", + "location", bootstrap, - )(window, document, AbortController, scheduleTimeout, clearTimeoutSpy, fetchMock); + )(window, document, AbortController, scheduleTimeout, clearTimeoutSpy, fetchMock, location); expect(ready).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledWith( "auth", expect.objectContaining({ method: "POST", + body: JSON.stringify({ + initData: "signed-init-data", + accountId: "ops", + launchTicket: "launch-ticket", + }), credentials: "same-origin", signal: expect.any(AbortSignal), }), @@ -74,4 +81,54 @@ describe("telegram miniapp auth timeout", () => { document.body.replaceChildren(); } }); + + it("redirects with the authenticated handoff", async () => { + const ready = vi.fn(); + const location = { hash: "#launchTicket=launch-ticket", replace: vi.fn() }; + const rendered = new DOMParser().parseFromString( + renderTelegramMiniAppPage({ accountId: "ops", scriptNonce: "test-nonce" }), + "text/html", + ); + const bootstrap = rendered.querySelector("script:not([src])")?.textContent; + if (!bootstrap) { + throw new Error("generated Mini App page is missing its bootstrap script"); + } + document.body.innerHTML = rendered.body.innerHTML; + Object.defineProperty(window, "Telegram", { + configurable: true, + value: { WebApp: { initData: "signed-init-data", ready } }, + }); + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ + bootstrapToken: "bootstrap-token", + controlUiUrl: "https://host.tailnet.ts.net/openclaw", + gatewayUrl: "wss://host.tailnet.ts.net", + }), + })); + + try { + // oxlint-disable-next-line typescript/no-implied-eval -- Execute the generated bootstrap itself so the test cannot drift into a reimplementation. + new Function( + "window", + "document", + "AbortController", + "setTimeout", + "clearTimeout", + "fetch", + "location", + bootstrap, + )(window, document, AbortController, setTimeout, clearTimeout, fetchMock, location); + + await vi.waitFor(() => { + expect(location.replace).toHaveBeenCalledWith( + "https://host.tailnet.ts.net/openclaw#gatewayUrl=wss%3A%2F%2Fhost.tailnet.ts.net&bootstrapToken=bootstrap-token", + ); + }); + expect(ready).toHaveBeenCalledTimes(1); + } finally { + Reflect.deleteProperty(window, "Telegram"); + document.body.replaceChildren(); + } + }); }); diff --git a/extensions/telegram/src/miniapp/page.test.ts b/extensions/telegram/src/miniapp/page.test.ts index c8e944985c6b..479cdbf1418d 100644 --- a/extensions/telegram/src/miniapp/page.test.ts +++ b/extensions/telegram/src/miniapp/page.test.ts @@ -6,6 +6,7 @@ describe("renderTelegramMiniAppPage", () => { const html = renderTelegramMiniAppPage({ accountId: "ops", scriptNonce: "nonce" }); expect(html).toContain('const accountId = "ops";'); + expect(html).toContain('new URLSearchParams(location.hash.slice(1)).get("launchTicket")'); expect(html).toContain("new URL(payload.controlUiUrl)"); expect(html).not.toContain("const controlUiUrl ="); }); diff --git a/extensions/telegram/src/miniapp/page.ts b/extensions/telegram/src/miniapp/page.ts index db43598a797e..3f4d9e0a1999 100644 --- a/extensions/telegram/src/miniapp/page.ts +++ b/extensions/telegram/src/miniapp/page.ts @@ -1,4 +1,3 @@ -// Telegram Mini App bootstrap page. import { escapeHtml } from "openclaw/plugin-sdk/text-utility-runtime"; export const TELEGRAM_MINIAPP_EXPIRED_MESSAGE = @@ -35,13 +34,14 @@ export function renderTelegramMiniAppPage(params: {