feat: Nostr profile enrichment with caching
Fetch full kind:0 metadata (display_name, about, banner, nip05) from relays with a 5-min TTL cache. Show Nostr banner, display name, and NIP-05 on bot profile pages. Expose ownerPubkey in stats API. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
7761f713d7
commit
d1fe4d6e83
@@ -397,21 +397,46 @@ export function useNostr() {
|
|||||||
updateWebhook,
|
updateWebhook,
|
||||||
getStoredNsec,
|
getStoredNsec,
|
||||||
logout,
|
logout,
|
||||||
|
fetchNostrProfile,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NostrProfile {
|
||||||
|
displayName: string | null
|
||||||
|
about: string | null
|
||||||
|
picture: string | null
|
||||||
|
banner: string | null
|
||||||
|
nip05: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile cache with 5-min TTL
|
||||||
|
const profileCache = new Map<string, { data: NostrProfile; ts: number }>()
|
||||||
|
const PROFILE_TTL = 5 * 60 * 1000
|
||||||
|
|
||||||
|
const RELAYS = [
|
||||||
|
'wss://relay.damus.io',
|
||||||
|
'wss://relay.nostr.band',
|
||||||
|
'wss://nos.lol',
|
||||||
|
]
|
||||||
|
|
||||||
// Fetch profile picture from a Nostr relay
|
// Fetch profile picture from a Nostr relay
|
||||||
async function fetchNostrProfilePic(pk: string): Promise<string | null> {
|
async function fetchNostrProfilePic(pk: string): Promise<string | null> {
|
||||||
const relays = [
|
const profile = await fetchNostrProfile(pk)
|
||||||
'wss://relay.damus.io',
|
return profile?.picture || null
|
||||||
'wss://relay.nostr.band',
|
}
|
||||||
'wss://nos.lol',
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const relay of relays) {
|
/** Fetch full Nostr profile (kind:0 metadata) with caching */
|
||||||
|
async function fetchNostrProfile(pk: string): Promise<NostrProfile | null> {
|
||||||
|
const cached = profileCache.get(pk)
|
||||||
|
if (cached && Date.now() - cached.ts < PROFILE_TTL) return cached.data
|
||||||
|
|
||||||
|
for (const relay of RELAYS) {
|
||||||
try {
|
try {
|
||||||
const pic = await queryRelay(relay, pk)
|
const profile = await queryRelayProfile(relay, pk)
|
||||||
if (pic) return pic
|
if (profile) {
|
||||||
|
profileCache.set(pk, { data: profile, ts: Date.now() })
|
||||||
|
return profile
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -419,7 +444,7 @@ async function fetchNostrProfilePic(pk: string): Promise<string | null> {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function queryRelay(url: string, pk: string): Promise<string | null> {
|
function queryRelayProfile(url: string, pk: string): Promise<NostrProfile | null> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let timedOut = false
|
let timedOut = false
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
@@ -433,7 +458,6 @@ function queryRelay(url: string, pk: string): Promise<string | null> {
|
|||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
if (timedOut) { ws.close(); return }
|
if (timedOut) { ws.close(); return }
|
||||||
// Request kind 0 (metadata) for this pubkey
|
|
||||||
ws.send(JSON.stringify(['REQ', subId, { kinds: [0], authors: [pk], limit: 1 }]))
|
ws.send(JSON.stringify(['REQ', subId, { kinds: [0], authors: [pk], limit: 1 }]))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,7 +468,13 @@ function queryRelay(url: string, pk: string): Promise<string | null> {
|
|||||||
const meta = JSON.parse(data[2].content)
|
const meta = JSON.parse(data[2].content)
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
ws.close()
|
ws.close()
|
||||||
resolve(meta.picture || null)
|
resolve({
|
||||||
|
displayName: meta.display_name || meta.name || null,
|
||||||
|
about: meta.about || null,
|
||||||
|
picture: meta.picture || null,
|
||||||
|
banner: meta.banner || null,
|
||||||
|
nip05: meta.nip05 || null,
|
||||||
|
})
|
||||||
} else if (data[0] === 'EOSE') {
|
} else if (data[0] === 'EOSE') {
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
ws.close()
|
ws.close()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||||
import { useNostr } from '../composables/useNostr'
|
import { useNostr, type NostrProfile } from '../composables/useNostr'
|
||||||
import SpritePreview from '../components/SpritePreview.vue'
|
import SpritePreview from '../components/SpritePreview.vue'
|
||||||
import HumanPreview from '../components/HumanPreview.vue'
|
import HumanPreview from '../components/HumanPreview.vue'
|
||||||
import WalletConnect from '../components/WalletConnect.vue'
|
import WalletConnect from '../components/WalletConnect.vue'
|
||||||
@@ -10,7 +10,7 @@ import type { SpriteCustomization } from '../game/sprites'
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { bot: nostrBot, pubkey, isLoggedIn, logout, updateCustomization, updateWebhook } = useNostr()
|
const { bot: nostrBot, pubkey, isLoggedIn, logout, updateCustomization, updateWebhook, fetchNostrProfile } = useNostr()
|
||||||
const botName = route.params.name as string
|
const botName = route.params.name as string
|
||||||
|
|
||||||
interface BotCustomization {
|
interface BotCustomization {
|
||||||
@@ -29,6 +29,7 @@ interface BotStats {
|
|||||||
archetype: string
|
archetype: string
|
||||||
customization: BotCustomization | null
|
customization: BotCustomization | null
|
||||||
profilePicUrl: string | null
|
profilePicUrl: string | null
|
||||||
|
ownerPubkey: string | null
|
||||||
eloRating: number
|
eloRating: number
|
||||||
wins: number
|
wins: number
|
||||||
losses: number
|
losses: number
|
||||||
@@ -86,6 +87,7 @@ const showChoose = ref(false)
|
|||||||
const waitingFighters = ref<QueueEntry[]>([])
|
const waitingFighters = ref<QueueEntry[]>([])
|
||||||
let pollHandle: ReturnType<typeof setInterval> | null = null
|
let pollHandle: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const nostrProfile = ref<NostrProfile | null>(null)
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
const fightError = ref('')
|
const fightError = ref('')
|
||||||
const showCustomize = ref(false)
|
const showCustomize = ref(false)
|
||||||
@@ -276,6 +278,13 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
|
|
||||||
|
// Fetch Nostr profile for the bot owner (non-blocking)
|
||||||
|
if (stats.value?.ownerPubkey) {
|
||||||
|
fetchNostrProfile(stats.value.ownerPubkey).then(p => {
|
||||||
|
nostrProfile.value = p
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
// Poll queue for "choose your fight"
|
// Poll queue for "choose your fight"
|
||||||
pollQueue()
|
pollQueue()
|
||||||
pollHandle = setInterval(pollQueue, 4000)
|
pollHandle = setInterval(pollQueue, 4000)
|
||||||
@@ -360,7 +369,14 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
|
|
||||||
<!-- LEFT COLUMN: Character + Stats + Actions -->
|
<!-- LEFT COLUMN: Character + Stats + Actions -->
|
||||||
<div class="lg:w-[380px] lg:shrink-0 flex flex-col">
|
<div class="lg:w-[380px] lg:shrink-0 flex flex-col">
|
||||||
<div class="border border-border bg-surface-raised/30 p-6 overflow-hidden">
|
<!-- Nostr banner -->
|
||||||
|
<div v-if="nostrProfile?.banner" class="relative h-24 -mx-0 mb-0 overflow-hidden border border-border border-b-0">
|
||||||
|
<img :src="nostrProfile.banner" alt="" class="w-full h-full object-cover opacity-60" />
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-surface-base to-transparent" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border border-border bg-surface-raised/30 p-6 overflow-hidden"
|
||||||
|
:class="{ 'border-t-0': nostrProfile?.banner }">
|
||||||
<!-- Human player: just the human avatar, centered -->
|
<!-- Human player: just the human avatar, centered -->
|
||||||
<div v-if="stats.archetype === 'human'" class="flex justify-center mb-4">
|
<div v-if="stats.archetype === 'human'" class="flex justify-center mb-4">
|
||||||
<HumanPreview
|
<HumanPreview
|
||||||
@@ -409,6 +425,15 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
<p class="font-mono text-text-muted text-[10px] mt-0.5">
|
<p class="font-mono text-text-muted text-[10px] mt-0.5">
|
||||||
#{{ stats.rank }} of {{ stats.totalBots }}
|
#{{ stats.rank }} of {{ stats.totalBots }}
|
||||||
</p>
|
</p>
|
||||||
|
<!-- Nostr identity -->
|
||||||
|
<div v-if="nostrProfile?.displayName || nostrProfile?.nip05" class="mt-2 space-y-0.5">
|
||||||
|
<p v-if="nostrProfile.displayName" class="font-mono text-xs text-text-secondary">
|
||||||
|
{{ nostrProfile.displayName }}
|
||||||
|
</p>
|
||||||
|
<p v-if="nostrProfile.nip05" class="font-mono text-[10px] text-neon-purple">
|
||||||
|
{{ nostrProfile.nip05 }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
v-if="isOwner && stats.archetype !== 'human'"
|
v-if="isOwner && stats.archetype !== 'human'"
|
||||||
class="mt-2 font-mono text-[10px] text-neon-cyan/60 hover:text-neon-cyan transition-colors"
|
class="mt-2 font-mono text-[10px] text-neon-cyan/60 hover:text-neon-cyan transition-colors"
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ botsRouter.get('/:name/stats', async (c) => {
|
|||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
...publicBot,
|
...publicBot,
|
||||||
|
ownerPubkey: rawBot.publicKey,
|
||||||
tierName: TIER_NAMES[bot.tier] || 'BABY',
|
tierName: TIER_NAMES[bot.tier] || 'BABY',
|
||||||
tierColor: TIER_COLORS[bot.tier] || '#888',
|
tierColor: TIER_COLORS[bot.tier] || '#888',
|
||||||
winRate,
|
winRate,
|
||||||
|
|||||||
Reference in New Issue
Block a user