feat(discord): Discord transport

This commit is contained in:
Shadow
2025-12-15 10:11:18 -06:00
committed by Peter Steinberger
parent 557f8e5a04
commit ac659ff5a7
44 changed files with 1352 additions and 56 deletions

2
src/discord/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { monitorDiscordProvider } from "./monitor.js";
export { sendMessageDiscord } from "./send.js";

323
src/discord/monitor.ts Normal file
View File

@@ -0,0 +1,323 @@
import {
Client,
Events,
GatewayIntentBits,
type Message,
Partials,
} from "discord.js";
import { chunkText } from "../auto-reply/chunk.js";
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
import { getReplyFromConfig } from "../auto-reply/reply.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import { loadConfig } from "../config/config.js";
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
import { danger, isVerbose, logVerbose } from "../globals.js";
import { getChildLogger } from "../logging.js";
import { detectMime } from "../media/mime.js";
import { saveMediaBuffer } from "../media/store.js";
import type { RuntimeEnv } from "../runtime.js";
import { sendMessageDiscord } from "./send.js";
import { normalizeDiscordToken } from "./token.js";
export type MonitorDiscordOpts = {
token?: string;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
allowFrom?: Array<string | number>;
requireMention?: boolean;
mediaMaxMb?: number;
};
type DiscordMediaInfo = {
path: string;
contentType?: string;
placeholder: string;
};
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const cfg = loadConfig();
const token = normalizeDiscordToken(
opts.token ?? process.env.DISCORD_BOT_TOKEN ?? cfg.discord?.token ?? undefined,
);
if (!token) {
throw new Error(
"DISCORD_BOT_TOKEN or discord.token is required for Discord gateway",
);
}
const runtime: RuntimeEnv = opts.runtime ?? {
log: console.log,
error: console.error,
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
};
const allowFrom = opts.allowFrom ?? cfg.discord?.allowFrom;
const requireMention =
opts.requireMention ?? cfg.discord?.requireMention ?? true;
const mediaMaxBytes =
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.DirectMessages,
],
partials: [Partials.Channel],
});
const logger = getChildLogger({ module: "discord-auto-reply" });
client.once(Events.ClientReady, () => {
runtime.log?.(`discord: logged in as ${client.user?.tag ?? "unknown"}`);
});
client.on(Events.Error, (err) => {
runtime.error?.(danger(`discord client error: ${String(err)}`));
});
client.on(Events.MessageCreate, async (message) => {
try {
if (message.author?.bot) return;
if (!message.author) return;
const isDirectMessage = !message.guild;
if (!isDirectMessage && requireMention) {
const botId = client.user?.id;
if (botId && !message.mentions.has(botId)) {
logger.info(
{
channelId: message.channelId,
reason: "no-mention",
},
"discord: skipping guild message",
);
return;
}
}
if (isDirectMessage && Array.isArray(allowFrom) && allowFrom.length > 0) {
const allowed = allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean);
const candidate = message.author.id;
const normalized = new Set(
allowed
.filter((entry) => entry !== "*")
.map((entry) => entry.replace(/^discord:/i, "")),
);
const permitted =
allowed.includes("*") ||
normalized.has(candidate) ||
allowed.includes(candidate);
if (!permitted) {
logVerbose(
`Blocked unauthorized discord sender ${candidate} (not in allowFrom)`,
);
return;
}
}
const media = await resolveMedia(message, mediaMaxBytes);
const text =
message.content?.trim() ??
media?.placeholder ??
message.embeds[0]?.description ??
"";
if (!text) return;
const fromLabel = isDirectMessage
? buildDirectLabel(message)
: buildGuildLabel(message);
const body = formatAgentEnvelope({
surface: "Discord",
from: fromLabel,
timestamp: message.createdTimestamp,
body: text,
});
const ctxPayload = {
Body: body,
From: isDirectMessage
? `discord:${message.author.id}`
: `group:${message.channelId}`,
To: isDirectMessage
? `user:${message.author.id}`
: `channel:${message.channelId}`,
ChatType: isDirectMessage ? "direct" : "group",
SenderName: message.member?.displayName ?? message.author.tag,
GroupSubject:
!isDirectMessage && "name" in message.channel
? message.channel.name
: undefined,
Surface: "discord" as const,
MessageSid: message.id,
Timestamp: message.createdTimestamp,
MediaPath: media?.path,
MediaType: media?.contentType,
MediaUrl: media?.path,
};
if (isDirectMessage) {
const sessionCfg = cfg.inbound?.reply?.session;
const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main";
const storePath = resolveStorePath(sessionCfg?.store);
await updateLastRoute({
storePath,
sessionKey: mainKey,
channel: "discord",
to: `user:${message.author.id}`,
});
}
if (isVerbose()) {
const preview = body.slice(0, 200).replace(/\n/g, "\\n");
logVerbose(
`discord inbound: channel=${message.channelId} from=${ctxPayload.From} preview="${preview}"`,
);
}
const replyResult = await getReplyFromConfig(
ctxPayload,
{
onReplyStart: () => sendTyping(message),
},
cfg,
);
const replies = replyResult
? Array.isArray(replyResult)
? replyResult
: [replyResult]
: [];
if (replies.length === 0) return;
await deliverReplies({
replies,
target: ctxPayload.To,
token,
runtime,
});
} catch (err) {
runtime.error?.(danger(`Discord handler failed: ${String(err)}`));
}
});
await client.login(token);
await new Promise<void>((resolve, reject) => {
const onAbort = () => {
cleanup();
client.destroy();
resolve();
};
const onError = (err: Error) => {
cleanup();
reject(err);
};
const cleanup = () => {
opts.abortSignal?.removeEventListener("abort", onAbort);
client.off(Events.Error, onError);
};
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
client.on(Events.Error, onError);
});
}
async function resolveMedia(
message: import("discord.js").Message,
maxBytes: number,
): Promise<DiscordMediaInfo | null> {
const attachment = message.attachments.first();
if (!attachment) return null;
const res = await fetch(attachment.url);
if (!res.ok) {
throw new Error(
`Failed to download discord attachment: HTTP ${res.status}`,
);
}
const buffer = Buffer.from(await res.arrayBuffer());
const saved = await saveMediaBuffer(
buffer,
detectMime({
buffer,
headerMime: attachment.contentType ?? res.headers.get("content-type"),
filePath: attachment.name ?? attachment.url,
}),
"inbound",
maxBytes,
);
return {
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder(attachment),
};
}
function inferPlaceholder(attachment: import("discord.js").Attachment): string {
const mime = attachment.contentType ?? "";
if (mime.startsWith("image/")) return "<media:image>";
if (mime.startsWith("video/")) return "<media:video>";
if (mime.startsWith("audio/")) return "<media:audio>";
return "<media:document>";
}
function buildDirectLabel(message: import("discord.js").Message) {
const username = message.author.tag;
return `${username} id:${message.author.id}`;
}
function buildGuildLabel(message: import("discord.js").Message) {
const channelName =
"name" in message.channel ? message.channel.name : message.channelId;
return `${message.guild?.name ?? "Guild"} #${channelName} id:${message.channelId}`;
}
async function sendTyping(message: Message) {
try {
const channel = message.channel;
if (channel.isSendable()) {
await channel.sendTyping();
}
} catch {
/* ignore */
}
}
async function deliverReplies({
replies,
target,
token,
runtime,
}: {
replies: ReplyPayload[];
target: string;
token: string;
runtime: RuntimeEnv;
}) {
for (const payload of replies) {
const mediaList =
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? "";
if (!text && mediaList.length === 0) continue;
if (mediaList.length === 0) {
for (const chunk of chunkText(text, 2000)) {
await sendMessageDiscord(target, chunk, { token });
}
} else {
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? text : "";
first = false;
await sendMessageDiscord(target, caption, {
token,
mediaUrl,
});
}
}
runtime.log?.(`discord: delivered reply to ${target}`);
}
}

