cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

158 lines
5.3 KiB
TypeScript

import { constants } from "node:fs";
import { mkdir, open, unlink } from "node:fs/promises";
import path from "node:path";
import type {
AdapterContext,
NotificationMessage,
NotificationProvider,
} from "@rakazo/adapter-kit";
import { getLogger } from "@rakazo/logging";
import { combineSignals } from "./connector-safety.js";
import { readBodyCapped, withAbort } from "./web-ssrf.js";
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
const EXPO_PUSH_TIMEOUT_MS = 15_000;
export const MAX_EXPO_PUSH_RESPONSE_BYTES = 64 * 1024;
export function pushTokenPath(dataDir: string, userId: string) {
return path.join(dataDir, "push-tokens", `${userId}.txt`);
}
export async function loadPushToken(dataDir: string, userId: string): Promise<string | undefined> {
try {
const handle = await open(pushTokenPath(dataDir, userId), constants.O_RDONLY | O_NOFOLLOW);
try {
const token = (await handle.readFile("utf8")).trim();
return token || undefined;
} finally {
await handle.close();
}
} catch {
return undefined;
}
}
export async function savePushToken(dataDir: string, userId: string, token: string): Promise<void> {
const file = pushTokenPath(dataDir, userId);
await mkdir(path.dirname(file), { recursive: true });
const handle = await open(
file,
constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | O_NOFOLLOW,
0o600,
);
try {
await handle.chmod(0o600);
await handle.writeFile(token.trim(), "utf8");
} finally {
await handle.close();
}
}
export async function deletePushToken(dataDir: string, userId: string): Promise<void> {
await unlink(pushTokenPath(dataDir, userId)).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
});
}
export type ExpoPushTicket = {
status?: string;
message?: string;
details?: { error?: string };
};
export function expoPushTickets(body: unknown): ExpoPushTicket[] {
if (!body || typeof body !== "object") return [];
const data = (body as { data?: unknown }).data;
if (Array.isArray(data)) {
return data.filter((item): item is ExpoPushTicket => Boolean(item) && typeof item === "object");
}
if (data && typeof data === "object") return [data as ExpoPushTicket];
return [];
}
export function expoPushErrorMessage(body: unknown, status: number): string | undefined {
if (body && typeof body === "object" && "errors" in body) {
const errors = (body as { errors?: Array<{ message?: string }> }).errors;
if (Array.isArray(errors) && errors.length > 0) {
return errors.map((error) => error.message ?? "expo push error").join("; ");
}
}
const failed = expoPushTickets(body).filter((ticket) => ticket.status === "error");
if (failed.length > 0) {
return failed
.map((ticket) => ticket.message ?? ticket.details?.error ?? "expo push ticket error")
.join("; ");
}
if (status < 200 || status >= 300) return `expo push failed (${status})`;
return undefined;
}
export class ExpoPushProvider implements NotificationProvider {
constructor(private readonly dataDir: string) {}
describe() {
return {
id: "expo-push",
contractVersion: "1",
adapterVersion: "0.1.0",
capabilities: { push: true, email: false },
};
}
async send(message: NotificationMessage, context: AdapterContext): Promise<void> {
const token = await loadPushToken(this.dataDir, context.userId);
if (!token) return;
const signal = combineSignals(context.signal, AbortSignal.timeout(EXPO_PUSH_TIMEOUT_MS));
let response: Response;
try {
response = await fetch("https://exp.host/--/api/v2/push/send", {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({
to: token,
title: message.title,
body: message.body,
collapseId: message.threadId,
tag: message.threadId,
data: { kind: message.kind, botId: message.botId, threadId: message.threadId },
}),
signal,
});
} catch (error) {
getLogger().error("expo push request failed", error);
throw error;
}
const body = await readExpoPushBody(response, signal);
if (response.ok && body === undefined) {
throw new Error("Expo push returned an invalid response.");
}
const failure = expoPushErrorMessage(body, response.status);
if (!failure) return;
getLogger().error(failure);
throw new Error(failure);
}
}
async function readExpoPushBody(response: Response, signal: AbortSignal): Promise<unknown> {
const declared = Number(response.headers.get("content-length") ?? 0);
if (Number.isFinite(declared) && declared > MAX_EXPO_PUSH_RESPONSE_BYTES) {
const cancel = response.body?.cancel() ?? Promise.resolve();
await withAbort(
cancel.catch(() => undefined),
signal,
).catch(() => undefined);
throw new Error("Expo push response is too large.");
}
try {
const bytes = await readBodyCapped(response, MAX_EXPO_PUSH_RESPONSE_BYTES, signal);
if (bytes.byteLength === 0) return undefined;
return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
} catch (error) {
if (error instanceof Error && error.message === "Response is too large") {
throw new Error("Expo push response is too large.");
}
if (error instanceof SyntaxError) return undefined;
throw error;
}
}