Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions .github/workflows/hubot-demo.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# This workflow will create a new Hubot, install dependencies, and connect to a Matrix server.
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions

name: Hubot demo (e2e)

on:
push:
branches: [ master ]
pull_request:
branches: [ master ]

jobs:
build:

runs-on: ubuntu-latest
timeout-minutes: 5

env:
HUBOT_ADAPTER: 'hubot-matrix'
HUBOT_FAREWELL_ENABLED: true
HUBOT_FAREWELL_MESSAGE: "Goodbye from GHA Hubot demo"
HUBOT_FAREWELL_TARGET: ${{ secrets.TEST_MATRIX_ROOM }}
HUBOT_FAREWELL_TIMEIN: 5000
HUBOT_FAREWELL_TIMEOUT: 30000
# HUBOT_LOG_LEVEL: debug
HUBOT_NAME: 'HubotMatrixTest'
HUBOT_MATRIX_HOST: ${{ secrets.TEST_MATRIX_URL }}
HUBOT_MATRIX_PASSWORD: ${{ secrets.TEST_MATRIX_PASSWORD }}
HUBOT_MATRIX_USER: ${{ secrets.TEST_MATRIX_USER }}
HUBOT_STARTUP_MESSAGE: "Hello from GHA Hubot demo"
HUBOT_STARTUP_ROOM: ${{ secrets.TEST_MATRIX_ROOM }}
# LOG_LEVEL: debug

steps:
- uses: actions/checkout@v2
- name: Use Node.js v22
uses: actions/setup-node@v1
with:
node-version: '22.x'
- name: Configure env vars
run: |
# echo "HUBOT_NAME=Github E2E $GITHUB_RUN_ID" >> $GITHUB_ENV
echo "GHA_URL=$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" >> $GITHUB_ENV
- name: NPM CI
run: npm ci
- name: NPM link
run: npm link
- name: Configure env vars
run: |
echo "ADAPTER_DIR=$( pwd )" >> $GITHUB_ENV
echo "HUBOT_DIR=$( pwd )/node_modules/hubot" >> $GITHUB_ENV
echo "HUBOT_ROOT=$( mktemp -d )" >> $GITHUB_ENV
- name: Create and configure Hubot instance
run: |
npx hubot --create $HUBOT_ROOT
cd $HUBOT_ROOT
mkdir -p configuration src/scripts # silence warnings
echo "Creating Hubot for this adapter in $TEMP_ROOT and installing Hubot from $HUBOT_DIR"
npm install --save github:xurizaemon/hubot-matrix#update-esm github:xurizaemon/hubot-farewell github:xurizaemon/hubot-startup
npm show matrix-js-sdk
echo '["@xurizaemon/hubot-farewell", "@xurizaemon/hubot-startup"]' > external-scripts.json
echo "PATH=$PATH:$( pwd )/node_modules/.bin" >> $GITHUB_ENV
echo "HUBOT_INSTALLATION_PATH=$TEMP_ROOT" >> $GITHUB_ENV
echo "NODE_PATH=$TEMP_ROOT/node_modules" >> $GITHUB_ENV
echo "PATH=$PATH:$TEMP_ROOT/node_modules/.bin" >> $GITHUB_ENV
cat package.json
- name: Start Hubot, let plugins say hello & goodbye
run: |
cd $HUBOT_ROOT
echo "First run, initialise local storage"
PATH=$PATH:$( pwd )/node_modules/.bin
echo
echo "Second run"
# Log in and save creds to local storage, then exit.
HUBOT_FAREWELL_TIMEOUT=15000 hubot
# Reconnect with saved creds from local storage.
hubot

- name: Preserve artifacts
run: |
mkdir tmp
cp -v $HUBOT_ROOT/package-lock.json tmp/
- name: Upload package-lock.json
uses: actions/upload-artifact@v4
with:
name: package-lock-json
path: tmp/package-lock.json
21 changes: 17 additions & 4 deletions .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
@@ -13,17 +13,30 @@ jobs:
build:

runs-on: ubuntu-latest
timeout-minutes: 5

strategy:
matrix:
node-version: [10.x, 12.x, 14.x]
node-version: [18.x, 20.x, 22.x]

steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm run build --if-present
- run: npm test
- name: NPM CI
run: npm ci
- name: NPM run build
run: npm run build --if-present
- name: Unit tests
run: npm run test:unit
- name: Integration Tests
if: ${{ github.event_name == 'pull_request' }}
env:
TEST_MATRIX_USER: ${{ secrets.TEST_MATRIX_USER }}
TEST_MATRIX_PASSWORD: ${{ secrets.TEST_MATRIX_PASSWORD }}
TEST_MATRIX_ROOM: ${{ secrets.TEST_MATRIX_ROOM }}
TEST_MATRIX_URL: ${{ secrets.TEST_MATRIX_URL }}
TEST_ROOM_ID: ${{ secrets.TEST_ROOM_ID }}
run: npm run test:integration
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
.env
.hubot_history
*.iml
.idea
node_modules
# Local storage may be created.
hubot-matrix.localStorage
integration-test.localStorage
41 changes: 35 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -4,20 +4,49 @@ This is a [Hubot](https://hubot.github.com) adapter for [Matrix](https://matrix.

## Installation

Use the version of this adapter appropriate to your Hubot version.
Use the following adapter based on your Hubot version (`npm list hubot`).

* For Hubot v2, `npm i hubot-matrix@1`
* For Hubot v3, `npm i hubot-matrix@2`

| **hubot** | **hubot-matrix** |
|----|----|
| v2 | v1 |
| v3 | v2 |
* For Hubot v13+, `npm i @xurizaemon/hubot-matrix`

## Adapter configuration

Set the following variables:

* `HUBOT_ADAPTER` should be `hubot-matrix`
* `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

To run tests in Github Actions, the following **Github Secrets** should be configured.

| Name | Example | Description |
|------------------------|----------------------------------|---------------------------|
| `TEST_MATRIX_URL` | https://matrix-client.matrix.org | Home instance URL |
| `TEST_MATRIX_ROOM` | `!something@example.org` | Room ID (where to get it) |
| `TEST_MATRIX_USER` | @someuser:example.org | Username |
| `TEST_MATRIX_PASSWORD` | **** | Password |

There are environment variables which the tests need set to execute also. In Github Actions,
these variables are set in the Github workflow configuration (`.github/workflows/*.yml`).

| Name | Example | Description |
|-------------------------|----------------------------------|----------------------------|
| `HUBOT_MATRIX_HOST` | https://matrix-client.matrix.org | Home instance URL |
| `HUBOT_MATRIX_PASSWORD` | **** | Test user a/c password. |
| `HUBOT_MATRIX_USERNAME` | **** | Test user a/c login. |
| `HUBOT_FAREWELL_*` | Configure `hubot-farewell`[^1] | Configure shutdown message |
| `HUBOT_STARTUP_*` | Configure `hubot-startup`[^2] | Configure startup message |

Jest requires the experimental-vm-modules Node option to run .mjs tests:

```shell
NODE_OPTIONS="$NODE_OPTIONS --experimental-vm-modules" npm run test
```

[^1]: https://github.com/xurizaemon/hubot-farewell/

[^2]: https://github.com/xurizaemon/hubot-startup/
16 changes: 16 additions & 0 deletions jest-setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Polyfill in some Node crypto for tests only.
import { TextEncoder, TextDecoder } from 'util'
import crypto from 'crypto'

// Add TextEncoder and TextDecoder to global
global.TextEncoder = TextEncoder
global.TextDecoder = TextDecoder

// Add crypto to global
if (typeof global.crypto === 'undefined') {
global.crypto = {
getRandomValues: function (array) {
return crypto.randomBytes(array.length).copy(array)
}
}
}
10 changes: 10 additions & 0 deletions jest.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"testMatch": [
"**/*.test.mjs"
],
"testTimeout": 30000,
"detectOpenHandles": true,
"setupFiles": [
"./jest-setup.js"
]
}
8,155 changes: 7,751 additions & 404 deletions package-lock.json

Large diffs are not rendered by default.

