matrix-cli – Blame information for rev 20

Subversion Repositories:
Rev:
Rev Author Line No. Line
4 office 1 #!/usr/bin/env node
1 office 2 ///////////////////////////////////////////////////////////////////////////
3 // Copyright (C) Wizardry and Steamworks 2024 - License: MIT //
4 // Please see: https://opensource.org/license/mit/ for legal details, //
5 // rights of fair usage, the disclaimer and warranty conditions. //
6 ///////////////////////////////////////////////////////////////////////////
7  
8 const matrix = require('matrix-js-sdk')
9 const loglevel = require('loglevel')
10 const { Command } = require('commander')
11 const program = new Command()
12 const winston = require('winston')
13 const { exitOnError } = require('winston')
14 const prompt = require('prompt')
7 office 15 const axios = require('axios')
16 const identify = require('buffer-signature').identify
17 const fs = require('fs')
18 const { parse } = require('node-html-parser')
19 const crypto = require("crypto")
17 office 20 const mqtt = require('mqtt')
1 office 21  
22 const log = winston.createLogger({
23 level: 'info',
24 transports: [
7 office 25 /*
11 office 26 * Installed as a global tool.
27 *
7 office 28 * new winston.transports.File({
29 * filename: 'logs/matrix-cli.log'
30 * }),
31 */
1 office 32 new winston.transports.Console({
33 format: winston.format.combine(
34 winston.format.colorize(),
35 winston.format.simple()
36 )
37 })
38 ],
39 })
40  
41 // suppress matrix logging
42 loglevel.getLogger('matrix').disableAll()
43  
44 function continueLogin(commandOptions, callback) {
45 const client = matrix.createClient(
46 {
47 baseUrl: commandOptions.server
48 }
49 )
50  
51 client.login(
52 "m.login.password", {
53 "user": commandOptions.user,
54 "password": commandOptions.password
55 })
56 .catch(error => {
7 office 57 log.error(`Login failed with error '${error}'`)
1 office 58 matrixLogout(client)
59 process.exit(1)
60 })
61 .then(() => {
62 log.info(`Logged in.`)
63 client.startClient(0)
64 log.info(`Client synchronizing...`)
65 client.once('sync', state => {
66 if (state === 'PREPARED') {
67 log.info(`Synchronized.`)
68 callback(client)
69 }
70 })
71 })
72 }
73  
74 function matrixLogin(commandOptions, callback) {
75 if (typeof commandOptions.password === 'undefined') {
76 log.info(`No password provided, reading password from command line.`)
77 prompt.start()
78 prompt.get({ description: 'Enter the password (will not be echoed): ', name: 'Password', hidden: true, required: true }, (error, result) => {
79 if (error) {
80 log.error(`Could not read password from prompt.`)
81 prompt.stop()
82 return
83 }
84 commandOptions.password = result.Password
85 prompt.stop()
86  
87 continueLogin(commandOptions, callback)
88 })
89 return
90 }
91 continueLogin(commandOptions, callback)
92 }
93  
94 function matrixLogout(client) {
95 client.stopClient()
96 // https://github.com/matrix-org/matrix-js-sdk/issues/2472
97 process.exit(0)
98 }
99  
7 office 100 function downloadFile(url, callback) {
101 return axios
102 .get(url, { responseType: 'arraybuffer' })
103 .then(response => {
104 /* info @ require('buffer-signature'):
105 * {
106 * mimeType: 'image/jpeg',
107 * description: 'JPEG raw or in the JFIF or Exif file format',
108 * extensions: [ 'jpg', 'jpeg' ]
109 * }
110 */
111 var info = identify(response.data)
112 callback(info, response.data)
113 })
114 .catch(error => {
115 // handle error
116 log.error(`Attachment download failed with error '${error}'`)
117 })
118 .finally(() => {
119 // always executed
120 })
121 }
122  
1 office 123 function matrixSay(sayRoom, sayText, commandOptions) {
124 matrixLogin(commandOptions, client => {
125 const rooms = client.getRooms()
126 var select = rooms.filter(room => room.name.localeCompare(sayRoom, { sensitiviy: 'accent' }) == 0)
127 if (select.length == 0) {
7 office 128 log.error(`Room '${sayRoom}' not found.`)
1 office 129 matrixLogout(client)
130 return
131 }
132 var room = select[0]
7 office 133 var payload = {}
134 switch (commandOptions.type.toUpperCase()) {
135 case 'HTML':
136 const promises = []
137 // interpret body as html and download/replace images
138 var htmlDocument = parse(sayText)
139 htmlDocument.querySelectorAll('img').forEach(node => {
140 if (typeof node.attrs.src !== 'undefined') {
141 promises.push(new Promise((resolve, _) => {
142 downloadFile(node.attrs.src, (info, buffer) => {
143 log.info(`Download complete: '${info.mimeType}'`)
144 client.uploadContent(buffer, { type: info.mimeType })
145 .then(response => {
146 node.setAttribute('src', response.content_uri)
147 resolve()
148 })
149 })
150 }))
151 }
152 })
153  
154 Promise.all(promises)
155 .then(() => {
156 payload = {
157 "format": "org.matrix.custom.html",
158 "body": '',
159 "formatted_body": htmlDocument.toString(),
160 "msgtype": matrix.MsgType.Text
161 }
162  
163 client.sendEvent(room.roomId, "m.room.message", payload, "").then(() => {
164 log.info(`Message sent to room '${room.name}'.`)
165 }).then(() => {
166 matrixLogout(client)
167 })
168 })
169 break
170 default:
171 payload = {
172 "body": sayText,
173 "msgtype": matrix.MsgType.Text
174 }
175 client.sendEvent(room.roomId, "m.room.message", payload, "").then(() => {
176 log.info(`Message sent to room '${room.name}'.`)
177 }).then(() => {
178 matrixLogout(client)
179 })
180 }
1 office 181 })
182 }
183  
184 function matrixJoin(joinRoom, commandOptions) {
185 matrixLogin(commandOptions, client => {
186 const rooms = client.getRooms()
12 office 187 log.info(`Searching for room ''${joinRoom}''...`)
1 office 188 var select = rooms.filter(room => room.name.localeCompare(joinRoom, { sensitiviy: 'accent' }) == 0)
189 if (select.length == 0) {
7 office 190 log.error(`Room '${joinRoom}' not found.`)
1 office 191 matrixLogout(client)
192 return
193 }
194 log.info(`Room '${joinRoom}' has been found.`)
195 var room = select[0]
196 switch (room.getMember(client.getUserId()).membership) {
197 case 'invite':
198 log.info(`Joining room.`)
199 client.joinRoom(room.roomId).then(() => {
200 log.info(`Room '${room.name}' joined successfully.`)
201 matrixLogout(client)
202 })
203 return
204 case 'join':
205 log.error(`Room '${joinRoom}' has already been joined.`)
206 matrixLogout(client)
207 return
208 }
209 })
210 }
211  
212 function matrixPart(partRoom, commandOptions) {
213 matrixLogin(commandOptions, client => {
214 const rooms = client.getRooms()
215 log.info(`Searching for room '${partRoom}'.`)
216 var select = rooms.filter(room => room.name.localeCompare(partRoom, { sensitiviy: 'accent' }) == 0)
217 if (select.length == 0) {
218 log.error(`Room '${partRoom}' not found.`)
219 matrixLogout(client)
220 return
221 }
222 log.info(`Room '${partRoom}' has been found.`)
223 var room = select[0]
224 switch (room.getMember(client.getUserId()).membership) {
225 case 'join':
226 log.info(`Leaving room.`)
227 client.leave(room.roomId).then(() => {
228 log.info(`Room '${room.name}' left successfully.`)
229 matrixLogout(client)
230 })
231 return
232 case 'invite':
233 log.error(`Not a member of '${partRoom}', but an invite is pending.`)
234 matrixLogout(client)
235 return
236 }
237 })
238 }
239  
240 function matrixList(commandOptions) {
241 matrixLogin(commandOptions, client => {
242 switch (commandOptions.entity.toUpperCase()) {
243 case 'ROOMS':
244 var rooms = client.getRooms()
7 office 245 log.info(`Available rooms: '${rooms.length}'`)
1 office 246 rooms.forEach(room => {
7 office 247 log.info(`\tRoom: '${room.name}' [${room.getMember(client.getUserId()).membership}]`);
1 office 248 })
249 log.info(`Done.`)
250 matrixLogout(client)
251 break
252 }
253 })
254 }
255  
7 office 256 function matrixPublish(sayRoom, publishFile, commandOptions) {
257 matrixLogin(commandOptions, client => {
258 const rooms = client.getRooms()
259 var select = rooms.filter(room => room.name.localeCompare(sayRoom, { sensitiviy: 'accent' }) == 0)
260 if (select.length == 0) {
261 log.error(`Room '${sayRoom}' not found.`)
262 matrixLogout(client)
263 return
264 }
13 office 265 const room = select[0]
7 office 266 var payloads = []
267 var promises = []
268 publishFile.forEach(filePath => {
269 // retrieve by URL
270 if (/^(http|https)/.test(filePath)) {
271 promises.push(new Promise((resolve, _) => {
272 log.info(`Downloading '${filePath}'...`)
273 downloadFile(filePath, (info, buffer) => {
274 log.info(`Download complete: '${info.mimeType}'`)
275 log.info(`Uploading file '${filePath}' with type '${info.mimeType}' to matrix...`)
276 client.uploadContent(buffer, { "type": info.mimeType })
277 .then(response => {
278 log.info(`Upload complete for '${filePath}': '${response.content_uri}'}`)
279 payloads.push({
280 "msgtype": matrix.MsgType.File,
281 "body": `${crypto.createHash('shake256', { outputLength: 8 })
282 .update(filePath)
283 .digest("hex")}.${info.extensions.pop()}`,
284 "url": response.content_uri,
285 "initial": filePath
286  
287 })
288 resolve()
289 })
290 })
291 }))
292  
293 return
294 }
295  
9 office 296 // load files otherwise...
7 office 297 promises.push(new Promise((resolve, reject) => {
298 log.info(`Checking path '${filePath}'...`)
299 fs.stat(filePath, (error, stats) => {
300 if (error) {
301 log.error(`Checking for file '${filePath}' failed with error '${error}'`)
302 reject()
303 return
304 }
305  
306 if (!stats.isFile()) {
307 log.error(`Path '${filePath}' does not point to a file.`)
308 reject()
309 }
310  
311 log.info(`Reading file '${filePath}'...`)
312 fs.readFile(filePath, (error, buffer) => {
313 if (error) {
314 log.error(`Could not read file '${filePath}'.`)
315 reject()
316 return
317 }
318 var info = identify(buffer)
319 if (info.mimeType === 'application/octet-stream') {
320 if (!/\ufffd/.test(buffer.toString())) {
321 info = {
322 "mimeType": "text/plain",
323 "extensions": ["txt"]
324 }
325 }
326 }
327 log.info(`Uploading file '${filePath}' with type '${info.mimeType}' to matrix...`)
328 client.uploadContent(buffer, { type: info.mimeType })
329 .then(response => {
330 log.info(`Upload complete for '${filePath}': '${response.content_uri}'.`)
331 payloads.push({
332 "msgtype": matrix.MsgType.File,
333 "body": `${crypto.createHash('shake256', { outputLength: 8 })
334 .update(filePath)
335 .digest("hex")}.${info.extensions.pop()}`,
336 "url": response.content_uri,
337 "initial": filePath
338 })
339 resolve()
340 })
341 })
342 })
343 }))
344 })
345  
346 log.info(`Waiting for all files to process...`)
347 Promise.all(promises)
348 .then(() => {
349 promises = []
350 payloads.forEach(payload => {
351 promises.push(new Promise((resolve, _) => {
352 log.info(`Sending '${payload.initial}'...`)
353 client.sendMessage(room.roomId, payload)
354 .then(response => {
355 log.info(`Sent file '${payload.initial}' as '${payload.body}' to '${room.name}'.`)
356 resolve()
357 })
358 }))
359 })
360  
361 Promise.all(promises)
362 .then(() => {
363 matrixLogout(client)
364 })
365 })
366 .catch(error => {
367 log.error(`Error occurred while waiting for file processing '${error}'`)
368 matrixLogout(client)
369 })
370 .finally(() => {
371  
372 })
373 })
374 }
375  
17 office 376 function matrixMqtt(matrixRoom, mqttUrl, mqttTopic, commandOptions) {
377 matrixLogin(commandOptions, matrixClient => {
378 const rooms = matrixClient.getRooms()
379 var select = rooms.filter(room => room.name.localeCompare(matrixRoom, { sensitiviy: 'accent' }) == 0)
380 if (select.length == 0) {
381 log.error(`Room '${sayRoom}' not found.`)
382 matrixLogout(matrixClient)
383 return
384 }
385  
386 const room = select[0]
387 log.info(`Room '${room.name}' has been found.`)
388  
389 log.info(`Connecting to MQTT via ${mqttUrl} and subscribing to MQTT topic '${mqttTopic}'.`)
390 // v5 and 'nl' on topic in order to not listen to self
391 const mqttClient = mqtt.connect(mqttUrl, { 'protocolVersion': 5 })
392 mqttClient.on('connect', () => {
393 mqttClient.subscribe(mqttTopic, { 'nl': true }, (error) => {
394 if (error) {
395 log.error(`Subscription to '${mqttTopic}' failed with error ${error}.`)
396 matrixLogout(matrixClient)
397 return
398 }
399  
400 log.info(`Successfully subscribed to MQTT topic '${mqttTopic}'.`)
401 matrixClient.on(matrix.RoomEvent.Timeline, (event, room, toStartOfTimeline) => {
402 if (toStartOfTimeline) {
403 return // don't print paginated results
404 }
405 if (event.getType() !== "m.room.message") {
406 return // only print messages
407 }
408  
409 var payload = {
410 'room': room.name,
411 'sender': event.getSender(),
412 'message': event.getContent().body
413 }
414  
415 log.info(`Publishing message '${JSON.stringify(payload)}' to MQTT server.`)
416 mqttClient.publish(mqttTopic, JSON.stringify(payload))
417 })
418  
419 mqttClient.on('message', (topic, payload) => {
420 if (topic !== mqttTopic) {
421 return
422 }
423  
424 log.info(`Sending message '${payload}' to MQTT server.`)
425 matrixClient.sendMessage(room.roomId, payload)
426 })
427  
428 // this might need winpty on Windows/cygwin
429 process.on('SIGINT', () => {
430 log.info(`Signal received, tearing bridge down and disconnecting...`)
431 mqttClient.end()
432 matrixLogout(matrixClient)
433 log.info(`Bye`)
434 process.exit(0)
435 })
436 process.stdin.resume()
437 })
438 })
439 })
440 }
441  
1 office 442 program
443 .name('matrix-cli')
444 .description(`+-- --+
445 | /\\ /\\ | matrix.org command-line client
446 | + + + | (c) 2024 Wizardry and Steamworks
447 +-- --+`).version('1.0')
448  
449 program.command('say <room> <text>')
450 .requiredOption('-u, --user <user>', 'the username to log-in to matrix.org')
451 .option('-p, --password <password>', 'the password to log-in to matrix.org')
452 .requiredOption('-s, --server <server>', 'the server to log-in to')
7 office 453 .option('-t, --type <type>', 'the message format; either text (default) or html')
1 office 454 .description('send a message to a room')
455 .action(matrixSay)
456  
7 office 457 program.command('publish <room> <file...>')
458 .requiredOption('-u, --user <user>', 'the username to log-in to matrix.org')
459 .option('-p, --password <password>', 'the password to log-in to matrix.org')
460 .requiredOption('-s, --server <server>', 'the server to log-in to')
461 .description('publish files to a room from either the local filesystem or from an URL')
462 .action(matrixPublish)
463  
1 office 464 program.command('join <room>')
465 .requiredOption('-u, --user <user>', 'the username to log-in to matrix.org')
466 .option('-p, --password <password>', 'the password to log-in to matrix.org')
467 .requiredOption('-s, --server <server>', 'the server to log-in to')
468 .description('join a room')
469 .action(matrixJoin)
470  
471 program.command('part <room>')
472 .requiredOption('-u, --user <user>', 'the username to log-in to matrix.org')
473 .option('-p, --password <password>', 'the password to log-in to matrix.org')
474 .requiredOption('-s, --server <server>', 'the server to log-in to')
475 .description('part with a room')
476 .action(matrixPart)
477  
478 program.command('list')
479 .requiredOption('-u, --user <user>', 'the username to log-in to matrix.org')
480 .option('-p, --password <password>', 'the password to log-in to matrix.org')
481 .requiredOption('-s, --server <server>', 'the server to log-in to')
482 .requiredOption('-e, --entity <entity>', 'the entity to list, valid options are "rooms", "members"')
483 .description('list various matrix entities')
484 .action(matrixList)
485  
17 office 486 program.command('mqtt <room> <connect> <topic>')
487 .requiredOption('-u, --user <user>', 'the username to log-in to matrix.org')
488 .option('-p, --password <password>', 'the password to log-in to matrix.org')
489 .requiredOption('-s, --server <server>', 'the server to log-in to')
490 .description('send and receive messages by bridging matrix.org to an MQTT server')
491 .action(matrixMqtt)
492  
1 office 493 program.parse()