507 lines
15 KiB
JavaScript
507 lines
15 KiB
JavaScript
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 = null;
|
|
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);
|
|
}
|
|
|
|
sendTextMessage(roomId, msgText, showToUser = null) {
|
|
const content = {
|
|
msgtype: "m.text",
|
|
body: msgText,
|
|
showToUser: showToUser
|
|
};
|
|
|
|
this.sendMessage(roomId, content);
|
|
}
|
|
|
|
sendMessage(roomId, content) {
|
|
return this.client.sendMessage(roomId, content).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, content);
|
|
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
|
|
.getGroupUsers(process.env.FACILITATOR_GROUP_ID)
|
|
.then(res => {
|
|
const members = res.chunk;
|
|
let onlineMembersCount = 0;
|
|
members.forEach(member => {
|
|
const memberId = member.user_id;
|
|
const user = this.client.getUser(memberId);
|
|
if (
|
|
user &&
|
|
user.presence === "online" &&
|
|
memberId !== process.env.BOT_USERID
|
|
) {
|
|
chatOffline = false;
|
|
this.inviteUserToRoom(this.client, roomId, memberId);
|
|
}
|
|
});
|
|
})
|
|
.then(() => {
|
|
if (chatOffline) {
|
|
this.sendTextMessage(roomId, process.env.CHAT_OFFLINE_MESSAGE);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
this.handleBotCrash(roomId, err);
|
|
logger.log("error", `ERROR GETTING FACILITATORS: ${err}`);
|
|
});
|
|
}
|
|
|
|
uninviteFacilitators(roomId) {
|
|
this.awaitingFacilitator[roomId] = false;
|
|
this.client
|
|
.getGroupUsers(process.env.FACILITATOR_GROUP_ID)
|
|
.then(groupUsers => {
|
|
this.client.getJoinedRoomMembers(roomId).then(roomMembers => {
|
|
const membersIds = Object.keys(roomMembers["joined"]);
|
|
const facilitators = groupUsers.chunk;
|
|
const facilitatorsIds = facilitators.map(f => f.user_id);
|
|
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.sendTextMessage(roomId, BOT_ERROR_MESSAGE);
|
|
}
|
|
|
|
this.sendTextMessage(
|
|
process.env.FACILITATOR_ROOM_ID,
|
|
`The Help Bot ran into an error: ${error}. Please verify that the chat service is working.`
|
|
);
|
|
}
|
|
|
|
handleMessageEvent(event) {
|
|
const content = event.getContent();
|
|
|
|
// do nothing if there's no content
|
|
if (!content) {
|
|
return;
|
|
}
|
|
|
|
// bot commands
|
|
if (content.body.startsWith("!bot")) {
|
|
return this.handleBotCommand(event);
|
|
}
|
|
|
|
// write to transcript
|
|
if (process.env.CAPTURE_TRANSCRIPTS) {
|
|
return this.writeToTranscript(event);
|
|
}
|
|
}
|
|
|
|
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;
|
|
const message = `${sender} [${time}]: ${content.body}\n`;
|
|
|
|
fs.appendFileSync(filepath, message, "utf8");
|
|
} catch (err) {
|
|
logger.log("error", `ERROR APPENDING TO TRANSCRIPT FILE: ${err}`);
|
|
}
|
|
}
|
|
|
|
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 "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;
|
|
}
|
|
} catch (err) {
|
|
logger.log("error", `ERROR EXECUTING BOT COMMAND: ${err}`);
|
|
}
|
|
}
|
|
|
|
sendTranscript(senderId, roomId) {
|
|
const transcriptFile = this.activeChatrooms[roomId].transcriptFile;
|
|
if (!transcriptFile) {
|
|
this.sendTextMessage(
|
|
roomId,
|
|
"There is no transcript for this chat.",
|
|
senderId
|
|
);
|
|
}
|
|
|
|
const filename = path.basename(transcriptFile) || "Transcript";
|
|
const stream = fs.createReadStream(transcriptFile);
|
|
|
|
this.client
|
|
.uploadContent({
|
|
stream: stream,
|
|
name: filename
|
|
})
|
|
.then(contentUrl => {
|
|
const content = {
|
|
msgtype: "m.file",
|
|
body: filename,
|
|
url: JSON.parse(contentUrl).content_uri,
|
|
showToUser: senderId
|
|
};
|
|
|
|
this.sendMessage(roomId, content);
|
|
})
|
|
.catch(err => {
|
|
logger.log("error", `ERROR UPLOADING CONTENT: ${err}`);
|
|
this.sendTextMessage(
|
|
roomId,
|
|
"There was an error uploading the transcript.",
|
|
senderId
|
|
);
|
|
});
|
|
}
|
|
|
|
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 => {
|
|
if (err.errcode === "M_LIMIT_EXCEEDED") {
|
|
const delay = err.retry_after_ms || 2000;
|
|
logger.log("info", `RETRYING DELETE OLD DEVICES: ${oldDevices}`);
|
|
setTimeout(() => {
|
|
this.client.deleteMultipleDevices(oldDevices);
|
|
}, delay);
|
|
} else {
|
|
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.sendTextMessage(
|
|
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, make them a moderator
|
|
// 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
|
|
};
|
|
const event = {
|
|
getType: () => {
|
|
return "m.room.power_levels";
|
|
},
|
|
getContent: () => {
|
|
return {
|
|
users: {
|
|
[process.env.BOT_USERID]: 100,
|
|
[member.userId]: 50
|
|
}
|
|
};
|
|
}
|
|
};
|
|
this.client.setPowerLevel(member.roomId, member.userId, 50, event);
|
|
this.sendTextMessage(
|
|
member.roomId,
|
|
`${member.name} has joined the chat.`
|
|
);
|
|
this.sendTextMessage(
|
|
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.sendTextMessage(
|
|
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.handleMessageEvent(event);
|
|
}
|
|
});
|
|
// unencrypted messages
|
|
this.client.on("Room.timeline", (event, room, toStartOfTimeline) => {
|
|
if (event.getType() === "m.room.message") {
|
|
logger.log("info", event);
|
|
logger.log("info", this.client.isCryptoEnabled());
|
|
logger.log("info", event.isEncrypted());
|
|
}
|
|
if (event.getType() === "m.room.message" && !event.isEncrypted()) {
|
|
this.handleMessageEvent(event);
|
|
}
|
|
});
|
|
}
|
|
|
|
start() {
|
|
const localStorage = this.createLocalStorage();
|
|
const tmpClient = matrix.createClient(process.env.MATRIX_SERVER_URL);
|
|
|
|
tmpClient
|
|
.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;
|
|
logger.log("info", `LOGIN DATA ==> ${JSON.stringify(data)}`);
|
|
|
|
// 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();
|
|
this.setMessageListeners();
|
|
})
|
|
.then(() => this.client.startClient({ initialSyncLimit: 0 }))
|
|
.catch(err => {
|
|
this.handleBotCrash(undefined, err);
|
|
logger.log("error", `ERROR INITIALIZING CLIENT: ${err}`);
|
|
});
|
|
}
|
|
}
|
|
|
|
export default OcrccBot;
|