matrix-cli – Rev 21

Subversion Repositories:
Rev:
#!/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()