/** * Yonexus Server - Fabric Notification Service * * Sends pairing notifications to a human admin via a Fabric channel. * * Flow: * 1. Exchange the Center API key for a user session * (POST {centerApiBase}/auth/agent/login → user + guild tokens) * 2. Resolve the target guild's endpoint + access token by guildNodeId * 3. POST the formatted pairing message to the configured channel * ({guildEndpoint}/api/channels/{channelId}/messages) * * Self-contained (global fetch) and dependency-free, mirroring the * Discord notifier; it does not import the Fabric.OpenclawPlugin. */ import type { PairingRequest } from "../services/pairing.js"; import type { PairingNotificationService } from "./types.js"; import { formatPairingMessage } from "./discord.js"; import { redactPairingCode, safeErrorMessage } from "../core/logging.js"; export interface FabricNotificationConfig { /** Fabric Center API base, e.g. http://localhost:7001/api */ centerApiBase: string; /** Fabric Center API key (fak_…) for the notifier identity */ apiKey: string; /** Target guild node id the admin channel lives on */ guildNodeId: string; /** Channel id (a Fabric channel the admin watches) */ channelId: string; } interface FabricSession { user: { id: string }; guilds: Array<{ nodeId: string; endpoint: string }>; guildAccessTokens: Array<{ guildNodeId: string; token: string }>; } export interface FabricApiResponse { ok: boolean; status: number; json(): Promise; } export type FabricFetch = ( input: string, init?: { method?: string; headers?: Record; body?: string } ) => Promise; /** * Create a Fabric notification service backed by the Fabric Center + * Guild REST API. */ export function createFabricNotificationService( config: FabricNotificationConfig, options: { fetcher?: FabricFetch } = {} ): PairingNotificationService { const fetcher = options.fetcher ?? getDefaultFetch(); return { async sendPairingNotification(request: PairingRequest): Promise { if ( !config.centerApiBase.trim() || !config.apiKey.trim() || !config.guildNodeId.trim() || !config.channelId.trim() ) { console.error("[Yonexus.Server] Fabric notification misconfigured", { hasCenterApiBase: Boolean(config.centerApiBase.trim()), hasApiKey: Boolean(config.apiKey.trim()), hasGuildNodeId: Boolean(config.guildNodeId.trim()), hasChannelId: Boolean(config.channelId.trim()) }); return false; } try { const base = config.centerApiBase.replace(/\/+$/, ""); const loginRes = await fetcher(`${base}/auth/agent/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ apiKey: config.apiKey }) }); if (!loginRes.ok) { throw new Error(`agent/login failed with status ${loginRes.status}`); } const session = (await loginRes.json()) as FabricSession; const endpoint = session.guilds .find((g) => g.nodeId === config.guildNodeId) ?.endpoint?.replace(/\/+$/, ""); const token = session.guildAccessTokens.find( (t) => t.guildNodeId === config.guildNodeId )?.token; if (!endpoint || !token) { throw new Error( `notifier identity is not a member of guild ${config.guildNodeId}` ); } const msgRes = await fetcher( `${endpoint}/api/channels/${config.channelId}/messages`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ content: formatPairingMessage(request), authorUserId: session.user.id }) } ); if (!msgRes.ok) { throw new Error(`message post failed with status ${msgRes.status}`); } await msgRes.json(); console.log("[Yonexus.Server] Pairing notification sent via Fabric", { identifier: request.identifier, pairingCode: redactPairingCode(request.pairingCode), expiresAt: request.expiresAt, ttlSeconds: request.ttlSeconds, guildNodeId: config.guildNodeId, channelId: config.channelId }); return true; } catch (error) { console.error( "[Yonexus.Server] Failed to send Fabric pairing notification", { identifier: request.identifier, pairingCode: redactPairingCode(request.pairingCode), guildNodeId: config.guildNodeId, channelId: config.channelId, error: safeErrorMessage(error) } ); return false; } } }; } function getDefaultFetch(): FabricFetch { if (typeof fetch !== "function") { throw new Error("Global fetch is not available in this runtime"); } return (input, init) => fetch(input, { method: init?.method, headers: init?.headers, body: init?.body }) as Promise; }