diff --git a/README.md b/README.md index 05d63c5..611d8b4 100644 --- a/README.md +++ b/README.md @@ -21,3 +21,11 @@ Set the following variables: * `HUBOT_MATRIX_HOST_SERVER` - the Matrix server to connect to (default is `https://matrix.org` if unset) * `HUBOT_MATRIX_USER` - bot login on the Matrix server - eg `@examplebotname:matrix.example.org` * `HUBOT_MATRIX_PASSWORD` - bot password on the Matrix server + +## Tests + +Since jest only runs mjs tests with experimental-vm-modules you need to set them when running the tests... + +```shell +NODE_OPTIONS="$NODE_OPTIONS --experimental-vm-modules" npm run test +``` diff --git a/jest.config.json b/jest.config.json new file mode 100644 index 0000000..a5f0349 --- /dev/null +++ b/jest.config.json @@ -0,0 +1,5 @@ +{ + "testMatch": [ + "**/*.test.mjs" + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7b39eb5..8a21eed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,13 +16,14 @@ "request": "^2.88.2" }, "devDependencies": { + "@jest/globals": "^29.7.0", "jest": "^29.7.0" }, "engines": { "node": ">=18" }, "peerDependencies": { - "hubot": ">=9.0" + "hubot": "^11.1.1" } }, "node_modules/@ampproject/remapping": { @@ -1846,19 +1847,6 @@ "node": ">= 0.12.0" } }, - "node_modules/coffeescript": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/coffeescript/-/coffeescript-2.7.0.tgz", - "integrity": "sha512-hzWp6TUE2d/jCcN67LrW1eh5b/rSDKQK6oD6VMLlggYVUUFexgTH9z3dNYihzX4RMhze5FTUsUmOXViJKFQR/A==", - "peer": true, - "bin": { - "cake": "bin/cake", - "coffee": "bin/coffee" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/collect-v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", @@ -2785,12 +2773,11 @@ } }, "node_modules/hubot": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/hubot/-/hubot-10.0.3.tgz", - "integrity": "sha512-s478bhXiNjeTYBC4twejPTWIoXw66TcgBWruD/LKyFk+Scu0zJMFp2O7XDnJos9k0gu1KqeMMqhsfpLNmUU6fQ==", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/hubot/-/hubot-11.1.1.tgz", + "integrity": "sha512-nI7qOjXemTlXLmf2qx7NZVG/6jXvB3x1Kp3Y70aYqcqpPj9D8vUmEhOv104kQeztN2sXJmmOJSvH7ZUOEBqENw==", "peer": true, "dependencies": { - "coffeescript": "^2.7.0", "connect-multiparty": "^2.2.0", "express": "^4.18.2", "express-basic-auth": "^1.2.1", diff --git a/package.json b/package.json index 4cf0168..09cb883 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "name": "hubot-matrix", "description": "Matrix adapter for Hubot", "license": "MIT", - "main": "src/matrix.js", + "main": "src/matrix.mjs", "dependencies": { "image-size": "^1.0.2", "matrix-js-sdk": "^30.0.0", @@ -12,10 +12,11 @@ "request": "^2.88.2" }, "devDependencies": { + "@jest/globals": "^29.7.0", "jest": "^29.7.0" }, "peerDependencies": { - "hubot": ">=9.0" + "hubot": "^11.1.1" }, "scripts": { "test": "jest" diff --git a/src/matrix.js b/src/matrix.js deleted file mode 100644 index 0270463..0000000 --- a/src/matrix.js +++ /dev/null @@ -1,186 +0,0 @@ -const { Robot, Adapter, TextMessage, User } = require.main.require('hubot/es2015'); - -let sdk = require("matrix-js-sdk"); - -let request = require('request'); -let sizeOf = require('image-size'); -let MatrixSession = require('./session.js'); - -let localStorage; -if (localStorage == null) { - let {LocalStorage} = require('node-localstorage'); - localStorage = new LocalStorage('./hubot-matrix.localStorage'); -} - -module.exports.use = (robot) => { - - let that; - - class Matrix extends Adapter { - constructor() { - super(...arguments); - this.robot.logger.info("Constructor"); - } - - handleUnknownDevices(err) { - let that = this; - return (() => { - let result = []; - for (const stranger in err.devices) { - const devices = err.devices[stranger]; - result.push((() => { - let result1 = []; - for (let device in devices) { - that.robot.logger.info(`Acknowledging ${stranger}'s device ${device}`); - result1.push(that.robot.matrixClient.setDeviceKnown(stranger, device)); - } - return result1; - })()); - } - return result; - })(); - } - - send(envelope, ...strings) { - return (() => { - let result = []; - for (const str of Array.from(strings)) { - that.robot.logger.info(`Sending to ${envelope.room}: ${str}`); - if (/^(f|ht)tps?:\/\//i.test(str)) { - result.push(that.sendURL(envelope, str)); - } else { - result.push(that.robot.matrixClient.sendNotice(envelope.room, str).catch(err => { - if (err.name === 'UnknownDeviceError') { - that.handleUnknownDevices(err); - return that.robot.matrixClient.sendNotice(envelope.room, str); - } - })); - } - } - return result; - })(); - } - - emote(envelope, ...strings) { - return Array.from(strings).map((str) => - that.robot.matrixClient.sendEmoteMessage(envelope.room, str).catch(err => { - if (err.name === 'UnknownDeviceError') { - that.handleUnknownDevices(err); - return that.robot.matrixClient.sendEmoteMessage(envelope.room, str); - } - })); - } - - reply(envelope, ...strings) { - return Array.from(strings).map((str) => - that.send(envelope, `${envelope.user.name}: ${str}`)); - } - - topic(envelope, ...strings) { - return Array.from(strings).map((str) => - that.robot.matrixClient.sendStateEvent(envelope.room, "m.room.topic", { - topic: str - }, "")); - } - - sendURL(envelope, url) { - that.robot.logger.info(`Downloading ${url}`); - return request({url, encoding: null}, (error, response, body) => { - if (error) { - return that.robot.logger.info(`Request error: ${JSON.stringify(error)}`); - } else if (response.statusCode === 200) { - let info; - try { - let dims = sizeOf(body); - that.robot.logger.info(`Image has dimensions ${JSON.stringify(dims)}, size ${body.length}`); - if (dims.type === 'jpg') { - dims.type = 'jpeg'; - } - info = {mimetype: `image/${dims.type}`, h: dims.height, w: dims.width, size: body.length}; - return that.robot.matrixClient.uploadContent(body, { - name: url, - type: info.mimetype - }).then(response => { - return that.robot.matrixClient.sendImageMessage(envelope.room, response.content_uri, info, url).catch(err => { - if (err.name === 'UnknownDeviceError') { - that.handleUnknownDevices(err); - return that.robot.matrixClient.sendImageMessage(envelope.room, response.content_uri, info, url); - } - }); - }); - } catch (error1) { - error = error1; - that.robot.logger.info(error.message); - return that.send(envelope, ` ${url}`); - } - } - }); - } - - run() { - this.robot.logger.info(`Run ${this.robot.name}`); - - let matrixServer = process.env.HUBOT_MATRIX_HOST_SERVER; - let matrixUser = process.env.HUBOT_MATRIX_USER; - let matrixPassword = process.env.HUBOT_MATRIX_PASSWORD; - let botName = this.robot.name; - - let that = this; - let matrixSession = new MatrixSession(botName, matrixServer, matrixUser, matrixPassword, this.robot.logger, localStorage); - matrixSession.createClient(async (err, client) => { - if (err) { - this.robot.logger.error(err); - return; - } - that.robot.matrixClient = client; - that.robot.matrixClient.on('sync', (state, prevState, data) => { - switch (state) { - case "PREPARED": - that.robot.logger.info(`Synced ${that.robot.matrixClient.getRooms().length} rooms`); - // We really don't want to let people set the display name to something other than the bot - // name because the bot only reacts to it's own name. - let userId = that.robot.matrixClient.getUserId(); - const currentDisplayName = that.robot.matrixClient.getUser(userId).displayName; - if (that.robot.name !== currentDisplayName) { - that.robot.logger.info(`Setting display name to ${that.robot.name}`); - that.robot.matrixClient.setDisplayName(that.robot.name); - } - return that.emit('connected'); - } - }); - that.robot.matrixClient.on('Room.timeline', (event, room, toStartOfTimeline) => { - if ((event.getType() === 'm.room.message') && (toStartOfTimeline === false)) { - that.robot.matrixClient.setPresence({ presence: "online" }); - let message = event.getContent(); - let name = event.getSender(); - let user = that.robot.brain.userForId(name); - user.room = room.roomId; - let userId = that.robot.matrixClient.getUserId(); - if (name !== userId) { - that.robot.logger.info(`Received message: ${JSON.stringify(message)} in room: ${user.room}, from: ${user.name} (${user.id}).`); - if (message.msgtype === "m.text") { - that.receive(new TextMessage(user, message.body)); - } - if ((message.msgtype !== "m.text") || (message.body.indexOf(that.robot.name) !== -1)) { - return that.robot.matrixClient.sendReadReceipt(event); - } - } - } - }); - that.robot.matrixClient.on('RoomMember.membership', (event, member) => { - let userId = that.robot.matrixClient.getUserId(); - if ((member.membership === 'invite') && (member.userId === userId)) { - return that.robot.matrixClient.joinRoom(member.roomId).then(() => { - return that.robot.logger.info(`Auto-joined ${member.roomId}`); - }); - } - }); - return that.robot.matrixClient.startClient(0); - }); - - } - } - - that = new Matrix(robot); - return that; -}; diff --git a/src/matrix.mjs b/src/matrix.mjs new file mode 100644 index 0000000..8e8e45a --- /dev/null +++ b/src/matrix.mjs @@ -0,0 +1,189 @@ +import { Robot, Adapter, TextMessage, User } from 'hubot'; + +import sdk from "matrix-js-sdk"; + +import request from 'request'; +import sizeOf from 'image-size'; +import MatrixSession from './session.mjs'; + +import {LocalStorage} from 'node-localstorage'; + +let localStorage; +if (localStorage == null) { + localStorage = new LocalStorage('./hubot-matrix.localStorage'); +} + +export default { + use(robot) { + + let that; + + class Matrix extends Adapter { + constructor() { + super(...arguments); + this.robot.logger.info("Constructor"); + } + + handleUnknownDevices(err) { + let that = this; + return (() => { + let result = []; + for (const stranger in err.devices) { + const devices = err.devices[stranger]; + result.push((() => { + let result1 = []; + for (let device in devices) { + that.robot.logger.info(`Acknowledging ${stranger}'s device ${device}`); + result1.push(that.robot.matrixClient.setDeviceKnown(stranger, device)); + } + return result1; + })()); + } + return result; + })(); + } + + send(envelope, ...strings) { + return (() => { + let result = []; + for (const str of Array.from(strings)) { + that.robot.logger.info(`Sending to ${envelope.room}: ${str}`); + if (/^(f|ht)tps?:\/\//i.test(str)) { + result.push(that.sendURL(envelope, str)); + } else { + result.push(that.robot.matrixClient.sendNotice(envelope.room, str).catch(err => { + if (err.name === 'UnknownDeviceError') { + that.handleUnknownDevices(err); + return that.robot.matrixClient.sendNotice(envelope.room, str); + } + })); + } + } + return result; + })(); + } + + emote(envelope, ...strings) { + return Array.from(strings).map((str) => + that.robot.matrixClient.sendEmoteMessage(envelope.room, str).catch(err => { + if (err.name === 'UnknownDeviceError') { + that.handleUnknownDevices(err); + return that.robot.matrixClient.sendEmoteMessage(envelope.room, str); + } + })); + } + + reply(envelope, ...strings) { + return Array.from(strings).map((str) => + that.send(envelope, `${envelope.user.name}: ${str}`)); + } + + topic(envelope, ...strings) { + return Array.from(strings).map((str) => + that.robot.matrixClient.sendStateEvent(envelope.room, "m.room.topic", { + topic: str + }, "")); + } + + sendURL(envelope, url) { + that.robot.logger.info(`Downloading ${url}`); + return request({url, encoding: null}, (error, response, body) => { + if (error) { + return that.robot.logger.info(`Request error: ${JSON.stringify(error)}`); + } else if (response.statusCode === 200) { + let info; + try { + let dims = sizeOf(body); + that.robot.logger.info(`Image has dimensions ${JSON.stringify(dims)}, size ${body.length}`); + if (dims.type === 'jpg') { + dims.type = 'jpeg'; + } + info = {mimetype: `image/${dims.type}`, h: dims.height, w: dims.width, size: body.length}; + return that.robot.matrixClient.uploadContent(body, { + name: url, + type: info.mimetype + }).then(response => { + return that.robot.matrixClient.sendImageMessage(envelope.room, response.content_uri, info, url).catch(err => { + if (err.name === 'UnknownDeviceError') { + that.handleUnknownDevices(err); + return that.robot.matrixClient.sendImageMessage(envelope.room, response.content_uri, info, url); + } + }); + }); + } catch (error1) { + error = error1; + that.robot.logger.info(error.message); + return that.send(envelope, ` ${url}`); + } + } + }); + } + + run() { + this.robot.logger.info(`Run ${this.robot.name}`); + + let matrixServer = process.env.HUBOT_MATRIX_HOST_SERVER; + let matrixUser = process.env.HUBOT_MATRIX_USER; + let matrixPassword = process.env.HUBOT_MATRIX_PASSWORD; + let botName = this.robot.name; + + let that = this; + let matrixSession = new MatrixSession(botName, matrixServer, matrixUser, matrixPassword, this.robot.logger, localStorage); + matrixSession.createClient(async (err, client) => { + if (err) { + this.robot.logger.error(err); + return; + } + that.robot.matrixClient = client; + that.robot.matrixClient.on('sync', (state, prevState, data) => { + switch (state) { + case "PREPARED": + that.robot.logger.info(`Synced ${that.robot.matrixClient.getRooms().length} rooms`); + // We really don't want to let people set the display name to something other than the bot + // name because the bot only reacts to it's own name. + let userId = that.robot.matrixClient.getUserId(); + const currentDisplayName = that.robot.matrixClient.getUser(userId).displayName; + if (that.robot.name !== currentDisplayName) { + that.robot.logger.info(`Setting display name to ${that.robot.name}`); + that.robot.matrixClient.setDisplayName(that.robot.name); + } + return that.emit('connected'); + } + }); + that.robot.matrixClient.on('Room.timeline', (event, room, toStartOfTimeline) => { + if ((event.getType() === 'm.room.message') && (toStartOfTimeline === false)) { + that.robot.matrixClient.setPresence({presence: "online"}); + let message = event.getContent(); + let name = event.getSender(); + let user = that.robot.brain.userForId(name); + user.room = room.roomId; + let userId = that.robot.matrixClient.getUserId(); + if (name !== userId) { + that.robot.logger.info(`Received message: ${JSON.stringify(message)} in room: ${user.room}, from: ${user.name} (${user.id}).`); + if (message.msgtype === "m.text") { + that.receive(new TextMessage(user, message.body)); + } + if ((message.msgtype !== "m.text") || (message.body.indexOf(that.robot.name) !== -1)) { + return that.robot.matrixClient.sendReadReceipt(event); + } + } + } + }); + that.robot.matrixClient.on('RoomMember.membership', (event, member) => { + let userId = that.robot.matrixClient.getUserId(); + if ((member.membership === 'invite') && (member.userId === userId)) { + return that.robot.matrixClient.joinRoom(member.roomId).then(() => { + return that.robot.logger.info(`Auto-joined ${member.roomId}`); + }); + } + }); + return that.robot.matrixClient.startClient(0); + }); + + } + } + + that = new Matrix(robot); + return that; + } +} diff --git a/src/session.js b/src/session.mjs similarity index 87% rename from src/session.js rename to src/session.mjs index 278d1d9..203893a 100644 --- a/src/session.js +++ b/src/session.mjs @@ -1,10 +1,8 @@ -const sdk = require("matrix-js-sdk"); -const { - LocalStorageCryptoStore, -} = require('matrix-js-sdk/lib/crypto/store/localStorage-crypto-store'); -const Store = require('./store') +import sdk from "matrix-js-sdk"; +import {LocalStorageCryptoStore} from 'matrix-js-sdk/lib/crypto/store/localStorage-crypto-store.js'; +import Store from './store.mjs'; -class MatrixSession { +export default class MatrixSession { constructor(botName, matrixServer, matrixUser, matrixPassword, logger, localStorage) { this.botName = botName; @@ -33,7 +31,8 @@ class MatrixSession { accessToken: accessToken, userId: userId, deviceId: deviceId, - store: new Store(this.localStorage) + store: new Store(this.localStorage), + logger: this.logger }); cb(null, this.client) @@ -54,7 +53,8 @@ class MatrixSession { accessToken: data.access_token, userId: data.user_id, deviceId: data.device_id, - cryptoStore: new LocalStorageCryptoStore(that.localStorage) + cryptoStore: new LocalStorageCryptoStore(that.localStorage), + logger: this.logger }); that.localStorage.setItem("access_token", data.access_token) @@ -69,5 +69,3 @@ class MatrixSession { } } - -module.exports = MatrixSession diff --git a/src/session.test.js b/src/session.test.js deleted file mode 100644 index 4871e0f..0000000 --- a/src/session.test.js +++ /dev/null @@ -1,68 +0,0 @@ -const MatrixSession = require('./session'); -const sdk = require("matrix-js-sdk"); - -jest.mock("matrix-js-sdk") - -test('client login if no authentication token is available in the local storage', () => { - const client = { - login: jest.fn(() => { - }) - }; - sdk.createClient.mockReturnValue(client); - - let matrixSession = new MatrixSession("juliasbot", "http://server:8080", "julia", - "123", logger, new LocalStorageMock()); - matrixSession.createClient(jest.fn(() => {})); - - expect(client.login).toHaveBeenCalledTimes(1); -}); - -test('no client login if authentication token is available in the local storage', () => { - const client = { - login: jest.fn(() => { - }) - }; - sdk.createClient.mockReturnValue(client); - - let localStorageMock = new LocalStorageMock(); - localStorageMock.setItem("access_token", "someAccessToken") - localStorageMock.setItem("bot_name", "someBotName") - localStorageMock.setItem("user_id", "someUserId") - localStorageMock.setItem("device_id", "someDeviceId") - - let matrixSession = new MatrixSession("juliasbot", "http://server:8080", "julia", - "123", logger, localStorageMock); - matrixSession.createClient(jest.fn(() => {})); - - expect(client.login).toHaveBeenCalledTimes(0); -}); - - -const logger = { - info: jest.fn(() => { - }), - error: jest.fn(() => { - }), -} - -class LocalStorageMock { - constructor() { - this.store = {}; - } - - clear() { - this.store = {}; - } - - getItem(key) { - return this.store[key] || null; - } - - setItem(key, value) { - this.store[key] = String(value); - } - - removeItem(key) { - delete this.store[key]; - } -} diff --git a/src/session.test.mjs b/src/session.test.mjs new file mode 100644 index 0000000..cf3488c --- /dev/null +++ b/src/session.test.mjs @@ -0,0 +1,107 @@ +import {jest, test, expect, afterEach, describe} from '@jest/globals'; + +//jest.mock("matrix-js-sdk") +jest.unstable_mockModule('matrix-js-sdk', () => ({ + default: { + createClient: jest.fn(), + } +})) + +afterEach(() => { + jest.clearAllMocks(); +}) + +const sdk = (await import('matrix-js-sdk')).default; +const MatrixSession = (await import('./session.mjs')).default + +const mockedLoginResult = { + user_id: "someUserId", + access_token: "someAccessToken", + bot_name: "juliasbot", + device_id: "someDeviceId" +} + +describe('if no authentication token is available in the local storage', () => { + test('performs a client login', () => { + const client = { + login: jest.fn().mockResolvedValue(mockedLoginResult) + }; + sdk.createClient.mockReturnValue(client); + + let matrixSession = new MatrixSession("juliasbot", "http://server:8080", "julia", + "123", logger, new LocalStorageMock()); + matrixSession.createClient(jest.fn(() => {})); + + expect(client.login).toHaveBeenCalledTimes(1); + }) + + test('updates the localStorage', (done) => { + const client = { + login: jest.fn().mockResolvedValue(mockedLoginResult) + }; + sdk.createClient.mockReturnValue(client); + + const localStorageMock = new LocalStorageMock() + localStorageMock.xxx = "ASDF" + + let matrixSession = new MatrixSession("juliasbot", "http://server:8080", "julia", + "123", logger, localStorageMock); + + matrixSession.createClient(jest.fn(() => { + expect(localStorageMock.getItem("access_token")).toEqual("someAccessToken") + expect(localStorageMock.getItem("bot_name")).toEqual("juliasbot") + expect(localStorageMock.getItem("user_id")).toEqual("someUserId") + expect(localStorageMock.getItem("device_id")).toEqual("someDeviceId") + done() + })); + }) +}) + +test('no client login if authentication token is available in the local storage', () => { + const client = { + login: jest.fn().mockResolvedValue(mockedLoginResult) + }; + sdk.createClient.mockReturnValue(client); + + let localStorageMock = new LocalStorageMock(); + localStorageMock.setItem("access_token", "someAccessToken") + localStorageMock.setItem("bot_name", "someBotName") + localStorageMock.setItem("user_id", "someUserId") + localStorageMock.setItem("device_id", "someDeviceId") + + let matrixSession = new MatrixSession("juliasbot", "http://server:8080", "julia", + "123", logger, localStorageMock); + matrixSession.createClient(jest.fn(() => {})); + + expect(client.login).toHaveBeenCalledTimes(0); +}); + + +const logger = { + info: jest.fn(() => { + }), + error: jest.fn(() => { + }), +} + +class LocalStorageMock { + constructor() { + this.store = {}; + } + + clear() { + this.store = {}; + } + + getItem(key) { + return this.store[key] || null; + } + + setItem(key, value) { + this.store[key] = String(value); + } + + removeItem(key) { + delete this.store[key]; + } +} diff --git a/src/store.js b/src/store.mjs similarity index 83% rename from src/store.js rename to src/store.mjs index 5a23ea5..f4644b0 100644 --- a/src/store.js +++ b/src/store.mjs @@ -1,8 +1,6 @@ -const { - MemoryStore, -} = require('matrix-js-sdk/lib/store/memory'); +import { MemoryStore } from 'matrix-js-sdk/lib/store/memory.js'; -class Store extends MemoryStore { +export default class Store extends MemoryStore { constructor(localStorage) { super(); @@ -33,5 +31,3 @@ class Store extends MemoryStore { return this.saveDirty; } } - -module.exports = Store; \ No newline at end of file