From 85034f5de0751b2ee02b0209883bb8d2f0fd3d25 Mon Sep 17 00:00:00 2001 From: hzhang Date: Tue, 19 May 2026 15:59:02 +0100 Subject: [PATCH] feat: selectable pairing-notify provider (discord optional, add fabric) Pairing-code delivery was hardwired to Discord DM (notifyBotToken + adminUserId required). Make the provider config-selectable. - core/config.ts: add notifyProvider ("discord"|"fabric", default "discord" for back-compat); discord fields required only for discord; add fabric block (centerApiBase/apiKey/guildNodeId/channelId) required only for fabric - notifications/types.ts: neutral PairingNotificationService interface (DiscordNotificationService kept as back-compat alias) - notifications/fabric.ts: post the pairing message to a Fabric channel (agent/login -> guild token -> POST messages); self-contained, no Fabric plugin dependency - notifications/factory.ts: select provider from config - core/runtime.ts: wire via factory - openclaw.plugin.json: notifyProvider enum + fabric object; drop notifyBotToken/adminUserId from required (conditional in code) - tests: fabric notifier + provider-selection config (80 passing) Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 8 ++ plugin/core/config.ts | 72 +++++++++++--- plugin/core/runtime.ts | 15 +-- plugin/index.ts | 6 ++ plugin/notifications/discord.ts | 13 ++- plugin/notifications/fabric.ts | 156 ++++++++++++++++++++++++++++++ plugin/notifications/factory.ts | 30 ++++++ plugin/notifications/types.ts | 18 ++++ plugin/openclaw.plugin.json | 14 ++- tests/fabric-notification.test.ts | 109 +++++++++++++++++++++ 10 files changed, 412 insertions(+), 29 deletions(-) create mode 100644 plugin/notifications/fabric.ts create mode 100644 plugin/notifications/factory.ts create mode 100644 plugin/notifications/types.ts create mode 100644 tests/fabric-notification.test.ts diff --git a/package-lock.json b/package-lock.json index d16eb28..2692059 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,10 +12,14 @@ }, "devDependencies": { "@types/node": "^25.5.2", + "openclaw": "file:/usr/lib/node_modules/openclaw", "typescript": "^5.6.3", "vitest": "^4.1.3" } }, + "../../../../../usr/lib/node_modules/openclaw": { + "dev": true + }, "node_modules/@emnapi/core": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", @@ -914,6 +918,10 @@ ], "license": "MIT" }, + "node_modules/openclaw": { + "resolved": "../../../../../usr/lib/node_modules/openclaw", + "link": true + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", diff --git a/plugin/core/config.ts b/plugin/core/config.ts index 98c4748..32fd25f 100644 --- a/plugin/core/config.ts +++ b/plugin/core/config.ts @@ -1,7 +1,22 @@ +export type NotifyProvider = "discord" | "fabric"; + +export interface FabricNotifyConfig { + centerApiBase: string; + apiKey: string; + guildNodeId: string; + channelId: string; +} + export interface YonexusServerConfig { followerIdentifiers: string[]; - notifyBotToken: string; - adminUserId: string; + /** Pairing-code delivery provider. Default: "discord" (back-compat). */ + notifyProvider: NotifyProvider; + /** Required only when notifyProvider === "discord". */ + notifyBotToken?: string; + /** Required only when notifyProvider === "discord". */ + adminUserId?: string; + /** Required only when notifyProvider === "fabric". */ + fabric?: FabricNotifyConfig; listenHost?: string; listenPort: number; publicWsUrl?: string; @@ -67,14 +82,45 @@ export function validateYonexusServerConfig(raw: unknown): YonexusServerConfig { issues.push("followerIdentifiers must not contain duplicates"); } - const rawNotifyBotToken = source.notifyBotToken; - if (!isNonEmptyString(rawNotifyBotToken)) { - issues.push("notifyBotToken is required"); + const rawNotifyProvider = source.notifyProvider; + let notifyProvider: NotifyProvider = "discord"; + if (rawNotifyProvider === undefined || rawNotifyProvider === null) { + notifyProvider = "discord"; + } else if (rawNotifyProvider === "discord" || rawNotifyProvider === "fabric") { + notifyProvider = rawNotifyProvider; + } else { + issues.push('notifyProvider must be "discord" or "fabric"'); } + // Discord fields — required only for the discord provider. + const rawNotifyBotToken = source.notifyBotToken; const rawAdminUserId = source.adminUserId; - if (!isNonEmptyString(rawAdminUserId)) { - issues.push("adminUserId is required"); + if (notifyProvider === "discord") { + if (!isNonEmptyString(rawNotifyBotToken)) { + issues.push('notifyBotToken is required when notifyProvider is "discord"'); + } + if (!isNonEmptyString(rawAdminUserId)) { + issues.push('adminUserId is required when notifyProvider is "discord"'); + } + } + + // Fabric fields — required only for the fabric provider. + let fabric: FabricNotifyConfig | undefined; + if (notifyProvider === "fabric") { + const f = (source.fabric && typeof source.fabric === "object" + ? source.fabric + : {}) as Record; + const centerApiBase = normalizeOptionalString(f.centerApiBase); + const apiKey = normalizeOptionalString(f.apiKey); + const guildNodeId = normalizeOptionalString(f.guildNodeId); + const channelId = normalizeOptionalString(f.channelId); + if (!centerApiBase) issues.push("fabric.centerApiBase is required when notifyProvider is \"fabric\""); + if (!apiKey) issues.push("fabric.apiKey is required when notifyProvider is \"fabric\""); + if (!guildNodeId) issues.push("fabric.guildNodeId is required when notifyProvider is \"fabric\""); + if (!channelId) issues.push("fabric.channelId is required when notifyProvider is \"fabric\""); + if (centerApiBase && apiKey && guildNodeId && channelId) { + fabric = { centerApiBase, apiKey, guildNodeId, channelId }; + } } const rawListenPort = source.listenPort; @@ -93,14 +139,18 @@ export function validateYonexusServerConfig(raw: unknown): YonexusServerConfig { throw new YonexusServerConfigError(issues); } - const notifyBotToken = rawNotifyBotToken as string; - const adminUserId = rawAdminUserId as string; const listenPort = rawListenPort as number; return { followerIdentifiers, - notifyBotToken: notifyBotToken.trim(), - adminUserId: adminUserId.trim(), + notifyProvider, + notifyBotToken: isNonEmptyString(rawNotifyBotToken) + ? rawNotifyBotToken.trim() + : undefined, + adminUserId: isNonEmptyString(rawAdminUserId) + ? rawAdminUserId.trim() + : undefined, + fabric, listenHost, listenPort, publicWsUrl diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index 03aa2ca..01cae3a 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -42,10 +42,8 @@ import { verifySignature } from "../../../Yonexus.Protocol/src/crypto.js"; import type { YonexusServerStore } from "./store.js"; import { type ClientConnection, type ServerTransport } from "./transport.js"; import { createPairingService, type PairingService } from "../services/pairing.js"; -import { - createDiscordNotificationService, - type DiscordNotificationService -} from "../notifications/discord.js"; +import type { PairingNotificationService } from "../notifications/types.js"; +import { createNotificationService } from "../notifications/factory.js"; import { safeErrorMessage } from "./logging.js"; import type { ServerRuleRegistry } from "./rules.js"; @@ -53,7 +51,7 @@ export interface YonexusServerRuntimeOptions { config: YonexusServerConfig; store: YonexusServerStore; transport: ServerTransport; - notificationService?: DiscordNotificationService; + notificationService?: PairingNotificationService; ruleRegistry?: ServerRuleRegistry; onClientAuthenticated?: (identifier: string) => void; now?: () => number; @@ -70,7 +68,7 @@ export class YonexusServerRuntime { private readonly now: () => number; private readonly registry: ServerRegistry; private readonly pairingService: PairingService; - private readonly notificationService: DiscordNotificationService; + private readonly notificationService: PairingNotificationService; private readonly sweepIntervalMs: number; private sweepTimer: NodeJS.Timeout | null = null; private started = false; @@ -86,10 +84,7 @@ export class YonexusServerRuntime { this.pairingService = createPairingService({ now: this.now }); this.notificationService = options.notificationService ?? - createDiscordNotificationService({ - botToken: options.config.notifyBotToken, - adminUserId: options.config.adminUserId - }); + createNotificationService(options.config); } get state(): ServerLifecycleState { diff --git a/plugin/index.ts b/plugin/index.ts index f3fc5ee..472203e 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -265,5 +265,11 @@ export { type DiscordNotificationService, type DiscordNotificationConfig } from "./notifications/discord.js"; +export type { PairingNotificationService } from "./notifications/types.js"; +export { + createFabricNotificationService, + type FabricNotificationConfig +} from "./notifications/fabric.js"; +export { createNotificationService } from "./notifications/factory.js"; export { manifest }; diff --git a/plugin/notifications/discord.ts b/plugin/notifications/discord.ts index b10a342..3830e9f 100644 --- a/plugin/notifications/discord.ts +++ b/plugin/notifications/discord.ts @@ -5,15 +5,14 @@ */ import type { PairingRequest } from "../services/pairing.js"; +import type { PairingNotificationService } from "./types.js"; import { redactPairingCode, safeErrorMessage } from "../core/logging.js"; -export interface DiscordNotificationService { - /** - * Send a pairing code notification to the admin user. - * @returns Whether the notification was sent successfully - */ - sendPairingNotification(request: PairingRequest): Promise; -} +/** + * Back-compat alias. The Discord notifier is one provider implementing + * the neutral {@link PairingNotificationService} contract. + */ +export type DiscordNotificationService = PairingNotificationService; export interface DiscordNotificationConfig { botToken: string; diff --git a/plugin/notifications/fabric.ts b/plugin/notifications/fabric.ts new file mode 100644 index 0000000..cad4ae5 --- /dev/null +++ b/plugin/notifications/fabric.ts @@ -0,0 +1,156 @@ +/** + * 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; +} diff --git a/plugin/notifications/factory.ts b/plugin/notifications/factory.ts new file mode 100644 index 0000000..a497e38 --- /dev/null +++ b/plugin/notifications/factory.ts @@ -0,0 +1,30 @@ +/** + * Yonexus Server - Pairing notification provider factory. + * + * Selects the notification provider from config (`notifyProvider`). + * Discord is the default for backward compatibility. + */ + +import type { YonexusServerConfig } from "../core/config.js"; +import type { PairingNotificationService } from "./types.js"; +import { createDiscordNotificationService } from "./discord.js"; +import { createFabricNotificationService } from "./fabric.js"; + +export function createNotificationService( + config: YonexusServerConfig +): PairingNotificationService { + if (config.notifyProvider === "fabric") { + if (!config.fabric) { + throw new Error( + 'Yonexus.Server: notifyProvider is "fabric" but fabric config is missing' + ); + } + return createFabricNotificationService(config.fabric); + } + + // Default / "discord" + return createDiscordNotificationService({ + botToken: config.notifyBotToken ?? "", + adminUserId: config.adminUserId ?? "" + }); +} diff --git a/plugin/notifications/types.ts b/plugin/notifications/types.ts new file mode 100644 index 0000000..6239c2e --- /dev/null +++ b/plugin/notifications/types.ts @@ -0,0 +1,18 @@ +/** + * Yonexus Server - Pairing notification provider interface. + * + * A provider delivers the out-of-band pairing code to a human admin. + * Implementations: Discord DM (`./discord.ts`), Fabric channel + * (`./fabric.ts`). The provider is selected by config (see + * `core/config.ts` `notifyProvider`). + */ + +import type { PairingRequest } from "../services/pairing.js"; + +export interface PairingNotificationService { + /** + * Send a pairing code notification to the admin. + * @returns Whether the notification was sent successfully. + */ + sendPairingNotification(request: PairingRequest): Promise; +} diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index bcdcc29..498bd58 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -16,12 +16,24 @@ "type": "array", "items": { "type": "string" } }, + "notifyProvider": { "type": "string", "enum": ["discord", "fabric"] }, "notifyBotToken": { "type": "string" }, "adminUserId": { "type": "string" }, + "fabric": { + "type": "object", + "additionalProperties": false, + "properties": { + "centerApiBase": { "type": "string" }, + "apiKey": { "type": "string" }, + "guildNodeId": { "type": "string" }, + "channelId": { "type": "string" } + }, + "required": ["centerApiBase", "apiKey", "guildNodeId", "channelId"] + }, "listenHost": { "type": "string" }, "listenPort": { "type": "number" }, "publicWsUrl": { "type": "string" } }, - "required": ["notifyBotToken", "adminUserId", "listenPort"] + "required": ["listenPort"] } } diff --git a/tests/fabric-notification.test.ts b/tests/fabric-notification.test.ts new file mode 100644 index 0000000..e4f141d --- /dev/null +++ b/tests/fabric-notification.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + createFabricNotificationService, + type FabricFetch +} from "../plugin/notifications/fabric.js"; +import { validateYonexusServerConfig } from "../plugin/core/config.js"; + +const request = { + identifier: "client-a", + pairingCode: "PAIR-1234-CODE", + expiresAt: 1_710_000_300, + ttlSeconds: 300, + createdAt: 1_710_000_000 +}; + +const fabricConfig = { + centerApiBase: "http://center.local/api", + apiKey: "fak_test", + guildNodeId: "guild-node-1", + channelId: "chan-1" +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Fabric notification service", () => { + it("logs in, resolves the guild token, and posts the pairing message", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + user: { id: "u-1" }, + guilds: [{ nodeId: "guild-node-1", endpoint: "http://guild.local" }], + guildAccessTokens: [{ guildNodeId: "guild-node-1", token: "gtok" }] + }) + }) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({}) }); + + const svc = createFabricNotificationService(fabricConfig, { fetcher }); + const ok = await svc.sendPairingNotification(request); + + expect(ok).toBe(true); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(fetcher.mock.calls[0][0]).toBe("http://center.local/api/auth/agent/login"); + expect(fetcher.mock.calls[1][0]).toBe( + "http://guild.local/api/channels/chan-1/messages" + ); + const msgInit = fetcher.mock.calls[1][1]!; + expect(msgInit.headers?.Authorization).toBe("Bearer gtok"); + expect(msgInit.body).toContain("PAIR-1234-CODE"); + }); + + it("returns false when the notifier is not a member of the guild", async () => { + const fetcher = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ user: { id: "u-1" }, guilds: [], guildAccessTokens: [] }) + }); + + const svc = createFabricNotificationService(fabricConfig, { fetcher }); + expect(await svc.sendPairingNotification(request)).toBe(false); + }); + + it("returns false when config is incomplete", async () => { + const fetcher = vi.fn(); + const svc = createFabricNotificationService( + { ...fabricConfig, apiKey: "" }, + { fetcher } + ); + expect(await svc.sendPairingNotification(request)).toBe(false); + expect(fetcher).not.toHaveBeenCalled(); + }); +}); + +describe("notifyProvider config selection", () => { + it("defaults to discord and still requires discord fields", () => { + expect(() => + validateYonexusServerConfig({ listenPort: 8080 }) + ).toThrowError(/notifyBotToken is required/); + }); + + it("does not require discord fields when provider is fabric", () => { + const cfg = validateYonexusServerConfig({ + followerIdentifiers: [], + listenPort: 8080, + notifyProvider: "fabric", + fabric: fabricConfig + }); + expect(cfg.notifyProvider).toBe("fabric"); + expect(cfg.fabric).toEqual(fabricConfig); + expect(cfg.notifyBotToken).toBeUndefined(); + }); + + it("requires fabric fields when provider is fabric", () => { + expect(() => + validateYonexusServerConfig({ listenPort: 8080, notifyProvider: "fabric" }) + ).toThrowError(/fabric\.centerApiBase is required/); + }); + + it("rejects an unknown provider", () => { + expect(() => + validateYonexusServerConfig({ listenPort: 8080, notifyProvider: "sms" }) + ).toThrowError(/notifyProvider must be/); + }); +});