openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
448 lines (447 loc) • 16.5 kB
JavaScript
import { c as isRecord } from "./record-coerce-DItp3I4t.js";
import { n as normalizeAccountId } from "./account-id-CETVCrTz.js";
import { r as runCommandWithTimeout } from "./exec-BIE-3oLG.js";
import { t as safeEqualSecret } from "./secret-equal-DRsL8lKD.js";
import { i as resolveTailscalePublishedHost, r as resolveTailnetHostWithRunner } from "./tailscale-status-D3cAZH54.js";
import { t as escapeHtml } from "./html-escape-BMD_QFeA.js";
import "./string-coerce-runtime-GQa0ehRA.js";
import { k as BOOTSTRAP_HANDOFF_OPERATOR_SCOPES, s as issueDeviceBootstrapToken } from "./device-bootstrap-DRFMRCcr.js";
import "./core-C-nFxxsj.js";
import "./security-runtime-Ckf0kc0h.js";
import "./process-runtime-pcN9RjT8.js";
import "./device-bootstrap-cBtmBkgx.js";
import "./text-utility-runtime-BjzvUG99.js";
import { i as WEBHOOK_RATE_LIMIT_DEFAULTS, o as createFixedWindowRateLimiter, t as resolveRequestClientIp } from "./webhook-ingress-CpRSOilv.js";
import { s as readJsonWebhookBodyOrReject } from "./webhook-request-guards-BoKMzZgq.js";
import { t as expandTelegramAllowFromWithAccessGroups } from "./access-groups-BCvoVMVk.js";
import { t as mergeTelegramAccountConfig } from "./account-config-B05_z8M7.js";
import { o as resolveTelegramAccount } from "./accounts-CgIQ5mFD.js";
import { r as normalizeTelegramAllowFromEntry, t as isNumericTelegramSenderUserId } from "./allow-from-Byf7JKVc.js";
import crypto from "node:crypto";
//#region extensions/telegram/src/miniapp/owner.ts
async function isTelegramMiniAppOwner(params) {
const userId = params.userId.trim();
if (!isNumericTelegramSenderUserId(userId)) return false;
const allowFrom = [...mergeTelegramAccountConfig(params.cfg, params.accountId).allowFrom ?? [], ...params.cfg.commands?.ownerAllowFrom ?? []];
return (await expandTelegramAllowFromWithAccessGroups({
cfg: params.cfg,
accountId: params.accountId,
allowFrom,
senderId: userId
})).some((entry) => normalizeTelegramAllowFromEntry(entry) === userId);
}
//#endregion
//#region extensions/telegram/src/miniapp/url.ts
const TELEGRAM_MINIAPP_PATH_PREFIX = "/__openclaw_tg_miniapp/";
const TELEGRAM_MINIAPP_URL_ERROR = "Mini App needs an HTTPS gateway URL. Set `gateway.tailscale.mode: serve` or `funnel`, then retry.";
async function resolveTelegramMiniAppUrls(params) {
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
});
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) {
const raw = typeof value === "string" ? value.trim() : "";
if (!raw || raw === "/") return "";
return (raw.startsWith("/") ? raw : `/${raw}`).replace(/\/+$/, "");
}
//#endregion
//#region extensions/telegram/src/miniapp/command.ts
function registerTelegramMiniAppCommand(api, launchTickets) {
api.registerCommand(createTelegramMiniAppDashboardCommand(api, launchTickets));
}
function createTelegramMiniAppDashboardCommand(api, launchTickets) {
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$1(api);
const accountId = normalizeAccountId(ctx.accountId ?? "default");
const userId = resolveTelegramDirectUserId(ctx);
if (!await isTelegramMiniAppOwner({
cfg,
accountId,
userId
})) return { text: "Restricted to the bot owner." };
let pageUrl;
try {
pageUrl = new URL((await resolveTelegramMiniAppUrls({ cfg })).pageUrl);
} catch {
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: { blocks: [{
type: "buttons",
buttons: [{
label: "Open dashboard",
webApp: { url: pageUrl.toString() }
}]
}] }
};
}
};
}
function currentConfig$1(api) {
return api.runtime.config?.current?.() ?? api.config;
}
function isTelegramDirectCommand(ctx) {
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) {
const senderId = ctx.senderId?.trim() ?? "";
if (/^\d+$/.test(senderId)) return senderId;
return /^telegram:(\d+)$/.exec(ctx.from?.trim() ?? "")?.[1] ?? "";
}
//#endregion
//#region extensions/telegram/src/miniapp/launch-ticket.ts
const LAUNCH_TICKET_TTL_MS = 3e5;
const LAUNCH_TICKET_LIMIT = 1e3;
function createTelegramMiniAppLaunchTickets() {
const tickets = /* @__PURE__ */ new Map();
function prune() {
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;
}
};
}
//#endregion
//#region extensions/telegram/src/miniapp/init-data.ts
const INIT_DATA_MAX_AGE_MS = 3e5;
function validateTelegramMiniAppInitData(params) {
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 * 1e3;
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 (!safeEqualSecret(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) {
try {
const parsed = JSON.parse(raw);
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;
}
}
//#endregion
//#region extensions/telegram/src/miniapp/page.ts
const TELEGRAM_MINIAPP_EXPIRED_MESSAGE = "This link expired. Reopen the dashboard from your bot chat.";
const TELEGRAM_MINIAPP_AUTH_TIMEOUT_MS = 15e3;
function renderTelegramMiniAppPage(params) {
const accountId = JSON.stringify(params.accountId);
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="${escapeHtml(params.scriptNonce)}">
const accountId = ${accountId};
const launchTicket = new URLSearchParams(location.hash.slice(1)).get("launchTicket") || "";
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 || !launchTicket) {
showExpired();
} else {
webApp.ready();
// AbortController works in WebViews that predate AbortSignal.timeout.
// Clear the timer after either outcome so a successful handoff is not aborted later.
const authController = new AbortController();
const authTimeout = setTimeout(function () {
authController.abort();
}, ${TELEGRAM_MINIAPP_AUTH_TIMEOUT_MS});
fetch("auth", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ initData, accountId, launchTicket }),
credentials: "same-origin",
signal: authController.signal
}).then(async (response) => {
if (!response.ok) {
throw new Error("auth failed");
}
return await response.json();
}).then((payload) => {
const next = new URL(payload.controlUiUrl);
next.hash = "gatewayUrl=" + encodeURIComponent(payload.gatewayUrl) +
"&bootstrapToken=" + encodeURIComponent(payload.bootstrapToken);
location.replace(next.toString());
}).catch(showExpired).then(function () {
clearTimeout(authTimeout);
});
}
<\/script>
</body>
</html>`;
}
//#endregion
//#region extensions/telegram/src/miniapp/routes.ts
const AUTH_PATH = `${TELEGRAM_MINIAPP_PATH_PREFIX}auth`;
const MAX_BODY_BYTES = 4096;
const REPLAY_CACHE_LIMIT = 1e3;
const RATE_LIMIT_WINDOW_MS = 6e4;
const RATE_LIMIT_MAX = 10;
const replayCache = /* @__PURE__ */ new Map();
const rateLimit = createFixedWindowRateLimiter({
windowMs: RATE_LIMIT_WINDOW_MS,
maxRequests: RATE_LIMIT_MAX,
maxTrackedKeys: WEBHOOK_RATE_LIMIT_DEFAULTS.maxTrackedKeys
});
function registerTelegramMiniAppRoutes(api, launchTickets) {
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 === "/__openclaw_tg_miniapp/") {
await handlePage(req, res, url);
return true;
}
if (url.pathname === AUTH_PATH) {
await handleAuth(api, launchTickets, req, res);
return true;
}
sendText(res, 404, "Not found");
return true;
}
});
}
async function handlePage(req, res, url) {
if (req.method !== "GET") {
sendText(res, 405, "Method not allowed");
return;
}
const accountId = normalizeAccountId(url.searchParams.get("accountId") ?? "default");
const nonce = crypto.randomBytes(16).toString("base64url");
sendHtml(res, 200, renderTelegramMiniAppPage({
accountId,
scriptNonce: nonce
}), nonce);
}
async function handleAuth(api, launchTickets, req, res) {
if (req.method !== "POST") {
sendText(res, 405, "Method not allowed");
return;
}
if ((req.headers["content-type"] ?? "").toLowerCase().split(";")[0]?.trim() !== "application/json") {
sendText(res, 415, "Unsupported media type");
return;
}
const cfg = currentConfig(api);
if (!consumeRateLimit(resolveRequestClientIp(req, cfg.gateway?.trustedProxies, cfg.gateway?.allowRealIpFallback === true) ?? "unknown")) {
sendText(res, 429, "Too many requests");
return;
}
const body = await readJsonWebhookBodyOrReject({
req,
res,
maxBytes: MAX_BODY_BYTES,
profile: "pre-auth",
emptyObjectOnEmpty: false,
invalidJsonMessage: TELEGRAM_MINIAPP_EXPIRED_MESSAGE,
invalidJsonStatusCode: 401
});
if (!body.ok) return;
const authBody = parseAuthBody(body.value);
if (!authBody) {
sendText(res, 401, TELEGRAM_MINIAPP_EXPIRED_MESSAGE);
return;
}
const accountId = normalizeAccountId(authBody.accountId ?? "default");
const account = resolveTelegramAccount({
cfg,
accountId
});
const validated = validateTelegramMiniAppInitData({
initData: authBody.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 (!launchTickets.consume({
ticket: authBody.launchTicket,
accountId,
userId: validated.userId
})) {
sendText(res, 401, TELEGRAM_MINIAPP_EXPIRED_MESSAGE);
return;
}
if (!rememberReplay(validated.hash, validated.authDateMs + 3e5)) {
sendText(res, 401, TELEGRAM_MINIAPP_EXPIRED_MESSAGE);
return;
}
sendJson(res, 200, {
bootstrapToken: (await issueDeviceBootstrapToken({ profile: {
roles: ["operator"],
scopes: BOOTSTRAP_HANDOFF_OPERATOR_SCOPES,
purpose: "control-ui"
} })).token,
controlUiUrl: urls.controlUiUrl,
gatewayUrl: urls.gatewayUrl
});
}
function currentConfig(api) {
return api.runtime.config?.current?.() ?? api.config;
}
function parseAuthBody(value) {
if (!isRecord(value)) return null;
if (typeof value.initData !== "string" || typeof value.launchTicket !== "string") return null;
return {
initData: value.initData,
launchTicket: value.launchTicket,
...typeof value.accountId === "string" ? { accountId: value.accountId } : {}
};
}
function consumeRateLimit(ip) {
return !rateLimit.isRateLimited(ip);
}
function rememberReplay(hash, expiresAtMs) {
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() {
const now = Date.now();
for (const [hash, expiresAtMs] of replayCache) if (expiresAtMs <= now) replayCache.delete(hash);
}
function securityHeaders(extra) {
return {
"Cache-Control": "no-store",
"Referrer-Policy": "no-referrer",
"X-Robots-Tag": "noindex",
...extra
};
}
function sendHtml(res, status, body, nonce) {
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, status, body) {
res.writeHead(status, securityHeaders({ "Content-Type": "application/json; charset=utf-8" }));
res.end(JSON.stringify(body));
}
function sendText(res, status, body) {
res.writeHead(status, securityHeaders({ "Content-Type": "text/plain; charset=utf-8" }));
res.end(body);
}
//#endregion
//#region extensions/telegram/miniapp-api.ts
function registerTelegramMiniApp(api) {
const launchTickets = createTelegramMiniAppLaunchTickets();
registerTelegramMiniAppRoutes(api, launchTickets);
registerTelegramMiniAppCommand(api, launchTickets);
}
//#endregion
export { registerTelegramMiniApp as t };