feat(telegram): add dashboard mini app command

This commit is contained in:
Ayaan Zaidi
2026-07-10 07:53:37 +05:30
parent 9cca9afa70
commit 3c99adb9ca
13 changed files with 1070 additions and 0 deletions
+2
View File
@@ -1,5 +1,6 @@
// Telegram plugin entrypoint registers its OpenClaw integration.
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
import { registerTelegramMiniApp } from "./miniapp-api.js";
export default defineBundledChannelEntry({
id: "telegram",
@@ -22,4 +23,5 @@ export default defineBundledChannelEntry({
specifier: "./account-inspect-api.js",
exportName: "inspectTelegramReadOnlyAccount",
},
registerFull: registerTelegramMiniApp,
});
+9
View File
@@ -0,0 +1,9 @@
// Telegram Mini App registerFull entrypoint.
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { registerTelegramMiniAppCommand } from "./src/miniapp/command.js";
import { registerTelegramMiniAppRoutes } from "./src/miniapp/routes.js";
export function registerTelegramMiniApp(api: OpenClawPluginApi): void {
registerTelegramMiniAppRoutes(api);
registerTelegramMiniAppCommand(api);
}
@@ -0,0 +1,99 @@
import type { PluginCommandContext } from "openclaw/plugin-sdk/plugin-entry";
import { describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../../../src/plugin-sdk/plugin-test-api.js";
const resolveTelegramMiniAppUrls = vi.hoisted(() => vi.fn());
vi.mock("./url.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./url.js")>()),
resolveTelegramMiniAppUrls,
}));
const { createTelegramMiniAppDashboardCommand } = await import("./command.js");
function commandContext(overrides: Partial<PluginCommandContext>): PluginCommandContext {
return {
channel: "telegram",
isAuthorizedSender: true,
commandBody: "/dashboard",
config: {},
requestConversationBinding: async () => ({ status: "error", message: "unused" }),
detachConversationBinding: async () => ({ removed: false }),
getCurrentConversationBinding: async () => null,
...overrides,
};
}
describe("createTelegramMiniAppDashboardCommand", () => {
it("returns a DM-only message for group invocations", async () => {
const command = createTelegramMiniAppDashboardCommand(
createTestPluginApi({
config: {
channels: {
telegram: {
accounts: {
ops: { allowFrom: ["123"] },
},
},
},
},
}),
);
await expect(
command.handler(
commandContext({
from: "telegram:group:-100",
sessionKey: "telegram:group:-100",
senderIsOwner: true,
}),
),
).resolves.toEqual({ text: "open this in a DM with the bot" });
expect(resolveTelegramMiniAppUrls).not.toHaveBeenCalled();
});
it("returns a web app button for owner DM invocations", async () => {
resolveTelegramMiniAppUrls.mockResolvedValue({
pageUrl: "https://host.tailnet.ts.net/__openclaw_tg_miniapp/",
controlUiUrl: "https://host.tailnet.ts.net/openclaw",
gatewayUrl: "wss://host.tailnet.ts.net/openclaw",
});
const command = createTelegramMiniAppDashboardCommand(
createTestPluginApi({
config: {
channels: {
telegram: {
accounts: {
ops: { allowFrom: ["123"] },
},
},
},
},
}),
);
const result = await command.handler(
commandContext({
from: "telegram:123",
sessionKey: "telegram:direct:123",
senderIsOwner: false,
accountId: "ops",
}),
);
expect(result.text).toBe("Open OpenClaw dashboard.");
expect(result.presentation?.blocks).toEqual([
{
type: "buttons",
buttons: [
{
label: "Open dashboard",
webApp: {
url: "https://host.tailnet.ts.net/__openclaw_tg_miniapp/?accountId=ops",
},
},
],
},
]);
});
});
@@ -0,0 +1,76 @@
// 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 {
OpenClawPluginApi,
OpenClawPluginCommandDefinition,
PluginCommandContext,
} from "openclaw/plugin-sdk/plugin-entry";
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 createTelegramMiniAppDashboardCommand(
api: OpenClawPluginApi,
): OpenClawPluginCommandDefinition {
return {
name: "dashboard",
description: "Open the OpenClaw dashboard",
channels: ["telegram"],
requireAuth: true,
exposeSenderIsOwner: true,
handler: async (ctx) => {
if (!isTelegramDirectCommand(ctx)) {
return { text: "open this in a DM with the bot" };
}
const cfg = currentConfig(api);
const accountId = normalizeAccountId(ctx.accountId ?? DEFAULT_ACCOUNT_ID);
const userId = resolveTelegramDirectUserId(ctx);
if (!(await isTelegramMiniAppOwner({ cfg, accountId, userId }))) {
return { text: "Restricted to the bot owner." };
}
let pageUrl: URL;
try {
pageUrl = new URL((await resolveTelegramMiniAppUrls({ cfg })).pageUrl);
} catch {
return { text: TELEGRAM_MINIAPP_URL_ERROR };
}
pageUrl.searchParams.set("accountId", accountId);
return {
text: "Open OpenClaw dashboard.",
presentation: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Open dashboard", webApp: { url: pageUrl.toString() } }],
},
],
},
};
},
};
}
function currentConfig(api: OpenClawPluginApi): OpenClawConfig {
return (api.runtime.config?.current?.() ?? api.config) as OpenClawConfig;
}
function isTelegramDirectCommand(ctx: PluginCommandContext): boolean {
const from = ctx.from?.trim() ?? "";
const sessionKey = ctx.sessionKey?.trim() ?? "";
if (from.startsWith("telegram:group:") || sessionKey.includes(":telegram:group:")) {
return false;
}
return /^telegram:\d+$/.test(from) || sessionKey.includes(":telegram:direct:");
}
function resolveTelegramDirectUserId(ctx: PluginCommandContext): string {
const senderId = ctx.senderId?.trim() ?? "";
if (/^\d+$/.test(senderId)) {
return senderId;
}
return /^telegram:(\d+)$/.exec(ctx.from?.trim() ?? "")?.[1] ?? "";
}
@@ -0,0 +1,69 @@
import crypto from "node:crypto";
import { describe, expect, it } from "vitest";
import { validateTelegramMiniAppInitData } from "./init-data.js";
const BOT_TOKEN = "fixture";
const AUTH_DATE = 1_800_000_000;
const INIT_DATA = signedInitData({});
describe("validateTelegramMiniAppInitData", () => {
it("accepts a signed Telegram init-data fixture", () => {
expect(
validateTelegramMiniAppInitData({
initData: INIT_DATA,
botToken: BOT_TOKEN,
nowMs: AUTH_DATE * 1000 + 10_000,
}),
).toEqual({
hash: fixtureHash(INIT_DATA),
authDateMs: AUTH_DATE * 1000,
userId: "123456",
});
});
it("rejects tampered, expired, and missing-user init data", () => {
expect(
validateTelegramMiniAppInitData({
initData: INIT_DATA.replace("Ayaan", "Mallory"),
botToken: BOT_TOKEN,
nowMs: AUTH_DATE * 1000 + 10_000,
}),
).toBeNull();
expect(
validateTelegramMiniAppInitData({
initData: INIT_DATA,
botToken: BOT_TOKEN,
nowMs: AUTH_DATE * 1000 + 301_000,
}),
).toBeNull();
expect(
validateTelegramMiniAppInitData({
initData: signedInitData({ user: "" }),
botToken: BOT_TOKEN,
nowMs: AUTH_DATE * 1000,
}),
).toBeNull();
});
});
function signedInitData(overrides: Record<string, string>): string {
const params = new URLSearchParams({
auth_date: String(AUTH_DATE),
query_id: "AAE-test",
user: JSON.stringify({ id: 123456, first_name: "Ayaan" }),
...overrides,
});
const entries = [...params.entries()].map(([key, value]) => `${key}=${value}`).toSorted();
const secret = crypto.createHmac("sha256", "WebAppData").update(BOT_TOKEN).digest();
const hash = crypto.createHmac("sha256", secret).update(entries.join("\n")).digest("hex");
params.set("hash", hash);
return params.toString();
}
function fixtureHash(initData: string): string {
const hash = new URLSearchParams(initData).get("hash");
if (!hash) {
throw new Error("expected fixture hash");
}
return hash;
}
@@ -0,0 +1,77 @@
// Telegram Mini App init-data validation.
import crypto from "node:crypto";
const INIT_DATA_MAX_AGE_MS = 300_000;
export type TelegramMiniAppInitData = {
hash: string;
authDateMs: number;
userId: string;
};
export function validateTelegramMiniAppInitData(params: {
initData: string;
botToken: string;
nowMs?: number;
}): TelegramMiniAppInitData | null {
const initData = params.initData.trim();
const botToken = params.botToken.trim();
if (!initData || !botToken) {
return null;
}
const parsed = new URLSearchParams(initData);
const receivedHash = parsed.get("hash")?.trim() ?? "";
const authDateRaw = parsed.get("auth_date")?.trim() ?? "";
const userRaw = parsed.get("user")?.trim() ?? "";
if (!receivedHash || !authDateRaw || !userRaw) {
return null;
}
const authDateSeconds = Number(authDateRaw);
if (!Number.isInteger(authDateSeconds) || authDateSeconds <= 0) {
return null;
}
const authDateMs = authDateSeconds * 1000;
const ageMs = (params.nowMs ?? Date.now()) - authDateMs;
if (ageMs < 0 || ageMs > INIT_DATA_MAX_AGE_MS) {
return null;
}
const entries = [...parsed.entries()]
.filter(([key]) => key !== "hash")
.map(([key, value]) => `${key}=${value}`)
.toSorted();
const secret = crypto.createHmac("sha256", "WebAppData").update(botToken).digest();
const computedHash = crypto.createHmac("sha256", secret).update(entries.join("\n")).digest("hex");
if (!timingSafeHexEqual(computedHash, receivedHash)) {
return null;
}
const user = parseTelegramMiniAppUser(userRaw);
if (!user?.id || !/^\d+$/.test(user.id)) {
return null;
}
return { hash: receivedHash, authDateMs, userId: user.id };
}
function parseTelegramMiniAppUser(raw: string): { id: string } | null {
try {
const parsed = JSON.parse(raw) as { id?: unknown };
if (typeof parsed.id === "number" && Number.isSafeInteger(parsed.id) && parsed.id > 0) {
return { id: String(parsed.id) };
}
return typeof parsed.id === "string" && /^\d+$/.test(parsed.id) ? { id: parsed.id } : null;
} catch {
return null;
}
}
function timingSafeHexEqual(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left, "hex");
const rightBuffer = Buffer.from(right, "hex");
if (leftBuffer.length !== rightBuffer.length) {
return false;
}
return crypto.timingSafeEqual(leftBuffer, rightBuffer);
}
@@ -0,0 +1,43 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { isTelegramMiniAppOwner } from "./owner.js";
describe("isTelegramMiniAppOwner", () => {
it("accepts numeric owners from account allowFrom and commands.ownerAllowFrom", async () => {
const cfg = {
commands: { ownerAllowFrom: ["telegram:200"] },
channels: {
telegram: {
allowFrom: ["100"],
accounts: {
ops: { allowFrom: ["300"] },
},
},
},
} satisfies OpenClawConfig;
await expect(
isTelegramMiniAppOwner({ cfg, accountId: "default", userId: "100" }),
).resolves.toBe(true);
await expect(isTelegramMiniAppOwner({ cfg, accountId: "ops", userId: "300" })).resolves.toBe(
true,
);
await expect(isTelegramMiniAppOwner({ cfg, accountId: "ops", userId: "200" })).resolves.toBe(
true,
);
});
it("rejects non-numeric and wildcard owners", async () => {
const cfg = {
commands: { ownerAllowFrom: ["*", "telegram:owner"] },
channels: { telegram: { allowFrom: ["*"] } },
} satisfies OpenClawConfig;
await expect(
isTelegramMiniAppOwner({ cfg, accountId: "default", userId: "100" }),
).resolves.toBe(false);
await expect(
isTelegramMiniAppOwner({ cfg, accountId: "default", userId: "-100" }),
).resolves.toBe(false);
});
});
+25
View File
@@ -0,0 +1,25 @@
// Telegram Mini App owner checks.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { expandTelegramAllowFromWithAccessGroups } from "../access-groups.js";
import { mergeTelegramAccountConfig } from "../accounts.js";
import { isNumericTelegramSenderUserId, normalizeTelegramAllowFromEntry } from "../allow-from.js";
export async function isTelegramMiniAppOwner(params: {
cfg: OpenClawConfig;
accountId: string;
userId: string;
}): Promise<boolean> {
const userId = params.userId.trim();
if (!isNumericTelegramSenderUserId(userId)) {
return false;
}
const account = mergeTelegramAccountConfig(params.cfg, params.accountId);
const allowFrom = [...(account.allowFrom ?? []), ...(params.cfg.commands?.ownerAllowFrom ?? [])];
const expanded = await expandTelegramAllowFromWithAccessGroups({
cfg: params.cfg,
accountId: params.accountId,
allowFrom,
senderId: userId,
});
return expanded.some((entry) => normalizeTelegramAllowFromEntry(entry) === userId);
}
+84
View File
@@ -0,0 +1,84 @@
// Telegram Mini App bootstrap page.
export const TELEGRAM_MINIAPP_EXPIRED_MESSAGE =
"This link expired. Reopen the dashboard from your bot chat.";
export function renderTelegramMiniAppPage(params: {
accountId: string;
controlUiUrl: string;
scriptNonce: string;
}): string {
const accountId = JSON.stringify(params.accountId);
const controlUiUrl = JSON.stringify(params.controlUiUrl);
const nonce = escapeHtml(params.scriptNonce);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>OpenClaw</title>
<style>
:root { color-scheme: light dark; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: Canvas; color: CanvasText; }
main { width: min(28rem, calc(100vw - 2rem)); }
h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
p { margin: 0; line-height: 1.5; }
</style>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
</head>
<body>
<main>
<h1>OpenClaw</h1>
<p id="status">Opening dashboard...</p>
</main>
<script nonce="${nonce}">
const accountId = ${accountId};
const controlUiUrl = ${controlUiUrl};
const status = document.getElementById("status");
const showExpired = () => {
status.textContent = ${JSON.stringify(TELEGRAM_MINIAPP_EXPIRED_MESSAGE)};
};
const webApp = window.Telegram && window.Telegram.WebApp;
const initData = webApp && typeof webApp.initData === "string" ? webApp.initData : "";
if (!initData) {
showExpired();
} else {
webApp.ready();
fetch("auth", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ initData, accountId }),
credentials: "same-origin"
}).then(async (response) => {
if (!response.ok) {
throw new Error("auth failed");
}
return await response.json();
}).then((payload) => {
const next = new URL(controlUiUrl);
next.hash = "gatewayUrl=" + encodeURIComponent(payload.gatewayUrl) +
"&bootstrapToken=" + encodeURIComponent(payload.bootstrapToken);
location.replace(next.toString());
}).catch(showExpired);
}
</script>
</body>
</html>`;
}
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (char) => {
switch (char) {
case "&":
return "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
case '"':
return "&quot;";
default:
return "&#39;";
}
});
}
@@ -0,0 +1,224 @@
import crypto from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it, vi } from "vitest";
import { createTestPluginApi } from "../../../../src/plugin-sdk/plugin-test-api.js";
import type { OpenClawPluginHttpRouteParams } from "../../../../src/plugins/types.js";
const issueDeviceBootstrapToken = vi.hoisted(() =>
vi.fn(async () => ({ token: "issued", expiresAtMs: Date.now() + 600_000 })),
);
const resolveTelegramMiniAppUrls = vi.hoisted(() =>
vi.fn(async () => ({
pageUrl: "https://host.tailnet.ts.net/__openclaw_tg_miniapp/",
controlUiUrl: "https://host.tailnet.ts.net/openclaw",
gatewayUrl: "wss://host.tailnet.ts.net/openclaw",
})),
);
vi.mock("openclaw/plugin-sdk/device-bootstrap", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/device-bootstrap")>()),
issueDeviceBootstrapToken,
}));
vi.mock("./url.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./url.js")>()),
resolveTelegramMiniAppUrls,
}));
const { registerTelegramMiniAppRoutes } = await import("./routes.js");
const BOT_TOKEN = "fixture";
class MockResponse {
statusCode = 200;
headers: Record<string, string> = {};
body = "";
writeHead(statusCode: number, headers: Record<string, string>) {
this.statusCode = statusCode;
this.headers = { ...this.headers, ...headers };
return this;
}
end(body?: string) {
this.body = body ?? "";
return this;
}
}
function createRoute(cfg: OpenClawConfig): OpenClawPluginHttpRouteParams {
let route: OpenClawPluginHttpRouteParams | null = null;
const api = createTestPluginApi({
config: cfg,
registerHttpRoute(params) {
route = params;
},
});
registerTelegramMiniAppRoutes(api);
if (!route) {
throw new Error("expected miniapp route registration");
}
return route;
}
async function callRoute(params: {
route: OpenClawPluginHttpRouteParams;
method: string;
url: string;
body?: string;
contentType?: string;
ip?: string;
}) {
const req = Readable.from(params.body ? [params.body] : []) as IncomingMessage;
req.method = params.method;
req.url = params.url;
req.headers = params.contentType ? { "content-type": params.contentType } : {};
Object.defineProperty(req, "socket", {
value: { remoteAddress: params.ip ?? "203.0.113.10" },
});
const res = new MockResponse() as ServerResponse & MockResponse;
await params.route.handler(req, res);
return res;
}
function config(allowFrom: string[] = ["123456"]): OpenClawConfig {
return {
channels: {
telegram: {
botToken: BOT_TOKEN,
allowFrom,
},
},
gateway: { tailscale: { mode: "funnel" } },
};
}
function signedInitData(userId: string, nonce: string): string {
const params = new URLSearchParams({
auth_date: String(Math.floor(Date.now() / 1000)),
query_id: nonce,
user: JSON.stringify({ id: Number(userId), first_name: "Ayaan" }),
});
const entries = [...params.entries()].map(([key, value]) => `${key}=${value}`).toSorted();
const secret = crypto.createHmac("sha256", "WebAppData").update(BOT_TOKEN).digest();
params.set("hash", crypto.createHmac("sha256", secret).update(entries.join("\n")).digest("hex"));
return params.toString();
}
describe("registerTelegramMiniAppRoutes", () => {
it("mints a control-ui bootstrap token for a valid owner request", async () => {
const route = createRoute(config());
const res = await callRoute({
route,
method: "POST",
url: "/__openclaw_tg_miniapp/auth",
contentType: "application/json; charset=utf-8",
body: JSON.stringify({
initData: signedInitData("123456", "success"),
accountId: "default",
}),
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toEqual({
bootstrapToken: "issued",
gatewayUrl: "wss://host.tailnet.ts.net/openclaw",
});
expect(issueDeviceBootstrapToken).toHaveBeenCalledWith({
profile: {
roles: ["operator"],
scopes: ["operator.approvals", "operator.read", "operator.talk.secrets", "operator.write"],
purpose: "control-ui",
},
});
});
it("rejects replayed init-data without minting again", async () => {
issueDeviceBootstrapToken.mockClear();
const route = createRoute(config());
const initData = signedInitData("123456", "replay");
await callRoute({
route,
method: "POST",
url: "/__openclaw_tg_miniapp/auth",
contentType: "application/json",
body: JSON.stringify({ initData }),
ip: "203.0.113.20",
});
const replay = await callRoute({
route,
method: "POST",
url: "/__openclaw_tg_miniapp/auth",
contentType: "application/json",
body: JSON.stringify({ initData }),
ip: "203.0.113.20",
});
expect(replay.statusCode).toBe(401);
expect(replay.body).toBe("This link expired. Reopen the dashboard from your bot chat.");
expect(issueDeviceBootstrapToken).toHaveBeenCalledTimes(1);
});
it("reserves validated init-data before minting", async () => {
issueDeviceBootstrapToken.mockClear();
const route = createRoute(config());
const initData = signedInitData("123456", "concurrent");
const responses = await Promise.all([
callRoute({
route,
method: "POST",
url: "/__openclaw_tg_miniapp/auth",
contentType: "application/json",
body: JSON.stringify({ initData }),
ip: "203.0.113.21",
}),
callRoute({
route,
method: "POST",
url: "/__openclaw_tg_miniapp/auth",
contentType: "application/json",
body: JSON.stringify({ initData }),
ip: "203.0.113.22",
}),
]);
expect(responses.map((res) => res.statusCode).toSorted()).toEqual([200, 401]);
expect(issueDeviceBootstrapToken).toHaveBeenCalledTimes(1);
});
it("rejects non-owner Mini App auth requests", async () => {
const route = createRoute(config(["999999"]));
const res = await callRoute({
route,
method: "POST",
url: "/__openclaw_tg_miniapp/auth",
contentType: "application/json",
body: JSON.stringify({ initData: signedInitData("123456", "non-owner") }),
ip: "203.0.113.30",
});
expect(res.statusCode).toBe(403);
expect(res.body).toBe("Restricted to the bot owner.");
});
it("rate-limits repeated auth requests by IP", async () => {
const route = createRoute(config());
let last: MockResponse | null = null;
for (let i = 0; i < 11; i += 1) {
last = await callRoute({
route,
method: "POST",
url: "/__openclaw_tg_miniapp/auth",
contentType: "application/json",
body: JSON.stringify({ initData: signedInitData("123456", `rate-${i}`) }),
ip: "203.0.113.40",
});
}
expect(last?.statusCode).toBe(429);
expect(last?.body).toBe("Too many requests");
});
});
+249
View File
@@ -0,0 +1,249 @@
// Telegram Mini App HTTP routes.
import crypto from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
BOOTSTRAP_HANDOFF_OPERATOR_SCOPES,
issueDeviceBootstrapToken,
} from "openclaw/plugin-sdk/device-bootstrap";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { resolveTelegramAccount } from "../accounts.js";
import { validateTelegramMiniAppInitData } from "./init-data.js";
import { isTelegramMiniAppOwner } from "./owner.js";
import { renderTelegramMiniAppPage, TELEGRAM_MINIAPP_EXPIRED_MESSAGE } from "./page.js";
import {
resolveTelegramMiniAppUrls,
TELEGRAM_MINIAPP_PATH_PREFIX,
TELEGRAM_MINIAPP_URL_ERROR,
} from "./url.js";
const AUTH_PATH = `${TELEGRAM_MINIAPP_PATH_PREFIX}auth`;
const MAX_BODY_BYTES = 4096;
const REPLAY_CACHE_LIMIT = 1000;
const RATE_LIMIT_WINDOW_MS = 60_000;
const RATE_LIMIT_MAX = 10;
const replayCache = new Map<string, number>();
const rateLimit = new Map<string, { count: number; resetAtMs: number }>();
export function registerTelegramMiniAppRoutes(api: OpenClawPluginApi): void {
api.registerHttpRoute({
path: TELEGRAM_MINIAPP_PATH_PREFIX,
match: "prefix",
auth: "plugin",
handler: async (req, res) => {
const url = new URL(req.url ?? "", "http://openclaw.local");
if (url.pathname === TELEGRAM_MINIAPP_PATH_PREFIX) {
await handlePage(api, req, res, url);
return true;
}
if (url.pathname === AUTH_PATH) {
await handleAuth(api, req, res);
return true;
}
sendText(res, 404, "Not found");
return true;
},
});
}
async function handlePage(
api: OpenClawPluginApi,
req: IncomingMessage,
res: ServerResponse,
url: URL,
): Promise<void> {
if (req.method !== "GET") {
sendText(res, 405, "Method not allowed");
return;
}
const cfg = currentConfig(api);
let urls;
try {
urls = await resolveTelegramMiniAppUrls({ cfg });
} catch {
sendText(res, 503, TELEGRAM_MINIAPP_URL_ERROR);
return;
}
const accountId = normalizeAccountId(url.searchParams.get("accountId") ?? DEFAULT_ACCOUNT_ID);
const nonce = crypto.randomBytes(16).toString("base64url");
sendHtml(
res,
200,
renderTelegramMiniAppPage({
accountId,
controlUiUrl: urls.controlUiUrl,
scriptNonce: nonce,
}),
nonce,
);
}
async function handleAuth(
api: OpenClawPluginApi,
req: IncomingMessage,
res: ServerResponse,
): Promise<void> {
if (req.method !== "POST") {
sendText(res, 405, "Method not allowed");
return;
}
const contentType = String(req.headers["content-type"] ?? "").toLowerCase();
if (contentType.split(";")[0]?.trim() !== "application/json") {
sendText(res, 415, "Unsupported media type");
return;
}
const ip = req.socket.remoteAddress ?? "unknown";
if (!consumeRateLimit(ip)) {
sendText(res, 429, "Too many requests");
return;
}
const body = await readJsonBody(req);
if (body === "too-large") {
sendText(res, 413, "Payload too large");
return;
}
if (!body) {
sendText(res, 401, TELEGRAM_MINIAPP_EXPIRED_MESSAGE);
return;
}
const accountId = normalizeAccountId(body.accountId ?? DEFAULT_ACCOUNT_ID);
const cfg = currentConfig(api);
const account = resolveTelegramAccount({ cfg, accountId });
const validated = validateTelegramMiniAppInitData({
initData: body.initData,
botToken: account.token,
});
if (!validated) {
sendText(res, 401, TELEGRAM_MINIAPP_EXPIRED_MESSAGE);
return;
}
if (!(await isTelegramMiniAppOwner({ cfg, accountId, userId: validated.userId }))) {
sendText(res, 403, "Restricted to the bot owner.");
return;
}
let urls;
try {
urls = await resolveTelegramMiniAppUrls({ cfg });
} catch {
sendText(res, 503, TELEGRAM_MINIAPP_URL_ERROR);
return;
}
if (!rememberReplay(validated.hash, validated.authDateMs + 300_000)) {
sendText(res, 401, TELEGRAM_MINIAPP_EXPIRED_MESSAGE);
return;
}
const issued = await issueDeviceBootstrapToken({
profile: {
roles: ["operator"],
scopes: BOOTSTRAP_HANDOFF_OPERATOR_SCOPES,
purpose: "control-ui",
},
});
sendJson(res, 200, {
bootstrapToken: issued.token,
gatewayUrl: urls.gatewayUrl,
});
}
function currentConfig(api: OpenClawPluginApi): OpenClawConfig {
return (api.runtime.config?.current?.() ?? api.config) as OpenClawConfig;
}
async function readJsonBody(
req: IncomingMessage,
): Promise<{ initData: string; accountId?: string } | "too-large" | null> {
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of req) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
total += buffer.length;
if (total > MAX_BODY_BYTES) {
return "too-large";
}
chunks.push(buffer);
}
try {
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")) as {
initData?: unknown;
accountId?: unknown;
};
if (typeof parsed.initData !== "string") {
return null;
}
return {
initData: parsed.initData,
...(typeof parsed.accountId === "string" ? { accountId: parsed.accountId } : {}),
};
} catch {
return null;
}
}
function consumeRateLimit(ip: string): boolean {
const now = Date.now();
const current = rateLimit.get(ip);
if (!current || current.resetAtMs <= now) {
rateLimit.set(ip, { count: 1, resetAtMs: now + RATE_LIMIT_WINDOW_MS });
return true;
}
current.count += 1;
return current.count <= RATE_LIMIT_MAX;
}
function rememberReplay(hash: string, expiresAtMs: number): boolean {
pruneReplayCache();
if (replayCache.has(hash)) {
return false;
}
replayCache.set(hash, expiresAtMs);
while (replayCache.size > REPLAY_CACHE_LIMIT) {
const first = replayCache.keys().next().value;
if (!first) {
return true;
}
replayCache.delete(first);
}
return true;
}
function pruneReplayCache(): void {
const now = Date.now();
for (const [hash, expiresAtMs] of replayCache) {
if (expiresAtMs <= now) {
replayCache.delete(hash);
}
}
}
function securityHeaders(extra?: Record<string, string>): Record<string, string> {
return {
"Cache-Control": "no-store",
"Referrer-Policy": "no-referrer",
"X-Robots-Tag": "noindex",
...extra,
};
}
function sendHtml(res: ServerResponse, status: number, body: string, nonce: string): void {
res.writeHead(
status,
securityHeaders({
"Content-Type": "text/html; charset=utf-8",
"Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}' https://telegram.org; connect-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'`,
}),
);
res.end(body);
}
function sendJson(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, securityHeaders({ "Content-Type": "application/json; charset=utf-8" }));
res.end(JSON.stringify(body));
}
function sendText(res: ServerResponse, status: number, body: string): void {
res.writeHead(status, securityHeaders({ "Content-Type": "text/plain; charset=utf-8" }));
res.end(body);
}
@@ -0,0 +1,56 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it, vi } from "vitest";
import { resolveTelegramMiniAppUrls, TELEGRAM_MINIAPP_URL_ERROR } from "./url.js";
describe("resolveTelegramMiniAppUrls", () => {
it("resolves HTTPS page and WSS gateway URLs from Tailscale Serve", async () => {
const runCommand = vi.fn(async () => ({
code: 0,
stdout: JSON.stringify({ Self: { DNSName: "host.tailnet.ts.net." } }),
}));
const cfg = {
gateway: {
tailscale: { mode: "serve" },
controlUi: { basePath: "/openclaw/" },
},
} satisfies OpenClawConfig;
await expect(resolveTelegramMiniAppUrls({ cfg, runCommand })).resolves.toEqual({
pageUrl: "https://host.tailnet.ts.net/__openclaw_tg_miniapp/",
controlUiUrl: "https://host.tailnet.ts.net/openclaw",
gatewayUrl: "wss://host.tailnet.ts.net/openclaw",
});
expect(runCommand).toHaveBeenCalledWith(["tailscale", "status", "--json"], {
timeoutMs: 5000,
});
});
it("uses service MagicDNS for Tailscale Serve service names", async () => {
const runCommand = vi.fn(async () => ({
code: 0,
stdout: JSON.stringify({ Self: { DNSName: "host.tailnet.ts.net" } }),
}));
const cfg = {
gateway: {
tailscale: { mode: "serve", serviceName: "svc:openclaw" },
},
} satisfies OpenClawConfig;
await expect(resolveTelegramMiniAppUrls({ cfg, runCommand })).resolves.toMatchObject({
pageUrl: "https://openclaw.tailnet.ts.net/__openclaw_tg_miniapp/",
gatewayUrl: "wss://openclaw.tailnet.ts.net",
});
});
it("fails loud when Tailscale mode is off or MagicDNS cannot resolve", async () => {
await expect(resolveTelegramMiniAppUrls({ cfg: {} })).rejects.toThrow(
TELEGRAM_MINIAPP_URL_ERROR,
);
await expect(
resolveTelegramMiniAppUrls({
cfg: { gateway: { tailscale: { mode: "funnel" } } },
runCommand: async () => ({ code: 1, stdout: "" }),
}),
).rejects.toThrow(TELEGRAM_MINIAPP_URL_ERROR);
});
});
+57
View File
@@ -0,0 +1,57 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
// Telegram Mini App published URL resolution.
import {
resolveTailnetHostWithRunner,
resolveTailscalePublishedHost,
type TailscaleStatusCommandRunner,
} from "openclaw/plugin-sdk/core";
import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime";
export const TELEGRAM_MINIAPP_PATH_PREFIX = "/__openclaw_tg_miniapp/";
export const TELEGRAM_MINIAPP_URL_ERROR =
"Mini App needs an HTTPS gateway URL. Set `gateway.tailscale.mode: serve` or `funnel`, then retry.";
export type TelegramMiniAppUrls = {
pageUrl: string;
controlUiUrl: string;
gatewayUrl: string;
};
export async function resolveTelegramMiniAppUrls(params: {
cfg: OpenClawConfig;
runCommand?: TailscaleStatusCommandRunner;
}): Promise<TelegramMiniAppUrls> {
const mode = params.cfg.gateway?.tailscale?.mode ?? "off";
if (mode !== "serve" && mode !== "funnel") {
throw new Error(TELEGRAM_MINIAPP_URL_ERROR);
}
const tailnetHost = await resolveTailnetHostWithRunner(
params.runCommand ?? runCommandWithTimeout,
);
const publishedHost = resolveTailscalePublishedHost({
tailscaleMode: mode,
tailnetHost,
serviceName: params.cfg.gateway?.tailscale?.serviceName,
});
if (!publishedHost) {
throw new Error(TELEGRAM_MINIAPP_URL_ERROR);
}
const controlUiPath = normalizeControlUiBasePath(params.cfg.gateway?.controlUi?.basePath);
const controlUiUrl = `https://${publishedHost}${controlUiPath}`;
return {
pageUrl: `https://${publishedHost}${TELEGRAM_MINIAPP_PATH_PREFIX}`,
controlUiUrl,
gatewayUrl: `wss://${publishedHost}${controlUiPath}`,
};
}
function normalizeControlUiBasePath(value: unknown): string {
const raw = typeof value === "string" ? value.trim() : "";
if (!raw || raw === "/") {
return "";
}
const withLeading = raw.startsWith("/") ? raw : `/${raw}`;
return withLeading.replace(/\/+$/, "");
}