OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-social / bridge-mastodon / lib / mastodon-client.js

183 lines · javascript ref:
import https from 'https'
import { URL } from 'url'

class MastodonClient {
  constructor (opts = {}) {
    this.instance = opts.instance // e.g. 'mastodon.social'
    this.token = opts.token
    this._streaming = false
    this._streamReq = null
  }

  _request (method, path, body) {
    return new Promise((resolve, reject) => {
      const url = new URL(`https://${this.instance}${path}`)

      const opts = {
        hostname: url.hostname,
        port: 443,
        path: url.pathname + url.search,
        method,
        headers: {
          Authorization: `Bearer ${this.token}`,
          Accept: 'application/json'
        }
      }

      let payload = ''
      if (body) {
        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) {
            return reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`))
          }
          try {
            resolve(JSON.parse(data))
          } catch {
            reject(new Error('Invalid JSON: ' + data.slice(0, 200)))
          }
        })
      })

      req.on('error', reject)
      req.setTimeout(15000, () => req.destroy(new Error('Request timeout')))

      if (payload) req.write(payload)
      req.end()
    })
  }

  // Post a status (toot)
  async postStatus (text, opts = {}) {
    const body = { status: text }
    if (opts.visibility) body.visibility = opts.visibility // public, unlisted, private, direct
    if (opts.spoilerText) body.spoiler_text = opts.spoilerText
    if (opts.inReplyTo) body.in_reply_to_id = opts.inReplyTo
    if (opts.mediaIds) body.media_ids = opts.mediaIds
    return this._request('POST', '/api/v1/statuses', body)
  }

  // Get home timeline
  async homeTimeline (limit = 20, sinceId) {
    let path = `/api/v1/timelines/home?limit=${limit}`
    if (sinceId) path += `&since_id=${sinceId}`
    return this._request('GET', path)
  }

  // Get hashtag timeline
  async hashtagTimeline (tag, limit = 20, sinceId) {
    let path = `/api/v1/timelines/tag/${encodeURIComponent(tag)}?limit=${limit}`
    if (sinceId) path += `&since_id=${sinceId}`
    return this._request('GET', path)
  }

  // Get notifications
  async notifications (limit = 15, sinceId) {
    let path = `/api/v1/notifications?limit=${limit}`
    if (sinceId) path += `&since_id=${sinceId}`
    return this._request('GET', path)
  }

  // Boost (reblog) a status
  async boost (statusId) {
    return this._request('POST', `/api/v1/statuses/${statusId}/reblog`)
  }

  // Favourite a status
  async favourite (statusId) {
    return this._request('POST', `/api/v1/statuses/${statusId}/favourite`)
  }

  // Verify credentials
  async verifyCredentials () {
    return this._request('GET', '/api/v1/accounts/verify_credentials')
  }

  // SSE streaming for user timeline
  stream (streamType, onStatus) {
    if (this._streaming) return
    this._streaming = true
    this._doStream(streamType, onStatus)
  }

  _doStream (streamType, onStatus) {
    if (!this._streaming) return

    // streamType: 'user', 'public', 'public:local', 'hashtag&tag=foo'
    const req = https.request({
      hostname: this.instance,
      port: 443,
      path: `/api/v1/streaming/${streamType}`,
      method: 'GET',
      headers: {
        Authorization: `Bearer ${this.token}`,
        Accept: 'text/event-stream'
      }
    }, (res) => {
      let buffer = ''

      res.on('data', (chunk) => {
        buffer += chunk.toString()
        const events = buffer.split('\n\n')
        buffer = events.pop()

        for (const event of events) {
          if (!event.trim()) continue
          const lines = event.split('\n')
          let type = ''
          let data = null

          for (const line of lines) {
            if (line.startsWith('event: ')) type = line.slice(7).trim()
            if (line.startsWith('data: ')) {
              try { data = JSON.parse(line.slice(6)) } catch {}
            }
          }

          if (type === 'update' && data) {
            onStatus(data)
          }
        }
      })

      res.on('end', () => {
        if (this._streaming) {
          setTimeout(() => this._doStream(streamType, onStatus), 3000)
        }
      })

      res.on('error', () => {
        if (this._streaming) {
          setTimeout(() => this._doStream(streamType, onStatus), 5000)
        }
      })
    })

    req.on('error', () => {
      if (this._streaming) {
        setTimeout(() => this._doStream(streamType, onStatus), 5000)
      }
    })

    this._streamReq = req
    req.end()
  }

  stopStream () {
    this._streaming = false
    if (this._streamReq) {
      this._streamReq.destroy()
      this._streamReq = null
    }
  }
}

export { MastodonClient }