Skip to content

Commit 7b62968

Browse files
committed
Add Discord prompt agent with event and Luma tools.
This enables channel message prompts via gateway listener cron, routes prompts through a model with event lookup/update and Luma fetch tools, and keeps approval actions in the same bot runtime.
1 parent 1c4a53d commit 7b62968

4 files changed

Lines changed: 250 additions & 1 deletion

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { after } from "next/server";
2+
import { mainConfig } from "@/lib/config";
3+
import { getDiscordReviewBot } from "@/lib/discord/review-bot";
4+
5+
export const runtime = "nodejs";
6+
export const dynamic = "force-dynamic";
7+
export const maxDuration = 800;
8+
9+
function isAuthorized(request: Request): boolean {
10+
const cronSecret = mainConfig.cron.secret?.trim();
11+
if (!cronSecret) {
12+
return false;
13+
}
14+
15+
return request.headers.get("authorization") === `Bearer ${cronSecret}`;
16+
}
17+
18+
export async function GET(request: Request): Promise<Response> {
19+
const cronSecret = mainConfig.cron.secret?.trim();
20+
if (!cronSecret) {
21+
return new Response("CRON_SECRET not configured", { status: 500 });
22+
}
23+
24+
if (!isAuthorized(request)) {
25+
return new Response("Unauthorized", { status: 401 });
26+
}
27+
28+
const bot = getDiscordReviewBot();
29+
const discordAdapter = bot.getAdapter("discord");
30+
const durationMs = 600 * 1000;
31+
const webhookUrl = `${mainConfig.instance.origin}/api/webhooks/discord`;
32+
33+
return discordAdapter.startGatewayListener(
34+
{ waitUntil: (task) => after(() => task) },
35+
durationMs,
36+
undefined,
37+
webhookUrl,
38+
);
39+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { db } from "@/lib/db";
2+
import { mainConfig } from "@/lib/config";
3+
import { eventsTable, type InsertEvent } from "@/lib/schema";
4+
import { createLumaClient } from "@/lib/luma";
5+
import { and, eq } from "drizzle-orm";
6+
import { createGateway, generateText, tool } from "ai";
7+
import { z } from "zod";
8+
9+
const DISCORD_AGENT_MODEL = "openai/gpt-5.3-medium";
10+
11+
const updateEventInputSchema = z.object({
12+
slug: z.string().min(1),
13+
eventData: z
14+
.object({
15+
name: z.string().optional(),
16+
slug: z.string().optional(),
17+
attendeeLimit: z.number().int().positive().optional(),
18+
tagline: z.string().optional(),
19+
startDate: z.string().optional(),
20+
endDate: z.string().optional(),
21+
lumaEventId: z.string().nullable().optional(),
22+
isDraft: z.boolean().optional(),
23+
isHackathon: z.boolean().optional(),
24+
highlightOnLandingPage: z.boolean().optional(),
25+
fullAddress: z.string().nullable().optional(),
26+
shortLocation: z.string().nullable().optional(),
27+
streetAddress: z.string().nullable().optional(),
28+
recordingUrl: z.string().nullable().optional(),
29+
})
30+
.refine((data) => Object.keys(data).length > 0, {
31+
message: "eventData must include at least one field",
32+
}),
33+
});
34+
35+
function getGatewayModel() {
36+
const gatewayApiKey = mainConfig.ai.gatewayApiKey;
37+
const oidcToken = mainConfig.ai.vercelOidcToken;
38+
39+
if (!gatewayApiKey && !oidcToken) {
40+
throw new Error(
41+
"AI gateway auth missing. Set AI_GATEWAY_API_KEY or VERCEL_OIDC_TOKEN.",
42+
);
43+
}
44+
45+
if (gatewayApiKey) {
46+
return createGateway({ apiKey: gatewayApiKey })(DISCORD_AGENT_MODEL);
47+
}
48+
49+
return createGateway({
50+
headers: {
51+
Authorization: `Bearer ${oidcToken}`,
52+
},
53+
})(DISCORD_AGENT_MODEL);
54+
}
55+
56+
function toDate(value: string): Date {
57+
const date = new Date(value);
58+
if (Number.isNaN(date.getTime())) {
59+
throw new Error(`Invalid date value: ${value}`);
60+
}
61+
return date;
62+
}
63+
64+
function normalizeUpdateData(
65+
eventData: z.infer<typeof updateEventInputSchema>["eventData"],
66+
): Partial<InsertEvent> {
67+
const normalized: Partial<InsertEvent> = {};
68+
69+
if (typeof eventData.name === "string") normalized.name = eventData.name;
70+
if (typeof eventData.slug === "string") normalized.slug = eventData.slug;
71+
if (typeof eventData.attendeeLimit === "number") {
72+
normalized.attendeeLimit = eventData.attendeeLimit;
73+
}
74+
if (typeof eventData.tagline === "string") normalized.tagline = eventData.tagline;
75+
if (typeof eventData.startDate === "string") {
76+
normalized.startDate = toDate(eventData.startDate);
77+
}
78+
if (typeof eventData.endDate === "string") {
79+
normalized.endDate = toDate(eventData.endDate);
80+
}
81+
if ("lumaEventId" in eventData) normalized.lumaEventId = eventData.lumaEventId;
82+
if (typeof eventData.isDraft === "boolean") normalized.isDraft = eventData.isDraft;
83+
if (typeof eventData.isHackathon === "boolean") {
84+
normalized.isHackathon = eventData.isHackathon;
85+
}
86+
if (typeof eventData.highlightOnLandingPage === "boolean") {
87+
normalized.highlightOnLandingPage = eventData.highlightOnLandingPage;
88+
}
89+
if ("fullAddress" in eventData) normalized.fullAddress = eventData.fullAddress;
90+
if ("shortLocation" in eventData) normalized.shortLocation = eventData.shortLocation;
91+
if ("streetAddress" in eventData) normalized.streetAddress = eventData.streetAddress;
92+
if ("recordingUrl" in eventData) normalized.recordingUrl = eventData.recordingUrl;
93+
94+
return normalized;
95+
}
96+
97+
async function getEventBySlug(slug: string) {
98+
const [event] = await db
99+
.select()
100+
.from(eventsTable)
101+
.where(eq(eventsTable.slug, slug))
102+
.limit(1);
103+
104+
return event ?? null;
105+
}
106+
107+
async function updateEventBySlug(input: z.infer<typeof updateEventInputSchema>) {
108+
const existingEvent = await getEventBySlug(input.slug);
109+
if (!existingEvent) {
110+
throw new Error(`Event not found for slug: ${input.slug}`);
111+
}
112+
113+
const normalized = normalizeUpdateData(input.eventData);
114+
const [updated] = await db
115+
.update(eventsTable)
116+
.set(normalized)
117+
.where(and(eq(eventsTable.id, existingEvent.id)))
118+
.returning();
119+
120+
return updated ?? null;
121+
}
122+
123+
function buildTools() {
124+
return {
125+
get_event_by_slug: tool({
126+
description: "Fetch an AllThingsWeb event by slug.",
127+
inputSchema: z.object({
128+
slug: z.string().min(1),
129+
}),
130+
execute: async ({ slug }) => getEventBySlug(slug),
131+
}),
132+
update_event: tool({
133+
description:
134+
"Update an AllThingsWeb event by slug. Use this only when the user explicitly asks to change event data.",
135+
inputSchema: updateEventInputSchema,
136+
execute: async ({ slug, eventData }) =>
137+
updateEventBySlug({
138+
slug,
139+
eventData,
140+
}),
141+
}),
142+
get_luma_event: tool({
143+
description: "Fetch a Luma event payload by Luma event ID (api_id).",
144+
inputSchema: z.object({
145+
eventId: z.string().min(1),
146+
}),
147+
execute: async ({ eventId }) => createLumaClient().getEvent(eventId),
148+
}),
149+
};
150+
}
151+
152+
export async function runDiscordPromptAgent(input: {
153+
prompt: string;
154+
}): Promise<string> {
155+
const { text } = await generateText({
156+
model: getGatewayModel(),
157+
tools: buildTools(),
158+
system: `You are the AllThingsWeb Discord ops assistant.
159+
You can inspect and update AllThingsWeb event data and fetch Luma event payloads.
160+
Use tools whenever the answer depends on current data.
161+
Before updating event data, confirm intent from the user message and summarize what changed.
162+
Be concise and action-oriented.`,
163+
prompt: input.prompt,
164+
});
165+
166+
return text.trim();
167+
}

app/src/lib/discord/review-bot.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
import { createDiscordAdapter } from "@chat-adapter/discord";
22
import { createRedisState } from "@chat-adapter/state-redis";
3-
import { Actions, Button, Card, CardText, Chat, Field, Fields } from "chat";
3+
import {
4+
Actions,
5+
Button,
6+
Card,
7+
CardText,
8+
Chat,
9+
Field,
10+
Fields,
11+
type Message,
12+
type Thread,
13+
} from "chat";
414
import { mainConfig } from "@/lib/config";
15+
import { runDiscordPromptAgent } from "@/lib/discord/prompt-agent";
516
import { approveDiscordReviewSessionByEventId } from "@/lib/review/approval";
617

718
type ReviewCardInput = {
@@ -83,6 +94,34 @@ function registerHandlers(
8394
await event.thread.post("No pending review session found for this event.");
8495
});
8596

97+
const promptFromMessage = async (thread: Thread, message: Message) => {
98+
const userPrompt = message.text?.trim();
99+
if (!userPrompt) {
100+
return;
101+
}
102+
103+
try {
104+
await thread.startTyping();
105+
const response = await runDiscordPromptAgent({
106+
prompt: userPrompt,
107+
});
108+
await thread.post(response || "I couldn't generate a response.");
109+
} catch (error) {
110+
const errorMessage =
111+
error instanceof Error ? error.message : "Unknown error";
112+
await thread.post(`I hit an error while processing that request: ${errorMessage}`);
113+
}
114+
};
115+
116+
bot.onNewMessage(/\S+/, async (thread, message) => {
117+
await thread.subscribe();
118+
await promptFromMessage(thread, message);
119+
});
120+
121+
bot.onSubscribedMessage(async (thread, message) => {
122+
await promptFromMessage(thread, message);
123+
});
124+
86125
handlersRegistered = true;
87126
}
88127

app/vercel.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
{
22
"crons": [
3+
{
4+
"path": "/api/discord/gateway",
5+
"schedule": "*/9 * * * *"
6+
},
37
{
48
"path": "/api/cron/luma-sync",
59
"schedule": "0 * * * *"

0 commit comments

Comments
 (0)