192 lines
5.5 KiB
TypeScript
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;
|
|
}
|
|
};
|
|
}
|