2026-03-06 22:13:19 +00:00
|
|
|
import { Hono } from 'hono'
|
|
|
|
|
import { db, schema } from '../db/index.js'
|
|
|
|
|
import { eq } from 'drizzle-orm'
|
|
|
|
|
import { joinQueue, leaveQueue, getQueueSize, getQueueSnapshot } from '../engine/queue.js'
|
2026-03-08 01:26:11 +00:00
|
|
|
import { joinRankedQueue, getRankedQueueStatus } from '../engine/ranked-queue.js'
|
2026-03-08 12:08:18 +00:00
|
|
|
import { rateLimit } from '../middleware/rate-limit.js'
|
2026-03-13 09:46:38 +00:00
|
|
|
import { joinRankedSchema, sanitizeError } from '../lib/validators.js'
|
2026-07-31 10:10:55 -04:00
|
|
|
import { authenticateBot } from '../middleware/bot-auth.js'
|
2026-03-06 22:13:19 +00:00
|
|
|
|
|
|
|
|
export const queueRouter = new Hono()
|
|
|
|
|
|
|
|
|
|
// Get queue status
|
|
|
|
|
queueRouter.get('/status', (c) => {
|
|
|
|
|
return c.json({
|
|
|
|
|
waiting: getQueueSize(),
|
|
|
|
|
queue: getQueueSnapshot(),
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Join the queue — blocks until matched, then returns fightId
|
|
|
|
|
queueRouter.post('/join/:botId', async (c) => {
|
|
|
|
|
const botId = c.req.param('botId')
|
|
|
|
|
|
|
|
|
|
const botRows = await db.select({ id: schema.bots.id, name: schema.bots.name })
|
|
|
|
|
.from(schema.bots)
|
|
|
|
|
.where(eq(schema.bots.id, botId))
|
|
|
|
|
.limit(1)
|
|
|
|
|
|
|
|
|
|
if (botRows.length === 0) {
|
|
|
|
|
return c.json({ error: 'Bot not found.' }, 404)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const fightId = await joinQueue(botId)
|
|
|
|
|
return c.json({ fightId, message: 'Matched! Fight starting.' })
|
2026-03-09 09:27:48 +00:00
|
|
|
} catch (err: any) {
|
2026-03-13 09:46:38 +00:00
|
|
|
const raw = err instanceof Error ? err.message : ''
|
|
|
|
|
const isConflict = raw.includes('already in a fight')
|
|
|
|
|
const message = isConflict ? raw : sanitizeError(err, 'Queue error')
|
|
|
|
|
const status = isConflict ? 409 : 500
|
2026-03-09 09:27:48 +00:00
|
|
|
return c.json({ error: message, fightId: err?.fightId || undefined }, status)
|
2026-03-06 22:13:19 +00:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Leave the queue
|
|
|
|
|
queueRouter.post('/leave/:botId', (c) => {
|
|
|
|
|
const botId = c.req.param('botId')
|
|
|
|
|
const left = leaveQueue(botId)
|
|
|
|
|
return c.json({ left })
|
|
|
|
|
})
|
2026-03-08 01:26:11 +00:00
|
|
|
|
|
|
|
|
// Get ranked queue status
|
|
|
|
|
queueRouter.get('/ranked-status', (c) => {
|
|
|
|
|
return c.json(getRankedQueueStatus())
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-31 10:10:55 -04:00
|
|
|
// Join ranked queue — requires confirmed payment + bot ownership.
|
|
|
|
|
// Ownership can be proven either way, since ranked/staked fights are for
|
|
|
|
|
// BOTH audiences (not just nostr-signed-in humans):
|
|
|
|
|
// 1. pubkey (nostr-authenticated bots, the web UI's own JWT session flow)
|
|
|
|
|
// 2. Authorization: Bot <id>:<secret> (anonymous poll-mode bots — the
|
|
|
|
|
// primary registration path for AI agents per BOTFIGHTS.md, which
|
|
|
|
|
// never have a publicKey at all: confirmed live, publicKey is null
|
|
|
|
|
// for every bot registered via POST /api/bots). Without this, staking
|
|
|
|
|
// was silently unusable for the whole poll-mode/AI-agent audience.
|
2026-03-08 01:26:11 +00:00
|
|
|
queueRouter.post('/join-ranked/:botId', async (c) => {
|
|
|
|
|
const botId = c.req.param('botId')
|
2026-03-13 09:16:04 +00:00
|
|
|
const parsed = joinRankedSchema.safeParse(await c.req.json().catch(() => ({})))
|
|
|
|
|
if (!parsed.success) {
|
|
|
|
|
return c.json({ error: parsed.error.issues[0]?.message || 'Missing paymentId' }, 400)
|
2026-03-08 01:26:11 +00:00
|
|
|
}
|
2026-03-13 09:16:04 +00:00
|
|
|
const { paymentId, pubkey } = parsed.data
|
2026-03-08 01:26:11 +00:00
|
|
|
|
2026-03-08 12:08:18 +00:00
|
|
|
// Verify bot ownership in production
|
|
|
|
|
if (process.env.NODE_ENV === 'production') {
|
2026-07-31 10:10:55 -04:00
|
|
|
if (pubkey && typeof pubkey === 'string' && pubkey.length === 64) {
|
|
|
|
|
const botRows = await db.select({ publicKey: schema.bots.publicKey })
|
|
|
|
|
.from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
|
|
|
|
|
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
|
|
|
|
|
return c.json({ error: 'Unauthorized' }, 403)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// No pubkey supplied — fall back to bot-secret auth (Authorization
|
|
|
|
|
// header or ?bot_id=&secret= query params, same as /api/fights/poll).
|
|
|
|
|
const botOrRes = await authenticateBot(c)
|
|
|
|
|
if (botOrRes instanceof Response) return botOrRes
|
|
|
|
|
if (botOrRes.botId !== botId) {
|
|
|
|
|
return c.json({ error: 'Unauthorized' }, 403)
|
|
|
|
|
}
|
2026-03-08 12:08:18 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 01:26:11 +00:00
|
|
|
try {
|
|
|
|
|
const fightId = await joinRankedQueue(botId, paymentId)
|
|
|
|
|
return c.json({ fightId, message: 'Ranked match found! Fight starting.' })
|
|
|
|
|
} catch (err) {
|
2026-03-13 09:46:38 +00:00
|
|
|
const message = sanitizeError(err, 'Ranked queue error')
|
2026-03-08 01:26:11 +00:00
|
|
|
return c.json({ error: message }, 500)
|
|
|
|
|
}
|
|
|
|
|
})
|