feat(bots): AI-answer settings + claim-winnings UI for existing bots
Two gaps found while answering "where can I change the API key for
existing users from a webhook":
1. The AI-answer API key ("let BotFights answer for me") could only
ever be set at the exact moment of bot creation on JoinBoutPage,
because that's the only place the bot's own secret is ever in the
browser's hands (Authorization: Bot <id>:<secret> was the sole auth
path for POST/GET/DELETE /api/bots/ai-config). An existing bot's
owner had no way back in to add, change, or remove their key later.
Fixed: new owner-scoped routes at /api/bots/:name/ai-config
(GET/POST/DELETE), authorized via verifyBotOwner (nostr JWT OR the
bot's own secret — see bot-auth.ts), reusing the same underlying
ai-bot-config storage. Wired into BotProfilePage's existing
owner-only settings area, mirroring the webhook-management section's
UX pattern (collapsed toggle, provider picker, masked key input,
configured/remove state).
2. Investigating the payout side of the same question ("can we confirm
the fighter wins all the cashu sats into their node wallet
automatically") surfaced that GET /winnings/:botId and POST
/claim/:paymentId existed on the backend but had NO frontend caller
anywhere — a Cashu payout (the common case: winner has no NWC/
Lightning-address wallet linked) minted a token that was completely
invisible in the UI.
Added a "claim your winnings" section to BotProfilePage, shown
proactively (not behind a toggle — it's the owner's own money):
lists unclaimed payouts with a CLAIM button, reveals the bearer
token once claimed with a copy-to-clipboard action and guidance to
paste it into any Cashu wallet (there's no "auto-deposit" for a
bearer token the way NWC allows for Lightning — no destination
address to push to).
Route-shadowing note: /:name/ai-config is a different segment count
than the existing bare /ai-config and /:name routes, so it can't
collide with either (unlike the /poll vs /:id and /ai-config vs /:name
bugs fixed earlier this session) — confirmed via the full route table.
13 new/updated tests in ai-config.test.ts (owner-JWT auth, wrong-owner
403, bot-secret still works via verifyBotOwner, no-auth 401). Full
server suite: 815/816 passing (only the same pre-existing CPU-load-
sensitive constant-time-comparison flake, confirmed unrelated and
passing in isolation). tsc --noEmit clean (server + frontend).
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useNostr, type NostrProfile } from '../composables/useNostr'
|
||||
import { authFetch } from '../lib/nostr-auth'
|
||||
import SpritePreview from '../components/SpritePreview.vue'
|
||||
import HumanPreview from '../components/HumanPreview.vue'
|
||||
import WalletConnect from '../components/WalletConnect.vue'
|
||||
@@ -97,6 +98,17 @@ const showCustomize = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const custError = ref('')
|
||||
|
||||
// Claim winnings (Cashu payouts sitting unclaimed — see payments.ts payWinner:
|
||||
// mints a bearer token server-side when the winner has no NWC/Lightning-address
|
||||
// wallet linked, which is the common case for a Cashu-primary bot). Fetched
|
||||
// proactively (not behind a toggle) since this is the owner's own money.
|
||||
interface UnclaimedWinning { paymentId: string; amountSats: number }
|
||||
const unclaimedWinnings = ref<UnclaimedWinning[]>([])
|
||||
const claimingId = ref<string | null>(null)
|
||||
const claimError = ref('')
|
||||
const claimedTokens = ref<{ paymentId: string; amountSats: number; token: string }[]>([])
|
||||
const tokenCopiedId = ref<string | null>(null)
|
||||
|
||||
// Webhook management
|
||||
const showWebhook = ref(false)
|
||||
const webhookInput = ref('')
|
||||
@@ -106,6 +118,68 @@ const webhookTestResult = ref<{ reachable: boolean; validResponse: boolean; late
|
||||
const webhookError = ref('')
|
||||
const webhookSuccess = ref('')
|
||||
|
||||
// AI-answer settings (existing bot — see /api/bots/:name/ai-config). Same
|
||||
// feature as JoinBoutPage's creation-time setup, but reachable afterward:
|
||||
// that flow only ever had the bot's own secret in hand at the moment of
|
||||
// creation, with nowhere to come back to later.
|
||||
const showAiConfig = ref(false)
|
||||
const aiConfigLoaded = ref(false)
|
||||
const aiConfigured = ref(false)
|
||||
const aiConfigProvider = ref<'anthropic' | 'openai' | null>(null)
|
||||
const aiProviderInput = ref<'anthropic' | 'openai'>('anthropic')
|
||||
const aiApiKeyInput = ref('')
|
||||
const aiConfigSaving = ref(false)
|
||||
const aiConfigError = ref('')
|
||||
|
||||
async function loadAiConfig() {
|
||||
if (!stats.value || aiConfigLoaded.value) return
|
||||
try {
|
||||
const res = await authFetch(`/api/bots/${encodeURIComponent(botName)}/ai-config`)
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { configured: boolean; provider: 'anthropic' | 'openai' | null }
|
||||
aiConfigured.value = data.configured
|
||||
aiConfigProvider.value = data.provider
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[BotProfile] ai-config load failed:', err)
|
||||
}
|
||||
aiConfigLoaded.value = true
|
||||
}
|
||||
|
||||
async function saveAiConfig() {
|
||||
if (!aiApiKeyInput.value.trim() || aiConfigSaving.value) return
|
||||
aiConfigSaving.value = true
|
||||
aiConfigError.value = ''
|
||||
try {
|
||||
const res = await authFetch(`/api/bots/${encodeURIComponent(botName)}/ai-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: aiProviderInput.value, apiKey: aiApiKeyInput.value.trim() }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
aiConfigError.value = data.error || 'Failed to save API key.'
|
||||
return
|
||||
}
|
||||
aiConfigured.value = true
|
||||
aiConfigProvider.value = aiProviderInput.value
|
||||
aiApiKeyInput.value = '' // never keep the raw key in page state longer than needed
|
||||
} catch {
|
||||
aiConfigError.value = 'Connection failed. Try again.'
|
||||
} finally {
|
||||
aiConfigSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAiConfig() {
|
||||
try {
|
||||
await authFetch(`/api/bots/${encodeURIComponent(botName)}/ai-config`, { method: 'DELETE' })
|
||||
} finally {
|
||||
aiConfigured.value = false
|
||||
aiConfigProvider.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Setup guide
|
||||
const showSetupGuide = ref(false)
|
||||
const isRegenerating = ref(false)
|
||||
@@ -240,6 +314,45 @@ async function saveCustomization() {
|
||||
isSaving.value = false
|
||||
}
|
||||
|
||||
async function fetchUnclaimedWinnings() {
|
||||
if (!stats.value || !isOwner.value) return
|
||||
try {
|
||||
const res = await authFetch(`/api/payments/winnings/${stats.value.id}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { unclaimed: UnclaimedWinning[] }
|
||||
unclaimedWinnings.value = data.unclaimed || []
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[BotProfile] winnings fetch failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function claimWinning(paymentId: string, amountSats: number) {
|
||||
if (claimingId.value) return
|
||||
claimingId.value = paymentId
|
||||
claimError.value = ''
|
||||
try {
|
||||
const res = await authFetch(`/api/payments/claim/${paymentId}`, { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
claimError.value = data.error || 'Claim failed.'
|
||||
return
|
||||
}
|
||||
claimedTokens.value.unshift({ paymentId, amountSats, token: data.cashuToken })
|
||||
unclaimedWinnings.value = unclaimedWinnings.value.filter(w => w.paymentId !== paymentId)
|
||||
} catch {
|
||||
claimError.value = 'Network error claiming winnings.'
|
||||
} finally {
|
||||
claimingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function copyClaimedToken(paymentId: string, token: string) {
|
||||
navigator.clipboard.writeText(token)
|
||||
tokenCopiedId.value = paymentId
|
||||
setTimeout(() => { if (tokenCopiedId.value === paymentId) tokenCopiedId.value = null }, 2500)
|
||||
}
|
||||
|
||||
async function testWebhook() {
|
||||
if (!stats.value || isTestingWebhook.value) return
|
||||
isTestingWebhook.value = true
|
||||
@@ -343,6 +456,10 @@ onMounted(async () => {
|
||||
}).catch(err => console.warn('[BotProfile] webhook test failed:', err))
|
||||
}
|
||||
|
||||
// Unclaimed Cashu winnings (non-blocking, owner only — fetchUnclaimedWinnings
|
||||
// itself checks isOwner, but stats must be loaded first)
|
||||
fetchUnclaimedWinnings()
|
||||
|
||||
// Poll queue for "choose your fight"
|
||||
pollQueue()
|
||||
pollHandle = setInterval(pollQueue, 4000)
|
||||
@@ -602,6 +719,56 @@ const tierClass = (t: number) => `tier-${t}`
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unclaimed Cashu winnings (owner only) — shown proactively, not
|
||||
behind a toggle: this is real money waiting on the winner.
|
||||
Payouts land here (instead of an auto-deposit) whenever the
|
||||
winner has no NWC/Lightning-address wallet linked, which is
|
||||
the common case for a Cashu-primary bot. -->
|
||||
<div v-if="isOwner && unclaimedWinnings.length > 0" class="mt-2 border-2 border-neon-yellow/50 bg-neon-yellow/10 p-3">
|
||||
<p class="font-display font-bold text-xs tracking-wider text-neon-yellow mb-2">
|
||||
🏆 YOU WON {{ unclaimedWinnings.reduce((s, w) => s + w.amountSats, 0) }} SATS — CLAIM YOUR CASHU
|
||||
</p>
|
||||
<div v-for="w in unclaimedWinnings" :key="w.paymentId" class="flex items-center justify-between gap-2 mb-1.5 last:mb-0">
|
||||
<span class="font-mono text-[10px] text-text-secondary">{{ w.amountSats }} sats</span>
|
||||
<button
|
||||
class="px-3 py-1.5 bg-neon-yellow/20 border border-neon-yellow/50 text-neon-yellow
|
||||
font-display font-bold text-[10px] tracking-wider
|
||||
hover:bg-neon-yellow/30 transition-all
|
||||
disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
:disabled="claimingId === w.paymentId"
|
||||
@click="claimWinning(w.paymentId, w.amountSats)"
|
||||
>
|
||||
{{ claimingId === w.paymentId ? 'CLAIMING...' : 'CLAIM' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="claimError" class="font-mono text-[10px] text-ko mt-1.5">{{ claimError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Just-claimed tokens: bearer instruments, revealed once. Copy
|
||||
into any Cashu wallet (e.g. Minibits) to redeem — there's no
|
||||
"auto-deposit" for Cashu the way NWC allows for Lightning,
|
||||
since a bearer token has no destination address to push to. -->
|
||||
<div v-if="claimedTokens.length > 0" class="mt-2 border border-neon-green/40 bg-neon-green/5 p-3 space-y-2">
|
||||
<div v-for="c in claimedTokens" :key="c.paymentId">
|
||||
<p class="font-display font-bold text-[10px] tracking-wider text-neon-green mb-1">
|
||||
✓ CLAIMED {{ c.amountSats }} SATS — paste into your Cashu wallet
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="flex-1 font-mono text-[9px] text-text-secondary break-all bg-black/30 border border-border p-1.5">
|
||||
{{ c.token }}
|
||||
</p>
|
||||
<button
|
||||
class="px-2 py-1.5 border border-neon-green/40 text-neon-green
|
||||
font-display font-bold text-[9px] tracking-wider
|
||||
hover:bg-neon-green/10 transition-all flex-shrink-0"
|
||||
@click="copyClaimedToken(c.paymentId, c.token)"
|
||||
>
|
||||
{{ tokenCopiedId === c.paymentId ? 'COPIED' : 'COPY' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wallet connection (owner only) -->
|
||||
<div v-if="isOwner" class="mt-3">
|
||||
<WalletConnect />
|
||||
@@ -834,6 +1001,78 @@ const tierClass = (t: number) => `tier-${t}`
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI-answer settings (owner only, bots only): "let BotFights
|
||||
answer for me" via an operator-supplied Anthropic/OpenAI key.
|
||||
Same feature JoinBoutPage offers at creation time, now also
|
||||
reachable afterward — for changing/rotating the key, or
|
||||
turning it on for a bot that skipped it at creation. -->
|
||||
<div v-if="isOwner && stats.archetype !== 'human' && !stats.isHuman" class="mt-4">
|
||||
<button
|
||||
class="w-full py-2 border border-border text-text-secondary font-display font-bold text-[10px]
|
||||
tracking-wider hover:border-neon-purple/40 hover:text-neon-purple transition-all text-center"
|
||||
@click="showAiConfig = !showAiConfig; if (showAiConfig) loadAiConfig()"
|
||||
>
|
||||
{{ showAiConfig ? 'HIDE' : 'AI ANSWER' }} SETTINGS
|
||||
</button>
|
||||
|
||||
<div v-if="showAiConfig" class="mt-3 border border-border bg-surface-raised/60 p-4 space-y-3">
|
||||
<p class="font-mono text-[10px] text-text-muted">
|
||||
Let BotFights answer poll-mode fights for you using your own
|
||||
Anthropic or OpenAI API key — no script or webhook required.
|
||||
</p>
|
||||
|
||||
<div v-if="!aiConfigLoaded" class="font-mono text-[10px] text-text-muted">Loading...</div>
|
||||
|
||||
<div v-else-if="aiConfigured" class="flex items-center justify-between p-2 border border-neon-green/30 bg-neon-green/5">
|
||||
<span class="font-mono text-[10px] text-neon-green">
|
||||
✓ Configured ({{ aiConfigProvider }})
|
||||
</span>
|
||||
<button
|
||||
class="px-2 py-1 border border-ko/40 text-ko font-display font-bold text-[9px] tracking-wider hover:bg-ko/10 transition-all"
|
||||
@click="removeAiConfig"
|
||||
>
|
||||
REMOVE
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="p in (['anthropic', 'openai'] as const)"
|
||||
:key="p"
|
||||
class="flex-1 py-1.5 border font-display font-bold text-[9px] tracking-wider transition-all"
|
||||
:class="aiProviderInput === p
|
||||
? 'border-neon-purple/50 bg-neon-purple/10 text-neon-purple'
|
||||
: 'border-border text-text-muted hover:border-neon-purple/30'"
|
||||
@click="aiProviderInput = p"
|
||||
>
|
||||
{{ p.toUpperCase() }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="aiApiKeyInput"
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
class="w-full px-2 py-1.5 bg-black/30 border border-border font-mono text-[10px] text-text-primary
|
||||
placeholder-text-muted/50 focus:outline-none focus:border-neon-purple/50"
|
||||
@keyup.enter="saveAiConfig"
|
||||
/>
|
||||
<button
|
||||
class="w-full py-1.5 bg-neon-purple/10 border border-neon-purple/40 text-neon-purple
|
||||
font-display font-bold text-[10px] tracking-wider
|
||||
hover:bg-neon-purple/20 transition-all
|
||||
disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
:disabled="!aiApiKeyInput.trim() || aiConfigSaving"
|
||||
@click="saveAiConfig"
|
||||
>
|
||||
{{ aiConfigSaving ? 'SAVING...' : 'SAVE KEY' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="aiConfigError" class="font-mono text-[10px] text-ko">{{ aiConfigError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Setup guide download (owner only, bots only) -->
|
||||
<div v-if="isOwner && stats.archetype !== 'human' && !stats.isHuman" class="mt-4">
|
||||
<button
|
||||
|
||||
@@ -2,11 +2,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { Hono } from 'hono'
|
||||
|
||||
// Mock DB: authenticateBot looks up a bot by id via db.select(...).from(...).where(...).limit(...)
|
||||
const TEST_PUBKEY = 'b'.repeat(64)
|
||||
const mockBotRow = {
|
||||
id: 'bot_test123',
|
||||
name: 'testbot',
|
||||
secretHash: 'aa'.repeat(32), // placeholder; overridden per-test via crypto mock below
|
||||
webhookUrl: 'http://poll.local/',
|
||||
publicKey: TEST_PUBKEY,
|
||||
}
|
||||
|
||||
vi.mock('../db/index.js', () => ({
|
||||
@@ -52,11 +54,13 @@ const REAL_SECRET = 'test-bot-secret-1234567890'
|
||||
mockBotRow.secretHash = createHash('sha256').update(REAL_SECRET).digest('hex')
|
||||
|
||||
const { botsRouter } = await import('./bots.js')
|
||||
const { createJwt } = await import('../middleware/jwt.js')
|
||||
|
||||
const app = new Hono()
|
||||
app.route('/api/bots', botsRouter)
|
||||
|
||||
const AUTH = { Authorization: `Bot ${mockBotRow.id}:${REAL_SECRET}` }
|
||||
const OWNER_JWT_AUTH = { Authorization: `Bearer ${createJwt(TEST_PUBKEY)}` }
|
||||
|
||||
beforeEach(() => { store.clear() })
|
||||
|
||||
@@ -131,3 +135,66 @@ describe('bots ai-config routes', () => {
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Existing-bot owner settings page: /api/bots/:name/ai-config ---
|
||||
// These exist because the routes above require the bot's own secret, which
|
||||
// is only ever available at the exact moment of creation (JoinBoutPage) —
|
||||
// there was previously no way to add/change/remove an AI key for a bot
|
||||
// after that moment, even for its nostr-logged-in owner.
|
||||
describe('bots :name/ai-config routes (existing-bot owner settings)', () => {
|
||||
it('GET /api/bots/:name/ai-config with a valid owner JWT returns configured status', async () => {
|
||||
const res = await app.request('/api/bots/testbot/ai-config', { headers: OWNER_JWT_AUTH })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ configured: false, provider: null })
|
||||
})
|
||||
|
||||
it('POST /api/bots/:name/ai-config with a valid owner JWT sets the config', async () => {
|
||||
const res = await app.request('/api/bots/testbot/ai-config', {
|
||||
method: 'POST',
|
||||
headers: { ...OWNER_JWT_AUTH, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: 'anthropic', apiKey: 'sk-ant-fake-key-value' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ configured: true, provider: 'anthropic' })
|
||||
|
||||
// Same underlying storage as the bot-secret path — visible either way.
|
||||
const check = await app.request('/api/bots/ai-config', { headers: AUTH })
|
||||
expect(await check.json()).toEqual({ configured: true, provider: 'anthropic' })
|
||||
})
|
||||
|
||||
it('DELETE /api/bots/:name/ai-config with a valid owner JWT removes the config', async () => {
|
||||
await app.request('/api/bots/testbot/ai-config', {
|
||||
method: 'POST',
|
||||
headers: { ...OWNER_JWT_AUTH, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: 'openai', apiKey: 'sk-openai-fake-key-value' }),
|
||||
})
|
||||
const del = await app.request('/api/bots/testbot/ai-config', { method: 'DELETE', headers: OWNER_JWT_AUTH })
|
||||
expect(del.status).toBe(200)
|
||||
const check = await app.request('/api/bots/testbot/ai-config', { headers: OWNER_JWT_AUTH })
|
||||
expect(await check.json()).toEqual({ configured: false, provider: null })
|
||||
})
|
||||
|
||||
it('rejects a JWT for a DIFFERENT pubkey than the bot owner (403)', async () => {
|
||||
const wrongOwnerJwt = { Authorization: `Bearer ${createJwt('c'.repeat(64))}` }
|
||||
const res = await app.request('/api/bots/testbot/ai-config', {
|
||||
method: 'POST',
|
||||
headers: { ...wrongOwnerJwt, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: 'anthropic', apiKey: 'sk-ant-fake-key-value' }),
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('rejects requests with no auth at all (401)', async () => {
|
||||
const res = await app.request('/api/bots/testbot/ai-config')
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('also accepts the bot\'s own secret (Authorization: Bot id:secret) via verifyBotOwner', async () => {
|
||||
const res = await app.request('/api/bots/testbot/ai-config', {
|
||||
method: 'POST',
|
||||
headers: { ...AUTH, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: 'openai', apiKey: 'sk-openai-fake-key-value' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ import { isAllowedWebhookUrl } from '../engine/orchestrator.js'
|
||||
import { testWebhook } from '../engine/webhook-test.js'
|
||||
import { rateLimit } from '../middleware/rate-limit.js'
|
||||
import { botNameSchema, httpUrlSchema } from '../lib/validators.js'
|
||||
import { authenticateBot } from '../middleware/bot-auth.js'
|
||||
import { authenticateBot, verifyBotOwner } from '../middleware/bot-auth.js'
|
||||
import { setAiBotConfig, getAiBotConfig, deleteAiBotConfig, type LlmProvider } from '../engine/ai-bot-config.js'
|
||||
|
||||
export const botsRouter = new Hono()
|
||||
@@ -193,6 +193,77 @@ botsRouter.delete('/ai-config', async (c) => {
|
||||
return c.json({ configured: false })
|
||||
})
|
||||
|
||||
// --- Same feature, for an EXISTING bot's owner settings page ---
|
||||
// The routes above require the bot's own secret (Authorization: Bot
|
||||
// <id>:<secret>), which only the JoinBoutPage bot-creation flow has in hand
|
||||
// at the moment of creation — it's never persisted anywhere the browser can
|
||||
// re-fetch it. Before this, there was no way for an existing bot's owner to
|
||||
// add, change, or remove their AI key later; they'd have to still be on the
|
||||
// exact creation tab. These are owner-scoped by :name + nostr JWT
|
||||
// (verifyBotOwner also accepts the bot's own secret, so an AI agent that
|
||||
// happens to hold both could use either path — no harm either way).
|
||||
//
|
||||
// MUST be registered before GET /:name below for the same reason as
|
||||
// /ai-config above (Hono resolves same-segment-count routes in registration
|
||||
// order) — but :name/ai-config is a DIFFERENT segment count than :name, so
|
||||
// it can't actually collide with it; kept adjacent for readability, not
|
||||
// because ordering is load-bearing here.
|
||||
botsRouter.get('/:name/ai-config', async (c) => {
|
||||
const name = c.req.param('name')
|
||||
const rows = await db.select({ id: schema.bots.id })
|
||||
.from(schema.bots)
|
||||
.where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase()))
|
||||
.limit(1)
|
||||
if (rows.length === 0) return c.json({ error: 'Bot not found.' }, 404)
|
||||
|
||||
const ownerCheck = await verifyBotOwner(c, rows[0].id)
|
||||
if (ownerCheck !== true) return ownerCheck
|
||||
|
||||
const config = getAiBotConfig(rows[0].id)
|
||||
return c.json({ configured: !!config, provider: config?.provider ?? null })
|
||||
})
|
||||
|
||||
botsRouter.post('/:name/ai-config', rateLimit(60_000, 10), async (c) => {
|
||||
const name = c.req.param('name')
|
||||
const rows = await db.select({ id: schema.bots.id })
|
||||
.from(schema.bots)
|
||||
.where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase()))
|
||||
.limit(1)
|
||||
if (rows.length === 0) return c.json({ error: 'Bot not found.' }, 404)
|
||||
|
||||
const ownerCheck = await verifyBotOwner(c, rows[0].id)
|
||||
if (ownerCheck !== true) return ownerCheck
|
||||
|
||||
const body = await c.req.json().catch(() => ({})) as { provider?: string; apiKey?: string }
|
||||
const provider = body.provider
|
||||
const apiKey = body.apiKey?.trim()
|
||||
|
||||
if (!provider || !AI_PROVIDERS.includes(provider as LlmProvider)) {
|
||||
return c.json({ error: `provider must be one of: ${AI_PROVIDERS.join(', ')}` }, 400)
|
||||
}
|
||||
if (!apiKey || apiKey.length < 8 || apiKey.length > 512) {
|
||||
return c.json({ error: 'apiKey is required (8-512 chars).' }, 400)
|
||||
}
|
||||
|
||||
setAiBotConfig(rows[0].id, { provider: provider as LlmProvider, apiKey })
|
||||
return c.json({ configured: true, provider })
|
||||
})
|
||||
|
||||
botsRouter.delete('/:name/ai-config', async (c) => {
|
||||
const name = c.req.param('name')
|
||||
const rows = await db.select({ id: schema.bots.id })
|
||||
.from(schema.bots)
|
||||
.where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase()))
|
||||
.limit(1)
|
||||
if (rows.length === 0) return c.json({ error: 'Bot not found.' }, 404)
|
||||
|
||||
const ownerCheck = await verifyBotOwner(c, rows[0].id)
|
||||
if (ownerCheck !== true) return ownerCheck
|
||||
|
||||
deleteAiBotConfig(rows[0].id)
|
||||
return c.json({ configured: false })
|
||||
})
|
||||
|
||||
// Get single bot profile
|
||||
botsRouter.get('/:name', async (c) => {
|
||||
const name = c.req.param('name')
|
||||
|
||||
Reference in New Issue
Block a user