add test suite

This commit is contained in:
Sharon Kennedy 2020-03-15 23:30:00 -04:00
parent 7b2da6b351
commit 434147c483
6 changed files with 3227 additions and 953 deletions

175
__mocks__/matrix-js-sdk.js Normal file
View File

@ -0,0 +1,175 @@
export const mockRegisterRequest = jest
.fn()
.mockImplementation((params) => {
if (!params.auth) {
return Promise.reject({
data: { session: "session_id_1234" }
})
} else {
return Promise.resolve({
data: {
device_id: 'device_id_1234',
access_token: 'token_1234',
user_id: 'user_id_1234',
session: "session_id_1234"
}
})
}
})
export const mockDeleteMultipleDevices = jest
.fn()
.mockImplementation((params) => {
if (!params.auth) {
return Promise.reject({
data: { session: "session_id_1234" }
})
} else {
return Promise.resolve({
data: {
device_id: 'device_id_1234',
access_token: 'token_1234',
user_id: 'user_id_1234',
session: "session_id_1234"
}
})
}
})
export const mockLeave = jest.fn(() => {
return Promise.resolve('value');
});
export const mockInitCrypto = jest.fn()
export const mockStartClient = jest.fn(() => {
return Promise.resolve('value');
});
export const mockOnce = jest.fn()
export const mockStopClient = jest.fn(() => {
return Promise.resolve('value');
});
export const mockClearStores = jest.fn(() => {
return Promise.resolve('value');
});
export const mockGetRoom = jest.fn()
export const mockDownloadKeys = jest.fn()
export const mockSetDeviceVerified = jest.fn()
export const mockIsCryptoEnabled = jest.fn()
export const mockCreateRoom = jest.fn().mockReturnValue({ room_id: 'room_id_1234' })
export const mockSetPowerLevel = jest.fn()
export const mockSendTextMessage = jest.fn()
.mockImplementationOnce(() => {
return Promise.reject({
name: "UnknownDeviceError",
devices: ['device1', 'device2']
})
})
.mockImplementationOnce(() => {
return Promise.resolve()
})
export const mockSetDeviceKnown = jest.fn()
export const mockInvite = jest.fn()
.mockImplementationOnce(() => {
return Promise.reject({
name: "M_LIMIT_EXCEEDED",
})
})
.mockImplementationOnce(() => {
return Promise.resolve()
})
export const mockKick = jest.fn()
.mockImplementationOnce(() => {
return Promise.reject({
name: "M_LIMIT_EXCEEDED",
})
})
.mockImplementationOnce(() => {
return Promise.resolve()
})
export const mockDeactivateAccount = jest.fn(() => {
return Promise.resolve('value');
});
export const mockOn = jest.fn()
export const mockGetDevices = jest.fn(() => {
return Promise.resolve({
devices: []
});
});
export const mockGetDeviceId = jest.fn().mockReturnValue('mockDeviceId');
export const mockGetJoinedRooms = jest.fn(() => {
return Promise.resolve({
data: {
joined_rooms: []
}
});
});
export const mockLogin = jest.fn(() => {
return Promise.resolve({
data: {
device_id: 'device_id_1234',
access_token: 'token_1234',
}
})
});
export const mockGetJoinedRoomMembers = jest.fn(() => {
return Promise.resolve({
data: {
joined: ['user_id_1', 'user_id_2']
}
})
});
export const mockGetUser = jest.fn().mockReturnValue({ presence: 'online'});
export const mockClient = {
registerRequest: mockRegisterRequest,
initCrypto: mockInitCrypto,
startClient: mockStartClient,
on: mockOn,
once: mockOnce,
leave: mockLeave,
stopClient: mockStopClient,
clearStores: mockClearStores,
getRoom: mockGetRoom,
downloadKeys: mockDownloadKeys,
setDeviceVerified: mockSetDeviceVerified,
setDeviceKnown: mockSetDeviceKnown,
isCryptoEnabled: mockIsCryptoEnabled,
createRoom: mockCreateRoom,
setPowerLevel: mockSetPowerLevel,
sendTextMessage: mockSendTextMessage,
deactivateAccount: mockDeactivateAccount,
login: mockLogin,
getDevices: mockGetDevices,
getDeviceId: mockGetDeviceId,
deleteMultipleDevices: mockDeleteMultipleDevices,
getJoinedRooms: mockGetJoinedRooms,
invite: mockInvite,
kick: mockKick,
getJoinedRoomMembers: mockGetJoinedRoomMembers,
}
export const WebStorageSessionStore = jest.fn()
export const createClient = jest.fn().mockReturnValue(mockClient)

