From 2972c4750e34cd453ec3fdbed6bf251603e44187 Mon Sep 17 00:00:00 2001 From: nav Date: Thu, 9 Apr 2026 04:06:06 +0000 Subject: [PATCH] feat(server): wire Discord DM pairing notifications --- README.md | 13 ++- plugin/notifications/discord.ts | 160 +++++++++++++++++++++++++------- tests/notifications.test.ts | 107 +++++++++++++++++++++ tests/runtime-flow.test.ts | 20 ++++ 4 files changed, 257 insertions(+), 43 deletions(-) create mode 100644 tests/notifications.test.ts diff --git a/README.md b/README.md index 9be3f84..d723e9d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ It runs on the main OpenClaw instance and is responsible for: ## Status -Current state: **scaffold + core runtime MVP** +Current state: **core runtime MVP with Discord DM transport wired via REST API** Implemented in this repository today: @@ -25,14 +25,13 @@ Implemented in this repository today: - auth proof validation flow - heartbeat receive + liveness sweep - rule registry + send-to-client APIs -- notification service stub/mock for pairing DM flow +- Discord DM pairing notifications via Discord REST API (`notifyBotToken` + `adminUserId`) Still pending before production use: -- automated Server unit/integration tests -- real Discord DM transport wiring -- operator hardening / troubleshooting docs - broader lifecycle integration with real OpenClaw plugin hooks +- more operator-facing hardening / troubleshooting polish +- expanded edge-case and live-environment validation beyond the current automated suite ## Install Layout @@ -133,11 +132,11 @@ npm run check Current known limitations: -- pairing DM sending is still a stub/mock abstraction +- DM delivery depends on Discord bot permissions and the target user's DM settings - no offline message queueing - no multi-server topology - no management UI -- no server-side unit/integration test suite yet +- transport is covered mainly by automated tests rather than live Discord end-to-end validation ## Related Repos diff --git a/plugin/notifications/discord.ts b/plugin/notifications/discord.ts index aad958c..c629965 100644 --- a/plugin/notifications/discord.ts +++ b/plugin/notifications/discord.ts @@ -1,11 +1,11 @@ /** * Yonexus Server - Discord Notification Service - * + * * Sends pairing notifications to the configured admin user via Discord DM. */ import type { PairingRequest } from "../services/pairing.js"; -import { redactPairingCode } from "../core/logging.js"; +import { redactPairingCode, safeErrorMessage } from "../core/logging.js"; export interface DiscordNotificationService { /** @@ -20,55 +20,143 @@ export interface DiscordNotificationConfig { adminUserId: string; } +export interface DiscordApiResponse { + ok: boolean; + status: number; + json(): Promise; +} + +export type DiscordFetch = ( + input: string, + init?: { + method?: string; + headers?: Record; + body?: string; + } +) => Promise; + +interface CreateDmChannelResponse { + id?: string; +} + +interface SendDiscordDirectMessageOptions { + config: DiscordNotificationConfig; + message: string; + fetcher?: DiscordFetch; +} + +const DISCORD_API_BASE_URL = "https://discord.com/api/v10"; + /** - * Create a Discord notification service. - * - * Note: This is a framework stub. Full implementation requires: - * - Discord bot client integration (e.g., using discord.js) - * - DM channel creation/fetching - * - Error handling for blocked DMs, invalid tokens, etc. - * - * For v1, this provides the interface and a mock implementation - * that logs to console. Production deployments should replace - * this with actual Discord bot integration. + * Create a Discord notification service backed by Discord's REST API. + * + * Flow: + * 1. Create or fetch a DM channel for the configured admin user + * 2. Post the formatted pairing message into that DM channel */ export function createDiscordNotificationService( - config: DiscordNotificationConfig + config: DiscordNotificationConfig, + options: { fetcher?: DiscordFetch } = {} ): DiscordNotificationService { + const fetcher = options.fetcher ?? getDefaultFetch(); + return { async sendPairingNotification(request: PairingRequest): Promise { - const redactedCode = redactPairingCode(request.pairingCode); - - // Log to console (visible in OpenClaw logs) - console.log("[Yonexus.Server] Pairing notification (Discord DM stub):", { - identifier: request.identifier, - pairingCode: redactedCode, - expiresAt: request.expiresAt, - ttlSeconds: request.ttlSeconds, - adminUserId: config.adminUserId - }); - - // TODO: Replace with actual Discord bot integration - // Example with discord.js: - // const client = new Client({ intents: [GatewayIntentBits.DirectMessages] }); - // await client.login(config.botToken); - // const user = await client.users.fetch(config.adminUserId); - // await user.send(message); - - // For now, return true to allow pairing flow to continue - // In production, this should return false if DM fails - return true; + if (!config.botToken.trim() || !config.adminUserId.trim()) { + console.error("[Yonexus.Server] Discord DM notification misconfigured", { + hasBotToken: Boolean(config.botToken.trim()), + hasAdminUserId: Boolean(config.adminUserId.trim()) + }); + return false; + } + + try { + await sendDiscordDirectMessage({ + config, + message: formatPairingMessage(request), + fetcher + }); + + console.log("[Yonexus.Server] Pairing notification sent via Discord DM", { + identifier: request.identifier, + pairingCode: redactPairingCode(request.pairingCode), + expiresAt: request.expiresAt, + ttlSeconds: request.ttlSeconds, + adminUserId: config.adminUserId + }); + + return true; + } catch (error) { + console.error("[Yonexus.Server] Failed to send Discord DM pairing notification", { + identifier: request.identifier, + pairingCode: redactPairingCode(request.pairingCode), + adminUserId: config.adminUserId, + error: safeErrorMessage(error) + }); + return false; + } } }; } +async function sendDiscordDirectMessage( + options: SendDiscordDirectMessageOptions +): Promise { + const { config, message, fetcher } = options; + const headers = { + Authorization: `Bot ${config.botToken}`, + "Content-Type": "application/json" + }; + + const dmResponse = await fetcher(`${DISCORD_API_BASE_URL}/users/@me/channels`, { + method: "POST", + headers, + body: JSON.stringify({ recipient_id: config.adminUserId }) + }); + + if (!dmResponse.ok) { + throw new Error(`Discord DM channel creation failed with status ${dmResponse.status}`); + } + + const dmPayload = (await dmResponse.json()) as CreateDmChannelResponse; + const channelId = dmPayload.id?.trim(); + if (!channelId) { + throw new Error("Discord DM channel creation did not return a channel id"); + } + + const messageResponse = await fetcher(`${DISCORD_API_BASE_URL}/channels/${channelId}/messages`, { + method: "POST", + headers, + body: JSON.stringify({ content: message }) + }); + + if (!messageResponse.ok) { + throw new Error(`Discord DM send failed with status ${messageResponse.status}`); + } + + await messageResponse.json(); +} + +function getDefaultFetch(): DiscordFetch { + 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; +} + /** * Format a pairing request as a Discord DM message. */ export function formatPairingMessage(request: PairingRequest): string { const expiresDate = new Date(request.expiresAt * 1000); const expiresStr = expiresDate.toISOString(); - + return [ "🔐 **Yonexus Pairing Request**", "", @@ -90,7 +178,7 @@ export function createMockNotificationService( options: { shouldSucceed?: boolean } = {} ): DiscordNotificationService { const shouldSucceed = options.shouldSucceed ?? true; - + return { async sendPairingNotification(request: PairingRequest): Promise { console.log("[Yonexus.Server] Mock pairing notification:"); diff --git a/tests/notifications.test.ts b/tests/notifications.test.ts new file mode 100644 index 0000000..a920102 --- /dev/null +++ b/tests/notifications.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + createDiscordNotificationService, + formatPairingMessage, + type DiscordFetch +} from "../plugin/notifications/discord.js"; + +const request = { + identifier: "client-a", + pairingCode: "PAIR-1234-CODE", + expiresAt: 1_710_000_300, + ttlSeconds: 300, + createdAt: 1_710_000_000 +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("Discord notification service", () => { + it("formats pairing requests as a DM-friendly message", () => { + const message = formatPairingMessage(request); + + expect(message).toContain("Yonexus Pairing Request"); + expect(message).toContain("`client-a`"); + expect(message).toContain("`PAIR-1234-CODE`"); + expect(message).toContain("TTL:** 300 seconds"); + }); + + it("creates a DM channel and posts the pairing message", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ id: "dm-channel-1" }) + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ id: "message-1" }) + }); + + const service = createDiscordNotificationService( + { + botToken: "discord-bot-token", + adminUserId: "123456789012345678" + }, + { fetcher } + ); + + await expect(service.sendPairingNotification(request)).resolves.toBe(true); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(fetcher).toHaveBeenNthCalledWith( + 1, + "https://discord.com/api/v10/users/@me/channels", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bot discord-bot-token" + }), + body: JSON.stringify({ recipient_id: "123456789012345678" }) + }) + ); + expect(fetcher).toHaveBeenNthCalledWith( + 2, + "https://discord.com/api/v10/channels/dm-channel-1/messages", + expect.objectContaining({ + method: "POST" + }) + ); + }); + + it("returns false when Discord rejects DM channel creation", async () => { + const fetcher = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 403, + json: async () => ({ message: "Missing Access" }) + }); + + const service = createDiscordNotificationService( + { + botToken: "discord-bot-token", + adminUserId: "123456789012345678" + }, + { fetcher } + ); + + await expect(service.sendPairingNotification(request)).resolves.toBe(false); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it("returns false when config is missing required Discord credentials", async () => { + const fetcher = vi.fn(); + const service = createDiscordNotificationService( + { + botToken: "", + adminUserId: "" + }, + { fetcher } + ); + + await expect(service.sendPairingNotification(request)).resolves.toBe(false); + expect(fetcher).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/runtime-flow.test.ts b/tests/runtime-flow.test.ts index 82a0f75..778177b 100644 --- a/tests/runtime-flow.test.ts +++ b/tests/runtime-flow.test.ts @@ -98,8 +98,27 @@ function createMockTransport() { }; } +function stubDiscordFetchSuccess() { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ id: "dm-channel-1" }) + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ id: "message-1" }) + }) + ); +} + describe("Yonexus.Server runtime flow", () => { it("runs hello -> pair_request for an unpaired client", async () => { + stubDiscordFetchSuccess(); const store = createMockStore(); const transportState = createMockTransport(); const runtime = createYonexusServerRuntime({ @@ -160,6 +179,7 @@ describe("Yonexus.Server runtime flow", () => { }); it("completes pair_confirm -> auth_request -> heartbeat for a client", async () => { + stubDiscordFetchSuccess(); let now = 1_710_000_000; const keyPair = await generateKeyPair(); const store = createMockStore();