35 changes: 23 additions & 12 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,21 +1,32 @@
{
"version": "3.0.0",
"name": "hubot-matrix",
"version": "2.0.1",
"description": "Matrix adapter for Hubot",
"main": "src/matrix.js",
"scripts": {
"test": "node --check src/*.js"
},
"author": "David A Roberts",
"license": "MIT",
"main": "src/matrix.mjs",
"type": "module",
"dependencies": {
"image-size": "^0.5.1",
"matrix-js-sdk": "^12.1.0",
"node-localstorage": "^1.3.0",
"image-size": "^1.0.2",
"matrix-js-sdk": "^37.4.0",
"node-localstorage": "^3.0.5",
"parent-require": "^1.0.0",
"request": "^2.79.0"
"request": "^2.88.2"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
"jest": "^29.7.0",
"standard": "^17.1.2"
},
"peerDependencies": {
"hubot": ">=3.0"
"hubot": "^13.0.1"
},
"scripts": {
"lint": "standard",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
"test:unit": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testPathIgnorePatterns='.*\\.integration\\.test\\.mjs'",
"test:integration": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testMatch='**/*.integration.test.mjs'"
},
"engines": {
"node": ">=18"
}
}
}
95 changes: 95 additions & 0 deletions src/adapter.integration.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { jest, test, expect, beforeAll, afterAll, describe } from '@jest/globals'
import MatrixAdapter from './matrix.mjs'
import { Robot, TextMessage } from 'hubot'

// Create a dedicated test bot account for these tests
const TEST_BOT_NAME = process.env.TEST_BOT_NAME || 'HubotMatrixTest'
const TEST_MATRIX_PASSWORD = process.env.TEST_MATRIX_PASSWORD
const TEST_MATRIX_ROOM = process.env.TEST_MATRIX_ROOM
const TEST_MATRIX_URL = process.env.TEST_MATRIX_URL || 'https://matrix-client.matrix.org'
const TEST_MATRIX_USER = process.env.TEST_MATRIX_USER

const runIntegrationTests = TEST_MATRIX_USER && TEST_MATRIX_PASSWORD && TEST_MATRIX_ROOM

describe('Matrix Adapter Integration Tests', () => {
let robot
let adapter

beforeAll(() => {
if (!runIntegrationTests) {
console.warn('Skipping integration tests - missing environment variables')
return
}

process.env.HUBOT_MATRIX_HOST_SERVER = TEST_MATRIX_URL
process.env.HUBOT_MATRIX_USER = TEST_MATRIX_USER
process.env.HUBOT_MATRIX_PASSWORD = TEST_MATRIX_PASSWORD

// Create test robot
robot = new Robot(null, 'mock-adapter', false, TEST_BOT_NAME)

// Spy on robot methods
robot.receive = jest.fn(robot.receive)

// Use the real adapter
adapter = MatrixAdapter.use(robot)
robot.adapter = adapter
})

afterAll(async () => {
if (robot && robot.matrixClient) {
await robot.matrixClient.stopClient()
}

// Reset environment variables
delete process.env.HUBOT_MATRIX_HOST_SERVER
delete process.env.HUBOT_MATRIX_USER
delete process.env.HUBOT_MATRIX_PASSWORD
})

test('should connect to Matrix server', (done) => {
if (!runIntegrationTests) {
done()
return
}

// Listen for connected event
adapter.on('connected', () => {
expect(robot.matrixClient).toBeDefined()
expect(robot.matrixClient.getUserId()).toBeDefined()
done()
})

// Run the adapter
adapter.run()
}, 30000)

test('should send a message to a room', async () => {
if (!runIntegrationTests) return

// Wait for client to be ready
if (!robot.matrixClient) {
await new Promise(resolve => setTimeout(resolve, 5000))
}

// Create an envelope for the test room
const envelope = { room: TEST_MATRIX_ROOM }

// Send a test message
const testMessage = `Test message from integration test: ${Date.now()}`

// Use a promise to track the send operation
return new Promise((resolve, reject) => {
try {
adapter.send(envelope, testMessage)

// Wait to allow the message to be sent
setTimeout(() => {
resolve()
}, 2000)
} catch (error) {
reject(error)
}
})
}, 30000)
})
16 changes: 16 additions & 0 deletions src/crypto-polyfill.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import crypto from 'crypto'

// Polyfill for crypto.getRandomValues() in Node.js
if (typeof global.crypto === 'undefined') {
global.crypto = {}
}

if (typeof global.crypto.getRandomValues === 'undefined') {
global.crypto.getRandomValues = function (array) {
const bytes = crypto.randomBytes(array.length)
array.set(bytes)
return array
}
}

