import { Buffer } from "node:buffer"; import { setTimeout as delay } from "node:timers/promises"; import type { AnonNotifyConfig } from "./config.ts"; import type { PresenceHeartbeatBody, PresenceOfflineBody } from "./presence.ts"; import { deliveryTargetStatus, type DeliveryTargetHealth } from "./target.ts"; export type NotifyStatus = "completed" | "failed" | "warning" | "info"; export interface NotificationContextUsage { usedTokens: number | null; contextWindow: number; percent: number | null; } export interface NotificationPayload { eventId: string; sessionId: string; source: string; task: string; status: NotifyStatus; summary?: string; contextUsage?: NotificationContextUsage | null; url?: string; reportedAt?: string; } export type CommandState = "pending" | "leased" | "accepted" | "failed" | "cancelled"; export interface AgentImageContent { data: string; mimeType: string; } export interface InboundAgentCommand { id: string; eventId: string; sessionId: string; instruction: string; images?: readonly AgentImageContent[]; createdAt: string; attempts: number; leaseToken: string; leaseExpiresAt?: string; } interface WebhookResponse { ok?: boolean; event_id?: string; state?: string; error?: string; message?: string; commands?: unknown; command_id?: string; delivery_target?: { platform?: unknown; self_id?: unknown; channel_id?: unknown; channel_type?: unknown; recipient_id?: unknown; target_status?: unknown; target_status_source?: unknown; detail?: unknown; }; target_status?: unknown; target_status_source?: unknown; } class HttpError extends Error { readonly status: number; readonly retryable: boolean; readonly retryAfterMs: number; constructor(message: string, status: number, retryable: boolean, retryAfterMs = 0) { super(message); this.status = status; this.retryable = retryable; this.retryAfterMs = retryAfterMs; } } function retryAfterMs(value: string | null): number { if (!value) return 0; const seconds = Number(value); const wait = Number.isFinite(seconds) ? seconds * 1_000 : Date.parse(value) - Date.now(); // Never let an untrusted header turn bounded retries into an unlimited wait. return Number.isFinite(wait) ? Math.min(60_000, Math.max(0, wait)) : 0; } function isObject(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } const IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); const MAX_IMAGES_PER_COMMAND = 4; const MAX_IMAGE_BYTES = 4 * 1024 * 1024; const MAX_TOTAL_IMAGE_BYTES = 8 * 1024 * 1024; const MAX_IMAGE_BASE64_CHARS = Math.ceil(MAX_IMAGE_BYTES / 3) * 4; function requiredString(value: unknown, field: string, maxLength: number): string { if (typeof value !== "string" || !value.trim() || value.length > maxLength) { throw new Error(`指令字段 ${field} 无效`); } return value.trim(); } function imageMagicMatches(mimeType: string, bytes: Buffer): boolean { if (mimeType === "image/png") { return bytes.length >= 8 && Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).equals(bytes.subarray(0, 8)); } if (mimeType === "image/jpeg") return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff; if (mimeType === "image/gif") { const header = bytes.subarray(0, 6).toString("ascii"); return header === "GIF87a" || header === "GIF89a"; } return bytes.length >= 12 && bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP"; } function decodeCommandImages(value: unknown): readonly AgentImageContent[] | undefined { if (value === undefined || value === null) return undefined; if (!Array.isArray(value) || value.length > MAX_IMAGES_PER_COMMAND) { throw new Error("指令字段 images 无效"); } if (value.length === 0) return undefined; let totalBytes = 0; const images = value.map((item, index) => { if (!isObject(item)) throw new Error(`指令图片 ${index} 无效`); const mimeType = requiredString(item.mime_type, `images[${index}].mime_type`, 40); if (!IMAGE_MIME_TYPES.has(mimeType)) throw new Error(`指令图片 ${index} MIME 不受支持`); const data = requiredString(item.data, `images[${index}].data`, MAX_IMAGE_BASE64_CHARS); if (data.startsWith("data:") || /\s/.test(data) || data.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(data)) { throw new Error(`指令图片 ${index} base64 无效`); } const bytes = Buffer.from(data, "base64"); if (!bytes.length || bytes.toString("base64") !== data) throw new Error(`指令图片 ${index} base64 无效`); if (bytes.length > MAX_IMAGE_BYTES || !imageMagicMatches(mimeType, bytes)) { throw new Error(`指令图片 ${index} 内容无效`); } totalBytes += bytes.length; return { data, mimeType } satisfies AgentImageContent; }); if (totalBytes > MAX_TOTAL_IMAGE_BYTES) throw new Error("指令图片总大小超限"); return images; } function deriveApiRoot(eventEndpoint: string): string { const normalized = eventEndpoint.replace(/\/+$/, ""); return /\/events$/i.test(normalized) ? normalized.replace(/\/events$/i, "") : normalized; } export function deriveCommandEndpoint(eventEndpoint: string): string { return `${deriveApiRoot(eventEndpoint)}/commands`; } export function deriveHealthEndpoint(eventEndpoint: string): string { return `${deriveApiRoot(eventEndpoint)}/health`; } export function deriveSessionHeartbeatEndpoint(eventEndpoint: string): string { return `${deriveApiRoot(eventEndpoint)}/sessions/heartbeat`; } export function deriveSessionOfflineEndpoint(eventEndpoint: string): string { return `${deriveApiRoot(eventEndpoint)}/sessions/offline`; } export class AnonNotificationClient { private readonly config: AnonNotifyConfig; private readonly token: string; private nextDeliveryAt: number; private readonly inFlight = new Set>(); constructor(config: AnonNotifyConfig, token: string, nextDeliveryAt = 0) { this.config = config; this.token = token; this.nextDeliveryAt = nextDeliveryAt; } getNextDeliveryAt(): number { return this.nextDeliveryAt; } enqueue(payload: NotificationPayload, bypassInterval = false): Promise { if (!this.config.enabled) return Promise.resolve(); // 进展类通知仍受本地限流;最终完成/失败通知可以绕过限流,避免丢失结果。 if (this.token && !bypassInterval && !this.reserveDeliverySlot()) return Promise.resolve(); const pending = this.send(payload, bypassInterval); this.inFlight.add(pending); // Both handlers fulfill, avoiding an unobserved rejecting finally promise. void pending.then(() => this.inFlight.delete(pending), () => this.inFlight.delete(pending)); return pending; } async getHealth(signal?: AbortSignal): Promise { if (!this.config.enabled || !this.token) { return { status: "unknown", source: "client-disabled-or-token-missing" }; } const body = await this.requestJson(deriveHealthEndpoint(this.config.endpoint), { method: "GET" }, signal); const target = body.delivery_target; const status = deliveryTargetStatus(target?.target_status ?? body.target_status); return { platform: typeof target?.platform === "string" ? target.platform : undefined, selfId: typeof target?.self_id === "string" ? target.self_id : undefined, channelId: typeof target?.channel_id === "string" ? target.channel_id : undefined, channelType: typeof target?.channel_type === "string" ? target.channel_type : undefined, recipientId: typeof target?.recipient_id === "string" ? target.recipient_id : undefined, status, source: typeof target?.target_status_source === "string" ? target.target_status_source : typeof body.target_status_source === "string" ? body.target_status_source : undefined, detail: typeof target?.detail === "string" ? target.detail : undefined, }; } async listCommands(sessionId: string, signal?: AbortSignal): Promise { if (!this.config.enabled || !this.config.command.enabled || !this.token) return []; const endpoint = new URL(deriveCommandEndpoint(this.config.endpoint)); endpoint.searchParams.set("session_id", sessionId); // Pending commands stay cancellable on the server until a safe handoff. endpoint.searchParams.set("limit", "1"); endpoint.searchParams.set("capabilities", "images"); const body = await this.requestJson(endpoint, { method: "GET" }, signal); if (!Array.isArray(body.commands)) throw new Error("指令接口返回了无效 commands 字段"); return body.commands.map((value) => { if (!isObject(value)) throw new Error("指令接口返回了无效指令项"); return { id: requiredString(value.command_id, "command_id", 128), eventId: requiredString(value.event_id, "event_id", 128), sessionId: requiredString(value.session_id, "session_id", 128), instruction: requiredString(value.instruction, "instruction", 4_000), images: decodeCommandImages(value.images), createdAt: requiredString(value.created_at, "created_at", 80), attempts: typeof value.attempts === "number" && Number.isInteger(value.attempts) ? value.attempts : 0, leaseToken: requiredString(value.lease_token, "lease_token", 128), leaseExpiresAt: typeof value.lease_expires_at === "string" ? value.lease_expires_at : undefined, } satisfies InboundAgentCommand; }); } async ackCommand( command: InboundAgentCommand, status: "accepted" | "failed", error?: string, signal?: AbortSignal, ): Promise { if (!this.config.enabled || !this.config.command.enabled || !this.token) return; const endpoint = `${deriveCommandEndpoint(this.config.endpoint)}/${encodeURIComponent(command.id)}/ack`; await this.requestJson(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ lease_token: command.leaseToken, status, error: error || undefined, }), }, signal); } async heartbeatPresence(body: PresenceHeartbeatBody, signal?: AbortSignal): Promise { if (!this.config.enabled || !this.config.command.enabled || !this.token) return; await this.requestJson(deriveSessionHeartbeatEndpoint(this.config.endpoint), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }, signal); } async markPresenceOffline(body: PresenceOfflineBody, signal?: AbortSignal): Promise { if (!this.config.enabled || !this.config.command.enabled || !this.token) return; await this.requestJson(deriveSessionOfflineEndpoint(this.config.endpoint), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }, signal); } async flush(): Promise { while (this.inFlight.size) await Promise.allSettled([...this.inFlight]); } private async send(payload: NotificationPayload, bypassInterval: boolean): Promise { if (!this.config.enabled) return; if (!this.token) throw new Error("通知 Token 未配置"); let lastError: unknown; for (let attempt = 1; attempt <= this.config.delivery.maxAttempts; attempt += 1) { try { if (attempt > 1 && !bypassInterval) await this.waitForDeliverySlot(); await this.sendOnce(payload); return; } catch (error) { lastError = error; const retryable = !(error instanceof HttpError) || error.retryable; if (!retryable || attempt >= this.config.delivery.maxAttempts) break; const backoff = this.config.delivery.retryBaseDelayMs * 2 ** (attempt - 1); await delay(Math.max(backoff, error instanceof HttpError ? error.retryAfterMs : 0)); } } throw lastError instanceof Error ? lastError : new Error(String(lastError)); } private reserveDeliverySlot(): boolean { const now = Date.now(); if (now < this.nextDeliveryAt) return false; this.nextDeliveryAt = now + this.config.delivery.minIntervalMs; return true; } private async waitForDeliverySlot(): Promise { while (true) { const waitMs = this.nextDeliveryAt - Date.now(); if (waitMs > 0) await delay(waitMs); if (this.reserveDeliverySlot()) return; } } private async sendOnce(payload: NotificationPayload): Promise { const receipt = await this.requestJson(this.config.endpoint, { method: "POST", headers: { "content-type": "application/json", "idempotency-key": payload.eventId, "user-agent": "pi-anon-notify/2.0", }, body: JSON.stringify({ event_id: payload.eventId, session_id: payload.sessionId, source: payload.source, task: payload.task, status: payload.status, summary: payload.summary, context_usage: payload.contextUsage ? { used_tokens: payload.contextUsage.usedTokens, context_window: payload.contextUsage.contextWindow, percent: payload.contextUsage.percent, } : null, url: payload.url, reported_at: payload.reportedAt || new Date().toISOString(), }), }); if (receipt.state === "failed") { throw new HttpError("通知事件已在服务端失败", 200, false); } if (receipt.ok !== true || receipt.event_id !== payload.eventId || !["queued", "processing", "context_injected", "delivered"].includes(receipt.state || "")) { throw new HttpError("通知接口返回了无效事件回执", 502, true); } } private async requestJson(url: string | URL, init: RequestInit, externalSignal?: AbortSignal): Promise { const controller = new AbortController(); const abort = () => controller.abort(externalSignal?.reason); if (externalSignal?.aborted) abort(); else externalSignal?.addEventListener("abort", abort, { once: true }); const timer = setTimeout(() => controller.abort(new Error("request_timeout")), this.config.delivery.timeoutMs); try { const headers = new Headers(init.headers); headers.set("authorization", `Bearer ${this.token}`); if (!headers.has("user-agent")) headers.set("user-agent", "pi-anon-notify/2.0"); const response = await fetch(url, { ...init, headers, signal: controller.signal }); const text = await response.text(); let body: WebhookResponse = {}; if (text) { try { const parsed = JSON.parse(text) as unknown; body = isObject(parsed) ? parsed as WebhookResponse : { message: text.slice(0, 300) }; } catch { body = { message: text.slice(0, 300) }; } } if (!response.ok || body.ok === false) { const message = body.error || body.message || `HTTP ${response.status}`; throw new HttpError( `通知接口返回错误:${message}`, response.status, response.status === 408 || response.status === 429 || response.status >= 500, retryAfterMs(response.headers.get("retry-after")), ); } return body; } finally { clearTimeout(timer); externalSignal?.removeEventListener("abort", abort); } } }