feat: polling API, HMAC webhook signing, session-only keys, prod audio fix
- Add polling API (GET/POST /api/fights/poll) so bots don't need public URLs - Add HMAC-SHA256 webhook signing (X-Botfights-Signature header) - Stop auto-persisting nsec keys — session-only by default with opt-in "Remember on this device" - Fix production TTS: add wav/mp3/ogg MIME types, /audio/* route, SPA blocklist - Overhaul docs: mode selector (poll vs webhook), AI-first bot examples, security tab - Fix duplicate sign-in buttons, login flow bugs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
150ce7447d
commit
95ed80335a
@@ -86,6 +86,7 @@ function clearAllState() {
|
|||||||
store('bf_bot', null)
|
store('bf_bot', null)
|
||||||
store('bf_pic', null)
|
store('bf_pic', null)
|
||||||
setToken(null)
|
setToken(null)
|
||||||
|
sessionNsec = null
|
||||||
localStorage.removeItem('bf_nsec')
|
localStorage.removeItem('bf_nsec')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +99,8 @@ const isLoading = ref(false)
|
|||||||
let autoRestoreRan = false
|
let autoRestoreRan = false
|
||||||
// Flag: skip relay pic fetch for freshly generated keys (no profile exists)
|
// Flag: skip relay pic fetch for freshly generated keys (no profile exists)
|
||||||
let freshlyGenerated = false
|
let freshlyGenerated = false
|
||||||
|
// In-memory nsec for current session (never auto-persisted to localStorage)
|
||||||
|
let sessionNsec: string | null = null
|
||||||
|
|
||||||
// Sync in-memory auth state when tab regains focus (handles external localStorage clearing)
|
// Sync in-memory auth state when tab regains focus (handles external localStorage clearing)
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
@@ -196,8 +199,8 @@ export function useNostr() {
|
|||||||
// Mobile signers (Amber) inject window.nostr late — poll for up to 3s
|
// Mobile signers (Amber) inject window.nostr late — poll for up to 3s
|
||||||
const found = await waitForSigner(3000)
|
const found = await waitForSigner(3000)
|
||||||
if (!found) {
|
if (!found) {
|
||||||
// Fall back to stored nsec if available
|
// Fall back to session or persisted nsec if available
|
||||||
const storedNsec = localStorage.getItem('bf_nsec')
|
const storedNsec = sessionNsec || localStorage.getItem('bf_nsec')
|
||||||
if (storedNsec) {
|
if (storedNsec) {
|
||||||
return loginWithNsec(storedNsec)
|
return loginWithNsec(storedNsec)
|
||||||
}
|
}
|
||||||
@@ -247,16 +250,16 @@ export function useNostr() {
|
|||||||
// Mark as freshly generated so login() skips relay pic fetch
|
// Mark as freshly generated so login() skips relay pic fetch
|
||||||
freshlyGenerated = true
|
freshlyGenerated = true
|
||||||
|
|
||||||
// Store new key (will be cleared on logout)
|
// Hold key in session memory only — user must opt in to persist
|
||||||
localStorage.setItem('bf_nsec', nsecHex)
|
sessionNsec = nsecHex
|
||||||
pubkey.value = pk
|
pubkey.value = pk
|
||||||
store('bf_pubkey', pk)
|
store('bf_pubkey', pk)
|
||||||
|
|
||||||
return { pubkey: pk, nsec: nsecBech32 }
|
return { pubkey: pk, nsec: nsecBech32 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Login with an existing nsec (hex). Signs NIP-98 locally. */
|
/** Login with an existing nsec (hex). Signs NIP-98 locally. If persist=true, saves to localStorage. */
|
||||||
async function loginWithNsec(nsecHex: string): Promise<{ pubkey: string; bot: BotData | null }> {
|
async function loginWithNsec(nsecHex: string, persist = false): Promise<{ pubkey: string; bot: BotData | null }> {
|
||||||
let secretKey: Uint8Array
|
let secretKey: Uint8Array
|
||||||
try {
|
try {
|
||||||
secretKey = hexToBytes(nsecHex)
|
secretKey = hexToBytes(nsecHex)
|
||||||
@@ -271,7 +274,8 @@ export function useNostr() {
|
|||||||
store('bf_bot', null)
|
store('bf_bot', null)
|
||||||
store('bf_pic', null)
|
store('bf_pic', null)
|
||||||
|
|
||||||
localStorage.setItem('bf_nsec', nsecHex)
|
sessionNsec = nsecHex
|
||||||
|
if (persist) localStorage.setItem('bf_nsec', nsecHex)
|
||||||
|
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -332,8 +336,7 @@ export function useNostr() {
|
|||||||
|
|
||||||
// Re-authenticate to get fresh JWT with botId
|
// Re-authenticate to get fresh JWT with botId
|
||||||
try {
|
try {
|
||||||
const nsec = localStorage.getItem('bf_nsec')
|
await authenticateSession(sessionNsec)
|
||||||
await authenticateSession(nsec)
|
|
||||||
} catch {
|
} catch {
|
||||||
// Non-critical: existing JWT still works, just missing botId
|
// Non-critical: existing JWT still works, just missing botId
|
||||||
}
|
}
|
||||||
@@ -426,8 +429,7 @@ export function useNostr() {
|
|||||||
|
|
||||||
// Re-authenticate to get fresh JWT with botId
|
// Re-authenticate to get fresh JWT with botId
|
||||||
try {
|
try {
|
||||||
const nsec = localStorage.getItem('bf_nsec')
|
await authenticateSession(sessionNsec)
|
||||||
await authenticateSession(nsec)
|
|
||||||
} catch {
|
} catch {
|
||||||
// Non-critical
|
// Non-critical
|
||||||
}
|
}
|
||||||
@@ -443,9 +445,14 @@ export function useNostr() {
|
|||||||
/** Check if user has a locally stored key (no extension needed) */
|
/** Check if user has a locally stored key (no extension needed) */
|
||||||
const hasStoredKey = computed(() => !!localStorage.getItem('bf_nsec'))
|
const hasStoredKey = computed(() => !!localStorage.getItem('bf_nsec'))
|
||||||
|
|
||||||
/** Get the stored nsec hex for backup display */
|
/** Get the current nsec hex (session memory first, then localStorage) */
|
||||||
function getStoredNsec(): string | null {
|
function getStoredNsec(): string | null {
|
||||||
return localStorage.getItem('bf_nsec')
|
return sessionNsec || localStorage.getItem('bf_nsec')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the current session key to localStorage (opt-in) */
|
||||||
|
function persistKey(): void {
|
||||||
|
if (sessionNsec) localStorage.setItem('bf_nsec', sessionNsec)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -539,6 +546,7 @@ export function useNostr() {
|
|||||||
updateCustomization,
|
updateCustomization,
|
||||||
updateWebhook,
|
updateWebhook,
|
||||||
getStoredNsec,
|
getStoredNsec,
|
||||||
|
persistKey,
|
||||||
logout,
|
logout,
|
||||||
fetchNostrProfile,
|
fetchNostrProfile,
|
||||||
initiateNip55Login,
|
initiateNip55Login,
|
||||||
|
|||||||
+689
-359
File diff suppressed because it is too large
Load Diff
@@ -10,10 +10,12 @@ import WalletConnect from '../components/WalletConnect.vue'
|
|||||||
import { authFetch } from '../lib/nostr-auth'
|
import { authFetch } from '../lib/nostr-auth'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, hasStoredKey, isLoading, login, waitForSigner, generateLogin, loginWithNsec, registerBot, registerHuman, getStoredNsec, logout, initiateNip55Login, processNip55Return, hasAndroidSigner } = useNostr()
|
const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, hasStoredKey, isLoading, login, waitForSigner, generateLogin, loginWithNsec, registerBot, registerHuman, getStoredNsec, persistKey, logout, initiateNip55Login, processNip55Return, hasAndroidSigner } = useNostr()
|
||||||
const showNsecBackup = ref(false)
|
const showNsecBackup = ref(false)
|
||||||
const generatedNsec = ref('')
|
const generatedNsec = ref('')
|
||||||
|
const generatedNsecHex = ref('')
|
||||||
const nsecInput = ref('')
|
const nsecInput = ref('')
|
||||||
|
const rememberKey = ref(false)
|
||||||
const { isWalletConnected, payEntryFee, paymentStatus } = useWallet()
|
const { isWalletConnected, payEntryFee, paymentStatus } = useWallet()
|
||||||
|
|
||||||
// Steps: 'login' | 'choose-mode' | 'pick-character' | 'name-bot' | 'bot-setup' | 'add-webhook' |
|
// Steps: 'login' | 'choose-mode' | 'pick-character' | 'name-bot' | 'bot-setup' | 'add-webhook' |
|
||||||
@@ -265,18 +267,46 @@ async function handleSignerLogin() {
|
|||||||
handleLogin()
|
handleLogin()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleStoredKeyLogin() {
|
||||||
|
error.value = ''
|
||||||
|
const storedNsec = localStorage.getItem('bf_nsec')
|
||||||
|
if (!storedNsec) {
|
||||||
|
error.value = 'No saved key found. Generate a new identity.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await loginWithNsec(storedNsec)
|
||||||
|
if (result.bot) {
|
||||||
|
isHumanMode.value = !!result.bot.isHuman
|
||||||
|
step.value = 'ready'
|
||||||
|
} else {
|
||||||
|
step.value = 'choose-mode'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
handleError(e, 'Login failed.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleGenerateLogin() {
|
function handleGenerateLogin() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
const { nsec } = generateLogin()
|
const { nsec } = generateLogin()
|
||||||
generatedNsec.value = nsec
|
generatedNsec.value = nsec
|
||||||
|
generatedNsecHex.value = getStoredNsec() || ''
|
||||||
showNsecBackup.value = true
|
showNsecBackup.value = true
|
||||||
|
rememberKey.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleNsecBackupDone() {
|
async function handleNsecBackupDone() {
|
||||||
if (isLoading.value) return
|
if (isLoading.value) return
|
||||||
showNsecBackup.value = false
|
showNsecBackup.value = false
|
||||||
try {
|
try {
|
||||||
const result = await login()
|
const nsecHex = generatedNsecHex.value || getStoredNsec()
|
||||||
|
if (!nsecHex) {
|
||||||
|
error.value = 'Key not found. Generate a new identity.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (rememberKey.value) persistKey()
|
||||||
|
const result = await loginWithNsec(nsecHex, rememberKey.value)
|
||||||
if (result.bot) {
|
if (result.bot) {
|
||||||
isHumanMode.value = !!result.bot.isHuman
|
isHumanMode.value = !!result.bot.isHuman
|
||||||
step.value = 'ready'
|
step.value = 'ready'
|
||||||
@@ -570,6 +600,15 @@ async function practice() {
|
|||||||
|
|
||||||
function handleSignOut() {
|
function handleSignOut() {
|
||||||
logout()
|
logout()
|
||||||
|
// Reset all UI state to prevent stale data
|
||||||
|
error.value = ''
|
||||||
|
generatedNsec.value = ''
|
||||||
|
generatedNsecHex.value = ''
|
||||||
|
showNsecBackup.value = false
|
||||||
|
nsecInput.value = ''
|
||||||
|
rememberKey.value = false
|
||||||
|
isHumanMode.value = false
|
||||||
|
activeFightLink.value = ''
|
||||||
step.value = 'login'
|
step.value = 'login'
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -617,6 +656,14 @@ function handleSignOut() {
|
|||||||
{{ nsecCopied ? 'COPIED' : 'COPY' }}
|
{{ nsecCopied ? 'COPIED' : 'COPY' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
v-model="rememberKey"
|
||||||
|
type="checkbox"
|
||||||
|
class="w-4 h-4 accent-neon-purple"
|
||||||
|
/>
|
||||||
|
<span class="font-mono text-xs text-text-muted">Remember on this device</span>
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
class="w-full py-3 bg-neon-green/10 border-2 border-neon-green/50 text-neon-green
|
class="w-full py-3 bg-neon-green/10 border-2 border-neon-green/50 text-neon-green
|
||||||
font-display font-black text-sm tracking-widest
|
font-display font-black text-sm tracking-widest
|
||||||
@@ -630,7 +677,7 @@ function handleSignOut() {
|
|||||||
<div v-if="!showNsecBackup" class="space-y-3">
|
<div v-if="!showNsecBackup" class="space-y-3">
|
||||||
<!-- Sign in with extension (NIP-07) -->
|
<!-- Sign in with extension (NIP-07) -->
|
||||||
<button
|
<button
|
||||||
v-if="hasExtension"
|
v-if="hasExtension && !hasStoredKey"
|
||||||
class="w-full py-4 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
|
class="w-full py-4 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
|
||||||
font-display font-black text-base tracking-widest
|
font-display font-black text-base tracking-widest
|
||||||
hover:bg-neon-purple/20 hover:border-neon-purple transition-all
|
hover:bg-neon-purple/20 hover:border-neon-purple transition-all
|
||||||
@@ -643,8 +690,9 @@ function handleSignOut() {
|
|||||||
{{ isLoading ? 'CONNECTING...' : 'SIGN IN WITH EXTENSION' }}
|
{{ isLoading ? 'CONNECTING...' : 'SIGN IN WITH EXTENSION' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Sign in with Nostr signer -->
|
<!-- Sign in with Nostr signer (Android / mobile signers) -->
|
||||||
<button
|
<button
|
||||||
|
v-if="hasAndroidSigner"
|
||||||
class="w-full py-4 bg-neon-yellow/10 border-2 border-neon-yellow/50 text-neon-yellow
|
class="w-full py-4 bg-neon-yellow/10 border-2 border-neon-yellow/50 text-neon-yellow
|
||||||
font-display font-black text-base tracking-widest
|
font-display font-black text-base tracking-widest
|
||||||
hover:bg-neon-yellow/20 hover:border-neon-yellow transition-all
|
hover:bg-neon-yellow/20 hover:border-neon-yellow transition-all
|
||||||
@@ -654,22 +702,22 @@ function handleSignOut() {
|
|||||||
@click="handleSignerLogin"
|
@click="handleSignerLogin"
|
||||||
>
|
>
|
||||||
<span v-if="isLoading" class="w-5 h-5 border-2 border-neon-yellow/30 border-t-neon-yellow rounded-full animate-spin" />
|
<span v-if="isLoading" class="w-5 h-5 border-2 border-neon-yellow/30 border-t-neon-yellow rounded-full animate-spin" />
|
||||||
{{ isLoading ? 'CONNECTING...' : hasAndroidSigner ? 'SIGN IN WITH AMBER / PRIMAL' : 'USE NOSTR SIGNER' }}
|
{{ isLoading ? 'CONNECTING...' : 'SIGN IN WITH AMBER / PRIMAL' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Sign in with stored key -->
|
<!-- Sign in with stored key (always show if key exists) -->
|
||||||
<button
|
<button
|
||||||
v-if="!hasExtension && hasStoredKey"
|
v-if="hasStoredKey"
|
||||||
class="w-full py-4 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
|
class="w-full py-4 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
|
||||||
font-display font-black text-base tracking-widest
|
font-display font-black text-base tracking-widest
|
||||||
hover:bg-neon-purple/20 hover:border-neon-purple transition-all
|
hover:bg-neon-purple/20 hover:border-neon-purple transition-all
|
||||||
disabled:opacity-50 disabled:cursor-wait
|
disabled:opacity-50 disabled:cursor-wait
|
||||||
flex items-center justify-center gap-3"
|
flex items-center justify-center gap-3"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
@click="handleLogin"
|
@click="handleStoredKeyLogin"
|
||||||
>
|
>
|
||||||
<span v-if="isLoading" class="w-5 h-5 border-2 border-neon-purple/30 border-t-neon-purple rounded-full animate-spin" />
|
<span v-if="isLoading" class="w-5 h-5 border-2 border-neon-purple/30 border-t-neon-purple rounded-full animate-spin" />
|
||||||
{{ isLoading ? 'CONNECTING...' : 'SIGN IN' }}
|
{{ isLoading ? 'CONNECTING...' : 'SIGN IN WITH EXTENSION' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Always show Generate Login -->
|
<!-- Always show Generate Login -->
|
||||||
|
|||||||
+6
-1
@@ -122,6 +122,9 @@ if (process.env.NODE_ENV === 'production' && existsSync(publicDir)) {
|
|||||||
woff2: 'font/woff2',
|
woff2: 'font/woff2',
|
||||||
webp: 'image/webp',
|
webp: 'image/webp',
|
||||||
webmanifest: 'application/manifest+json',
|
webmanifest: 'application/manifest+json',
|
||||||
|
wav: 'audio/wav',
|
||||||
|
mp3: 'audio/mpeg',
|
||||||
|
ogg: 'audio/ogg',
|
||||||
}
|
}
|
||||||
|
|
||||||
function serveFile(c: Context, reqPath: string, cacheControl: string) {
|
function serveFile(c: Context, reqPath: string, cacheControl: string) {
|
||||||
@@ -160,12 +163,14 @@ if (process.env.NODE_ENV === 'production' && existsSync(publicDir)) {
|
|||||||
app.get('/icon.svg', (c) => serveFile(c, '/icon.svg', 'public, max-age=86400'))
|
app.get('/icon.svg', (c) => serveFile(c, '/icon.svg', 'public, max-age=86400'))
|
||||||
app.get('/apple-touch-icon.png', (c) => serveFile(c, '/apple-touch-icon.png', 'public, max-age=86400'))
|
app.get('/apple-touch-icon.png', (c) => serveFile(c, '/apple-touch-icon.png', 'public, max-age=86400'))
|
||||||
|
|
||||||
|
// Audio files (pre-generated TTS, SFX)
|
||||||
|
app.get('/audio/*', (c) => serveFile(c, c.req.path, 'public, max-age=86400'))
|
||||||
// SPA fallback: only for navigation requests (not JS/CSS/asset files)
|
// SPA fallback: only for navigation requests (not JS/CSS/asset files)
|
||||||
app.get('*', (c) => {
|
app.get('*', (c) => {
|
||||||
if (c.req.path.startsWith('/api/')) return c.notFound()
|
if (c.req.path.startsWith('/api/')) return c.notFound()
|
||||||
// Don't serve index.html for asset requests — return 404 so the browser gets a proper error
|
// Don't serve index.html for asset requests — return 404 so the browser gets a proper error
|
||||||
const ext = c.req.path.split('.').pop()
|
const ext = c.req.path.split('.').pop()
|
||||||
if (ext && ext !== c.req.path && ['js', 'css', 'map', 'json', 'png', 'jpg', 'svg', 'woff', 'woff2', 'webp', 'ico'].includes(ext)) {
|
if (ext && ext !== c.req.path && ['js', 'css', 'map', 'json', 'png', 'jpg', 'svg', 'woff', 'woff2', 'webp', 'ico', 'wav', 'mp3', 'ogg'].includes(ext)) {
|
||||||
return c.notFound()
|
return c.notFound()
|
||||||
}
|
}
|
||||||
const indexPath = join(publicDir, 'index.html')
|
const indexPath = join(publicDir, 'index.html')
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { FIGHT_LOOP_INTERVAL_MS, ELO_MATCHING_RANDOMNESS } from '../lib/constant
|
|||||||
import { logger } from '../lib/logger.js'
|
import { logger } from '../lib/logger.js'
|
||||||
import { db, schema } from '../db/index.js'
|
import { db, schema } from '../db/index.js'
|
||||||
import { runMockFight } from './mock.js'
|
import { runMockFight } from './mock.js'
|
||||||
import { eq } from 'drizzle-orm'
|
import { eq, ne } from 'drizzle-orm'
|
||||||
|
import { isPollingBot } from './poll-responses.js'
|
||||||
import { pick, toError } from '../lib/utils.js'
|
import { pick, toError } from '../lib/utils.js'
|
||||||
import { fightEvents } from './events.js'
|
import { fightEvents } from './events.js'
|
||||||
|
|
||||||
@@ -58,14 +59,16 @@ export async function startFightLoop(options: FightLoopOptions = {}): Promise<vo
|
|||||||
|
|
||||||
while (fightCount < maxFights) {
|
while (fightCount < maxFights) {
|
||||||
try {
|
try {
|
||||||
// Re-fetch bots to get updated elo ratings
|
// Re-fetch bots to get updated elo ratings (exclude polling bots — they need active polling)
|
||||||
const bots = await db.select({
|
const allFetchedBots = await db.select({
|
||||||
id: schema.bots.id,
|
id: schema.bots.id,
|
||||||
eloRating: schema.bots.eloRating,
|
eloRating: schema.bots.eloRating,
|
||||||
name: schema.bots.name,
|
name: schema.bots.name,
|
||||||
wins: schema.bots.wins,
|
wins: schema.bots.wins,
|
||||||
losses: schema.bots.losses,
|
losses: schema.bots.losses,
|
||||||
|
webhookUrl: schema.bots.webhookUrl,
|
||||||
}).from(schema.bots)
|
}).from(schema.bots)
|
||||||
|
const bots = allFetchedBots.filter(b => !isPollingBot(b.webhookUrl))
|
||||||
|
|
||||||
const [botA, botB] = pickMatchup(bots, matchmakingStyle, fightCount)
|
const [botA, botB] = pickMatchup(bots, matchmakingStyle, fightCount)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { getCurrentSeason } from './seasons.js'
|
|||||||
import { onFightFinished as onTournamentFightFinished } from './tournaments.js'
|
import { onFightFinished as onTournamentFightFinished } from './tournaments.js'
|
||||||
import { trackFightCompleted, trackBotActive, trackMetric } from './analytics.js'
|
import { trackFightCompleted, trackBotActive, trackMetric } from './analytics.js'
|
||||||
import { invalidateLeaderboardCache } from '../routes/bots.js'
|
import { invalidateLeaderboardCache } from '../routes/bots.js'
|
||||||
|
import { createHmac } from 'crypto'
|
||||||
|
import { isPollingBot, waitForPollResponse } from './poll-responses.js'
|
||||||
|
|
||||||
const webhookResponseSchema = z.object({
|
const webhookResponseSchema = z.object({
|
||||||
answer: z.string().nullable().optional(),
|
answer: z.string().nullable().optional(),
|
||||||
@@ -29,6 +31,7 @@ interface BotRecord {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
webhookUrl: string
|
webhookUrl: string
|
||||||
|
secretHash: string
|
||||||
eloRating: number
|
eloRating: number
|
||||||
wins: number
|
wins: number
|
||||||
losses: number
|
losses: number
|
||||||
@@ -146,6 +149,7 @@ async function callWebhook(
|
|||||||
fightId: string,
|
fightId: string,
|
||||||
opponent: { name: string; wins: number; losses: number },
|
opponent: { name: string; wins: number; losses: number },
|
||||||
arena: Arena,
|
arena: Arena,
|
||||||
|
secretHash?: string,
|
||||||
): Promise<WebhookResponse> {
|
): Promise<WebhookResponse> {
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
fight_id: fightId,
|
fight_id: fightId,
|
||||||
@@ -174,9 +178,20 @@ async function callWebhook(
|
|||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeout = setTimeout(() => controller.abort(), challenge.timeout_ms)
|
const timeout = setTimeout(() => controller.abort(), challenge.timeout_ms)
|
||||||
|
|
||||||
|
// HMAC-SHA256 signature for webhook verification
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||||
|
if (secretHash) {
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000).toString()
|
||||||
|
const signature = createHmac('sha256', secretHash)
|
||||||
|
.update(`${timestamp}.${body}`)
|
||||||
|
.digest('hex')
|
||||||
|
headers['X-Botfights-Signature'] = `sha256=${signature}`
|
||||||
|
headers['X-Botfights-Timestamp'] = timestamp
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers,
|
||||||
body,
|
body,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
@@ -288,8 +303,17 @@ async function getBotResponse(
|
|||||||
error: mock.error,
|
error: mock.error,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isPollingBot(bot.webhookUrl)) {
|
||||||
|
logger.info('fight', `${bot.name} is polling bot, waiting for poll response`)
|
||||||
|
emit(fightId, 'poll_challenge', { botId: bot.id, round: roundNumber, type: challenge.type })
|
||||||
|
const start = Date.now()
|
||||||
|
const result = await waitForPollResponse(fightId, bot.id, challenge, roundNumber, opponent, arena.id, arena.modifier)
|
||||||
|
const elapsed = Date.now() - start
|
||||||
|
return { answer: result.answer, trashTalk: result.trashTalk, timeMs: elapsed, timedOut: result.timedOut, error: false }
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('fight', `${bot.name} has real webhook: ${bot.webhookUrl}`)
|
logger.info('fight', `${bot.name} has real webhook: ${bot.webhookUrl}`)
|
||||||
return callWebhook(bot.webhookUrl, challenge, roundNumber, fightId, opponent, arena)
|
return callWebhook(bot.webhookUrl, challenge, roundNumber, fightId, opponent, arena, bot.secretHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadBots(botAId: string, botBId: string): Promise<[BotRecord, BotRecord]> {
|
async function loadBots(botAId: string, botBId: string): Promise<[BotRecord, BotRecord]> {
|
||||||
@@ -332,7 +356,7 @@ async function createFightRecord(botA: BotRecord, botB: BotRecord, arena: Arena,
|
|||||||
|
|
||||||
// Track webhook errors per bot
|
// Track webhook errors per bot
|
||||||
async function trackWebhookResult(botId: string, webhookUrl: string, succeeded: boolean) {
|
async function trackWebhookResult(botId: string, webhookUrl: string, succeeded: boolean) {
|
||||||
if (isMockBot(webhookUrl) || isClassicBot(webhookUrl) || isHumanPlayer(webhookUrl)) return
|
if (isMockBot(webhookUrl) || isClassicBot(webhookUrl) || isHumanPlayer(webhookUrl) || isPollingBot(webhookUrl)) return
|
||||||
if (succeeded) {
|
if (succeeded) {
|
||||||
await db.update(schema.bots).set({ consecutiveErrors: 0 }).where(eq(schema.bots.id, botId))
|
await db.update(schema.bots).set({ consecutiveErrors: 0 }).where(eq(schema.bots.id, botId))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// In-memory store for pending polling bot challenges.
|
||||||
|
// When the fight engine needs a polling bot's response, it stores the challenge here
|
||||||
|
// and waits for the bot to poll and submit an answer via REST.
|
||||||
|
|
||||||
|
import { logger } from '../lib/logger.js'
|
||||||
|
import type { Challenge } from './challenges.js'
|
||||||
|
import { POLL_GRACE_MS } from '../lib/constants.js'
|
||||||
|
|
||||||
|
interface PendingPollChallenge {
|
||||||
|
fightId: string
|
||||||
|
botId: string
|
||||||
|
challenge: Challenge
|
||||||
|
roundNumber: number
|
||||||
|
createdAt: number
|
||||||
|
opponent: { name: string; wins: number; losses: number }
|
||||||
|
arena: string
|
||||||
|
arenaModifier: string | null
|
||||||
|
resolve: (response: { answer: string | null; trashTalk?: string; timedOut: boolean }) => void
|
||||||
|
timeoutHandle: ReturnType<typeof setTimeout>
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = new Map<string, PendingPollChallenge>()
|
||||||
|
// Secondary index: botId -> key (for poll lookups where bot doesn't know fightId)
|
||||||
|
const botIndex = new Map<string, string>()
|
||||||
|
|
||||||
|
/** Clear all pending poll challenges (used during graceful shutdown) */
|
||||||
|
export function clearAllPendingPolls(): void {
|
||||||
|
for (const [key, entry] of pending) {
|
||||||
|
clearTimeout(entry.timeoutHandle)
|
||||||
|
pending.delete(key)
|
||||||
|
}
|
||||||
|
botIndex.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPollingBot(webhookUrl: string): boolean {
|
||||||
|
return webhookUrl === 'http://poll.local/'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function waitForPollResponse(
|
||||||
|
fightId: string,
|
||||||
|
botId: string,
|
||||||
|
challenge: Challenge,
|
||||||
|
roundNumber: number,
|
||||||
|
opponent: { name: string; wins: number; losses: number },
|
||||||
|
arena: string,
|
||||||
|
arenaModifier: string | null,
|
||||||
|
): Promise<{ answer: string | null; trashTalk?: string; timedOut: boolean }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const key = `${fightId}:${botId}`
|
||||||
|
const timeoutMs = (challenge.timeout_ms || 8000) + POLL_GRACE_MS
|
||||||
|
|
||||||
|
const timeoutHandle = setTimeout(() => {
|
||||||
|
pending.delete(key)
|
||||||
|
botIndex.delete(botId)
|
||||||
|
logger.info('poll', `${key} timed out after ${timeoutMs}ms`)
|
||||||
|
resolve({ answer: null, timedOut: true })
|
||||||
|
}, timeoutMs)
|
||||||
|
|
||||||
|
pending.set(key, {
|
||||||
|
fightId,
|
||||||
|
botId,
|
||||||
|
challenge,
|
||||||
|
roundNumber,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
opponent,
|
||||||
|
arena,
|
||||||
|
arenaModifier,
|
||||||
|
resolve: (response) => {
|
||||||
|
clearTimeout(timeoutHandle)
|
||||||
|
pending.delete(key)
|
||||||
|
botIndex.delete(botId)
|
||||||
|
resolve(response)
|
||||||
|
},
|
||||||
|
timeoutHandle,
|
||||||
|
})
|
||||||
|
|
||||||
|
botIndex.set(botId, key)
|
||||||
|
logger.info('poll', `waiting: ${key} round=${roundNumber} type=${challenge.type} timeout=${timeoutMs}ms`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function submitPollResponse(
|
||||||
|
botId: string,
|
||||||
|
answer: string,
|
||||||
|
trashTalk?: string,
|
||||||
|
): boolean {
|
||||||
|
const key = botIndex.get(botId)
|
||||||
|
if (!key) return false
|
||||||
|
const entry = pending.get(key)
|
||||||
|
if (!entry) return false
|
||||||
|
logger.info('poll', `response: ${key} answer=${answer.slice(0, 80)}`)
|
||||||
|
entry.resolve({
|
||||||
|
answer: answer.slice(0, 2000),
|
||||||
|
trashTalk: trashTalk?.slice(0, 200),
|
||||||
|
timedOut: false,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPendingPollChallenge(botId: string): {
|
||||||
|
fightId: string
|
||||||
|
type: string
|
||||||
|
label: string
|
||||||
|
prompt: string
|
||||||
|
roundNumber: number
|
||||||
|
timeoutMs: number
|
||||||
|
remainingMs: number
|
||||||
|
scoring: string
|
||||||
|
opponent: { name: string; wins: number; losses: number }
|
||||||
|
arena: string
|
||||||
|
arenaModifier: string | null
|
||||||
|
constraints: { timeout_ms: number; max_tokens: number }
|
||||||
|
} | null {
|
||||||
|
const key = botIndex.get(botId)
|
||||||
|
if (!key) return null
|
||||||
|
const entry = pending.get(key)
|
||||||
|
if (!entry) return null
|
||||||
|
|
||||||
|
const totalTimeoutMs = (entry.challenge.timeout_ms || 8000) + POLL_GRACE_MS
|
||||||
|
const elapsed = Date.now() - entry.createdAt
|
||||||
|
const remaining = Math.max(0, totalTimeoutMs - elapsed)
|
||||||
|
|
||||||
|
return {
|
||||||
|
fightId: entry.fightId,
|
||||||
|
type: entry.challenge.type,
|
||||||
|
label: entry.challenge.label,
|
||||||
|
prompt: entry.challenge.prompt,
|
||||||
|
roundNumber: entry.roundNumber,
|
||||||
|
timeoutMs: entry.challenge.timeout_ms || 8000,
|
||||||
|
remainingMs: remaining,
|
||||||
|
scoring: entry.challenge.scoring,
|
||||||
|
opponent: entry.opponent,
|
||||||
|
arena: entry.arena,
|
||||||
|
arenaModifier: entry.arenaModifier,
|
||||||
|
constraints: {
|
||||||
|
timeout_ms: entry.challenge.timeout_ms || 8000,
|
||||||
|
max_tokens: 500,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,3 +85,6 @@ const CREATOR_PUBKEY = 'da5e0c1b646bdb13c2300f805b0ca3e5afe5b052c594ce78bac8978d
|
|||||||
export function isCreatorPubkey(pubkey: string | undefined | null): boolean {
|
export function isCreatorPubkey(pubkey: string | undefined | null): boolean {
|
||||||
return !!pubkey && pubkey === CREATOR_PUBKEY
|
return !!pubkey && pubkey === CREATOR_PUBKEY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Polling bot ---
|
||||||
|
export const POLL_GRACE_MS = 10_000 // Extra time for polling bots to discover + respond
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Bot authentication helper.
|
||||||
|
// Verifies bot identity via bot_id + secret (SHA256 hash comparison).
|
||||||
|
// Supports: Authorization header or query params.
|
||||||
|
|
||||||
|
import { createHash } from 'crypto'
|
||||||
|
import { db, schema } from '../db/index.js'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import type { Context } from 'hono'
|
||||||
|
|
||||||
|
export interface BotAuthContext {
|
||||||
|
botId: string
|
||||||
|
botName: string
|
||||||
|
webhookUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract and verify bot credentials from request. Returns bot context or error response. */
|
||||||
|
export async function authenticateBot(c: Context): Promise<BotAuthContext | Response> {
|
||||||
|
let botId: string | undefined
|
||||||
|
let secret: string | undefined
|
||||||
|
|
||||||
|
// 1. Authorization: Bot <bot_id>:<secret>
|
||||||
|
const auth = c.req.header('Authorization')
|
||||||
|
if (auth?.startsWith('Bot ')) {
|
||||||
|
const parts = auth.slice(4).split(':')
|
||||||
|
if (parts.length === 2 && parts[0] && parts[1]) {
|
||||||
|
botId = parts[0]
|
||||||
|
secret = parts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Query params: ?bot_id=xxx&secret=yyy
|
||||||
|
if (!botId || !secret) {
|
||||||
|
const qBotId = c.req.query('bot_id')
|
||||||
|
const qSecret = c.req.query('secret')
|
||||||
|
if (qBotId && qSecret) {
|
||||||
|
botId = qBotId
|
||||||
|
secret = qSecret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!botId || !secret) {
|
||||||
|
return c.json({ error: 'Authentication required. Use Authorization: Bot <bot_id>:<secret> or query params ?bot_id=...&secret=...' }, 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = createHash('sha256').update(secret).digest('hex')
|
||||||
|
|
||||||
|
const rows = await db.select({
|
||||||
|
id: schema.bots.id,
|
||||||
|
name: schema.bots.name,
|
||||||
|
secretHash: schema.bots.secretHash,
|
||||||
|
webhookUrl: schema.bots.webhookUrl,
|
||||||
|
}).from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return c.json({ error: 'Invalid bot_id or secret.' }, 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constant-time comparison
|
||||||
|
const expected = rows[0].secretHash
|
||||||
|
if (hash.length !== expected.length) {
|
||||||
|
return c.json({ error: 'Invalid bot_id or secret.' }, 401)
|
||||||
|
}
|
||||||
|
let mismatch = 0
|
||||||
|
for (let i = 0; i < hash.length; i++) {
|
||||||
|
mismatch |= hash.charCodeAt(i) ^ expected.charCodeAt(i)
|
||||||
|
}
|
||||||
|
if (mismatch !== 0) {
|
||||||
|
return c.json({ error: 'Invalid bot_id or secret.' }, 401)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
botId: rows[0].id,
|
||||||
|
botName: rows[0].name,
|
||||||
|
webhookUrl: rows[0].webhookUrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-22
@@ -153,18 +153,21 @@ authRouter.post('/register', rateLimit(600_000, 10), async (c) => {
|
|||||||
|
|
||||||
const normalizedName = name.toLowerCase()
|
const normalizedName = name.toLowerCase()
|
||||||
|
|
||||||
if (!webhookUrl || typeof webhookUrl !== 'string') {
|
// Poll mode: if no webhookUrl provided, bot will poll for challenges
|
||||||
return c.json({ error: 'webhookUrl is required.' }, 400)
|
const isPollMode = !webhookUrl || webhookUrl === ''
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
if (!isPollMode) {
|
||||||
new URL(webhookUrl)
|
if (typeof webhookUrl !== 'string') {
|
||||||
} catch {
|
return c.json({ error: 'webhookUrl must be a string.' }, 400)
|
||||||
return c.json({ error: 'webhookUrl must be a valid URL.' }, 400)
|
}
|
||||||
}
|
try {
|
||||||
|
new URL(webhookUrl)
|
||||||
if (!isAllowedWebhookUrl(webhookUrl)) {
|
} catch {
|
||||||
return c.json({ error: 'webhookUrl must not point to private/internal addresses.' }, 400)
|
return c.json({ error: 'webhookUrl must be a valid URL.' }, 400)
|
||||||
|
}
|
||||||
|
if (!isAllowedWebhookUrl(webhookUrl)) {
|
||||||
|
return c.json({ error: 'webhookUrl must not point to private/internal addresses.' }, 400)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check pubkey not already used
|
// Check pubkey not already used
|
||||||
@@ -187,18 +190,23 @@ authRouter.post('/register', rateLimit(600_000, 10), async (c) => {
|
|||||||
return c.json({ error: 'A bot with that name already exists.' }, 409)
|
return c.json({ error: 'A bot with that name already exists.' }, 409)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test the webhook
|
// Test the webhook (skip for poll mode)
|
||||||
const testResult = await testWebhook(webhookUrl)
|
let testResult: { latencyMs: number } | null = null
|
||||||
if (!testResult.reachable || !testResult.validResponse) {
|
if (!isPollMode) {
|
||||||
return c.json({
|
const result = await testWebhook(webhookUrl)
|
||||||
error: 'Webhook verification failed.',
|
if (!result.reachable || !result.validResponse) {
|
||||||
details: testResult.error || 'Webhook must return {"answer": "..."} as JSON.',
|
return c.json({
|
||||||
latencyMs: testResult.latencyMs,
|
error: 'Webhook verification failed.',
|
||||||
}, 422)
|
details: result.error || 'Webhook must return {"answer": "..."} as JSON.',
|
||||||
|
latencyMs: result.latencyMs,
|
||||||
|
}, 422)
|
||||||
|
}
|
||||||
|
testResult = result
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = nanoid(12)
|
const id = nanoid(12)
|
||||||
const secret = randomBytes(32).toString('hex')
|
const secret = randomBytes(32).toString('hex')
|
||||||
|
const effectiveWebhookUrl = isPollMode ? 'http://poll.local/' : webhookUrl
|
||||||
|
|
||||||
const baseArchetype = custResult.data.archetype || archetype || 'standard'
|
const baseArchetype = custResult.data.archetype || archetype || 'standard'
|
||||||
const effectiveArchetype = isCreatorPubkey(pubkey) ? 'the_creator' : baseArchetype
|
const effectiveArchetype = isCreatorPubkey(pubkey) ? 'the_creator' : baseArchetype
|
||||||
@@ -207,7 +215,7 @@ authRouter.post('/register', rateLimit(600_000, 10), async (c) => {
|
|||||||
await db.insert(schema.bots).values({
|
await db.insert(schema.bots).values({
|
||||||
id,
|
id,
|
||||||
name: normalizedName,
|
name: normalizedName,
|
||||||
webhookUrl,
|
webhookUrl: effectiveWebhookUrl,
|
||||||
avatarSeed: normalizedName,
|
avatarSeed: normalizedName,
|
||||||
archetype: effectiveArchetype,
|
archetype: effectiveArchetype,
|
||||||
secretHash: createHash('sha256').update(secret).digest('hex'),
|
secretHash: createHash('sha256').update(secret).digest('hex'),
|
||||||
@@ -220,10 +228,14 @@ authRouter.post('/register', rateLimit(600_000, 10), async (c) => {
|
|||||||
return c.json({
|
return c.json({
|
||||||
id,
|
id,
|
||||||
name: normalizedName,
|
name: normalizedName,
|
||||||
|
secret,
|
||||||
|
mode: isPollMode ? 'poll' : 'webhook',
|
||||||
archetype: effectiveArchetype,
|
archetype: effectiveArchetype,
|
||||||
customization: custResult.data,
|
customization: custResult.data,
|
||||||
webhookLatencyMs: testResult.latencyMs,
|
webhookLatencyMs: testResult?.latencyMs ?? null,
|
||||||
message: 'Bot registered. Webhook verified.',
|
message: isPollMode
|
||||||
|
? 'Bot registered in poll mode. No public URL needed. Use GET /api/fights/poll to receive challenges.'
|
||||||
|
: 'Bot registered. Webhook verified.',
|
||||||
}, 201)
|
}, 201)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+33
-23
@@ -39,19 +39,21 @@ botsRouter.post('/', rateLimit(3600_000, 5), async (c) => {
|
|||||||
// Force lowercase for case-insensitive uniqueness
|
// Force lowercase for case-insensitive uniqueness
|
||||||
const normalizedName = name.toLowerCase()
|
const normalizedName = name.toLowerCase()
|
||||||
|
|
||||||
if (!webhook_url || typeof webhook_url !== 'string') {
|
// Poll mode: webhook_url is optional
|
||||||
return c.json({ error: 'webhook_url is required.' }, 400)
|
const isPollMode = !webhook_url || webhook_url === ''
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
if (!isPollMode) {
|
||||||
new URL(webhook_url)
|
if (typeof webhook_url !== 'string') {
|
||||||
} catch {
|
return c.json({ error: 'webhook_url must be a string.' }, 400)
|
||||||
return c.json({ error: 'webhook_url must be a valid URL.' }, 400)
|
}
|
||||||
}
|
try {
|
||||||
|
new URL(webhook_url)
|
||||||
// SSRF check
|
} catch {
|
||||||
if (!isAllowedWebhookUrl(webhook_url)) {
|
return c.json({ error: 'webhook_url must be a valid URL.' }, 400)
|
||||||
return c.json({ error: 'webhook_url must not point to private/internal addresses.' }, 400)
|
}
|
||||||
|
if (!isAllowedWebhookUrl(webhook_url)) {
|
||||||
|
return c.json({ error: 'webhook_url must not point to private/internal addresses.' }, 400)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate name (case-insensitive)
|
// Check for duplicate name (case-insensitive)
|
||||||
@@ -64,23 +66,28 @@ botsRouter.post('/', rateLimit(3600_000, 5), async (c) => {
|
|||||||
return c.json({ error: 'A bot with that name already exists.' }, 409)
|
return c.json({ error: 'A bot with that name already exists.' }, 409)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test the webhook before accepting registration
|
// Test the webhook before accepting registration (skip for poll mode)
|
||||||
const testResult = await testWebhook(webhook_url)
|
let testResult: { latencyMs: number } | null = null
|
||||||
if (!testResult.reachable || !testResult.validResponse) {
|
if (!isPollMode) {
|
||||||
return c.json({
|
const result = await testWebhook(webhook_url)
|
||||||
error: 'Webhook verification failed.',
|
if (!result.reachable || !result.validResponse) {
|
||||||
details: testResult.error || 'Webhook must return {"answer": "..."} as JSON.',
|
return c.json({
|
||||||
latencyMs: testResult.latencyMs,
|
error: 'Webhook verification failed.',
|
||||||
}, 422)
|
details: result.error || 'Webhook must return {"answer": "..."} as JSON.',
|
||||||
|
latencyMs: result.latencyMs,
|
||||||
|
}, 422)
|
||||||
|
}
|
||||||
|
testResult = result
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = nanoid(12)
|
const id = nanoid(12)
|
||||||
const secret = randomBytes(32).toString('hex')
|
const secret = randomBytes(32).toString('hex')
|
||||||
|
const effectiveWebhookUrl = isPollMode ? 'http://poll.local/' : webhook_url
|
||||||
|
|
||||||
await db.insert(schema.bots).values({
|
await db.insert(schema.bots).values({
|
||||||
id,
|
id,
|
||||||
name: normalizedName,
|
name: normalizedName,
|
||||||
webhookUrl: webhook_url,
|
webhookUrl: effectiveWebhookUrl,
|
||||||
avatarSeed: avatar_seed || normalizedName,
|
avatarSeed: avatar_seed || normalizedName,
|
||||||
secretHash: hashSecret(secret),
|
secretHash: hashSecret(secret),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
@@ -90,8 +97,11 @@ botsRouter.post('/', rateLimit(3600_000, 5), async (c) => {
|
|||||||
id,
|
id,
|
||||||
name: normalizedName,
|
name: normalizedName,
|
||||||
secret,
|
secret,
|
||||||
webhookLatencyMs: testResult.latencyMs,
|
mode: isPollMode ? 'poll' : 'webhook',
|
||||||
message: 'Bot registered. Webhook verified. Save your secret -- it will not be shown again.',
|
webhookLatencyMs: testResult?.latencyMs ?? null,
|
||||||
|
message: isPollMode
|
||||||
|
? 'Bot registered in poll mode. Save your secret and bot ID. Use GET /api/fights/poll to receive challenges.'
|
||||||
|
: 'Bot registered. Webhook verified. Save your secret -- it will not be shown again.',
|
||||||
}, 201)
|
}, 201)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { runFight, runFightAsync, isInFight, getActiveFightId } from '../engine/
|
|||||||
import { fightEvents } from '../engine/events.js'
|
import { fightEvents } from '../engine/events.js'
|
||||||
import { botRateLimit } from '../middleware/rate-limit.js'
|
import { botRateLimit } from '../middleware/rate-limit.js'
|
||||||
import { getPendingChallenge, getPendingAnswers, submitHumanResponse } from '../engine/human-responses.js'
|
import { getPendingChallenge, getPendingAnswers, submitHumanResponse } from '../engine/human-responses.js'
|
||||||
|
import { getPendingPollChallenge, submitPollResponse } from '../engine/poll-responses.js'
|
||||||
|
import { authenticateBot } from '../middleware/bot-auth.js'
|
||||||
import { checkAnswer } from '../engine/answers.js'
|
import { checkAnswer } from '../engine/answers.js'
|
||||||
|
|
||||||
// --- Request validation schemas ---
|
// --- Request validation schemas ---
|
||||||
@@ -365,6 +367,55 @@ fightsRouter.post('/:fightId/respond/:botId', async (c) => {
|
|||||||
return c.json({ accepted: true, correct })
|
return c.json({ accepted: true, correct })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Polling API (for bots that don't expose a public URL) ---
|
||||||
|
|
||||||
|
// Poll for a pending challenge (bot authenticates with id+secret)
|
||||||
|
fightsRouter.get('/poll', async (c) => {
|
||||||
|
const botOrRes = await authenticateBot(c)
|
||||||
|
if (botOrRes instanceof Response) return botOrRes
|
||||||
|
const bot = botOrRes
|
||||||
|
const challenge = getPendingPollChallenge(bot.botId)
|
||||||
|
|
||||||
|
if (!challenge) {
|
||||||
|
return c.json({ pending: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
pending: true,
|
||||||
|
fight_id: challenge.fightId,
|
||||||
|
round: challenge.roundNumber,
|
||||||
|
type: challenge.type,
|
||||||
|
challenge: challenge.prompt,
|
||||||
|
constraints: challenge.constraints,
|
||||||
|
opponent: challenge.opponent,
|
||||||
|
arena: challenge.arena,
|
||||||
|
arena_modifier: challenge.arenaModifier,
|
||||||
|
remaining_ms: challenge.remainingMs,
|
||||||
|
scoring: challenge.scoring,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Submit answer to a pending poll challenge
|
||||||
|
fightsRouter.post('/poll/respond', async (c) => {
|
||||||
|
const botOrRes = await authenticateBot(c)
|
||||||
|
if (botOrRes instanceof Response) return botOrRes
|
||||||
|
const bot = botOrRes
|
||||||
|
const parsed = respondSchema.safeParse(await c.req.json().catch(() => ({})))
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return c.json({ error: 'Answer is required (string, 1-2000 chars).' }, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { answer, trashTalk } = parsed.data
|
||||||
|
const accepted = submitPollResponse(bot.botId, answer, trashTalk)
|
||||||
|
|
||||||
|
if (!accepted) {
|
||||||
|
return c.json({ error: 'No pending challenge. Either timed out or no active fight.' }, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ accepted: true })
|
||||||
|
})
|
||||||
|
|
||||||
// SSE stream for live fight events
|
// SSE stream for live fight events
|
||||||
fightsRouter.get('/:id/stream', (c) => {
|
fightsRouter.get('/:id/stream', (c) => {
|
||||||
const fightId = c.req.param('id')
|
const fightId = c.req.param('id')
|
||||||
|
|||||||
Reference in New Issue
Block a user