matrix-cli – Rev 21
?pathlinks?
#!/usr/bin/env node
///////////////////////////////////////////////////////////////////////////
// Copyright (C) Wizardry and Steamworks 2024 - License: MIT //
// Please see: https://opensource.org/license/mit/ for legal details, //
// rights of fair usage, the disclaimer and warranty conditions. //
///////////////////////////////////////////////////////////////////////////
const matrix = require('matrix-js-sdk')
const loglevel = require('loglevel')
const { Command } = require('commander')
const program = new Command()
const winston = require('winston')
const { exitOnError } = require('winston')
const prompt = require('prompt')
const axios = require('axios')
const identify = require('buffer-signature').identify
const fs = require('fs')
const { parse } = require('node-html-parser')
const crypto = require("crypto")
const mqtt = require('mqtt')
const log = winston.createLogger({
level: 'info',
transports: [
/*
* Installed as a global tool.
*
* new winston.transports.File({
* filename: 'logs/matrix-cli.log'
* }),
*/
new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
})
],
})
// suppress matrix logging
loglevel.getLogger('matrix').disableAll()
function continueLogin(commandOptions, callback) {
const client = matrix.createClient(
{
baseUrl: commandOptions.server
}
)
client.login(
"m.login.password", {
"user": commandOptions.user,
"password": commandOptions.password
})
.catch(error => {
log.error(`Login failed with error '${error}'`)
matrixLogout(client)
process.exit(1)
})
.then(() => {
log.info(`Logged in.`)
client.startClient(0)
log.info(`Client synchronizing...`)
client.once('sync', state => {
if (state === 'PREPARED') {
log.info(`Synchronized.`)
callback(client)
}
})
})
}
function matrixLogin(commandOptions, callback) {
if (typeof commandOptions.password === 'undefined') {
log.info(`No password provided, reading password from command line.`)
prompt.start()
prompt.get({ description: 'Enter the password (will not be echoed): ', name: 'Password', hidden: true, required: true }, (error, result) => {
if (error) {
log.error(`Could not read password from prompt.`)
prompt.stop()
return
}
commandOptions.password = result.Password
prompt.stop()
continueLogin(commandOptions, callback)
})
return
}
continueLogin(commandOptions, callback)
}
function matrixLogout(client) {
client.stopClient()
// https://github.com/matrix-org/matrix-js-sdk/issues/2472
process.exit(0)
}
function downloadFile(url, callback) {
return axios
.get(url, { responseType: 'arraybuffer' })
.then(response => {
/* info @ require('buffer-signature'):
* {
* mimeType: 'image/jpeg',
* description: 'JPEG raw or in the JFIF or Exif file format',
* extensions: [ 'jpg', 'jpeg' ]
* }
*/
var info = identify(response.data)
callback(info, response.data)
})
.catch(error => {
// handle error
log.error(`Attachment download failed with error '${error}'`)
})
.finally(() => {
// always executed
})
}
function matrixSay(sayRoom, sayText, commandOptions) {
matrixLogin(commandOptions, client => {
const rooms = client.getRooms()
var select = rooms.filter(room => room.name.localeCompare(sayRoom, { sensitiviy: 'accent' }) == 0)
if (select.length == 0) {
log.error(`Room '${sayRoom}' not found.`)
matrixLogout(client)
return
}
var room = select[0]
var payload = {}
switch (commandOptions.type.toUpperCase()) {
case 'HTML':
const promises = []
// interpret body as html and download/replace images
var htmlDocument = parse(sayText)
htmlDocument.querySelectorAll('img').forEach(node => {
if (typeof node.attrs.src !== 'undefined') {
promises.push(new Promise((resolve, _) => {
downloadFile(node.attrs.src, (info, buffer) => {
log.info(`Download complete: '${info.mimeType}'`)
client.uploadContent(buffer, { type: info.mimeType })
.then(response => {
node.setAttribute('src', response.content_uri)
resolve()
})
})
}))
}
})
Promise.all(promises)
.then(() => {
payload = {
"format": "org.matrix.custom.html",
"body": '',
"formatted_body": htmlDocument.toString(),
"msgtype": matrix.MsgType.Text
}
client.sendEvent(room.roomId, "m.room.message", payload, "").then(() => {
log.info(`Message sent to room '${room.name}'.`)
}).then(() => {
matrixLogout(client)
})
})
break
default:
payload = {
"body": sayText,
"msgtype": matrix.MsgType.Text
}
client.sendEvent(room.roomId, "m.room.message", payload, "").then(() => {
log.info(`Message sent to room '${room.name}'.`)
}).then(() => {
matrixLogout(client)
})
}
})
}
function matrixJoin(joinRoom, commandOptions) {
matrixLogin(commandOptions, client => {
const rooms = client.getRooms()
log.info(`Searching for room ''${joinRoom}''...`)
var select = rooms.filter(room => room.name.localeCompare(joinRoom, { sensitiviy: 'accent' }) == 0)
if (select.length == 0) {
log.error(`Room '${joinRoom}' not found.`)
matrixLogout(client)
return
}
log.info(`Room '${joinRoom}' has been found.`)
var room = select[0]
switch (room.getMember(client.getUserId()).membership) {
case 'invite':
log.info(`Joining room.`)
client.joinRoom(room.roomId).then(() => {
log.info(`Room '${room.name}' joined successfully.`)
matrixLogout(client)
})
return
case 'join':
log.error(`Room '${joinRoom}' has already been joined.`)
matrixLogout(client)
return
}
})
}
function matrixPart(partRoom, commandOptions) {
matrixLogin(commandOptions, client => {
const rooms = client.getRooms()
log.info(`Searching for room '${partRoom}'.`)
var select = rooms.filter(room => room.name.localeCompare(partRoom, { sensitiviy: 'accent' }) == 0)
if (select.length == 0) {
log.error(`Room '${partRoom}' not found.`)
matrixLogout(client)
return
}
log.info(`Room '${partRoom}' has been found.`)
var room = select[0]
switch (room.getMember(client.getUserId()).membership) {
case 'join':
log.info(`Leaving room.`)
client.leave(room.roomId).then(() => {
log.info(`Room '${room.name}' left successfully.`)
matrixLogout(client)
})
return
case 'invite':
log.error(`Not a member of '${partRoom}', but an invite is pending.`)
matrixLogout(client)
return
}
})
}
function matrixList(commandOptions) {
matrixLogin(commandOptions, client => {
switch (commandOptions.entity.toUpperCase()) {
case 'ROOMS':
var rooms = client.getRooms()
log.info(`Available rooms: '${rooms.length}'`)
rooms.forEach(room => {
log.info(`\tRoom: '${room.name}' [${room.getMember(client.getUserId()).membership}]`);
})
log.info(`Done.`)
matrixLogout(client)
break
}
})
}
function matrixPublish(sayRoom, publishFile, commandOptions) {
matrixLogin(commandOptions, client => {
const rooms = client.getRooms()
var select = rooms.filter(room => room.name.localeCompare(sayRoom, { sensitiviy: 'accent' }) == 0)
if (select.length == 0) {
log.error(`Room '${sayRoom}' not found.`)
matrixLogout(client)
return
}
const room = select[0]
var payloads = []
var promises = []
publishFile.forEach(filePath => {
// retrieve by URL
if (/^(http|https)/.test(filePath)) {
promises.push(new Promise((resolve, _) => {
log.info(`Downloading '${filePath}'...`)
downloadFile(filePath, (info, buffer) => {
log.info(`Download complete: '${info.mimeType}'`)
log.info(`Uploading file '${filePath}' with type '${info.mimeType}' to matrix...`)
client.uploadContent(buffer, { "type": info.mimeType })
.then(response => {
log.info(`Upload complete for '${filePath}': '${response.content_uri}'}`)
payloads.push({
"msgtype": matrix.MsgType.File,
"body": `${crypto.createHash('shake256', { outputLength: 8 })
.update(filePath)
.digest("hex")}.${info.extensions.pop()}`,
"url": response.content_uri,
"initial": filePath
})
resolve()
})
})
}))
return
}
// load files otherwise...
promises.push(new Promise((resolve, reject) => {
log.info(`Checking path '${filePath}'...`)
fs.stat(filePath, (error, stats) => {
if (error) {
log.error(`Checking for file '${filePath}' failed with error '${error}'`)
reject()
return
}
if (!stats.isFile()) {
log.error(`Path '${filePath}' does not point to a file.`)
reject()
}
log.info(`Reading file '${filePath}'...`)
fs.readFile(filePath, (error, buffer) => {
if (error) {
log.error(`Could not read file '${filePath}'.`)
reject()
return
}
var info = identify(buffer)
if (info.mimeType === 'application/octet-stream') {
if (!/\ufffd/.test(buffer.toString())) {
info = {
"mimeType": "text/plain",
"extensions": ["txt"]
}
}
}
log.info(`Uploading file '${filePath}' with type '${info.mimeType}' to matrix...`)
client.uploadContent(buffer, { type: info.mimeType })
.then(response => {
log.info(`Upload complete for '${filePath}': '${response.content_uri}'.`)
payloads.push({
"msgtype": matrix.MsgType.File,
"body": `${crypto.createHash('shake256', { outputLength: 8 })
.update(filePath)
.digest("hex")}.${info.extensions.pop()}`,
"url": response.content_uri,
"initial": filePath
})
resolve()
})
})
})
}))
})
log.info(`Waiting for all files to process...`)
Promise.all(promises)
.then(() => {
promises = []
payloads.forEach(payload => {
promises.push(new Promise((resolve, _) => {
log.info(`Sending '${payload.initial}'...`)
client.sendMessage(room.roomId, payload)
.then(response => {
log.info(`Sent file '${payload.initial}' as '${payload.body}' to '${room.name}'.`)
resolve()
})
}))
})
Promise.all(promises)
.then(() => {
matrixLogout(client)
})
})
.catch(error => {
log.error(`Error occurred while waiting for file processing '${error}'`)
matrixLogout(client)
})
.finally(() => {
})
})
}
function matrixMqtt(matrixRoom, mqttUrl, mqttTopic, commandOptions) {
matrixLogin(commandOptions, matrixClient => {
const rooms = matrixClient.getRooms()
var select = rooms.filter(room => room.name.localeCompare(matrixRoom, { sensitiviy: 'accent' }) == 0)
if (select.length == 0) {
log.error(`Room '${sayRoom}' not found.`)
matrixLogout(matrixClient)
return
}
const room = select[0]
log.info(`Room '${room.name}' has been found.`)
log.info(`Connecting to MQTT via ${mqttUrl} and subscribing to MQTT topic '${mqttTopic}'.`)
// v5 and 'nl' on topic in order to not listen to self
const mqttClient = mqtt.connect(mqttUrl, { 'protocolVersion': 5 })
mqttClient.on('connect', () => {
mqttClient.subscribe(mqttTopic, { 'nl': true }, (error) => {
if (error) {
log.error(`Subscription to '${mqttTopic}' failed with error ${error}.`)
matrixLogout(matrixClient)
return
}
log.info(`Successfully subscribed to MQTT topic '${mqttTopic}'.`)
matrixClient.on(matrix.RoomEvent.Timeline, (event, room, toStartOfTimeline) => {
if (toStartOfTimeline) {
return // don't print paginated results
}
if (event.getType() !== "m.room.message") {
return // only print messages
}
var payload = {
'room': room.name,
'sender': event.getSender(),
'message': event.getContent().body
}
log.info(`Publishing message '${JSON.stringify(payload)}' to MQTT server.`)
mqttClient.publish(mqttTopic, JSON.stringify(payload))
})
mqttClient.on('message', (topic, payload) => {
if (topic !== mqttTopic) {
return
}
log.info(`Sending message '${payload}' to MQTT server.`)
matrixClient.sendMessage(room.roomId, payload)
})
// this might need winpty on Windows/cygwin
process.on('SIGINT', () => {
log.info(`Signal received, tearing bridge down and disconnecting...`)
mqttClient.end()
matrixLogout(matrixClient)
log.info(`Bye`)
process.exit(0)
})
process.stdin.resume()
})
})
})
}
program
.name('matrix-cli')
.description(`+-- --+
| /\\ /\\ | matrix.org command-line client
| + + + | (c) 2024 Wizardry and Steamworks
+-- --+`).version('1.0')
program.command('say <room> <text>')
.requiredOption('-u, --user <user>', 'the username to log-in to matrix.org')
.option('-p, --password <password>', 'the password to log-in to matrix.org')
.requiredOption('-s, --server <server>', 'the server to log-in to', 'https://matrix.org')
.option('-t, --type <type>', 'the message format; either text (default) or html', 'text')
.description('send a message to a room')
.action(matrixSay)
program.command('publish <room> <file...>')
.requiredOption('-u, --user <user>', 'the username to log-in to matrix.org')
.option('-p, --password <password>', 'the password to log-in to matrix.org')
.requiredOption('-s, --server <server>', 'the server to log-in to', 'https://matrix.org')
.description('publish files to a room from either the local filesystem or from an URL')
.action(matrixPublish)
program.command('join <room>')
.requiredOption('-u, --user <user>', 'the username to log-in to matrix.org')
.option('-p, --password <password>', 'the password to log-in to matrix.org')
.requiredOption('-s, --server <server>', 'the server to log-in to', 'https://matrix.org')
.description('join a room')
.action(matrixJoin)
program.command('part <room>')
.requiredOption('-u, --user <user>', 'the username to log-in to matrix.org')
.option('-p, --password <password>', 'the password to log-in to matrix.org')
.requiredOption('-s, --server <server>', 'the server to log-in to', 'https://matrix.org')
.description('part with a room')
.action(matrixPart)
program.command('list')
.requiredOption('-u, --user <user>', 'the username to log-in to matrix.org')
.option('-p, --password <password>', 'the password to log-in to matrix.org')
.requiredOption('-s, --server <server>', 'the server to log-in to', 'https://matrix.org')
.requiredOption('-e, --entity <entity>', 'the entity to list, valid options are "rooms", "members"', 'rooms')
.description('list various matrix entities')
.action(matrixList)
program.command('mqtt <room> <connect> <topic>')
.requiredOption('-u, --user <user>', 'the username to log-in to matrix.org')
.option('-p, --password <password>', 'the password to log-in to matrix.org')
.requiredOption('-s, --server <server>', 'the server to log-in to', 'https://matrix.org')
.description('send and receive messages by bridging matrix.org to an MQTT server')
.action(matrixMqtt)
program.parse()