feat(server): wire Discord DM pairing notifications
This commit is contained in:
13
README.md
13
README.md
@@ -13,7 +13,7 @@ It runs on the main OpenClaw instance and is responsible for:
|
|||||||
|
|
||||||
## Status
|
## 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:
|
Implemented in this repository today:
|
||||||
|
|
||||||
@@ -25,14 +25,13 @@ Implemented in this repository today:
|
|||||||
- auth proof validation flow
|
- auth proof validation flow
|
||||||
- heartbeat receive + liveness sweep
|
- heartbeat receive + liveness sweep
|
||||||
- rule registry + send-to-client APIs
|
- 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:
|
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
|
- 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
|
## Install Layout
|
||||||
|
|
||||||
@@ -133,11 +132,11 @@ npm run check
|
|||||||
|
|
||||||
Current known limitations:
|
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 offline message queueing
|
||||||
- no multi-server topology
|
- no multi-server topology
|
||||||
- no management UI
|
- 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
|
## Related Repos
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PairingRequest } from "../services/pairing.js";
|
import type { PairingRequest } from "../services/pairing.js";
|
||||||
import { redactPairingCode } from "../core/logging.js";
|
import { redactPairingCode, safeErrorMessage } from "../core/logging.js";
|
||||||
|
|
||||||
export interface DiscordNotificationService {
|
export interface DiscordNotificationService {
|
||||||
/**
|
/**
|
||||||
@@ -20,48 +20,136 @@ export interface DiscordNotificationConfig {
|
|||||||
adminUserId: string;
|
adminUserId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DiscordApiResponse {
|
||||||
|
ok: boolean;
|
||||||
|
status: number;
|
||||||
|
json(): Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DiscordFetch = (
|
||||||
|
input: string,
|
||||||
|
init?: {
|
||||||
|
method?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
}
|
||||||
|
) => Promise<DiscordApiResponse>;
|
||||||
|
|
||||||
|
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.
|
* Create a Discord notification service backed by Discord's REST API.
|
||||||
*
|
*
|
||||||
* Note: This is a framework stub. Full implementation requires:
|
* Flow:
|
||||||
* - Discord bot client integration (e.g., using discord.js)
|
* 1. Create or fetch a DM channel for the configured admin user
|
||||||
* - DM channel creation/fetching
|
* 2. Post the formatted pairing message into that DM channel
|
||||||
* - 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.
|
|
||||||
*/
|
*/
|
||||||
export function createDiscordNotificationService(
|
export function createDiscordNotificationService(
|
||||||
config: DiscordNotificationConfig
|
config: DiscordNotificationConfig,
|
||||||
|
options: { fetcher?: DiscordFetch } = {}
|
||||||
): DiscordNotificationService {
|
): DiscordNotificationService {
|
||||||
|
const fetcher = options.fetcher ?? getDefaultFetch();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async sendPairingNotification(request: PairingRequest): Promise<boolean> {
|
async sendPairingNotification(request: PairingRequest): Promise<boolean> {
|
||||||
const redactedCode = redactPairingCode(request.pairingCode);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// Log to console (visible in OpenClaw logs)
|
try {
|
||||||
console.log("[Yonexus.Server] Pairing notification (Discord DM stub):", {
|
await sendDiscordDirectMessage({
|
||||||
|
config,
|
||||||
|
message: formatPairingMessage(request),
|
||||||
|
fetcher
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[Yonexus.Server] Pairing notification sent via Discord DM", {
|
||||||
identifier: request.identifier,
|
identifier: request.identifier,
|
||||||
pairingCode: redactedCode,
|
pairingCode: redactPairingCode(request.pairingCode),
|
||||||
expiresAt: request.expiresAt,
|
expiresAt: request.expiresAt,
|
||||||
ttlSeconds: request.ttlSeconds,
|
ttlSeconds: request.ttlSeconds,
|
||||||
adminUserId: config.adminUserId
|
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;
|
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<void> {
|
||||||
|
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<DiscordApiResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a pairing request as a Discord DM message.
|
* Format a pairing request as a Discord DM message.
|
||||||
*/
|
*/
|
||||||
|
|||||||
107
tests/notifications.test.ts
Normal file
107
tests/notifications.test.ts
Normal file
@@ -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<DiscordFetch>()
|
||||||
|
.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<DiscordFetch>().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<DiscordFetch>();
|
||||||
|
const service = createDiscordNotificationService(
|
||||||
|
{
|
||||||
|
botToken: "",
|
||||||
|
adminUserId: ""
|
||||||
|
},
|
||||||
|
{ fetcher }
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.sendPairingNotification(request)).resolves.toBe(false);
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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", () => {
|
describe("Yonexus.Server runtime flow", () => {
|
||||||
it("runs hello -> pair_request for an unpaired client", async () => {
|
it("runs hello -> pair_request for an unpaired client", async () => {
|
||||||
|
stubDiscordFetchSuccess();
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const transportState = createMockTransport();
|
const transportState = createMockTransport();
|
||||||
const runtime = createYonexusServerRuntime({
|
const runtime = createYonexusServerRuntime({
|
||||||
@@ -160,6 +179,7 @@ describe("Yonexus.Server runtime flow", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("completes pair_confirm -> auth_request -> heartbeat for a client", async () => {
|
it("completes pair_confirm -> auth_request -> heartbeat for a client", async () => {
|
||||||
|
stubDiscordFetchSuccess();
|
||||||
let now = 1_710_000_000;
|
let now = 1_710_000_000;
|
||||||
const keyPair = await generateKeyPair();
|
const keyPair = await generateKeyPair();
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
|
|||||||
Reference in New Issue
Block a user