UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

310 lines (309 loc) 11.4 kB
import { f as normalizeStringifiedOptionalString } from "./string-coerce-mnp54Vah.js"; import { y as resolveStateDir } from "./paths-mvMm5bYV.js"; import { t as createAsyncLock } from "./async-lock-CaiUOILd.js"; import { m as writeJson, o as tryReadJson } from "./json-files-2umMHm0W.js"; import { in as errorShape, rn as ErrorCodes } from "./schema-BwaBORnA.js"; import { Mt as validatePushTestParams, cr as validateWebPushUnsubscribeParams, lr as validateWebPushVapidPublicKeyParams, or as validateWebPushSubscribeParams, sr as validateWebPushTestParams } from "./src-oj0IwW6K.js"; import { r as respondUnavailableOnThrow, t as respondInvalidParams } from "./nodes.helpers-DeAuiiQp.js"; import { d as shouldClearStoredApnsRegistration, f as resolveApnsRelayConfigFromEnv, i as normalizeApnsEnvironment, n as loadApnsRegistration, o as resolveApnsAuthConfigFromEnv, s as sendApnsAlert, t as clearApnsRegistrationIfCurrent } from "./push-apns-2937xPaj.js"; import { t as normalizeTrimmedString } from "./record-shared-BFsRS6nC.js"; import path from "node:path"; import { createHash, randomUUID } from "node:crypto"; //#region src/infra/push-web.ts const WEB_PUSH_STATE_FILENAME = "push/web-push-subscriptions.json"; const VAPID_KEYS_FILENAME = "push/vapid-keys.json"; const MAX_ENDPOINT_LENGTH = 2048; const MAX_KEY_LENGTH = 512; const DEFAULT_VAPID_SUBJECT = "https://openclaw.ai"; const withLock = createAsyncLock(); let webPushRuntimePromise; async function loadWebPushRuntime() { webPushRuntimePromise ??= import("web-push").then((mod) => mod.default ?? mod); return await webPushRuntimePromise; } function resolveWebPushStatePath(baseDir) { const root = baseDir ?? resolveStateDir(); return path.join(root, WEB_PUSH_STATE_FILENAME); } function resolveVapidKeysPath(baseDir) { const root = baseDir ?? resolveStateDir(); return path.join(root, VAPID_KEYS_FILENAME); } function hashEndpoint(endpoint) { return createHash("sha256").update(endpoint).digest("hex").slice(0, 32); } function isValidEndpoint(endpoint) { if (!endpoint || endpoint.length > MAX_ENDPOINT_LENGTH) return false; try { return new URL(endpoint).protocol === "https:"; } catch { return false; } } function isValidKey(key) { return typeof key === "string" && key.length > 0 && key.length <= MAX_KEY_LENGTH; } async function loadState(baseDir) { return await tryReadJson(resolveWebPushStatePath(baseDir)) ?? { subscriptionsByEndpointHash: {} }; } async function persistState(state, baseDir) { await writeJson(resolveWebPushStatePath(baseDir), state, { trailingNewline: true }); } async function resolveVapidKeys(baseDir) { const envPublic = resolveVapidPublicKeyFromEnv(); const envPrivate = resolveVapidPrivateKeyFromEnv(); if (envPublic && envPrivate) return { publicKey: envPublic, privateKey: envPrivate, subject: resolveVapidSubjectFromEnv() }; return await withLock(async () => { const filePath = resolveVapidKeysPath(baseDir); const existing = await tryReadJson(filePath); if (existing?.publicKey && existing?.privateKey) return { publicKey: existing.publicKey, privateKey: existing.privateKey, subject: resolveVapidSubjectFromEnv() }; const keys = (await loadWebPushRuntime()).generateVAPIDKeys(); const pair = { publicKey: keys.publicKey, privateKey: keys.privateKey, subject: resolveVapidSubjectFromEnv() }; await writeJson(filePath, pair, { trailingNewline: true }); return pair; }); } function resolveVapidSubjectFromEnv() { return process.env.OPENCLAW_VAPID_SUBJECT || DEFAULT_VAPID_SUBJECT; } function resolveVapidPublicKeyFromEnv() { return process.env.OPENCLAW_VAPID_PUBLIC_KEY || void 0; } function resolveVapidPrivateKeyFromEnv() { return process.env.OPENCLAW_VAPID_PRIVATE_KEY || void 0; } async function registerWebPushSubscription(params) { const { endpoint, keys, baseDir } = params; if (!isValidEndpoint(endpoint)) throw new Error("invalid push subscription endpoint: must be an HTTPS URL under 2048 chars"); if (!isValidKey(keys.p256dh) || !isValidKey(keys.auth)) throw new Error("invalid push subscription keys: must be non-empty strings under 512 chars"); return await withLock(async () => { const state = await loadState(baseDir); const hash = hashEndpoint(endpoint); const now = Date.now(); const existing = state.subscriptionsByEndpointHash[hash]; const subscription = { subscriptionId: existing?.subscriptionId ?? randomUUID(), endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth }, createdAtMs: existing?.createdAtMs ?? now, updatedAtMs: now }; state.subscriptionsByEndpointHash[hash] = subscription; await persistState(state, baseDir); return subscription; }); } async function listWebPushSubscriptions(baseDir) { const state = await loadState(baseDir); return Object.values(state.subscriptionsByEndpointHash); } async function clearWebPushSubscriptionByEndpoint(endpoint, baseDir) { return await withLock(async () => { const state = await loadState(baseDir); const hash = hashEndpoint(endpoint); if (state.subscriptionsByEndpointHash[hash]) { delete state.subscriptionsByEndpointHash[hash]; await persistState(state, baseDir); return true; } return false; }); } function applyVapidDetails(webPush, keys) { webPush.setVapidDetails(keys.subject, keys.publicKey, keys.privateKey); } async function sendPreparedWebPushNotification(webPush, subscription, payload) { const pushSubscription = { endpoint: subscription.endpoint, keys: { p256dh: subscription.keys.p256dh, auth: subscription.keys.auth } }; try { const result = await webPush.sendNotification(pushSubscription, JSON.stringify(payload)); return { ok: true, subscriptionId: subscription.subscriptionId, statusCode: result.statusCode }; } catch (err) { const statusCode = typeof err === "object" && err !== null && "statusCode" in err ? err.statusCode : void 0; const message = typeof err === "object" && err !== null && "message" in err ? err.message : "unknown error"; return { ok: false, subscriptionId: subscription.subscriptionId, statusCode, error: message }; } } async function broadcastWebPush(payload, baseDir) { const subscriptions = await listWebPushSubscriptions(baseDir); if (subscriptions.length === 0) return []; const vapidKeys = await resolveVapidKeys(baseDir); const webPush = await loadWebPushRuntime(); applyVapidDetails(webPush, vapidKeys); const mapped = (await Promise.allSettled(subscriptions.map((sub) => sendPreparedWebPushNotification(webPush, sub, payload)))).map((r, i) => r.status === "fulfilled" ? r.value : { ok: false, subscriptionId: subscriptions[i].subscriptionId, error: r.reason instanceof Error ? r.reason.message : "unknown error" }); const expiredEndpoints = mapped.map((result, i) => ({ result, sub: subscriptions[i] })).filter(({ result }) => !result.ok && (result.statusCode === 410 || result.statusCode === 404)).map(({ sub }) => sub.endpoint); if (expiredEndpoints.length > 0) await Promise.allSettled(expiredEndpoints.map((endpoint) => clearWebPushSubscriptionByEndpoint(endpoint, baseDir))); return mapped; } //#endregion //#region src/gateway/server-methods/push.ts const pushHandlers = { "push.test": async ({ params, respond, context }) => { if (!validatePushTestParams(params)) { respondInvalidParams({ respond, method: "push.test", validator: validatePushTestParams }); return; } const nodeId = normalizeStringifiedOptionalString(params.nodeId) ?? ""; if (!nodeId) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required")); return; } const title = normalizeTrimmedString(params.title) ?? "OpenClaw"; const body = normalizeTrimmedString(params.body) ?? `Push test for node ${nodeId}`; await respondUnavailableOnThrow(respond, async () => { const registration = await loadApnsRegistration(nodeId); if (!registration) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, `node ${nodeId} has no APNs registration (connect iOS node first)`)); return; } const overrideEnvironment = normalizeApnsEnvironment(params.environment); const result = registration.transport === "direct" ? await (async () => { const auth = await resolveApnsAuthConfigFromEnv(process.env); if (!auth.ok) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, auth.error)); return null; } return await sendApnsAlert({ registration: { ...registration, environment: overrideEnvironment ?? registration.environment }, nodeId, title, body, auth: auth.value }); })() : await (async () => { const relay = resolveApnsRelayConfigFromEnv(process.env, context.getRuntimeConfig().gateway, { registrationRelayOrigin: registration.relayOrigin }); if (!relay.ok) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, relay.error)); return null; } return await sendApnsAlert({ registration, nodeId, title, body, relayConfig: relay.value }); })(); if (!result) return; if (shouldClearStoredApnsRegistration({ registration, result, overrideEnvironment })) await clearApnsRegistrationIfCurrent({ nodeId, registration }); respond(true, result, void 0); }); }, "push.web.vapidPublicKey": async ({ params, respond }) => { if (!validateWebPushVapidPublicKeyParams(params)) { respondInvalidParams({ respond, method: "push.web.vapidPublicKey", validator: validateWebPushVapidPublicKeyParams }); return; } await respondUnavailableOnThrow(respond, async () => { respond(true, { vapidPublicKey: (await resolveVapidKeys()).publicKey }, void 0); }); }, "push.web.subscribe": async ({ params, respond }) => { if (!validateWebPushSubscribeParams(params)) { respondInvalidParams({ respond, method: "push.web.subscribe", validator: validateWebPushSubscribeParams }); return; } await respondUnavailableOnThrow(respond, async () => { respond(true, { subscriptionId: (await registerWebPushSubscription({ endpoint: params.endpoint, keys: params.keys })).subscriptionId }, void 0); }); }, "push.web.unsubscribe": async ({ params, respond }) => { if (!validateWebPushUnsubscribeParams(params)) { respondInvalidParams({ respond, method: "push.web.unsubscribe", validator: validateWebPushUnsubscribeParams }); return; } await respondUnavailableOnThrow(respond, async () => { respond(true, { removed: await clearWebPushSubscriptionByEndpoint(params.endpoint) }, void 0); }); }, "push.web.test": async ({ params, respond }) => { if (!validateWebPushTestParams(params)) { respondInvalidParams({ respond, method: "push.web.test", validator: validateWebPushTestParams }); return; } const title = normalizeTrimmedString(params.title) ?? "OpenClaw"; const body = normalizeTrimmedString(params.body) ?? "Web push test notification"; await respondUnavailableOnThrow(respond, async () => { const results = await broadcastWebPush({ title, body }); if (results.length === 0) { respond(false, void 0, errorShape(ErrorCodes.INVALID_REQUEST, "no web push subscriptions registered")); return; } respond(true, { results }, void 0); }); } }; //#endregion export { pushHandlers };