mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
195 lines
5.5 KiB
TypeScript
195 lines
5.5 KiB
TypeScript
import { URL } from "node:url";
|
|
|
|
export type ApnsRelayPushType = "alert" | "background";
|
|
|
|
export type ApnsRelayConfig = {
|
|
baseUrl: string;
|
|
authToken: string;
|
|
timeoutMs: number;
|
|
};
|
|
|
|
export type ApnsRelayConfigResolution =
|
|
| { ok: true; value: ApnsRelayConfig }
|
|
| { ok: false; error: string };
|
|
|
|
export type ApnsRelayPushResponse = {
|
|
ok: boolean;
|
|
status: number;
|
|
apnsId?: string;
|
|
reason?: string;
|
|
environment: "production";
|
|
tokenSuffix?: string;
|
|
};
|
|
|
|
export type ApnsRelayRequestSender = (params: {
|
|
relayConfig: ApnsRelayConfig;
|
|
relayHandle: string;
|
|
pushType: ApnsRelayPushType;
|
|
priority: "10" | "5";
|
|
payload: object;
|
|
}) => Promise<ApnsRelayPushResponse>;
|
|
|
|
const DEFAULT_APNS_RELAY_TIMEOUT_MS = 10_000;
|
|
|
|
function normalizeNonEmptyString(value: string | undefined): string | null {
|
|
const trimmed = value?.trim() ?? "";
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
function normalizeTimeoutMs(value: string | undefined): number {
|
|
const raw = value?.trim();
|
|
if (!raw) {
|
|
return DEFAULT_APNS_RELAY_TIMEOUT_MS;
|
|
}
|
|
const parsed = Number(raw);
|
|
if (!Number.isFinite(parsed)) {
|
|
return DEFAULT_APNS_RELAY_TIMEOUT_MS;
|
|
}
|
|
return Math.max(1000, Math.trunc(parsed));
|
|
}
|
|
|
|
function readAllowHttp(value: string | undefined): boolean {
|
|
const normalized = value?.trim().toLowerCase();
|
|
return normalized === "1" || normalized === "true" || normalized === "yes";
|
|
}
|
|
|
|
function isLoopbackRelayHostname(hostname: string): boolean {
|
|
const normalized = hostname.trim().toLowerCase();
|
|
return (
|
|
normalized === "localhost" ||
|
|
normalized === "::1" ||
|
|
normalized === "[::1]" ||
|
|
/^127(?:\.\d{1,3}){3}$/.test(normalized)
|
|
);
|
|
}
|
|
|
|
function parseReason(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
|
}
|
|
|
|
export function resolveApnsRelayConfigFromEnv(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): ApnsRelayConfigResolution {
|
|
const baseUrl = normalizeNonEmptyString(env.OPENCLAW_APNS_RELAY_BASE_URL);
|
|
const authToken = normalizeNonEmptyString(env.OPENCLAW_APNS_RELAY_AUTH_TOKEN);
|
|
if (!baseUrl || !authToken) {
|
|
return {
|
|
ok: false,
|
|
error:
|
|
"APNs relay config missing: set OPENCLAW_APNS_RELAY_BASE_URL and OPENCLAW_APNS_RELAY_AUTH_TOKEN",
|
|
};
|
|
}
|
|
|
|
try {
|
|
const parsed = new URL(baseUrl);
|
|
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
throw new Error("unsupported protocol");
|
|
}
|
|
if (!parsed.hostname) {
|
|
throw new Error("host required");
|
|
}
|
|
if (parsed.protocol === "http:" && !readAllowHttp(env.OPENCLAW_APNS_RELAY_ALLOW_HTTP)) {
|
|
throw new Error(
|
|
"http relay URLs require OPENCLAW_APNS_RELAY_ALLOW_HTTP=true (development only)",
|
|
);
|
|
}
|
|
if (parsed.protocol === "http:" && !isLoopbackRelayHostname(parsed.hostname)) {
|
|
throw new Error("http relay URLs are limited to loopback hosts");
|
|
}
|
|
if (parsed.username || parsed.password) {
|
|
throw new Error("userinfo is not allowed");
|
|
}
|
|
if (parsed.search || parsed.hash) {
|
|
throw new Error("query and fragment are not allowed");
|
|
}
|
|
return {
|
|
ok: true,
|
|
value: {
|
|
baseUrl: parsed.toString().replace(/\/+$/, ""),
|
|
authToken,
|
|
timeoutMs: normalizeTimeoutMs(env.OPENCLAW_APNS_RELAY_TIMEOUT_MS),
|
|
},
|
|
};
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return {
|
|
ok: false,
|
|
error: `invalid OPENCLAW_APNS_RELAY_BASE_URL (${baseUrl}): ${message}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function sendApnsRelayRequest(params: {
|
|
relayConfig: ApnsRelayConfig;
|
|
relayHandle: string;
|
|
pushType: ApnsRelayPushType;
|
|
priority: "10" | "5";
|
|
payload: object;
|
|
}): Promise<ApnsRelayPushResponse> {
|
|
const response = await fetch(`${params.relayConfig.baseUrl}/v1/push/send`, {
|
|
method: "POST",
|
|
redirect: "manual",
|
|
headers: {
|
|
authorization: `Bearer ${params.relayConfig.authToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
relayHandle: params.relayHandle,
|
|
pushType: params.pushType,
|
|
priority: Number(params.priority),
|
|
payload: params.payload,
|
|
}),
|
|
signal: AbortSignal.timeout(params.relayConfig.timeoutMs),
|
|
});
|
|
if (response.status >= 300 && response.status < 400) {
|
|
return {
|
|
ok: false,
|
|
status: response.status,
|
|
reason: "RelayRedirectNotAllowed",
|
|
environment: "production",
|
|
};
|
|
}
|
|
|
|
let json: unknown = null;
|
|
try {
|
|
json = (await response.json()) as unknown;
|
|
} catch {
|
|
json = null;
|
|
}
|
|
const body =
|
|
json && typeof json === "object" && !Array.isArray(json)
|
|
? (json as Record<string, unknown>)
|
|
: {};
|
|
|
|
const status =
|
|
typeof body.status === "number" && Number.isFinite(body.status)
|
|
? Math.trunc(body.status)
|
|
: response.status;
|
|
return {
|
|
ok: typeof body.ok === "boolean" ? body.ok : response.ok && status >= 200 && status < 300,
|
|
status,
|
|
apnsId: parseReason(body.apnsId),
|
|
reason: parseReason(body.reason),
|
|
environment: "production",
|
|
tokenSuffix: parseReason(body.tokenSuffix),
|
|
};
|
|
}
|
|
|
|
export async function sendApnsRelayPush(params: {
|
|
relayConfig: ApnsRelayConfig;
|
|
relayHandle: string;
|
|
pushType: ApnsRelayPushType;
|
|
priority: "10" | "5";
|
|
payload: object;
|
|
requestSender?: ApnsRelayRequestSender;
|
|
}): Promise<ApnsRelayPushResponse> {
|
|
const sender = params.requestSender ?? sendApnsRelayRequest;
|
|
return await sender({
|
|
relayConfig: params.relayConfig,
|
|
relayHandle: params.relayHandle,
|
|
pushType: params.pushType,
|
|
priority: params.priority,
|
|
payload: params.payload,
|
|
});
|
|
}
|