export default global.crypto
7 changes: 3 additions & 4 deletions src/matrix.js
Original file line number Diff line number Diff line change
@@ -162,11 +162,10 @@ class Matrix extends Adapter {
that.client.setPresence("online");
let message = event.getContent();
let name = event.getSender();
let prettyname = room.currentState._userIdsToDisplayNames[name];
let user = that.robot.brain.userForId(name, { name: prettyname });
let user = that.robot.brain.userForId(name);
user.room = room.roomId;
if (user.id !== that.user_id) {
that.robot.logger.info(`Received message: ${JSON.stringify(message)} in room: ${user.room}, from: ${user.name}.`);
if (name !== that.user_id) {
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.client.sendReadReceipt(event); }
}
216 changes: 216 additions & 0 deletions src/matrix.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
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.lastPresenceUpdate = 0
this.PRESENCE_UPDATE_INTERVAL = 60000 // Minimum 1 minute between updates
}

updatePresence () {
const now = Date.now()
if (now - this.lastPresenceUpdate >= this.PRESENCE_UPDATE_INTERVAL) {
this.robot.logger.debug(`Setting presence. Last update was ${this.lastPresenceUpdate}ms ago`)
this.robot.matrixClient.setPresence({ presence: 'online' })
.catch(error => {
if (error.errcode === 'M_LIMIT_EXCEEDED') {
this.robot.logger.warn(`Rate limited when setting presence. Retry after: ${error.retry_after_ms}ms`)
} else {
this.robot.logger.warn(`Error ${error.errcode} setting presence: ${error.message}`)
}
})
this.lastPresenceUpdate = now
}
}

handleUnknownDevices (error) {
const that = this
return (() => {
const result = []
for (const stranger in error.devices) {
const devices = error.devices[stranger]
result.push((() => {
const result1 = []
for (const device in devices) {
that.robot.logger.debug(`Acknowledging ${stranger}'s device ${device}`)
result1.push(that.robot.matrixClient.setDeviceKnown(stranger, device))
}
return result1
})())
}
return result
})()
}

