Files
Yonexus.Server/plugin/notifications/discord.ts

192 lines
5.5 KiB
TypeScript

/**
* 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, 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>;
}
export interface DiscordNotificationConfig {
botToken: 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 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,
options: { fetcher?: DiscordFetch } = {}
): DiscordNotificationService {
const fetcher = options.fetcher ?? getDefaultFetch();
return {
async sendPairingNotification(request: PairingRequest): Promise<boolean> {
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<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.
*/
export function formatPairingMessage(request: PairingRequest): string {
const expiresDate = new Date(request.expiresAt * 1000);
const expiresStr = expiresDate.toISOString();
return [
"🔐 **Yonexus Pairing Request**",
"",
`**Identifier:** \`${request.identifier}\``,
`**Pairing Code:** \`${request.pairingCode}\``,
`**Expires At:** ${expiresStr}`,
`**TTL:** ${request.ttlSeconds} seconds`,
"",
"Please relay this pairing code to the client operator via a trusted out-of-band channel.",
"Do not share this code over the Yonexus WebSocket connection."
].join("\n");
}
/**
* Create a mock notification service for testing.
* Returns success/failure based on configuration.
*/
export function createMockNotificationService(
options: { shouldSucceed?: boolean } = {}
): DiscordNotificationService {
const shouldSucceed = options.shouldSucceed ?? true;
return {
async sendPairingNotification(request: PairingRequest): Promise<boolean> {
console.log("[Yonexus.Server] Mock pairing notification:");
console.log(` Identifier: ${request.identifier}`);
console.log(` Pairing Code: ${redactPairingCode(request.pairingCode)}`);
console.log(` Success: ${shouldSucceed}`);
return shouldSucceed;
}
};
}