convert to cf worker
This commit is contained in:
parent
e792b3dcf8
commit
1175fd90b0
@ -1,7 +1,10 @@
|
||||
BASE_URL=
|
||||
DEPLOY_SECRET=
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_PUBLIC_KEY=
|
||||
DISCORD_BOT_TOKEN=
|
||||
DISCORD_DEV_GUILDS=
|
||||
ANSWER_OVERFLOW_API_KEY=
|
||||
ANSWER_OVERFLOW_API_BASE_URL=
|
||||
WORKER_EVENT_URL=
|
||||
WORKER_EVENT_SECRET=
|
||||
HELPER_THREAD_WELCOME_PARENT_ID=
|
||||
HELPER_THREAD_WELCOME_TEMPLATE=
|
||||
THREAD_LENGTH_CHECK_INTERVAL_HOURS=
|
||||
|
||||
438
README.md
438
README.md
@ -1,415 +1,73 @@
|
||||
# Hermit
|
||||
# Hermit (Cloudflare Worker)
|
||||
|
||||
Hermit is the OpenClaw Discord bot built on [Carbon](https://carbon.buape.com), Bun, and SQLite.
|
||||
Discord bot built with Carbon on Cloudflare Workers.
|
||||
|
||||
It handles:
|
||||
## Stack
|
||||
|
||||
- Discord slash commands and message-context moderation actions
|
||||
- helper-thread onboarding and thread-length enforcement
|
||||
- keyword-based automod responses
|
||||
- announcement crossposting for selected channels
|
||||
- local event and helper-thread state persistence in SQLite
|
||||
- a read-only local dashboard for helper events and tracked threads
|
||||
- `@buape/carbon`
|
||||
- Cloudflare Workers (`@buape/carbon/adapters/fetch`)
|
||||
- Gateway plugin: `CloudflareGatewayPlugin` + `CloudflareGatewayDurableObject`
|
||||
- Cloudflare D1 + Drizzle ORM
|
||||
|
||||
Repository: [openclaw/hermit](https://github.com/openclaw/hermit)
|
||||
## Setup
|
||||
|
||||
## Runtime Overview
|
||||
|
||||
Hermit runs as a gateway-first Discord bot:
|
||||
|
||||
- Bun is the runtime and package manager
|
||||
- Carbon handles command registration, gateway events, and Discord API access
|
||||
- Drizzle manages the SQLite schema and migrations
|
||||
- SQLite stores helper event history and tracked helper thread state
|
||||
- A small Bun HTTP server exposes read-only operational visibility
|
||||
|
||||
Main entrypoint: [src/index.ts](src/index.ts)
|
||||
|
||||
## Features
|
||||
|
||||
- `/github` looks up GitHub issues and pull requests
|
||||
- `Solved (Mod)` marks a thread as solved in Answer Overflow and closes it
|
||||
- `/say ...` posts common guidance and documentation links
|
||||
- `/helper ...` posts helper-thread moderation messages and closes threads
|
||||
- `/role ...` toggles specific server roles
|
||||
- helper-thread creation triggers a welcome message and thread tracking
|
||||
- a background monitor warns on long threads and auto-closes very long ones
|
||||
- automod rules can repost/redact matching messages and send guidance replies
|
||||
- selected announcement channels are auto-crossposted
|
||||
|
||||
## Requirements
|
||||
|
||||
- Bun
|
||||
- a Discord application and bot token
|
||||
- access to the target Discord server
|
||||
- SQLite filesystem access for `DB_PATH`
|
||||
|
||||
## Installation
|
||||
|
||||
1. Install dependencies:
|
||||
1. Install deps:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. Create a `.env` file.
|
||||
2. Create `.env` from `.env.example`.
|
||||
|
||||
Recommended variables:
|
||||
Required:
|
||||
|
||||
```env
|
||||
DISCORD_CLIENT_ID="your-client-id"
|
||||
DISCORD_BOT_TOKEN="your-bot-token"
|
||||
DISCORD_DEV_GUILDS="guild_id_1,guild_id_2"
|
||||
|
||||
ANSWER_OVERFLOW_API_KEY="your-answer-overflow-api-key"
|
||||
ANSWER_OVERFLOW_API_BASE_URL="https://www.answeroverflow.com"
|
||||
|
||||
HELPER_THREAD_WELCOME_PARENT_ID="123456789012345678"
|
||||
HELPER_THREAD_WELCOME_TEMPLATE="Welcome to helpers. Please include expected vs actual behavior, what you already tried, and relevant logs/code."
|
||||
THREAD_LENGTH_CHECK_INTERVAL_HOURS="2"
|
||||
|
||||
DB_PATH="data/hermit.sqlite"
|
||||
DRIZZLE_MIGRATIONS="drizzle"
|
||||
|
||||
HELPER_LOGS_HOST="127.0.0.1"
|
||||
HELPER_LOGS_PORT="8787"
|
||||
BASE_URL=
|
||||
DEPLOY_SECRET=
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_PUBLIC_KEY=
|
||||
DISCORD_BOT_TOKEN=
|
||||
```
|
||||
|
||||
3. Apply migrations:
|
||||
Optional:
|
||||
|
||||
```bash
|
||||
bun run db:migrate
|
||||
```env
|
||||
DISCORD_DEV_GUILDS=
|
||||
ANSWER_OVERFLOW_API_KEY=
|
||||
HELPER_THREAD_WELCOME_PARENT_ID=
|
||||
HELPER_THREAD_WELCOME_TEMPLATE=
|
||||
THREAD_LENGTH_CHECK_INTERVAL_HOURS=
|
||||
```
|
||||
|
||||
4. Start Hermit:
|
||||
3. Configure `wrangler.jsonc` D1 binding:
|
||||
|
||||
- set `d1_databases[0].database_id` to your real D1 database id
|
||||
- keep `binding = "DB"`
|
||||
|
||||
4. Apply D1 migrations:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
pnpm run db:apply:local
|
||||
# or
|
||||
pnpm run db:apply:remote
|
||||
```
|
||||
|
||||
5. Run locally:
|
||||
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
- `bun run dev` starts Hermit in watch mode
|
||||
- `bun run start` starts Hermit normally
|
||||
- `bun run typecheck` runs TypeScript without emitting files
|
||||
- `bun run db:migrate` applies Drizzle migrations to SQLite
|
||||
- `bun run db:generate` generates Drizzle migration files from the schema
|
||||
- `pnpm run dev` → `wrangler dev --env-file .env`
|
||||
- `pnpm run deploy` → deploy worker
|
||||
- `pnpm run cf-typegen` → regenerate `worker-configuration.d.ts`
|
||||
- `pnpm run typecheck` → TypeScript check
|
||||
- `pnpm run db:generate` → generate Drizzle SQL
|
||||
- `pnpm run db:apply:local` / `db:apply:remote` → apply D1 migrations
|
||||
|
||||
## Environment Variables
|
||||
## Notes
|
||||
|
||||
### Required
|
||||
|
||||
- `DISCORD_CLIENT_ID`: Discord application client ID
|
||||
- `DISCORD_BOT_TOKEN`: Discord bot token
|
||||
|
||||
### Optional
|
||||
|
||||
- `DISCORD_DEV_GUILDS`: comma-separated guild IDs for dev command registration
|
||||
- `ANSWER_OVERFLOW_API_KEY`: required for `Solved (Mod)` to call Answer Overflow
|
||||
- `ANSWER_OVERFLOW_API_BASE_URL`: defaults to `https://www.answeroverflow.com`
|
||||
- `HELPER_THREAD_WELCOME_PARENT_ID`: parent forum or helper channel whose new threads should receive the welcome message
|
||||
- `HELPER_THREAD_WELCOME_TEMPLATE`: overrides the default helper welcome text
|
||||
- `THREAD_LENGTH_CHECK_INTERVAL_HOURS`: enables the helper thread monitor when set to a positive number
|
||||
- `DB_PATH`: SQLite database path, defaults to `data/hermit.sqlite`
|
||||
- `DRIZZLE_MIGRATIONS`: migration directory, defaults to `drizzle`
|
||||
- `HELPER_LOGS_HOST`: host for the read-only helper dashboard, defaults to `127.0.0.1`
|
||||
- `HELPER_LOGS_PORT`: port for the read-only helper dashboard, defaults to `8787`; set to `0` to disable it
|
||||
- `SKIP_DB_MIGRATIONS`: set to `1` to skip automatic migration-on-startup
|
||||
|
||||
## Commands
|
||||
|
||||
### `/github`
|
||||
|
||||
Looks up a GitHub issue or pull request and returns:
|
||||
|
||||
- title and state
|
||||
- repo and author
|
||||
- labels
|
||||
- description summary
|
||||
- recent comments
|
||||
- pull request change stats when applicable
|
||||
|
||||
Options:
|
||||
|
||||
- `number` required
|
||||
- `user` optional, defaults to `openclaw`
|
||||
- `repo` optional, defaults to `hermit`
|
||||
|
||||
Available in:
|
||||
|
||||
- guilds
|
||||
- bot DMs
|
||||
|
||||
Source: [src/commands/github.ts](src/commands/github.ts)
|
||||
|
||||
### `Solved (Mod)`
|
||||
|
||||
Message-context moderation action that:
|
||||
|
||||
- posts the chosen solution message to Answer Overflow
|
||||
- adds a checkmark reaction to the solved message
|
||||
- archives and locks the thread
|
||||
- records a `mark_solution` helper event in SQLite
|
||||
|
||||
Permissions:
|
||||
|
||||
- `ManageMessages`
|
||||
- `ManageThreads`
|
||||
|
||||
Requires:
|
||||
|
||||
- `ANSWER_OVERFLOW_API_KEY`
|
||||
|
||||
Source: [src/commands/solvedMod.ts](src/commands/solvedMod.ts)
|
||||
|
||||
### `/say`
|
||||
|
||||
Posts common canned guidance messages.
|
||||
|
||||
Subcommands:
|
||||
|
||||
- `guide`
|
||||
- `server-faq`
|
||||
- `help`
|
||||
- `user-help`
|
||||
- `model`
|
||||
- `stuck`
|
||||
- `ci`
|
||||
- `answeroverflow`
|
||||
- `pinging`
|
||||
- `docs`
|
||||
- `security`
|
||||
- `install`
|
||||
- `blog-rename`
|
||||
|
||||
Available in:
|
||||
|
||||
- guilds
|
||||
- bot DMs
|
||||
|
||||
Source: [src/commands/say.ts](src/commands/say.ts)
|
||||
|
||||
### `/helper`
|
||||
|
||||
Helper-channel moderation utilities.
|
||||
|
||||
Subcommands:
|
||||
|
||||
- `warn-new-thread`: posts the long-thread warning message
|
||||
- `close`: posts the close message, archives the thread, and locks it
|
||||
- `close-thread`: same behavior as `close`
|
||||
|
||||
These commands also emit helper events into SQLite.
|
||||
|
||||
Source: [src/commands/helper.ts](src/commands/helper.ts)
|
||||
|
||||
### `/role`
|
||||
|
||||
Toggles specific hard-coded server roles.
|
||||
|
||||
Current subcommands:
|
||||
|
||||
- `showcase-ban`
|
||||
- `clawtributor`
|
||||
|
||||
Permissions:
|
||||
|
||||
- command requires `ManageRoles`
|
||||
- runtime access also checks that the invoking member has the hard-coded `communityStaff` role
|
||||
|
||||
Source: [src/commands/role.ts](src/commands/role.ts)
|
||||
|
||||
## Gateway Events And Background Behavior
|
||||
|
||||
### Ready
|
||||
|
||||
On startup, Hermit logs the connected username and starts the helper thread monitor when configured.
|
||||
|
||||
Source: [src/events/ready.ts](src/events/ready.ts)
|
||||
|
||||
### Thread Create Welcome
|
||||
|
||||
When a new thread is created under `HELPER_THREAD_WELCOME_PARENT_ID`, Hermit:
|
||||
|
||||
- stores the thread in `tracked_threads`
|
||||
- records a `thread_welcome_created` event
|
||||
- posts the helper welcome message
|
||||
|
||||
Source: [src/events/threadCreateWelcome.ts](src/events/threadCreateWelcome.ts)
|
||||
|
||||
### Thread Length Monitor
|
||||
|
||||
When `THREAD_LENGTH_CHECK_INTERVAL_HOURS` is set, Hermit polls tracked helper threads with `setInterval`.
|
||||
|
||||
Behavior:
|
||||
|
||||
- loads open tracked threads from SQLite
|
||||
- fetches the Discord thread live
|
||||
- updates message counts and close state
|
||||
- warns at more than `100` messages
|
||||
- warns again at more than `150` messages
|
||||
- posts a close notice and archives/locks at more than `200` messages
|
||||
|
||||
Configured messages live in:
|
||||
|
||||
- [src/config/threadLengthMessages.ts](src/config/threadLengthMessages.ts)
|
||||
|
||||
Source: [src/services/threadLengthMonitor.ts](src/services/threadLengthMonitor.ts)
|
||||
|
||||
### AutoModeration Action Execution
|
||||
|
||||
Hermit listens to automod keyword actions and can:
|
||||
|
||||
- repost the triggering content through a webhook
|
||||
- redact the matched trigger in the repost
|
||||
- send a follow-up warning/guidance message
|
||||
- optionally include a role mention in the guidance message
|
||||
|
||||
Automod rule configuration lives in:
|
||||
|
||||
- [src/config/automod-messages.json](src/config/automod-messages.json)
|
||||
|
||||
Message template placeholders:
|
||||
|
||||
- `{user}`
|
||||
- `{keyword}`
|
||||
- `{content}`
|
||||
|
||||
Source: [src/events/autoModerationActionExecution.ts](src/events/autoModerationActionExecution.ts)
|
||||
|
||||
### Auto Publish Message Create
|
||||
|
||||
Hermit auto-crossposts messages from a fixed set of announcement channel IDs.
|
||||
|
||||
Source: [src/events/autoPublishMessageCreate.ts](src/events/autoPublishMessageCreate.ts)
|
||||
|
||||
## Database
|
||||
|
||||
Hermit uses SQLite via Bun and Drizzle.
|
||||
|
||||
Database bootstrap: [src/db.ts](src/db.ts)
|
||||
|
||||
Schema definition: [src/db/schema.ts](src/db/schema.ts)
|
||||
|
||||
### Tables
|
||||
|
||||
#### `keyValue`
|
||||
|
||||
Generic key/value storage with:
|
||||
|
||||
- `key`
|
||||
- `value`
|
||||
- `createdAt`
|
||||
- `updatedAt`
|
||||
|
||||
#### `helper_events`
|
||||
|
||||
Operational event log for helper-related actions.
|
||||
|
||||
Fields:
|
||||
|
||||
- `id`
|
||||
- `event_type`
|
||||
- `thread_id`
|
||||
- `message_count`
|
||||
- `event_time`
|
||||
- `command`
|
||||
- `invoked_by_id`
|
||||
- `invoked_by_username`
|
||||
- `invoked_by_global_name`
|
||||
- `received_at`
|
||||
- `raw_payload`
|
||||
|
||||
Typical event types:
|
||||
|
||||
- `mark_solution`
|
||||
- `helper_command`
|
||||
- `thread_welcome_created`
|
||||
|
||||
#### `tracked_threads`
|
||||
|
||||
Persistent helper-thread state used by the monitor.
|
||||
|
||||
Fields:
|
||||
|
||||
- `id`
|
||||
- `thread_id`
|
||||
- `created_at`
|
||||
- `last_checked`
|
||||
- `solved`
|
||||
- `warning_level`
|
||||
- `closed`
|
||||
- `last_message_count`
|
||||
- `received_at`
|
||||
- `raw_payload`
|
||||
|
||||
## Migrations
|
||||
|
||||
Drizzle configuration: [drizzle.config.ts](drizzle.config.ts)
|
||||
|
||||
Migration runner: [src/scripts/migrate.ts](src/scripts/migrate.ts)
|
||||
|
||||
Committed SQL migrations live under:
|
||||
|
||||
- [drizzle/](drizzle)
|
||||
|
||||
On startup, Hermit automatically applies migrations unless `SKIP_DB_MIGRATIONS=1`.
|
||||
|
||||
## Read-Only Helper Logs HTTP Server
|
||||
|
||||
Hermit starts a small Bun HTTP server for local visibility into helper activity.
|
||||
|
||||
Default address:
|
||||
|
||||
- `http://127.0.0.1:8787`
|
||||
|
||||
Routes:
|
||||
|
||||
- `GET /`: dashboard UI for helper events
|
||||
- `GET /api/events`: JSON event listing
|
||||
- `GET /api/threads`: JSON tracked-thread listing
|
||||
|
||||
Supported `GET /api/events` query params:
|
||||
|
||||
- `eventType`
|
||||
- `command`
|
||||
- `threadId`
|
||||
- `invokedBy`
|
||||
- `from`
|
||||
- `to`
|
||||
- `limit` up to `500`
|
||||
|
||||
Supported `GET /api/threads` query params:
|
||||
|
||||
- `threadId`
|
||||
- `solved`
|
||||
- `closed`
|
||||
- `limit` up to `500`
|
||||
|
||||
Source: [src/server/helperLogsServer.ts](src/server/helperLogsServer.ts)
|
||||
|
||||
## Configuration Files
|
||||
|
||||
- [src/config/automod-messages.json](src/config/automod-messages.json): automod trigger-to-response mapping
|
||||
- [src/config/threadLengthMessages.ts](src/config/threadLengthMessages.ts): warning and auto-close helper thread messages
|
||||
|
||||
## Development Notes
|
||||
|
||||
- Hermit is Bun-first; `package-lock.json` is intentionally not used
|
||||
- command registration and gateway listeners are wired in [src/index.ts](src/index.ts)
|
||||
- helper events and tracked-thread writes are internal; the HTTP server is read-only
|
||||
- the thread-length scheduler is interval-based, not cron-based
|
||||
|
||||
## Verification
|
||||
|
||||
Useful local checks:
|
||||
|
||||
```bash
|
||||
bun run typecheck
|
||||
bun run db:migrate
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
- Answer Overflow base URL is hardcoded to `https://www.answeroverflow.com`.
|
||||
- Helper thread monitor runs via Worker cron (`wrangler.jsonc` `triggers.crons`).
|
||||
|
||||
@ -1,12 +1,7 @@
|
||||
import { defineConfig } from "drizzle-kit"
|
||||
|
||||
const dbPath = process.env.DB_PATH ?? "data/hermit.sqlite"
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "sqlite",
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dbCredentials: {
|
||||
url: dbPath
|
||||
}
|
||||
out: "./drizzle"
|
||||
})
|
||||
|
||||
18
package.json
18
package.json
@ -5,19 +5,23 @@
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"scripts": {
|
||||
"dev": "bun --watch run src/index.ts",
|
||||
"start": "bun run src/index.ts",
|
||||
"dev": "wrangler dev --port 3000 --env-file .env",
|
||||
"deploy": "wrangler deploy --env-file .env",
|
||||
"cf-typegen": "wrangler types",
|
||||
"postinstall": "wrangler types",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:migrate": "bun run src/scripts/migrate.ts",
|
||||
"db:generate": "drizzle-kit generate"
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:apply:local": "wrangler d1 migrations apply hermit-db --local",
|
||||
"db:apply:remote": "wrangler d1 migrations apply hermit-db --remote"
|
||||
},
|
||||
"dependencies": {
|
||||
"@buape/carbon": "0.16.0",
|
||||
"@buape/carbon": "0.0.0-beta-20260427233936",
|
||||
"drizzle-orm": "^0.45.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "1.3.6",
|
||||
"@types/node": "^25.6.0",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "5.9.3",
|
||||
"wrangler": "^4.85.0"
|
||||
}
|
||||
}
|
||||
|
||||
864
pnpm-lock.yaml
generated
864
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -12,9 +12,7 @@ import {
|
||||
import BaseCommand from "./base.js"
|
||||
import { sendWorkerEvent } from "../utils/workerEvent.js"
|
||||
|
||||
const answerOverflowBaseUrl = (
|
||||
process.env.ANSWER_OVERFLOW_API_BASE_URL ?? "https://www.answeroverflow.com"
|
||||
).replace(/\/+$/, "")
|
||||
const answerOverflowBaseUrl = "https://www.answeroverflow.com"
|
||||
|
||||
type MarkSolutionResponse = {
|
||||
success?: boolean
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { and, desc, eq, gte, lte, sql } from "drizzle-orm"
|
||||
import { db } from "../db.js"
|
||||
import { getDb } from "../db.js"
|
||||
import { helperEvents, trackedThreads } from "../db/schema.js"
|
||||
|
||||
type GenericWorkerEventPayload = {
|
||||
@ -169,7 +169,7 @@ export const normalizeEventPayload = (
|
||||
}
|
||||
|
||||
export const insertEvent = async (normalizedEvent: NormalizedEvent) => {
|
||||
await db.insert(helperEvents).values(normalizedEvent)
|
||||
await getDb().insert(helperEvents).values(normalizedEvent)
|
||||
}
|
||||
|
||||
export const listEvents = async ({
|
||||
@ -181,6 +181,7 @@ export const listEvents = async ({
|
||||
to,
|
||||
limit = 100
|
||||
}: EventFilters = {}) => {
|
||||
const db = getDb()
|
||||
const filters = []
|
||||
|
||||
if (eventType) {
|
||||
@ -229,6 +230,7 @@ export const listEvents = async ({
|
||||
}
|
||||
|
||||
export const upsertTrackedThread = async (payload: ThreadUpsertPayload) => {
|
||||
const db = getDb()
|
||||
const threadId = asStringOrNull(payload.threadId)
|
||||
if (!threadId) {
|
||||
return { error: "threadId is required", status: 400 as const }
|
||||
@ -278,6 +280,7 @@ export const listTrackedThreads = async ({
|
||||
closed,
|
||||
limit = 100
|
||||
}: ThreadFilters = {}) => {
|
||||
const db = getDb()
|
||||
const filters = []
|
||||
|
||||
if (threadId) {
|
||||
|
||||
33
src/db.ts
33
src/db.ts
@ -1,32 +1,5 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
import { existsSync, mkdirSync } from "node:fs"
|
||||
import { dirname, isAbsolute, resolve } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite"
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
|
||||
|
||||
import { drizzle } from "drizzle-orm/d1"
|
||||
import { getRuntimeEnv } from "./runtime/env.js"
|
||||
import * as schema from "./db/schema.js"
|
||||
|
||||
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..")
|
||||
|
||||
const resolveFromProjectRoot = (path: string) =>
|
||||
isAbsolute(path) ? path : resolve(projectRoot, path)
|
||||
|
||||
const DB_PATH = resolveFromProjectRoot(Bun.env.DB_PATH ?? "data/hermit.sqlite")
|
||||
const MIGRATIONS_FOLDER = resolveFromProjectRoot(
|
||||
Bun.env.DRIZZLE_MIGRATIONS ?? "drizzle"
|
||||
)
|
||||
|
||||
mkdirSync(dirname(DB_PATH), { recursive: true })
|
||||
|
||||
const sqlite = new Database(DB_PATH)
|
||||
sqlite.exec("PRAGMA journal_mode = WAL;")
|
||||
sqlite.exec("PRAGMA synchronous = NORMAL;")
|
||||
|
||||
export const db = drizzle(sqlite, { schema })
|
||||
|
||||
if (existsSync(MIGRATIONS_FOLDER) && Bun.env.SKIP_DB_MIGRATIONS !== "1") {
|
||||
migrate(db, { migrationsFolder: MIGRATIONS_FOLDER })
|
||||
}
|
||||
|
||||
export { DB_PATH, sqlite }
|
||||
export const getDb = () => drizzle(getRuntimeEnv().DB, { schema })
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
serializePayload,
|
||||
type MessagePayloadObject
|
||||
} from "@buape/carbon"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import automodMessages from "../config/automod-messages.js"
|
||||
|
||||
type AutomodRuleConfig = {
|
||||
trigger: string
|
||||
@ -32,20 +32,13 @@ type WebhookSendPayload = MessagePayloadObject & {
|
||||
avatar_url?: string
|
||||
}
|
||||
|
||||
const automodMessagesUrl = new URL("../config/automod-messages.json", import.meta.url)
|
||||
const webhookCache = new Map<string, WebhookCacheEntry>()
|
||||
const webhookCacheTtlMs = 15 * 60 * 1000
|
||||
|
||||
const normalizeKeyword = (keyword: string) => keyword.trim().toLowerCase()
|
||||
|
||||
const loadAutomodMessages = async (): Promise<AutomodMessageMap> => {
|
||||
try {
|
||||
const raw = await readFile(automodMessagesUrl, "utf8")
|
||||
return JSON.parse(raw) as AutomodMessageMap
|
||||
} catch (error) {
|
||||
console.error("Failed to load automod messages:", error)
|
||||
return {}
|
||||
}
|
||||
return automodMessages as AutomodMessageMap
|
||||
}
|
||||
|
||||
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
|
||||
@ -1,13 +1,7 @@
|
||||
import {
|
||||
type Client,
|
||||
ReadyListener,
|
||||
type ListenerEventData
|
||||
} from "@buape/carbon"
|
||||
import { startThreadLengthMonitor } from "../services/threadLengthMonitor.js"
|
||||
import { ReadyListener, type ListenerEventData } from "@buape/carbon"
|
||||
|
||||
export default class Ready extends ReadyListener {
|
||||
async handle(data: ListenerEventData[this["type"]], client: Client) {
|
||||
async handle(data: ListenerEventData[this["type"]]) {
|
||||
console.log(`Logged in as ${data.user.username}`)
|
||||
startThreadLengthMonitor(client)
|
||||
}
|
||||
}
|
||||
|
||||
66
src/index.ts
66
src/index.ts
@ -1,20 +1,25 @@
|
||||
import { Client } from "@buape/carbon"
|
||||
import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway"
|
||||
import GithubCommand from "./commands/github.js"
|
||||
import SolvedModCommand from "./commands/solvedMod.js"
|
||||
import SayRootCommand from "./commands/say.js"
|
||||
import RoleCommand from "./commands/role.js"
|
||||
import HelperRootCommand from "./commands/helper.js"
|
||||
import { createHandler } from "@buape/carbon/adapters/fetch"
|
||||
import {
|
||||
CloudflareGatewayDurableObject,
|
||||
CloudflareGatewayPlugin
|
||||
} from "@buape/carbon/cloudflare-gateway"
|
||||
import { GatewayIntents } from "@buape/carbon/gateway"
|
||||
import AdminCommand from "./commands/admin.js"
|
||||
import GithubCommand from "./commands/github.js"
|
||||
import HelperRootCommand from "./commands/helper.js"
|
||||
import RoleCommand from "./commands/role.js"
|
||||
import SayRootCommand from "./commands/say.js"
|
||||
import SolvedModCommand from "./commands/solvedMod.js"
|
||||
import AutoModerationActionExecution from "./events/autoModerationActionExecution.js"
|
||||
import AutoPublishMessageCreate from "./events/autoPublishMessageCreate.js"
|
||||
import Ready from "./events/ready.js"
|
||||
import ThreadCreateWelcome from "./events/threadCreateWelcome.js"
|
||||
import { startHelperLogsServer } from "./server/helperLogsServer.js"
|
||||
import { hydrateRuntimeEnv, type HermitEnv } from "./runtime/env.js"
|
||||
import { registerHelperLogsRoutes } from "./server/helperLogsServer.js"
|
||||
import { runThreadLengthMonitor } from "./services/threadLengthMonitor.js"
|
||||
|
||||
startHelperLogsServer()
|
||||
|
||||
const gateway = new GatewayPlugin({
|
||||
const gateway = new CloudflareGatewayPlugin({
|
||||
intents:
|
||||
GatewayIntents.Guilds |
|
||||
GatewayIntents.GuildMessages |
|
||||
@ -23,18 +28,15 @@ const gateway = new GatewayPlugin({
|
||||
autoInteractions: true
|
||||
})
|
||||
|
||||
const client = new Client(
|
||||
export const client = new Client(
|
||||
{
|
||||
baseUrl: "http://localhost:3000",
|
||||
deploySecret: "unused",
|
||||
baseUrl: process.env.BASE_URL,
|
||||
deploySecret: process.env.DEPLOY_SECRET,
|
||||
clientId: process.env.DISCORD_CLIENT_ID,
|
||||
publicKey: "unused",
|
||||
publicKey: process.env.DISCORD_PUBLIC_KEY,
|
||||
token: process.env.DISCORD_BOT_TOKEN,
|
||||
autoDeploy: true,
|
||||
disableDeployRoute: true,
|
||||
disableInteractionsRoute: true,
|
||||
disableEventsRoute: true,
|
||||
devGuilds: process.env.DISCORD_DEV_GUILDS?.split(","), // Optional: comma-separated list of dev guild IDs
|
||||
devGuilds: process.env.DISCORD_DEV_GUILDS?.split(",")
|
||||
},
|
||||
{
|
||||
commands: [
|
||||
@ -50,11 +52,31 @@ const client = new Client(
|
||||
new AutoPublishMessageCreate(),
|
||||
new ThreadCreateWelcome(),
|
||||
new Ready()
|
||||
],
|
||||
]
|
||||
},
|
||||
[gateway]
|
||||
)
|
||||
|
||||
registerHelperLogsRoutes(client)
|
||||
|
||||
const handler = createHandler(client)
|
||||
|
||||
export { CloudflareGatewayDurableObject }
|
||||
|
||||
export default {
|
||||
fetch(request: Request, env: HermitEnv, ctx: ExecutionContext) {
|
||||
hydrateRuntimeEnv(env)
|
||||
return handler(request, {
|
||||
env,
|
||||
waitUntil: ctx.waitUntil.bind(ctx)
|
||||
})
|
||||
},
|
||||
scheduled(_controller: ScheduledController, env: HermitEnv, ctx: ExecutionContext) {
|
||||
hydrateRuntimeEnv(env)
|
||||
ctx.waitUntil(runThreadLengthMonitor(client))
|
||||
}
|
||||
} satisfies ExportedHandler<Env>
|
||||
|
||||
declare global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
@ -63,15 +85,11 @@ declare global {
|
||||
DISCORD_CLIENT_ID: string;
|
||||
DISCORD_PUBLIC_KEY: string;
|
||||
DISCORD_BOT_TOKEN: string;
|
||||
DISCORD_DEV_GUILDS?: string;
|
||||
ANSWER_OVERFLOW_API_KEY?: string;
|
||||
ANSWER_OVERFLOW_API_BASE_URL?: string;
|
||||
HELPER_THREAD_WELCOME_PARENT_ID?: string;
|
||||
HELPER_THREAD_WELCOME_TEMPLATE?: string;
|
||||
THREAD_LENGTH_CHECK_INTERVAL_HOURS?: string;
|
||||
HELPER_LOGS_HOST?: string;
|
||||
HELPER_LOGS_PORT?: string;
|
||||
DB_PATH?: string;
|
||||
DRIZZLE_MIGRATIONS?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +1 @@
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
|
||||
import { dirname, isAbsolute, resolve } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { db } from "../db.js"
|
||||
|
||||
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..")
|
||||
const configuredMigrationsFolder = Bun.env.DRIZZLE_MIGRATIONS ?? "drizzle"
|
||||
const migrationsFolder = isAbsolute(configuredMigrationsFolder)
|
||||
? configuredMigrationsFolder
|
||||
: resolve(projectRoot, configuredMigrationsFolder)
|
||||
|
||||
migrate(db, { migrationsFolder })
|
||||
|
||||
console.log(`Applied migrations from ${migrationsFolder}.`)
|
||||
console.log("Use `pnpm run db:apply:local` or `pnpm run db:apply:remote` for D1 migrations.")
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
import {
|
||||
listEvents,
|
||||
listTrackedThreads,
|
||||
} from "../data/helperLogs.js"
|
||||
|
||||
let serverStarted = false
|
||||
import type { Client } from "@buape/carbon"
|
||||
import { listEvents, listTrackedThreads } from "../data/helperLogs.js"
|
||||
|
||||
const asStringOrNull = (value: unknown): string | null =>
|
||||
typeof value === "string" && value.trim().length > 0 ? value.trim() : null
|
||||
@ -31,407 +27,77 @@ const renderHtml = () => `<!doctype html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Worker Events</title>
|
||||
<title>Hermit helper logs</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f7fb;
|
||||
--panel: #ffffff;
|
||||
--text: #1b2330;
|
||||
--muted: #5d6b82;
|
||||
--border: #dbe2ee;
|
||||
--accent: #1b6fff;
|
||||
--accent-2: #0f4dcf;
|
||||
--shadow: 0 10px 30px rgba(25, 40, 70, 0.08);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at 10% 10%, #dbe8ff 0%, transparent 40%),
|
||||
radial-gradient(circle at 90% 20%, #e7f0ff 0%, transparent 35%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 16px 40px;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.filters {
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(130px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filters label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
input, select, button {
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 9px 10px;
|
||||
font-size: 0.92rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
button:hover { background: var(--accent-2); }
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.button-row button:last-child {
|
||||
background: #eef3ff;
|
||||
color: #1d3f88;
|
||||
}
|
||||
|
||||
.meta {
|
||||
padding: 0 14px 10px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 1200px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #f8faff;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #f9fbff;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.filters {
|
||||
grid-template-columns: repeat(2, minmax(140px, 1fr));
|
||||
}
|
||||
}
|
||||
body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 2rem; }
|
||||
a { color: #2563eb; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
code { background: #f3f4f6; padding: 0.15rem 0.35rem; border-radius: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<header class="header">
|
||||
<h1>Worker Events</h1>
|
||||
<p class="subtitle">Generic worker events captured from Hermit.</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<div class="filters">
|
||||
<div>
|
||||
<label for="eventType">Event Type</label>
|
||||
<input id="eventType" placeholder="mark_solution" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="command">Command</label>
|
||||
<input id="command" placeholder="Solved (Mod)" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="threadId">Thread ID</label>
|
||||
<input id="threadId" placeholder="123..." />
|
||||
</div>
|
||||
<div>
|
||||
<label for="invokedBy">Invoker ID</label>
|
||||
<input id="invokedBy" placeholder="145..." />
|
||||
</div>
|
||||
<div>
|
||||
<label for="from">From (ISO)</label>
|
||||
<input id="from" placeholder="2026-03-01T00:00:00Z" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="to">To (ISO)</label>
|
||||
<input id="to" placeholder="2026-03-06T23:59:59Z" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="limit">Limit</label>
|
||||
<select id="limit">
|
||||
<option value="50">50</option>
|
||||
<option value="100" selected>100</option>
|
||||
<option value="250">250</option>
|
||||
<option value="500">500</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="apply">Apply filters</button>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="clear" type="button">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta" id="meta">Loading...</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Event Type</th>
|
||||
<th>Event Time</th>
|
||||
<th>Received</th>
|
||||
<th>Command</th>
|
||||
<th>Thread ID</th>
|
||||
<th>Message Count</th>
|
||||
<th>Invoker ID</th>
|
||||
<th>Invoker Username</th>
|
||||
<th>Invoker Global Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const els = {
|
||||
eventType: document.getElementById("eventType"),
|
||||
command: document.getElementById("command"),
|
||||
threadId: document.getElementById("threadId"),
|
||||
invokedBy: document.getElementById("invokedBy"),
|
||||
from: document.getElementById("from"),
|
||||
to: document.getElementById("to"),
|
||||
limit: document.getElementById("limit"),
|
||||
apply: document.getElementById("apply"),
|
||||
clear: document.getElementById("clear"),
|
||||
rows: document.getElementById("rows"),
|
||||
meta: document.getElementById("meta")
|
||||
}
|
||||
|
||||
const escapeHtml = (value) => String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
|
||||
const buildParams = () => {
|
||||
const params = new URLSearchParams()
|
||||
const values = {
|
||||
eventType: els.eventType.value.trim(),
|
||||
command: els.command.value.trim(),
|
||||
threadId: els.threadId.value.trim(),
|
||||
invokedBy: els.invokedBy.value.trim(),
|
||||
from: els.from.value.trim(),
|
||||
to: els.to.value.trim(),
|
||||
limit: els.limit.value.trim()
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value) params.set(key, value)
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
const renderRows = (rows) => {
|
||||
if (!rows.length) {
|
||||
els.rows.innerHTML = '<tr><td colspan="10">No matching events.</td></tr>'
|
||||
return
|
||||
}
|
||||
|
||||
const html = rows.map((row) => {
|
||||
return '<tr>' +
|
||||
'<td class="mono">' + escapeHtml(row.id) + '</td>' +
|
||||
'<td>' + escapeHtml(row.event_type) + '</td>' +
|
||||
'<td class="mono">' + escapeHtml(row.event_time) + '</td>' +
|
||||
'<td class="mono">' + escapeHtml(row.received_at) + '</td>' +
|
||||
'<td>' + escapeHtml(row.command) + '</td>' +
|
||||
'<td class="mono">' + escapeHtml(row.thread_id ?? "") + '</td>' +
|
||||
'<td>' + escapeHtml(row.message_count ?? "") + '</td>' +
|
||||
'<td class="mono">' + escapeHtml(row.invoked_by_id ?? "") + '</td>' +
|
||||
'<td>' + escapeHtml(row.invoked_by_username ?? "") + '</td>' +
|
||||
'<td>' + escapeHtml(row.invoked_by_global_name ?? "") + '</td>' +
|
||||
'</tr>'
|
||||
}).join("")
|
||||
|
||||
els.rows.innerHTML = html
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
els.meta.textContent = "Loading..."
|
||||
const params = buildParams()
|
||||
const response = await fetch('/api/events?' + params.toString())
|
||||
|
||||
if (!response.ok) {
|
||||
els.meta.textContent = 'Failed to load events (' + response.status + ')'
|
||||
els.rows.innerHTML = ""
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
renderRows(data.events || [])
|
||||
els.meta.textContent = 'Showing ' + data.count + ' events'
|
||||
}
|
||||
|
||||
els.apply.addEventListener("click", load)
|
||||
els.clear.addEventListener("click", () => {
|
||||
els.eventType.value = ""
|
||||
els.command.value = ""
|
||||
els.threadId.value = ""
|
||||
els.invokedBy.value = ""
|
||||
els.from.value = ""
|
||||
els.to.value = ""
|
||||
els.limit.value = "100"
|
||||
load()
|
||||
})
|
||||
|
||||
load()
|
||||
</script>
|
||||
<h1>Hermit helper logs</h1>
|
||||
<p>JSON endpoints:</p>
|
||||
<ul>
|
||||
<li><a href="/api/events">/api/events</a></li>
|
||||
<li><a href="/api/threads">/api/threads</a></li>
|
||||
</ul>
|
||||
<p>Filters: <code>eventType</code>, <code>command</code>, <code>threadId</code>, <code>invokedBy</code>, <code>from</code>, <code>to</code>, <code>limit</code>.</p>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const parsePort = () => {
|
||||
const rawPort = process.env.HELPER_LOGS_PORT?.trim()
|
||||
if (!rawPort) {
|
||||
return 8787
|
||||
}
|
||||
export const registerHelperLogsRoutes = (client: Client) => {
|
||||
client.routes.push(
|
||||
{
|
||||
method: "GET",
|
||||
path: "/",
|
||||
handler: () =>
|
||||
new Response(renderHtml(), {
|
||||
headers: {
|
||||
"content-type": "text/html; charset=utf-8"
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/events",
|
||||
handler: async (request) => {
|
||||
const url = new URL(request.url)
|
||||
const events = await listEvents({
|
||||
eventType: asStringOrNull(url.searchParams.get("eventType")),
|
||||
command: asStringOrNull(url.searchParams.get("command")),
|
||||
threadId: asStringOrNull(url.searchParams.get("threadId")),
|
||||
invokedBy: asStringOrNull(url.searchParams.get("invokedBy")),
|
||||
from: asStringOrNull(url.searchParams.get("from")),
|
||||
to: asStringOrNull(url.searchParams.get("to")),
|
||||
limit: parseLimit(url.searchParams.get("limit"))
|
||||
})
|
||||
|
||||
const port = Number.parseInt(rawPort, 10)
|
||||
if (!Number.isInteger(port) || port < 0) {
|
||||
console.warn(`Invalid HELPER_LOGS_PORT "${rawPort}". Falling back to 8787.`)
|
||||
return 8787
|
||||
}
|
||||
|
||||
return port
|
||||
}
|
||||
|
||||
export const startHelperLogsServer = () => {
|
||||
if (serverStarted) {
|
||||
return
|
||||
}
|
||||
|
||||
const port = parsePort()
|
||||
if (port === 0) {
|
||||
console.log("Helper logs server disabled.")
|
||||
return
|
||||
}
|
||||
|
||||
const hostname = process.env.HELPER_LOGS_HOST?.trim() || "127.0.0.1"
|
||||
|
||||
Bun.serve({
|
||||
hostname,
|
||||
port,
|
||||
routes: {
|
||||
"/": {
|
||||
GET: () =>
|
||||
new Response(renderHtml(), {
|
||||
headers: {
|
||||
"content-type": "text/html; charset=utf-8"
|
||||
}
|
||||
})
|
||||
},
|
||||
"/api/events": {
|
||||
GET: async (request) => {
|
||||
const url = new URL(request.url)
|
||||
const events = await listEvents({
|
||||
eventType: asStringOrNull(url.searchParams.get("eventType")),
|
||||
command: asStringOrNull(url.searchParams.get("command")),
|
||||
threadId: asStringOrNull(url.searchParams.get("threadId")),
|
||||
invokedBy: asStringOrNull(url.searchParams.get("invokedBy")),
|
||||
from: asStringOrNull(url.searchParams.get("from")),
|
||||
to: asStringOrNull(url.searchParams.get("to")),
|
||||
limit: parseLimit(url.searchParams.get("limit"))
|
||||
})
|
||||
|
||||
return json({ count: events.length, events })
|
||||
}
|
||||
},
|
||||
"/api/threads": {
|
||||
GET: async (request) => {
|
||||
const url = new URL(request.url)
|
||||
const threads = await listTrackedThreads({
|
||||
threadId: asStringOrNull(url.searchParams.get("threadId")),
|
||||
solved:
|
||||
url.searchParams.get("solved") === null
|
||||
? undefined
|
||||
: url.searchParams.get("solved") === "1" ||
|
||||
url.searchParams.get("solved")?.toLowerCase() === "true",
|
||||
closed:
|
||||
url.searchParams.get("closed") === null
|
||||
? undefined
|
||||
: url.searchParams.get("closed") === "1" ||
|
||||
url.searchParams.get("closed")?.toLowerCase() === "true",
|
||||
limit: parseLimit(url.searchParams.get("limit"))
|
||||
})
|
||||
|
||||
return json({ count: threads.length, threads })
|
||||
}
|
||||
return json({ count: events.length, events })
|
||||
}
|
||||
},
|
||||
fetch: () => json({ error: "Not found" }, { status: 404 }),
|
||||
error: (error) => {
|
||||
console.error("Helper logs server error:", error)
|
||||
return json({ error: "Internal Server Error" }, { status: 500 })
|
||||
}
|
||||
})
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/threads",
|
||||
handler: async (request) => {
|
||||
const url = new URL(request.url)
|
||||
const threads = await listTrackedThreads({
|
||||
threadId: asStringOrNull(url.searchParams.get("threadId")),
|
||||
solved:
|
||||
url.searchParams.get("solved") === null
|
||||
? undefined
|
||||
: url.searchParams.get("solved") === "1" ||
|
||||
url.searchParams.get("solved")?.toLowerCase() === "true",
|
||||
closed:
|
||||
url.searchParams.get("closed") === null
|
||||
? undefined
|
||||
: url.searchParams.get("closed") === "1" ||
|
||||
url.searchParams.get("closed")?.toLowerCase() === "true",
|
||||
limit: parseLimit(url.searchParams.get("limit"))
|
||||
})
|
||||
|
||||
serverStarted = true
|
||||
console.log(`Helper logs server listening on http://${hostname}:${port}`)
|
||||
return json({ count: threads.length, threads })
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ -20,14 +20,12 @@ const SECOND_WARNING_THRESHOLD = 150
|
||||
const AUTO_CLOSE_THRESHOLD = 200
|
||||
const DEFAULT_FETCH_LIMIT = 500
|
||||
|
||||
let monitorStarted = false
|
||||
let monitorInterval: ReturnType<typeof setInterval> | null = null
|
||||
let monitorRunInFlight = false
|
||||
|
||||
const parseIntervalMs = () => {
|
||||
const isThreadLengthMonitorEnabled = () => {
|
||||
const rawValue = process.env.THREAD_LENGTH_CHECK_INTERVAL_HOURS?.trim()
|
||||
if (!rawValue) {
|
||||
return null
|
||||
return false
|
||||
}
|
||||
|
||||
const intervalHours = Number.parseFloat(rawValue)
|
||||
@ -35,10 +33,10 @@ const parseIntervalMs = () => {
|
||||
console.warn(
|
||||
`THREAD_LENGTH_CHECK_INTERVAL_HOURS must be a positive number. Got "${rawValue}".`
|
||||
)
|
||||
return null
|
||||
return false
|
||||
}
|
||||
|
||||
return Math.round(intervalHours * 60 * 60 * 1000)
|
||||
return true
|
||||
}
|
||||
|
||||
const getErrorStatus = (error: unknown) => {
|
||||
@ -250,42 +248,24 @@ const runMonitorPass = async (client: Client) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const startThreadLengthMonitor = (client: Client) => {
|
||||
if (monitorStarted) {
|
||||
export const runThreadLengthMonitor = async (client: Client) => {
|
||||
if (!isThreadLengthMonitorEnabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
monitorStarted = true
|
||||
|
||||
const intervalMs = parseIntervalMs()
|
||||
if (!intervalMs) {
|
||||
console.log("Thread length monitor disabled.")
|
||||
if (monitorRunInFlight) {
|
||||
console.log(
|
||||
"Skipping thread length monitor pass because the previous pass is still running."
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
if (monitorRunInFlight) {
|
||||
console.log("Skipping thread length monitor pass because the previous pass is still running.")
|
||||
return
|
||||
}
|
||||
|
||||
monitorRunInFlight = true
|
||||
try {
|
||||
await runMonitorPass(client)
|
||||
} catch (error) {
|
||||
console.error("Thread length monitor pass failed:", error)
|
||||
} finally {
|
||||
monitorRunInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Thread length monitor enabled with interval ${intervalMs}ms.`)
|
||||
void run()
|
||||
monitorInterval = setInterval(() => {
|
||||
void run()
|
||||
}, intervalMs)
|
||||
|
||||
if (typeof monitorInterval.unref === "function") {
|
||||
monitorInterval.unref()
|
||||
monitorRunInFlight = true
|
||||
try {
|
||||
await runMonitorPass(client)
|
||||
} catch (error) {
|
||||
console.error("Thread length monitor pass failed:", error)
|
||||
} finally {
|
||||
monitorRunInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"include": ["src"],
|
||||
"include": ["src", "worker-configuration.d.ts"],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
@ -7,7 +7,9 @@
|
||||
"lib": ["dom", "es2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": ["node"],
|
||||
"resolveJsonModule": true,
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user