mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 21:51:24 +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:
@@ -157,6 +157,9 @@ import {
|
||||
type PollParams,
|
||||
PollParamsSchema,
|
||||
PROTOCOL_VERSION,
|
||||
type PushTestParams,
|
||||
PushTestParamsSchema,
|
||||
PushTestResultSchema,
|
||||
type PresenceEntry,
|
||||
PresenceEntrySchema,
|
||||
ProtocolSchemas,
|
||||
@@ -277,6 +280,7 @@ export const validateNodeInvokeResultParams = ajv.compile<NodeInvokeResultParams
|
||||
NodeInvokeResultParamsSchema,
|
||||
);
|
||||
export const validateNodeEventParams = ajv.compile<NodeEventParams>(NodeEventParamsSchema);
|
||||
export const validatePushTestParams = ajv.compile<PushTestParams>(PushTestParamsSchema);
|
||||
export const validateSessionsListParams = ajv.compile<SessionsListParams>(SessionsListParamsSchema);
|
||||
export const validateSessionsPreviewParams = ajv.compile<SessionsPreviewParams>(
|
||||
SessionsPreviewParamsSchema,
|
||||
@@ -428,6 +432,8 @@ export {
|
||||
AgentIdentityParamsSchema,
|
||||
AgentIdentityResultSchema,
|
||||
WakeParamsSchema,
|
||||
PushTestParamsSchema,
|
||||
PushTestResultSchema,
|
||||
NodePairRequestParamsSchema,
|
||||
NodePairListParamsSchema,
|
||||
NodePairApproveParamsSchema,
|
||||
|
||||
@@ -10,6 +10,7 @@ export * from "./schema/frames.js";
|
||||
export * from "./schema/logs-chat.js";
|
||||
export * from "./schema/nodes.js";
|
||||
export * from "./schema/protocol-schemas.js";
|
||||
export * from "./schema/push.js";
|
||||
export * from "./schema/sessions.js";
|
||||
export * from "./schema/snapshot.js";
|
||||
export * from "./schema/types.js";
|
||||
|
||||
@@ -118,6 +118,7 @@ import {
|
||||
NodePairVerifyParamsSchema,
|
||||
NodeRenameParamsSchema,
|
||||
} from "./nodes.js";
|
||||
import { PushTestParamsSchema, PushTestResultSchema } from "./push.js";
|
||||
import {
|
||||
SessionsCompactParamsSchema,
|
||||
SessionsDeleteParamsSchema,
|
||||
@@ -171,6 +172,8 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
NodeInvokeResultParams: NodeInvokeResultParamsSchema,
|
||||
NodeEventParams: NodeEventParamsSchema,
|
||||
NodeInvokeRequestEvent: NodeInvokeRequestEventSchema,
|
||||
PushTestParams: PushTestParamsSchema,
|
||||
PushTestResult: PushTestResultSchema,
|
||||
SessionsListParams: SessionsListParamsSchema,
|
||||
SessionsPreviewParams: SessionsPreviewParamsSchema,
|
||||
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,
|
||||
NodeRenameParamsSchema,
|
||||
} from "./nodes.js";
|
||||
import type { PushTestParamsSchema, PushTestResultSchema } from "./push.js";
|
||||
import type {
|
||||
SessionsCompactParamsSchema,
|
||||
SessionsDeleteParamsSchema,
|
||||
@@ -160,6 +161,8 @@ export type NodeDescribeParams = Static<typeof NodeDescribeParamsSchema>;
|
||||
export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
|
||||
export type NodeInvokeResultParams = Static<typeof NodeInvokeResultParamsSchema>;
|
||||
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 SessionsPreviewParams = Static<typeof SessionsPreviewParamsSchema>;
|
||||
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 { modelsHandlers } from "./server-methods/models.js";
|
||||
import { nodeHandlers } from "./server-methods/nodes.js";
|
||||
import { pushHandlers } from "./server-methods/push.js";
|
||||
import { sendHandlers } from "./server-methods/send.js";
|
||||
import { sessionsHandlers } from "./server-methods/sessions.js";
|
||||
import { skillsHandlers } from "./server-methods/skills.js";
|
||||
@@ -95,6 +96,7 @@ const WRITE_METHODS = new Set([
|
||||
"chat.send",
|
||||
"chat.abort",
|
||||
"browser.request",
|
||||
"push.test",
|
||||
]);
|
||||
|
||||
function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) {
|
||||
@@ -190,6 +192,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
...systemHandlers,
|
||||
...updateHandlers,
|
||||
...nodeHandlers,
|
||||
...pushHandlers,
|
||||
...sendHandlers,
|
||||
...usageHandlers,
|
||||
...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 { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
|
||||
import { resolveOutboundTarget } from "../infra/outbound/targets.js";
|
||||
import { registerApnsToken } from "../infra/push-apns.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import { normalizeMainKey } from "../routing/session-key.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
@@ -508,6 +509,33 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
||||
requestHeartbeatNow({ reason: "exec-event" });
|
||||
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:
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user