73
src/discord/probe.ts Normal file
View File

@@ -0,0 +1,73 @@
import { normalizeDiscordToken } from "./token.js";
const DISCORD_API_BASE = "https://discord.com/api/v10";
export type DiscordProbe = {
ok: boolean;
status?: number | null;
error?: string | null;
elapsedMs: number;
bot?: { id?: string | null; username?: string | null };
};
async function fetchWithTimeout(
url: string,
timeoutMs: number,
fetcher: typeof fetch,
headers?: HeadersInit,
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetcher(url, { signal: controller.signal, headers });
} finally {
clearTimeout(timer);
}
}
export async function probeDiscord(
token: string,
timeoutMs: number,
): Promise<DiscordProbe> {
const started = Date.now();
const normalized = normalizeDiscordToken(token);
const result: DiscordProbe = {
ok: false,
status: null,
error: null,
elapsedMs: 0,
};
if (!normalized) {
return { ...result, error: "missing token", elapsedMs: Date.now() - started };
}
try {
const res = await fetchWithTimeout(
`${DISCORD_API_BASE}/users/@me`,
timeoutMs,
fetch,
{
Authorization: `Bot ${normalized}`,
},
);
if (!res.ok) {
result.status = res.status;
result.error = `getMe failed (${res.status})`;
return { ...result, elapsedMs: Date.now() - started };
}
const json = (await res.json()) as { id?: string; username?: string };
result.ok = true;
result.bot = {
id: json.id ?? null,
username: json.username ?? null,
};
return { ...result, elapsedMs: Date.now() - started };
} catch (err) {
return {
...result,
status: err instanceof Response ? err.status : result.status,
error: err instanceof Error ? err.message : String(err),
elapsedMs: Date.now() - started,
};
}
}

