matrix-cli – Blame information for rev 21

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 }
21 office 132  
1 office 133 var room = select[0]
7 office 134 var payload = {}
21 office 135  
7 office 136 switch (commandOptions.type.toUpperCase()) {
137 case 'HTML':
138 const promises = []
139 // interpret body as html and download/replace images
140 var htmlDocument = parse(sayText)
141 htmlDocument.querySelectorAll('img').forEach(node => {
142 if (typeof node.attrs.src !== 'undefined') {
143 promises.push(new Promise((resolve, _) => {
144 downloadFile(node.attrs.src, (info, buffer) => {
145 log.info(`Download complete: '${info.mimeType}'`)
146 client.uploadContent(buffer, { type: info.mimeType })
147 .then(response => {
148 node.setAttribute('src', response.content_uri)
149 resolve()
150 })
151 })
152 }))
153 }
154 })
21 office 155  
7 office 156 Promise.all(promises)
157 .then(() => {
158 payload = {
159 "format": "org.matrix.custom.html",
160 "body": '',
161 "formatted_body": htmlDocument.toString(),
162 "msgtype": matrix.MsgType.Text
163 }
164  
165 client.sendEvent(room.roomId, "m.room.message", payload, "").then(() => {
166 log.info(`Message sent to room '${room.name}'.`)
167 }).then(() => {
168 matrixLogout(client)
169 })
170 })
171 break
172 default:
173 payload = {
174 "body": sayText,
175 "msgtype": matrix.MsgType.Text
176 }
177 client.sendEvent(room.roomId, "m.room.message", payload, "").then(() => {
178 log.info(`Message sent to room '${room.name}'.`)
179 }).then(() => {
180 matrixLogout(client)
181 })
182 }
1 office 183 })
184 }
185  
186 function matrixJoin(joinRoom, commandOptions) {
187 matrixLogin(commandOptions, client => {
188 const rooms = client.getRooms()
12 office 189 log.info(`Searching for room ''${joinRoom}''...`)
1 office 190 var select = rooms.filter(room => room.name.localeCompare(joinRoom, { sensitiviy: 'accent' }) == 0)
191 if (select.length == 0) {
7 office 192 log.error(`Room '${joinRoom}' not found.`)
1 office 193 matrixLogout(client)
194 return
195 }
196 log.info(`Room '${joinRoom}' has been found.`)
197 var room = select[0]
198 switch (room.getMember(client.getUserId()).membership) {
199 case 'invite':
200 log.info(`Joining room.`)
201 client.joinRoom(room.roomId).then(() => {
202 log.info(`Room '${room.name}' joined successfully.`)
203 matrixLogout(client)
204 })
205 return
206 case 'join':
207 log.error(`Room '${joinRoom}' has already been joined.`)
208 matrixLogout(client)
209 return
210 }
211 })
212 }
213  
214 function matrixPart(partRoom, commandOptions) {
215 matrixLogin(commandOptions, client => {
216 const rooms = client.getRooms()
217 log.info(`Searching for room '${partRoom}'.`)
218 var select = rooms.filter(room => room.name.localeCompare(partRoom, { sensitiviy: 'accent' }) == 0)
219 if (select.length == 0) {
220 log.error(`Room '${partRoom}' not found.`)
221 matrixLogout(client)
222 return
223 }
224 log.info(`Room '${partRoom}' has been found.`)
225 var room = select[0]
226 switch (room.getMember(client.getUserId()).membership) {
227 case 'join':
228 log.info(`Leaving room.`)
229 client.leave(room.roomId).then(() => {
230 log.info(`Room '${room.name}' left successfully.`)
231 matrixLogout(client)
232 })
233 return
234 case 'invite':
235 log.error(`Not a member of '${partRoom}', but an invite is pending.`)
236 matrixLogout(client)
237 return
238 }
239 })
240 }
241  
242 function matrixList(commandOptions) {
243 matrixLogin(commandOptions, client => {
244 switch (commandOptions.entity.toUpperCase()) {
245 case 'ROOMS':
246 var rooms = client.getRooms()
7 office 247 log.info(`Available rooms: '${rooms.length}'`)
1 office 248 rooms.forEach(room => {
7 office 249 log.info(`\tRoom: '${room.name}' [${room.getMember(client.getUserId()).membership}]`);
1 office 250 })
251 log.info(`Done.`)
252 matrixLogout(client)
253 break
254 }
255 })
256 }
257  
7 office 258 function matrixPublish(sayRoom, publishFile, commandOptions) {
259 matrixLogin(commandOptions, client => {
260 const rooms = client.getRooms()
261 var select = rooms.filter(room => room.name.localeCompare(sayRoom, { sensitiviy: 'accent' }) == 0)
262 if (select.length == 0) {
263 log.error(`Room '${sayRoom}' not found.`)
264 matrixLogout(client)
265 return
266 }
13 office 267 const room = select[0]
7 office 268 var payloads = []
269 var promises = []
270 publishFile.forEach(filePath => {
271 // retrieve by URL
272 if (/^(http|https)/.test(filePath)) {
273 promises.push(new Promise((resolve, _) => {
274 log.info(`Downloading '${filePath}'...`)
275 downloadFile(filePath, (info, buffer) => {
276 log.info(`Download complete: '${info.mimeType}'`)
277 log.info(`Uploading file '${filePath}' with type '${info.mimeType}' to matrix...`)
278 client.uploadContent(buffer, { "type": info.mimeType })
279 .then(response => {
280 log.info(`Upload complete for '${filePath}': '${response.content_uri}'}`)
281 payloads.push({
282 "msgtype": matrix.MsgType.File,
283 "body": `${crypto.createHash('shake256', { outputLength: 8 })
284 .update(filePath)
285 .digest("hex")}.${info.extensions.pop()}`,
286 "url": response.content_uri,
287 "initial": filePath
288  
289 })
290 resolve()
291 })
292 })
293 }))
294  
295 return
296 }
297  
9 office 298 // load files otherwise...
7 office 299 promises.push(new Promise((resolve, reject) => {
300 log.info(`Checking path '${filePath}'...`)
301 fs.stat(filePath, (error, stats) => {
302 if (error) {
303 log.error(`Checking for file '${filePath}' failed with error '${error}'`)
304 reject()
305 return
306 }
307  
308 if (!stats.isFile()) {
309 log.error(`Path '${filePath}' does not point to a file.`)
310 reject()
311 }
312  
313 log.info(`Reading file '${filePath}'...`)
314 fs.readFile(filePath, (error, buffer) => {
315 if (error) {
316 log.error(`Could not read file '${filePath}'.`)
317 reject()
318 return
319 }
320 var info = identify(buffer)
321 if (info.mimeType === 'application/octet-stream') {
322 if (!/\ufffd/.test(buffer.toString())) {
323 info = {
324 "mimeType": "text/plain",
325 "extensions": ["txt"]
326 }
327 }
328 }
329 log.info(`Uploading file '${filePath}' with type '${info.mimeType}' to matrix...`)
330 client.uploadContent(buffer, { type: info.mimeType })
331 .then(response => {
332 log.info(`Upload complete for '${filePath}': '${response.content_uri}'.`)
333 payloads.push({
334 "msgtype": matrix.MsgType.File,
335 "body": `${crypto.createHash('shake256', { outputLength: 8 })
336 .update(filePath)
337 .digest("hex")}.${info.extensions.pop()}`,
338 "url": response.content_uri,
339 "initial": filePath
340 })
341 resolve()
342 })
343 })
344 })
345 }))
346 })
347  
348 log.info(`Waiting for all files to process...`)
349 Promise.all(promises)
350 .then(() => {
351 promises = []
352 payloads.forEach(payload => {
353 promises.push(new Promise((resolve, _) => {
354 log.info(`Sending '${payload.initial}'...`)
355 client.sendMessage(room.roomId, payload)
356 .then(response => {
357 log.info(`Sent file '${payload.initial}' as '${payload.body}' to '${room.name}'.`)
358 resolve()
359 })
360 }))
361 })
362  
363 Promise.all(promises)
364 .then(() => {
365 matrixLogout(client)
366 })
367 })
368 .catch(error => {
369 log.error(`Error occurred while waiting for file processing '${error}'`)
370 matrixLogout(client)
371 })
372 .finally(() => {
373  
374 })
375 })
376 }
377  
17 office 378 function matrixMqtt(matrixRoom, mqttUrl, mqttTopic, commandOptions) {
379 matrixLogin(commandOptions, matrixClient => {
380 const rooms = matrixClient.getRooms()
381 var select = rooms.filter(room => room.name.localeCompare(matrixRoom, { sensitiviy: 'accent' }) == 0)
382 if (select.length == 0) {
383 log.error(`Room '${sayRoom}' not found.`)
384 matrixLogout(matrixClient)
385 return
386 }
387  
388 const room = select[0]
389 log.info(`Room '${room.name}' has been found.`)
390  
391 log.info(`Connecting to MQTT via ${mqttUrl} and subscribing to MQTT topic '${mqttTopic}'.`)
392 // v5 and 'nl' on topic in order to not listen to self
393 const mqttClient = mqtt.connect(mqttUrl, { 'protocolVersion': 5 })
394 mqttClient.on('connect', () => {
395 mqttClient.subscribe(mqttTopic, { 'nl': true }, (error) => {
396 if (error) {
397 log.error(`Subscription to '${mqttTopic}' failed with error ${error}.`)
398 matrixLogout(matrixClient)
399 return
400 }
401  
402 log.info(`Successfully subscribed to MQTT topic '${mqttTopic}'.`)
403 matrixClient.on(matrix.RoomEvent.Timeline, (event, room, toStartOfTimeline) => {
404 if (toStartOfTimeline) {
405 return // don't print paginated results
406 }
407 if (event.getType() !== "m.room.message") {
408 return // only print messages
409 }
410  
411 var payload = {
412 'room': room.name,
413 'sender': event.getSender(),
414 'message': event.getContent().body
415 }
416  
417 log.info(`Publishing message '${JSON.stringify(payload)}' to MQTT server.`)
418 mqttClient.publish(mqttTopic, JSON.stringify(payload))
419 })
420  
421 mqttClient.on('message', (topic, payload) => {
422 if (topic !== mqttTopic) {
423 return
424 }
425  
426 log.info(`Sending message '${payload}' to MQTT server.`)
427 matrixClient.sendMessage(room.roomId, payload)
428 })
429  
430 // this might need winpty on Windows/cygwin
431 process.on('SIGINT', () => {
432 log.info(`Signal received, tearing bridge down and disconnecting...`)
433 mqttClient.end()
434 matrixLogout(matrixClient)
435 log.info(`Bye`)
436 process.exit(0)
437 })
438 process.stdin.resume()
439 })
440 })
441 })
442 }
443  
1 office 444 program
445 .name('matrix-cli')
446 .description(`+-- --+
447 | /\\ /\\ | matrix.org command-line client
448 | + + + | (c) 2024 Wizardry and Steamworks
449 +-- --+`).version('1.0')
450  
451 program.command('say <room> <text>')
452 .requiredOption('-u, --user <user>', 'the username to log-in to matrix.org')
453 .option('-p, --password <password>', 'the password to log-in to matrix.org')
21 office 454 .requiredOption('-s, --server <server>', 'the server to log-in to', 'https://matrix.org')
455 .option('-t, --type <type>', 'the message format; either text (default) or html', 'text')
1 office 456 .description('send a message to a room')
457 .action(matrixSay)
458  
7 office 459 program.command('publish <room> <file...>')
460 .requiredOption('-u, --user <user>', 'the username to log-in to matrix.org')
461 .option('-p, --password <password>', 'the password to log-in to matrix.org')
21 office 462 .requiredOption('-s, --server <server>', 'the server to log-in to', 'https://matrix.org')
7 office 463 .description('publish files to a room from either the local filesystem or from an URL')
464 .action(matrixPublish)
465  
1 office 466 program.command('join <room>')
467 .requiredOption('-u, --user <user>', 'the username to log-in to matrix.org')
468 .option('-p, --password <password>', 'the password to log-in to matrix.org')
21 office 469 .requiredOption('-s, --server <server>', 'the server to log-in to', 'https://matrix.org')
1 office 470 .description('join a room')
471 .action(matrixJoin)
472  
473 program.command('part <room>')
474 .requiredOption('-u, --user <user>', 'the username to log-in to matrix.org')
475 .option('-p, --password <password>', 'the password to log-in to matrix.org')
21 office 476 .requiredOption('-s, --server <server>', 'the server to log-in to', 'https://matrix.org')
1 office 477 .description('part with a room')
478 .action(matrixPart)
479  
480 program.command('list')
481 .requiredOption('-u, --user <user>', 'the username to log-in to matrix.org')
482 .option('-p, --password <password>', 'the password to log-in to matrix.org')
21 office 483 .requiredOption('-s, --server <server>', 'the server to log-in to', 'https://matrix.org')
484 .requiredOption('-e, --entity <entity>', 'the entity to list, valid options are "rooms", "members"', 'rooms')
1 office 485 .description('list various matrix entities')
486 .action(matrixList)
487  
17 office 488 program.command('mqtt <room> <connect> <topic>')
489 .requiredOption('-u, --user <user>', 'the username to log-in to matrix.org')
490 .option('-p, --password <password>', 'the password to log-in to matrix.org')
21 office 491 .requiredOption('-s, --server <server>', 'the server to log-in to', 'https://matrix.org')
17 office 492 .description('send and receive messages by bridging matrix.org to an MQTT server')
493 .action(matrixMqtt)
494  
1 office 495 program.parse()