mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 12:41:37 +00:00
Gateway: add APNs push test pipeline (#20307)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 6a1c442207
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|
||||||
|
- Gateway/APNs: add a push-test pipeline for APNs delivery validation in gateway flows. (#20307) Thanks @mbelinky.
|
||||||
- iOS/Watch: add an Apple Watch companion MVP with watch inbox UI, watch notification relay handling, and gateway command surfaces for watch status/send flows. (#20054) Thanks @mbelinky.
|
- iOS/Watch: add an Apple Watch companion MVP with watch inbox UI, watch notification relay handling, and gateway command surfaces for watch status/send flows. (#20054) Thanks @mbelinky.
|
||||||
- Gateway/CLI: add paired-device hygiene flows with `device.pair.remove`, plus `openclaw devices remove` and guarded `openclaw devices clear --yes [--pending]` commands for removing paired entries and optionally rejecting pending requests. (#20057) Thanks @mbelinky.
|
- Gateway/CLI: add paired-device hygiene flows with `device.pair.remove`, plus `openclaw devices remove` and guarded `openclaw devices clear --yes [--pending]` commands for removing paired entries and optionally rejecting pending requests. (#20057) Thanks @mbelinky.
|
||||||
- Skills: harden coding-agent skill guidance by removing shell-command examples that interpolate untrusted issue text directly into command strings.
|
- Skills: harden coding-agent skill guidance by removing shell-command examples that interpolate untrusted issue text directly into command strings.
|
||||||
|
|||||||
88
src/cli/nodes-cli/register.push.ts
Normal file
88
src/cli/nodes-cli/register.push.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import type { Command } from "commander";
|
||||||
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
|
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
|
||||||
|
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||||
|
import type { NodesRpcOpts } from "./types.js";
|
||||||
|
|
||||||
|
type NodesPushOpts = NodesRpcOpts & {
|
||||||
|
node?: string;
|
||||||
|
title?: string;
|
||||||
|
body?: string;
|
||||||
|
environment?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeEnvironment(value: unknown): "sandbox" | "production" | null {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (normalized === "sandbox" || normalized === "production") {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerNodesPushCommand(nodes: Command) {
|
||||||
|
nodesCallOpts(
|
||||||
|
nodes
|
||||||
|
.command("push")
|
||||||
|
.description("Send an APNs test push to an iOS node")
|
||||||
|
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||||
|
.option("--title <text>", "Push title", "OpenClaw")
|
||||||
|
.option("--body <text>", "Push body")
|
||||||
|
.option("--environment <sandbox|production>", "Override APNs environment")
|
||||||
|
.action(async (opts: NodesPushOpts) => {
|
||||||
|
await runNodesCommand("push", async () => {
|
||||||
|
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
||||||
|
const title = String(opts.title ?? "").trim() || "OpenClaw";
|
||||||
|
const body = String(opts.body ?? "").trim() || `Push test for node ${nodeId}`;
|
||||||
|
const environment = normalizeEnvironment(opts.environment);
|
||||||
|
if (opts.environment && !environment) {
|
||||||
|
throw new Error("invalid --environment (use sandbox|production)");
|
||||||
|
}
|
||||||
|
|
||||||
|
const params: Record<string, unknown> = {
|
||||||
|
nodeId,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
if (environment) {
|
||||||
|
params.environment = environment;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await callGatewayCli("push.test", opts, params);
|
||||||
|
if (opts.json) {
|
||||||
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed =
|
||||||
|
typeof result === "object" && result !== null
|
||||||
|
? (result as {
|
||||||
|
ok?: unknown;
|
||||||
|
status?: unknown;
|
||||||
|
reason?: unknown;
|
||||||
|
environment?: unknown;
|
||||||
|
})
|
||||||
|
: {};
|
||||||
|
const ok = parsed.ok === true;
|
||||||
|
const status = typeof parsed.status === "number" ? parsed.status : 0;
|
||||||
|
const reason =
|
||||||
|
typeof parsed.reason === "string" && parsed.reason.trim().length > 0
|
||||||
|
? parsed.reason.trim()
|
||||||
|
: undefined;
|
||||||
|
const env =
|
||||||
|
typeof parsed.environment === "string" && parsed.environment.trim().length > 0
|
||||||
|
? parsed.environment.trim()
|
||||||
|
: "unknown";
|
||||||
|
const { ok: okLabel, error: errorLabel } = getNodesTheme();
|
||||||
|
const label = ok ? okLabel : errorLabel;
|
||||||
|
defaultRuntime.log(label(`push.test status=${status} ok=${ok} env=${env}`));
|
||||||
|
if (reason) {
|
||||||
|
defaultRuntime.log(`reason: ${reason}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
{ timeoutMs: 25_000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { registerNodesInvokeCommands } from "./register.invoke.js";
|
|||||||
import { registerNodesLocationCommands } from "./register.location.js";
|
import { registerNodesLocationCommands } from "./register.location.js";
|
||||||
import { registerNodesNotifyCommand } from "./register.notify.js";
|
import { registerNodesNotifyCommand } from "./register.notify.js";
|
||||||
import { registerNodesPairingCommands } from "./register.pairing.js";
|
import { registerNodesPairingCommands } from "./register.pairing.js";
|
||||||
|
import { registerNodesPushCommand } from "./register.push.js";
|
||||||
import { registerNodesScreenCommands } from "./register.screen.js";
|
import { registerNodesScreenCommands } from "./register.screen.js";
|
||||||
import { registerNodesStatusCommands } from "./register.status.js";
|
import { registerNodesStatusCommands } from "./register.status.js";
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export function registerNodesCli(program: Command) {
|
|||||||
registerNodesPairingCommands(nodes);
|
registerNodesPairingCommands(nodes);
|
||||||
registerNodesInvokeCommands(nodes);
|
registerNodesInvokeCommands(nodes);
|
||||||
registerNodesNotifyCommand(nodes);
|
registerNodesNotifyCommand(nodes);
|
||||||
|
registerNodesPushCommand(nodes);
|
||||||
registerNodesCanvasCommands(nodes);
|
registerNodesCanvasCommands(nodes);
|
||||||
registerNodesCameraCommands(nodes);
|
registerNodesCameraCommands(nodes);
|
||||||
registerNodesScreenCommands(nodes);
|
registerNodesScreenCommands(nodes);
|
||||||
|
|||||||
@@ -157,6 +157,9 @@ import {
|
|||||||
type PollParams,
|
type PollParams,
|
||||||
PollParamsSchema,
|
PollParamsSchema,
|
||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
|
type PushTestParams,
|
||||||
|
PushTestParamsSchema,
|
||||||
|
PushTestResultSchema,
|
||||||
type PresenceEntry,
|
type PresenceEntry,
|
||||||
PresenceEntrySchema,
|
PresenceEntrySchema,
|
||||||
ProtocolSchemas,
|
ProtocolSchemas,
|
||||||
@@ -277,6 +280,7 @@ export const validateNodeInvokeResultParams = ajv.compile<NodeInvokeResultParams
|
|||||||
NodeInvokeResultParamsSchema,
|
NodeInvokeResultParamsSchema,
|
||||||
);
|
);
|
||||||
export const validateNodeEventParams = ajv.compile<NodeEventParams>(NodeEventParamsSchema);
|
export const validateNodeEventParams = ajv.compile<NodeEventParams>(NodeEventParamsSchema);
|
||||||
|
export const validatePushTestParams = ajv.compile<PushTestParams>(PushTestParamsSchema);
|
||||||
export const validateSessionsListParams = ajv.compile<SessionsListParams>(SessionsListParamsSchema);
|
export const validateSessionsListParams = ajv.compile<SessionsListParams>(SessionsListParamsSchema);
|
||||||
export const validateSessionsPreviewParams = ajv.compile<SessionsPreviewParams>(
|
export const validateSessionsPreviewParams = ajv.compile<SessionsPreviewParams>(
|
||||||
SessionsPreviewParamsSchema,
|
SessionsPreviewParamsSchema,
|
||||||
@@ -428,6 +432,8 @@ export {
|
|||||||
AgentIdentityParamsSchema,
|
AgentIdentityParamsSchema,
|
||||||
AgentIdentityResultSchema,
|
AgentIdentityResultSchema,
|
||||||
WakeParamsSchema,
|
WakeParamsSchema,
|
||||||
|
PushTestParamsSchema,
|
||||||
|
PushTestResultSchema,
|
||||||
NodePairRequestParamsSchema,
|
NodePairRequestParamsSchema,
|
||||||
NodePairListParamsSchema,
|
NodePairListParamsSchema,
|
||||||
NodePairApproveParamsSchema,
|
NodePairApproveParamsSchema,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export * from "./schema/frames.js";
|
|||||||
export * from "./schema/logs-chat.js";
|
export * from "./schema/logs-chat.js";
|
||||||
export * from "./schema/nodes.js";
|
export * from "./schema/nodes.js";
|
||||||
export * from "./schema/protocol-schemas.js";
|
export * from "./schema/protocol-schemas.js";
|
||||||
|
export * from "./schema/push.js";
|
||||||
export * from "./schema/sessions.js";
|
export * from "./schema/sessions.js";
|
||||||
export * from "./schema/snapshot.js";
|
export * from "./schema/snapshot.js";
|
||||||
export * from "./schema/types.js";
|
export * from "./schema/types.js";
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ import {
|
|||||||
NodePairVerifyParamsSchema,
|
NodePairVerifyParamsSchema,
|
||||||
NodeRenameParamsSchema,
|
NodeRenameParamsSchema,
|
||||||
} from "./nodes.js";
|
} from "./nodes.js";
|
||||||
|
import { PushTestParamsSchema, PushTestResultSchema } from "./push.js";
|
||||||
import {
|
import {
|
||||||
SessionsCompactParamsSchema,
|
SessionsCompactParamsSchema,
|
||||||
SessionsDeleteParamsSchema,
|
SessionsDeleteParamsSchema,
|
||||||
@@ -171,6 +172,8 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
|||||||
NodeInvokeResultParams: NodeInvokeResultParamsSchema,
|
NodeInvokeResultParams: NodeInvokeResultParamsSchema,
|
||||||
NodeEventParams: NodeEventParamsSchema,
|
NodeEventParams: NodeEventParamsSchema,
|
||||||
NodeInvokeRequestEvent: NodeInvokeRequestEventSchema,
|
NodeInvokeRequestEvent: NodeInvokeRequestEventSchema,
|
||||||
|
PushTestParams: PushTestParamsSchema,
|
||||||
|
PushTestResult: PushTestResultSchema,
|
||||||
SessionsListParams: SessionsListParamsSchema,
|
SessionsListParams: SessionsListParamsSchema,
|
||||||
SessionsPreviewParams: SessionsPreviewParamsSchema,
|
SessionsPreviewParams: SessionsPreviewParamsSchema,
|
||||||
SessionsResolveParams: SessionsResolveParamsSchema,
|
SessionsResolveParams: SessionsResolveParamsSchema,
|
||||||
|
|||||||
27
src/gateway/protocol/schema/push.ts
Normal file
27
src/gateway/protocol/schema/push.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
import { NonEmptyString } from "./primitives.js";
|
||||||
|
|
||||||
|
const ApnsEnvironmentSchema = Type.String({ enum: ["sandbox", "production"] });
|
||||||
|
|
||||||
|
export const PushTestParamsSchema = Type.Object(
|
||||||
|
{
|
||||||
|
nodeId: NonEmptyString,
|
||||||
|
title: Type.Optional(Type.String()),
|
||||||
|
body: Type.Optional(Type.String()),
|
||||||
|
environment: Type.Optional(ApnsEnvironmentSchema),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PushTestResultSchema = Type.Object(
|
||||||
|
{
|
||||||
|
ok: Type.Boolean(),
|
||||||
|
status: Type.Integer(),
|
||||||
|
apnsId: Type.Optional(Type.String()),
|
||||||
|
reason: Type.Optional(Type.String()),
|
||||||
|
tokenSuffix: Type.String(),
|
||||||
|
topic: Type.String(),
|
||||||
|
environment: ApnsEnvironmentSchema,
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
@@ -111,6 +111,7 @@ import type {
|
|||||||
NodePairVerifyParamsSchema,
|
NodePairVerifyParamsSchema,
|
||||||
NodeRenameParamsSchema,
|
NodeRenameParamsSchema,
|
||||||
} from "./nodes.js";
|
} from "./nodes.js";
|
||||||
|
import type { PushTestParamsSchema, PushTestResultSchema } from "./push.js";
|
||||||
import type {
|
import type {
|
||||||
SessionsCompactParamsSchema,
|
SessionsCompactParamsSchema,
|
||||||
SessionsDeleteParamsSchema,
|
SessionsDeleteParamsSchema,
|
||||||
@@ -160,6 +161,8 @@ export type NodeDescribeParams = Static<typeof NodeDescribeParamsSchema>;
|
|||||||
export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
|
export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
|
||||||
export type NodeInvokeResultParams = Static<typeof NodeInvokeResultParamsSchema>;
|
export type NodeInvokeResultParams = Static<typeof NodeInvokeResultParamsSchema>;
|
||||||
export type NodeEventParams = Static<typeof NodeEventParamsSchema>;
|
export type NodeEventParams = Static<typeof NodeEventParamsSchema>;
|
||||||
|
export type PushTestParams = Static<typeof PushTestParamsSchema>;
|
||||||
|
export type PushTestResult = Static<typeof PushTestResultSchema>;
|
||||||
export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
|
export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
|
||||||
export type SessionsPreviewParams = Static<typeof SessionsPreviewParamsSchema>;
|
export type SessionsPreviewParams = Static<typeof SessionsPreviewParamsSchema>;
|
||||||
export type SessionsResolveParams = Static<typeof SessionsResolveParamsSchema>;
|
export type SessionsResolveParams = Static<typeof SessionsResolveParamsSchema>;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { healthHandlers } from "./server-methods/health.js";
|
|||||||
import { logsHandlers } from "./server-methods/logs.js";
|
import { logsHandlers } from "./server-methods/logs.js";
|
||||||
import { modelsHandlers } from "./server-methods/models.js";
|
import { modelsHandlers } from "./server-methods/models.js";
|
||||||
import { nodeHandlers } from "./server-methods/nodes.js";
|
import { nodeHandlers } from "./server-methods/nodes.js";
|
||||||
|
import { pushHandlers } from "./server-methods/push.js";
|
||||||
import { sendHandlers } from "./server-methods/send.js";
|
import { sendHandlers } from "./server-methods/send.js";
|
||||||
import { sessionsHandlers } from "./server-methods/sessions.js";
|
import { sessionsHandlers } from "./server-methods/sessions.js";
|
||||||
import { skillsHandlers } from "./server-methods/skills.js";
|
import { skillsHandlers } from "./server-methods/skills.js";
|
||||||
@@ -95,6 +96,7 @@ const WRITE_METHODS = new Set([
|
|||||||
"chat.send",
|
"chat.send",
|
||||||
"chat.abort",
|
"chat.abort",
|
||||||
"browser.request",
|
"browser.request",
|
||||||
|
"push.test",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) {
|
function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) {
|
||||||
@@ -190,6 +192,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
|||||||
...systemHandlers,
|
...systemHandlers,
|
||||||
...updateHandlers,
|
...updateHandlers,
|
||||||
...nodeHandlers,
|
...nodeHandlers,
|
||||||
|
...pushHandlers,
|
||||||
...sendHandlers,
|
...sendHandlers,
|
||||||
...usageHandlers,
|
...usageHandlers,
|
||||||
...agentHandlers,
|
...agentHandlers,
|
||||||
|
|||||||
101
src/gateway/server-methods/push.test.ts
Normal file
101
src/gateway/server-methods/push.test.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ErrorCodes } from "../protocol/index.js";
|
||||||
|
import { pushHandlers } from "./push.js";
|
||||||
|
|
||||||
|
vi.mock("../../infra/push-apns.js", () => ({
|
||||||
|
loadApnsRegistration: vi.fn(),
|
||||||
|
normalizeApnsEnvironment: vi.fn(),
|
||||||
|
resolveApnsAuthConfigFromEnv: vi.fn(),
|
||||||
|
sendApnsAlert: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
loadApnsRegistration,
|
||||||
|
normalizeApnsEnvironment,
|
||||||
|
resolveApnsAuthConfigFromEnv,
|
||||||
|
sendApnsAlert,
|
||||||
|
} from "../../infra/push-apns.js";
|
||||||
|
|
||||||
|
type RespondCall = [boolean, unknown?, { code: number; message: string }?];
|
||||||
|
|
||||||
|
function createInvokeParams(params: Record<string, unknown>) {
|
||||||
|
const respond = vi.fn();
|
||||||
|
return {
|
||||||
|
respond,
|
||||||
|
invoke: async () =>
|
||||||
|
await pushHandlers["push.test"]({
|
||||||
|
params,
|
||||||
|
respond: respond as never,
|
||||||
|
context: {} as never,
|
||||||
|
client: null,
|
||||||
|
req: { type: "req", id: "req-1", method: "push.test" },
|
||||||
|
isWebchatConnect: () => false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("push.test handler", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(loadApnsRegistration).mockReset();
|
||||||
|
vi.mocked(normalizeApnsEnvironment).mockReset();
|
||||||
|
vi.mocked(resolveApnsAuthConfigFromEnv).mockReset();
|
||||||
|
vi.mocked(sendApnsAlert).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid params", async () => {
|
||||||
|
const { respond, invoke } = createInvokeParams({ title: "hello" });
|
||||||
|
await invoke();
|
||||||
|
const call = respond.mock.calls[0] as RespondCall | undefined;
|
||||||
|
expect(call?.[0]).toBe(false);
|
||||||
|
expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST);
|
||||||
|
expect(call?.[2]?.message).toContain("invalid push.test params");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns invalid request when node has no APNs registration", async () => {
|
||||||
|
vi.mocked(loadApnsRegistration).mockResolvedValue(null);
|
||||||
|
const { respond, invoke } = createInvokeParams({ nodeId: "ios-node-1" });
|
||||||
|
await invoke();
|
||||||
|
const call = respond.mock.calls[0] as RespondCall | undefined;
|
||||||
|
expect(call?.[0]).toBe(false);
|
||||||
|
expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST);
|
||||||
|
expect(call?.[2]?.message).toContain("has no APNs registration");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends push test when registration and auth are available", async () => {
|
||||||
|
vi.mocked(loadApnsRegistration).mockResolvedValue({
|
||||||
|
nodeId: "ios-node-1",
|
||||||
|
token: "abcd",
|
||||||
|
topic: "ai.openclaw.ios",
|
||||||
|
environment: "sandbox",
|
||||||
|
updatedAtMs: 1,
|
||||||
|
});
|
||||||
|
vi.mocked(resolveApnsAuthConfigFromEnv).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
teamId: "TEAM123",
|
||||||
|
keyId: "KEY123",
|
||||||
|
privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(normalizeApnsEnvironment).mockReturnValue(null);
|
||||||
|
vi.mocked(sendApnsAlert).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
tokenSuffix: "1234abcd",
|
||||||
|
topic: "ai.openclaw.ios",
|
||||||
|
environment: "sandbox",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { respond, invoke } = createInvokeParams({
|
||||||
|
nodeId: "ios-node-1",
|
||||||
|
title: "Wake",
|
||||||
|
body: "Ping",
|
||||||
|
});
|
||||||
|
await invoke();
|
||||||
|
|
||||||
|
expect(sendApnsAlert).toHaveBeenCalledTimes(1);
|
||||||
|
const call = respond.mock.calls[0] as RespondCall | undefined;
|
||||||
|
expect(call?.[0]).toBe(true);
|
||||||
|
expect(call?.[1]).toMatchObject({ ok: true, status: 200 });
|
||||||
|
});
|
||||||
|
});
|
||||||
73
src/gateway/server-methods/push.ts
Normal file
73
src/gateway/server-methods/push.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
loadApnsRegistration,
|
||||||
|
normalizeApnsEnvironment,
|
||||||
|
resolveApnsAuthConfigFromEnv,
|
||||||
|
sendApnsAlert,
|
||||||
|
} from "../../infra/push-apns.js";
|
||||||
|
import { ErrorCodes, errorShape, validatePushTestParams } from "../protocol/index.js";
|
||||||
|
import { respondInvalidParams, respondUnavailableOnThrow } from "./nodes.helpers.js";
|
||||||
|
import type { GatewayRequestHandlers } from "./types.js";
|
||||||
|
|
||||||
|
function normalizeOptionalString(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pushHandlers: GatewayRequestHandlers = {
|
||||||
|
"push.test": async ({ params, respond }) => {
|
||||||
|
if (!validatePushTestParams(params)) {
|
||||||
|
respondInvalidParams({
|
||||||
|
respond,
|
||||||
|
method: "push.test",
|
||||||
|
validator: validatePushTestParams,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeId = String(params.nodeId ?? "").trim();
|
||||||
|
if (!nodeId) {
|
||||||
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = normalizeOptionalString(params.title) ?? "OpenClaw";
|
||||||
|
const body = normalizeOptionalString(params.body) ?? `Push test for node ${nodeId}`;
|
||||||
|
|
||||||
|
await respondUnavailableOnThrow(respond, async () => {
|
||||||
|
const registration = await loadApnsRegistration(nodeId);
|
||||||
|
if (!registration) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`node ${nodeId} has no APNs registration (connect iOS node first)`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = await resolveApnsAuthConfigFromEnv(process.env);
|
||||||
|
if (!auth.ok) {
|
||||||
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, auth.error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrideEnvironment = normalizeApnsEnvironment(params.environment);
|
||||||
|
const result = await sendApnsAlert({
|
||||||
|
auth: auth.value,
|
||||||
|
registration: {
|
||||||
|
...registration,
|
||||||
|
environment: overrideEnvironment ?? registration.environment,
|
||||||
|
},
|
||||||
|
nodeId,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
respond(true, result, undefined);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@ import { updateSessionStore } from "../config/sessions.js";
|
|||||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||||
import { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
|
import { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
|
||||||
import { resolveOutboundTarget } from "../infra/outbound/targets.js";
|
import { resolveOutboundTarget } from "../infra/outbound/targets.js";
|
||||||
|
import { registerApnsToken } from "../infra/push-apns.js";
|
||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import { normalizeMainKey } from "../routing/session-key.js";
|
import { normalizeMainKey } from "../routing/session-key.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
@@ -508,6 +509,33 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
|||||||
requestHeartbeatNow({ reason: "exec-event" });
|
requestHeartbeatNow({ reason: "exec-event" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case "push.apns.register": {
|
||||||
|
if (!evt.payloadJSON) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(evt.payloadJSON) as unknown;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const obj =
|
||||||
|
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : {};
|
||||||
|
const token = typeof obj.token === "string" ? obj.token : "";
|
||||||
|
const topic = typeof obj.topic === "string" ? obj.topic : "";
|
||||||
|
const environment = obj.environment;
|
||||||
|
try {
|
||||||
|
await registerApnsToken({
|
||||||
|
nodeId,
|
||||||
|
token,
|
||||||
|
topic,
|
||||||
|
environment,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
ctx.logGateway.warn(`push apns register failed node=${nodeId}: ${formatForLog(err)}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
94
src/infra/push-apns.test.ts
Normal file
94
src/infra/push-apns.test.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
loadApnsRegistration,
|
||||||
|
normalizeApnsEnvironment,
|
||||||
|
registerApnsToken,
|
||||||
|
resolveApnsAuthConfigFromEnv,
|
||||||
|
} from "./push-apns.js";
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
async function makeTempDir(): Promise<string> {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-push-apns-test-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
while (tempDirs.length > 0) {
|
||||||
|
const dir = tempDirs.pop();
|
||||||
|
if (dir) {
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("push APNs registration store", () => {
|
||||||
|
it("stores and reloads node APNs registration", async () => {
|
||||||
|
const baseDir = await makeTempDir();
|
||||||
|
const saved = await registerApnsToken({
|
||||||
|
nodeId: "ios-node-1",
|
||||||
|
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
|
||||||
|
topic: "ai.openclaw.ios",
|
||||||
|
environment: "sandbox",
|
||||||
|
baseDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaded = await loadApnsRegistration("ios-node-1", baseDir);
|
||||||
|
expect(loaded).not.toBeNull();
|
||||||
|
expect(loaded?.nodeId).toBe("ios-node-1");
|
||||||
|
expect(loaded?.token).toBe("abcd1234abcd1234abcd1234abcd1234");
|
||||||
|
expect(loaded?.topic).toBe("ai.openclaw.ios");
|
||||||
|
expect(loaded?.environment).toBe("sandbox");
|
||||||
|
expect(loaded?.updatedAtMs).toBe(saved.updatedAtMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid APNs tokens", async () => {
|
||||||
|
const baseDir = await makeTempDir();
|
||||||
|
await expect(
|
||||||
|
registerApnsToken({
|
||||||
|
nodeId: "ios-node-1",
|
||||||
|
token: "not-a-token",
|
||||||
|
topic: "ai.openclaw.ios",
|
||||||
|
baseDir,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("invalid APNs token");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("push APNs env config", () => {
|
||||||
|
it("normalizes APNs environment values", () => {
|
||||||
|
expect(normalizeApnsEnvironment("sandbox")).toBe("sandbox");
|
||||||
|
expect(normalizeApnsEnvironment("PRODUCTION")).toBe("production");
|
||||||
|
expect(normalizeApnsEnvironment("staging")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves inline private key and unescapes newlines", async () => {
|
||||||
|
const env = {
|
||||||
|
OPENCLAW_APNS_TEAM_ID: "TEAM123",
|
||||||
|
OPENCLAW_APNS_KEY_ID: "KEY123",
|
||||||
|
OPENCLAW_APNS_PRIVATE_KEY_P8:
|
||||||
|
"-----BEGIN PRIVATE KEY-----\\nline-a\\nline-b\\n-----END PRIVATE KEY-----",
|
||||||
|
} as NodeJS.ProcessEnv;
|
||||||
|
const resolved = await resolveApnsAuthConfigFromEnv(env);
|
||||||
|
expect(resolved.ok).toBe(true);
|
||||||
|
if (!resolved.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(resolved.value.privateKey).toContain("\nline-a\n");
|
||||||
|
expect(resolved.value.teamId).toBe("TEAM123");
|
||||||
|
expect(resolved.value.keyId).toBe("KEY123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an error when required APNs auth vars are missing", async () => {
|
||||||
|
const resolved = await resolveApnsAuthConfigFromEnv({} as NodeJS.ProcessEnv);
|
||||||
|
expect(resolved.ok).toBe(false);
|
||||||
|
if (resolved.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(resolved.error).toContain("OPENCLAW_APNS_TEAM_ID");
|
||||||
|
});
|
||||||
|
});
|
||||||
409
src/infra/push-apns.ts
Normal file
409
src/infra/push-apns.ts
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
import { createHash, createPrivateKey, sign as signJwt } from "node:crypto";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import http2 from "node:http2";
|
||||||
|
import path from "node:path";
|
||||||
|
import { resolveStateDir } from "../config/paths.js";
|
||||||
|
import { createAsyncLock, readJsonFile, writeJsonAtomic } from "./json-files.js";
|
||||||
|
|
||||||
|
export type ApnsEnvironment = "sandbox" | "production";
|
||||||
|
|
||||||
|
export type ApnsRegistration = {
|
||||||
|
nodeId: string;
|
||||||
|
token: string;
|
||||||
|
topic: string;
|
||||||
|
environment: ApnsEnvironment;
|
||||||
|
updatedAtMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApnsAuthConfig = {
|
||||||
|
teamId: string;
|
||||||
|
keyId: string;
|
||||||
|
privateKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApnsAuthConfigResolution =
|
||||||
|
| { ok: true; value: ApnsAuthConfig }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
export type ApnsPushAlertResult = {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
apnsId?: string;
|
||||||
|
reason?: string;
|
||||||
|
tokenSuffix: string;
|
||||||
|
topic: string;
|
||||||
|
environment: ApnsEnvironment;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApnsRegistrationState = {
|
||||||
|
registrationsByNodeId: Record<string, ApnsRegistration>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const APNS_STATE_FILENAME = "push/apns-registrations.json";
|
||||||
|
const APNS_JWT_TTL_MS = 50 * 60 * 1000;
|
||||||
|
const DEFAULT_APNS_TIMEOUT_MS = 10_000;
|
||||||
|
const withLock = createAsyncLock();
|
||||||
|
|
||||||
|
let cachedJwt: { cacheKey: string; token: string; expiresAtMs: number } | null = null;
|
||||||
|
|
||||||
|
function resolveApnsRegistrationPath(baseDir?: string): string {
|
||||||
|
const root = baseDir ?? resolveStateDir();
|
||||||
|
return path.join(root, APNS_STATE_FILENAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNodeId(value: string): string {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeApnsToken(value: string): string {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.replace(/[<>\s]/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTopic(value: string): string {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyApnsToken(value: string): boolean {
|
||||||
|
return /^[0-9a-f]{32,}$/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseReason(body: string): string | undefined {
|
||||||
|
const trimmed = body.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as { reason?: unknown };
|
||||||
|
return typeof parsed.reason === "string" && parsed.reason.trim().length > 0
|
||||||
|
? parsed.reason.trim()
|
||||||
|
: trimmed.slice(0, 200);
|
||||||
|
} catch {
|
||||||
|
return trimmed.slice(0, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBase64UrlBytes(value: Uint8Array): string {
|
||||||
|
return Buffer.from(value)
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBase64UrlJson(value: object): string {
|
||||||
|
return toBase64UrlBytes(Buffer.from(JSON.stringify(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJwtCacheKey(auth: ApnsAuthConfig): string {
|
||||||
|
const keyHash = createHash("sha256").update(auth.privateKey).digest("hex");
|
||||||
|
return `${auth.teamId}:${auth.keyId}:${keyHash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApnsBearerToken(auth: ApnsAuthConfig, nowMs: number = Date.now()): string {
|
||||||
|
const cacheKey = getJwtCacheKey(auth);
|
||||||
|
if (cachedJwt && cachedJwt.cacheKey === cacheKey && nowMs < cachedJwt.expiresAtMs) {
|
||||||
|
return cachedJwt.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iat = Math.floor(nowMs / 1000);
|
||||||
|
const header = toBase64UrlJson({ alg: "ES256", kid: auth.keyId, typ: "JWT" });
|
||||||
|
const payload = toBase64UrlJson({ iss: auth.teamId, iat });
|
||||||
|
const signingInput = `${header}.${payload}`;
|
||||||
|
const signature = signJwt("sha256", Buffer.from(signingInput, "utf8"), {
|
||||||
|
key: createPrivateKey(auth.privateKey),
|
||||||
|
dsaEncoding: "ieee-p1363",
|
||||||
|
});
|
||||||
|
const token = `${signingInput}.${toBase64UrlBytes(signature)}`;
|
||||||
|
cachedJwt = {
|
||||||
|
cacheKey,
|
||||||
|
token,
|
||||||
|
expiresAtMs: nowMs + APNS_JWT_TTL_MS,
|
||||||
|
};
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePrivateKey(value: string): string {
|
||||||
|
return value.trim().replace(/\\n/g, "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNonEmptyString(value: string | undefined): string | null {
|
||||||
|
const trimmed = value?.trim() ?? "";
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRegistrationsState(baseDir?: string): Promise<ApnsRegistrationState> {
|
||||||
|
const filePath = resolveApnsRegistrationPath(baseDir);
|
||||||
|
const existing = await readJsonFile<ApnsRegistrationState>(filePath);
|
||||||
|
if (!existing || typeof existing !== "object") {
|
||||||
|
return { registrationsByNodeId: {} };
|
||||||
|
}
|
||||||
|
const registrations =
|
||||||
|
existing.registrationsByNodeId &&
|
||||||
|
typeof existing.registrationsByNodeId === "object" &&
|
||||||
|
!Array.isArray(existing.registrationsByNodeId)
|
||||||
|
? existing.registrationsByNodeId
|
||||||
|
: {};
|
||||||
|
return { registrationsByNodeId: registrations };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistRegistrationsState(
|
||||||
|
state: ApnsRegistrationState,
|
||||||
|
baseDir?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const filePath = resolveApnsRegistrationPath(baseDir);
|
||||||
|
await writeJsonAtomic(filePath, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeApnsEnvironment(value: unknown): ApnsEnvironment | null {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (normalized === "sandbox" || normalized === "production") {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerApnsToken(params: {
|
||||||
|
nodeId: string;
|
||||||
|
token: string;
|
||||||
|
topic: string;
|
||||||
|
environment?: unknown;
|
||||||
|
baseDir?: string;
|
||||||
|
}): Promise<ApnsRegistration> {
|
||||||
|
const nodeId = normalizeNodeId(params.nodeId);
|
||||||
|
const token = normalizeApnsToken(params.token);
|
||||||
|
const topic = normalizeTopic(params.topic);
|
||||||
|
const environment = normalizeApnsEnvironment(params.environment) ?? "sandbox";
|
||||||
|
|
||||||
|
if (!nodeId) {
|
||||||
|
throw new Error("nodeId required");
|
||||||
|
}
|
||||||
|
if (!topic) {
|
||||||
|
throw new Error("topic required");
|
||||||
|
}
|
||||||
|
if (!isLikelyApnsToken(token)) {
|
||||||
|
throw new Error("invalid APNs token");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await withLock(async () => {
|
||||||
|
const state = await loadRegistrationsState(params.baseDir);
|
||||||
|
const next: ApnsRegistration = {
|
||||||
|
nodeId,
|
||||||
|
token,
|
||||||
|
topic,
|
||||||
|
environment,
|
||||||
|
updatedAtMs: Date.now(),
|
||||||
|
};
|
||||||
|
state.registrationsByNodeId[nodeId] = next;
|
||||||
|
await persistRegistrationsState(state, params.baseDir);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadApnsRegistration(
|
||||||
|
nodeId: string,
|
||||||
|
baseDir?: string,
|
||||||
|
): Promise<ApnsRegistration | null> {
|
||||||
|
const normalizedNodeId = normalizeNodeId(nodeId);
|
||||||
|
if (!normalizedNodeId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const state = await loadRegistrationsState(baseDir);
|
||||||
|
return state.registrationsByNodeId[normalizedNodeId] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveApnsAuthConfigFromEnv(
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): Promise<ApnsAuthConfigResolution> {
|
||||||
|
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 {
|
||||||
|
const privateKey = normalizePrivateKey(await fs.readFile(keyPath, "utf8"));
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
teamId,
|
||||||
|
keyId,
|
||||||
|
privateKey,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `failed reading OPENCLAW_APNS_PRIVATE_KEY_PATH (${keyPath}): ${message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendApnsRequest(params: {
|
||||||
|
token: string;
|
||||||
|
topic: string;
|
||||||
|
environment: ApnsEnvironment;
|
||||||
|
bearerToken: string;
|
||||||
|
payload: object;
|
||||||
|
timeoutMs: number;
|
||||||
|
}): Promise<{ status: number; apnsId?: string; body: string }> {
|
||||||
|
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}`;
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const client = http2.connect(authority);
|
||||||
|
let settled = false;
|
||||||
|
const fail = (err: unknown) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
client.destroy();
|
||||||
|
reject(err);
|
||||||
|
};
|
||||||
|
const finish = (result: { status: number; apnsId?: string; body: string }) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
client.close();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
client.once("error", (err) => fail(err));
|
||||||
|
|
||||||
|
const req = client.request({
|
||||||
|
":method": "POST",
|
||||||
|
":path": requestPath,
|
||||||
|
authorization: `bearer ${params.bearerToken}`,
|
||||||
|
"apns-topic": params.topic,
|
||||||
|
"apns-push-type": "alert",
|
||||||
|
"apns-priority": "10",
|
||||||
|
"apns-expiration": "0",
|
||||||
|
"content-type": "application/json",
|
||||||
|
"content-length": Buffer.byteLength(body).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let statusCode = 0;
|
||||||
|
let apnsId: string | undefined;
|
||||||
|
let responseBody = "";
|
||||||
|
|
||||||
|
req.setEncoding("utf8");
|
||||||
|
req.setTimeout(params.timeoutMs, () => {
|
||||||
|
req.close(http2.constants.NGHTTP2_CANCEL);
|
||||||
|
fail(new Error(`APNs request timed out after ${params.timeoutMs}ms`));
|
||||||
|
});
|
||||||
|
req.on("response", (headers) => {
|
||||||
|
const statusHeader = headers[":status"];
|
||||||
|
statusCode = typeof statusHeader === "number" ? statusHeader : Number(statusHeader ?? 0);
|
||||||
|
const idHeader = headers["apns-id"];
|
||||||
|
if (typeof idHeader === "string" && idHeader.trim().length > 0) {
|
||||||
|
apnsId = idHeader.trim();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on("data", (chunk) => {
|
||||||
|
if (typeof chunk === "string") {
|
||||||
|
responseBody += chunk;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on("end", () => {
|
||||||
|
finish({ status: statusCode, apnsId, body: responseBody });
|
||||||
|
});
|
||||||
|
req.on("error", (err) => fail(err));
|
||||||
|
|
||||||
|
req.end(body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendApnsAlert(params: {
|
||||||
|
auth: ApnsAuthConfig;
|
||||||
|
registration: ApnsRegistration;
|
||||||
|
nodeId: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): Promise<ApnsPushAlertResult> {
|
||||||
|
const token = normalizeApnsToken(params.registration.token);
|
||||||
|
if (!isLikelyApnsToken(token)) {
|
||||||
|
throw new Error("invalid APNs token");
|
||||||
|
}
|
||||||
|
const topic = normalizeTopic(params.registration.topic);
|
||||||
|
if (!topic) {
|
||||||
|
throw new Error("topic required");
|
||||||
|
}
|
||||||
|
const environment = params.registration.environment;
|
||||||
|
const bearerToken = getApnsBearerToken(params.auth);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
aps: {
|
||||||
|
alert: {
|
||||||
|
title: params.title,
|
||||||
|
body: params.body,
|
||||||
|
},
|
||||||
|
sound: "default",
|
||||||
|
},
|
||||||
|
openclaw: {
|
||||||
|
kind: "push.test",
|
||||||
|
nodeId: params.nodeId,
|
||||||
|
ts: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await sendApnsRequest({
|
||||||
|
token,
|
||||||
|
topic,
|
||||||
|
environment,
|
||||||
|
bearerToken,
|
||||||
|
payload,
|
||||||
|
timeoutMs:
|
||||||
|
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
|
||||||
|
? Math.max(1000, Math.trunc(params.timeoutMs))
|
||||||
|
: DEFAULT_APNS_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: response.status === 200,
|
||||||
|
status: response.status,
|
||||||
|
apnsId: response.apnsId,
|
||||||
|
reason: parseReason(response.body),
|
||||||
|
tokenSuffix: token.slice(-8),
|
||||||
|
topic,
|
||||||
|
environment,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user