85
src/discord/send.test.ts Normal file
View File

@@ -0,0 +1,85 @@
import { Routes } from "discord.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { sendMessageDiscord } from "./send.js";
vi.mock("../web/media.js", () => ({
loadWebMedia: vi.fn().mockResolvedValue({
buffer: Buffer.from("img"),
fileName: "photo.jpg",
contentType: "image/jpeg",
kind: "image",
}),
}));
const makeRest = () => {
const postMock = vi.fn();
return {
rest: {
post: postMock,
} as unknown as import("discord.js").REST,
postMock,
};
};
describe("sendMessageDiscord", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("sends basic channel messages", async () => {
const { rest, postMock } = makeRest();
postMock.mockResolvedValue({
id: "msg1",
channel_id: "789",
});
const res = await sendMessageDiscord("channel:789", "hello world", {
rest,
token: "t",
});
expect(res).toEqual({ messageId: "msg1", channelId: "789" });
expect(postMock).toHaveBeenCalledWith(
Routes.channelMessages("789"),
expect.objectContaining({ body: { content: "hello world" } }),
);
});
it("starts DM when recipient is a user", async () => {
const { rest, postMock } = makeRest();
postMock
.mockResolvedValueOnce({ id: "chan1" })
.mockResolvedValueOnce({ id: "msg1", channel_id: "chan1" });
const res = await sendMessageDiscord("user:123", "hiya", {
rest,
token: "t",
});
expect(postMock).toHaveBeenNthCalledWith(
1,
Routes.userChannels(),
expect.objectContaining({ body: { recipient_id: "123" } }),
);
expect(postMock).toHaveBeenNthCalledWith(
2,
Routes.channelMessages("chan1"),
expect.objectContaining({ body: { content: "hiya" } }),
);
expect(res.channelId).toBe("chan1");
});
it("uploads media attachments", async () => {
const { rest, postMock } = makeRest();
postMock.mockResolvedValue({ id: "msg", channel_id: "789" });
const res = await sendMessageDiscord("channel:789", "photo", {
rest,
token: "t",
mediaUrl: "file:///tmp/photo.jpg",
});
expect(res.messageId).toBe("msg");
expect(postMock).toHaveBeenCalledWith(
Routes.channelMessages("789"),
expect.objectContaining({
files: [expect.objectContaining({ name: "photo.jpg" })],
}),
);
});
});

166
src/discord/send.ts Normal file
View File

