Universal Event Bus + Discord Adapter (Topics → Routes → Actions)
I don’t want a pile of bespoke bot commands. I want a small event system with pluggable adapters.
Declare a topic
with a typed payload → the router selects a route → an action renders a message model → the adapter delivers it (Discord today, Slack/Webhook tomorrow).
This is a drop-in mini-architecture you can use anywhere (Next.js API routes, workers, CRON jobs). It’s opinionated, framework-agnostic, and production-lean.
Folder Layout
bot/
src/
adapter/
discord.ts # Discord adapter (delivery)
slack.ts # (optional) Slack adapter stub
webhook.ts # (optional) HTTP webhook adapter
transport.ts # transport interface + factory
message.ts # unified message model (embeds, components)
core/
bus.ts # topic emission + middleware
router.ts # topic → route (channel, action, adapter)
middleware.ts # before/after hooks (audit, retries, enrich)
actions/
userSignup.ts
labPublished.ts
opsAlert.ts
events/
types.ts # zod schemas + Topic union
config/
routes.ts # declarative routing
settings.ts # env/config loader
tests/
actions.spec.ts # example unit test
index.ts # entrypoint / demo producers
.env
Install
mkdir bot && cd bot
pnpm init -y
pnpm add discord.js undici zod
pnpm add -D typescript tsx @types/node vitest
npx tsc --init --rootDir src --outDir dist --module ESNext --moduleResolution Bundler --target ES2022
.env
(do not commit):
DISCORD_BOT_TOKEN=xxxxx
DISCORD_DEFAULT_CHANNEL_ID=111111111111111111
DISCORD_SIGNUPS_CHANNEL_ID=...
DISCORD_LABS_CHANNEL_ID=...
DISCORD_OPS_CHANNEL_ID=...
Message Contract (unified across adapters)
// src/adapter/message.ts
export type Button = {
id: string; // action id (e.g., "lab.open")
label: string;
style?: "primary" | "secondary" | "danger";
url?: string; // if present, treated as link button
};
export type SimpleMessage = {
content?: string; // plain text (fallback)
embed?: {
title?: string;
description?: string;
url?: string;
fields?: Array<{ name: string; value: string; inline?: boolean }>;
footer?: string;
thumbnailUrl?: string;
};
components?: {
buttons?: Button[];
};
// hint for adapters (optional)
notify?: "low" | "normal" | "high";
};
Event Types (typed topics)
// src/events/types.ts
import { z } from "zod";
export type Topic =
| "user.signup"
| "lab.published"
| "ops.alert";
export const UserSignup = z.object({
id: z.string(),
username: z.string(),
profileUrl: z.string().url().optional(),
joinedAt: z.string().datetime().optional(),
});
export type UserSignup = z.infer<typeof UserSignup>;
export const LabPublished = z.object({
title: z.string(),
slug: z.string(),
summary: z.string().optional(),
url: z.string().url(),
tags: z.array(z.string()).default([]),
date: z.string().optional(),
});
export type LabPublished = z.infer<typeof LabPublished>;
export const OpsAlert = z.object({
severity: z.enum(["low", "medium", "high", "critical"]),
message: z.string(),
link: z.string().url().optional(),
code: z.string().optional(), // incident id
});
export type OpsAlert = z.infer<typeof OpsAlert>;
Tip: validate at the edge (API route/producer) with
safeParse
so your bus only accepts well-formed payloads.
Actions (pure render functions)
// src/actions/userSignup.ts
import type { SimpleMessage } from "../adapter/message";
import type { UserSignup } from "../events/types";
export function userSignupAction(ev: UserSignup): SimpleMessage {
return {
embed: {
title: "New Signup",
description: `**${ev.username}** joined.`,
url: ev.profileUrl,
fields: [
{ name: "User ID", value: ev.id },
...(ev.joinedAt ? [{ name: "Joined", value: new Date(ev.joinedAt).toLocaleString() }] : []),
],
footer: "labs · signups",
},
components: { buttons: ev.profileUrl ? [{ id: "user.open", label: "Open Profile", url: ev.profileUrl }] : [] },
};
}
// src/actions/labPublished.ts
import type { SimpleMessage } from "../adapter/message";
import type { LabPublished } from "../events/types";
export function labPublishedAction(ev: LabPublished): SimpleMessage {
const tags = ev.tags?.length ? "\n\n" + ev.tags.map(t => `\`${t}\``).join(", ") : "";
return {
embed: {
title: ev.title,
description: `${(ev.summary || "").slice(0, 220)}${tags}`.trim(),
url: ev.url,
footer: "labs · published",
},
components: { buttons: [{ id: "lab.open", label: "Read", url: ev.url, style: "primary" }] },
};
}
// src/actions/opsAlert.ts
import type { SimpleMessage } from "../adapter/message";
import type { OpsAlert } from "../events/types";
export function opsAlertAction(ev: OpsAlert): SimpleMessage {
const prefix = ev.severity.toUpperCase();
return {
content: `[${prefix}] ${ev.message}${ev.link ? ` — ${ev.link}` : ""}`,
notify: ev.severity === "critical" ? "high" : "normal",
};
}
Settings Loader
// src/config/settings.ts
export const env = (k: string, fallback?: string) => {
const v = process.env[k];
if (v && v.length) return v;
if (fallback !== undefined) return fallback;
throw new Error(`Missing env var: ${k}`);
};
Declarative Routes
// src/config/routes.ts
import type { Topic } from "../events/types";
import type { SimpleMessage } from "../adapter/message";
import { env } from "./settings";
import { userSignupAction } from "../actions/userSignup";
import { labPublishedAction } from "../actions/labPublished";
import { opsAlertAction } from "../actions/opsAlert";
type Route = {
adapter: "discord" | "slack" | "webhook";
channelId?: string; // for chat adapters
endpointUrl?: string; // for webhooks
action: (payload: any) => SimpleMessage;
};
export const routes: Record<Topic, Route[]> = {
"user.signup": [
{ adapter: "discord", channelId: env("DISCORD_SIGNUPS_CHANNEL_ID"), action: userSignupAction },
],
"lab.published": [
{ adapter: "discord", channelId: env("DISCORD_LABS_CHANNEL_ID"), action: labPublishedAction },
],
"ops.alert": [
{ adapter: "discord", channelId: env("DISCORD_OPS_CHANNEL_ID"), action: opsAlertAction },
// Example fan-out to a webhook:
// { adapter: "webhook", endpointUrl: env("OPS_WEBHOOK_URL"), action: opsAlertAction },
],
};
Transport Interface + Factory
// src/adapter/transport.ts
import type { SimpleMessage } from "./message";
export interface Transport {
send(target: { channelId?: string; endpointUrl?: string }, msg: SimpleMessage): Promise<void>;
}
export type TransportFactory = (kind: "discord" | "slack" | "webhook") => Transport;
Discord Transport (adapter)
// src/adapter/discord.ts
import { Client, GatewayIntentBits, TextChannel, EmbedBuilder } from "discord.js";
import type { SimpleMessage } from "./message";
import type { Transport } from "./transport";
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
let ready = false;
async function ensureReady() {
if (ready) return;
await client.login(process.env.DISCORD_BOT_TOKEN);
await new Promise<void>((res) => client.once("ready", () => { ready = true; res(); }));
}
export function discordTransport(): Transport {
return {
async send(target, msg) {
await ensureReady();
const channelId = target.channelId;
if (!channelId) throw new Error("Discord target missing channelId");
const ch = await client.channels.fetch(channelId);
if (!ch || !ch.isTextBased()) throw new Error("Discord channel not text-capable");
if (msg.embed) {
const eb = new EmbedBuilder()
.setTitle(msg.embed.title || "")
.setDescription(msg.embed.description || "")
.setURL(msg.embed.url || null as any);
msg.embed.fields?.forEach(f => eb.addFields({ name: f.name, value: f.value, inline: f.inline ?? false }));
if (msg.embed.footer) eb.setFooter({ text: msg.embed.footer });
if (msg.embed.thumbnailUrl) eb.setThumbnail(msg.embed.thumbnailUrl);
await (ch as TextChannel).send({ embeds: [eb], content: msg.content || undefined });
} else {
await (ch as TextChannel).send({ content: msg.content || "" });
}
// buttons omitted for brevity; map to discord components if needed
}
};
}
Webhook Transport (generic HTTP)
// src/adapter/webhook.ts
import { Transport } from "./transport";
import { fetch } from "undici";
export function webhookTransport(): Transport {
return {
async send(target, msg) {
if (!target.endpointUrl) throw new Error("Webhook target missing endpointUrl");
await fetch(target.endpointUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(msg),
});
}
};
}
Router + Bus + Middleware
// src/core/middleware.ts
import type { Topic } from "../events/types";
export type Before = (ctx: { topic: Topic; payload: any }) => Promise<void> | void;
export type After = (ctx: { topic: Topic; payload: any; error?: unknown }) => Promise<void> | void;
export type Middleware = { before?: Before; after?: After };
export function chain(middlewares: Middleware[]): Middleware {
return {
async before(ctx) { for (const m of middlewares) await m.before?.(ctx); },
async after(ctx) { for (const m of middlewares.slice().reverse()) await m.after?.(ctx); },
};
}
// src/core/router.ts
import type { Topic } from "../events/types";
import { routes } from "../config/routes";
export function getRoutes(topic: Topic) {
const r = routes[topic];
if (!r || r.length === 0) throw new Error(`No routes configured for topic: ${topic}`);
return r;
}
// src/core/bus.ts
import type { Topic } from "../events/types";
import type { TransportFactory } from "../adapter/transport";
import { getRoutes } from "./router";
import { chain, type Middleware } from "./middleware";
export function createBus(makeTransport: TransportFactory, middleware: Middleware[] = []) {
const mw = chain(middleware);
return {
async emit<T = any>(topic: Topic, payload: T) {
await mw.before?.({ topic, payload });
let error: unknown;
try {
const routes = getRoutes(topic);
for (const r of routes) {
const transport = makeTransport(r.adapter);
const message = r.action(payload);
await transport.send({ channelId: r.channelId, endpointUrl: r.endpointUrl }, message);
}
} catch (err) {
error = err;
throw err;
} finally {
await mw.after?.({ topic, payload, error });
}
}
};
}
Transport Factory
// src/adapter/factory.ts
import type { Transport, TransportFactory } from "./transport";
import { discordTransport } from "./discord";
import { webhookTransport } from "./webhook";
export const makeTransport: TransportFactory = (kind) => {
const map: Record<string, Transport> = {
discord: discordTransport(),
webhook: webhookTransport(),
// slack: slackTransport(), // add later
};
const t = map[kind];
if (!t) throw new Error(`Unknown adapter: ${kind}`);
return t;
};
Middleware Examples (audit, retry)
// src/mw/audit.ts
import type { Middleware } from "../core/middleware";
export const audit: Middleware = {
before: ({ topic }) => {
console.log(`[bus] emit ${topic}`);
},
after: ({ topic, error }) => {
if (error) console.error(`[bus] error on ${topic}:`, error);
else console.log(`[bus] delivered ${topic}`);
},
};
// src/mw/retry.ts
import type { Middleware } from "../core/middleware";
export function retry({ attempts = 3, delayMs = 300 }: { attempts?: number; delayMs?: number }): Middleware {
return {
async after(ctx) {
// no-op here; we want retry around send.
// For simplicity, wrap at transport.send if you need per-send retry.
}
};
}
For real retries, wrap the
transport.send
call inbus.emit
with a small exponential backoff.
Entry (demo producers)
// src/index.ts
import "dotenv/config";
import { createBus } from "./core/bus";
import { makeTransport } from "./adapter/factory";
import { audit } from "./mw/audit";
async function main() {
const bus = createBus(makeTransport, [audit]);
await bus.emit("user.signup", {
id: "u_123",
username: "new_user",
profileUrl: "https://example.com/u/new_user",
joinedAt: new Date().toISOString(),
});
await bus.emit("lab.published", {
title: "Workflow Superchargers with ChatGPT",
slug: "workflow-superchargers",
summary: "Top use cases where AI accelerates daily work.",
url: "https://labs.example.com/workflow-superchargers",
tags: ["workflow", "ai"],
});
await bus.emit("ops.alert", {
severity: "critical",
message: "DB connection pool saturation detected",
link: "https://status.example.com/incidents/abcd",
code: "INC-2025-09-19",
});
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
Run it:
pnpm tsx src/index.ts
Next.js Hook (example)
In a Next.js signup API route:
// app/api/signup/route.ts
import { NextResponse } from "next/server";
import { createBus } from "@/bot/core/bus";
import { makeTransport } from "@/bot/adapter/factory";
const bus = createBus(makeTransport);
export async function POST(req: Request) {
const body = await req.json();
// ...create user...
await bus.emit("user.signup", {
id: "u_123",
username: body.username,
profileUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/u/${body.username}`,
joinedAt: new Date().toISOString(),
});
return NextResponse.json({ ok: true });
}
Testing an Action (Vitest)
// src/tests/actions.spec.ts
import { describe, it, expect } from "vitest";
import { labPublishedAction } from "../actions/labPublished";
describe("labPublishedAction", () => {
it("renders embed with title and link", () => {
const msg = labPublishedAction({
title: "T",
slug: "t",
url: "https://x.y/t",
summary: "S",
tags: ["a","b"]
});
expect(msg.embed?.title).toBe("T");
expect(msg.embed?.url).toBe("https://x.y/t");
});
});
Run: pnpm vitest
Security & Ops Notes
- Least privilege: keep the bot in only the channels it needs.
- Secrets in env, rotated periodically.
- Backpressure: if traffic spikes, queue
bus.emit
calls (BullMQ/SQS/Cloud Tasks). - Idempotency: include event IDs to avoid dupes on retries.
- Observability: add audit middleware that logs topic, duration, adapter target, and result.
Takeaway
Keep domain logic event-shaped and the chat plumbing adapter-shaped.
You should be able to add Slack, webhooks, or email by adding a small adapter, not rewriting your app.