310 lines
9.8 KiB
JavaScript
310 lines
9.8 KiB
JavaScript
// ATProto API client
|
|
import { ATProtoError } from '../utils/errorHandler.js'
|
|
|
|
const ENDPOINTS = {
|
|
describeRepo: 'com.atproto.repo.describeRepo',
|
|
getProfile: 'app.bsky.actor.getProfile',
|
|
listRecords: 'com.atproto.repo.listRecords',
|
|
putRecord: 'com.atproto.repo.putRecord'
|
|
}
|
|
|
|
async function request(url, options = {}) {
|
|
try {
|
|
const controller = new AbortController()
|
|
const timeoutId = setTimeout(() => controller.abort(), 15000) // 15秒タイムアウト
|
|
|
|
const response = await fetch(url, {
|
|
...options,
|
|
signal: controller.signal
|
|
})
|
|
|
|
clearTimeout(timeoutId)
|
|
|
|
if (!response.ok) {
|
|
throw new ATProtoError(
|
|
`Request failed: ${response.statusText}`,
|
|
response.status,
|
|
{ url, method: options.method || 'GET' }
|
|
)
|
|
}
|
|
|
|
return await response.json()
|
|
} catch (error) {
|
|
if (error.name === 'AbortError') {
|
|
const timeoutError = new ATProtoError(
|
|
'リクエストがタイムアウトしました',
|
|
408,
|
|
{ url }
|
|
)
|
|
throw timeoutError
|
|
}
|
|
|
|
if (error instanceof ATProtoError) {
|
|
throw error
|
|
}
|
|
|
|
// ネットワークエラーなど
|
|
const networkError = new ATProtoError(
|
|
'ネットワークエラーが発生しました',
|
|
0,
|
|
{ url, originalError: error.message }
|
|
)
|
|
throw networkError
|
|
}
|
|
}
|
|
|
|
export const atproto = {
|
|
async getDid(pds, handle) {
|
|
const endpoint = pds.startsWith('http') ? pds : `https://${pds}`
|
|
const res = await request(`${endpoint}/xrpc/${ENDPOINTS.describeRepo}?repo=${handle}`)
|
|
return res.did
|
|
},
|
|
|
|
async getProfile(bsky, actor) {
|
|
// Skip test DIDs
|
|
if (actor && actor.includes('test-')) {
|
|
return {
|
|
did: actor,
|
|
handle: 'test.user',
|
|
displayName: 'Test User',
|
|
avatar: null
|
|
}
|
|
}
|
|
|
|
// Check if endpoint supports getProfile
|
|
let apiEndpoint = bsky
|
|
|
|
// Allow public.api.bsky.app and bsky.syu.is, redirect other PDS endpoints
|
|
if (!bsky.includes('public.api.bsky.app') && !bsky.includes('bsky.syu.is')) {
|
|
// If it's a PDS endpoint that doesn't support getProfile, redirect to public API
|
|
apiEndpoint = 'https://public.api.bsky.app'
|
|
}
|
|
|
|
return await request(`${apiEndpoint}/xrpc/${ENDPOINTS.getProfile}?actor=${actor}`)
|
|
},
|
|
|
|
async getRecords(pds, repo, collection, limit = 10, cursor = null) {
|
|
let url = `${pds}/xrpc/${ENDPOINTS.listRecords}?repo=${repo}&collection=${collection}&limit=${limit}`
|
|
if (cursor) {
|
|
url += `&cursor=${cursor}`
|
|
}
|
|
const res = await request(url)
|
|
return {
|
|
records: res.records || [],
|
|
cursor: res.cursor || null
|
|
}
|
|
},
|
|
|
|
async searchPlc(plc, did) {
|
|
try {
|
|
const data = await request(`${plc}/${did}`)
|
|
return {
|
|
success: true,
|
|
endpoint: data?.service?.[0]?.serviceEndpoint || null,
|
|
handle: data?.alsoKnownAs?.[0]?.replace('at://', '') || null
|
|
}
|
|
} catch {
|
|
return { success: false, endpoint: null, handle: null }
|
|
}
|
|
},
|
|
|
|
async putRecord(pds, record, agent) {
|
|
if (!agent) {
|
|
throw new Error('Agent required for putRecord')
|
|
}
|
|
|
|
// Use Agent's putRecord method instead of direct fetch
|
|
return await agent.com.atproto.repo.putRecord(record)
|
|
},
|
|
|
|
// Find all records for a specific post by paginating through all records
|
|
async findRecordsForPost(pds, repo, collection, targetRkey) {
|
|
let cursor = null
|
|
let allMatchingRecords = []
|
|
let pageCount = 0
|
|
const maxPages = 50 // Safety limit to prevent infinite loops
|
|
|
|
do {
|
|
pageCount++
|
|
if (pageCount > maxPages) {
|
|
console.warn(`Reached max pages (${maxPages}) while searching for ${targetRkey}`)
|
|
break
|
|
}
|
|
|
|
const result = await this.getRecords(pds, repo, collection, 100, cursor)
|
|
|
|
// Filter records that match the target post
|
|
const matchingRecords = result.records.filter(record => {
|
|
const postUrl = record.value?.post?.url
|
|
if (!postUrl) return false
|
|
|
|
try {
|
|
// Extract rkey from URL
|
|
const recordRkey = new URL(postUrl).pathname.split('/').pop()?.replace(/\.html$/, '')
|
|
return recordRkey === targetRkey
|
|
} catch {
|
|
return false
|
|
}
|
|
})
|
|
|
|
allMatchingRecords.push(...matchingRecords)
|
|
cursor = result.cursor
|
|
|
|
// Optional: Stop early if we found some records (uncomment if desired)
|
|
// if (allMatchingRecords.length > 0) break
|
|
|
|
} while (cursor)
|
|
|
|
console.log(`Found ${allMatchingRecords.length} records for ${targetRkey} after searching ${pageCount} pages`)
|
|
return allMatchingRecords
|
|
}
|
|
}
|
|
|
|
import { dataCache } from '../utils/cache.js'
|
|
|
|
// Collection specific methods
|
|
export const collections = {
|
|
async getBase(pds, repo, collection, limit = 10) {
|
|
const cacheKey = dataCache.generateKey('base', pds, repo, collection, limit)
|
|
const cached = dataCache.get(cacheKey)
|
|
if (cached) return cached
|
|
|
|
const data = await atproto.getRecords(pds, repo, collection, limit)
|
|
// Extract records array for backward compatibility
|
|
const records = data.records || data
|
|
dataCache.set(cacheKey, records)
|
|
return records
|
|
},
|
|
|
|
async getLang(pds, repo, collection, limit = 10) {
|
|
const cacheKey = dataCache.generateKey('lang', pds, repo, collection, limit)
|
|
const cached = dataCache.get(cacheKey)
|
|
if (cached) return cached
|
|
|
|
const data = await atproto.getRecords(pds, repo, `${collection}.chat.lang`, limit)
|
|
// Extract records array for backward compatibility
|
|
const records = data.records || data
|
|
dataCache.set(cacheKey, records)
|
|
return records
|
|
},
|
|
|
|
async getComment(pds, repo, collection, limit = 10) {
|
|
const cacheKey = dataCache.generateKey('comment', pds, repo, collection, limit)
|
|
const cached = dataCache.get(cacheKey)
|
|
if (cached) return cached
|
|
|
|
const data = await atproto.getRecords(pds, repo, `${collection}.chat.comment`, limit)
|
|
// Extract records array for backward compatibility
|
|
const records = data.records || data
|
|
dataCache.set(cacheKey, records)
|
|
return records
|
|
},
|
|
|
|
async getChat(pds, repo, collection, limit = 10, cursor = null) {
|
|
// Don't use cache for pagination requests
|
|
if (cursor) {
|
|
const result = await atproto.getRecords(pds, repo, `${collection}.chat`, limit, cursor)
|
|
return result
|
|
}
|
|
|
|
const cacheKey = dataCache.generateKey('chat', pds, repo, collection, limit)
|
|
const cached = dataCache.get(cacheKey)
|
|
if (cached) {
|
|
// Ensure cached data has the correct structure
|
|
return Array.isArray(cached) ? { records: cached, cursor: null } : cached
|
|
}
|
|
|
|
const data = await atproto.getRecords(pds, repo, `${collection}.chat`, limit)
|
|
// Cache only the records array for backward compatibility
|
|
dataCache.set(cacheKey, data.records || data)
|
|
return data
|
|
},
|
|
|
|
async getUserList(pds, repo, collection, limit = 100) {
|
|
const cacheKey = dataCache.generateKey('userlist', pds, repo, collection, limit)
|
|
const cached = dataCache.get(cacheKey)
|
|
if (cached) return cached
|
|
|
|
const data = await atproto.getRecords(pds, repo, `${collection}.user`, limit)
|
|
// Extract records array for backward compatibility
|
|
const records = data.records || data
|
|
dataCache.set(cacheKey, records)
|
|
return records
|
|
},
|
|
|
|
async getUserComments(pds, repo, collection, limit = 10) {
|
|
const cacheKey = dataCache.generateKey('usercomments', pds, repo, collection, limit)
|
|
const cached = dataCache.get(cacheKey)
|
|
if (cached) return cached
|
|
|
|
const data = await atproto.getRecords(pds, repo, collection, limit)
|
|
// Extract records array for backward compatibility
|
|
const records = data.records || data
|
|
dataCache.set(cacheKey, records)
|
|
return records
|
|
},
|
|
|
|
async getProfiles(pds, repo, collection, limit = 100) {
|
|
const cacheKey = dataCache.generateKey('profiles', pds, repo, collection, limit)
|
|
const cached = dataCache.get(cacheKey)
|
|
if (cached) return cached
|
|
|
|
const data = await atproto.getRecords(pds, repo, `${collection}.profile`, limit)
|
|
// Extract records array for backward compatibility
|
|
const records = data.records || data
|
|
dataCache.set(cacheKey, records)
|
|
return records
|
|
},
|
|
|
|
// Find chat records for a specific post using pagination
|
|
async getChatForPost(pds, repo, collection, targetRkey) {
|
|
const cacheKey = dataCache.generateKey('chatForPost', pds, repo, collection, targetRkey)
|
|
const cached = dataCache.get(cacheKey)
|
|
if (cached) return cached
|
|
|
|
const records = await atproto.findRecordsForPost(pds, repo, `${collection}.chat`, targetRkey)
|
|
|
|
// Process into chat pairs like the original getChat function
|
|
const chatPairs = []
|
|
const recordMap = new Map()
|
|
|
|
// First pass: organize records by base rkey
|
|
records.forEach(record => {
|
|
const rkey = record.uri.split('/').pop()
|
|
const baseRkey = rkey.replace('-answer', '')
|
|
|
|
if (!recordMap.has(baseRkey)) {
|
|
recordMap.set(baseRkey, { question: null, answer: null })
|
|
}
|
|
|
|
if (record.value.type === 'question') {
|
|
recordMap.get(baseRkey).question = record
|
|
} else if (record.value.type === 'answer') {
|
|
recordMap.get(baseRkey).answer = record
|
|
}
|
|
})
|
|
|
|
// Second pass: create chat pairs
|
|
recordMap.forEach((pair, rkey) => {
|
|
if (pair.question) {
|
|
chatPairs.push({
|
|
rkey,
|
|
question: pair.question,
|
|
answer: pair.answer,
|
|
createdAt: pair.question.value.createdAt
|
|
})
|
|
}
|
|
})
|
|
|
|
// Sort by creation time (oldest first) - for chronological conversation flow
|
|
chatPairs.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
|
|
|
|
dataCache.set(cacheKey, chatPairs)
|
|
return chatPairs
|
|
},
|
|
|
|
// 投稿後にキャッシュを無効化
|
|
invalidateCache(collection) {
|
|
dataCache.invalidatePattern(collection)
|
|
}
|
|
} |