mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-12 20:04:47 +00:00
Compare commits
4 Commits
dev
...
feat/disco
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c332258f54 | ||
|
|
bbab5b10d3 | ||
|
|
9444c95eb2 | ||
|
|
b0e49eb1ac |
30
packages/discord/.env.example
Normal file
30
packages/discord/.env.example
Normal file
@@ -0,0 +1,30 @@
|
||||
# Discord
|
||||
DISCORD_TOKEN=
|
||||
DATABASE_PATH=discord.sqlite # SQLite file path
|
||||
ALLOWED_CHANNEL_IDS= # Comma-separated Discord channel IDs
|
||||
DISCORD_ROLE_ID= # Role ID that triggers the bot (optional, for @role mentions)
|
||||
DISCORD_CATEGORY_ID= # Optional category ID that is allowed
|
||||
DISCORD_REQUIRED_ROLE_ID= # Optional role required to talk to bot
|
||||
|
||||
# Daytona
|
||||
DAYTONA_API_KEY=
|
||||
|
||||
# OpenCode (injected into sandboxes)
|
||||
OPENCODE_ZEN_API_KEY=
|
||||
GITHUB_TOKEN= # Optional; enables authenticated gh CLI inside sandbox
|
||||
|
||||
# Observability
|
||||
LOG_LEVEL=info
|
||||
LOG_PRETTY=false
|
||||
HEALTH_HOST=0.0.0.0
|
||||
HEALTH_PORT=8787
|
||||
TURN_ROUTING_MODE=ai # off | heuristic | ai
|
||||
TURN_ROUTING_MODEL=claude-haiku-4-5
|
||||
|
||||
# Bot behavior
|
||||
SANDBOX_REUSE_POLICY=resume_preferred
|
||||
SANDBOX_TIMEOUT_MINUTES=30
|
||||
PAUSED_TTL_MINUTES=180
|
||||
RESUME_HEALTH_TIMEOUT_MS=120000
|
||||
SANDBOX_CREATION_TIMEOUT=180
|
||||
OPENCODE_MODEL=opencode/claude-sonnet-4-5
|
||||
38
packages/discord/.gitignore
vendored
Normal file
38
packages/discord/.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Bun
|
||||
*.lockb
|
||||
|
||||
# Turbo
|
||||
.turbo/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Local SQLite
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
|
||||
# Sensitive
|
||||
.censitive
|
||||
211
packages/discord/AGENTS.md
Normal file
211
packages/discord/AGENTS.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# AGENTS.md
|
||||
|
||||
Guide for coding agents working in this repository.
|
||||
Use this file for build/test commands and coding conventions.
|
||||
|
||||
## Project Snapshot
|
||||
|
||||
- Stack: Bun + TypeScript (ESM, strict mode)
|
||||
- App: Discord bot that provisions Daytona sandboxes
|
||||
- Persistence: SQLite (`discord.sqlite`, table `discord_sessions`)
|
||||
- Runtime flow: Discord thread -> sandbox -> OpenCode session
|
||||
- Ops: structured JSON logs + `/healthz` and `/readyz`
|
||||
|
||||
## Repository Map
|
||||
|
||||
- `src/index.ts`: startup, wiring, graceful shutdown
|
||||
- `src/config.ts`: env schema and parsing (Zod)
|
||||
- `src/discord/`: Discord client + handlers + routing logic
|
||||
- `src/sandbox/`: sandbox lifecycle + OpenCode transport
|
||||
- `src/sessions/store.ts`: SQLite-backed session store
|
||||
- `src/db/init.ts`: idempotent DB schema initialization
|
||||
- `src/http/health.ts`: health/readiness HTTP server
|
||||
- `.env.example`: env contract
|
||||
|
||||
## Setup and Run Commands
|
||||
|
||||
### Install
|
||||
|
||||
- `bun install`
|
||||
|
||||
### First-time local setup
|
||||
|
||||
- `cp .env.example .env`
|
||||
- Fill required secrets in `.env`
|
||||
- Initialize schema: `bun run db:init`
|
||||
|
||||
### Development run
|
||||
|
||||
- Watch mode: `bun run dev`
|
||||
- Normal run: `bun run start`
|
||||
- Dev bootstrap helper: `bun run dev:setup`
|
||||
|
||||
### Static checks
|
||||
|
||||
- Typecheck: `bun run typecheck`
|
||||
- Build: `bun run build`
|
||||
- Combined check: `bun run check`
|
||||
|
||||
### Health checks
|
||||
|
||||
- `curl -s http://127.0.0.1:8787/healthz`
|
||||
- `curl -i http://127.0.0.1:8787/readyz`
|
||||
|
||||
## Testing Commands
|
||||
|
||||
There is no first-party test suite in `src/` yet.
|
||||
Use Bun test commands for new tests.
|
||||
|
||||
- Run all tests (if present): `bun test`
|
||||
- Run a single test file: `bun test path/to/file.test.ts`
|
||||
- Run one file in watch mode: `bun test --watch path/to/file.test.ts`
|
||||
When adding tests, prefer colocated `*.test.ts` near implementation files.
|
||||
|
||||
## Cursor / Copilot Rules
|
||||
|
||||
Checked these paths:
|
||||
|
||||
- `.cursor/rules/`
|
||||
- `.cursorrules`
|
||||
- `.github/copilot-instructions.md`
|
||||
No Cursor/Copilot rule files currently exist in this repo.
|
||||
If added later, update this file and follow those rules.
|
||||
|
||||
## Code Style
|
||||
|
||||
### TypeScript and modules
|
||||
|
||||
- Keep code strict-TypeScript compatible.
|
||||
- Use ESM imports/exports only.
|
||||
- Prefer named exports over default exports.
|
||||
- Add explicit return types on exported functions.
|
||||
|
||||
### Imports
|
||||
|
||||
- Group imports as: external first, then internal.
|
||||
- Use `import type` for type-only imports.
|
||||
- Keep import paths consistent with existing relative style.
|
||||
|
||||
### Formatting
|
||||
|
||||
- Match existing style:
|
||||
- double quotes
|
||||
- semicolons
|
||||
- trailing commas where appropriate
|
||||
- Keep functions small and focused.
|
||||
- Avoid comments unless logic is non-obvious.
|
||||
|
||||
### Naming
|
||||
|
||||
- `camelCase`: variables/functions
|
||||
- `PascalCase`: classes/interfaces/type aliases
|
||||
- `UPPER_SNAKE_CASE`: env keys and constants
|
||||
- Log events should be stable (`domain.action.result`).
|
||||
|
||||
### Types and contracts
|
||||
|
||||
- Reuse shared types from `src/types.ts`.
|
||||
- Preserve `SessionStatus` semantics when adding new states.
|
||||
- Prefer `unknown` over `any` at untrusted boundaries.
|
||||
- Narrow and validate external data before use.
|
||||
|
||||
## Error Handling and Logging
|
||||
|
||||
- Use `logger` from `src/observability/logger.ts`.
|
||||
- Do not add raw `console.log` in app paths.
|
||||
- Include context fields when available:
|
||||
- `threadId`
|
||||
- `channelId`
|
||||
- `guildId`
|
||||
- `sandboxId`
|
||||
- `sessionId`
|
||||
- Fail fast on invalid config in `src/config.ts`.
|
||||
- Wrap network/process operations in contextual `try/catch`.
|
||||
- Separate recoverable errors from terminal errors.
|
||||
- Never log secret values.
|
||||
|
||||
## Environment and Secrets
|
||||
|
||||
- Read env only through `getEnv()`.
|
||||
- Update `.env.example` for env schema changes.
|
||||
- Keep auth tokens out of command strings and logs.
|
||||
- Pass runtime secrets via environment variables.
|
||||
|
||||
## Domain-Specific Rules
|
||||
|
||||
### Session lifecycle
|
||||
|
||||
- Session mapping (`thread_id`, `sandbox_id`, `session_id`) is authoritative.
|
||||
- Resume existing sandbox/session before creating replacements.
|
||||
- Recreate only when sandbox is unavailable/destroyed.
|
||||
- If session changes, replay Discord thread history as fallback context.
|
||||
|
||||
### Daytona behavior
|
||||
|
||||
- `stop()` clears running processes but keeps filesystem state.
|
||||
- `start()` requires process bootstrap (`opencode serve`).
|
||||
- Keep lifecycle transitions deterministic and observable.
|
||||
|
||||
### OpenCode transport
|
||||
|
||||
- Keep preview token separate from persisted URL when possible.
|
||||
- Send token using `x-daytona-preview-token` header.
|
||||
- Keep retry loops bounded and configurable.
|
||||
|
||||
### Discord handler behavior
|
||||
|
||||
- Ignore bot/self chatter and respect mention/role gating.
|
||||
- Preserve thread ownership checks for bot-managed threads.
|
||||
- Keep outbound messages chunked for Discord size limits.
|
||||
|
||||
## Non-Obvious Discoveries
|
||||
|
||||
### OpenCode session persistence
|
||||
|
||||
- Sessions are disk-persistent JSON files in `~/.local/share/opencode/storage/session/<projectID>/`
|
||||
- Sessions survive `opencode serve` restarts if filesystem intact AND process restarts from same git repo directory
|
||||
- Sessions are scoped by `projectID` = git root commit hash (or `"global"` for non-git dirs)
|
||||
- After `daytona.start()`, processes are guaranteed dead - always restart `opencode serve` immediately, don't wait for health first (`src/sandbox/manager.ts:400-420`)
|
||||
|
||||
### Session reattach debugging
|
||||
|
||||
- If `sessionExists()` returns false but sandbox filesystem is intact, search by title (`Discord thread <threadId>`) via `listSessions()` - session may exist under different ID due to OpenCode internal state changes
|
||||
- Thread lock per `threadId` prevents concurrent create/resume races (`src/sandbox/manager.ts:614-632`)
|
||||
- Never fall back to new sandbox when `daytona.start()` succeeds - filesystem is intact, only OpenCode process needs restart
|
||||
|
||||
### Discord + multiple processes
|
||||
|
||||
- Multiple bot processes with same `DISCORD_TOKEN` cause race conditions - one succeeds, others fail with `DiscordAPIError[160004]` (thread already created)
|
||||
- PTY sessions with `exec bash -l` stay alive after command exits, leading to duplicate bot runtimes if not cleaned up
|
||||
|
||||
### Sandbox runtime auth
|
||||
|
||||
- Pass `GITHUB_TOKEN` as process env to `opencode serve` via `sandbox.process.executeCommand()` `env` parameter
|
||||
- Never interpolate tokens into command strings - use `env` parameter in `exec()` options (`src/sandbox/manager.ts:27-72`)
|
||||
|
||||
## Agent Workflow Checklist
|
||||
|
||||
### Before coding
|
||||
|
||||
- Read related modules and follow existing patterns.
|
||||
- Prefer narrow, minimal changes over broad refactors.
|
||||
|
||||
### During coding
|
||||
|
||||
- Keep behavior backwards-compatible unless intentionally changing it.
|
||||
- Keep changes cohesive (schema + store + manager together).
|
||||
- Add/update logs for important lifecycle branches.
|
||||
|
||||
### After coding
|
||||
|
||||
- Run `bun run typecheck`
|
||||
- Run `bun run build`
|
||||
- Run `bun run db:init` for schema-affecting changes
|
||||
- Smoke-check health endpoints if bootstrap/runtime changed
|
||||
|
||||
## Git/PR Safety for Agents
|
||||
|
||||
- Do not commit or push unless explicitly requested.
|
||||
- Do not amend commits unless explicitly requested.
|
||||
- Avoid destructive git commands unless explicitly requested.
|
||||
- Summaries should cite changed files and operational impact.
|
||||
24
packages/discord/Dockerfile
Normal file
24
packages/discord/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
# Stage 1: Install dependencies
|
||||
FROM oven/bun:1.2-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json bun.lock* ./
|
||||
COPY web/package.json web/
|
||||
COPY discord-bot/package.json discord-bot/
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Stage 2: Production image
|
||||
FROM oven/bun:1.2-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json bun.lock* ./
|
||||
COPY web/package.json web/
|
||||
COPY discord-bot/package.json discord-bot/
|
||||
RUN bun install --production --frozen-lockfile
|
||||
|
||||
# Copy shared DB schema (discord-bot imports from main project)
|
||||
COPY src/db/schema.ts src/db/schema.ts
|
||||
|
||||
# Copy discord bot source
|
||||
COPY discord-bot/src/ discord-bot/src/
|
||||
|
||||
CMD ["bun", "run", "discord-bot/src/index.ts"]
|
||||
122
packages/discord/README.md
Normal file
122
packages/discord/README.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# OpenCord
|
||||
|
||||
Discord bot that provisions [Daytona](https://daytona.io) sandboxes running [OpenCode](https://opencode.ai) sessions. Each Discord thread gets its own isolated sandbox with full conversational context.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Mention the bot in an allowed channel
|
||||
2. Bot creates a Discord thread and provisions a Daytona sandbox
|
||||
3. OpenCode runs inside the sandbox, responding to messages in the thread
|
||||
4. Inactive threads pause their sandbox automatically; activity resumes them
|
||||
5. Conversational context is preserved across bot restarts
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh) installed
|
||||
- A Discord bot application (see below)
|
||||
- A [Daytona](https://daytona.io) account with API access
|
||||
- An [OpenCode](https://opencode.ai) API key
|
||||
|
||||
### 1. Create a Discord Bot
|
||||
|
||||
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
|
||||
2. Create a new application
|
||||
3. Go to **Bot** and click **Reset Token** — save this as `DISCORD_TOKEN`
|
||||
4. Enable **Message Content Intent** under **Privileged Gateway Intents**
|
||||
5. Go to **OAuth2 > URL Generator**, select the `bot` scope with permissions: **Send Messages**, **Create Public Threads**, **Send Messages in Threads**, **Read Message History**
|
||||
6. Use the generated URL to invite the bot to your server
|
||||
|
||||
### 2. Get Your API Keys
|
||||
|
||||
- **Daytona**: Sign up at [daytona.io](https://daytona.io) and generate an API key from your dashboard
|
||||
- **OpenCode**: Get an API key from [opencode.ai](https://opencode.ai)
|
||||
- **GitHub Token** (optional): A personal access token — enables authenticated `gh` CLI inside sandboxes
|
||||
|
||||
### 3. Configure and Run
|
||||
|
||||
```bash
|
||||
bun install
|
||||
cp .env.example .env
|
||||
# Fill in required values (see below)
|
||||
bun run db:init
|
||||
bun run dev
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
#### Required
|
||||
|
||||
| Variable | Description |
|
||||
| ---------------------- | ------------------------------------------- |
|
||||
| `DISCORD_TOKEN` | Bot token from the Discord Developer Portal |
|
||||
| `DAYTONA_API_KEY` | API key from your Daytona dashboard |
|
||||
| `OPENCODE_ZEN_API_KEY` | API key from OpenCode |
|
||||
|
||||
#### Optional — Discord
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------------------------- | --------- | ----------------------------------------------------------------------- |
|
||||
| `ALLOWED_CHANNEL_IDS` | _(empty)_ | Comma-separated channel IDs where the bot listens. Empty = all channels |
|
||||
| `DISCORD_CATEGORY_ID` | _(empty)_ | Restrict the bot to a specific channel category |
|
||||
| `DISCORD_ROLE_ID` | _(empty)_ | Role ID that triggers the bot via @role mentions |
|
||||
| `DISCORD_REQUIRED_ROLE_ID` | _(empty)_ | Role users must have to interact with the bot |
|
||||
|
||||
#### Optional — Storage & Runtime
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ---------------- | ---------------------------- | -------------------------------------------------- |
|
||||
| `DATABASE_PATH` | `discord.sqlite` | Path to the local SQLite file |
|
||||
| `GITHUB_TOKEN` | _(empty)_ | Injected into sandboxes for authenticated `gh` CLI |
|
||||
| `OPENCODE_MODEL` | `opencode/claude-sonnet-4-5` | Model used inside OpenCode sessions |
|
||||
|
||||
#### Optional — Bot Behavior
|
||||
|
||||
| Variable | Default | Description |
|
||||
| -------------------------- | ------------------ | ------------------------------------------------------------------------------ |
|
||||
| `SANDBOX_REUSE_POLICY` | `resume_preferred` | `resume_preferred` or `recreate` |
|
||||
| `SANDBOX_TIMEOUT_MINUTES` | `30` | Minutes of inactivity before pausing a sandbox |
|
||||
| `PAUSED_TTL_MINUTES` | `180` | Minutes a paused sandbox lives before being destroyed |
|
||||
| `RESUME_HEALTH_TIMEOUT_MS` | `120000` | Timeout (ms) when waiting for a sandbox to resume |
|
||||
| `SANDBOX_CREATION_TIMEOUT` | `180` | Timeout (s) for sandbox creation |
|
||||
| `TURN_ROUTING_MODE` | `ai` | How the bot decides if a message needs a response: `off`, `heuristic`, or `ai` |
|
||||
| `TURN_ROUTING_MODEL` | `claude-haiku-4-5` | Model used for AI turn routing |
|
||||
|
||||
#### Optional — Observability
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ------------- | --------- | ----------------------------------- |
|
||||
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, or `error` |
|
||||
| `LOG_PRETTY` | `false` | Pretty-print JSON logs |
|
||||
| `HEALTH_HOST` | `0.0.0.0` | Host for the health HTTP server |
|
||||
| `HEALTH_PORT` | `8787` | Port for the health HTTP server |
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
| ------------------- | --------------------------- |
|
||||
| `bun run dev` | Watch mode |
|
||||
| `bun run start` | Production run |
|
||||
| `bun run db:init` | Initialize/migrate database |
|
||||
| `bun run typecheck` | TypeScript checks |
|
||||
| `bun run build` | Bundle for deployment |
|
||||
| `bun run check` | Typecheck + build |
|
||||
|
||||
## Health Endpoints
|
||||
|
||||
- `GET /healthz` — Liveness check (uptime, Discord status, active sessions)
|
||||
- `GET /readyz` — Readiness check (200 when Discord connected, 503 otherwise)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Discord thread
|
||||
└─ message-create handler
|
||||
└─ SandboxManager.resolveSessionForMessage()
|
||||
├─ active? → health check → reuse
|
||||
├─ paused? → daytona.start() → restart opencode → reattach session
|
||||
└─ missing? → create sandbox → clone repo → start opencode → new session
|
||||
```
|
||||
|
||||
Sessions are persisted in a local SQLite file. Sandbox filesystem (including OpenCode session state) survives pause/resume cycles via Daytona stop/start.
|
||||
27
packages/discord/package.json
Normal file
27
packages/discord/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@opencode/discord",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"db:init": "bun run src/db/init.ts",
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"dev:setup": "bun run db:init",
|
||||
"start": "bun run src/index.ts",
|
||||
"build": "bun build src/index.ts --target=bun --outdir=dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check": "bun run typecheck && bun run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"discord.js": "^14",
|
||||
"@daytonaio/sdk": "latest",
|
||||
"@opencode-ai/sdk": "latest",
|
||||
"effect": "^3",
|
||||
"zod": "^3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "^22",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
28
packages/discord/src/agent-prompt.md
Normal file
28
packages/discord/src/agent-prompt.md
Normal file
@@ -0,0 +1,28 @@
|
||||
You're a senior engineer on the OpenCode team. You're in a Discord channel where teammates and community members ask questions about the codebase. You have the full opencode repo cloned at your working directory.
|
||||
|
||||
This is an internal tool — people tag you to ask about how things work, where code lives, why something was built a certain way, or to get help debugging. Think of it like someone pinging you on Slack.
|
||||
|
||||
## Tone
|
||||
|
||||
- Just answer the question. Don't preface with "Based on my analysis" or "I'd be happy to help" or "Let me look into that for you." Just give the answer.
|
||||
- Write like you're messaging a coworker. Lowercase is fine. Short paragraphs. No essays.
|
||||
- Don't over-format. Use markdown for code blocks and the occasional list, but don't turn every response into a formatted document with headers and bullet points. Just talk.
|
||||
- Be direct and opinionated when it makes sense. "yeah that's a bug" or "I'd just use X here" is better than hedging everything.
|
||||
- If you don't know, say "not sure" or "I'd have to dig into that more." Don't make stuff up.
|
||||
- Match the vibe. Quick question = quick answer. Detailed question = longer answer with code refs.
|
||||
|
||||
## What you do
|
||||
|
||||
- Search and read the codebase to answer questions
|
||||
- Run git, grep, gh CLI to find things
|
||||
- Reference specific files and line numbers like `src/tui/app.ts:142`
|
||||
- Quote relevant code when it helps
|
||||
- Explain architecture and design decisions based on what's actually in the code
|
||||
|
||||
## Rules
|
||||
|
||||
- **Search the code first.** Don't answer from memory — look it up and cite where things are.
|
||||
- **Don't edit files unless someone explicitly asks you to.**
|
||||
- **Keep it short.** Under 1500 chars unless the question actually needs a longer answer.
|
||||
- **Summarize command output.** Don't paste raw terminal dumps.
|
||||
- When you reference code, include the file path so people can go look at it.
|
||||
61
packages/discord/src/config.ts
Normal file
61
packages/discord/src/config.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const envSchema = z.object({
|
||||
DISCORD_TOKEN: z.string().min(1),
|
||||
ALLOWED_CHANNEL_IDS: z
|
||||
.string()
|
||||
.default("")
|
||||
.transform((s) =>
|
||||
s
|
||||
.split(",")
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id.length > 0),
|
||||
),
|
||||
DISCORD_CATEGORY_ID: z.string().default(""),
|
||||
DISCORD_ROLE_ID: z.string().default(""),
|
||||
DISCORD_REQUIRED_ROLE_ID: z.string().default(""),
|
||||
DATABASE_PATH: z.string().default("discord.sqlite"),
|
||||
DAYTONA_API_KEY: z.string().min(1),
|
||||
OPENCODE_ZEN_API_KEY: z.string().min(1),
|
||||
GITHUB_TOKEN: z.string().default(""),
|
||||
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
||||
LOG_PRETTY: z
|
||||
.string()
|
||||
.default("false")
|
||||
.transform((value) => value.toLowerCase() === "true"),
|
||||
HEALTH_HOST: z.string().default("0.0.0.0"),
|
||||
HEALTH_PORT: z.coerce.number().default(8787),
|
||||
TURN_ROUTING_MODE: z.enum(["off", "heuristic", "ai"]).default("ai"),
|
||||
TURN_ROUTING_MODEL: z.string().default("claude-haiku-4-5"),
|
||||
SANDBOX_REUSE_POLICY: z.enum(["resume_preferred", "recreate"]).default("resume_preferred"),
|
||||
SANDBOX_TIMEOUT_MINUTES: z.coerce.number().default(30),
|
||||
PAUSED_TTL_MINUTES: z.coerce.number().default(180),
|
||||
RESUME_HEALTH_TIMEOUT_MS: z.coerce.number().default(120000),
|
||||
SANDBOX_CREATION_TIMEOUT: z.coerce.number().default(180),
|
||||
OPENCODE_MODEL: z.string().default("opencode/claude-sonnet-4-5"),
|
||||
})
|
||||
|
||||
export type Env = z.infer<typeof envSchema>
|
||||
|
||||
let _config: Env | null = null
|
||||
|
||||
export function getEnv(): Env {
|
||||
if (!_config) {
|
||||
const result = envSchema.safeParse(process.env)
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
ts: new Date().toISOString(),
|
||||
level: "error",
|
||||
event: "config.invalid",
|
||||
component: "config",
|
||||
message: "Invalid environment variables",
|
||||
fieldErrors: result.error.flatten().fieldErrors,
|
||||
}),
|
||||
)
|
||||
throw new Error("Invalid environment configuration")
|
||||
}
|
||||
_config = result.data
|
||||
}
|
||||
return _config
|
||||
}
|
||||
13
packages/discord/src/db/client.ts
Normal file
13
packages/discord/src/db/client.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
import { getEnv } from "../config"
|
||||
|
||||
let _db: Database | null = null
|
||||
|
||||
export function getDb(): Database {
|
||||
if (!_db) {
|
||||
_db = new Database(getEnv().DATABASE_PATH, { create: true })
|
||||
_db.exec("PRAGMA journal_mode = WAL;")
|
||||
_db.exec("PRAGMA busy_timeout = 5000;")
|
||||
}
|
||||
return _db
|
||||
}
|
||||
76
packages/discord/src/db/init.ts
Normal file
76
packages/discord/src/db/init.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { Database } from "bun:sqlite"
|
||||
import { getDb } from "./client"
|
||||
import { logger } from "../observability/logger"
|
||||
|
||||
const PREFIX = "[db]"
|
||||
|
||||
export async function initializeDatabase(): Promise<void> {
|
||||
const db = getDb()
|
||||
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS discord_sessions (
|
||||
thread_id TEXT PRIMARY KEY,
|
||||
channel_id TEXT NOT NULL,
|
||||
guild_id TEXT NOT NULL,
|
||||
sandbox_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
preview_url TEXT NOT NULL,
|
||||
preview_token TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN ('creating', 'active', 'pausing', 'paused', 'resuming', 'destroying', 'destroyed', 'error')),
|
||||
last_activity TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
pause_requested_at TEXT,
|
||||
paused_at TEXT,
|
||||
resume_attempted_at TEXT,
|
||||
resumed_at TEXT,
|
||||
destroyed_at TEXT,
|
||||
last_health_ok_at TEXT,
|
||||
last_error TEXT,
|
||||
resume_fail_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`)
|
||||
|
||||
addColumn(db, "preview_token", "TEXT")
|
||||
addColumn(db, "last_activity", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP")
|
||||
addColumn(db, "created_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP")
|
||||
addColumn(db, "updated_at", "TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP")
|
||||
addColumn(db, "pause_requested_at", "TEXT")
|
||||
addColumn(db, "paused_at", "TEXT")
|
||||
addColumn(db, "resume_attempted_at", "TEXT")
|
||||
addColumn(db, "resumed_at", "TEXT")
|
||||
addColumn(db, "destroyed_at", "TEXT")
|
||||
addColumn(db, "last_health_ok_at", "TEXT")
|
||||
addColumn(db, "last_error", "TEXT")
|
||||
addColumn(db, "resume_fail_count", "INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS discord_sessions_status_last_activity_idx
|
||||
ON discord_sessions (status, last_activity)`)
|
||||
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS discord_sessions_status_updated_at_idx
|
||||
ON discord_sessions (status, updated_at)`)
|
||||
}
|
||||
|
||||
function addColumn(db: Database, name: string, definition: string): void {
|
||||
if (hasColumn(db, name)) return
|
||||
db.exec(`ALTER TABLE discord_sessions ADD COLUMN ${name} ${definition}`)
|
||||
}
|
||||
|
||||
function hasColumn(db: Database, name: string): boolean {
|
||||
const rows = db.query("PRAGMA table_info(discord_sessions)").all() as Array<{ name: string }>
|
||||
return rows.some((row) => row.name === name)
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
initializeDatabase()
|
||||
.then(() => {
|
||||
logger.info({ event: "db.schema.ready", component: "db", message: `${PREFIX} Schema is ready` })
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error({
|
||||
event: "db.schema.failed",
|
||||
component: "db",
|
||||
message: `${PREFIX} Failed to initialize schema`,
|
||||
error: err,
|
||||
})
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
14
packages/discord/src/discord/client.ts
Normal file
14
packages/discord/src/discord/client.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Client, GatewayIntentBits, Partials } from "discord.js";
|
||||
|
||||
export function createDiscordClient(): Client {
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.MessageContent,
|
||||
],
|
||||
partials: [Partials.Channel],
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
70
packages/discord/src/discord/format.ts
Normal file
70
packages/discord/src/discord/format.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
const MAX_MESSAGE_LENGTH = 1900;
|
||||
|
||||
/**
|
||||
* Splits a long response into Discord-safe message chunks (<2000 chars).
|
||||
* Splits at code block boundaries, paragraph breaks, or sentence ends.
|
||||
* Handles unclosed code blocks across chunks.
|
||||
*/
|
||||
export function splitForDiscord(text: string): string[] {
|
||||
if (!text || text.length === 0) return ["(No response)"];
|
||||
if (text.length <= MAX_MESSAGE_LENGTH) return [text];
|
||||
|
||||
const messages: string[] = [];
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= MAX_MESSAGE_LENGTH) {
|
||||
messages.push(remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
// Find best split point
|
||||
let splitAt = -1;
|
||||
|
||||
// Prefer splitting at end of code block
|
||||
splitAt = remaining.lastIndexOf("\n```\n", MAX_MESSAGE_LENGTH);
|
||||
if (splitAt !== -1) splitAt += 4; // include the closing ```\n
|
||||
|
||||
// Then paragraph break
|
||||
if (splitAt === -1 || splitAt < MAX_MESSAGE_LENGTH / 2) {
|
||||
const paraBreak = remaining.lastIndexOf("\n\n", MAX_MESSAGE_LENGTH);
|
||||
if (paraBreak > MAX_MESSAGE_LENGTH / 2) splitAt = paraBreak;
|
||||
}
|
||||
|
||||
// Then sentence end
|
||||
if (splitAt === -1 || splitAt < MAX_MESSAGE_LENGTH / 2) {
|
||||
const sentenceEnd = remaining.lastIndexOf(". ", MAX_MESSAGE_LENGTH);
|
||||
if (sentenceEnd > MAX_MESSAGE_LENGTH / 2) splitAt = sentenceEnd + 1;
|
||||
}
|
||||
|
||||
// Fallback: hard cut
|
||||
if (splitAt === -1 || splitAt < MAX_MESSAGE_LENGTH / 4) {
|
||||
splitAt = MAX_MESSAGE_LENGTH;
|
||||
}
|
||||
|
||||
const chunk = remaining.slice(0, splitAt);
|
||||
remaining = remaining.slice(splitAt).trimStart();
|
||||
|
||||
// Handle unclosed code blocks
|
||||
const backtickCount = (chunk.match(/```/g) || []).length;
|
||||
if (backtickCount % 2 !== 0) {
|
||||
// Odd = unclosed code block
|
||||
messages.push(chunk + "\n```");
|
||||
remaining = "```\n" + remaining;
|
||||
} else {
|
||||
messages.push(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
return messages.filter((m) => m.trim().length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up the response text for Discord.
|
||||
* Strips any leading/trailing whitespace and limits consecutive newlines.
|
||||
*/
|
||||
export function cleanResponse(text: string): string {
|
||||
return text
|
||||
.trim()
|
||||
.replace(/\n{4,}/g, "\n\n\n"); // max 3 consecutive newlines
|
||||
}
|
||||
321
packages/discord/src/discord/handlers/message-create.ts
Normal file
321
packages/discord/src/discord/handlers/message-create.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import type { Client, Message, TextChannel, ThreadChannel, GuildMember } from "discord.js";
|
||||
import { ChannelType } from "discord.js";
|
||||
import { getEnv } from "../../config";
|
||||
import { cleanResponse, splitForDiscord } from "../format";
|
||||
import { shouldRespondToOwnedThreadTurn } from "../turn-routing";
|
||||
import { generateThreadName } from "../thread-name";
|
||||
import type { SandboxManager } from "../../sandbox/manager";
|
||||
import { logger } from "../../observability/logger";
|
||||
|
||||
/**
|
||||
* Checks if a channel (or its parent for threads) is allowed.
|
||||
* Allowed means: in the ALLOWED_CHANNEL_IDS list, OR in the DISCORD_CATEGORY_ID category.
|
||||
*/
|
||||
function isChannelAllowed(channelId: string, categoryId: string | null, env: ReturnType<typeof getEnv>): boolean {
|
||||
if (env.ALLOWED_CHANNEL_IDS.length > 0 && env.ALLOWED_CHANNEL_IDS.includes(channelId)) {
|
||||
return true;
|
||||
}
|
||||
if (env.DISCORD_CATEGORY_ID && categoryId === env.DISCORD_CATEGORY_ID) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user has the required role (if configured).
|
||||
*/
|
||||
function hasRequiredRole(member: GuildMember | null, env: ReturnType<typeof getEnv>): boolean {
|
||||
if (!env.DISCORD_REQUIRED_ROLE_ID) return true; // no role requirement
|
||||
if (!member) return false;
|
||||
return member.roles.cache.has(env.DISCORD_REQUIRED_ROLE_ID);
|
||||
}
|
||||
|
||||
const HISTORY_FETCH_LIMIT = 40;
|
||||
const HISTORY_LINE_CHAR_LIMIT = 500;
|
||||
const HISTORY_TOTAL_CHAR_LIMIT = 6000;
|
||||
|
||||
function normalizeHistoryText(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
async function buildHistoryReplayPrompt(
|
||||
thread: ThreadChannel,
|
||||
currentMessage: Message,
|
||||
latestUserContent: string,
|
||||
): Promise<{ prompt: string; historyCount: number }> {
|
||||
try {
|
||||
const fetched = await thread.messages.fetch({ limit: HISTORY_FETCH_LIMIT });
|
||||
const ordered = [...fetched.values()].sort((a, b) => a.createdTimestamp - b.createdTimestamp);
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const prior of ordered) {
|
||||
if (prior.id === currentMessage.id || prior.system) continue;
|
||||
|
||||
let lineContent = normalizeHistoryText(prior.content);
|
||||
if (!lineContent && prior.attachments.size > 0) {
|
||||
const files = [...prior.attachments.values()].map((att) => att.name ?? "file").join(", ");
|
||||
lineContent = `[attachments: ${files}]`;
|
||||
}
|
||||
|
||||
if (!lineContent) continue;
|
||||
if (lineContent.length > HISTORY_LINE_CHAR_LIMIT) {
|
||||
lineContent = `${lineContent.slice(0, HISTORY_LINE_CHAR_LIMIT)}...`;
|
||||
}
|
||||
|
||||
const role = prior.author.bot ? "assistant" : "user";
|
||||
lines.push(`${role}: ${lineContent}`);
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
return { prompt: latestUserContent, historyCount: 0 };
|
||||
}
|
||||
|
||||
const selected: string[] = [];
|
||||
let totalChars = 0;
|
||||
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
||||
const candidate = lines[i];
|
||||
if (totalChars + candidate.length > HISTORY_TOTAL_CHAR_LIMIT && selected.length > 0) break;
|
||||
selected.unshift(candidate);
|
||||
totalChars += candidate.length;
|
||||
}
|
||||
|
||||
return {
|
||||
prompt: [
|
||||
"Conversation history from this same Discord thread (oldest to newest):",
|
||||
selected.join("\n"),
|
||||
"",
|
||||
"Continue the same conversation and respond to the latest user message:",
|
||||
latestUserContent,
|
||||
].join("\n"),
|
||||
historyCount: selected.length,
|
||||
};
|
||||
} catch {
|
||||
return { prompt: latestUserContent, historyCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a messageCreate event handler bound to the given SandboxManager.
|
||||
*/
|
||||
export function createMessageHandler(client: Client, sandboxManager: SandboxManager) {
|
||||
const env = getEnv();
|
||||
|
||||
return async (message: Message): Promise<void> => {
|
||||
if (message.author.bot) return;
|
||||
|
||||
// Check if the bot is mentioned (ignore @everyone and @here)
|
||||
if (message.mentions.everyone) return;
|
||||
|
||||
const botUserId = client.user?.id ?? "";
|
||||
const roleId = env.DISCORD_ROLE_ID;
|
||||
|
||||
const mentionedByUser = client.user ? message.mentions.has(client.user, { ignoreEveryone: true, ignoreRoles: false }) : false;
|
||||
const mentionedByRole = roleId ? message.mentions.roles.has(roleId) : false;
|
||||
const mentionedInContent = message.content.includes(`<@${botUserId}>`) || message.content.includes(`<@!${botUserId}>`);
|
||||
const roleMentionInContent = roleId ? message.content.includes(`<@&${roleId}>`) : false;
|
||||
|
||||
const isMentioned = mentionedByUser || mentionedByRole || mentionedInContent || roleMentionInContent;
|
||||
|
||||
// In threads the bot owns, respond to ALL messages (no mention needed)
|
||||
const isInThread = message.channel.type === ChannelType.PublicThread || message.channel.type === ChannelType.PrivateThread;
|
||||
let isOwnedThread = false;
|
||||
if (isInThread && !isMentioned) {
|
||||
isOwnedThread = await sandboxManager.hasTrackedThread(message.channelId);
|
||||
|
||||
if (isOwnedThread) {
|
||||
const decision = await shouldRespondToOwnedThreadTurn({
|
||||
mode: env.TURN_ROUTING_MODE,
|
||||
model: env.TURN_ROUTING_MODEL,
|
||||
apiKey: env.OPENCODE_ZEN_API_KEY,
|
||||
content: message.content,
|
||||
botUserId,
|
||||
botRoleId: roleId,
|
||||
mentionedUserIds: [...message.mentions.users.keys()],
|
||||
mentionedRoleIds: [...message.mentions.roles.keys()],
|
||||
});
|
||||
|
||||
if (!decision.shouldRespond) {
|
||||
logger.info({
|
||||
event: "discord.message.skipped.not_directed",
|
||||
component: "message-handler",
|
||||
message: "Skipped turn not directed at bot",
|
||||
channelId: message.channelId,
|
||||
userId: message.author.id,
|
||||
reason: decision.reason,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isMentioned && !isOwnedThread) return;
|
||||
|
||||
// Check required role
|
||||
const member = message.member;
|
||||
if (!hasRequiredRole(member, env)) {
|
||||
logger.info({
|
||||
event: "discord.message.ignored.role",
|
||||
component: "message-handler",
|
||||
message: "Ignored message from user without required role",
|
||||
channelId: message.channelId,
|
||||
userId: message.author.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info({
|
||||
event: "discord.message.triggered",
|
||||
component: "message-handler",
|
||||
message: "Bot triggered",
|
||||
channelId: message.channelId,
|
||||
userId: message.author.id,
|
||||
isMentioned,
|
||||
isOwnedThread,
|
||||
contentLength: message.content.length,
|
||||
});
|
||||
|
||||
// Strip mentions from content
|
||||
const content = message.content.replace(/<@[!&]?\d+>/g, "").trim();
|
||||
if (!content) {
|
||||
await message.reply("Tag me with a question!").catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
let thread: ThreadChannel;
|
||||
let parentChannelId: string;
|
||||
let parentCategoryId: string | null = null;
|
||||
|
||||
try {
|
||||
if (isInThread) {
|
||||
thread = message.channel as ThreadChannel;
|
||||
parentChannelId = thread.parentId ?? "";
|
||||
// Get the category from the parent channel
|
||||
const parentChannel = thread.parent;
|
||||
parentCategoryId = parentChannel?.parentId ?? null;
|
||||
} else {
|
||||
parentChannelId = message.channelId;
|
||||
parentCategoryId = (message.channel as TextChannel).parentId ?? null;
|
||||
|
||||
if (!isChannelAllowed(parentChannelId, parentCategoryId, env)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const threadName = await generateThreadName(content);
|
||||
thread = await (message.channel as TextChannel).threads.create({
|
||||
name: threadName,
|
||||
startMessage: message,
|
||||
autoArchiveDuration: 60,
|
||||
});
|
||||
}
|
||||
|
||||
// Check allowed for threads too
|
||||
if (!isOwnedThread && !isChannelAllowed(parentChannelId, parentCategoryId, env)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const threadId = thread.id;
|
||||
const channelId = parentChannelId;
|
||||
const guildId = message.guildId ?? "";
|
||||
|
||||
const trackedBeforeResolve = await sandboxManager.getTrackedSession(threadId);
|
||||
|
||||
// Typing indicator
|
||||
let typingActive = true;
|
||||
const sendTyping = () => {
|
||||
if (typingActive) thread.sendTyping().catch(() => {});
|
||||
};
|
||||
sendTyping();
|
||||
const typingInterval = setInterval(sendTyping, 8000);
|
||||
|
||||
try {
|
||||
let session = await sandboxManager.resolveSessionForMessage(threadId, channelId, guildId);
|
||||
let historyPromptCache: { prompt: string; historyCount: number } | null = null;
|
||||
|
||||
const getHistoryPrompt = async (): Promise<{ prompt: string; historyCount: number }> => {
|
||||
if (!historyPromptCache) {
|
||||
historyPromptCache = await buildHistoryReplayPrompt(thread, message, content);
|
||||
}
|
||||
return historyPromptCache;
|
||||
};
|
||||
|
||||
let promptForAgent = content;
|
||||
if (trackedBeforeResolve && trackedBeforeResolve.sessionId !== session.sessionId) {
|
||||
const replay = await getHistoryPrompt();
|
||||
promptForAgent = replay.prompt;
|
||||
logger.info({
|
||||
event: "discord.context.replayed",
|
||||
component: "message-handler",
|
||||
message: "Replayed thread history into replacement session",
|
||||
threadId,
|
||||
previousSessionId: trackedBeforeResolve.sessionId,
|
||||
sessionId: session.sessionId,
|
||||
historyMessages: replay.historyCount,
|
||||
});
|
||||
}
|
||||
|
||||
let response: string;
|
||||
try {
|
||||
response = await sandboxManager.sendMessage(session, promptForAgent);
|
||||
} catch (err: any) {
|
||||
if (err?.recoverable && err?.message === "SANDBOX_DEAD") {
|
||||
logger.warn({
|
||||
event: "discord.message.recovering",
|
||||
component: "message-handler",
|
||||
message: "Recovering by resolving session again",
|
||||
threadId,
|
||||
});
|
||||
await thread.send("*Session changed state, recovering...*").catch(() => {});
|
||||
const sessionBeforeRecovery = session.sessionId;
|
||||
session = await sandboxManager.resolveSessionForMessage(threadId, channelId, guildId);
|
||||
let recoveryPrompt = content;
|
||||
if (sessionBeforeRecovery !== session.sessionId) {
|
||||
const replay = await getHistoryPrompt();
|
||||
recoveryPrompt = replay.prompt;
|
||||
logger.info({
|
||||
event: "discord.context.replayed",
|
||||
component: "message-handler",
|
||||
message: "Replayed thread history after recovery",
|
||||
threadId,
|
||||
previousSessionId: sessionBeforeRecovery,
|
||||
sessionId: session.sessionId,
|
||||
historyMessages: replay.historyCount,
|
||||
});
|
||||
}
|
||||
response = await sandboxManager.sendMessage(session, recoveryPrompt);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const cleaned = cleanResponse(response);
|
||||
const chunks = splitForDiscord(cleaned);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
await thread.send(chunk);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({
|
||||
event: "discord.message.failed",
|
||||
component: "message-handler",
|
||||
message: "Error handling message",
|
||||
threadId,
|
||||
error: err,
|
||||
});
|
||||
const errorMsg = err instanceof Error ? err.message : "Unknown error";
|
||||
await thread.send(`Something went wrong: ${errorMsg}`).catch(() => {});
|
||||
} finally {
|
||||
typingActive = false;
|
||||
clearInterval(typingInterval);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({
|
||||
event: "discord.message.setup_failed",
|
||||
component: "message-handler",
|
||||
message: "Error processing message",
|
||||
channelId: message.channelId,
|
||||
error: err,
|
||||
});
|
||||
await message.reply("Something went wrong setting up the thread.").catch(() => {});
|
||||
}
|
||||
};
|
||||
}
|
||||
59
packages/discord/src/discord/thread-name.ts
Normal file
59
packages/discord/src/discord/thread-name.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { getEnv } from "../config";
|
||||
|
||||
const ZEN_MESSAGES_URL = "https://opencode.ai/zen/v1/messages";
|
||||
|
||||
/**
|
||||
* Uses Claude Haiku 4.5 via OpenCode Zen to generate a concise thread name
|
||||
* from the user's message. Falls back to truncation on error.
|
||||
*/
|
||||
export async function generateThreadName(userMessage: string): Promise<string> {
|
||||
try {
|
||||
const env = getEnv();
|
||||
|
||||
const res = await fetch(ZEN_MESSAGES_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": env.OPENCODE_ZEN_API_KEY,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "claude-haiku-4-5",
|
||||
max_tokens: 60,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: `Generate a short, descriptive thread title (max 90 chars) for this Discord question. Return ONLY the title, no quotes, no explanation.\n\nQuestion: ${userMessage}`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(`[thread-name] Zen API returned ${res.status}, falling back to truncation`);
|
||||
return fallback(userMessage);
|
||||
}
|
||||
|
||||
const data = await res.json() as {
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
|
||||
const title = data.content
|
||||
?.filter((c) => c.type === "text")
|
||||
.map((c) => c.text ?? "")
|
||||
.join("")
|
||||
.trim();
|
||||
|
||||
if (!title || title.length === 0) return fallback(userMessage);
|
||||
|
||||
// Ensure it fits Discord's thread name limit (100 chars)
|
||||
return title.slice(0, 95) + (title.length > 95 ? "..." : "");
|
||||
} catch (err) {
|
||||
console.warn("[thread-name] Failed to generate name:", err);
|
||||
return fallback(userMessage);
|
||||
}
|
||||
}
|
||||
|
||||
function fallback(message: string): string {
|
||||
return message.slice(0, 95) + (message.length > 95 ? "..." : "");
|
||||
}
|
||||
127
packages/discord/src/discord/turn-routing.ts
Normal file
127
packages/discord/src/discord/turn-routing.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
const ZEN_MESSAGES_URL = "https://opencode.ai/zen/v1/messages";
|
||||
|
||||
export type TurnRoutingMode = "off" | "heuristic" | "ai";
|
||||
|
||||
type TurnRoutingInput = {
|
||||
mode: TurnRoutingMode;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
content: string;
|
||||
botUserId: string;
|
||||
botRoleId: string;
|
||||
mentionedUserIds: string[];
|
||||
mentionedRoleIds: string[];
|
||||
};
|
||||
|
||||
export type TurnRoutingDecision = {
|
||||
shouldRespond: boolean;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
const QUICK_CHAT_RE = /^(ok|okay|k|kk|thanks|thank you|thx|lol|lmao|haha|nice|cool|yup|yep|nah|nope|got it|sgtm)[!. ]*$/i;
|
||||
|
||||
function heuristicDecision(input: TurnRoutingInput): TurnRoutingDecision | null {
|
||||
const text = input.content.trim();
|
||||
const lower = text.toLowerCase();
|
||||
|
||||
if (!text) {
|
||||
return { shouldRespond: false, reason: "empty-message" };
|
||||
}
|
||||
|
||||
const mentionsOtherUser = input.mentionedUserIds.some((id) => id !== input.botUserId);
|
||||
if (mentionsOtherUser) {
|
||||
return { shouldRespond: false, reason: "mentions-other-user" };
|
||||
}
|
||||
|
||||
const mentionsOtherRole = input.mentionedRoleIds.some((id) => id !== input.botRoleId);
|
||||
if (mentionsOtherRole) {
|
||||
return { shouldRespond: false, reason: "mentions-other-role" };
|
||||
}
|
||||
|
||||
if (text.length <= 40 && QUICK_CHAT_RE.test(text)) {
|
||||
return { shouldRespond: false, reason: "quick-chat" };
|
||||
}
|
||||
|
||||
if (/\b(opencode|bot)\b/i.test(text)) {
|
||||
return { shouldRespond: true, reason: "bot-keyword" };
|
||||
}
|
||||
|
||||
if (text.includes("?") && /\b(you|your|can you|could you|would you|please|help)\b/i.test(text)) {
|
||||
return { shouldRespond: true, reason: "direct-question" };
|
||||
}
|
||||
|
||||
if (text.includes("?") && /\b(how|what|why|where|when|which)\b/i.test(text)) {
|
||||
return { shouldRespond: true, reason: "general-question" };
|
||||
}
|
||||
|
||||
if (lower.startsWith("do this") || lower.startsWith("run ") || lower.startsWith("fix ")) {
|
||||
return { shouldRespond: true, reason: "instruction" };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function aiDecision(input: TurnRoutingInput): Promise<TurnRoutingDecision> {
|
||||
const prompt = [
|
||||
"You route turns for an engineering Discord bot.",
|
||||
"Decide if the latest message is directed at the bot assistant or is side conversation.",
|
||||
"Return EXACTLY one token: RESPOND or SKIP.",
|
||||
"",
|
||||
`Message: ${input.content}`,
|
||||
`MentionsOtherUser: ${input.mentionedUserIds.some((id) => id !== input.botUserId)}`,
|
||||
`MentionsOtherRole: ${input.mentionedRoleIds.some((id) => id !== input.botRoleId)}`,
|
||||
].join("\n");
|
||||
|
||||
const res = await fetch(ZEN_MESSAGES_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": input.apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: input.model,
|
||||
max_tokens: 10,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return { shouldRespond: true, reason: `ai-http-${res.status}` };
|
||||
}
|
||||
|
||||
const data = await res.json() as { content?: Array<{ type: string; text?: string }> };
|
||||
const output = data.content
|
||||
?.filter((c) => c.type === "text")
|
||||
.map((c) => c.text ?? "")
|
||||
.join(" ")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
|
||||
if (output?.includes("SKIP")) {
|
||||
return { shouldRespond: false, reason: "ai-skip" };
|
||||
}
|
||||
|
||||
return { shouldRespond: true, reason: output?.includes("RESPOND") ? "ai-respond" : "ai-default-respond" };
|
||||
}
|
||||
|
||||
export async function shouldRespondToOwnedThreadTurn(input: TurnRoutingInput): Promise<TurnRoutingDecision> {
|
||||
if (input.mode === "off") {
|
||||
return { shouldRespond: true, reason: "routing-off" };
|
||||
}
|
||||
|
||||
const heuristic = heuristicDecision(input);
|
||||
if (heuristic) {
|
||||
return heuristic;
|
||||
}
|
||||
|
||||
if (input.mode === "heuristic") {
|
||||
return { shouldRespond: true, reason: "heuristic-uncertain-default-respond" };
|
||||
}
|
||||
|
||||
try {
|
||||
return await aiDecision(input);
|
||||
} catch {
|
||||
return { shouldRespond: true, reason: "ai-error-default-respond" };
|
||||
}
|
||||
}
|
||||
43
packages/discord/src/http/health.ts
Normal file
43
packages/discord/src/http/health.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Client } from "discord.js";
|
||||
|
||||
type HealthDependencies = {
|
||||
client: Client;
|
||||
isCleanupLoopRunning: () => boolean;
|
||||
getActiveSessionCount: () => Promise<number>;
|
||||
};
|
||||
|
||||
export function startHealthServer(host: string, port: number, deps: HealthDependencies): Bun.Server<unknown> {
|
||||
const startedAt = Date.now();
|
||||
|
||||
return Bun.serve({
|
||||
hostname: host,
|
||||
port,
|
||||
fetch: async (request) => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === "/healthz") {
|
||||
const activeSessions = await deps.getActiveSessionCount().catch(() => 0);
|
||||
return Response.json({
|
||||
ok: true,
|
||||
uptimeSec: Math.floor((Date.now() - startedAt) / 1000),
|
||||
discordReady: deps.client.isReady(),
|
||||
cleanupLoopRunning: deps.isCleanupLoopRunning(),
|
||||
activeSessions,
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/readyz") {
|
||||
const ready = deps.client.isReady();
|
||||
return Response.json(
|
||||
{
|
||||
ok: ready,
|
||||
discordReady: ready,
|
||||
},
|
||||
{ status: ready ? 200 : 503 },
|
||||
);
|
||||
}
|
||||
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
});
|
||||
}
|
||||
83
packages/discord/src/index.ts
Normal file
83
packages/discord/src/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { getEnv } from "./config";
|
||||
import { createDiscordClient } from "./discord/client";
|
||||
import { createMessageHandler } from "./discord/handlers/message-create";
|
||||
import { initializeDatabase } from "./db/init";
|
||||
import { startHealthServer } from "./http/health";
|
||||
import { logger } from "./observability/logger";
|
||||
import { SandboxManager } from "./sandbox/manager";
|
||||
|
||||
async function main() {
|
||||
const env = getEnv();
|
||||
logger.info({ event: "app.starting", component: "index", message: "Starting Discord bot" });
|
||||
await initializeDatabase();
|
||||
logger.info({ event: "db.ready", component: "index", message: "Database ready" });
|
||||
|
||||
const client = createDiscordClient();
|
||||
const sandboxManager = new SandboxManager();
|
||||
const healthServer = startHealthServer(env.HEALTH_HOST, env.HEALTH_PORT, {
|
||||
client,
|
||||
isCleanupLoopRunning: () => sandboxManager.isCleanupLoopRunning(),
|
||||
getActiveSessionCount: () => sandboxManager.getActiveSessionCount(),
|
||||
});
|
||||
|
||||
logger.info({
|
||||
event: "health.server.started",
|
||||
component: "index",
|
||||
message: "Health server started",
|
||||
host: env.HEALTH_HOST,
|
||||
port: env.HEALTH_PORT,
|
||||
});
|
||||
|
||||
// Register message handler
|
||||
const messageHandler = createMessageHandler(client, sandboxManager);
|
||||
client.on("messageCreate", messageHandler);
|
||||
|
||||
// Ready event
|
||||
client.on("clientReady", () => {
|
||||
logger.info({
|
||||
event: "discord.ready",
|
||||
component: "index",
|
||||
message: "Discord client ready",
|
||||
tag: client.user?.tag,
|
||||
allowedChannels: env.ALLOWED_CHANNEL_IDS,
|
||||
});
|
||||
sandboxManager.startCleanupLoop();
|
||||
});
|
||||
|
||||
// Login
|
||||
await client.login(env.DISCORD_TOKEN);
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async (signal: string) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
|
||||
logger.info({
|
||||
event: "app.shutdown.start",
|
||||
component: "index",
|
||||
message: "Shutting down",
|
||||
signal,
|
||||
});
|
||||
healthServer.stop();
|
||||
sandboxManager.stopCleanupLoop();
|
||||
await sandboxManager.destroyAll();
|
||||
client.destroy();
|
||||
logger.info({ event: "app.shutdown.complete", component: "index", message: "Shutdown complete" });
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
logger.error({
|
||||
event: "app.fatal",
|
||||
component: "index",
|
||||
message: "Fatal error",
|
||||
error: err,
|
||||
});
|
||||
process.exit(1);
|
||||
});
|
||||
76
packages/discord/src/observability/logger.ts
Normal file
76
packages/discord/src/observability/logger.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { getEnv } from "../config";
|
||||
|
||||
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
type LogFields = {
|
||||
event: string;
|
||||
message: string;
|
||||
component?: string;
|
||||
threadId?: string;
|
||||
channelId?: string;
|
||||
guildId?: string;
|
||||
sandboxId?: string;
|
||||
sessionId?: string;
|
||||
durationMs?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
const levelOrder: Record<LogLevel, number> = {
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40,
|
||||
};
|
||||
|
||||
function shouldLog(level: LogLevel): boolean {
|
||||
const env = getEnv();
|
||||
return levelOrder[level] >= levelOrder[env.LOG_LEVEL];
|
||||
}
|
||||
|
||||
function serializeError(err: unknown) {
|
||||
if (!(err instanceof Error)) return err;
|
||||
return {
|
||||
name: err.name,
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
};
|
||||
}
|
||||
|
||||
function write(level: LogLevel, fields: LogFields): void {
|
||||
if (!shouldLog(level)) return;
|
||||
|
||||
const env = getEnv();
|
||||
const payload = {
|
||||
ts: new Date().toISOString(),
|
||||
level,
|
||||
...fields,
|
||||
};
|
||||
|
||||
if (env.LOG_PRETTY) {
|
||||
const line = `[${payload.ts}] ${level.toUpperCase()} ${fields.event} ${fields.message}`;
|
||||
console.log(line, JSON.stringify(payload));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug(fields: LogFields) {
|
||||
write("debug", fields);
|
||||
},
|
||||
info(fields: LogFields) {
|
||||
write("info", fields);
|
||||
},
|
||||
warn(fields: LogFields) {
|
||||
write("warn", fields);
|
||||
},
|
||||
error(fields: LogFields & { error?: unknown }) {
|
||||
write("error", {
|
||||
...fields,
|
||||
error: fields.error ? serializeError(fields.error) : undefined,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export type { LogFields, LogLevel };
|
||||
15
packages/discord/src/sandbox/image.ts
Normal file
15
packages/discord/src/sandbox/image.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Image } from "@daytonaio/sdk";
|
||||
|
||||
/**
|
||||
* Custom Daytona sandbox image with git, gh CLI, opencode, and bun.
|
||||
* Cached by Daytona for 24h — subsequent creates are near-instant.
|
||||
*/
|
||||
export function getDiscordBotImage() {
|
||||
return Image.base("node:22-bookworm-slim")
|
||||
.runCommands(
|
||||
"apt-get update && apt-get install -y git curl && rm -rf /var/lib/apt/lists/*",
|
||||
"curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /usr/share/keyrings/githubcli-archive-keyring.gpg && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" > /etc/apt/sources.list.d/github-cli.list && apt-get update && apt-get install -y gh && rm -rf /var/lib/apt/lists/*",
|
||||
"npm install -g opencode-ai@latest bun",
|
||||
)
|
||||
.workdir("/home/daytona");
|
||||
}
|
||||
735
packages/discord/src/sandbox/manager.ts
Normal file
735
packages/discord/src/sandbox/manager.ts
Normal file
@@ -0,0 +1,735 @@
|
||||
import { Daytona } from "@daytonaio/sdk";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { getEnv } from "../config";
|
||||
import { logger } from "../observability/logger";
|
||||
import { getSessionStore } from "../sessions/store";
|
||||
import type { SessionInfo, SessionStatus } from "../types";
|
||||
import { getDiscordBotImage } from "./image";
|
||||
import { createSession, listSessions, sendPrompt, sessionExists, waitForHealthy } from "./opencode-client";
|
||||
|
||||
/** In-memory timeout handles keyed by threadId */
|
||||
const timeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
type ResumeAttemptResult = {
|
||||
session: SessionInfo | null;
|
||||
allowRecreate: boolean;
|
||||
};
|
||||
|
||||
function timer() {
|
||||
const start = Date.now();
|
||||
return {
|
||||
elapsedMs: () => Date.now() - start,
|
||||
};
|
||||
}
|
||||
|
||||
function createDaytona() {
|
||||
return new Daytona({
|
||||
apiKey: getEnv().DAYTONA_API_KEY,
|
||||
_experimental: {},
|
||||
});
|
||||
}
|
||||
|
||||
function isSandboxMissingError(error: unknown): boolean {
|
||||
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
||||
return message.includes("not found") || message.includes("does not exist") || message.includes("destroyed");
|
||||
}
|
||||
|
||||
async function exec(
|
||||
sandbox: {
|
||||
process: {
|
||||
executeCommand: (
|
||||
cmd: string,
|
||||
cwd?: string,
|
||||
env?: Record<string, string>,
|
||||
timeout?: number,
|
||||
) => Promise<{ exitCode: number; result: string }>;
|
||||
};
|
||||
},
|
||||
label: string,
|
||||
command: string,
|
||||
context: Pick<SessionInfo, "threadId" | "sandboxId">,
|
||||
options?: { cwd?: string; env?: Record<string, string> },
|
||||
): Promise<string> {
|
||||
const t = timer();
|
||||
const result = await sandbox.process.executeCommand(command, options?.cwd, options?.env);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
logger.error({
|
||||
event: "sandbox.exec.failed",
|
||||
component: "sandbox-manager",
|
||||
message: "Sandbox command failed",
|
||||
threadId: context.threadId,
|
||||
sandboxId: context.sandboxId,
|
||||
label,
|
||||
exitCode: result.exitCode,
|
||||
durationMs: t.elapsedMs(),
|
||||
stdout: result.result.slice(0, 500),
|
||||
});
|
||||
throw new Error(`${label} failed (exit ${result.exitCode})`);
|
||||
}
|
||||
|
||||
logger.debug({
|
||||
event: "sandbox.exec.ok",
|
||||
component: "sandbox-manager",
|
||||
message: "Sandbox command completed",
|
||||
threadId: context.threadId,
|
||||
sandboxId: context.sandboxId,
|
||||
label,
|
||||
durationMs: t.elapsedMs(),
|
||||
});
|
||||
|
||||
return result.result.trim();
|
||||
}
|
||||
|
||||
export class SandboxManager {
|
||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private readonly store = getSessionStore();
|
||||
private readonly threadLocks = new Map<string, Promise<void>>();
|
||||
|
||||
async getActiveSessionCount(): Promise<number> {
|
||||
return (await this.store.listActive()).length;
|
||||
}
|
||||
|
||||
isCleanupLoopRunning(): boolean {
|
||||
return this.cleanupInterval !== null;
|
||||
}
|
||||
|
||||
async hasTrackedThread(threadId: string): Promise<boolean> {
|
||||
return this.store.hasTrackedThread(threadId);
|
||||
}
|
||||
|
||||
async getTrackedSession(threadId: string): Promise<SessionInfo | null> {
|
||||
return this.store.getByThread(threadId);
|
||||
}
|
||||
|
||||
async getSession(threadId: string): Promise<SessionInfo | null> {
|
||||
return this.store.getActive(threadId);
|
||||
}
|
||||
|
||||
async resolveSessionForMessage(threadId: string, channelId: string, guildId: string): Promise<SessionInfo> {
|
||||
return this.withThreadLock(threadId, async () => {
|
||||
const existing = await this.store.getByThread(threadId);
|
||||
const env = getEnv();
|
||||
|
||||
if (!existing) {
|
||||
return this.createSessionUnlocked(threadId, channelId, guildId);
|
||||
}
|
||||
|
||||
let candidate = existing;
|
||||
|
||||
if (candidate.status === "active") {
|
||||
const healthy = await this.ensureSessionHealthy(candidate, 15_000);
|
||||
if (healthy) return candidate;
|
||||
candidate = (await this.store.getByThread(threadId)) ?? { ...candidate, status: "error" };
|
||||
}
|
||||
|
||||
if (env.SANDBOX_REUSE_POLICY === "resume_preferred") {
|
||||
const resumed = await this.tryResumeSession(candidate);
|
||||
if (resumed.session) return resumed.session;
|
||||
|
||||
if (!resumed.allowRecreate) {
|
||||
throw new Error("Unable to reattach to existing sandbox session. Try again shortly.");
|
||||
}
|
||||
}
|
||||
|
||||
return this.createSessionUnlocked(threadId, channelId, guildId);
|
||||
});
|
||||
}
|
||||
|
||||
async createSession(threadId: string, channelId: string, guildId: string): Promise<SessionInfo> {
|
||||
return this.withThreadLock(threadId, async () => this.createSessionUnlocked(threadId, channelId, guildId));
|
||||
}
|
||||
|
||||
private async createSessionUnlocked(threadId: string, channelId: string, guildId: string): Promise<SessionInfo> {
|
||||
const env = getEnv();
|
||||
const totalTimer = timer();
|
||||
|
||||
await this.store.updateStatus(threadId, "creating").catch(() => {});
|
||||
|
||||
const daytona = createDaytona();
|
||||
const image = getDiscordBotImage();
|
||||
const sandbox = await daytona.create(
|
||||
{
|
||||
image,
|
||||
labels: {
|
||||
app: "opencord",
|
||||
threadId,
|
||||
guildId,
|
||||
},
|
||||
autoStopInterval: 0,
|
||||
autoArchiveInterval: 0,
|
||||
},
|
||||
{ timeout: env.SANDBOX_CREATION_TIMEOUT },
|
||||
);
|
||||
|
||||
const sandboxId = sandbox.id;
|
||||
logger.info({
|
||||
event: "sandbox.create.started",
|
||||
component: "sandbox-manager",
|
||||
message: "Created sandbox",
|
||||
threadId,
|
||||
channelId,
|
||||
guildId,
|
||||
sandboxId,
|
||||
});
|
||||
|
||||
try {
|
||||
const context = { threadId, sandboxId };
|
||||
const home = await exec(sandbox, "discover-home", "echo $HOME", context);
|
||||
|
||||
await exec(
|
||||
sandbox,
|
||||
"clone-opencode",
|
||||
`git clone --depth=1 https://github.com/anomalyco/opencode.git ${home}/opencode`,
|
||||
context,
|
||||
);
|
||||
|
||||
const authJson = JSON.stringify({
|
||||
opencode: { type: "api", key: env.OPENCODE_ZEN_API_KEY },
|
||||
});
|
||||
|
||||
await exec(
|
||||
sandbox,
|
||||
"write-auth",
|
||||
`mkdir -p ${home}/.local/share/opencode && cat > ${home}/.local/share/opencode/auth.json << 'AUTHEOF'\n${authJson}\nAUTHEOF`,
|
||||
context,
|
||||
);
|
||||
|
||||
const agentPromptPath = new URL("../agent-prompt.md", import.meta.url);
|
||||
const agentPrompt = readFileSync(agentPromptPath, "utf-8");
|
||||
|
||||
const opencodeConfig = JSON.stringify({
|
||||
model: env.OPENCODE_MODEL,
|
||||
share: "disabled",
|
||||
permission: "allow",
|
||||
agent: {
|
||||
build: {
|
||||
mode: "primary",
|
||||
prompt: agentPrompt,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const configB64 = Buffer.from(opencodeConfig).toString("base64");
|
||||
await exec(
|
||||
sandbox,
|
||||
"write-config",
|
||||
`echo "${configB64}" | base64 -d > ${home}/opencode/opencode.json`,
|
||||
context,
|
||||
);
|
||||
|
||||
const opencodeEnv = this.buildRuntimeEnv();
|
||||
const githubToken = opencodeEnv.GITHUB_TOKEN ?? "";
|
||||
|
||||
logger.info({
|
||||
event: "sandbox.github.auth",
|
||||
component: "sandbox-manager",
|
||||
message: githubToken.length > 0
|
||||
? "Configured authenticated gh CLI in sandbox runtime"
|
||||
: "Running sandbox gh CLI unauthenticated (no GITHUB_TOKEN provided)",
|
||||
threadId,
|
||||
sandboxId,
|
||||
authenticated: githubToken.length > 0,
|
||||
});
|
||||
|
||||
await exec(
|
||||
sandbox,
|
||||
"start-opencode",
|
||||
"setsid opencode serve --port 4096 --hostname 0.0.0.0 > /tmp/opencode.log 2>&1 &",
|
||||
context,
|
||||
{
|
||||
cwd: `${home}/opencode`,
|
||||
env: opencodeEnv,
|
||||
},
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
|
||||
const preview = await sandbox.getPreviewLink(4096);
|
||||
const previewUrl = preview.url.replace(/\/$/, "");
|
||||
const previewToken = preview.token ?? null;
|
||||
|
||||
const healthy = await waitForHealthy({ previewUrl, previewToken }, 120_000);
|
||||
if (!healthy) {
|
||||
const startupLog = await exec(sandbox, "read-opencode-log", "cat /tmp/opencode.log 2>/dev/null | tail -100", context);
|
||||
throw new Error(`OpenCode server did not become healthy: ${startupLog.slice(0, 400)}`);
|
||||
}
|
||||
|
||||
const sessionId = await createSession({ previewUrl, previewToken }, `Discord thread ${threadId}`);
|
||||
|
||||
const session: SessionInfo = {
|
||||
threadId,
|
||||
channelId,
|
||||
guildId,
|
||||
sandboxId,
|
||||
sessionId,
|
||||
previewUrl,
|
||||
previewToken,
|
||||
status: "active",
|
||||
};
|
||||
|
||||
await this.store.upsert(session);
|
||||
await this.store.markHealthOk(threadId);
|
||||
this.resetTimeout(threadId);
|
||||
|
||||
logger.info({
|
||||
event: "sandbox.create.ready",
|
||||
component: "sandbox-manager",
|
||||
message: "Session is ready",
|
||||
threadId,
|
||||
channelId,
|
||||
guildId,
|
||||
sandboxId,
|
||||
sessionId,
|
||||
durationMs: totalTimer.elapsedMs(),
|
||||
});
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
event: "sandbox.create.failed",
|
||||
component: "sandbox-manager",
|
||||
message: "Failed to create session",
|
||||
threadId,
|
||||
channelId,
|
||||
guildId,
|
||||
sandboxId,
|
||||
durationMs: totalTimer.elapsedMs(),
|
||||
error,
|
||||
});
|
||||
|
||||
await this.store.updateStatus(threadId, "error", error instanceof Error ? error.message : String(error)).catch(() => {});
|
||||
await daytona.delete(sandbox).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(session: SessionInfo, text: string): Promise<string> {
|
||||
return this.withThreadLock(session.threadId, async () => {
|
||||
const t = timer();
|
||||
await this.store.markActivity(session.threadId);
|
||||
this.resetTimeout(session.threadId);
|
||||
|
||||
try {
|
||||
const response = await sendPrompt(
|
||||
{ previewUrl: session.previewUrl, previewToken: session.previewToken },
|
||||
session.sessionId,
|
||||
text,
|
||||
);
|
||||
|
||||
logger.info({
|
||||
event: "session.message.ok",
|
||||
component: "sandbox-manager",
|
||||
message: "Message processed",
|
||||
threadId: session.threadId,
|
||||
sandboxId: session.sandboxId,
|
||||
sessionId: session.sessionId,
|
||||
durationMs: t.elapsedMs(),
|
||||
responseChars: response.length,
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const sessionMissing = message.includes("Failed to send prompt (404");
|
||||
const recoverable =
|
||||
message.includes("no IP address found") ||
|
||||
message.includes("Is the Sandbox started") ||
|
||||
message.includes("sandbox not found") ||
|
||||
message.includes("Failed to send prompt (5") ||
|
||||
sessionMissing;
|
||||
|
||||
if (recoverable) {
|
||||
await this.store.incrementResumeFailure(session.threadId, message);
|
||||
if (sessionMissing) {
|
||||
await this.store.updateStatus(session.threadId, "error", "opencode-session-missing").catch(() => {});
|
||||
} else {
|
||||
await this.pauseSessionUnlocked(session.threadId, "recoverable send failure").catch(() => {});
|
||||
}
|
||||
const recoveryError = new Error("SANDBOX_DEAD");
|
||||
(recoveryError as any).recoverable = true;
|
||||
throw recoveryError;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async pauseSession(threadId: string, reason = "manual"): Promise<void> {
|
||||
await this.withThreadLock(threadId, async () => {
|
||||
await this.pauseSessionUnlocked(threadId, reason);
|
||||
});
|
||||
}
|
||||
|
||||
private async pauseSessionUnlocked(threadId: string, reason: string): Promise<void> {
|
||||
const session = await this.store.getByThread(threadId);
|
||||
if (!session) return;
|
||||
if (session.status === "paused") return;
|
||||
|
||||
await this.store.updateStatus(threadId, "pausing", reason);
|
||||
|
||||
try {
|
||||
const daytona = createDaytona();
|
||||
const sandbox = await daytona.get(session.sandboxId);
|
||||
await daytona.stop(sandbox);
|
||||
await this.store.updateStatus(threadId, "paused", null);
|
||||
this.clearTimeout(threadId);
|
||||
|
||||
logger.info({
|
||||
event: "sandbox.paused",
|
||||
component: "sandbox-manager",
|
||||
message: "Paused sandbox",
|
||||
threadId,
|
||||
sandboxId: session.sandboxId,
|
||||
reason,
|
||||
});
|
||||
} catch (error) {
|
||||
await this.store.updateStatus(threadId, "destroyed", error instanceof Error ? error.message : String(error));
|
||||
logger.warn({
|
||||
event: "sandbox.pause.missing",
|
||||
component: "sandbox-manager",
|
||||
message: "Sandbox unavailable while pausing; marked destroyed",
|
||||
threadId,
|
||||
sandboxId: session.sandboxId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async tryResumeSession(session: SessionInfo): Promise<ResumeAttemptResult> {
|
||||
if (!["paused", "destroyed", "error", "pausing", "resuming"].includes(session.status)) {
|
||||
return { session: null, allowRecreate: true };
|
||||
}
|
||||
|
||||
await this.store.updateStatus(session.threadId, "resuming");
|
||||
|
||||
const daytona = createDaytona();
|
||||
let sandbox: Awaited<ReturnType<typeof daytona.get>>;
|
||||
|
||||
try {
|
||||
sandbox = await daytona.get(session.sandboxId);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
await this.store.incrementResumeFailure(session.threadId, errorMessage).catch(() => {});
|
||||
await this.store.updateStatus(session.threadId, "destroyed", errorMessage).catch(() => {});
|
||||
|
||||
logger.warn({
|
||||
event: "sandbox.resume.sandbox_missing",
|
||||
component: "sandbox-manager",
|
||||
message: "Sandbox missing during resume; safe to recreate",
|
||||
threadId: session.threadId,
|
||||
sandboxId: session.sandboxId,
|
||||
errorMessage,
|
||||
});
|
||||
|
||||
return { session: null, allowRecreate: true };
|
||||
}
|
||||
|
||||
try {
|
||||
await daytona.start(sandbox, getEnv().SANDBOX_CREATION_TIMEOUT);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
await this.store.incrementResumeFailure(session.threadId, errorMessage).catch(() => {});
|
||||
await this.store.updateStatus(session.threadId, "error", errorMessage).catch(() => {});
|
||||
|
||||
const allowRecreate = isSandboxMissingError(error);
|
||||
logger.warn({
|
||||
event: "sandbox.resume.start_failed",
|
||||
component: "sandbox-manager",
|
||||
message: allowRecreate
|
||||
? "Sandbox no longer exists while starting; safe to recreate"
|
||||
: "Sandbox start failed; refusing automatic recreate to avoid context loss",
|
||||
threadId: session.threadId,
|
||||
sandboxId: session.sandboxId,
|
||||
errorMessage,
|
||||
allowRecreate,
|
||||
});
|
||||
|
||||
return { session: null, allowRecreate };
|
||||
}
|
||||
|
||||
try {
|
||||
const preview = await sandbox.getPreviewLink(4096);
|
||||
const previewUrl = preview.url.replace(/\/$/, "");
|
||||
const previewToken = preview.token ?? null;
|
||||
|
||||
const context = { threadId: session.threadId, sandboxId: session.sandboxId };
|
||||
|
||||
logger.info({
|
||||
event: "sandbox.resume.restarting_opencode",
|
||||
component: "sandbox-manager",
|
||||
message: "Restarting opencode serve after sandbox start",
|
||||
threadId: session.threadId,
|
||||
sandboxId: session.sandboxId,
|
||||
});
|
||||
|
||||
await exec(
|
||||
sandbox,
|
||||
"restart-opencode-serve",
|
||||
"pkill -f 'opencode serve --port 4096' >/dev/null 2>&1 || true; for d in \"$HOME/opencode\" \"/home/daytona/opencode\" \"/root/opencode\"; do if [ -d \"$d\" ]; then cd \"$d\" && setsid opencode serve --port 4096 --hostname 0.0.0.0 > /tmp/opencode.log 2>&1 & exit 0; fi; done; exit 1",
|
||||
context,
|
||||
{ env: this.buildRuntimeEnv() },
|
||||
);
|
||||
|
||||
const healthy = await waitForHealthy(
|
||||
{ previewUrl, previewToken },
|
||||
getEnv().RESUME_HEALTH_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (!healthy) {
|
||||
const startupLog = await exec(
|
||||
sandbox,
|
||||
"read-opencode-log-after-resume",
|
||||
"cat /tmp/opencode.log 2>/dev/null | tail -120",
|
||||
context,
|
||||
).catch(() => "(unable to read opencode log)");
|
||||
|
||||
const errorMessage = `OpenCode health check failed after resume. Log: ${startupLog.slice(0, 500)}`;
|
||||
await this.store.incrementResumeFailure(session.threadId, errorMessage).catch(() => {});
|
||||
await this.store.updateStatus(session.threadId, "error", errorMessage).catch(() => {});
|
||||
|
||||
logger.error({
|
||||
event: "sandbox.resume.health_failed",
|
||||
component: "sandbox-manager",
|
||||
message: "OpenCode did not become healthy after restart; refusing recreate",
|
||||
threadId: session.threadId,
|
||||
sandboxId: session.sandboxId,
|
||||
errorMessage,
|
||||
});
|
||||
|
||||
return { session: null, allowRecreate: false };
|
||||
}
|
||||
|
||||
let sessionId = session.sessionId;
|
||||
const existingSession = await sessionExists({ previewUrl, previewToken }, sessionId);
|
||||
if (!existingSession) {
|
||||
const expectedTitle = `Discord thread ${session.threadId}`;
|
||||
const sessions = await listSessions({ previewUrl, previewToken }, 50).catch(() => []);
|
||||
|
||||
const replacement = sessions
|
||||
.filter((candidate) => candidate.title === expectedTitle)
|
||||
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];
|
||||
|
||||
if (replacement) {
|
||||
sessionId = replacement.id;
|
||||
logger.info({
|
||||
event: "sandbox.resume.session_reused_by_title",
|
||||
component: "sandbox-manager",
|
||||
message: "Reattached to existing session by title",
|
||||
threadId: session.threadId,
|
||||
sandboxId: session.sandboxId,
|
||||
previousSessionId: session.sessionId,
|
||||
sessionId,
|
||||
});
|
||||
} else {
|
||||
logger.warn({
|
||||
event: "sandbox.resume.session_missing",
|
||||
component: "sandbox-manager",
|
||||
message: "OpenCode session missing after resume; creating replacement session",
|
||||
threadId: session.threadId,
|
||||
sandboxId: session.sandboxId,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
sessionId = await createSession({ previewUrl, previewToken }, expectedTitle);
|
||||
}
|
||||
}
|
||||
|
||||
const resumed: SessionInfo = {
|
||||
...session,
|
||||
sessionId,
|
||||
previewUrl,
|
||||
previewToken,
|
||||
status: "active",
|
||||
};
|
||||
|
||||
await this.store.upsert(resumed);
|
||||
await this.store.markHealthOk(session.threadId);
|
||||
this.resetTimeout(session.threadId);
|
||||
|
||||
logger.info({
|
||||
event: "sandbox.resumed",
|
||||
component: "sandbox-manager",
|
||||
message: "Resumed existing sandbox",
|
||||
threadId: session.threadId,
|
||||
sandboxId: session.sandboxId,
|
||||
previousSessionId: session.sessionId,
|
||||
sessionId,
|
||||
sessionReattached: sessionId === session.sessionId,
|
||||
});
|
||||
|
||||
return { session: resumed, allowRecreate: false };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
await this.store.incrementResumeFailure(session.threadId, errorMessage).catch(() => {});
|
||||
await this.store.updateStatus(session.threadId, "error", errorMessage).catch(() => {});
|
||||
|
||||
logger.warn({
|
||||
event: "sandbox.resume.failed",
|
||||
component: "sandbox-manager",
|
||||
message: "Resume failed after sandbox start; refusing automatic recreate",
|
||||
threadId: session.threadId,
|
||||
sandboxId: session.sandboxId,
|
||||
errorMessage,
|
||||
});
|
||||
|
||||
return { session: null, allowRecreate: false };
|
||||
}
|
||||
}
|
||||
|
||||
async destroySession(threadId: string): Promise<void> {
|
||||
await this.withThreadLock(threadId, async () => {
|
||||
const session = await this.store.getByThread(threadId);
|
||||
if (!session) return;
|
||||
|
||||
await this.store.updateStatus(threadId, "destroying");
|
||||
|
||||
try {
|
||||
const daytona = createDaytona();
|
||||
const sandbox = await daytona.get(session.sandboxId);
|
||||
await daytona.delete(sandbox);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
|
||||
await this.store.updateStatus(threadId, "destroyed");
|
||||
this.clearTimeout(threadId);
|
||||
});
|
||||
}
|
||||
|
||||
resetTimeout(threadId: string): void {
|
||||
this.clearTimeout(threadId);
|
||||
const timeoutMs = getEnv().SANDBOX_TIMEOUT_MINUTES * 60 * 1000;
|
||||
|
||||
const handle = setTimeout(async () => {
|
||||
timeouts.delete(threadId);
|
||||
await this.pauseSession(threadId, "inactivity-timeout").catch((error) => {
|
||||
logger.error({
|
||||
event: "sandbox.pause.timeout.failed",
|
||||
component: "sandbox-manager",
|
||||
message: "Failed to pause sandbox on inactivity timeout",
|
||||
threadId,
|
||||
error,
|
||||
});
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
timeouts.set(threadId, handle);
|
||||
}
|
||||
|
||||
private clearTimeout(threadId: string): void {
|
||||
const existing = timeouts.get(threadId);
|
||||
if (!existing) return;
|
||||
clearTimeout(existing);
|
||||
timeouts.delete(threadId);
|
||||
}
|
||||
|
||||
startCleanupLoop(): void {
|
||||
const intervalMs = 5 * 60 * 1000;
|
||||
|
||||
this.cleanupInterval = setInterval(async () => {
|
||||
try {
|
||||
const env = getEnv();
|
||||
const staleActive = await this.store.listStaleActive(env.SANDBOX_TIMEOUT_MINUTES + 5);
|
||||
|
||||
for (const session of staleActive) {
|
||||
await this.pauseSession(session.threadId, "cleanup-stale-active");
|
||||
}
|
||||
|
||||
const expiredPaused = await this.store.listExpiredPaused(env.PAUSED_TTL_MINUTES);
|
||||
for (const session of expiredPaused) {
|
||||
await this.destroySession(session.threadId);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
event: "cleanup.loop.failed",
|
||||
component: "sandbox-manager",
|
||||
message: "Cleanup loop failed",
|
||||
error,
|
||||
});
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
logger.info({
|
||||
event: "cleanup.loop.started",
|
||||
component: "sandbox-manager",
|
||||
message: "Started cleanup loop",
|
||||
intervalMs,
|
||||
timeoutMinutes: getEnv().SANDBOX_TIMEOUT_MINUTES,
|
||||
pausedTtlMinutes: getEnv().PAUSED_TTL_MINUTES,
|
||||
});
|
||||
}
|
||||
|
||||
stopCleanupLoop(): void {
|
||||
if (!this.cleanupInterval) return;
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
|
||||
async destroyAll(): Promise<void> {
|
||||
const active = await this.store.listActive();
|
||||
await Promise.allSettled(active.map((session) => this.pauseSession(session.threadId, "shutdown")));
|
||||
}
|
||||
|
||||
private buildRuntimeEnv(): Record<string, string> {
|
||||
const env = getEnv();
|
||||
const runtimeEnv: Record<string, string> = {};
|
||||
const githubToken = env.GITHUB_TOKEN.trim();
|
||||
|
||||
if (githubToken.length > 0) {
|
||||
runtimeEnv.GH_TOKEN = githubToken;
|
||||
runtimeEnv.GITHUB_TOKEN = githubToken;
|
||||
}
|
||||
|
||||
return runtimeEnv;
|
||||
}
|
||||
|
||||
private async ensureSessionHealthy(session: SessionInfo, maxWaitMs: number): Promise<boolean> {
|
||||
const healthy = await waitForHealthy(
|
||||
{ previewUrl: session.previewUrl, previewToken: session.previewToken },
|
||||
maxWaitMs,
|
||||
);
|
||||
|
||||
if (!healthy) {
|
||||
await this.store.incrementResumeFailure(session.threadId, "active-session-healthcheck-failed").catch(() => {});
|
||||
await this.store.updateStatus(session.threadId, "error", "active-session-healthcheck-failed").catch(() => {});
|
||||
return false;
|
||||
}
|
||||
|
||||
const attached = await sessionExists(
|
||||
{ previewUrl: session.previewUrl, previewToken: session.previewToken },
|
||||
session.sessionId,
|
||||
).catch(() => false);
|
||||
|
||||
if (!attached) {
|
||||
await this.store.incrementResumeFailure(session.threadId, "active-session-missing").catch(() => {});
|
||||
await this.store.updateStatus(session.threadId, "error", "active-session-missing").catch(() => {});
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.store.markHealthOk(session.threadId).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
private async withThreadLock<T>(threadId: string, fn: () => Promise<T>): Promise<T> {
|
||||
const previous = this.threadLocks.get(threadId) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const current = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
this.threadLocks.set(threadId, previous.then(() => current));
|
||||
await previous;
|
||||
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
if (this.threadLocks.get(threadId) === current) {
|
||||
this.threadLocks.delete(threadId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type { SessionStatus };
|
||||
198
packages/discord/src/sandbox/opencode-client.ts
Normal file
198
packages/discord/src/sandbox/opencode-client.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { Effect, Schedule } from "effect";
|
||||
import { logger } from "../observability/logger";
|
||||
|
||||
type PreviewAccess = {
|
||||
previewUrl: string;
|
||||
previewToken?: string | null;
|
||||
};
|
||||
|
||||
export type OpenCodeSessionSummary = {
|
||||
id: string;
|
||||
title: string;
|
||||
updatedAt?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a Daytona preview URL into base URL and token.
|
||||
* Preview URLs look like: https://4096-xxx.proxy.daytona.works?tkn=abc123
|
||||
*/
|
||||
function parsePreview(input: string | PreviewAccess): { base: string; token: string | null } {
|
||||
const previewUrl = typeof input === "string" ? input : input.previewUrl;
|
||||
const url = new URL(previewUrl);
|
||||
const token = typeof input === "string"
|
||||
? url.searchParams.get("tkn")
|
||||
: (input.previewToken ?? url.searchParams.get("tkn"));
|
||||
url.searchParams.delete("tkn");
|
||||
return { base: url.toString().replace(/\/$/, ""), token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch wrapper that properly handles Daytona preview URL token auth.
|
||||
* Sends token as x-daytona-preview-token header.
|
||||
*/
|
||||
async function previewFetch(preview: string | PreviewAccess, path: string, init?: RequestInit): Promise<Response> {
|
||||
const { base, token } = parsePreview(preview);
|
||||
const url = `${base}${path}`;
|
||||
const headers = new Headers(init?.headers);
|
||||
if (token) {
|
||||
headers.set("x-daytona-preview-token", token);
|
||||
}
|
||||
return fetch(url, { ...init, headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the OpenCode server inside a sandbox to become healthy.
|
||||
* Polls GET /global/health every 2s up to maxWaitMs.
|
||||
*/
|
||||
export async function waitForHealthy(preview: string | PreviewAccess, maxWaitMs = 120_000): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
let lastStatus = "";
|
||||
|
||||
const poll = Effect.tryPromise(async () => {
|
||||
const res = await previewFetch(preview, "/global/health");
|
||||
lastStatus = `${res.status}`;
|
||||
|
||||
if (res.ok) {
|
||||
const body = await res.json() as { healthy?: boolean };
|
||||
if (body.healthy) return true;
|
||||
lastStatus = `200 but healthy=${body.healthy}`;
|
||||
throw new Error(lastStatus);
|
||||
}
|
||||
|
||||
const body = await res.text().catch(() => "");
|
||||
lastStatus = `${res.status}: ${body.slice(0, 150)}`;
|
||||
throw new Error(lastStatus);
|
||||
}).pipe(
|
||||
Effect.tapError(() =>
|
||||
Effect.sync(() => {
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(0);
|
||||
logger.warn({
|
||||
event: "opencode.health.poll",
|
||||
component: "opencode-client",
|
||||
message: "Health check poll failed",
|
||||
elapsedSec: Number(elapsed),
|
||||
lastStatus,
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const maxAttempts = Math.max(1, Math.ceil(maxWaitMs / 2000));
|
||||
|
||||
return Effect.runPromise(
|
||||
poll.pipe(
|
||||
Effect.retry(
|
||||
Schedule.intersect(
|
||||
Schedule.spaced("2 seconds"),
|
||||
Schedule.recurs(maxAttempts - 1),
|
||||
),
|
||||
),
|
||||
Effect.as(true),
|
||||
Effect.catchAll(() =>
|
||||
Effect.sync(() => {
|
||||
logger.error({
|
||||
event: "opencode.health.failed",
|
||||
component: "opencode-client",
|
||||
message: "Health check failed",
|
||||
maxWaitMs,
|
||||
lastStatus,
|
||||
});
|
||||
return false;
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new OpenCode session and returns the session ID.
|
||||
*/
|
||||
export async function createSession(preview: string | PreviewAccess, title: string): Promise<string> {
|
||||
const res = await previewFetch(preview, "/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`Failed to create session (${res.status}): ${body}`);
|
||||
}
|
||||
|
||||
const session = await res.json() as { id: string };
|
||||
return session.id;
|
||||
}
|
||||
|
||||
export async function sessionExists(preview: string | PreviewAccess, sessionId: string): Promise<boolean> {
|
||||
const res = await previewFetch(preview, `/session/${sessionId}`, {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (res.ok) return true;
|
||||
if (res.status === 404) return false;
|
||||
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`Failed to check session (${res.status}): ${body}`);
|
||||
}
|
||||
|
||||
export async function listSessions(preview: string | PreviewAccess, limit = 50): Promise<OpenCodeSessionSummary[]> {
|
||||
const query = limit > 0 ? `?limit=${limit}` : "";
|
||||
const res = await previewFetch(preview, `/session${query}`, {
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`Failed to list sessions (${res.status}): ${body}`);
|
||||
}
|
||||
|
||||
const sessions = await res.json() as Array<{
|
||||
id?: string;
|
||||
title?: string;
|
||||
time?: { updated?: number };
|
||||
}>;
|
||||
|
||||
return sessions
|
||||
.filter((session) => typeof session.id === "string")
|
||||
.map((session) => ({
|
||||
id: session.id as string,
|
||||
title: session.title ?? "",
|
||||
updatedAt: session.time?.updated,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a prompt to an existing session and returns the text response.
|
||||
* This call blocks until the agent finishes processing.
|
||||
*/
|
||||
export async function sendPrompt(preview: string | PreviewAccess, sessionId: string, text: string): Promise<string> {
|
||||
const res = await previewFetch(preview, `/session/${sessionId}/message`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
parts: [{ type: "text", text }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`Failed to send prompt (${res.status}): ${body}`);
|
||||
}
|
||||
|
||||
const result = await res.json() as { parts?: Array<{ type: string; text?: string; content?: string }> };
|
||||
const parts = result.parts ?? [];
|
||||
|
||||
const textContent = parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text || p.content || "")
|
||||
.filter(Boolean);
|
||||
|
||||
return textContent.join("\n\n") || "(No response from agent)";
|
||||
}
|
||||
|
||||
/**
|
||||
* Aborts a running session.
|
||||
*/
|
||||
export async function abortSession(preview: string | PreviewAccess, sessionId: string): Promise<void> {
|
||||
await previewFetch(preview, `/session/${sessionId}/abort`, { method: "POST" }).catch(() => {});
|
||||
}
|
||||
321
packages/discord/src/sessions/store.ts
Normal file
321
packages/discord/src/sessions/store.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
import { getDb } from "../db/client"
|
||||
import type { SessionInfo, SessionStatus } from "../types"
|
||||
|
||||
type SessionRow = {
|
||||
thread_id: string
|
||||
channel_id: string
|
||||
guild_id: string
|
||||
sandbox_id: string
|
||||
session_id: string
|
||||
preview_url: string
|
||||
preview_token: string | null
|
||||
status: SessionStatus
|
||||
last_error: string | null
|
||||
resume_fail_count: number
|
||||
}
|
||||
|
||||
export interface SessionStore {
|
||||
upsert(session: SessionInfo): Promise<void>
|
||||
getByThread(threadId: string): Promise<SessionInfo | null>
|
||||
hasTrackedThread(threadId: string): Promise<boolean>
|
||||
getActive(threadId: string): Promise<SessionInfo | null>
|
||||
markActivity(threadId: string): Promise<void>
|
||||
markHealthOk(threadId: string): Promise<void>
|
||||
updateStatus(threadId: string, status: SessionStatus, lastError?: string | null): Promise<void>
|
||||
incrementResumeFailure(threadId: string, lastError: string): Promise<void>
|
||||
listActive(): Promise<SessionInfo[]>
|
||||
listStaleActive(cutoffMinutes: number): Promise<SessionInfo[]>
|
||||
listExpiredPaused(pausedTtlMinutes: number): Promise<SessionInfo[]>
|
||||
}
|
||||
|
||||
class SqliteSessionStore implements SessionStore {
|
||||
private readonly db = getDb()
|
||||
|
||||
async upsert(session: SessionInfo): Promise<void> {
|
||||
this.db
|
||||
.query(
|
||||
`
|
||||
INSERT INTO discord_sessions (
|
||||
thread_id,
|
||||
channel_id,
|
||||
guild_id,
|
||||
sandbox_id,
|
||||
session_id,
|
||||
preview_url,
|
||||
preview_token,
|
||||
status,
|
||||
last_error,
|
||||
last_activity,
|
||||
resumed_at,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
CURRENT_TIMESTAMP,
|
||||
CASE WHEN ? = 'active' THEN CURRENT_TIMESTAMP ELSE NULL END,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
ON CONFLICT(thread_id)
|
||||
DO UPDATE SET
|
||||
channel_id = excluded.channel_id,
|
||||
guild_id = excluded.guild_id,
|
||||
sandbox_id = excluded.sandbox_id,
|
||||
session_id = excluded.session_id,
|
||||
preview_url = excluded.preview_url,
|
||||
preview_token = excluded.preview_token,
|
||||
status = excluded.status,
|
||||
last_error = excluded.last_error,
|
||||
last_activity = CURRENT_TIMESTAMP,
|
||||
resumed_at = CASE WHEN excluded.status = 'active' THEN CURRENT_TIMESTAMP ELSE discord_sessions.resumed_at END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`,
|
||||
)
|
||||
.run(
|
||||
session.threadId,
|
||||
session.channelId,
|
||||
session.guildId,
|
||||
session.sandboxId,
|
||||
session.sessionId,
|
||||
session.previewUrl,
|
||||
session.previewToken,
|
||||
session.status,
|
||||
session.lastError ?? null,
|
||||
session.status,
|
||||
)
|
||||
}
|
||||
|
||||
async getByThread(threadId: string): Promise<SessionInfo | null> {
|
||||
const row = this.db
|
||||
.query(
|
||||
`
|
||||
SELECT
|
||||
thread_id,
|
||||
channel_id,
|
||||
guild_id,
|
||||
sandbox_id,
|
||||
session_id,
|
||||
preview_url,
|
||||
preview_token,
|
||||
status,
|
||||
last_error,
|
||||
resume_fail_count
|
||||
FROM discord_sessions
|
||||
WHERE thread_id = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(threadId) as SessionRow | null
|
||||
|
||||
if (!row) return null
|
||||
return toSessionInfo(row)
|
||||
}
|
||||
|
||||
async hasTrackedThread(threadId: string): Promise<boolean> {
|
||||
const row = this.db
|
||||
.query(
|
||||
`
|
||||
SELECT thread_id
|
||||
FROM discord_sessions
|
||||
WHERE thread_id = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(threadId) as { thread_id: string } | null
|
||||
|
||||
return Boolean(row)
|
||||
}
|
||||
|
||||
async getActive(threadId: string): Promise<SessionInfo | null> {
|
||||
const row = this.db
|
||||
.query(
|
||||
`
|
||||
SELECT
|
||||
thread_id,
|
||||
channel_id,
|
||||
guild_id,
|
||||
sandbox_id,
|
||||
session_id,
|
||||
preview_url,
|
||||
preview_token,
|
||||
status,
|
||||
last_error,
|
||||
resume_fail_count
|
||||
FROM discord_sessions
|
||||
WHERE thread_id = ?
|
||||
AND status = 'active'
|
||||
LIMIT 1
|
||||
`,
|
||||
)
|
||||
.get(threadId) as SessionRow | null
|
||||
|
||||
if (!row) return null
|
||||
return toSessionInfo(row)
|
||||
}
|
||||
|
||||
async markActivity(threadId: string): Promise<void> {
|
||||
this.db
|
||||
.query(
|
||||
`
|
||||
UPDATE discord_sessions
|
||||
SET last_activity = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE thread_id = ?
|
||||
`,
|
||||
)
|
||||
.run(threadId)
|
||||
}
|
||||
|
||||
async markHealthOk(threadId: string): Promise<void> {
|
||||
this.db
|
||||
.query(
|
||||
`
|
||||
UPDATE discord_sessions
|
||||
SET last_health_ok_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE thread_id = ?
|
||||
`,
|
||||
)
|
||||
.run(threadId)
|
||||
}
|
||||
|
||||
async updateStatus(threadId: string, status: SessionStatus, lastError?: string | null): Promise<void> {
|
||||
this.db
|
||||
.query(
|
||||
`
|
||||
UPDATE discord_sessions
|
||||
SET
|
||||
status = ?,
|
||||
last_error = ?,
|
||||
pause_requested_at = CASE WHEN ? = 'pausing' THEN CURRENT_TIMESTAMP ELSE pause_requested_at END,
|
||||
paused_at = CASE WHEN ? = 'paused' THEN CURRENT_TIMESTAMP ELSE paused_at END,
|
||||
resume_attempted_at = CASE WHEN ? = 'resuming' THEN CURRENT_TIMESTAMP ELSE resume_attempted_at END,
|
||||
resumed_at = CASE WHEN ? = 'active' THEN CURRENT_TIMESTAMP ELSE resumed_at END,
|
||||
destroyed_at = CASE WHEN ? = 'destroyed' THEN CURRENT_TIMESTAMP ELSE destroyed_at END,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE thread_id = ?
|
||||
`,
|
||||
)
|
||||
.run(status, lastError ?? null, status, status, status, status, status, threadId)
|
||||
}
|
||||
|
||||
async incrementResumeFailure(threadId: string, lastError: string): Promise<void> {
|
||||
this.db
|
||||
.query(
|
||||
`
|
||||
UPDATE discord_sessions
|
||||
SET
|
||||
resume_fail_count = resume_fail_count + 1,
|
||||
last_error = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE thread_id = ?
|
||||
`,
|
||||
)
|
||||
.run(lastError, threadId)
|
||||
}
|
||||
|
||||
async listActive(): Promise<SessionInfo[]> {
|
||||
const rows = this.db
|
||||
.query(
|
||||
`
|
||||
SELECT
|
||||
thread_id,
|
||||
channel_id,
|
||||
guild_id,
|
||||
sandbox_id,
|
||||
session_id,
|
||||
preview_url,
|
||||
preview_token,
|
||||
status,
|
||||
last_error,
|
||||
resume_fail_count
|
||||
FROM discord_sessions
|
||||
WHERE status = 'active'
|
||||
ORDER BY last_activity DESC
|
||||
`,
|
||||
)
|
||||
.all() as SessionRow[]
|
||||
|
||||
return rows.map(toSessionInfo)
|
||||
}
|
||||
|
||||
async listStaleActive(cutoffMinutes: number): Promise<SessionInfo[]> {
|
||||
const rows = this.db
|
||||
.query(
|
||||
`
|
||||
SELECT
|
||||
thread_id,
|
||||
channel_id,
|
||||
guild_id,
|
||||
sandbox_id,
|
||||
session_id,
|
||||
preview_url,
|
||||
preview_token,
|
||||
status,
|
||||
last_error,
|
||||
resume_fail_count
|
||||
FROM discord_sessions
|
||||
WHERE status = 'active'
|
||||
AND last_activity < datetime('now', '-' || ? || ' minutes')
|
||||
ORDER BY last_activity ASC
|
||||
`,
|
||||
)
|
||||
.all(cutoffMinutes) as SessionRow[]
|
||||
|
||||
return rows.map(toSessionInfo)
|
||||
}
|
||||
|
||||
async listExpiredPaused(pausedTtlMinutes: number): Promise<SessionInfo[]> {
|
||||
const rows = this.db
|
||||
.query(
|
||||
`
|
||||
SELECT
|
||||
thread_id,
|
||||
channel_id,
|
||||
guild_id,
|
||||
sandbox_id,
|
||||
session_id,
|
||||
preview_url,
|
||||
preview_token,
|
||||
status,
|
||||
last_error,
|
||||
resume_fail_count
|
||||
FROM discord_sessions
|
||||
WHERE status = 'paused'
|
||||
AND paused_at IS NOT NULL
|
||||
AND paused_at < datetime('now', '-' || ? || ' minutes')
|
||||
ORDER BY paused_at ASC
|
||||
`,
|
||||
)
|
||||
.all(pausedTtlMinutes) as SessionRow[]
|
||||
|
||||
return rows.map(toSessionInfo)
|
||||
}
|
||||
}
|
||||
|
||||
function toSessionInfo(row: SessionRow): SessionInfo {
|
||||
return {
|
||||
threadId: row.thread_id,
|
||||
channelId: row.channel_id,
|
||||
guildId: row.guild_id,
|
||||
sandboxId: row.sandbox_id,
|
||||
sessionId: row.session_id,
|
||||
previewUrl: row.preview_url,
|
||||
previewToken: row.preview_token,
|
||||
status: row.status,
|
||||
lastError: row.last_error,
|
||||
resumeFailCount: row.resume_fail_count,
|
||||
}
|
||||
}
|
||||
|
||||
const sessionStore: SessionStore = new SqliteSessionStore()
|
||||
|
||||
export function getSessionStore(): SessionStore {
|
||||
return sessionStore
|
||||
}
|
||||
22
packages/discord/src/types.ts
Normal file
22
packages/discord/src/types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type SessionStatus =
|
||||
| "creating"
|
||||
| "active"
|
||||
| "pausing"
|
||||
| "paused"
|
||||
| "resuming"
|
||||
| "destroying"
|
||||
| "destroyed"
|
||||
| "error";
|
||||
|
||||
export interface SessionInfo {
|
||||
threadId: string;
|
||||
channelId: string;
|
||||
guildId: string;
|
||||
sandboxId: string;
|
||||
sessionId: string;
|
||||
previewUrl: string;
|
||||
previewToken: string | null;
|
||||
status: SessionStatus;
|
||||
lastError?: string | null;
|
||||
resumeFailCount?: number;
|
||||
}
|
||||
18
packages/discord/tsconfig.json
Normal file
18
packages/discord/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"types": ["bun-types", "node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user