revive threads

This commit is contained in:
Corey Johnson 2025-07-15 14:14:07 -07:00
parent 8e3ef39116
commit a495a5c50c
14 changed files with 171 additions and 46 deletions

View File

@ -1,3 +1,6 @@
OPENAI_API_KEY=
DISCORD_TOKEN=
DISCORD_CLIENT_ID=
DISCORD_CLIENT_ID=
CHANNEL_ID=
RENDER_GIT_COMMIT=
DATA_DIR=

View File

@ -0,0 +1,8 @@
---
applyTo: "**/*.{ts,tsx}"
---
Prefer `type` to `interface` for defining types.
Prefer `=>` for arrow functions.
Don't include return type annotations unless necessary.
Exports should appear at the start of the file.

View File

@ -1 +0,0 @@
console.log("Hello via Bun!");

View File

@ -5,7 +5,8 @@
"./kv": "./src/kv.ts",
"./utils": "./src/utils.ts",
"./log": "./src/log.ts",
"./reminders": "./src/reminders.ts"
"./reminders": "./src/reminders.ts",
"./errors": "./src/errors.ts"
},
"devDependencies": {
"@types/bun": "latest"

View File

@ -0,0 +1,20 @@
export const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
return error.message
}
return String(error)
}
export const getErrorStack = (error: unknown): string | undefined => {
if (error instanceof Error) {
return error.stack
}
return undefined
}
export const getErrorName = (error: unknown): string => {
if (error instanceof Error) {
return error.name
}
return "UnknownError"
}

View File