View File

@ -6,11 +6,13 @@
"scripts": {
"develop": "nodemon --exec babel-node src/index.js",
"build": "babel src -d dist",
"start": "yarn build && node dist/index.js"
"start": "yarn build && node dist/index.js",
"test": "jest"
},
"author": "",
"license": "ISC",
"dependencies": {
"dotenv": "^8.2.0",
"matrix-js-sdk": "^5.0.1",
"node-localstorage": "^2.1.5",
"olm": "https://packages.matrix.org/npm/olm/olm-3.1.4.tgz",
@ -21,7 +23,11 @@
"@babel/core": "^7.8.4",
"@babel/node": "^7.8.4",
"@babel/preset-env": "^7.8.4",
"babel-jest": "^25.1.0",
"babel-plugin-dynamic-import-node": "^2.3.0",
"nodemon": "^2.0.2"
"jest": "^25.1.0",
"jest-cli": "^25.1.0",
"nodemon": "^2.0.2",
"wait-for-expect": "^3.0.2"
}
}

380
src/bot.js Normal file
View File

@ -0,0 +1,380 @@
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import * as util from "util";
import { LocalStorage } from "node-localstorage";
global.Olm = require("olm");
import * as matrix from "matrix-js-sdk";
import logger from "./logger";
const ENCRYPTION_CONFIG = { algorithm: "m.megolm.v1.aes-sha2" };
const KICK_REASON = "A facilitator has already joined this chat.";
const BOT_ERROR_MESSAGE = "Something went wrong on our end, please restart the chat and try again.";
const MAX_RETRIES = 3;
class OcrccBot {
constructor() {
this.awaitingFacilitator = {};
this.client = matrix.createClient(process.env.MATRIX_SERVER_URL);
this.joinedRooms = [];
this.activeChatrooms = {};
}
createLocalStorage() {
const storageLoc = `matrix-chatbot-${process.env.BOT_USERNAME}`;
const dir = path.resolve(path.join(os.homedir(), ".local-storage"));
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
const localStoragePath = path.resolve(path.join(dir, storageLoc));
return new LocalStorage(localStoragePath);
}
sendMessage(roomId, msgText) {
return this.client.sendTextMessage(roomId, msgText).catch(err => {
switch (err["name"]) {
case "UnknownDeviceError":
Object.keys(err.devices).forEach(userId => {
Object.keys(err.devices[userId]).map(deviceId => {
this.client.setDeviceVerified(userId, deviceId, true);
});
});
return this.sendMessage(roomId, msgText);
break;
default:
logger.log("error", `ERROR SENDING MESSAGE: ${err}`);
this.handleBotCrash(roomId, err);
break;
}
});
}
inviteUserToRoom(client, roomId, member, retries = 0) {
logger.log("info", "INVITING MEMBER: " + member);
if (retries > MAX_RETRIES) {
this.handleBotCrash(roomId, "Rate limit exceeded for bot account");
return logger.log(
"error",
`RATE LIMIT EXCEEDED AND RETRY LIMIT EXCEEDED`
);
}
return client.invite(roomId, member).catch(err => {
switch (err["name"]) {
case "M_LIMIT_EXCEEDED":
logger.log("info", "Rate limit exceeded, retrying.");
const retryCount = retries + 1;
const delay = retryCount * 2 * 1000;
return setTimeout(
this.inviteUserToRoom,
delay,
client,
roomId,
member,
retryCount
);
break;
default:
logger.log("error", `ERROR INVITING MEMBER: ${err}`);
this.handleBotCrash(roomId, err);
break;
}
});
}
kickUserFromRoom(client, roomId, member, retries = 0) {
logger.log("info", "KICKING OUT MEMBER: " + member);
if (retries > MAX_RETRIES) {
this.handleBotCrash(roomId, "Rate limit exceeded for bot account.");
return logger.log(
"error",
`RATE LIMIT EXCEEDED AND RETRY LIMIT EXCEEDED`
);
}
return client.kick(roomId, member, KICK_REASON).catch(err => {
switch (err["name"]) {
case "M_LIMIT_EXCEEDED":
logger.log("info", "Rate limit exceeded, retrying.");
const retryCount = retries + 1;
const delay = retryCount * 2 * 1000;
return setTimeout(
this.kickUserFromRoom,
delay,
client,
roomId,
member,
retryCount
);
break;
default:
this.handleBotCrash(roomId, err);
logger.log("error", `ERROR KICKING OUT MEMBER: ${err}`);
break;
}
});
}
inviteFacilitators(roomId) {
this.awaitingFacilitator[roomId] = true;
let chatOffline = true;
this.client
.getJoinedRoomMembers(process.env.FACILITATOR_ROOM_ID)
.then(members => {
let onlineMembersCount = 0;
Object.keys(members["joined"]).forEach(member => {
const user = this.client.getUser(member);
if (user.presence === "online" && member !== process.env.BOT_USERID) {
chatOffline = false;
this.inviteUserToRoom(this.client, roomId, member);
}
});
})
.then(() => {
if (chatOffline) {
this.sendMessage(roomId, process.env.CHAT_OFFLINE_MESSAGE);
}
})
.catch(err => {
this.handleBotCrash(roomId, err);
logger.log("error", `ERROR GETTING ROOM MEMBERS: ${err}`);
});
}
uninviteFacilitators(roomId) {
this.awaitingFacilitator[roomId] = false;
this.client
.getJoinedRoomMembers(process.env.FACILITATOR_ROOM_ID)
.then(allFacilitators => {
this.client.getJoinedRoomMembers(roomId).then(roomMembers => {
const membersIds = Object.keys(roomMembers["joined"]);
const facilitatorsIds = Object.keys(allFacilitators["joined"]);
facilitatorsIds.forEach(f => {
if (!membersIds.includes(f)) {
this.kickUserFromRoom(this.client, roomId, f);
}
});
});
})
.catch(err => {
this.handleBotCrash(roomId, err);
logger.log("error", err);
});
}
handleBotCrash(roomId, error) {
if (roomId) {
this.sendMessage(roomId, BOT_ERROR_MESSAGE);
}
this.sendMessage(
process.env.FACILITATOR_ROOM_ID,
`The Help Bot ran into an error: ${error}. Please verify that the chat service is working.`
);
}
writeToTranscript(event) {
try {
const sender = event.getSender();
const roomId = event.getRoomId();
const content = event.getContent();
const date = event.getDate();
const time = date.toLocaleTimeString("en-GB", {
timeZone: "America/New_York"
});
const filepath = this.activeChatrooms[roomId].transcriptFile;
if (!content) {
return;
}
const message = `${sender} [${time}]: ${content.body}\n`;
fs.appendFileSync(filepath, message, "utf8");
} catch (err) {
logger.log("error", `ERROR APPENDING TO TRANSCRIPT FILE: ${err}`);
}
}
deleteOldDevices() {
return this.client.getDevices().then(data => {
const currentDeviceId = this.client.getDeviceId();
const allDeviceIds = data.devices.map(d => d.device_id);
const oldDevices = allDeviceIds.filter(id => id !== currentDeviceId);
logger.log("info", `DELETING OLD DEVICES: ${oldDevices}`);
this.client
.deleteMultipleDevices(oldDevices)
.catch(err => {
const auth = {
session: err.data.session,
type: "m.login.password",
user: process.env.BOT_USERID,
identifier: { type: "m.id.user", user: process.env.BOT_USERID },
password: process.env.BOT_PASSWORD
};
this.client
.deleteMultipleDevices(oldDevices, auth)
.then(() => logger.log("info", "DELETED OLD DEVICES"))
.catch(err =>
logger.log(
"error",
`ERROR DELETING OLD DEVICES: ${JSON.stringify(err.data)}`
)
);
});
});
}
setMembershipListeners() {
// Automatically accept all room invitations
return this.client.on("RoomMember.membership", (event, member) => {
if (
member.membership === "invite" &&
member.userId === process.env.BOT_USERID &&
!this.joinedRooms.includes(member.roomId)
) {
logger.log("info", "Auto-joining room " + member.roomId);
this.client
.joinRoom(member.roomId)
.then(room => {
this.sendMessage(
process.env.FACILITATOR_ROOM_ID,
`A support seeker requested a chat (Room ID: ${member.roomId})`
);
})
.then(() => this.inviteFacilitators(member.roomId))
.catch(err => {
logger.log("error", err);
});
}
// When a facilitator joins a support session, revoke the other invitations
if (
member.membership === "join" &&
member.userId !== process.env.BOT_USERID &&
this.awaitingFacilitator[member.roomId]
) {
this.activeChatrooms[member.roomId] = {
facilitator: member.userId
};
this.sendMessage(
member.roomId,
`${member.name} has joined the chat.`
);
this.sendMessage(
process.env.FACILITATOR_ROOM_ID,
`${member.name} joined the chat (Room ID: ${member.roomId})`
);
this.uninviteFacilitators(member.roomId);
if (process.env.CAPTURE_TRANSCRIPTS) {
const currentDate = new Date();
const dateOpts = {
year: "numeric",
month: "short",
day: "numeric"
};
const chatDate = currentDate.toLocaleDateString(
"en-GB",
dateOpts
);
const chatTime = currentDate.toLocaleTimeString("en-GB", {
timeZone: "America/New_York"
});
const filename = `${chatDate} - ${chatTime} - ${member.roomId}.txt`;
const filepath = path.resolve(path.join("transcripts", filename));
this.activeChatrooms[member.roomId].transcriptFile = filepath;
}
}
if (
member.membership === "leave" &&
member.userId !== process.env.BOT_USERID &&
this.activeChatrooms[member.roomId] &&
member.userId === this.activeChatrooms[member.roomId].facilitator
) {
this.sendMessage(
member.roomId,
`${member.name} has left the chat.`
);
}
});
}
setMessageListeners() {
// encrypted messages
this.client.on("Event.decrypted", (event, err) => {
if (err) {
return logger.log("error", `ERROR DECRYPTING EVENT: ${err}`);
}
if (event.getType() === "m.room.message") {
this.writeToTranscript(event);
}
});
// unencrypted messages
this.client.on("Room.timeline", (event, room, toStartOfTimeline) => {
if (
event.getType() === "m.room.message" &&
!this.client.isCryptoEnabled()
) {
if (event.isEncrypted()) {
return;
}
this.writeToTranscript(event);
}
});
}
start() {
const localStorage = this.createLocalStorage();
this.client
.login("m.login.password", {
user: process.env.BOT_USERNAME,
password: process.env.BOT_PASSWORD,
initial_device_display_name: process.env.BOT_DISPLAY_NAME
})
.then(data => {
const accessToken = data.access_token;
const deviceId = data.device_id;
// create new client with full options
let opts = {
baseUrl: process.env.MATRIX_SERVER_URL,
accessToken: accessToken,
userId: process.env.BOT_USERID,
deviceId: deviceId,
sessionStore: new matrix.WebStorageSessionStore(localStorage)
};
this.client = matrix.createClient(opts);
})
.catch(err => {
logger.log("error", `ERROR WITH LOGIN: ${err}`);
})
.then(() => this.deleteOldDevices())
.then(() => this.client.initCrypto())
.catch(err => logger.log("error", `ERROR STARTING CRYPTO: ${err}`))
.then(() =>
this.client.getJoinedRooms().then(data => {
this.joinedRooms = data["joined_rooms"];
})
)
.then(() => {
this.setMembershipListeners()
if (process.env.CAPTURE_TRANSCRIPTS) {
this.setMessageListeners()
}
})
.then(() => this.client.startClient({ initialSyncLimit: 0 }))
.catch(err => {
this.handleBotCrash(undefined, err);
logger.log("error", `ERROR INITIALIZING CLIENT: ${err}`);
});
}
}
export default OcrccBot;

