feat(09-03): serve the unified AI bot-setup prompt at GET /api/docs/prompt
CI / check (push) Has been cancelled
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user