feat(09-03): serve the unified AI bot-setup prompt at GET /api/docs/prompt
CI / check (push) Has been cancelled

- New docsRouter.get('/prompt') resolves the shipped container path
  (server/public/docs/BOTFIGHTS.md) then falls back to the dev-checkout
  path (frontend/public/docs/BOTFIGHTS.md), matching app.ts's publicDir
  derivation pattern
- Substitutes {{ARENA_URL}} with PUBLIC_ARENA_URL when set, otherwise the
  request's own origin, so a cloud agent that curls the prompt gets
  working examples pointed back at the arena it fetched from
- Responds as text/markdown so an agent can pipe the response straight
  into its context
- 5 new Vitest cases: 200+content-type, no leftover {{ARENA_URL}} token,
  PUBLIC_ARENA_URL precedence, origin fallback, YOUR_BOT_ID placeholder
  preserved for the in-app substitution flow
This commit is contained in:
Dorian
2026-07-30 22:23:20 -04:00
parent bf240cef9e
commit a0809565f2
2 changed files with 83 additions and 1 deletions
+48 -1
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, afterEach } from 'vitest'
import { Hono } from 'hono'
import { docsRouter } from './docs.js'
@@ -38,3 +38,50 @@ describe('docs webhook tester', () => {
expect(res.status).toBe(400)
})
})
describe('GET /api/docs/prompt', () => {
const ORIGINAL_PUBLIC_ARENA_URL = process.env.PUBLIC_ARENA_URL
afterEach(() => {
if (ORIGINAL_PUBLIC_ARENA_URL === undefined) {
delete process.env.PUBLIC_ARENA_URL
} else {
process.env.PUBLIC_ARENA_URL = ORIGINAL_PUBLIC_ARENA_URL
}
})
it('returns 200 with text/markdown containing the registration endpoint', async () => {
const res = await app.request('/api/docs/prompt')
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('text/markdown')
const body = await res.text()
expect(body).toContain('POST')
expect(body).toContain('/api/bots')
})
it('leaves no unsubstituted {{ARENA_URL}} token in the response body', async () => {
const res = await app.request('/api/docs/prompt')
const body = await res.text()
expect(body).not.toContain('{{ARENA_URL}}')
})
it('uses PUBLIC_ARENA_URL when set', async () => {
process.env.PUBLIC_ARENA_URL = 'https://botfights.archipelago-foundation.org'
const res = await app.request('/api/docs/prompt')
const body = await res.text()
expect(body).toContain('https://botfights.archipelago-foundation.org')
})
it('falls back to the request origin when PUBLIC_ARENA_URL is unset', async () => {
delete process.env.PUBLIC_ARENA_URL
const res = await app.request('http://test-origin.example/api/docs/prompt')
const body = await res.text()
expect(body).toContain('http://test-origin.example')
})
it('preserves the YOUR_BOT_ID in-app substitution placeholder', async () => {
const res = await app.request('/api/docs/prompt')
const body = await res.text()
expect(body).toContain('YOUR_BOT_ID')
})
})
+35
View File
@@ -1,8 +1,43 @@
import { Hono } from 'hono'
import { existsSync, readFileSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import { getAllChallengeTypes } from '../engine/challenges.js'
import { testWebhookSchema } from '../lib/validators.js'
export const docsRouter = new Hono()
// The unified AI bot-setup prompt (BOT-02). Try the shipped container layout
// first (server/public/docs/BOTFIGHTS.md, populated by the frontend build +
// Dockerfile's `COPY frontend/dist server/public`), then fall back to a dev
// checkout where the frontend hasn't been built yet.
const __dirname = dirname(fileURLToPath(import.meta.url))
const PROMPT_PATHS = [
join(__dirname, '..', '..', 'public', 'docs', 'BOTFIGHTS.md'),
join(__dirname, '..', '..', '..', 'frontend', 'public', 'docs', 'BOTFIGHTS.md'),
]
// GET /prompt — the complete, self-contained AI bot-setup prompt as plain
// markdown, with {{ARENA_URL}} resolved to the real arena origin so an agent
// can curl this and get working examples with no further substitution.
docsRouter.get('/prompt', (c) => {
let content: string | null = null
for (const p of PROMPT_PATHS) {
if (existsSync(p)) {
content = readFileSync(p, 'utf-8')
break
}
}
if (content === null) {
return c.json({ error: 'Prompt not available.' }, 404)
}
const arenaUrl = process.env.PUBLIC_ARENA_URL || new URL(c.req.url).origin
const substituted = content.replaceAll('{{ARENA_URL}}', arenaUrl)
c.header('Content-Type', 'text/markdown; charset=utf-8')
return c.body(substituted)
})
docsRouter.get('/webhook', (c) => {
return c.json({
title: 'BOTFIGHTS Webhook API',