235
src/bot.test.js Normal file
View File

@ -0,0 +1,235 @@
require('dotenv').config()
import * as path from "path";
import * as os from "os";
import waitForExpect from 'wait-for-expect'
import {
createClient,
WebStorageSessionStore,
mockClient,
mockRegisterRequest,
mockInitCrypto,
mockStartClient,
mockSetPowerLevel,
mockCreateRoom,
mockLeave,
mockDeactivateAccount,
mockStopClient,
mockClearStores,
mockOn,
mockOnce,
mockSendTextMessage,
mockLogin,
mockGetDevices,
mockGetDeviceId,
mockDeleteMultipleDevices,
mockGetJoinedRooms,
mockSetDeviceVerified,
mockInvite,
mockKick,
mockGetJoinedRoomMembers,
mockGetUser
} from "matrix-js-sdk";
import OcrccBot from './bot'
describe('OcrccBot', () => {
beforeEach(() => {
createClient.mockClear()
mockInitCrypto.mockClear()
mockStartClient.mockClear()
mockRegisterRequest.mockClear()
mockSetPowerLevel.mockClear()
mockCreateRoom.mockClear()
mockLeave.mockClear()
mockDeactivateAccount.mockClear()
mockStopClient.mockClear()
mockClearStores.mockClear()
mockOnce.mockClear()
mockOn.mockClear()
mockLogin.mockClear()
mockGetDevices.mockClear()
mockGetDeviceId.mockClear()
mockDeleteMultipleDevices.mockClear()
mockGetJoinedRooms.mockClear()
mockSetDeviceVerified.mockClear()
mockInvite.mockClear()
mockKick.mockClear()
mockGetJoinedRoomMembers.mockClear()
mockGetUser.mockClear()
})
test('constructor should inititialize matrix client', () => {
const bot = new OcrccBot()
expect(createClient).toHaveBeenCalledWith(process.env.MATRIX_SERVER_URL)
expect(bot.joinedRooms).toEqual([])
expect(bot.awaitingFacilitator).toEqual({})
expect(bot.activeChatrooms).toEqual({})
})
test('#createLocalStorage should have correct storage location', () => {
const bot = new OcrccBot()
const localStorage = bot.createLocalStorage()
const localStoragePath = path.resolve(path.join(os.homedir(), ".local-storage", `matrix-chatbot-${process.env.BOT_USERNAME}`));
expect(localStorage._location).toBe(localStoragePath)
})
test('#sendMessage should send a text message', () => {
const bot = new OcrccBot()
bot.start()
waitForExpect(() => {
expect(mockStartClient).toHaveBeenCalled()
})
const testRoom = 'room_id_1234'
const testMsg = 'test message'
bot.sendMessage(testRoom, testMsg)
waitForExpect(() => {
expect(mockSetDeviceVerified).toHaveBeenCalledTimes(2)
})
waitForExpect(() => {
expect(mockSendTextMessage).toHaveBeenCalledWith(testRoom, testMsg)
})
})
test('#inviteUserToRoom should add member to room and retry on rate limit error', () => {
const bot = new OcrccBot()
bot.start()
waitForExpect(() => {
expect(mockStartClient).toHaveBeenCalled()
})
bot.inviteUserToRoom(bot.client, 'room_id_1234', process.env.BOT_USERNAME)
waitForExpect(() => {
expect(mockInvite).toHaveBeenCalledTimes(2)
})
})
test('#kickUserFromRoom should remove member from room and retry on rate limit error', () => {
const bot = new OcrccBot()
bot.start()
waitForExpect(() => {
expect(mockStartClient).toHaveBeenCalled()
})
bot.kickUserFromRoom(bot.client, 'room_id_1234', process.env.BOT_USERNAME)
waitForExpect(() => {
expect(mockKick).toHaveBeenCalledTimes(2)
})
})
test('#inviteFacilitators should invite all members from Facilitator room', () => {
const bot = new OcrccBot()
bot.start()
waitForExpect(() => {
expect(mockStartClient).toHaveBeenCalled()
})
bot.inviteFacilitators()
waitForExpect(() => {
expect(mockGetJoinedRoomMembers).toHaveBeenCalledWith(process.env.FACILITATOR_ROOM_ID)
})
waitForExpect(() => {
expect(mockGetUser).toHaveBeenCalledTimes(2)
})
waitForExpect(() => {
expect(mockInvite).toHaveBeenCalledTimes(2)
})
})
test('#uninviteFacilitators should remove all members that have not accepted the invite', () => {
const bot = new OcrccBot()
bot.start()
waitForExpect(() => {
expect(mockStartClient).toHaveBeenCalled()
})
bot.uninviteFacilitators()
waitForExpect(() => {
expect(mockGetJoinedRoomMembers).toHaveBeenCalledWith(process.env.FACILITATOR_ROOM_ID)
})
waitForExpect(() => {
expect(mockGetJoinedRoomMembers).toHaveBeenCalledWith('room_id_1234')
})
waitForExpect(() => {
expect(mockKick).toHaveBeenCalled()
})
})
test('#handleBotCrash should notify rooms', () => {
})
test('#writeToTranscript should parse event and write to transcript file', () => {
})
test('#deleteOldDevices should delete old sessions', () => {
})
// TODO test listeners for membership events and message events
test('#start should start bot and set up listeners', () => {
const bot = new OcrccBot()
bot.start()
waitForExpect(() => {
expect(mockLogin).toHaveBeenCalled()
})
waitForExpect(() => {
expect(WebStorageSessionStore).toHaveBeenCalled()
})
waitForExpect(() => {
expect(createClient).toHaveBeenCalled()
})
waitForExpect(() => {
expect(mockGetDevices).toHaveBeenCalled()
})
waitForExpect(() => {
expect(mockGetDeviceId).toHaveBeenCalled()
})
waitForExpect(() => {
expect(mockDeleteMultipleDevices).toHaveBeenCalled()
})
waitForExpect(() => {
expect(mockInitCrypto).toHaveBeenCalled()
})
waitForExpect(() => {
expect(mockGetJoinedRooms).toHaveBeenCalled()
})
waitForExpect(() => {
expect(mockOn).toHaveBeenCalled()
})
waitForExpect(() => {
expect(mockStartClient).toHaveBeenCalled()
})
})
})

