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:
109
tests/fabric-notification.test.ts
Normal file
109
tests/fabric-notification.test.ts
Normal file
@@ -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<FabricFetch>()
|
||||
.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<FabricFetch>().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<FabricFetch>();
|
||||
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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user