OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-social / hyper-social-web / lib / clients / atproto-client.js

278 lines · javascript ref:
import https from 'bare-https'

/**
 * Minimal AT Protocol client using raw XRPC HTTP calls.
 * No SDK dependency — just fetch + JSON + JWT.
 */
class AtprotoClient {
  constructor (opts = {}) {
    this.service = opts.service || 'https://bsky.social'
    this.identifier = opts.identifier // handle or DID
    this.password = opts.password // app password

    // Session state
    this.accessJwt = null
    this.refreshJwt = null
    this.did = null
    this.handle = null

    // Parse hostname from service URL
    const url = new URL(this.service)
    this.hostname = url.hostname
    this.basePath = url.pathname === '/' ? '' : url.pathname
  }

  _request (method, nsid, params, body) {
    return new Promise((resolve, reject) => {
      let path = `${this.basePath}/xrpc/${nsid}`

      // Query params for GET
      if (params && method === 'GET') {
        const qs = new URLSearchParams()
        for (const [k, v] of Object.entries(params)) {
          if (v !== undefined && v !== null) qs.set(k, String(v))
        }
        const str = qs.toString()
        if (str) path += '?' + str
      }

      const opts = {
        hostname: this.hostname,
        port: 443,
        path,
        method,
        headers: { Accept: 'application/json' }
      }

      if (this.accessJwt) {
        opts.headers.Authorization = `Bearer ${this.accessJwt}`
      }

      let payload = ''
      if (body && method === 'POST') {
        payload = JSON.stringify(body)
        opts.headers['Content-Type'] = 'application/json'
        opts.headers['Content-Length'] = Buffer.byteLength(payload)
      }

      const req = https.request(opts, (res) => {
        let data = ''
        res.on('data', (chunk) => { data += chunk.toString() })
        res.on('end', () => {
          if (res.statusCode >= 400) {
            // Check if token expired
            if (res.statusCode === 401 && this.refreshJwt) {
              return this._refreshSession()
                .then(() => this._request(method, nsid, params, body))
                .then(resolve)
                .catch(reject)
            }
            return reject(new Error(`XRPC ${res.statusCode}: ${data.slice(0, 300)}`))
          }
          try { resolve(JSON.parse(data)) } catch { resolve(data) }
        })
      })

      req.on('error', reject)
      req.setTimeout(15000, () => req.destroy(new Error('Request timeout')))
      if (payload) req.write(payload)
      req.end()
    })
  }

  // --- Session management ---

  async login () {
    const res = await this._requestNoAuth('POST', 'com.atproto.server.createSession', null, {
      identifier: this.identifier,
      password: this.password
    })
    this.accessJwt = res.accessJwt
    this.refreshJwt = res.refreshJwt
    this.did = res.did
    this.handle = res.handle
    return res
  }

  async _refreshSession () {
    const oldRefresh = this.refreshJwt
    this.accessJwt = null

    const res = await this._requestWithToken('POST', 'com.atproto.server.refreshSession', null, null, oldRefresh)
    this.accessJwt = res.accessJwt
    this.refreshJwt = res.refreshJwt
    return res
  }

  _requestNoAuth (method, nsid, params, body) {
    const saved = this.accessJwt
    this.accessJwt = null
    return this._request(method, nsid, params, body).finally(() => {
      this.accessJwt = saved
    })
  }

  _requestWithToken (method, nsid, params, body, token) {
    const saved = this.accessJwt
    this.accessJwt = token
    return this._request(method, nsid, params, body).finally(() => {
      this.accessJwt = saved
    })
  }

  // --- Feed reading ---

  async getTimeline (limit = 30, cursor) {
    const res = await this._request('GET', 'app.bsky.feed.getTimeline', { limit, cursor })
    return res.feed || []
  }

  async getAuthorFeed (actor, limit = 30, cursor) {
    const res = await this._request('GET', 'app.bsky.feed.getAuthorFeed', {
      actor: actor || this.did,
      limit,
      cursor
    })
    return res.feed || []
  }

  async getProfile (actor) {
    return this._request('GET', 'app.bsky.actor.getProfile', {
      actor: actor || this.did
    })
  }

