From 41f1b93e9ec18aa239cd796bbbf3fbf83fdd13d2 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 31 Jul 2026 14:54:40 -0400 Subject: [PATCH] feat(bots): AI-answer settings + claim-winnings UI for existing bots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 : 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 --- frontend/src/pages/BotProfilePage.vue | 239 ++++++++++++++++++++++++++ server/src/routes/ai-config.test.ts | 67 ++++++++ server/src/routes/bots.ts | 73 +++++++- 3 files changed, 378 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/BotProfilePage.vue b/frontend/src/pages/BotProfilePage.vue index 526b761..9b610df 100644 --- a/frontend/src/pages/BotProfilePage.vue +++ b/frontend/src/pages/BotProfilePage.vue @@ -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([]) +const claimingId = ref(null) +const claimError = ref('') +const claimedTokens = ref<{ paymentId: string; amountSats: number; token: string }[]>([]) +const tokenCopiedId = ref(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}` + +
+

+ 🏆 YOU WON {{ unclaimedWinnings.reduce((s, w) => s + w.amountSats, 0) }} SATS — CLAIM YOUR CASHU +

+
+ {{ w.amountSats }} sats + +
+

{{ claimError }}

+
+ + +
+
+

+ ✓ CLAIMED {{ c.amountSats }} SATS — paste into your Cashu wallet +

+
+

+ {{ c.token }} +

+ +
+
+
+
@@ -834,6 +1001,78 @@ const tierClass = (t: number) => `tier-${t}`
+ +
+ + +
+

+ Let BotFights answer poll-mode fights for you using your own + Anthropic or OpenAI API key — no script or webhook required. +

+ +
Loading...
+ +
+ + ✓ Configured ({{ aiConfigProvider }}) + + +
+ +
+
+ +
+ + +
+ +

{{ aiConfigError }}

+
+
+