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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<boolean>;
|
||||
}
|
||||
/**
|
||||
* Back-compat alias. The Discord notifier is one provider implementing
|
||||
* the neutral {@link PairingNotificationService} contract.
|
||||
*/
|
||||
export type DiscordNotificationService = PairingNotificationService;
|
||||
|
||||
export interface DiscordNotificationConfig {
|
||||
botToken: string;
|
||||
|
||||
156
plugin/notifications/fabric.ts
Normal file
156
plugin/notifications/fabric.ts
Normal file
@@ -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<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>;
|
||||
}
|
||||
30
plugin/notifications/factory.ts
Normal file
30
plugin/notifications/factory.ts
Normal file
@@ -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 ?? ""
|
||||
});
|
||||
}
|
||||
18
plugin/notifications/types.ts
Normal file
18
plugin/notifications/types.ts
Normal file
@@ -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<boolean>;
|
||||
}
|
||||
Reference in New Issue
Block a user