OverviewFilesCommitsIssuesDocsReleasesPeers

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

285 lines · javascript ref:
import https from 'https'
import http from 'http'

class MatrixClient {
  constructor (opts = {}) {
    this.homeserver = opts.homeserver || 'matrix.org'
    this.userId = opts.userId || null
    this.accessToken = opts.accessToken || null

    this._onMessage = opts.onMessage || null
    this._onJoin = opts.onJoin || null
    this._onLeave = opts.onLeave || null

    this._nextBatch = null
    this._syncing = false
    this._txnId = 0
    this._displayNameCache = new Map()
    this._seenEvents = new Set()
    this._maxSeenEvents = 5000
  }

  // Authenticate with username/password and get an access token
  async login (homeserver, user, password) {
    if (homeserver) this.homeserver = homeserver

    const body = {
      type: 'm.login.password',
      identifier: {
        type: 'm.id.user',
        user
      },
      password
    }

    const res = await this._request('POST', '/_matrix/client/v3/login', body)
    this.accessToken = res.access_token
    this.userId = res.user_id
    return res
  }

  // Long-poll the /sync endpoint
  async sync (since, timeout) {
    let path = '/_matrix/client/v3/sync?timeout=' + (timeout || 30000)
    if (since) path += '&since=' + encodeURIComponent(since)

    // Filter to only get room messages (reduce payload)
    const filter = JSON.stringify({
      room: {
        timeline: { limit: 20 },
        state: { lazy_load_members: true }
      },
      presence: { types: [] },
      account_data: { types: [] }
    })
    path += '&filter=' + encodeURIComponent(filter)

    return this._request('GET', path, null, (timeout || 30000) + 15000)
  }

  // Send a text message to a room
  async sendMessage (roomId, text) {
    const txnId = 'm' + Date.now() + '.' + (this._txnId++)
    const encodedRoom = encodeURIComponent(roomId)
    const path = `/_matrix/client/v3/rooms/${encodedRoom}/send/m.room.message/${txnId}`

    return this._request('PUT', path, {
      msgtype: 'm.text',
      body: text
    })
  }

  // Join a room by ID or alias
  async joinRoom (roomIdOrAlias) {
    const encoded = encodeURIComponent(roomIdOrAlias)
    return this._request('POST', `/_matrix/client/v3/join/${encoded}`, {})
  }

  // Get room display name
  async getRoomName (roomId) {
    const encoded = encodeURIComponent(roomId)
    try {
      const res = await this._request('GET', `/_matrix/client/v3/rooms/${encoded}/state/m.room.name`)
      return res.name || null
    } catch {
      return null
    }
  }

  // Get display name for a user (cached)
  async getDisplayName (userId) {
    if (this._displayNameCache.has(userId)) {
      return this._displayNameCache.get(userId)
    }

    try {
      const encoded = encodeURIComponent(userId)
      const res = await this._request('GET', `/_matrix/client/v3/profile/${encoded}/displayname`)
      const name = res.displayname || userId
      this._displayNameCache.set(userId, name)
      return name
    } catch {
      this._displayNameCache.set(userId, userId)
      return userId
    }
  }

  // Start the long-polling sync loop
  _startSync () {
    if (this._syncing) return
    this._syncing = true
    this._syncLoop()
  }

  async _syncLoop () {
    let backoff = 1000

    while (this._syncing) {
      try {
        const res = await this.sync(this._nextBatch, 30000)
        this._nextBatch = res.next_batch
        backoff = 1000 // reset on success

        // Process joined rooms
        const joined = res.rooms?.join
        if (joined) {
          for (const [roomId, roomData] of Object.entries(joined)) {
            const events = roomData.timeline?.events || []
            for (const event of events) {
              await this._handleEvent(roomId, event)
            }
          }
        }

        // Process invites (auto-join if callback present)
        const invited = res.rooms?.invite
        if (invited && this._onJoin) {
          for (const roomId of Object.keys(invited)) {
            this._onJoin(roomId)
          }
        }

        // Process leaves
        const left = res.rooms?.leave
        if (left && this._onLeave) {
          for (const roomId of Object.keys(left)) {
            this._onLeave(roomId)
          }
        }
      } catch (err) {
        if (!this._syncing) break

        // Exponential backoff on error
        await sleep(backoff)
        backoff = Math.min(backoff * 2, 30000)
      }
    }
  }

  async _handleEvent (roomId, event) {
    if (event.type !== 'm.room.message') return
    if (!event.content) return

    // Skip own messages
    if (event.sender === this.userId) return

    // Dedup by event_id
    if (this._seenEvents.has(event.event_id)) return

    // Prune cache when it gets too large
    if (this._seenEvents.size > this._maxSeenEvents) {
      const arr = [...this._seenEvents]
      this._seenEvents = new Set(arr.slice(-Math.floor(this._maxSeenEvents / 2)))
    }
    this._seenEvents.add(event.event_id)

    // Extract text — prefer plain body over formatted
    let text = event.content.body || ''

    // If formatted_body exists and body is the same, use body (plain text)
    // formatted_body is HTML, body is the plain text fallback
    if (event.content.format === 'org.matrix.custom.html' && event.content.formatted_body) {
      // body already contains the plain text version, use it as-is
      // Only strip HTML if for some reason body is missing
      if (!text) {
        text = stripHtml(event.content.formatted_body)
      }
    }

    // Get display name
    const sender = await this.getDisplayName(event.sender)

    if (this._onMessage) {
      this._onMessage(roomId, sender, text, event.event_id)
    }
  }

  // Stop the sync loop
  stopSync () {
    this._syncing = false
  }

  // Generic HTTPS request to the homeserver
  _request (method, path, body, timeout) {
    return new Promise((resolve, reject) => {
      const isHttps = !this.homeserver.includes('://') || this.homeserver.startsWith('https')
      const mod = isHttps ? https : http

      let hostname = this.homeserver
        .replace('https://', '')
        .replace('http://', '')

      // Extract port if present
      let port = isHttps ? 443 : 80
      const colonIdx = hostname.indexOf(':')
      if (colonIdx !== -1) {
        port = parseInt(hostname.slice(colonIdx + 1), 10)
        hostname = hostname.slice(0, colonIdx)
      }

      const headers = {}
      if (this.accessToken) {
        headers.Authorization = 'Bearer ' + this.accessToken
      }

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

      const req = mod.request({
        hostname,
        port,
        path,
        method,
        headers
      }, (res) => {
        let data = ''
        res.on('data', (chunk) => { data += chunk.toString() })
        res.on('end', () => {
          try {
            const parsed = JSON.parse(data)
            if (parsed.errcode) {
              reject(new Error(`Matrix error: ${parsed.errcode} - ${parsed.error}`))
            } else {
              resolve(parsed)
            }
          } catch {
            reject(new Error('Invalid JSON response: ' + data.slice(0, 200)))
          }
        })
      })

      req.on('error', reject)
      req.setTimeout(timeout || 30000, () => {
        req.destroy(new Error('Request timeout'))
      })

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

// Strip HTML tags (for formatted_body fallback)
function stripHtml (html) {
  return html
    .replace(/<br\s*\/?>/gi, '\n')
    .replace(/<\/p>\s*<p>/gi, '\n\n')
    .replace(/<[^>]+>/g, '')
    .replace(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")
    .trim()
}

function sleep (ms) {
  return new Promise(resolve => setTimeout(resolve, ms))
}

export { MatrixClient, stripHtml }