  // --- Blob upload ---

  async uploadBlob (buffer, mimeType) {
    return new Promise((resolve, reject) => {
      const req = https.request({
        hostname: this.hostname,
        port: 443,
        path: `${this.basePath}/xrpc/com.atproto.repo.uploadBlob`,
        method: 'POST',
        headers: {
          Authorization: `Bearer ${this.accessJwt}`,
          'Content-Type': mimeType,
          'Content-Length': buffer.length
        }
      }, (res) => {
        let data = ''
        res.on('data', (chunk) => { data += chunk.toString() })
        res.on('end', () => {
          if (res.statusCode >= 400) {
            return reject(new Error(`Upload ${res.statusCode}: ${data.slice(0, 300)}`))
          }
          try { resolve(JSON.parse(data)) } catch { reject(new Error('Invalid JSON')) }
        })
      })

      req.on('error', reject)
      req.setTimeout(60000, () => req.destroy(new Error('Upload timeout')))
      req.write(buffer)
      req.end()
    })
  }

  // --- Posting ---

  async post (text, opts = {}) {
    const now = new Date().toISOString()

    const record = {
      $type: 'app.bsky.feed.post',
      text,
      createdAt: now
    }

    // Facets (mentions, links, hashtags) — auto-detect
    const facets = detectFacets(text)
    if (facets.length > 0) record.facets = facets

    // Reply
    if (opts.replyTo) {
      record.reply = opts.replyTo
    }

    // Image embed
    if (opts.images && opts.images.length > 0) {
      record.embed = {
        $type: 'app.bsky.embed.images',
        images: opts.images.map(img => ({
          alt: img.alt || '',
          image: img.blob, // { $type: 'blob', ref: { $link: cid }, mimeType, size }
          aspectRatio: img.width && img.height ? { width: img.width, height: img.height } : undefined
        }))
      }
    }

    return this._request('POST', 'com.atproto.repo.createRecord', null, {
      repo: this.did,
      collection: 'app.bsky.feed.post',
      record
    })
  }

  // --- Notifications ---

  async getNotifications (limit = 20, cursor) {
    const res = await this._request('GET', 'app.bsky.notification.listNotifications', { limit, cursor })
    return res.notifications || []
  }

  // --- Search ---

  async searchPosts (query, limit = 25) {
    const res = await this._request('GET', 'app.bsky.feed.searchPosts', { q: query, limit })
    return res.posts || []
  }
}

/**
 * Auto-detect facets (links, mentions, hashtags) in text.
 * Returns AT Proto facet objects with byte offsets.
 */
function detectFacets (text) {
  const facets = []
  const encoder = new TextEncoder()
  const bytes = encoder.encode(text)

  // URLs
  const urlRegex = /https?:\/\/[^\s<>)"']+/g
  let match
  while ((match = urlRegex.exec(text)) !== null) {
    const before = encoder.encode(text.slice(0, match.index))
    const target = encoder.encode(match[0])
    facets.push({
      index: { byteStart: before.length, byteEnd: before.length + target.length },
      features: [{ $type: 'app.bsky.richtext.facet#link', uri: match[0] }]
    })
  }

  // Mentions (@handle.domain)
  const mentionRegex = /@([a-zA-Z0-9._-]+\.[a-zA-Z]{2,})/g
  while ((match = mentionRegex.exec(text)) !== null) {
    const before = encoder.encode(text.slice(0, match.index))
    const target = encoder.encode(match[0])
    facets.push({
      index: { byteStart: before.length, byteEnd: before.length + target.length },
      features: [{ $type: 'app.bsky.richtext.facet#mention', did: '' }] // DID resolved by AppView
    })
  }

  // Hashtags (#tag)
  const tagRegex = /#([a-zA-Z0-9_]+)/g
  while ((match = tagRegex.exec(text)) !== null) {
    const before = encoder.encode(text.slice(0, match.index))
    const target = encoder.encode(match[0])
    facets.push({
      index: { byteStart: before.length, byteEnd: before.length + target.length },
      features: [{ $type: 'app.bsky.richtext.facet#tag', tag: match[1] }]
    })
  }

  return facets
}

export { AtprotoClient, detectFacets }