185 lines
4.3 KiB
TypeScript
185 lines
4.3 KiB
TypeScript
import { ref, readonly, computed } from 'vue'
|
|||
|
|
|
||
|
|
interface BotData {
|
||
|
|
id: string
|
||
|
|
name: string
|
||
|
|
avatarSeed: string
|
||
|
|
archetype: string
|
||
|
|
profilePicUrl: string | null
|
||
|
|
eloRating: number
|
||
|
|
wins: number
|
||
|
|
losses: number
|
||
|
|
winStreak: number
|
||
|
|
bestStreak: number
|
||
|
|
tier: number
|
||
|
|
}
|
||
|
|
|
||
|
|
interface NostrWindow {
|
||
|
|
getPublicKey(): Promise<string>
|
||
|
|
signEvent(event: Record<string, unknown>): Promise<Record<string, unknown>>
|
||
|
|
getRelays?(): Promise<Record<string, { read: boolean; write: boolean }>>
|
||
|
|
}
|
||
|
|
|
||
|
|
declare global {
|
||
|
|
interface Window {
|
||
|
|
nostr?: NostrWindow
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const pubkey = ref<string | null>(null)
|
||
|
|
const bot = ref<BotData | null>(null)
|
||
|
|
const profilePicUrl = ref<string | null>(null)
|
||
|
|
const isLoading = ref(false)
|
||
|
|
|
||
|
|
export function useNostr() {
|
||
|
|
const isLoggedIn = computed(() => !!pubkey.value && !!bot.value)
|
||
|
|
const hasExtension = computed(() => !!window.nostr)
|
||
|
|
|
||
|
|
async function login(): Promise<{ pubkey: string; bot: BotData | null }> {
|
||
|
|
if (!window.nostr) {
|
||
|
|
throw new Error('No Nostr extension found. Install nos2x, Alby, or another NIP-07 extension.')
|
||
|
|
}
|
||
|
|
|
||
|
|
isLoading.value = true
|
||
|
|
try {
|
||
|
|
const pk = await window.nostr.getPublicKey()
|
||
|
|
pubkey.value = pk
|
||
|
|
|
||
|
|
// Fetch Nostr profile pic from relay
|
||
|
|
const pic = await fetchNostrProfilePic(pk)
|
||
|
|
if (pic) profilePicUrl.value = pic
|
||
|
|
|
||
|
|
// Check if this pubkey has a bot
|
||
|
|
const res = await fetch('/api/auth/login', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ pubkey: pk }),
|
||
|
|
})
|
||
|
|
|
||
|
|
if (res.ok) {
|
||
|
|
const data = await res.json()
|
||
|
|
if (data.exists) {
|
||
|
|
bot.value = data.bot
|
||
|
|
return { pubkey: pk, bot: data.bot }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return { pubkey: pk, bot: null }
|
||
|
|
} finally {
|
||
|
|
isLoading.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function registerBot(name: string, webhookUrl: string, archetype: string): Promise<BotData> {
|
||
|
|
if (!pubkey.value) throw new Error('Not logged in')
|
||
|
|
|
||
|
|
const res = await fetch('/api/auth/register', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({
|
||
|
|
pubkey: pubkey.value,
|
||
|
|
name,
|
||
|
|
webhookUrl,
|
||
|
|
archetype,
|
||
|
|
profilePicUrl: profilePicUrl.value,
|
||
|
|
}),
|
||
|
|
})
|
||
|
|
|
||
|
|
const data = await res.json()
|
||
|
|
if (!res.ok) throw new Error(data.error || 'Registration failed')
|
||
|
|
|
||
|
|
bot.value = {
|
||
|
|
id: data.id,
|
||
|
|
name: data.name,
|
||
|
|
avatarSeed: data.name,
|
||
|
|
archetype: data.archetype,
|
||
|
|
profilePicUrl: profilePicUrl.value,
|
||
|
|
eloRating: 1200,
|
||
|
|
wins: 0,
|
||
|
|
losses: 0,
|
||
|
|
winStreak: 0,
|
||
|
|
bestStreak: 0,
|
||
|
|
tier: 0,
|
||
|
|
}
|
||
|
|
|
||
|
|
return bot.value
|
||
|
|
}
|
||
|
|
|
||
|
|
function logout() {
|
||
|
|
pubkey.value = null
|
||
|
|
bot.value = null
|
||
|
|
profilePicUrl.value = null
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
pubkey: readonly(pubkey),
|
||
|
|
bot: readonly(bot),
|
||
|
|
profilePicUrl: readonly(profilePicUrl),
|
||
|
|
isLoggedIn,
|
||
|
|
isLoading: readonly(isLoading),
|
||
|
|
hasExtension,
|
||
|
|
login,
|
||
|
|
registerBot,
|
||
|
|
logout,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Fetch profile picture from a Nostr relay
|
||
|
|
async function fetchNostrProfilePic(pk: string): Promise<string | null> {
|
||
|
|
const relays = [
|
||
|
|
'wss://relay.damus.io',
|
||
|
|
'wss://relay.nostr.band',
|
||
|
|
'wss://nos.lol',
|
||
|
|
]
|
||
|
|
|
||
|
|
for (const relay of relays) {
|
||
|
|
try {
|
||
|
|
const pic = await queryRelay(relay, pk)
|
||
|
|
if (pic) return pic
|
||
|
|
} catch {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
|
||
|
|
function queryRelay(url: string, pk: string): Promise<string | null> {
|
||
|
|
return new Promise((resolve) => {
|
||
|
|
const timeout = setTimeout(() => {
|
||
|
|
ws.close()
|
||
|
|
resolve(null)
|
||
|
|
}, 3000)
|
||
|
|
|
||
|
|
const ws = new WebSocket(url)
|
||
|
|
const subId = Math.random().toString(36).slice(2, 10)
|
||
|
|
|
||
|
|
ws.onopen = () => {
|
||
|
|
// Request kind 0 (metadata) for this pubkey
|
||
|
|
ws.send(JSON.stringify(['REQ', subId, { kinds: [0], authors: [pk], limit: 1 }]))
|
||
|
|
}
|
||
|
|
|
||
|
|
ws.onmessage = (msg) => {
|
||
|
|
try {
|
||
|
|
const data = JSON.parse(msg.data)
|
||
|
|
if (data[0] === 'EVENT' && data[2]?.kind === 0) {
|
||
|
|
const meta = JSON.parse(data[2].content)
|
||
|
|
clearTimeout(timeout)
|
||
|
|
ws.close()
|
||
|
|
resolve(meta.picture || null)
|
||
|
|
} else if (data[0] === 'EOSE') {
|
||
|
|
clearTimeout(timeout)
|
||
|
|
ws.close()
|
||
|
|
resolve(null)
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// ignore parse errors
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
ws.onerror = () => {
|
||
|
|
clearTimeout(timeout)
|
||
|
|
resolve(null)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|