View File

@ -1,369 +1,6 @@
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import * as util from "util";
import { LocalStorage } from "node-localstorage";
require('dotenv').config()
global.Olm = require("olm");
import * as matrix from "matrix-js-sdk";
import logger from "./logger";
const ENCRYPTION_CONFIG = { algorithm: "m.megolm.v1.aes-sha2" };
const KICK_REASON = "A facilitator has already joined this chat.";
const BOT_ERROR_MESSAGE =
"Something went wrong on our end, please restart the chat and try again.";
const MAX_RETRIES = 3;
class OcrccBot {
constructor() {
this.awaitingFacilitator = {};
this.client = matrix.createClient(process.env.MATRIX_SERVER_URL);
this.joinedRooms = [];
this.activeChatrooms = {};
}
createLocalStorage() {
const storageLoc = `matrix-chatbot-${process.env.BOT_USERNAME}`;
const dir = path.resolve(path.join(os.homedir(), ".local-storage"));
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
const localStoragePath = path.resolve(path.join(dir, storageLoc));
return new LocalStorage(localStoragePath);
}
sendMessage(roomId, msgText) {
return this.client.sendTextMessage(roomId, msgText).catch(err => {
switch (err["name"]) {
case "UnknownDeviceError":
Object.keys(err.devices).forEach(userId => {
Object.keys(err.devices[userId]).map(deviceId => {
this.client.setDeviceVerified(userId, deviceId, true);
});
});
return this.sendMessage(roomId, msgText);
break;
default:
logger.log("error", `ERROR SENDING MESSAGE: ${err}`);
this.handleBotCrash(roomId, err);
break;
}
});
}
inviteUserToRoom(client, roomId, member, retries = 0) {
logger.log("info", "INVITING MEMBER: " + member);
if (retries > MAX_RETRIES) {
this.handleBotCrash(roomId, "Rate limit exceeded for bot account");
return logger.log(
"error",
`RATE LIMIT EXCEEDED AND RETRY LIMIT EXCEEDED`
);
}
return client.invite(roomId, member).catch(err => {
switch (err["name"]) {
case "M_LIMIT_EXCEEDED":
logger.log("info", "Rate limit exceeded, retrying.");
const retryCount = retries + 1;
const delay = retryCount * 2 * 1000;
return setTimeout(
this.inviteUserToRoom,
delay,
client,
roomId,
member,
retryCount
);
break;
default:
logger.log("error", `ERROR INVITING MEMBER: ${err}`);
this.handleBotCrash(roomId, err);
break;
}
});
}
kickUserFromRoom(client, roomId, member, retries = 0) {
logger.log("info", "KICKING OUT MEMBER: " + member);
if (retries > MAX_RETRIES) {
this.handleBotCrash(roomId, "Rate limit exceeded for bot account.");
return logger.log(
"error",
`RATE LIMIT EXCEEDED AND RETRY LIMIT EXCEEDED`
);
}
return client.kick(roomId, member, KICK_REASON).catch(err => {
switch (err["name"]) {
case "M_LIMIT_EXCEEDED":
logger.log("info", "Rate limit exceeded, retrying.");
const retryCount = retries + 1;
const delay = retryCount * 2 * 1000;
return setTimeout(
this.kickUserFromRoom,
delay,
client,
roomId,
member,
retryCount
);
break;
default:
this.handleBotCrash(roomId, err);
logger.log("error", `ERROR KICKING OUT MEMBER: ${err}`);
break;
}
});
}
inviteFacilitators(roomId) {
this.awaitingFacilitator[roomId] = true;
let chatOffline = true;
this.client
.getJoinedRoomMembers(process.env.FACILITATOR_ROOM_ID)
.then(members => {
let onlineMembersCount = 0;
Object.keys(members["joined"]).forEach(member => {
const user = this.client.getUser(member);
if (user.presence === "online" && member !== process.env.BOT_USERID) {
chatOffline = false;
this.inviteUserToRoom(this.client, roomId, member);
}
});
})
.then(() => {
if (chatOffline) {
this.sendMessage(roomId, process.env.CHAT_OFFLINE_MESSAGE);
}
})
.catch(err => {
this.handleBotCrash(roomId, err);
logger.log("error", `ERROR GETTING ROOM MEMBERS: ${err}`);
});
}
uninviteFacilitators(roomId) {
this.awaitingFacilitator[roomId] = false;
this.client
.getJoinedRoomMembers(process.env.FACILITATOR_ROOM_ID)
.then(allFacilitators => {
this.client.getJoinedRoomMembers(roomId).then(roomMembers => {
const membersIds = Object.keys(roomMembers["joined"]);
const facilitatorsIds = Object.keys(allFacilitators["joined"]);
facilitatorsIds.forEach(f => {
if (!membersIds.includes(f)) {
this.kickUserFromRoom(this.client, roomId, f);
}
});
});
})
.catch(err => {
this.handleBotCrash(roomId, err);
logger.log("error", err);
});
}
handleBotCrash(roomId, error) {
if (roomId) {
this.sendMessage(roomId, BOT_ERROR_MESSAGE);
}
this.sendMessage(
process.env.FACILITATOR_ROOM_ID,
`The Help Bot ran into an error: ${error}. Please verify that the chat service is working.`
);
}
writeToTranscript(event) {
try {
const sender = event.getSender();
const roomId = event.getRoomId();
const content = event.getContent();
const date = event.getDate();
const time = date.toLocaleTimeString("en-GB", {
timeZone: "America/New_York"
});
const filepath = this.activeChatrooms[roomId].transcriptFile;
if (!content) {
return;
}
const message = `${sender} [${time}]: ${content.body}\n`;
fs.appendFileSync(filepath, message, "utf8");
} catch (err) {
logger.log("error", `ERROR APPENDING TO TRANSCRIPT FILE: ${err}`);
}
}
start() {
const localStorage = this.createLocalStorage();
this.client
.login("m.login.password", {
user: process.env.BOT_USERNAME,
password: process.env.BOT_PASSWORD,
initial_device_display_name: process.env.BOT_DISPLAY_NAME
})
.then(data => {
const accessToken = data.access_token;
const deviceId = data.device_id;
// create new client with full options
let opts = {
baseUrl: process.env.MATRIX_SERVER_URL,
accessToken: accessToken,
userId: process.env.BOT_USERID,
deviceId: deviceId,
sessionStore: new matrix.WebStorageSessionStore(localStorage)
};
this.client = matrix.createClient(opts);
})
.catch(err => {
logger.log("error", `ERROR WITH LOGIN: ${err}`);
})
.then(() => {
this.client.getDevices().then(data => {
const currentDeviceId = this.client.getDeviceId();
const allDeviceIds = data.devices.map(d => d.device_id);
const oldDevices = allDeviceIds.filter(id => id !== currentDeviceId);
logger.log("info", `DELETING OLD DEVICES: ${oldDevices}`);
this.client.deleteMultipleDevices(oldDevices).catch(err => {
const auth = {
session: err.data.session,
type: "m.login.password",
user: process.env.BOT_USERID,
identifier: { type: "m.id.user", user: process.env.BOT_USERID },
password: process.env.BOT_PASSWORD
};
this.client
.deleteMultipleDevices(oldDevices, auth)
.then(() => logger.log("info", "DELETED OLD DEVICES"))
.catch(err =>
logger.log(
"error",
`ERROR DELETING OLD DEVICES: ${JSON.stringify(err.data)}`
)
);
});
});
})
.then(() => this.client.initCrypto())
.catch(err => logger.log("error", `ERROR STARTING CRYPTO: ${err}`))
.then(() =>
this.client.getJoinedRooms().then(data => {
this.joinedRooms = data["joined_rooms"];
})
)
.then(() => {
// Automatically accept all room invitations
this.client.on("RoomMember.membership", (event, member) => {
if (
member.membership === "invite" &&
member.userId === process.env.BOT_USERID &&
!this.joinedRooms.includes(member.roomId)
) {
logger.log("info", "Auto-joining room " + member.roomId);
this.client
.joinRoom(member.roomId)
.then(room => {
this.sendMessage(
process.env.FACILITATOR_ROOM_ID,
`A support seeker requested a chat (Room ID: ${member.roomId})`
);
})
.then(() => this.inviteFacilitators(member.roomId))
.catch(err => {
logger.log("error", err);
});
}
// When a facilitator joins a support session, revoke the other invitations
if (
member.membership === "join" &&
member.userId !== process.env.BOT_USERID &&
this.awaitingFacilitator[member.roomId]
) {
this.activeChatrooms[member.roomId] = {
facilitator: member.userId
};
this.sendMessage(
member.roomId,
`${member.name} has joined the chat.`
);
this.sendMessage(
process.env.FACILITATOR_ROOM_ID,
`${member.name} joined the chat (Room ID: ${member.roomId})`
);
this.uninviteFacilitators(member.roomId);
if (process.env.CAPTURE_TRANSCRIPTS) {
const currentDate = new Date();
const dateOpts = {
year: "numeric",
month: "short",
day: "numeric"
};
const chatDate = currentDate.toLocaleDateString(
"en-GB",
dateOpts
);
const chatTime = currentDate.toLocaleTimeString("en-GB", {
timeZone: "America/New_York"
});
const filename = `${chatDate} - ${chatTime} - ${member.roomId}.txt`;
const filepath = path.resolve(path.join("transcripts", filename));
this.activeChatrooms[member.roomId].transcriptFile = filepath;
}
}
if (
member.membership === "leave" &&
member.userId !== process.env.BOT_USERID &&
this.activeChatrooms[member.roomId] &&
member.userId === this.activeChatrooms[member.roomId].facilitator
) {
this.sendMessage(
member.roomId,
`${member.name} has left the chat.`
);
}
});
if (process.env.CAPTURE_TRANSCRIPTS) {
// encrypted messages
this.client.on("Event.decrypted", (event, err) => {
if (err) {
return logger.log("error", `ERROR DECRYPTING EVENT: ${err}`);
}
if (event.getType() === "m.room.message") {
this.writeToTranscript(event);
}
});
// unencrypted messages
this.client.on("Room.timeline", (event, room, toStartOfTimeline) => {
if (
event.getType() === "m.room.message" &&
!this.client.isCryptoEnabled()
) {
if (event.isEncrypted()) {
return;
}
this.writeToTranscript(event);
}
});
}
})
.then(() => this.client.startClient({ initialSyncLimit: 0 }))
.catch(err => {
this.handleBotCrash(undefined, err);
logger.log("error", `ERROR INITIALIZING CLIENT: ${err}`);
});
}
}
import OcrccBot from './bot'
const bot = new OcrccBot();
bot.start();

3011
yarn.lock

File diff suppressed because it is too large Load Diff