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(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.trim()
}
function sleep (ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
export { MatrixClient, stripHtml }