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) <noreply@anthropic.com>
157 lines
5.1 KiB
TypeScript
157 lines
5.1 KiB
TypeScript
/**
|
|
* 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<unknown>;
|
|
}
|
|
|
|
export type FabricFetch = (
|
|
input: string,
|
|
init?: { method?: string; headers?: Record<string, string>; body?: string }
|
|
) => Promise<FabricApiResponse>;
|
|
|
|
/**
|
|
* 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<boolean> {
|
|
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<FabricApiResponse>;
|
|
}
|