send (envelope, ...strings) {
this.updatePresence()
return (() => {
const result = []
for (const str of Array.from(strings)) {
that.robot.logger.debug(`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(error => {
if (error.name === 'UnknownDeviceError') {
that.handleUnknownDevices(error)
return that.robot.matrixClient.sendNotice(envelope.room, str)
}
console.error(error, 'error')
}))
}
}
return result
})()
}

emote (envelope, ...strings) {
return Array.from(strings).map((str) =>
that.robot.matrixClient.sendEmoteMessage(envelope.room, str).catch(error => {
if (error.name === 'UnknownDeviceError') {
that.handleUnknownDevices(error)
return that.robot.matrixClient.sendEmoteMessage(envelope.room, str)
}
console.error(error, 'error')
}))
}

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.debug(`Downloading ${url}`)
return request({ url, encoding: null }, (error, response, body) => {
if (error) {
that.robot.logger.error(`Request error: ${JSON.stringify(error)}`)
return false
} else if (response.statusCode === 200) {
let info
try {
const dimensions = sizeOf(body)
that.robot.logger.debug(`Image has dimensions ${JSON.stringify(dimensions)}, size ${body.length}`)
if (dimensions.type === 'jpg') {
dimensions.type = 'jpeg'
}
info = { mimetype: `image/${dimensions.type}`, h: dimensions.height, w: dimensions.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(error1 => {
if (error1.name === 'UnknownDeviceError') {
that.handleUnknownDevices(error1)
return that.robot.matrixClient.sendImageMessage(envelope.room, response.content_uri, info, url)
}
})
})
} catch (error2) {
that.robot.logger.error(error2.message)
return that.send(envelope, ` ${url}`)
}
}
})
}

run () {
this.robot.logger.debug(`Run ${this.robot.name}`)

const matrixServer = process.env.HUBOT_MATRIX_HOST_SERVER
const matrixUser = process.env.HUBOT_MATRIX_USER
const matrixPassword = process.env.HUBOT_MATRIX_PASSWORD
const botName = this.robot.name

const that = this
const matrixSession = new MatrixSession(botName, matrixServer, matrixUser, matrixPassword, this.robot.logger, localStorage)
matrixSession.createClient(async (error, client) => {
if (error) {
this.robot.logger.error(error)
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 its own name.
const userId = that.robot.matrixClient.getUserId()
const currentDisplayName = that.robot.matrixClient.getUser(userId).displayName
if (that.robot.name !== currentDisplayName) {
const botDisplayName = String(that.robot.name || 'MatrixBot')
that.robot.logger.info(`Setting display name to ${botDisplayName}`)
that.robot.matrixClient.setDisplayName(botDisplayName)
}
return that.emit('connected')
}
})
that.robot.matrixClient.on('Room.timeline', (event, room, toStartOfTimeline) => {
if ((event.getType() === 'm.room.message') && (toStartOfTimeline === false)) {
const message = event.getContent()
const senderName = event.getSender()
const senderUser = that.robot.brain.userForId(senderName)
senderUser.room = room.roomId
const userId = that.robot.matrixClient.getUserId()
if (senderName !== userId) {
that.robot.logger.debug(`Received message: ${JSON.stringify(message)} in room: ${senderUser.room}, from: ${senderUser.name} (${senderUser.id}).`)
if (message.msgtype === 'm.text') {
if (message.body.indexOf(that.robot.name) !== -1) {
that.updatePresence()
}
that.receive(new TextMessage(senderUser, 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) => {
const 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({
initialSyncLimit: 0,
presence: {
enabled: false
},
pendingEventOrdering: 'detached'
})
})
}
}

that = new Matrix(robot)
return that
}
}
158 changes: 158 additions & 0 deletions src/session.integration.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { jest, test, expect, beforeAll, afterAll, describe } from '@jest/globals'
import { LocalStorage } from 'node-localstorage'
import MatrixSession from './session.mjs'

// Create a dedicated test bot account for these tests
const TEST_BOT_NAME = process.env.TEST_BOT_NAME || 'hubot-matrix-test-bot'
const TEST_MATRIX_PASSWORD = process.env.TEST_MATRIX_PASSWORD
const TEST_MATRIX_ROOM_ID = process.env.TEST_MATRIX_ROOM_ID
const TEST_MATRIX_SERVER = process.env.TEST_MATRIX_SERVER || 'https://matrix-client.matrix.org'
const TEST_MATRIX_USER = process.env.TEST_MATRIX_USER

// Skip all tests if credentials aren't provided
const runIntegrationTests = TEST_MATRIX_USER && TEST_MATRIX_PASSWORD

function createLogger () {
return {
info: jest.fn(msg => console.log(`[INFO] ${msg}`)),
warn: jest.fn(msg => console.warn(`[WARN] ${msg}`)),
error: jest.fn(msg => console.error(`[ERROR] ${msg}`)),
debug: jest.fn(msg => console.debug(`[DEBUG] ${msg}`)),
trace: jest.fn(msg => {}),
log: jest.fn(msg => console.log(`[LOG] ${msg}`)),
setLevel: jest.fn(),
silly: jest.fn()
}
}

describe('Matrix Session Integration Tests', () => {
let matrixClient
let localStorage
let logger

// Only run these tests if credentials are provided
beforeAll(() => {
if (!runIntegrationTests) {
console.warn('Skipping integration tests - no TEST_MATRIX_USER/PASSWORD environment variables set')
} else {
localStorage = new LocalStorage('./integration-test.localStorage')
logger = {
info: jest.fn(msg => console.log(`[INFO] ${msg}`)),
warn: jest.fn(msg => console.warn(`[WARN] ${msg}`)),
error: jest.fn(msg => console.error(`[ERROR] ${msg}`)),
debug: jest.fn(msg => console.debug(`[DEBUG] ${msg}`)),
trace: jest.fn(msg => {}),
setLevel: jest.fn(),
log: jest.fn(msg => console.log(`[LOG] ${msg}`)),
silly: jest.fn()
}
}
})

afterAll(async () => {
if (localStorage) {
try {
localStorage.clear()
} catch (err) {
console.warn('Error clearing localStorage:', err.message)
}
}

if (matrixClient) {
try {
await matrixClient.stopClient({
unsetStoppedFlag: true
})
} catch (err) {
console.warn('Error stopping Matrix client:', err.message)
}
}
})

test('should connect to Matrix server and authenticate', async () => {
if (!runIntegrationTests) return

const matrixSession = new MatrixSession(
TEST_BOT_NAME,
TEST_MATRIX_SERVER,
TEST_MATRIX_USER,
TEST_MATRIX_PASSWORD,
logger,
localStorage
)

let timeoutId
try {
const result = await Promise.race([
new Promise((resolve, reject) => {
matrixSession.createClient((err, client) => {
if (err) {
reject(err)
return
}
matrixClient = client
resolve(client)
})
}),
new Promise((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error('Matrix client creation timed out'))
}, 25000)
})
])

expect(result).toBeDefined()
expect(result.getUserId()).toBeDefined()

expect(localStorage.getItem('access_token')).toBeDefined()
expect(localStorage.getItem('user_id')).toBeDefined()
} finally {
// Clear the timeout to prevent open handles
if (timeoutId) clearTimeout(timeoutId)
}
}, 30000)

test('should sync rooms with the server', async () => {
if (!runIntegrationTests) return
if (!matrixClient) {
console.warn('Skipping sync test - no Matrix client available')
return
}

const syncPromise = new Promise((resolve) => {
// Timeout handler which won't fail the test, just resolve.
const timeoutId = setTimeout(() => {
console.warn('Sync timeout - continuing test anyway')
matrixClient.removeListener('sync', onSync)
resolve()
}, 25000)

const onSync = (state) => {
console.log(`Sync state: ${state}`)
if (state === 'PREPARED') {
clearTimeout(timeoutId)
matrixClient.removeListener('sync', onSync)
resolve()
}
}

matrixClient.on('sync', onSync)

try {
matrixClient.startClient({
initialSyncLimit: 1,
pendingEventOrdering: 'detached',
timelineSupport: false // Reduce updates to fetch.
})
} catch (err) {
console.error('Error starting client:', err)
clearTimeout(timeoutId)
resolve() // Continue rather than failing.
}
})

await syncPromise

expect(matrixClient).toBeDefined()
}, 60000) // Allow 60s for network operations.
})
73 changes: 73 additions & 0 deletions src/session.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import './crypto-polyfill.mjs'
import sdk from 'matrix-js-sdk'
import { LocalStorageCryptoStore } from 'matrix-js-sdk/lib/crypto/store/localStorage-crypto-store.js'
import Store from './store.mjs'

export default class MatrixSession {
constructor (botName, matrixServer, matrixUser, matrixPassword, logger, localStorage) {
this.botName = botName
this.matrixServer = matrixServer
this.matrixUser = matrixUser
this.matrixPassword = matrixPassword
this.logger = logger
this.localStorage = localStorage
}

createClient (cb) {
const accessToken = this.localStorage.getItem('access_token')
const botName = this.localStorage.getItem('bot_name')
const deviceId = this.localStorage.getItem('device_id')
const userId = this.localStorage.getItem('user_id')

if (!accessToken || !botName || !deviceId || !userId) {
this.logger.debug('Creating a new session: no authentication token can be found in local storage.')
this.login((error, client) => cb(error, client))
return
}

this.logger.info('Reusing existing session: authentication information found in local storage, for user: ' + userId)
this.client = sdk.createClient({
baseUrl: this.matrixServer || 'https://matrix-client.matrix.org',
accessToken,
userId,
deviceId,
store: new Store(this.localStorage),
cryptoStore: new LocalStorageCryptoStore(this.localStorage),
logger: this.logger
})

cb(null, this.client)
}

login (cb) {
const that = this
this.client = sdk.createClient({
baseUrl: this.matrixServer || 'https://matrix-client.matrix.org'
})
this.client.loginRequest({
user: this.matrixUser || this.botName,
password: this.matrixPassword,
type: 'm.login.password'
}).then((data) => {
that.logger.debug(`Logged in ${data.user_id} on device ${data.device_id}`)
that.client = sdk.createClient({
baseUrl: that.matrixServer || 'https://matrix-client.matrix.org',
accessToken: data.access_token,
userId: data.user_id,
deviceId: data.device_id,
store: new Store(this.localStorage),
cryptoStore: new LocalStorageCryptoStore(that.localStorage),
logger: this.logger
})

that.localStorage.setItem('access_token', data.access_token)
that.localStorage.setItem('bot_name', that.botName)
that.localStorage.setItem('user_id', data.user_id)
that.localStorage.setItem('device_id', data.device_id)
cb(null, that.client)
}).catch((error) => {
that.logger.error(error)
cb(error, null)
})
}
}
152 changes: 152 additions & 0 deletions src/session.unit.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { jest, test, expect, afterEach, describe } from '@jest/globals'

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 = {
loginRequest: jest.fn().mockResolvedValue(mockedLoginResult)
}
sdk.createClient.mockReturnValue(client)

const matrixSession = new MatrixSession('juliasbot', 'http://server:8080', 'julia',
'123', logger, new LocalStorageMock())
matrixSession.createClient(jest.fn(() => {}))

expect(client.loginRequest).toHaveBeenCalledTimes(1)
})

test('updates the localStorage', (done) => {
const client = {
loginRequest: jest.fn().mockResolvedValue(mockedLoginResult)
}
sdk.createClient.mockReturnValue(client)

const localStorageMock = new LocalStorageMock()
localStorageMock.xxx = 'ASDF'

const 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 = {
loginRequest: jest.fn().mockResolvedValue(mockedLoginResult)
}
sdk.createClient.mockReturnValue(client)

const localStorageMock = new LocalStorageMock()
localStorageMock.setItem('access_token', 'someAccessToken')
localStorageMock.setItem('bot_name', 'someBotName')
localStorageMock.setItem('user_id', 'someUserId')
localStorageMock.setItem('device_id', 'someDeviceId')

const matrixSession = new MatrixSession('juliasbot', 'http://server:8080', 'julia',
'123', logger, localStorageMock)
matrixSession.createClient(jest.fn(() => {}))

expect(client.loginRequest).toHaveBeenCalledTimes(0)
})

const logger = {
info: jest.fn(msg => console.log(`[INFO] ${msg}`)),
warn: jest.fn(msg => console.warn(`[WARN] ${msg}`)),
error: jest.fn(msg => console.error(`[ERROR] ${msg}`)),
debug: jest.fn(msg => console.debug(`[DEBUG] ${msg}`)),
trace: jest.fn(msg => {}),
setLevel: jest.fn(),
// Matrix SDK sometimes checks for these too
log: jest.fn(msg => console.log(`[LOG] ${msg}`)),
silly: 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]
}
}

/**
* Retry a function with exponential backoff when rate limited
* @param {Function} fn - Function to retry (should return a promise)
* @param {number} maxRetries - Maximum number of retries
* @param {number} initialDelay - Initial delay in ms
* @param {Logger} logger - Logger instance
* @returns {Promise<any>} - Result of the function
*/
async function retryWithBackoff (fn, maxRetries = 3, initialDelay = 1000, logger) {
let retries = 0
let lastError
let delay = initialDelay

while (retries < maxRetries) {
try {
return await fn()
} catch (err) {
lastError = err

// Check if it's a rate limit error
if (err.errcode === 'M_LIMIT_EXCEEDED') {
// Use the retry_after_ms from the error if available, otherwise use exponential backoff
const retryAfter = err.retry_after_ms || delay
logger.warn(`Rate limited, retrying after ${retryAfter}ms (retry ${retries + 1}/${maxRetries})`)

// Wait for the specified time
await new Promise(resolve => setTimeout(resolve, retryAfter))

// Increase delay for next retry (exponential backoff)
delay = delay * 2
retries++
} else {
// If it's not a rate limit error, don't retry
throw err
}
}
}

// If we've exhausted all retries, throw the last error
throw lastError
}
32 changes: 32 additions & 0 deletions src/store.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { MemoryStore } from 'matrix-js-sdk/lib/store/memory.js'

export default class Store extends MemoryStore {
constructor (localStorage) {
super()
this._localStorage = localStorage
this.syncToken = null
this.saveDirty = false
}

getSyncToken () {
return this.syncToken
}

getSavedSyncToken () {
return Promise.resolve(this._localStorage.getItem('sync_token') || null)
}

setSyncToken (token) {
this.saveDirty = this.saveDirty || this.syncToken !== token
this.syncToken = token
}

save (force) {
this._localStorage.setItem('sync_token', this.syncToken)
return Promise.resolve()
}

wantsSave () {
return this.saveDirty
}
}