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 }