import { xrpcUrl, comAtprotoIdentity, comAtprotoRepo } from '../lexicons' import type { AppConfig, Networks, Profile, Post, ListRecordsResponse, ChatMessage, CardCollection } from '../types' // Fetch with timeout (default 10 seconds) async function fetchWithTimeout(url: string, options: RequestInit = {}, timeout = 10000): Promise { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), timeout) try { const res = await fetch(url, { ...options, signal: controller.signal }) return res } finally { clearTimeout(timeoutId) } } // Cache let configCache: AppConfig | null = null let networksCache: Networks | null = null // Load config.json export async function getConfig(): Promise { if (configCache) return configCache const res = await fetch('/config.json') configCache = await res.json() return configCache! } // Load networks.json export async function getNetworks(): Promise { if (networksCache) return networksCache const res = await fetch('/networks.json') networksCache = await res.json() return networksCache! } // Resolve handle to DID (try all networks) export async function resolveHandle(handle: string): Promise { const networks = await getNetworks() // Try each network until one succeeds for (const network of Object.values(networks)) { try { const host = network.bsky.replace('https://', '') const url = `${xrpcUrl(host, comAtprotoIdentity.resolveHandle)}?handle=${handle}` const res = await fetchWithTimeout(url, {}, 5000) if (res.ok) { const data = await res.json() return data.did } } catch { // Try next network (timeout or error) } } return null } // Get PDS endpoint for DID (try all networks) export async function getPds(did: string): Promise { const networks = await getNetworks() for (const network of Object.values(networks)) { try { const res = await fetchWithTimeout(`${network.plc}/${did}`, {}, 5000) if (res.ok) { const didDoc = await res.json() const service = didDoc.service?.find((s: { type: string }) => s.type === 'AtprotoPersonalDataServer') if (service?.serviceEndpoint) { return service.serviceEndpoint } } } catch { // Try next network (timeout or error) } } return null } // Check if response is JSON function isJsonResponse(res: Response): boolean { const contentType = res.headers.get('content-type') return contentType?.includes('application/json') ?? false } // Load local profile async function getLocalProfile(did: string): Promise { try { const res = await fetch(`/content/${did}/app.bsky.actor.profile/self.json`) if (res.ok && isJsonResponse(res)) return res.json() } catch { // Not found } return null } // Load profile (local only for admin, remote for others) export async function getProfile(did: string, localOnly = false): Promise { // Try local first const local = await getLocalProfile(did) if (local) return local // If local only mode, don't call API if (localOnly) return null // Remote fallback const pds = await getPds(did) if (!pds) return null try { const host = pds.replace('https://', '') const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=app.bsky.actor.profile&rkey=self` const res = await fetchWithTimeout(url, {}, 8000) if (res.ok) return res.json() } catch { // Failed or timeout } return null } // Get avatar URL (local only for admin, remote for others) export function getAvatarUrl(did: string, profile: Profile, localOnly = false): string | null { if (!profile.value.avatar) return null const cid = profile.value.avatar.ref.$link // Local mode: use local blob path (sync command downloads this) if (localOnly) { return `/content/${did}/blob/${cid}` } // Remote mode: use PDS blob URL (requires getPds call from caller if needed) return null } // Get avatar URL with PDS lookup (async, for remote users) export async function getAvatarUrlRemote(did: string, profile: Profile): Promise { if (!profile.value.avatar) return null const pds = await getPds(did) if (!pds) return null return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${profile.value.avatar.ref.$link}` } // Load local posts async function getLocalPosts(did: string, collection: string): Promise { try { const indexRes = await fetch(`/content/${did}/${collection}/index.json`) if (indexRes.ok && isJsonResponse(indexRes)) { const rkeys: string[] = await indexRes.json() const posts: Post[] = [] for (const rkey of rkeys) { const res = await fetch(`/content/${did}/${collection}/${rkey}.json`) if (res.ok && isJsonResponse(res)) posts.push(await res.json()) } return posts.sort((a, b) => new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime() ) } } catch { // Not found } return [] } // Load posts (local only for admin, remote for others) export async function getPosts(did: string, collection: string, localOnly = false): Promise { // Try local first const local = await getLocalPosts(did, collection) if (local.length > 0) return local // If local only mode, don't call API if (localOnly) return [] // Remote fallback const pds = await getPds(did) if (!pds) return [] try { const host = pds.replace('https://', '') const url = `${xrpcUrl(host, comAtprotoRepo.listRecords)}?repo=${did}&collection=${collection}&limit=100` const res = await fetch(url) if (res.ok) { const data: ListRecordsResponse = await res.json() return data.records.sort((a, b) => new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime() ) } } catch { // Failed } return [] } // Get single post (local only for admin, remote for others) export async function getPost(did: string, collection: string, rkey: string, localOnly = false): Promise { // Try local first try { const res = await fetch(`/content/${did}/${collection}/${rkey}.json`) if (res.ok && isJsonResponse(res)) return res.json() } catch { // Not found } // If local only mode, don't call API if (localOnly) return null // Remote fallback const pds = await getPds(did) if (!pds) return null try { const host = pds.replace('https://', '') const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=${collection}&rkey=${rkey}` const res = await fetch(url) if (res.ok) return res.json() } catch { // Failed } return null } // Describe repo - get collections list export async function describeRepo(did: string): Promise { // Try local first try { const res = await fetch(`/content/${did}/describe.json`) if (res.ok && isJsonResponse(res)) { const data = await res.json() return data.collections || [] } } catch { // Not found } // Remote const pds = await getPds(did) if (!pds) return [] try { const host = pds.replace('https://', '') const url = `${xrpcUrl(host, comAtprotoRepo.describeRepo)}?repo=${did}` const res = await fetchWithTimeout(url, {}, 8000) if (res.ok) { const data = await res.json() return data.collections || [] } } catch { // Failed or timeout } return [] } // List records from any collection export async function listRecords(did: string, collection: string, limit = 50): Promise<{ uri: string; cid: string; value: unknown }[]> { const pds = await getPds(did) if (!pds) return [] try { const host = pds.replace('https://', '') const url = `${xrpcUrl(host, comAtprotoRepo.listRecords)}?repo=${did}&collection=${collection}&limit=${limit}` const res = await fetchWithTimeout(url, {}, 8000) if (res.ok) { const data = await res.json() return data.records || [] } } catch { // Failed or timeout } return [] } // Get single record from any collection export async function getRecord(did: string, collection: string, rkey: string): Promise<{ uri: string; cid: string; value: unknown } | null> { const pds = await getPds(did) if (!pds) return null try { const host = pds.replace('https://', '') const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=${collection}&rkey=${rkey}` const res = await fetchWithTimeout(url, {}, 8000) if (res.ok) return res.json() } catch { // Failed or timeout } return null } // Constants for search const SEARCH_TIMEOUT_MS = 5000 // Get current network config export async function getCurrentNetwork(): Promise<{ plc: string; bsky: string; web: string }> { const config = await getConfig() const networks = await getNetworks() const networkKey = config.network || 'bsky.social' const network = networks[networkKey] return { plc: network?.plc || 'https://plc.directory', bsky: network?.bsky || 'https://public.api.bsky.app', web: network?.web || 'https://bsky.app' } } // Get search endpoint for current network async function getSearchEndpoint(): Promise { const network = await getCurrentNetwork() return network.bsky } // Search posts that link to a URL export async function searchPostsForUrl(url: string): Promise { // Use current network's endpoint for search const endpoint = await getSearchEndpoint() // Extract search-friendly patterns from URL // Note: Search API doesn't index paths well, so search by domain and filter client-side const searchQueries: string[] = [] try { const urlObj = new URL(url) // Search by domain only (paths with / don't return results) searchQueries.push(urlObj.host) } catch { searchQueries.push(url) } const allPosts: SearchPost[] = [] const seenUris = new Set() for (const query of searchQueries) { try { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), SEARCH_TIMEOUT_MS) const res = await fetch( `${endpoint}/xrpc/app.bsky.feed.searchPosts?q=${encodeURIComponent(query)}&limit=20`, { signal: controller.signal } ) clearTimeout(timeoutId) if (!res.ok) continue const data = await res.json() const posts = (data.posts || []).filter((post: SearchPost) => { const embedUri = (post.record as { embed?: { external?: { uri?: string } } })?.embed?.external?.uri const text = (post.record as { text?: string })?.text || '' return embedUri === url || text.includes(url) || embedUri?.includes(url.replace(/\/$/, '')) }) for (const post of posts) { if (!seenUris.has(post.uri)) { seenUris.add(post.uri) allPosts.push(post) } } } catch { // Timeout or network error } } // Sort by date (newest first) allPosts.sort((a, b) => { const aDate = (a.record as { createdAt?: string })?.createdAt || '' const bDate = (b.record as { createdAt?: string })?.createdAt || '' return new Date(bDate).getTime() - new Date(aDate).getTime() }) return allPosts } // Search post type export interface SearchPost { uri: string cid: string author: { did: string handle: string displayName?: string avatar?: string } record: unknown } // Load chat messages from both user and bot repos export async function getChatMessages( userDid: string, botDid: string, collection: string = 'ai.syui.log.chat' ): Promise { // Load messages for a single DID async function loadForDid(did: string): Promise { // Try local first try { const res = await fetch(`/content/${did}/${collection}/index.json`) if (res.ok && isJsonResponse(res)) { const rkeys: string[] = await res.json() // Load all messages in parallel const msgPromises = rkeys.map(async (rkey) => { const msgRes = await fetch(`/content/${did}/${collection}/${rkey}.json`) if (msgRes.ok && isJsonResponse(msgRes)) { return msgRes.json() as Promise } return null }) const results = await Promise.all(msgPromises) return results.filter((m): m is ChatMessage => m !== null) } } catch { // Try remote } // Remote fallback const pds = await getPds(did) if (!pds) return [] try { const host = pds.replace('https://', '') const url = `${xrpcUrl(host, comAtprotoRepo.listRecords)}?repo=${did}&collection=${collection}&limit=100` const res = await fetch(url) if (res.ok) { const data: ListRecordsResponse = await res.json() return data.records } } catch { // Failed } return [] } // Load from both DIDs in parallel const [userMessages, botMessages] = await Promise.all([ loadForDid(userDid), loadForDid(botDid) ]) const messages = [...userMessages, ...botMessages] // Sort by createdAt return messages.sort((a, b) => new Date(a.value.createdAt).getTime() - new Date(b.value.createdAt).getTime() ) } // ============================================ // api.syui.ai migration functions // ============================================ const API_SYUI_AI = 'https://api.syui.ai' // Old API user type export interface OldApiUser { id: number username: string did: string member: boolean book: boolean manga: boolean badge: boolean bsky: boolean mastodon: boolean delete: boolean handle: boolean created_at: string updated_at: string raid_at: string server_at: string egg_at: string luck: number luck_at: string like: number like_rank: number like_at: string fav: number ten: boolean ten_su: number ten_kai: number aiten: number ten_card: string ten_delete: string ten_post: string ten_get: string ten_at: string next: string room: number model: boolean model_at: string model_attack: number model_limit: number model_skill: number model_mode: number model_critical: number model_critical_d: number game: boolean game_test: boolean game_end: boolean game_account: boolean game_lv: number game_exp: number game_story: number game_limit: boolean coin: number coin_open: boolean coin_at: string planet: number planet_at: string login: boolean login_at: string location_x: number location_y: number location_z: number location_n: number } // Old API card type export interface OldApiCard { id: number card: number skill: string status: string cp: number url: string count: number author: string created_at: string } // Check if user exists in api.syui.ai by DID export async function getOldApiUserByDid(did: string): Promise { try { const res = await fetch(`${API_SYUI_AI}/users?itemsPerPage=2500`) if (!res.ok) return null const users: OldApiUser[] = await res.json() return users.find(u => u.did === did) || null } catch { return null } } // Get user's cards from api.syui.ai export async function getOldApiCards(userId: number): Promise { try { const res = await fetch(`${API_SYUI_AI}/users/${userId}/card?itemsPerPage=5000`) if (!res.ok) return [] return res.json() } catch { return [] } } // Check if ai.syui.card.old record exists and return the rkey export async function getCardOldRecordKey(did: string): Promise { const pds = await getPds(did) if (!pds) return null try { const host = pds.replace('https://', '') const url = `${xrpcUrl(host, comAtprotoRepo.listRecords)}?repo=${did}&collection=ai.syui.card.old&limit=1` const res = await fetch(url) if (!res.ok) return null const data = await res.json() if (data.records && data.records.length > 0) { // Extract rkey from URI: at://did/collection/rkey const uri = data.records[0].uri as string const rkey = uri.split('/').pop() return rkey || null } return null } catch { return null } } // Check if ai.syui.card.old record exists export async function hasCardOldRecord(did: string): Promise { const rkey = await getCardOldRecordKey(did) return rkey !== null } // Generate checksum for verification export function generateChecksum(user: OldApiUser, cards: OldApiCard[]): string { const sum = user.id + user.aiten + user.fav + cards.reduce((acc, c) => acc + c.id + c.cp + c.card, 0) return btoa(String(sum)) } // Get user's card collection (ai.syui.card.user) export async function getCards( did: string, collection: string = 'ai.syui.card.user' ): Promise { // Try local first try { const res = await fetch(`/content/${did}/${collection}/self.json`) if (res.ok && isJsonResponse(res)) { const record = await res.json() return record.value as CardCollection } } catch { // Try remote } // Remote fallback const pds = await getPds(did) if (!pds) return null try { const host = pds.replace('https://', '') const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=${collection}&rkey=self` const res = await fetch(url) if (res.ok) { const record = await res.json() return record.value as CardCollection } } catch { // Failed } return null } // RSE collection type export interface RseItem { id: number cp: number mode: number shiny: boolean unique: boolean } export interface RseCollection { item: RseItem[] character: RseItem[] createdAt: string updatedAt: string } // Get user's RSE collection (ai.syui.rse.user) export async function getRse(did: string): Promise { const collection = 'ai.syui.rse.user' // Try local first try { const res = await fetch(`/content/${did}/${collection}/self.json`) if (res.ok && isJsonResponse(res)) { const record = await res.json() return record.value as RseCollection } } catch { // Try remote } // Remote fallback const pds = await getPds(did) if (!pds) return null try { const host = pds.replace('https://', '') const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=${collection}&rkey=self` const res = await fetch(url) if (res.ok) { const record = await res.json() return record.value as RseCollection } } catch { // Failed } return null } // Link item type export interface LinkItem { service: 'github' | 'youtube' | 'x' username: string } // Link collection type export interface LinkCollection { links: LinkItem[] createdAt: string updatedAt?: string } // Get user's links (ai.syui.at.link) export async function getLinks(did: string): Promise { const collection = 'ai.syui.at.link' // Try local first try { const res = await fetch(`/content/${did}/${collection}/self.json`) if (res.ok && isJsonResponse(res)) { const record = await res.json() return record.value as LinkCollection } } catch { // Try remote } // Remote fallback const pds = await getPds(did) if (!pds) return null try { const host = pds.replace('https://', '') const url = `${xrpcUrl(host, comAtprotoRepo.getRecord)}?repo=${did}&collection=${collection}&rkey=self` const res = await fetch(url) if (res.ok) { const record = await res.json() return record.value as LinkCollection } } catch { // Failed } return null }