Added cron

This commit is contained in:
Corey Johnson 2025-07-25 14:15:23 -07:00
parent 0e058001ac
commit 3742a016aa
18 changed files with 430 additions and 200 deletions

21
main.ts
View File

@ -17,14 +17,31 @@ const run = async (cmd: string[]) => {
console.log(`👋 Process ${commandText}(PID ${proc.pid}) exited with code ${status}`)
}
return status
return proc
}
// Make sure we cleanup processes on exit
const prepareCleanup = () => {
const cleanup = async (signal: NodeJS.Signals | "exit") => {
process.on(signal, () => {
console.log(`🛑 Received ${signal}, cleaning up...`)
procs.forEach((proc) => proc.kill("SIGTERM"))
})
}
cleanup("SIGINT")
cleanup("SIGTERM")
cleanup("SIGHUP")
cleanup("exit")
}
let procs: Bun.Subprocess[] = []
try {
prepareCleanup()
const isDev = process.env.NODE_ENV !== "production"
const noElide = isDev ? "--elide-lines=0" : ""
await Promise.all([
procs = await Promise.all([
run(["bun", "run", noElide, "--filter=@workshop/http", "start"]),
run(["bun", "run", noElide, "--filter=@workshop/spike", "bot:discord"]),
])

View File

@ -7,7 +7,8 @@
],
"catalog": {
"hono": "^4.8.0",
"zod": "3.25.67"
"zod": "3.25.67",
"luxon": "^3.7.1"
}
},
"prettier": {

View File

@ -4,8 +4,13 @@
"type": "module",
"private": true,
"scripts": {
"dev": "bun run --hot src/server",
"start": "bun run src/server"
"dev": "bun run --hot src/main.ts",
"start": "bun run src/main.ts",
"cron:constantly": "echo '💰 constantly'",
"cron:hourly@0:25": "echo '⌛ hourly@:030'",
"cron:daily@21:20": "echo '🏬 daily at midnight'",
"cron:monthly@13:00": "echo '🏬 daily at noon'",
"cron:daily@21:22 #fail": "echo '🍖 constantly with failure' && ls /nonexistant"
},
"prettier": {
"printWidth": 110,
@ -16,7 +21,9 @@
"@workshop/shared": "workspace:*",
"@workshop/todo": "workspace:*",
"hono": "catalog:",
"tailwind": "^4.0.0"
"tailwind": "^4.0.0",
"luxon": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@types/bun": "latest"

44
packages/http/src/auth.ts Normal file
View File

@ -0,0 +1,44 @@
export const requireAuth = (handler: (req: Request) => Response | Promise<Response>) => {
return async (req: Request) => {
if (process.env.NODE_ENV !== "production") {
return handler(req)
}
const cookies = new Bun.CookieMap(req.headers.get("cookie") ?? "")
if (cookies.get("auth") === "authenticated") {
return handler(req)
}
if (validAuth(req)) {
const response = await handler(req)
const newHeaders = new Headers(response.headers)
const url = new URL(req.url)
const cookieDomain = `.${url.hostname.split(".").slice(-2).join(".")}`
const authCookie = new Bun.Cookie({
name: "auth",
value: "authenticated",
domain: cookieDomain,
path: "/",
maxAge: 86400,
httpOnly: true,
})
newHeaders.set("Set-Cookie", authCookie.toString())
return new Response(response.body, { status: response.status, headers: newHeaders })
}
return new Response("Unauthorized", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="Workshop Access"' },
})
}
}
const validAuth = (req: Request): boolean => {
const logins = ["spike:888", "defunkt:888", "probablycorey:888"]
const auth = req.headers.get("authorization")
return logins.some((login) => `Basic ${btoa(login)}` === auth)
}

View File

@ -0,0 +1,59 @@
import { type PackageInfo } from "@workshop/shared/packages"
import { DateTime } from "luxon"
import { test, expect } from "bun:test"
import { CronJob, type CronSchedule } from "./cron"
const createCron = (cronString: string, lastRunAt?: DateTime) => {
const cronJob = new CronJob(cronString, "", {json: {name: "test", version: "1.0.0"}, path: "."}, lastRunAt)
return cronJob
}
test("valid CronJobs", () => {
expect(createCron("hourly").schedule).toEqual({ unit: "hourly", hour: 0, minute: 0 })
expect(createCron("hourly@").schedule).toEqual({ unit: "hourly", hour: 0, minute: 0 })
expect(createCron("hourly@0:30").schedule).toEqual({ unit: "hourly", hour: 0, minute: 30 })
expect(createCron("hourly@10:59").schedule).toEqual({ unit: "hourly", hour: 10, minute: 59 })
})
test("invalid CronJobs", () => {
expect(() => createCron("")).toThrow()
expect(() => createCron("hourl")).toThrow()
expect(() => createCron("hourly@*")).toThrow()
expect(() => createCron("hourly@45:59")).toThrow()
expect(() => createCron("hourly@0:99")).toThrow()
})
test("isDue", () => {
const lastRunAt = DateTime.fromRFC2822("1 Jan 2025 00:00:00 GMT")
let cronJob = createCron("constantly", lastRunAt)
expect(cronJob.isDue(lastRunAt.plus({ seconds: 5 }))).toBe(false)
expect(cronJob.isDue(lastRunAt.plus({ seconds: 60 }))).toBe(true)
cronJob = createCron("hourly@0:15", lastRunAt)
expect(cronJob.isDue(lastRunAt.plus({ minutes: 14 }))).toBe(false)
cronJob = createCron("hourly@0:30", lastRunAt.set({ minute: 45 }).minus({ hour: 1 }))
expect(cronJob.isDue(lastRunAt.set({ minute: 15 }))).toBe(false)
expect(cronJob.isDue(lastRunAt.set({ minute: 50 }))).toBe(true)
const dailyLastRunAt = lastRunAt.set({ hour: 1, minute: 30 }).minus({ days: 1 })
cronJob = createCron("daily@1:20", dailyLastRunAt)
expect(cronJob.isDue(dailyLastRunAt.plus({ hours: 20 }))).toBe(false)
expect(cronJob.isDue(dailyLastRunAt.plus({ hours: 25 }))).toBe(true)
const weeklyLastRunAt = lastRunAt.set({ hour: 5, minute: 59 }).minus({ week: 1 })
cronJob = createCron("weekly@5:59", weeklyLastRunAt)
expect(cronJob.isDue(weeklyLastRunAt.plus({ days: 1 }))).toBe(false)
expect(cronJob.isDue(weeklyLastRunAt.plus({ days: 7 }))).toBe(true)
const monthLastRunAt = lastRunAt.set({ hour: 1, minute: 0 }).minus({ days: 20 })
cronJob = createCron("monthly@1:00", monthLastRunAt)
expect(cronJob.isDue(monthLastRunAt.plus({ days: 5 }))).toBe(false)
expect(cronJob.isDue(monthLastRunAt.plus({ days: 20 }))).toBe(true)
const shortMonthLastRunAt = DateTime.fromRFC2822("1 Feb 2025 00:00:00 GMT").set({ hour: 1, minute: 0 })
cronJob = createCron("monthly@1:00", shortMonthLastRunAt)
expect(cronJob.isDue(shortMonthLastRunAt.plus({ days: 5 }))).toBe(false)
expect(cronJob.isDue(shortMonthLastRunAt.plus({ days: 29 }))).toBe(true)
})

211
packages/http/src/cron.ts Normal file
View File

@ -0,0 +1,211 @@
import { getErrorMessage } from "@workshop/shared/errors"
import kv from "@workshop/shared/kv"
import { getPackageInfo, type PackageInfo } from "@workshop/shared/packages"
import { DateTime } from "luxon"
import { z } from "zod"
export const startCron = async () => {
const crons = await getCrons()
await runCrons(crons)
}
const runCrons = async (crons: CronJob[]) => {
try {
const promises = crons.map((cron) => {
return cron.runIfDue()
})
await Promise.allSettled(promises)
} catch (error) {
console.error("💥 Error running cron jobs:", error)
} finally {
setTimeout(() => {
runCrons(crons)
}, 500)
}
}
const getCrons = async () => {
const packageInfo = await getPackageInfo()
const cronScripts: CronJob[] = []
for (const info of packageInfo) {
if (!info.json.scripts) continue
for (const [scriptName, scriptCommand] of Object.entries(info.json.scripts)) {
if (!scriptName.startsWith("cron:")) continue
try {
const cronJob = new CronJob(scriptName, scriptCommand, info)
// Create an entry for everything so we see it in the UI
await kv.update("cronJobs", {}, (jobs) => {
if (jobs[cronJob.id]) return jobs
jobs[cronJob.id] = []
return jobs
})
await cronJob.recoverLastRun()
cronScripts.push(cronJob)
} catch (error) {
console.error(`Invalid cron job "${scriptName}" in package "${info.json.name}":`, error)
continue
}
}
}
return cronScripts
}
const DEBUG = false
export class CronJob {
schedule: CronSchedule
id: string
private lastRunAt: DateTime = DateTime.now().minus({ seconds: 60 })
private scriptName: string
private script: string
private info: PackageInfo
constructor(scriptName: string, script: string, info: PackageInfo, lastRunAt?: DateTime) {
this.scriptName = scriptName
this.script = script
this.info = info
this.schedule = parseScriptName(this.scriptName)
this.id = `${this.info.json.name} ${this.scriptName} ${this.script}`
this.lastRunAt = lastRunAt || DateTime.now().minus({ seconds: 60 })
}
static isValid(cron: string): boolean {
try {
parseScriptName(cron)
return true
} catch (error) {
return false
}
}
debug(...args: unknown[]) {
if (!DEBUG) return
console.debug("🪲", ...args)
}
async recoverLastRun() {
const cronJobs = await kv.get("cronJobs", {})
const lastJob = cronJobs[this.id]?.[0]
const defaultValue = DateTime.now().minus({ seconds: 60 })
if (!lastJob) return defaultValue
const lastRunAt = DateTime.fromISO(lastJob.runAt)
this.debug(`Recovering last run for "${this.scriptName}"`, lastRunAt.toISO())
this.lastRunAt = lastRunAt.isValid ? lastRunAt : defaultValue
}
toString() {
const format = "LLL d, yyyy HH:mm:ss ZZZZ"
return `CronJob({"${this.scriptName}": "${this.script}"} lastRun:"${this.lastRunAt.toFormat(format)}")`
}
isDue(now: DateTime): boolean {
if (this.schedule.unit === "constantly") {
const diff = now.diff(this.lastRunAt, "minutes").minutes
return diff >= 1
}
let previousScheduledAt = now.setZone("UTC").set({
hour: this.schedule.hour,
minute: this.schedule.minute,
second: 0,
millisecond: 0,
})
if (this.schedule.unit === "weekly") {
previousScheduledAt = previousScheduledAt.startOf("week")
} else if (this.schedule.unit === "monthly") {
previousScheduledAt = previousScheduledAt.startOf("month")
}
// If the scheduled time is in the future, go back one period
if (previousScheduledAt > now) {
const unitMap = {
hourly: { hour: 1 },
daily: { day: 1 },
weekly: { week: 1 },
monthly: { month: 1 },
}
previousScheduledAt = previousScheduledAt.minus(unitMap[this.schedule.unit])
}
const shouldRun = this.lastRunAt < previousScheduledAt // If the last run is before the previous scheduled time, it's due
this.debug(
`${shouldRun ? "✅" : "❌"} ${
this.scriptName
}: Last run at "${this.lastRunAt.toISO()}" previous scheduled at "${previousScheduledAt.toISO()}"`
)
return shouldRun
}
async runIfDue() {
const now = DateTime.now()
if (!this.isDue(now)) return
this.run(now)
}
async run(now: DateTime) {
this.lastRunAt = now
let runError: unknown
let stdout: string | undefined
let stderr: string | undefined
try {
// --silent to suppress output of the command to stderr
const proc = Bun.spawn(["bun", "run", "--silent", this.scriptName], {
stderr: "pipe",
stdout: "pipe",
cwd: this.info.path,
})
await proc.exited
stdout = typeof proc.stdout === "number" ? proc.stdout : await Bun.readableStreamToText(proc.stdout)
stderr = typeof proc.stderr === "number" ? proc.stderr : await Bun.readableStreamToText(proc.stderr)
const status = proc.exitCode
if (status !== 0) {
throw new Error(`Failed with exit code ${status}`)
}
} catch (error) {
console.error(`💥 Error running cron job "${this.scriptName}" in "${this.info.json.name}":`, error)
runError = error
} finally {
await kv.update("cronJobs", {}, (jobs) => {
let history = jobs[this.id] || []
history = history.slice(0, 9) // Keep only the last 10 runs
history.unshift({
runAt: now.toISO()!,
error: runError ? getErrorMessage(runError) : undefined,
stdout,
stderr,
})
jobs[this.id] = history
return jobs
})
}
}
}
const parseScriptName = (scriptName: string): CronSchedule => {
const cron = scriptName.replace("cron:", "").replace(/ #.*$/, "")
const parts = cron.split("@")
const [unit, time] = parts
const [hour, minute] = time?.split(":") || []
return cronScheduleSchema.parse({ unit, hour, minute })
}
export type CronSchedule = z.infer<typeof cronScheduleSchema>
const cronScheduleSchema = z.object({
unit: z.enum(["constantly", "hourly", "daily", "weekly", "monthly"]),
hour: z.coerce.number().int().min(0).max(23).default(0),
minute: z.coerce.number().int().min(0).max(59).default(0),
})

10
packages/http/src/main.ts Normal file
View File

@ -0,0 +1,10 @@
import { startCron } from "./cron"
import { startServer } from "./server"
try {
await startServer()
await startCron()
} catch (error) {
console.error("💥 Error starting http:", error)
process.exit(1)
}

View File

@ -40,6 +40,7 @@ export const subdomainInfo = async () => {
subdomainInfo.push({
path: info.path,
name: basename(info.path),
hasDevScript: Boolean(info.json.scripts?.["subdomain:dev"]),
})
}

View File

@ -1,24 +1,66 @@
import { subdomainPackageInfo } from "../orchestrator"
import { subdomainInfo } from "../orchestrator"
import type { LoaderProps } from "@workshop/nano-remix"
import kv, { type CronJobHistory } from "@workshop/shared/kv"
import { zone } from "@workshop/shared/utils"
import { useMemo } from "hono/jsx"
import { DateTime } from "luxon"
export const loader = async (_req: Request) => {
const packagePaths = await subdomainPackageInfo()
return { packagePaths }
const info = await subdomainInfo()
const cronJobs = await kv.get("cronJobs", {})
return { subdomains: info.map((i) => i.name), cronJobs }
}
export default function Index({ packagePaths }: LoaderProps<typeof loader>) {
export default function Index({ subdomains, cronJobs }: LoaderProps<typeof loader>) {
const url = new URL(import.meta.url)
const sortedEntries = useMemo(() => {
return Object.entries(cronJobs).sort((a, b) => {
const aHistory = a[1] ?? []
const bHistory = b[1] ?? []
const latestA = aHistory[0]
const latestB = bHistory[0]
if (!latestA && !latestB) return 0
if (!latestA) return 1
if (!latestB) return -1
return DateTime.fromISO(latestB.runAt).diff(DateTime.fromISO(latestA.runAt)).as("milliseconds")
})
}, [cronJobs])
return (
<div>
<h1>Subdomain Servers</h1>
<h2>Subdomain Servers</h2>
<ul>
{packagePaths.map((pkg) => (
<li key={pkg.packageName}>
<a href={`${url.protocol}//${pkg.packageName}.${url.host}`}>{pkg.packageName}</a>
{subdomains.map((subdomain) => (
<li key={subdomain}>
<a href={`${url.protocol}//${subdomain}.${url.host}`}>{subdomain}</a>
</li>
))}
</ul>
<h2>CronJobs</h2>
<ul>
{sortedEntries.map(([id, history]) => (
<li key={id}>
<h4>{id}</h4>
<JobHistory history={history} />
</li>
))}
</ul>
</div>
)
}
const JobHistory = ({ history }: { history: CronJobHistory[] }) => {
return (
<ul>
{history.slice(0, 2).map((run, index) => (
<li key={index}>
<strong>{DateTime.fromISO(run.runAt).setZone(zone).toRFC2822()}</strong>
{run.error && <div style={{ color: "red" }}>Error: {run.error}</div>}
{run.stdout && <pre>Output: {run.stdout}</pre>}
{run.stderr && <pre style={{ color: "red" }}>Error Output: {run.stderr}</pre>}
</li>
))}
</ul>
)
}

View File

@ -1,5 +1,6 @@
import { startSubdomainServers } from "./orchestrator"
import { nanoRemix } from "@workshop/nano-remix"
import { requireAuth } from "./auth"
import { join } from "node:path"
/**
@ -9,27 +10,16 @@ import { join } from "node:path"
* 1. Starts multiple subdomain servers on different ports (3001+)
* 2. Main server (port 3000) acts as a proxy that routes requests based on subdomain
* 3. All requests are protected with Basic HTTP auth + persistent cookies
* 4. Once authenticated, cookies work across all subdomains (*.thedomain.com)
* 4. Once authenticated, cookies work across all subdomains (*.theworkshop.cc)
* 5. In development, uses subdomain:dev scripts if available for hot reloading
*
* This server is designed to be run in production with NODE_ENV=production. On development,
* it allows unauthenticated access because YOLO.
*/
const serve = async () => {
try {
const portMap = await startSubdomainServers()
const server = startServer(portMap)
logServerInfo(server, portMap)
} catch (error) {
console.error("❌ Error starting http package:", error)
process.exit(1)
}
}
export const startServer = async () => {
const portMap = await startSubdomainServers()
serve()
const startServer = (portMap: Record<string, number>) => {
const server = Bun.serve({
port: 3000,
routes: {
@ -69,12 +59,13 @@ const startServer = (portMap: Record<string, number>) => {
},
})
return server
logServerInfo(server, portMap)
}
const logServerInfo = (server: Bun.Server, portMap: Record<string, number>) => {
console.log(`${server.url}`)
const subdomainEntries = Object.entries(portMap)
subdomainEntries.forEach(([subdomain, port], index) => {
const subdomainUrl = new URL(server.url)
subdomainUrl.hostname = `${subdomain}.${subdomainUrl.hostname}`
@ -85,48 +76,3 @@ const logServerInfo = (server: Bun.Server, portMap: Record<string, number>) => {
})
console.log("")
}
const requireAuth = (handler: (req: Request) => Response | Promise<Response>) => {
return async (req: Request) => {
if (process.env.NODE_ENV !== "production") {
return handler(req)
}
const cookies = new Bun.CookieMap(req.headers.get("cookie") ?? "")
if (cookies.get("auth") === "authenticated") {
return handler(req)
}
if (validAuth(req)) {
const response = await handler(req)
const newHeaders = new Headers(response.headers)
const url = new URL(req.url)
const cookieDomain = `.${url.hostname.split(".").slice(-2).join(".")}`
const authCookie = new Bun.Cookie({
name: "auth",
value: "authenticated",
domain: cookieDomain,
path: "/",
maxAge: 86400,
httpOnly: true,
})
newHeaders.set("Set-Cookie", authCookie.toString())
return new Response(response.body, { status: response.status, headers: newHeaders })
}
return new Response("Unauthorized", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="Workshop Access"' },
})
}
}
const validAuth = (req: Request): boolean => {
const logins = ["spike:888", "defunkt:888", "probablycorey:888"]
const auth = req.headers.get("authorization")
return logins.some((login) => `Basic ${btoa(login)}` === auth)
}

View File

@ -1,5 +1,6 @@
import { mkdir } from "node:fs/promises"
import { dirname, join } from "node:path"
import { DateTime } from "luxon"
const set = async <T extends keyof Keys>(key: T, value: Keys[T]) => {
try {
@ -87,7 +88,14 @@ export default { set, get, remove, update }
export type Keys = {
threads: Record<string, TrackedThread> // threadId: thread metadata
todos: Record<string, string> // todoId: todoText
evaluations: EvalData[] // evaluation data for dashboard import
cronJobs: Record<string, CronJobHistory[]> // jobId: array of job runs
}
export type CronJobHistory = {
error?: string
stderr?: string
stdout?: string
runAt: string
}
export type TrackedThread = {
@ -98,18 +106,7 @@ export type TrackedThread = {
const keyVersions: Record<keyof Keys, number> = {
threads: 1,
todos: 1,
evaluations: 1,
cronJobs: 1,
} as const
export type Conversation = { message: string; role: "user" | "assistant" }
export type EvalData = {
channel: string
rating: "good" | "bad"
context: {
content: string
role: "user" | "assistant"
author?: string
}[] // Last 5 messages leading up to the rated response
ratedBy: string
}

View File

@ -1,9 +1,9 @@
import { readdir } from "node:fs/promises"
import { join } from "node:path"
type PackageInfo = {
export type PackageInfo = {
path: string
json?: {
json: {
name: string
version: string
scripts?: Record<string, string>
@ -33,7 +33,6 @@ export const getPackageInfo = async (): Promise<PackageInfo[]> => {
packageInfo.push({ path, json })
} catch (error) {
console.error(`Error processing package ${packageName}:`, error)
packageInfo.push({ path })
}
}

View File

@ -16,11 +16,10 @@
"@openai/agents": "^0.0.10",
"@workshop/shared": "workspace:*",
"discord.js": "^14.19.3",
"luxon": "^3.6.1",
"luxon": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@types/luxon": "^3.6.2",
"@types/bun": "latest"
},
"peerDependencies": {

View File

@ -1,5 +1,4 @@
import { respondToUserMessage } from "../ai"
import { storeEvaluation } from "../eval"
import { ActivityType, type Client } from "discord.js"
import { runCommand } from "./commands"
@ -32,17 +31,6 @@ export const listenForEvents = (client: Client) => {
}
})
client.on("messageReactionAdd", async (reaction, user) => {
if (user.bot) return
const emoji = reaction.emoji.name
// Only process evaluation emojis
if (emoji === "✅" || emoji === "❌") {
await storeEvaluation(reaction.message, emoji, user.username || user.id)
}
})
client.on("ready", () => {
// set the bots description
const branch = process.env.RENDER_GIT_BRANCH || "unknown"

View File

@ -2,11 +2,7 @@ import { Client } from "discord.js"
import { timeBomb } from "@workshop/shared/utils"
import { DateTime } from "luxon"
timeBomb("7-15-2025", "Get rid of all these console messages Corey!")
// const timeout = 1000 * 60 * 30 // 30 minutes
// const timeout = 1000 * 60 // 1 minute
const timeout = 3000 // 1 minute
const timeout = 1000 * 60 * 10 // 10 minutes
export const keepThreadsAlive = async (client: Client) => {
try {
if (!client.isReady() || client.guilds.cache.size === 0) return
@ -14,19 +10,14 @@ export const keepThreadsAlive = async (client: Client) => {
for (const guild of client.guilds.cache.values()) {
const fetchedThreads = await guild.channels.fetchActiveThreads()
for (const [threadId, thread] of fetchedThreads.threads) {
for (const [_threadId, thread] of fetchedThreads.threads) {
if (!thread.name.includes("💾")) continue
const archiveAt = DateTime.fromMillis(thread.archiveTimestamp || 0)
const result = await thread.setArchived(
false,
"Revived by Spike - keeping important discussion alive"
)
// send message to the thread if it was archived
if (thread.archived) {
const message = `🔍 Revived: ${thread.name} -- ${archiveAt.toFormat("F")}`
await thread.send(message)
}
console.log(`🔍 Revive: ${thread.name}: ${archiveAt.toFormat("F")} -- ${result.archived}`)
}
}
} catch (error) {

View File

@ -1,82 +0,0 @@
import kv from "@workshop/shared/kv"
import type { EvalData } from "@workshop/shared/kv"
import type { Message, PartialMessage } from "discord.js"
export const storeEvaluation = async (message: Message | PartialMessage, emoji: string, ratedBy: string) => {
try {
const rating = emoji === "✅" ? "good" : emoji === "❌" ? "bad" : null
if (!rating) {
throw new Error(`Invalid evaluation emoji: ${emoji}`)
}
// If message is partial, fetch the full message
let fullMessage: Message
if (message.partial) {
fullMessage = await message.fetch()
} else {
fullMessage = message
}
// Fetch conversation context using Discord API
const context = await fetchConversationContext(fullMessage)
let channelName = `${fullMessage.guild?.name} - ???`
if ("name" in fullMessage.channel) {
channelName = `${fullMessage.guild?.name} - ${fullMessage.channel.name}`
}
const evalData: EvalData = {
channel: channelName,
rating,
context,
ratedBy,
}
// Add to evaluations
await kv.update("evaluations", [], (prev) => {
return [...prev, evalData]
})
console.log(`📊 Evaluation stored: ${rating} rating for message ${fullMessage.id} by ${ratedBy}`)
// React with thumbs up to acknowledge
await fullMessage.react("👍")
return evalData
} catch (error) {
console.error("Error storing evaluation:", error)
throw error
}
}
// Fetch conversation context around a message using Discord API
const fetchConversationContext = async (message: Message, contextSize = 5): Promise<EvalData["context"]> => {
try {
// Fetch messages before the target message (the conversation leading up to this response)
const messagesBefore = await message.channel.messages.fetch({
before: message.id,
limit: contextSize,
})
// Include the target message and sort by timestamp
const allMessages = [...messagesBefore.values(), message].sort(
(a, b) => a.createdTimestamp - b.createdTimestamp
)
return allMessages.map((msg) => ({
content: msg.content,
role: msg.author.bot ? "assistant" : "user",
author: msg.author.username,
}))
} catch (error) {
console.error("Error fetching conversation context:", error)
// Fallback to just the target message
return [
{
content: message.content,
role: message.author.bot ? "assistant" : "user",
author: message.author.username,
},
]
}
}

View File

@ -21,7 +21,7 @@
"@lezer/highlight": "^1.2.1",
"@lezer/lr": "^1.4.2",
"hono": "catalog:",
"luxon": "^3.6.1",
"luxon": "catalog:",
"zzfx": "^1.3.0"
},
"prettier": {

View File

@ -16,7 +16,7 @@
"@workshop/nano-remix": "workspace:*",
"@workshop/shared": "workspace:*",
"hono": "catalog:",
"luxon": "^3.7.1",
"luxon": "catalog:",
"pixabay-api": "^1.0.4",
"pngjs": "^7.0.0",
"tailwind": "^4.0.0",