@@ -0,0 +1,166 @@
import { REST, Routes } from "discord.js";
import { chunkText } from "../auto-reply/chunk.js";
import { loadConfig } from "../config/config.js";
import { loadWebMedia } from "../web/media.js";
import { normalizeDiscordToken } from "./token.js";
const DISCORD_TEXT_LIMIT = 2000;
type DiscordRecipient =
| {
kind: "user";
id: string;
}
| {
kind: "channel";
id: string;
};
type DiscordSendOpts = {
token?: string;
mediaUrl?: string;
verbose?: boolean;
rest?: REST;
};
export type DiscordSendResult = {
messageId: string;
channelId: string;
};
function resolveToken(explicit?: string) {
const cfgToken = loadConfig().discord?.token;
const token = normalizeDiscordToken(
explicit ?? process.env.DISCORD_BOT_TOKEN ?? cfgToken ?? undefined,
);
if (!token) {
throw new Error(
"DISCORD_BOT_TOKEN or discord.token is required for Discord sends",
);
}
return token;
}
function parseRecipient(raw: string): DiscordRecipient {
const trimmed = raw.trim();
if (!trimmed) {
throw new Error("Recipient is required for Discord sends");
}
const mentionMatch = trimmed.match(/^<@!?(\d+)>$/);
if (mentionMatch) {
return { kind: "user", id: mentionMatch[1] };
}
if (trimmed.startsWith("user:")) {
return { kind: "user", id: trimmed.slice("user:".length) };
}
if (trimmed.startsWith("channel:")) {
return { kind: "channel", id: trimmed.slice("channel:".length) };
}
if (trimmed.startsWith("discord:")) {
return { kind: "user", id: trimmed.slice("discord:".length) };
}
if (trimmed.startsWith("@")) {
const candidate = trimmed.slice(1);
if (!/^\d+$/.test(candidate)) {
throw new Error(
"Discord DMs require a user id (use user:<id> or a <@id> mention)",
);
}
return { kind: "user", id: candidate };
}
return { kind: "channel", id: trimmed };
}
async function resolveChannelId(
rest: REST,
recipient: DiscordRecipient,
): Promise<{ channelId: string; dm?: boolean }> {
if (recipient.kind === "channel") {
return { channelId: recipient.id };
}
const dmChannel = (await rest.post(Routes.userChannels(), {
body: { recipient_id: recipient.id },
})) as { id: string };
if (!dmChannel?.id) {
throw new Error("Failed to create Discord DM channel");
}
return { channelId: dmChannel.id, dm: true };
}
async function sendDiscordText(rest: REST, channelId: string, text: string) {
if (!text.trim()) {
throw new Error("Message must be non-empty for Discord sends");
}
if (text.length <= DISCORD_TEXT_LIMIT) {
const res = (await rest.post(Routes.channelMessages(channelId), {
body: { content: text },
})) as { id: string; channel_id: string };
return res;
}
const chunks = chunkText(text, DISCORD_TEXT_LIMIT);
let last: { id: string; channel_id: string } | null = null;
for (const chunk of chunks) {
last = (await rest.post(Routes.channelMessages(channelId), {
body: { content: chunk },
})) as { id: string; channel_id: string };
}
if (!last) {
throw new Error("Discord send failed (empty chunk result)");
}
return last;
}
async function sendDiscordMedia(
rest: REST,
channelId: string,
text: string,
mediaUrl: string,
) {
const media = await loadWebMedia(mediaUrl);
const caption =
text.length > DISCORD_TEXT_LIMIT ? text.slice(0, DISCORD_TEXT_LIMIT) : text;
const res = (await rest.post(Routes.channelMessages(channelId), {
body: {
content: caption || undefined,
},
files: [
{
data: media.buffer,
name: media.fileName ?? "upload",
},
],
})) as { id: string; channel_id: string };
if (text.length > DISCORD_TEXT_LIMIT) {
const remaining = text.slice(DISCORD_TEXT_LIMIT).trim();
if (remaining) {
await sendDiscordText(rest, channelId, remaining);
}
}
return res;
}
export async function sendMessageDiscord(
to: string,
text: string,
opts: DiscordSendOpts = {},
): Promise<DiscordSendResult> {
const token = resolveToken(opts.token);
const rest = opts.rest ?? new REST({ version: "10" }).setToken(token);
const recipient = parseRecipient(to);
const { channelId } = await resolveChannelId(rest, recipient);
let result:
| { id: string; channel_id: string }
| { id: string | null; channel_id: string };
if (opts.mediaUrl) {
result = await sendDiscordMedia(rest, channelId, text, opts.mediaUrl);
} else {
result = await sendDiscordText(rest, channelId, text);
}
return {
messageId: result.id ? String(result.id) : "unknown",
channelId: String(result.channel_id ?? channelId),
};
}

7
src/discord/token.ts Normal file
View File

@@ -0,0 +1,7 @@
export function normalizeDiscordToken(raw?: string | null): string | undefined {
if (!raw) return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;
return trimmed.replace(/^Bot\s+/i, "");
}