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:
h z
2026-05-19 15:59:02 +01:00
parent b696c6a0af
commit 85034f5de0
10 changed files with 412 additions and 29 deletions

View File

@@ -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<string, unknown>;
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