diff --git a/__mocks__/matrix-js-sdk.js b/__mocks__/matrix-js-sdk.js index 508557e..8942117 100644 --- a/__mocks__/matrix-js-sdk.js +++ b/__mocks__/matrix-js-sdk.js @@ -157,6 +157,10 @@ export const mockGetGroupUsers = jest.fn(() => { export const mockGetUser = jest.fn().mockReturnValue({ presence: 'online'}); +export const mockSendStateEvent = jest.fn(() => { + return Promise.resolve(); +}); + export const mockClient = { registerRequest: mockRegisterRequest, initCrypto: mockInitCrypto, @@ -186,6 +190,7 @@ export const mockClient = { getJoinedRoomMembers: mockGetJoinedRoomMembers, getUser: mockGetUser, getGroupUsers: mockGetGroupUsers, + sendStateEvent: mockSendStateEvent, } export const WebStorageSessionStore = jest.fn() diff --git a/dist/bot.js b/dist/bot.js index 30a20bf..4f25e6f 100644 --- a/dist/bot.js +++ b/dist/bot.js @@ -26,6 +26,7 @@ var _logger = _interopRequireDefault(require("./logger")); var _encryptAttachment = _interopRequireDefault(require("./encrypt-attachment")); global.Olm = require("olm"); +const BOT_SIGNAL_END_CHAT = 'END_CHAT'; class OcrccBot { constructor(botConfig) { @@ -227,35 +228,43 @@ class OcrccBot { } handleBotCommand(event) { + const botCommands = [{ + keyword: 'transcript', + function: (senderId, roomId) => { + this.sendTranscript(senderId, roomId); + } + }, { + keyword: 'delete transcript', + function: (senderId, roomId) => { + this.deleteTranscript(senderId, roomId); + } + }, { + keyword: 'say', + function: (senderId, roomId, message) => { + this.sendTextMessage(roomId, message, senderId); + } + }, { + keyword: 'hi', + function: (senderId, roomId) => { + const responses = ["Hi!", "Hello", "Hey :)", "Hi there", "Bleep bloop"]; + const message = responses[Math.floor(Math.random() * responses.length)]; + this.sendTextMessage(roomId, message, senderId); + } + }]; + try { const senderId = event.getSender(); const roomId = event.getRoomId(); const content = event.getContent(); - const command = content.body.substring("!bot".length).trim(); + const commandText = content.body.substring("!bot".length).trim(); + const command = botCommands.find(c => commandText.startsWith(c.keyword)); - switch (command) { - case "transcript": - this.sendTranscript(senderId, roomId); - break; - - case "transcript please": - this.sendTranscript(senderId, roomId); - break; - - case "delete transcript": - this.deleteTranscript(senderId, roomId); - break; - - case "hi": - const responses = ["Hi!", "Hello", "Hey :)", "Hi there", "Bleep bloop"]; - const message = responses[Math.floor(Math.random() * responses.length)]; - this.sendTextMessage(roomId, message, senderId); - break; - - default: - this.sendTextMessage(roomId, `Sorry, I don't know that command. I'm not a very smart bot.`, senderId); - break; + if (!command) { + this.sendTextMessage(roomId, `Sorry, I don't know that command. I'm not a very smart bot.`, senderId); } + + const args = commandText.substring(command.keyword.length).trim(); + command.function(senderId, roomId, args); } catch (err) { _logger.default.log("error", `ERROR EXECUTING BOT COMMAND: ${err}`); } @@ -418,9 +427,9 @@ class OcrccBot { _logger.default.log("info", "AUTO JOINED ROOM => " + room.roomId); - const currentDate = new Date(); - const chatDate = currentDate.toLocaleDateString(); - const chatTime = currentDate.toLocaleTimeString(); + const inviteDate = event.getDate(); + const chatDate = inviteDate.toLocaleDateString(); + const chatTime = inviteDate.toLocaleTimeString(); const roomId = room.roomId.split(':')[0]; const notification = `Incoming support chat at ${chatTime} (room ID: ${roomId})`; this.sendTextMessage(this.config.FACILITATOR_ROOM_ID, notification); @@ -489,25 +498,48 @@ class OcrccBot { const joinedRooms = roomData["joined_rooms"]; const isBotInRoom = joinedRooms.includes(member.roomId); const room = this.client.getRoom(member.roomId); - if (!room) return; // leave if there is nobody in the room - - const memberCount = room.getJoinedMemberCount(); - - if (memberCount === 1 && isBotInRoom) { - // just the bot left - _logger.default.log("info", `LEAVING EMPTY ROOM ==> ${member.roomId}`); - - this.deleteTranscript(member.userId, member.roomId); - this.localStorage.removeItem(`${member.roomId}-facilitator`); - this.localStorage.removeItem(`${member.roomId}-transcript`); - return this.client.leave(member.roomId); - } // notify room if the facilitator has left - + if (!room) return; // notify room if the facilitator has left const facilitatorId = this.localStorage.getItem(`${member.roomId}-facilitator`); if (isBotInRoom && member.userId === facilitatorId) { this.sendTextMessage(member.roomId, `${member.name} has left the chat.`); + } // leave if there is nobody in the room + + + try { + const memberCount = room.getJoinedMemberCount(); + + if (memberCount === 1 && isBotInRoom) { + // just the bot left + _logger.default.log("info", `LEAVING EMPTY ROOM ==> ${member.roomId}`); + + this.deleteTranscript(member.userId, member.roomId); + this.localStorage.removeItem(`${member.roomId}-facilitator`); + this.localStorage.removeItem(`${member.roomId}-transcript`); + return this.client.leave(member.roomId); + } + } catch (err) { + _logger.default.log("error", `ERROR LEAVING EMPTY ROOM ==> ${err}`); + } // send signal to close the chat if there are no facilitators in the room + + + try { + const roomMembers = await room.getJoinedMembers(); + const facilitatorRoomMembers = await this.client.getJoinedRoomMembers(this.config.FACILITATOR_ROOM_ID); + const facilitators = facilitatorRoomMembers['joined']; + let facilitatorInRoom = false; + roomMembers.forEach(member => { + if (member.userId !== this.config.BOT_USERID && Boolean(facilitators[member.userId])) { + facilitatorInRoom = true; + } + }); + + if (!facilitatorInRoom) { + this.sendBotSignal(member.roomId, BOT_SIGNAL_END_CHAT); + } + } catch (err) { + _logger.default.log("error", `ERROR SENDING BOT SIGNAL ==> ${err}`); } } }); @@ -543,6 +575,14 @@ class OcrccBot { }); } + async sendBotSignal(roomId, signal, args) { + let content = { + signal: signal, + args: args + }; + await this.client.sendStateEvent(roomId, 'm.bot.signal', content); + } + async start() { const localStorage = this.createLocalStorage(); this.localStorage = localStorage; diff --git a/src/bot.js b/src/bot.js index eaa1fcf..354c261 100644 --- a/src/bot.js +++ b/src/bot.js @@ -11,12 +11,15 @@ import * as matrix from "matrix-js-sdk"; import logger from "./logger"; import encrypt from "./encrypt-attachment"; +const BOT_SIGNAL_END_CHAT = 'END_CHAT' + class OcrccBot { constructor(botConfig) { this.config = botConfig this.client = matrix.createClient(this.config.MATRIX_SERVER_URL); this.joinedRooms = []; + this.inactivityTimers = {}; } createLocalStorage() { @@ -176,12 +179,20 @@ class OcrccBot { handleMessageEvent(event) { const content = event.getContent(); + const sender = event.getSender() + const roomId = event.getRoomId(); // do nothing if there's no content if (!content) { return; } + // if it's a chat message and the facilitator has joined, reset the inactivity timeout + const facilitatorId = this.localStorage.getItem(`${roomId}-facilitator`) + if (Boolean(facilitatorId) && sender !== this.config.BOT_USERID) { + this.setInactivityTimeout(roomId) + } + // bot commands if (content.body.startsWith("!bot")) { return this.handleBotCommand(event); @@ -217,23 +228,24 @@ class OcrccBot { } handleBotCommand(event) { - try { - const senderId = event.getSender(); - const roomId = event.getRoomId(); - const content = event.getContent(); - const command = content.body.substring("!bot".length).trim(); - - switch (command) { - case "transcript": - this.sendTranscript(senderId, roomId); - break; - case "transcript please": - this.sendTranscript(senderId, roomId); - break; - case "delete transcript": - this.deleteTranscript(senderId, roomId); - break; - case "hi": + const botCommands = [ + { + keyword: 'transcript', + function: (senderId, roomId) => { this.sendTranscript(senderId, roomId) } + }, + { + keyword: 'delete transcript', + function: (senderId, roomId) => { this.deleteTranscript(senderId, roomId) } + }, + { + keyword: 'say', + function: (senderId, roomId, message) => { + this.sendTextMessage(roomId, message, senderId); + } + }, + { + keyword: 'hi', + function: (senderId, roomId) => { const responses = [ "Hi!", "Hello", @@ -241,18 +253,28 @@ class OcrccBot { "Hi there", "Bleep bloop" ]; - const message = - responses[Math.floor(Math.random() * responses.length)]; + const message = responses[Math.floor(Math.random() * responses.length)]; this.sendTextMessage(roomId, message, senderId); - break; - default: - this.sendTextMessage( - roomId, - `Sorry, I don't know that command. I'm not a very smart bot.`, - senderId - ); - break; + } } + ] + try { + const senderId = event.getSender(); + const roomId = event.getRoomId(); + const content = event.getContent(); + const commandText = content.body.substring("!bot".length).trim(); + const command = botCommands.find(c => commandText.startsWith(c.keyword)) + + if (!command) { + this.sendTextMessage( + roomId, + `Sorry, I don't know that command. I'm not a very smart bot.`, + senderId + ); + } + + const args = commandText.substring(command.keyword.length).trim() + command.function(senderId, roomId, args) } catch (err) { logger.log("error", `ERROR EXECUTING BOT COMMAND: ${err}`); } @@ -423,13 +445,14 @@ class OcrccBot { if (!joinedRooms.includes(member.roomId)) { const room = await this.client.joinRoom(member.roomId) logger.log("info", "AUTO JOINED ROOM => " + room.roomId) - const currentDate = new Date() - const chatDate = currentDate.toLocaleDateString() - const chatTime = currentDate.toLocaleTimeString() + const inviteDate = event.getDate() + const chatDate = inviteDate.toLocaleDateString() + const chatTime = inviteDate.toLocaleTimeString() const roomId = room.roomId.split(':')[0] const notification = `Incoming support chat at ${chatTime} (room ID: ${roomId})` this.sendTextMessage(this.config.FACILITATOR_ROOM_ID, notification); this.inviteFacilitators(room.roomId) + this.setTimeoutforFacilitator(room.roomId) } } catch(err) { logger.log("error", "ERROR JOINING ROOM => " + err) @@ -441,61 +464,65 @@ class OcrccBot { member.userId !== this.config.BOT_USERID && this.localStorage.getItem(`${member.roomId}-waiting`) ) { - // make sure it's a facilitator joining - const roomMembers = await this.client.getJoinedRoomMembers(this.config.FACILITATOR_ROOM_ID) - const members = Object.keys(roomMembers["joined"]); - const isFacilitator = members.includes(member.userId) + try { + // make sure it's a facilitator joining + const roomMembers = await this.client.getJoinedRoomMembers(this.config.FACILITATOR_ROOM_ID) + const members = Object.keys(roomMembers["joined"]); + const isFacilitator = members.includes(member.userId) - if (isFacilitator) { - // made facilitator a moderator in the room - this.localStorage.setItem(`${member.roomId}-facilitator`, member.userId) - const event = { - getType: () => { - return "m.room.power_levels"; - }, - getContent: () => { - return { - users: { - [this.config.BOT_USERID]: 100, - [member.userId]: 50 - } - }; - } - }; - this.client.setPowerLevel(member.roomId, member.userId, 50, event); - - // send notification to Support Chat Notifications room - const currentDate = new Date() - const chatTime = currentDate.toLocaleTimeString() - const roomId = member.roomId.split(':')[0] - const notification = `${member.name} joined the chat at ${chatTime} (room ID: ${roomId})` - this.sendTextMessage(this.config.FACILITATOR_ROOM_ID, notification); - - // send notification to chat room - this.sendTextMessage( - member.roomId, - `${member.name} has joined the chat.` - ); - - // revoke the other invitations - this.uninviteFacilitators(member.roomId); - - // set transcript file - if (this.config.CAPTURE_TRANSCRIPTS) { - const currentDate = new Date(); - const dateOpts = { - year: "numeric", - month: "short", - day: "numeric" + if (isFacilitator) { + // made facilitator a moderator in the room + this.localStorage.setItem(`${member.roomId}-facilitator`, member.userId) + const event = { + getType: () => { + return "m.room.power_levels"; + }, + getContent: () => { + return { + users: { + [this.config.BOT_USERID]: 100, + [member.userId]: 50 + } + }; + } }; - 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.localStorage.setItem(`${member.roomId}-transcript`, filepath) + this.client.setPowerLevel(member.roomId, member.userId, 50, event); + + // send notification to Support Chat Notifications room + const currentDate = new Date() + const chatTime = currentDate.toLocaleTimeString() + const roomId = member.roomId.split(':')[0] + const notification = `${member.name} joined the chat at ${chatTime} (room ID: ${roomId})` + this.sendTextMessage(this.config.FACILITATOR_ROOM_ID, notification); + + // send notification to chat room + this.sendTextMessage( + member.roomId, + `${member.name} has joined the chat.` + ); + + // revoke the other invitations + this.uninviteFacilitators(member.roomId); + + // set transcript file + if (this.config.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.localStorage.setItem(`${member.roomId}-transcript`, filepath) + } } + } catch(err) { + logger.log("error", `ERROR WHEN FACILITATOR JOINED ROOM ==> ${err}`); } } @@ -503,36 +530,89 @@ class OcrccBot { member.membership === "leave" && member.userId !== this.config.BOT_USERID ) { - // ensure bot is still in the room - const roomData = await this.client.getJoinedRooms() - const joinedRooms = roomData["joined_rooms"] - const isBotInRoom = joinedRooms.includes(member.roomId) - const room = this.client.getRoom(member.roomId) - if (!room) return + if (!room) return; - // leave if there is nobody in the room - const memberCount = room.getJoinedMemberCount() - if (memberCount === 1 && isBotInRoom) { // just the bot left - logger.log("info", `LEAVING EMPTY ROOM ==> ${member.roomId}`); - this.deleteTranscript(member.userId, member.roomId); - this.localStorage.removeItem(`${member.roomId}-facilitator`) - this.localStorage.removeItem(`${member.roomId}-transcript`) - return this.client.leave(member.roomId) - } + const roomMembers = await room.getJoinedMembers() // array + const facilitatorRoomMembers = await this.client.getJoinedRoomMembers(this.config.FACILITATOR_ROOM_ID) // object + const isBotInRoom = roomMembers.find(member => member.userId === this.config.BOT_USERID) // notify room if the facilitator has left - const facilitatorId = this.localStorage.getItem(`${member.roomId}-facilitator`) - if (isBotInRoom && member.userId === facilitatorId) { - this.sendTextMessage( - member.roomId, - `${member.name} has left the chat.` - ); + try { + const facilitatorId = this.localStorage.getItem(`${member.roomId}-facilitator`) + if (isBotInRoom && member.userId === facilitatorId) { + this.sendTextMessage( + member.roomId, + `${member.name} has left the chat.` + ); + } + } catch(err) { + logger.log("error", `ERROR NOTIFYING THAT FACLITATOR HAS LEFT THE ROOM ==> ${err}`); + } + + // leave if there is nobody in the room + try { + const memberCount = roomMembers.length + if (memberCount === 1 && isBotInRoom) { // just the bot left + logger.log("info", `LEAVING EMPTY ROOM ==> ${member.roomId}`); + this.deleteTranscript(member.userId, member.roomId); + this.localStorage.removeItem(`${member.roomId}-facilitator`) + this.localStorage.removeItem(`${member.roomId}-transcript`) + return this.client.leave(member.roomId) + } + } catch(err) { + logger.log("error", `ERROR LEAVING EMPTY ROOM ==> ${err}`); + } + + // send signal to close the chat if there are no facilitators in the room + try { + const facilitators = facilitatorRoomMembers['joined'] + let facilitatorInRoom = false; + + roomMembers.forEach(member => { + if (member.userId !== this.config.BOT_USERID && Boolean(facilitators[member.userId])) { + facilitatorInRoom = true + } + }) + + if (!facilitatorInRoom) { + this.sendBotSignal(member.roomId, BOT_SIGNAL_END_CHAT) + } + + } catch(err) { + logger.log("error", `ERROR SENDING BOT SIGNAL ==> ${err}`); } } }) } + setTimeoutforFacilitator(roomId) { + setTimeout(() => { + const stillWaiting = this.localStorage.getItem(`${roomId}-waiting`) + if (stillWaiting) { + this.sendBotSignal(roomId, BOT_SIGNAL_END_CHAT) + } + }, this.config.MAX_WAIT_TIME) + } + + setInactivityTimeout(roomId) { + const oldTimeout = this.inactivityTimers[roomId]; + + if (oldTimeout) { + clearTimeout(oldTimeout); + } + + const newTimeout = setTimeout(() => { + this.sendTextMessage( + roomId, + `This chat has been closed due to inactivity.` + ); + this.sendBotSignal(roomId, BOT_SIGNAL_END_CHAT) + }, this.config.MAX_INACTIVE) + + this.inactivityTimers[roomId] = newTimeout; + } + async setMessageListeners() { // encrypted messages this.client.on("Event.decrypted", (event, err) => { @@ -563,6 +643,18 @@ class OcrccBot { }) } + async sendBotSignal (roomId, signal, args) { + let content = { + signal: signal, + args: args, + } + try { + await this.client.sendStateEvent(roomId, 'm.bot.signal', content) + } catch(err) { + logger.log('error', "ERROR SENDING BOT SIGNAL => " + err) + } + } + async start() { const localStorage = this.createLocalStorage(); this.localStorage = localStorage diff --git a/src/bot.test.js b/src/bot.test.js index c312908..24a6148 100644 --- a/src/bot.test.js +++ b/src/bot.test.js @@ -32,7 +32,8 @@ import { mockKick, mockGetJoinedRoomMembers, mockGetUser, - mockGetGroupUsers + mockGetGroupUsers, + mockSendStateEvent, } from "matrix-js-sdk"; import OcrccBot from './bot' @@ -100,6 +101,7 @@ describe('OcrccBot', () => { mockSendTextMessage.mockClear() mockAppendFileSync.mockClear() mockGetGroupUsers.mockClear() + mockSendStateEvent.mockClear() }) @@ -325,4 +327,17 @@ describe('OcrccBot', () => { expect(mockStartClient).toHaveBeenCalled() }) }) + + test('#sendBotSignal should send custom state event', () => { + const bot = new OcrccBot(botConfig) + bot.start() + const test_room_id = 'test_room_id' + const signal = 'END_CHAT' + + bot.sendBotSignal(test_room_id, signal) + + waitForExpect(() => { + expect(mockSendStateEvent).toHaveBeenCalledWith(test_room_id, 'm.bot.signal', { signal }) + }) + }) }) \ No newline at end of file diff --git a/src/index.js b/src/index.js index 1a11115..450e1b6 100644 --- a/src/index.js +++ b/src/index.js @@ -5,6 +5,7 @@ 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; + const { MATRIX_SERVER_URL, BOT_USERNAME, @@ -13,6 +14,9 @@ const { BOT_DISPLAY_NAME, FACILITATOR_ROOM_ID, CAPTURE_TRANSCRIPTS, + CHAT_NOT_AVAILABLE_MESSAGE, + MAX_WAIT_TIME, + MAX_INACTIVE, } = process.env; const botConfig = { @@ -27,6 +31,9 @@ const botConfig = { BOT_DISPLAY_NAME, FACILITATOR_ROOM_ID, CAPTURE_TRANSCRIPTS, + CHAT_NOT_AVAILABLE_MESSAGE, + MAX_WAIT_TIME, + MAX_INACTIVE, } import OcrccBot from './bot'