mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
feat(telegram): add dashboard mini app command
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 "&";
|
||||
case "<":
|
||||
return "<";
|
||||
case ">":
|
||||
return ">";
|
||||
case '"':
|
||||
return """;
|
||||
default:
|
||||
return "'";
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(/\/+$/, "");
|
||||
}
|
||||
Reference in New Issue
Block a user