openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
531 lines (530 loc) • 18.1 kB
JavaScript
import { F as resolveTimerTimeoutMs } from "./number-coercion-CLj0HTDM.js";
import { l as toErrorObject } from "./error-coercion-D_-xJ90S.js";
import { l as normalizeOptionalString } from "./string-coerce-CIXf7egm.js";
import { r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.js";
import { F as readSecretFile } from "./fs-safe-B6pvPGnf.js";
import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js";
import { a as getApnsResponseBodyCaptureText, i as createApnsResponseBodyCapture, n as appendApnsResponseBodyCapture, r as connectApnsHttp2Session, t as APNS_HTTP2_CANCEL_CODE } from "./push-apns-http2-DGTDI3Ss.js";
import { _ as sendApnsRelayPush, a as isLikelyApnsToken, f as normalizeApnsToken, p as normalizeApnsTopic, s as isValidApnsTopic } from "./push-apns-store-cDZzCfro.js";
import { createHash, createPrivateKey, sign } from "node:crypto";
//#region src/infra/push-apns-auth.ts
const APNS_JWT_TTL_MS = 3e6;
let cachedJwt = null;
function toBase64UrlBytes(value) {
return Buffer.from(value).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function toBase64UrlJson(value) {
return toBase64UrlBytes(Buffer.from(JSON.stringify(value)));
}
function getJwtCacheKey(auth) {
const keyHash = createHash("sha256").update(auth.privateKey).digest("hex");
return `${auth.teamId}:${auth.keyId}:${keyHash}`;
}
function getApnsBearerToken(auth, nowMs = Date.now()) {
const cacheKey = getJwtCacheKey(auth);
if (cachedJwt && cachedJwt.cacheKey === cacheKey && nowMs < cachedJwt.expiresAtMs) return cachedJwt.token;
const iat = Math.floor(nowMs / 1e3);
const signingInput = `${toBase64UrlJson({
alg: "ES256",
kid: auth.keyId,
typ: "JWT"
})}.${toBase64UrlJson({
iss: auth.teamId,
iat
})}`;
const token = `${signingInput}.${toBase64UrlBytes(sign("sha256", Buffer.from(signingInput, "utf8"), {
key: createPrivateKey(auth.privateKey),
dsaEncoding: "ieee-p1363"
}))}`;
cachedJwt = {
cacheKey,
token,
expiresAtMs: nowMs + APNS_JWT_TTL_MS
};
return token;
}
function normalizePrivateKey(value) {
return value.trim().replace(/\\n/g, "\n");
}
function normalizeNonEmptyString(value) {
const trimmed = normalizeOptionalString(value) ?? "";
return trimmed.length > 0 ? trimmed : null;
}
/** Resolves direct APNs provider auth from env, accepting inline or file-backed keys. */
async function resolveApnsAuthConfigFromEnv(env = process.env) {
const teamId = normalizeNonEmptyString(env.OPENCLAW_APNS_TEAM_ID);
const keyId = normalizeNonEmptyString(env.OPENCLAW_APNS_KEY_ID);
if (!teamId || !keyId) return {
ok: false,
error: "APNs auth missing: set OPENCLAW_APNS_TEAM_ID and OPENCLAW_APNS_KEY_ID"
};
const inlineKeyRaw = normalizeNonEmptyString(env.OPENCLAW_APNS_PRIVATE_KEY_P8) ?? normalizeNonEmptyString(env.OPENCLAW_APNS_PRIVATE_KEY);
if (inlineKeyRaw) return {
ok: true,
value: {
teamId,
keyId,
privateKey: normalizePrivateKey(inlineKeyRaw)
}
};
const keyPath = normalizeNonEmptyString(env.OPENCLAW_APNS_PRIVATE_KEY_PATH);
if (!keyPath) return {
ok: false,
error: "APNs private key missing: set OPENCLAW_APNS_PRIVATE_KEY_P8 or OPENCLAW_APNS_PRIVATE_KEY_PATH"
};
try {
return {
ok: true,
value: {
teamId,
keyId,
privateKey: normalizePrivateKey(await readSecretFile(keyPath, "APNs private key"))
}
};
} catch (err) {
return {
ok: false,
error: `failed reading OPENCLAW_APNS_PRIVATE_KEY_PATH (${keyPath}): ${formatErrorMessage(err)}`
};
}
}
//#endregion
//#region src/infra/push-apns-payloads.ts
const EXEC_APPROVAL_GENERIC_ALERT_BODY = "Open OpenClaw to review this request.";
const PLUGIN_APPROVAL_ALERT_BODY_MAX_LENGTH = 256;
function toPushMetadata(params) {
return {
kind: params.kind,
nodeId: params.nodeId,
ts: Date.now(),
...params.reason ? { reason: params.reason } : {}
};
}
function createApnsAlertPayload(params) {
return {
aps: {
alert: {
title: params.title,
body: params.body
},
sound: "default"
},
openclaw: toPushMetadata({
kind: "push.test",
nodeId: params.nodeId
})
};
}
function createApnsBackgroundPayload(params) {
return {
aps: { "content-available": 1 },
openclaw: toPushMetadata({
kind: "node.wake",
reason: params.wakeReason ?? "node.invoke",
nodeId: params.nodeId
})
};
}
function resolveExecApprovalAlertBody() {
return EXEC_APPROVAL_GENERIC_ALERT_BODY;
}
function createApnsApprovalAlertPayload(params) {
return {
aps: {
alert: {
title: params.title,
body: params.body
},
sound: "default",
category: params.category,
"content-available": 1
},
openclaw: {
kind: `${params.kind}.approval.requested`,
approvalId: params.approvalId,
gatewayDeviceId: params.gatewayDeviceId,
ts: Date.now()
}
};
}
function resolvePluginApprovalAlertBody(description) {
const body = normalizeOptionalString(description) ?? "";
if (body.length <= PLUGIN_APPROVAL_ALERT_BODY_MAX_LENGTH) return body;
return `${truncateUtf16Safe(body, 255).trimEnd()}…`;
}
function createApnsApprovalResolvedPayload(params) {
return {
aps: { "content-available": 1 },
openclaw: {
kind: `${params.kind}.approval.resolved`,
approvalId: params.approvalId,
gatewayDeviceId: params.gatewayDeviceId,
ts: Date.now()
}
};
}
//#endregion
//#region src/infra/push-apns.ts
const EXEC_APPROVAL_NOTIFICATION_CATEGORY = "openclaw.exec-approval";
const PLUGIN_APPROVAL_NOTIFICATION_CATEGORY = "openclaw.plugin-approval";
const DEFAULT_APNS_TIMEOUT_MS = 1e4;
function throwIfApnsSendAborted(signal) {
if (!signal?.aborted) return;
throw signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("APNs send invalidated");
}
async function requireCurrentApnsSend(params) {
throwIfApnsSendAborted(params.signal);
if (params.isCurrent && !await params.isCurrent()) throw new Error("APNs send invalidated");
throwIfApnsSendAborted(params.signal);
}
function parseReason(body) {
const trimmed = body.trim();
if (!trimmed) return;
try {
const parsed = JSON.parse(trimmed);
return typeof parsed.reason === "string" && parsed.reason.trim().length > 0 ? parsed.reason.trim() : truncateUtf16Safe(trimmed, 200);
} catch {
return truncateUtf16Safe(trimmed, 200);
}
}
/** Returns true for APNs responses that mean the direct device token is no longer usable. */
function shouldInvalidateApnsRegistration(result) {
if (result.status === 410) return true;
return result.status === 400 && result.reason?.trim() === "BadDeviceToken";
}
/** Decides whether a failed direct push should clear the persisted registration. */
function shouldClearStoredApnsRegistration(params) {
if (params.registration.transport !== "direct") return false;
if (params.overrideEnvironment && params.overrideEnvironment !== params.registration.environment) return false;
return shouldInvalidateApnsRegistration(params.result);
}
async function sendApnsRequest(params) {
const authority = params.environment === "production" ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com";
const body = JSON.stringify(params.payload);
const requestPath = `/3/device/${params.token}`;
const client = await connectApnsHttp2Session({
authority,
timeoutMs: params.timeoutMs,
...params.signal ? { signal: params.signal } : {}
});
const consumeSessionError = () => void 0;
client.on("error", consumeSessionError);
client.once("close", () => client.off("error", consumeSessionError));
return await new Promise((resolve, reject) => {
let settled = false;
let activeRequest;
const cleanup = () => {
client.off("error", fail);
params.signal?.removeEventListener("abort", onAbort);
};
const fail = (err, options) => {
if (settled) return;
settled = true;
cleanup();
if (options?.cancelRequest && activeRequest && !activeRequest.destroyed) {
activeRequest.close(APNS_HTTP2_CANCEL_CODE);
client.close();
} else client.destroy();
reject(toErrorObject(err, "Non-Error rejection"));
};
const onAbort = () => fail(params.signal?.reason instanceof Error ? params.signal.reason : /* @__PURE__ */ new Error("APNs send invalidated"), { cancelRequest: true });
const finish = (result) => {
if (settled) return;
settled = true;
cleanup();
client.close();
resolve(result);
};
const startRequest = async () => {
try {
await requireCurrentApnsSend(params);
if (settled) return;
if (params.signal?.aborted) {
onAbort();
return;
}
const req = client.request({
":method": "POST",
":path": requestPath,
authorization: `bearer ${params.bearerToken}`,
"apns-topic": params.topic,
"apns-push-type": params.pushType,
"apns-priority": params.priority,
"apns-expiration": "0",
"content-type": "application/json",
"content-length": Buffer.byteLength(body).toString()
});
activeRequest = req;
let statusCode = 0;
let apnsId;
const responseBody = createApnsResponseBodyCapture();
req.setTimeout(params.timeoutMs, () => {
fail(/* @__PURE__ */ new Error(`APNs request timed out after ${params.timeoutMs}ms`), { cancelRequest: true });
});
req.on("response", (headers) => {
statusCode = headers[":status"] ?? 0;
const idHeader = headers["apns-id"];
if (typeof idHeader === "string" && idHeader.trim().length > 0) apnsId = idHeader.trim();
});
req.on("data", (chunk) => {
appendApnsResponseBodyCapture(responseBody, chunk);
});
req.on("end", () => {
finish({
status: statusCode,
apnsId,
body: getApnsResponseBodyCaptureText(responseBody)
});
});
req.on("error", (err) => fail(err));
if (params.signal?.aborted) {
onAbort();
return;
}
req.end(body);
} catch (error) {
fail(error);
}
};
client.once("error", fail);
params.signal?.addEventListener("abort", onAbort, { once: true });
if (params.signal?.aborted) {
onAbort();
return;
}
startRequest();
});
}
function resolveApnsTimeoutMs(timeoutMs) {
return resolveTimerTimeoutMs(timeoutMs, DEFAULT_APNS_TIMEOUT_MS, 1e3);
}
function resolveDirectSendContext(params) {
const token = normalizeApnsToken(params.registration.token);
if (!isLikelyApnsToken(token)) throw new Error("invalid APNs token");
const topic = normalizeApnsTopic(params.registration.topic);
if (!isValidApnsTopic(topic)) throw new Error("topic required");
return {
token,
topic,
environment: params.registration.environment,
bearerToken: getApnsBearerToken(params.auth)
};
}
function resolveRegistrationDebugSuffix(registration, relayResult) {
if (registration.transport === "direct") return registration.token.slice(-8);
return relayResult?.tokenSuffix ?? registration.tokenDebugSuffix ?? registration.relayHandle.slice(-8);
}
function toPushResult(params) {
const response = "body" in params.response ? {
ok: params.response.status === 200,
status: params.response.status,
apnsId: params.response.apnsId,
reason: parseReason(params.response.body),
environment: params.registration.environment,
tokenSuffix: params.tokenSuffix
} : params.response;
return {
ok: response.ok,
status: response.status,
apnsId: response.apnsId,
reason: response.reason,
tokenSuffix: params.tokenSuffix ?? resolveRegistrationDebugSuffix(params.registration, "tokenSuffix" in response ? response : void 0),
topic: params.registration.topic,
environment: response.environment ?? params.registration.environment,
transport: params.registration.transport
};
}
async function sendDirectApnsPush(params) {
const { token, topic, environment, bearerToken } = resolveDirectSendContext({
auth: params.auth,
registration: params.registration
});
await requireCurrentApnsSend(params);
const response = await (params.requestSender ?? sendApnsRequest)({
token,
topic,
environment,
bearerToken,
payload: params.payload,
timeoutMs: resolveApnsTimeoutMs(params.timeoutMs),
pushType: params.pushType,
priority: params.priority,
...params.signal ? { signal: params.signal } : {},
...params.isCurrent ? { isCurrent: params.isCurrent } : {}
});
return toPushResult({
registration: params.registration,
response,
tokenSuffix: token.slice(-8)
});
}
async function sendRelayApnsPush(params) {
const response = await sendApnsRelayPush({
relayConfig: params.relayConfig,
sendGrant: params.registration.sendGrant,
relayHandle: params.registration.relayHandle,
payload: params.payload,
pushType: params.pushType,
priority: params.priority,
gatewayIdentity: params.gatewayIdentity,
requestSender: params.requestSender,
...params.signal ? { signal: params.signal } : {},
...params.isCurrent ? { isCurrent: params.isCurrent } : {}
});
return toPushResult({
registration: params.registration,
response
});
}
/** Sends a visible APNs alert via direct APNs token or relay registration. */
async function sendApnsAlert(params) {
const payload = createApnsAlertPayload({
nodeId: params.nodeId,
title: params.title,
body: params.body
});
if (params.registration.transport === "relay") {
const relayParams = params;
return await sendRelayApnsPush({
relayConfig: relayParams.relayConfig,
registration: relayParams.registration,
payload,
pushType: "alert",
priority: "10",
gatewayIdentity: relayParams.relayGatewayIdentity,
requestSender: relayParams.relayRequestSender,
...relayParams.signal ? { signal: relayParams.signal } : {},
...relayParams.isCurrent ? { isCurrent: relayParams.isCurrent } : {}
});
}
const directParams = params;
return await sendDirectApnsPush({
auth: directParams.auth,
registration: directParams.registration,
payload,
timeoutMs: directParams.timeoutMs,
requestSender: directParams.requestSender,
pushType: "alert",
priority: "10",
...directParams.signal ? { signal: directParams.signal } : {},
...directParams.isCurrent ? { isCurrent: directParams.isCurrent } : {}
});
}
/** Sends a silent background wake via direct APNs token or relay registration. */
async function sendApnsBackgroundWake(params) {
const payload = createApnsBackgroundPayload({
nodeId: params.nodeId,
wakeReason: params.wakeReason
});
if (params.registration.transport === "relay") {
const relayParams = params;
return await sendRelayApnsPush({
relayConfig: relayParams.relayConfig,
registration: relayParams.registration,
payload,
pushType: "background",
priority: "5",
gatewayIdentity: relayParams.relayGatewayIdentity,
requestSender: relayParams.relayRequestSender,
...relayParams.signal ? { signal: relayParams.signal } : {},
...relayParams.isCurrent ? { isCurrent: relayParams.isCurrent } : {}
});
}
const directParams = params;
return await sendDirectApnsPush({
auth: directParams.auth,
registration: directParams.registration,
payload,
timeoutMs: directParams.timeoutMs,
requestSender: directParams.requestSender,
pushType: "background",
priority: "5",
...directParams.signal ? { signal: directParams.signal } : {},
...directParams.isCurrent ? { isCurrent: directParams.isCurrent } : {}
});
}
async function sendApnsApprovalPush(params) {
const transport = params.transport;
if (transport.registration.transport === "relay") {
const relayParams = transport;
return await sendRelayApnsPush({
relayConfig: relayParams.relayConfig,
registration: relayParams.registration,
payload: params.payload,
pushType: params.pushType,
priority: params.priority,
gatewayIdentity: relayParams.relayGatewayIdentity,
requestSender: relayParams.relayRequestSender
});
}
const directParams = transport;
return await sendDirectApnsPush({
auth: directParams.auth,
registration: directParams.registration,
payload: params.payload,
timeoutMs: directParams.timeoutMs,
requestSender: directParams.requestSender,
pushType: params.pushType,
priority: params.priority
});
}
/** Sends an exec-approval alert notification via direct APNs or relay. */
async function sendApnsExecApprovalAlert(params) {
return await sendApnsApprovalPush({
transport: params,
payload: createApnsApprovalAlertPayload({
kind: "exec",
approvalId: params.approvalId,
gatewayDeviceId: params.gatewayDeviceId,
title: "Exec approval required",
body: resolveExecApprovalAlertBody(),
category: EXEC_APPROVAL_NOTIFICATION_CATEGORY
}),
pushType: "alert",
priority: "10"
});
}
/** Sends a plugin-approval alert notification via direct APNs or relay. */
async function sendApnsPluginApprovalAlert(params) {
return await sendApnsApprovalPush({
transport: params,
payload: createApnsApprovalAlertPayload({
kind: "plugin",
approvalId: params.approvalId,
gatewayDeviceId: params.gatewayDeviceId,
title: normalizeOptionalString(params.title) ?? "Approval required",
body: resolvePluginApprovalAlertBody(params.description),
category: PLUGIN_APPROVAL_NOTIFICATION_CATEGORY
}),
pushType: "alert",
priority: "10"
});
}
async function sendApnsApprovalResolvedWake(params) {
return await sendApnsApprovalPush({
transport: params.transport,
payload: createApnsApprovalResolvedPayload({
kind: params.kind,
approvalId: params.transport.approvalId,
gatewayDeviceId: params.transport.gatewayDeviceId
}),
pushType: "background",
priority: "5"
});
}
/** Sends a silent wake telling the app an exec approval changed state. */
async function sendApnsExecApprovalResolvedWake(params) {
return await sendApnsApprovalResolvedWake({
transport: params,
kind: "exec"
});
}
/** Sends a silent wake telling the app a plugin approval changed state. */
async function sendApnsPluginApprovalResolvedWake(params) {
return await sendApnsApprovalResolvedWake({
transport: params,
kind: "plugin"
});
}
//#endregion
export { sendApnsPluginApprovalAlert as a, resolveApnsAuthConfigFromEnv as c, sendApnsExecApprovalResolvedWake as i, sendApnsBackgroundWake as n, sendApnsPluginApprovalResolvedWake as o, sendApnsExecApprovalAlert as r, shouldClearStoredApnsRegistration as s, sendApnsAlert as t };