@ -1,22 +1,6 @@
import { mkdir } from "node:fs/promises"
import { dirname, join } from "node:path"
import type { Reminder } from "./reminders"
import { timeBomb } from "./utils"
export interface Keys {
threads: Record<string, string> // threadId: previousResponseId
reminders: Reminder[]
todos: Record<string, string> // todoId: todoText
evaluations: EvalData[] // evaluation data for dashboard import
}
// Individual versions for each key type
const keyVersions = {
threads: 1,
reminders: 1,
todos: 1,
evaluations: 1,
} as const
const set = async <T extends keyof Keys>(key: T, value: Keys[T]) => {
try {
@ -34,8 +18,8 @@ const update = async <T extends keyof Keys>(
updateFn: (prev: Keys[T]) => Keys[T] | Promise<Keys[T]>
) => {
try {
const currentValue = await get(key)
const newValue = await updateFn(currentValue ?? defaultValue)
const currentValue = await get(key, defaultValue)
const newValue = await updateFn(currentValue)
return await set(key, newValue)
} catch (error) {
console.error(`Error updating key "${key}":`, error)
@ -101,6 +85,25 @@ const getStorePath = <T extends keyof Keys>(key: T): string => {
export default { set, get, remove, update }
export type Keys = {
threads: Record<string, TrackedThread> // threadId: thread metadata
reminders: Reminder[]
todos: Record<string, string> // todoId: todoText
evaluations: EvalData[] // evaluation data for dashboard import
}
export type TrackedThread = {
threadId: string
threadName: string
}
const keyVersions: Record<keyof Keys, number> = {
threads: 1,
reminders: 1,
todos: 1,
evaluations: 1,
} as const
export type Conversation = { message: string; role: "user" | "assistant" }
export type EvalData = {

View File

@ -1,15 +1,12 @@
# spike
# Spike
To install dependencies:
# Installation
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.2.15. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
1. Clone this repository, d'uh.
1. You need to have [bun](https://bun.sh) installed.
1. Install deps with `bun install`.
1. Setup a dummy Discord bot and get your DISCORD*TOKEN and CLIENT_ID at https://discord.com/developers/applications *(Corey doesn't remember how to do this all this, so if pair with him if you run into problems. Then document it in here).
1. `cp .env.example .env` and fill in the values for everything.
1. Find a Discord channel id to use for testing and set it in `.env` as `CHANNEL_ID`.
1. There is a very simple auth server you can use to invite the bot to your server. Run it with `bun authServer`.
1. Run the bot with `bun bot:discord`

View File

@ -5,7 +5,8 @@
"private": true,
"scripts": {
"bot:cli": "bun run --watch src/cli",
"bot:discord": "bun run --watch src/discord"
"bot:discord": "bun run --watch src/discord",
"authServer": "bun run --watch src/discord/auth.ts"
},
"prettier": {
"printWidth": 110,
@ -13,6 +14,8 @@
},
"dependencies": {
"@openai/agents": "^0.0.10",
"@workshop/kv": "workspace:*",
"@workshop/shared": "workspace:*",
"discord.js": "^14.19.3",
"luxon": "^3.6.1",
"zod": "3.25.67"

View File

@ -1,5 +1,6 @@
import { serve } from "bun"
const premissions = "300648825856"
export const startAuthServer = async (port = "3000") => {
const server = serve({
port,
@ -10,7 +11,7 @@ export const startAuthServer = async (port = "3000") => {
`<html>
<body>
<h1>Authenticate spike</h1>
<a href="https://discord.com/oauth2/authorize?client_id=${process.env.DISCORD_CLIENT_ID}&scope=bot&permissions=0">Authorize</a>
<a href="https://discord.com/oauth2/authorize?client_id=${process.env.DISCORD_CLIENT_ID}&scope=bot&permissions=${premissions}">Authorize</a>
</body>
</html>`,
{

View File

@ -2,6 +2,7 @@ import { Client, GatewayIntentBits, Partials } from "discord.js"
import { listenForEvents } from "./events"
import { runCronJobs } from "./cron"
import { alertAboutCrashLog, logCrash } from "./crash"
import { keepThreadsAlive } from "./threadKeepAlive"
const client = new Client({
intents: [
@ -18,6 +19,7 @@ const client = new Client({
await client.login(process.env.DISCORD_TOKEN)
listenForEvents(client)
keepThreadsAlive(client)
process.on("unhandledRejection", async (error) => {
console.error("💥 Unhandled promise rejection:", error)

View File

@ -0,0 +1,82 @@
import { Client, type PrivateThreadChannel, type PublicThreadChannel } from "discord.js"
import KV, { type TrackedThread } from "@workshop/shared/kv"
import { getErrorMessage } from "@workshop/shared/errors"
import { timeBomb } from "@workshop/shared/utils"
timeBomb("7-15-2025", "Get rid of all these console messages Corey!")
const timeout = 1000 * 60 * 30 // 30 minutes
export const keepThreadsAlive = async (client: Client) => {
try {
if (!client.isReady() || client.guilds.cache.size === 0) {
console.log("📭 Bot not ready or no guilds available")
return
}
const trackedThreads = await KV.get("threads", {})
const activeThreadIds = new Set()
// Scan all active threads and update our tracking
for (const guild of client.guilds.cache.values()) {
const fetchedThreads = await guild.channels.fetchActiveThreads()
for (const [threadId, thread] of fetchedThreads.threads) {
const threadData: TrackedThread = { threadId, threadName: thread.name }
trackedThreads[threadId] = threadData
activeThreadIds.add(threadId)
}
if (fetchedThreads.threads.size === 0) {
console.log(`📭 No active threads in ${guild.name}`)
} else {
console.log(`🧵 Found ${fetchedThreads.threads.size} active threads in ${guild.name}`)
}
}
// Find threads to revive (any thread we're tracking that's not currently active)
for (const [threadId, threadData] of Object.entries(trackedThreads)) {
if (!activeThreadIds.has(threadId)) {
await reviveThread(client, threadData)
}
}
await KV.set("threads", trackedThreads)
} catch (error) {
console.error("Error scanning and reviving threads:", error)
} finally {
setTimeout(() => keepThreadsAlive(client), timeout)
}
}
const reviveThread = async (client: Client, threadData: TrackedThread) => {
try {
const thread = await client.channels.fetch(threadData.threadId)
if (!thread?.isThread()) {
console.error(`❌ Thread "${threadData.threadName}" no longer exists, removing from tracking`)
await KV.update("threads", {}, (threads) => {
delete threads[threadData.threadId]
return threads
})
return
}
if (thread.archived || aboutToBeArchived(thread)) {
await thread.setArchived(false, "Revived by Spike - keeping important discussion alive")
console.log(`✅ Revived thread: "${threadData.threadName}"`)
} else {
console.log(`🟢 Thread "${threadData.threadName}" is already active, no action needed`)
}
} catch (error) {
console.error(`🚫 Failed to revive "${threadData.threadName}".`, getErrorMessage(error))
}
}
const aboutToBeArchived = (thread: PublicThreadChannel<boolean> | PrivateThreadChannel) => {
const archiveTimestamp = thread.archiveTimestamp
if (!archiveTimestamp) return true // I'm not sure when this happens
const timeUntilArchive = archiveTimestamp - Date.now()
return timeUntilArchive <= timeout
}

View File

@ -6,6 +6,7 @@ import { Input } from "../lib/input"
import { Select } from "../lib/select"
import { Grid } from "../lib/grid"
import { Divider } from "../lib/divider"
import type { AvatarProps } from "../lib/avatar"
export const Cards = () => {
return (
@ -203,12 +204,22 @@ TeamCard = Card(
<HStack gap="2" v="center">
<Placeholder.Avatar size="20" rounded seed="Isaac Flath" />
<div>
<Title>Isaac Flath</Title>
<Subtitle>Library Creator</Subtitle>
<H1>Isaac Flath</H1>
<h2>Isaac Flath</h2>
<h4>Library Creator</h4>
</div>
</HStack>
<HStack h="between" gap="3" v="center">
<Icon name="MapPin" size={6} />
<Text small>Alexandria, VA</Text>
<Text size="small">Alexandria, VA</Text>
<Text.Small>Alexandria, VA</Text.Small>
<p class="grow">Alexandria, VA</p>
<IconLink name="Mail" size={6} />
<IconLink name="Linkedin" size={6} />
@ -269,7 +280,6 @@ section_content =(('bell','Everything',"Email digest, mentions & all activity.")
</VStack>
)
}
const DateCard = () => {
const date: string = new Date().toISOString().slice(0, 16)
return (

View File

@ -19,9 +19,13 @@
.my-*: margin-top: *; margin-bottom: *;
}
body {
@apply bg-background text-foreground;
}
@theme {
--color-background: #ffffff;
--color-foreground: #09090b;
--color-foreground: red;
--color-primary: #18181b;
--color-primary-foreground: #fafafa;

View File

@ -1,8 +0,0 @@
// This file has been replaced with AI-based shape detection
// See ai.ts for the current implementation using Gemini/OpenAI
// which is more suitable for hand-drawn shape detection on whiteboards
export const detectShapesFromBuffer = (arrayBuffer: ArrayBuffer) => {
console.warn("detectShapesFromBuffer is deprecated. Use AI-based detection from ai.ts instead.")
return []
}