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