matrix-cli – Blame information for rev 20
?pathlinks?
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() |