├── globals.d.ts ├── .gitignore ├── src ├── modules │ ├── uuid.ts │ ├── pun.ts │ ├── treestatus.ts │ ├── github.ts │ ├── expand-bug.ts │ ├── help.ts │ ├── gitlab.ts │ ├── reviewers.ts │ ├── social.ts │ ├── horse.ts │ ├── confession.ts │ └── admin.ts ├── autojoin-upgraded-rooms.ts ├── html.ts ├── db.ts ├── settings.ts ├── utils.ts └── index.ts ├── migrations └── 001-initial-schema.sql ├── Dockerfile ├── config.json.example ├── package.json ├── .github └── workflows │ └── tsnode.yml ├── README.md └── tsconfig.json /globals.d.ts: -------------------------------------------------------------------------------- 1 | // Fix an unknown name error in masto. 2 | interface FormData {} 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config*.json 2 | data/ 3 | 4 | yarn.lock 5 | yarn-error.log 6 | 7 | node_modules/ 8 | .cache/ 9 | tsconfig.tsbuildinfo 10 | -------------------------------------------------------------------------------- /src/modules/uuid.ts: -------------------------------------------------------------------------------- 1 | // Provides random uuids. 2 | import { v4 as uuid } from "uuid"; 3 | 4 | module.exports = { 5 | handler: async function uuidHandler(client, msg) { 6 | if (msg.body.indexOf("!uuid") === -1) { 7 | return; 8 | } 9 | let text = uuid(); 10 | client.sendText(msg.room, text); 11 | }, 12 | 13 | help: "Generates a random uuid following uuidv4.", 14 | }; 15 | -------------------------------------------------------------------------------- /migrations/001-initial-schema.sql: -------------------------------------------------------------------------------- 1 | -- Up 2 | CREATE TABLE ModuleSetting ( 3 | id INTEGER PRIMARY KEY, 4 | matrixRoomId TEXT NOT NULL, 5 | moduleName TEXT NOT NULL, 6 | enabled BOOLEAN NOT NULL, 7 | options TEXT 8 | ); 9 | 10 | INSERT INTO ModuleSetting (matrixRoomId, moduleName, enabled) VALUES ('*', 'help', true); 11 | 12 | -- Down 13 | DROP TABLE ModuleSetting; 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10-slim 2 | 3 | RUN apt-get update \ 4 | && apt-get dist-upgrade -y \ 5 | && rm -rf /var/lib/apt/lists/* \ 6 | && mkdir -p /app/data 7 | 8 | COPY ./src /app/src 9 | COPY ./migrations /app/migrations 10 | COPY ./build /app/build 11 | COPY package.json /app/package.json 12 | 13 | WORKDIR /app 14 | RUN npm install --production 15 | 16 | VOLUME /app/config.json 17 | VOLUME /app/data 18 | 19 | CMD ["node", "/app/build/index.js", "/config.json"] 20 | -------------------------------------------------------------------------------- /config.json.example: -------------------------------------------------------------------------------- 1 | { 2 | "homeserver": "https://mozilla-test.modular.im", 3 | "accessToken": "Your access token here. See https://t2bot.io/docs/access_tokens/ for details.", 4 | "owner": "@somebody:matrix.example.com", 5 | "logLevel": "warn", 6 | "githubToken": "hunter2", 7 | "mastodon": { 8 | "#alias:matrix.example.org": { 9 | "baseUrl": "https://mastodon.example.org/", 10 | "accessToken": "fewjilfelfwejilfwej423890fjkeowl43892" 11 | }, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/modules/pun.ts: -------------------------------------------------------------------------------- 1 | // Reads a random pun from icanhazdadjoke.com 2 | // TODO probably better to pick a set of puns instead of relying on the 3 | // external website, to avoid offensive content. 4 | 5 | import { requestJson } from "../utils"; 6 | 7 | const URL = "https://icanhazdadjoke.com/"; 8 | 9 | module.exports = { 10 | handler: async function pun(client, msg) { 11 | if (msg.body.indexOf("!pun") === -1) { 12 | return; 13 | } 14 | let json = await requestJson(URL); 15 | client.sendText(msg.room, json.joke); 16 | }, 17 | 18 | help: "Reads a joke out loud from icanhazdadjoke.com. UNSAFE!", 19 | }; 20 | -------------------------------------------------------------------------------- /src/autojoin-upgraded-rooms.ts: -------------------------------------------------------------------------------- 1 | import { MatrixClient } from "matrix-bot-sdk"; 2 | import { migrateRoom } from "./settings"; 3 | 4 | export default { 5 | setupOnClient(client: MatrixClient) { 6 | client.on( 7 | "room.archived", 8 | async (prevRoomId: string, tombstoneEvent: any) => { 9 | if (!tombstoneEvent["content"]) return; 10 | if (!tombstoneEvent["sender"]) return; 11 | if (!tombstoneEvent["content"]["replacement_room"]) return; 12 | 13 | const serverName = tombstoneEvent["sender"] 14 | .split(":") 15 | .splice(1) 16 | .join(":"); 17 | 18 | const newRoomId = tombstoneEvent["content"]["replacement_room"]; 19 | await migrateRoom(prevRoomId, newRoomId); 20 | 21 | return client.joinRoom(newRoomId, [serverName]); 22 | } 23 | ); 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "botzilla", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "pretty": "npx prettier \"./src/**/*\" --write", 8 | "build": "tsc", 9 | "dev": "tsc -w --incremental --preserveWatchOutput", 10 | "start": "node ./build/index" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/bnjbvr/botzilla.git" 15 | }, 16 | "author": "", 17 | "license": "ISC", 18 | "bugs": { 19 | "url": "https://github.com/bnjbvr/botzilla/issues" 20 | }, 21 | "homepage": "https://github.com/bnjbvr/botzilla#readme", 22 | "dependencies": { 23 | "masto": "^3.7.0", 24 | "matrix-bot-sdk": "^0.6.2", 25 | "octonode": "^0.9.5", 26 | "prettier": "2.1.2", 27 | "request": "^2.88.0", 28 | "sqlite": "^4.0.15", 29 | "sqlite3": "^5.0.3", 30 | "twit": "^2.2.11", 31 | "typescript": "4.8.4", 32 | "uuid": "^8.3.1" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/tsnode.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [10.x, 12.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm install 28 | name: Install dependencies 29 | - run: npm run build 30 | name: Build TypeScript code 31 | - run: npx --no-install prettier --check ./src/**/*.ts 32 | name: Check Prettier has been run 33 | -------------------------------------------------------------------------------- /src/html.ts: -------------------------------------------------------------------------------- 1 | function helper(tag) { 2 | return (attributes, ...children) => { 3 | let maybeAttributes = ""; 4 | if (Object.keys(attributes).length) { 5 | maybeAttributes = 6 | " " + 7 | Object.keys(attributes) 8 | .map((key) => { 9 | return `${key}="${attributes[key]}"`; 10 | }) 11 | .join(" "); 12 | } 13 | return `<${tag}${maybeAttributes}>${children.join(" ")}`; 14 | }; 15 | } 16 | 17 | export const p = helper("p"); 18 | export const ul = helper("ul"); 19 | export const li = helper("li"); 20 | export const strong = helper("strong"); 21 | export const a = helper("a"); 22 | 23 | if (module.parent === null) { 24 | // Tests. 25 | let expected = `

hello google world

`; 26 | let _ = module.exports; 27 | let observed = _.p( 28 | {}, 29 | "hello", 30 | _.a({ href: "https://www.google.com" }, "google"), 31 | "world" 32 | ); 33 | if (expected !== observed) { 34 | throw new Error(`Assertion error, expected: 35 | ${expected} 36 | Observed: 37 | ${observed}`); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/modules/treestatus.ts: -------------------------------------------------------------------------------- 1 | import { requestJson } from "../utils"; 2 | 3 | const URL = "https://treestatus.mozilla-releng.net/trees2"; 4 | const MATCH_REGEXP = /!treestatus ?([a-zA-Z0-9-]+)?/g; 5 | 6 | function formatOne(treeInfo) { 7 | let reason = 8 | treeInfo.status !== "open" && treeInfo.reason && treeInfo.reason.length > 0 9 | ? ` (${treeInfo.reason})` 10 | : ""; 11 | return `${treeInfo.tree}: ${treeInfo.status}${reason}`; 12 | } 13 | 14 | async function handler(client, msg) { 15 | MATCH_REGEXP.lastIndex = 0; 16 | 17 | let match = MATCH_REGEXP.exec(msg.body); 18 | if (match === null) { 19 | return; 20 | } 21 | 22 | let whichTree = match[1]; // first group. 23 | 24 | let results = (await requestJson(URL)).result; 25 | let treeMap = {}; 26 | for (let result of results) { 27 | treeMap[result.tree] = result; 28 | } 29 | 30 | if (whichTree) { 31 | if (!treeMap[whichTree]) { 32 | // Try mozilla- with the tree name, to allow inbound instead of 33 | // mozilla-inbound. 34 | whichTree = `mozilla-${whichTree}`; 35 | } 36 | 37 | let treeInfo = treeMap[whichTree]; 38 | if (!treeInfo) { 39 | client.sendText(msg.room, `unknown tree '${whichTree}'`); 40 | } else { 41 | client.sendText(msg.room, formatOne(treeInfo)); 42 | } 43 | } else { 44 | // Respond with a few interesting trees. 45 | let answer = ["autoland", "mozilla-inbound", "try"] 46 | .map((name) => treeMap[name]) 47 | .map(formatOne) 48 | .join("\n"); 49 | client.sendText(msg.room, answer); 50 | } 51 | } 52 | 53 | module.exports = { 54 | handler, 55 | 56 | help: 57 | "Reads the status of all the trees with !treestatus, or of a single one with !treestatus NAME, if it's a well-known tree.", 58 | }; 59 | -------------------------------------------------------------------------------- /src/modules/github.ts: -------------------------------------------------------------------------------- 1 | import { request } from "../utils"; 2 | import * as settings from "../settings"; 3 | import * as _ from "../html"; 4 | 5 | const ISSUE_OR_PR_REGEXP = /#(\d+)/g; 6 | 7 | async function handleIssueOrPr(client, repo, roomId, issueNumber) { 8 | let url = `https://api.github.com/repos/${repo}/issues/${issueNumber}`; 9 | 10 | let response = await request({ 11 | uri: url, 12 | headers: { 13 | accept: "application/vnd.github.v3+json", 14 | host: "api.github.com", 15 | "user-agent": "curl/7.64.0", // oh you 16 | }, 17 | }); 18 | 19 | if (!response) { 20 | return; 21 | } 22 | 23 | if (response.statusCode !== 200) { 24 | console.warn("github: error status code", response.statusCode); 25 | return; 26 | } 27 | 28 | let json = JSON.parse(response.body); 29 | if (!json) { 30 | return; 31 | } 32 | 33 | let text = `${json.title} | ${json.html_url}`; 34 | let html = _.a({ href: json.html_url }, json.title); 35 | 36 | client.sendMessage(roomId, { 37 | msgtype: "m.notice", 38 | body: text, 39 | format: "org.matrix.custom.html", 40 | formatted_body: html, 41 | }); 42 | } 43 | 44 | async function expandGithub(client, msg) { 45 | let repo = await settings.getOption(msg.room, "github", "user-repo"); 46 | if (typeof repo === "undefined") { 47 | return; 48 | } 49 | 50 | var matches: RegExpExecArray | null = null; 51 | while ((matches = ISSUE_OR_PR_REGEXP.exec(msg.body)) !== null) { 52 | await handleIssueOrPr(client, repo, msg.room, matches[1]); 53 | } 54 | } 55 | 56 | module.exports = { 57 | handler: expandGithub, 58 | help: 59 | "If configured for a specific Github repository (via the 'user-repo' set option), in this room, will expand #123 into the issue's title and URL.", 60 | }; 61 | -------------------------------------------------------------------------------- /src/modules/expand-bug.ts: -------------------------------------------------------------------------------- 1 | // Expands "bug XXXXXX" into a short URL to the bug, the status, assignee and 2 | // title of the bug. 3 | // 4 | // Note: don't catch expansions when seeing full Bugzilla URLs, because the 5 | // Matrix client may or may not display the URL, according to channel's 6 | // settings, users' settings, etc. and it's not possible to do something wise 7 | // for all the possible different configurations. 8 | 9 | import * as utils from "../utils"; 10 | 11 | const BUG_NUMBER_REGEXP = /[Bb]ug (\d+)/g; 12 | 13 | const COOLDOWN_TIME = 120000; // milliseconds 14 | const COOLDOWN_NUM_MESSAGES = 15; 15 | 16 | let cooldowns = {}; 17 | 18 | async function handleBug(client, roomId, bugNumber) { 19 | if (typeof cooldowns[bugNumber] === "undefined") { 20 | cooldowns[bugNumber] = new utils.Cooldown(null, 5); 21 | } 22 | let cooldown = cooldowns[bugNumber]; 23 | if (!cooldown.check(roomId)) { 24 | return; 25 | } 26 | 27 | let url = `https://bugzilla.mozilla.org/rest/bug/${bugNumber}?include_fields=summary,assigned_to,status,resolution`; 28 | 29 | let response = await utils.request(url); 30 | if (!response) { 31 | return; 32 | } 33 | 34 | let shortUrl = `https://bugzil.la/${bugNumber}`; 35 | if (response.statusCode === 401) { 36 | // Probably a private bug! Just send the basic information. 37 | cooldown.didAnswer(roomId); 38 | client.sendText(roomId, shortUrl); 39 | return; 40 | } 41 | 42 | if (response.statusCode !== 200) { 43 | return; 44 | } 45 | 46 | let json = JSON.parse(response.body); 47 | if (!json.bugs || !json.bugs.length) { 48 | return; 49 | } 50 | 51 | let bug = json.bugs[0]; 52 | let msg = `${shortUrl} — ${bug.status} (${bug.assigned_to_detail.nick}) — ${bug.summary}`; 53 | cooldown.didAnswer(roomId); 54 | client.sendText(roomId, msg); 55 | } 56 | 57 | async function expandBugNumber(client, msg) { 58 | for (let key of Object.getOwnPropertyNames(cooldowns)) { 59 | cooldowns[key].onNewMessage(msg.room); 60 | } 61 | 62 | let matches: RegExpExecArray | null = null; 63 | while ((matches = BUG_NUMBER_REGEXP.exec(msg.body)) !== null) { 64 | await handleBug(client, msg.room, matches[1]); 65 | } 66 | } 67 | 68 | module.exports = { 69 | handler: expandBugNumber, 70 | help: 71 | "Expands bug numbers into (URL, status, assignee, title) when it sees 'bug 123456'.", 72 | }; 73 | -------------------------------------------------------------------------------- /src/db.ts: -------------------------------------------------------------------------------- 1 | // DB facilities. Don't use these directly, instead go through the Settings 2 | // module which adds in-memory caching. Only the Settings module should 3 | // directly interact with this. 4 | 5 | import * as sqlite from "sqlite"; 6 | import sqlite3 from "sqlite3"; 7 | import * as path from "path"; 8 | 9 | let db; 10 | 11 | export async function init(storageDir) { 12 | let dbPath = path.join(storageDir, "db.sqlite"); 13 | db = await sqlite.open({ filename: dbPath, driver: sqlite3.Database }); 14 | db.on("trace", (event) => console.log(event)); 15 | await db.migrate(); 16 | } 17 | 18 | export async function migrateRoomSettings( 19 | prevRoomId: string, 20 | newRoomId: string 21 | ) { 22 | await db.run( 23 | "UPDATE ModuleSetting SET matrixRoomId = ? WHERE matrixRoomId = ?", 24 | newRoomId, 25 | prevRoomId 26 | ); 27 | } 28 | 29 | export async function upsertModuleSettingEnabled(roomId, moduleName, enabled) { 30 | let former = await db.get( 31 | "SELECT id FROM ModuleSetting WHERE matrixRoomId = ? AND moduleName = ?", 32 | roomId, 33 | moduleName 34 | ); 35 | if (typeof former === "undefined") { 36 | await db.run( 37 | "INSERT INTO ModuleSetting (matrixRoomId, moduleName, enabled) VALUES (?, ?, ?)", 38 | roomId, 39 | moduleName, 40 | enabled 41 | ); 42 | } else { 43 | await db.run( 44 | "UPDATE ModuleSetting SET enabled = ? WHERE id = ?", 45 | enabled, 46 | former.id 47 | ); 48 | } 49 | } 50 | 51 | export async function upsertModuleSettingOptions(roomId, moduleName, options) { 52 | let stringified = JSON.stringify(options); 53 | let former = await db.get( 54 | "SELECT id FROM ModuleSetting WHERE matrixRoomId = ? AND moduleName = ?", 55 | roomId, 56 | moduleName 57 | ); 58 | if (typeof former === "undefined") { 59 | await db.run( 60 | "INSERT INTO ModuleSetting (matrixRoomId, moduleName, enabled, options) VALUES (?, ?, ?, ?)", 61 | roomId, 62 | moduleName, 63 | false /*enabled*/, 64 | stringified 65 | ); 66 | } else { 67 | await db.run( 68 | "UPDATE ModuleSetting SET options = ? WHERE id = ?", 69 | stringified, 70 | former.id 71 | ); 72 | } 73 | } 74 | 75 | export async function getModuleSettings() { 76 | let results = await db.all( 77 | "SELECT moduleName, matrixRoomId, enabled, options FROM ModuleSetting;" 78 | ); 79 | return results; 80 | } 81 | -------------------------------------------------------------------------------- /src/modules/help.ts: -------------------------------------------------------------------------------- 1 | import * as _ from "../html"; 2 | 3 | const MATCH_REGEXP = /!help ?([a-zA-Z-]+)?/g; 4 | 5 | function fullHelp(client, msg, extra) { 6 | let text = 7 | "Hi there! Botzilla is a bot trying to help you."; 8 | let html = _.p( 9 | {}, 10 | "Hi there!", 11 | _.a({ href: "https://github.com/bnjbvr/botzilla" }, "Botzilla"), 12 | "is a bot trying to help you." 13 | ); 14 | 15 | if (extra.handlerNames.length) { 16 | text += "\nModules enabled:\n\n"; 17 | text += extra.handlerNames 18 | .map((name) => `- ${name} : ${extra.helpMessages[name]}`) 19 | .join("\n"); 20 | 21 | let handlersHelp = extra.handlerNames.map((name) => 22 | _.li({}, _.strong({}, name), ":", extra.helpMessages[name]) 23 | ); 24 | 25 | html += _.p({}, "Modules enabled:", _.ul({}, ...handlersHelp)); 26 | } else { 27 | let notice = "No modules enabled for this instance of Botzilla!"; 28 | text += `\n${notice}`; 29 | html += _.p({}, notice); 30 | } 31 | 32 | if (typeof extra.owner !== "undefined") { 33 | let notice = `The owner of this bot is ${extra.owner}. In case the bot misbehaves in any ways, feel free to get in touch with its owner.`; 34 | html += _.p(notice); 35 | text += `\n${notice}`; 36 | } 37 | 38 | client.sendMessage(msg.room, { 39 | msgtype: "m.notice", 40 | body: text, 41 | format: "org.matrix.custom.html", 42 | formatted_body: html, 43 | }); 44 | } 45 | 46 | module.exports = { 47 | handler: function (client, msg, extra) { 48 | MATCH_REGEXP.lastIndex = 0; 49 | let match = MATCH_REGEXP.exec(msg.body); 50 | if (match === null) { 51 | return; 52 | } 53 | 54 | let moduleName = match[1]; 55 | if (moduleName) { 56 | let text; 57 | let html; 58 | if (extra.handlerNames.indexOf(moduleName) !== -1) { 59 | text = extra.helpMessages[moduleName]; 60 | html = _.p( 61 | {}, 62 | _.strong({}, moduleName), 63 | ":", 64 | extra.helpMessages[moduleName] 65 | ); 66 | } else { 67 | let modulesList = extra.handlerNames.join(", "); 68 | text = `unknown module ${moduleName}. Currently enabled modules are: ${modulesList}`; 69 | html = _.p({}, text); 70 | } 71 | 72 | client.sendMessage(msg.room, { 73 | msgtype: "m.notice", 74 | body: text, 75 | format: "org.matrix.custom.html", 76 | formatted_body: html, 77 | }); 78 | } else { 79 | fullHelp(client, msg, extra); 80 | } 81 | }, 82 | 83 | help: "Well, this is what you're looking at :)", 84 | }; 85 | -------------------------------------------------------------------------------- /src/modules/gitlab.ts: -------------------------------------------------------------------------------- 1 | import { request } from "../utils"; 2 | import * as settings from "../settings"; 3 | import * as _ from "../html"; 4 | 5 | const ISSUE_OR_MR_REGEXP = /(#|!)(\d+)/g; 6 | 7 | async function handleIssueOrMr( 8 | client, 9 | baseUrl, 10 | user, 11 | project, 12 | roomId, 13 | isIssue, 14 | number 15 | ) { 16 | let encoded = `${user}%2F${project}`; 17 | let url = isIssue 18 | ? `https://${baseUrl}/api/v4/projects/${encoded}/issues/${number}` 19 | : `https://${baseUrl}/api/v4/projects/${encoded}/merge_requests/${number}`; 20 | let response = await request({ 21 | uri: url, 22 | headers: { 23 | accept: "application/json", 24 | "user-agent": "curl/7.64.0", // oh you 25 | }, 26 | }); 27 | 28 | if (!response) { 29 | return; 30 | } 31 | 32 | if (response.statusCode !== 200) { 33 | console.warn("gitlab: error status code", response.statusCode); 34 | return; 35 | } 36 | 37 | let json = JSON.parse(response.body); 38 | if (!json) { 39 | return; 40 | } 41 | 42 | let text = `${json.title} | ${json.web_url}`; 43 | let html = _.a({ href: json.web_url }, json.title); 44 | 45 | client.sendMessage(roomId, { 46 | msgtype: "m.notice", 47 | body: text, 48 | format: "org.matrix.custom.html", 49 | formatted_body: html, 50 | }); 51 | } 52 | 53 | async function expandGitlab(client, msg) { 54 | let url = await settings.getOption(msg.room, "gitlab", "url"); 55 | if (typeof url === "undefined") { 56 | return; 57 | } 58 | 59 | // Remove the protocol. 60 | if (url.startsWith("http://")) { 61 | url = url.split("http://")[1]; 62 | } else if (url.startsWith("https://")) { 63 | url = url.split("https://")[1]; 64 | } 65 | 66 | // Remove trailing slash, if it's there. 67 | if (url.endsWith("/")) { 68 | url = url.substr(0, url.length - 1); 69 | } 70 | 71 | // e.g.: gitlab.com/somebody/project 72 | let split = url.split("/"); 73 | let project = split.pop(); 74 | let user = split.pop(); 75 | let baseUrl = split.join("/"); 76 | 77 | var matches: RegExpExecArray | null = null; 78 | while ((matches = ISSUE_OR_MR_REGEXP.exec(msg.body)) !== null) { 79 | await handleIssueOrMr( 80 | client, 81 | baseUrl, 82 | user, 83 | project, 84 | msg.room, 85 | matches[1] === "#", 86 | matches[2] 87 | ); 88 | } 89 | } 90 | 91 | module.exports = { 92 | handler: expandGitlab, 93 | help: 94 | "If configured for a specific Gitlab repository (via the 'url' set " + 95 | "option), in this room, will expand #123 into the issue's title and URL, " + 96 | "!123 into the MR's title and URL.", 97 | }; 98 | -------------------------------------------------------------------------------- /src/modules/reviewers.ts: -------------------------------------------------------------------------------- 1 | // Suggest reviewers for given file in m-c. 2 | // 3 | // This communicates with hg.m.o to get the log. 4 | 5 | import { requestJson } from "../utils"; 6 | 7 | const SEARCH_FOX_QUERY = 8 | "https://searchfox.org/mozilla-central/search?q=&path=**/{{FILENAME}}"; 9 | const JSON_URL = "https://hg.mozilla.org/mozilla-central/json-log/tip/"; 10 | const MESSAGE_REGEXP = /[wW]ho (?:can review|has reviewed) (?:a patch in |patches in )?\/?(\S+?)\s*\??$/; 11 | const REVIEWER_REGEXP = /r=(\S+)/; 12 | const MAX_REVIEWERS = 3; 13 | 14 | async function getReviewers(path) { 15 | const url = `${JSON_URL}${path}`; 16 | const log = await requestJson(url); 17 | 18 | const reviewers: { 19 | [reviewer: string]: number; 20 | } = {}; 21 | for (const item of log.entries) { 22 | const m = item.desc.match(REVIEWER_REGEXP); 23 | if (!m) { 24 | continue; 25 | } 26 | 27 | for (const r of m[1].split(",")) { 28 | if (r in reviewers) { 29 | reviewers[r]++; 30 | } else { 31 | reviewers[r] = 1; 32 | } 33 | } 34 | } 35 | 36 | return Object.entries(reviewers) 37 | .map(([name, count]) => ({ name, count })) 38 | .sort((a, b) => { 39 | return b.count - a.count; 40 | }); 41 | } 42 | 43 | async function fuzzyMatch(path) { 44 | let result; 45 | try { 46 | const url = SEARCH_FOX_QUERY.replace("{{FILENAME}}", path); 47 | result = await requestJson(url); 48 | } catch (_) { 49 | // Just try the normal path in case of error. 50 | } 51 | 52 | if (result && result.normal && result.normal.Files) { 53 | let files = result.normal.Files; 54 | if (files.length === 1 && files[0].path.trim().length) { 55 | return files[0].path; 56 | } 57 | } 58 | 59 | return path; 60 | } 61 | 62 | module.exports = { 63 | handler: async function (client, msg) { 64 | const m = msg.body.match(MESSAGE_REGEXP); 65 | if (!m) { 66 | return; 67 | } 68 | 69 | const path = await fuzzyMatch(m[1]); 70 | 71 | try { 72 | const reviewers = await getReviewers(path); 73 | 74 | if (reviewers.length > MAX_REVIEWERS) { 75 | reviewers.length = MAX_REVIEWERS; 76 | } 77 | 78 | const list = 79 | reviewers.length > 0 80 | ? reviewers 81 | .map(({ name, count }) => { 82 | return `${name} x${count}`; 83 | }) 84 | .join(", ") 85 | : "found no previous reviewers for"; 86 | 87 | client.sendText(msg.room, `${list}: /${path}`); 88 | } catch (e) { 89 | client.sendText(msg.room, `Could not find reviewers for /${path}`); 90 | } 91 | }, 92 | 93 | help: 94 | "Suggest reviewers for given file in m-c. Usage: Who can review ?", 95 | }; 96 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import * as db from "./db"; 2 | import { assert } from "./utils"; 3 | 4 | interface SettingEntry { 5 | enabled: boolean; 6 | options: object; 7 | } 8 | 9 | type Settings = { 10 | [roomId: string]: { 11 | [moduleName: string]: SettingEntry; 12 | }; 13 | }; 14 | 15 | let SETTINGS: Settings | null = null; 16 | 17 | function ensureCacheEntry(matrixRoomId, moduleName) { 18 | assert(SETTINGS !== null, "settings should have been defined first"); 19 | SETTINGS[matrixRoomId] = SETTINGS[matrixRoomId] || {}; 20 | SETTINGS[matrixRoomId][moduleName] = SETTINGS[matrixRoomId][moduleName] || {}; 21 | return SETTINGS[matrixRoomId][moduleName]; 22 | } 23 | 24 | async function forceReloadSettings() { 25 | SETTINGS = { 26 | "*": {}, 27 | }; 28 | let results = await db.getModuleSettings(); 29 | for (const r of results) { 30 | let entry = ensureCacheEntry(r.matrixRoomId, r.moduleName); 31 | entry.enabled = r.enabled === 1; 32 | entry.options = r.options === null ? null : JSON.parse(r.options); 33 | } 34 | } 35 | 36 | export async function migrateRoom(fromRoomId, toRoomId) { 37 | await db.migrateRoomSettings(fromRoomId, toRoomId); 38 | // Update in-memory cache. 39 | await forceReloadSettings(); 40 | } 41 | 42 | export async function getSettings(): Promise { 43 | if (SETTINGS === null) { 44 | await forceReloadSettings(); 45 | } 46 | assert(SETTINGS !== null, "settings should have been loaded"); 47 | return SETTINGS; 48 | } 49 | 50 | export async function enableModule(matrixRoomId, moduleName, enabled) { 51 | await getSettings(); 52 | let entry = ensureCacheEntry(matrixRoomId, moduleName); 53 | entry.enabled = enabled; 54 | await db.upsertModuleSettingEnabled(matrixRoomId, moduleName, enabled); 55 | } 56 | 57 | export async function getOption(matrixRoomId, moduleName, key) { 58 | await getSettings(); 59 | 60 | // Prefer the room option if there's one, otherwise return the general value. 61 | 62 | let allValue; 63 | let allEntry = ensureCacheEntry("*", moduleName); 64 | if ( 65 | typeof allEntry.options === "object" && 66 | allEntry.options !== null && 67 | !!allEntry.options[key] 68 | ) { 69 | allValue = allEntry.options[key]; 70 | } 71 | 72 | let entry = ensureCacheEntry(matrixRoomId, moduleName); 73 | if ( 74 | typeof entry.options === "object" && 75 | entry.options !== null && 76 | !!entry.options[key] 77 | ) { 78 | return entry.options[key]; 79 | } 80 | // May be undefined. 81 | return allValue; 82 | } 83 | 84 | export async function setOption(matrixRoomId, moduleName, key, value) { 85 | await getSettings(); 86 | let entry = ensureCacheEntry(matrixRoomId, moduleName); 87 | entry.options = entry.options || {}; 88 | entry.options[key] = value; 89 | await db.upsertModuleSettingOptions(matrixRoomId, moduleName, entry.options); 90 | } 91 | 92 | export async function isModuleEnabled(matrixRoomId, moduleName) { 93 | await getSettings(); 94 | 95 | // Favor per room preferences over general preferences. 96 | let entry = ensureCacheEntry(matrixRoomId, moduleName); 97 | if (typeof entry.enabled === "boolean") { 98 | return entry.enabled; 99 | } 100 | 101 | entry = ensureCacheEntry("*", moduleName); 102 | return !!entry.enabled; 103 | } 104 | -------------------------------------------------------------------------------- /src/modules/social.ts: -------------------------------------------------------------------------------- 1 | import { Masto } from "masto"; 2 | import { Twit } from "twit"; 3 | 4 | import * as github from "octonode"; 5 | import * as settings from "../settings"; 6 | import * as utils from "../utils"; 7 | 8 | const TOOT_REGEXP = /^!(toot|mastodon) (.*)/g; 9 | const TWEET_REGEXP = /^!(tweet|twitter) (.*)/g; 10 | 11 | interface MastodonConfig { 12 | baseUrl: string; 13 | accessToken: string; 14 | } 15 | 16 | interface TwitterConfig { 17 | consumer_key: string; 18 | consumer_secret: string; 19 | access_token: string; 20 | access_token_secret: string; 21 | } 22 | 23 | let CONFIG_MASTODON: { [roomAlias: string]: MastodonConfig } | null = null; 24 | let CONFIG_TWITTER: { [roomAlias: string]: TwitterConfig } | null = null; 25 | 26 | async function init(config) { 27 | CONFIG_MASTODON = config.mastodon || null; 28 | CONFIG_TWITTER = config.twitter || null; 29 | } 30 | 31 | async function toot(client, msg, extra): Promise { 32 | if (CONFIG_MASTODON === null) { 33 | return false; 34 | } 35 | 36 | TOOT_REGEXP.lastIndex = 0; 37 | let match = TOOT_REGEXP.exec(msg.body); 38 | if (match === null) { 39 | return false; 40 | } 41 | let content = match[2]; 42 | 43 | let alias = await utils.getRoomAlias(client, msg.room); 44 | if (!alias) { 45 | return false; 46 | } 47 | 48 | if (typeof CONFIG_MASTODON[alias] === "undefined") { 49 | return true; 50 | } 51 | let { baseUrl, accessToken } = CONFIG_MASTODON[alias]; 52 | if (!baseUrl || !accessToken) { 53 | return true; 54 | } 55 | 56 | if (!(await utils.isAdmin(client, msg.room, msg.sender, extra))) { 57 | return true; 58 | } 59 | 60 | const masto = await Masto.login({ 61 | uri: baseUrl, 62 | accessToken, 63 | }); 64 | 65 | await masto.createStatus({ 66 | status: content, 67 | visibility: "public", 68 | }); 69 | 70 | await utils.sendSeen(client, msg); 71 | return true; 72 | } 73 | 74 | // WARNING: not tested yet. 75 | async function twitter(client, msg, extra): Promise { 76 | if (CONFIG_TWITTER === null) { 77 | return false; 78 | } 79 | 80 | TWEET_REGEXP.lastIndex = 0; 81 | let match = TWEET_REGEXP.exec(msg.body); 82 | if (match === null) { 83 | return false; 84 | } 85 | let content = match[2]; 86 | 87 | let alias = await utils.getRoomAlias(client, msg.room); 88 | if (!alias) { 89 | return false; 90 | } 91 | 92 | if (typeof CONFIG_TWITTER[alias] === "undefined") { 93 | return true; 94 | } 95 | let { 96 | consumer_key, 97 | consumer_secret, 98 | access_token, 99 | access_token_secret, 100 | } = CONFIG_TWITTER[alias]; 101 | if ( 102 | !consumer_key || 103 | !consumer_secret || 104 | !access_token || 105 | !access_token_secret 106 | ) { 107 | return true; 108 | } 109 | 110 | if (!(await utils.isAdmin(client, msg.room, msg.sender, extra))) { 111 | return true; 112 | } 113 | 114 | const T = new Twit({ 115 | consumer_key, 116 | consumer_secret, 117 | access_token, 118 | access_token_secret, 119 | }); 120 | 121 | await T.post("statuses/update", { status: content }); 122 | 123 | await utils.sendSeen(client, msg); 124 | return true; 125 | } 126 | 127 | async function handler(client, msg, extra) { 128 | if (await toot(client, msg, extra)) { 129 | return; 130 | } 131 | if (await twitter(client, msg, extra)) { 132 | return; 133 | } 134 | } 135 | 136 | module.exports = { 137 | handler, 138 | init, 139 | help: "Helps posting to Mastodon/Twitter accounts from Matrix", 140 | }; 141 | -------------------------------------------------------------------------------- /src/modules/horse.ts: -------------------------------------------------------------------------------- 1 | // This bot fetches quotes from the @horsejs twitter account, and reads them 2 | // out loud. It's unsafe. 3 | 4 | import { assert, requestJson } from "../utils"; 5 | 6 | // Constants. 7 | var KNOWN_FRAMEWORKS = [ 8 | "react", 9 | "angular", 10 | "jquery", 11 | "backbone", 12 | "meteor", 13 | "vue", 14 | "mocha", 15 | "jest", 16 | ]; 17 | 18 | var KNOWN_KEYWORDS = [ 19 | "ember.js", 20 | "emberjs", 21 | "node.js", 22 | "nodejs", 23 | "crockford", 24 | "eich", 25 | "rhino", 26 | "spidermonkey", 27 | "v8", 28 | "spartan", 29 | "chakra", 30 | "webkit", 31 | "blink", 32 | "jsc", 33 | "turbofan", 34 | "tc39", 35 | "wasm", 36 | "webassembly", 37 | "webasm", 38 | "ecma262", 39 | "ecmascript", 40 | ]; 41 | 42 | var PRE_LOADED_TWEETS = 10; 43 | 44 | const URL = "http://javascript.horse/random.json"; 45 | 46 | // Global values 47 | var TWEETS: string[] = []; 48 | 49 | var KEYWORD_MAP = {}; 50 | 51 | function maybeCacheTweet(tweet) { 52 | for (var j = 0; j < KNOWN_KEYWORDS.length; j++) { 53 | var keyword = KNOWN_KEYWORDS[j]; 54 | if (tweet.toLowerCase().indexOf(keyword) === -1) { 55 | continue; 56 | } 57 | console.log("Found:", keyword, "in", tweet); 58 | KEYWORD_MAP[keyword] = KEYWORD_MAP[keyword] || []; 59 | KEYWORD_MAP[keyword].push(tweet); 60 | } 61 | } 62 | 63 | async function getTweet(): Promise { 64 | let result: string | undefined | null = null; 65 | if (TWEETS.length) { 66 | result = TWEETS.pop(); 67 | } 68 | 69 | let tweet = (await requestJson(URL)).text; 70 | if (result !== null) { 71 | maybeCacheTweet(tweet); 72 | TWEETS.push(tweet); 73 | } else { 74 | result = tweet; 75 | } 76 | 77 | assert(typeof result === "string", "string resolved at this point"); 78 | return result; 79 | } 80 | 81 | async function onLoad() { 82 | // Note all the known keywords. 83 | for (var i = 0; i < KNOWN_FRAMEWORKS.length; i++) { 84 | var fw = KNOWN_FRAMEWORKS[i]; 85 | KNOWN_KEYWORDS.push(fw + "js"); 86 | KNOWN_KEYWORDS.push(fw + ".js"); 87 | } 88 | KNOWN_KEYWORDS = KNOWN_KEYWORDS.concat(KNOWN_FRAMEWORKS); 89 | 90 | // Preload a few tweets. 91 | var promises: Promise[] = []; 92 | for (var i = 0; i < PRE_LOADED_TWEETS; i++) { 93 | promises.push(getTweet()); 94 | } 95 | let tweets = await Promise.all(promises); 96 | for (let tweet of tweets) { 97 | maybeCacheTweet(tweet); 98 | } 99 | console.log("preloaded", tweets.length, "tweets"); 100 | } 101 | 102 | // TODO there should be a way to properly init a submodule. In the meanwhile, 103 | // just do it in the global scope here. 104 | (async function () { 105 | try { 106 | await onLoad(); 107 | } catch (err: any) { 108 | console.error("when initializing horse.js:", err.message, err.stack); 109 | } 110 | })(); 111 | 112 | module.exports = { 113 | handler: async function (client, msg) { 114 | if (msg.body.indexOf("!horsejs") == -1) { 115 | return; 116 | } 117 | 118 | // Try to see if the message contained a known keyword. 119 | for (var kw in KEYWORD_MAP) { 120 | if (msg.body.toLowerCase().indexOf(kw) === -1) { 121 | continue; 122 | } 123 | var tweets = KEYWORD_MAP[kw]; 124 | var index = (Math.random() * tweets.length) | 0; 125 | client.sendText(msg.room, tweets[index]); 126 | tweets.splice(index, 1); 127 | return; 128 | } 129 | 130 | // No it didn't, just send a random tweet. 131 | let tweet = await getTweet(); 132 | client.sendText(msg.room, tweet); 133 | }, 134 | 135 | help: "Tells a random message from the @horsejs twitter account. UNSAFE!", 136 | }; 137 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Botzilla 2 | === 3 | 4 | **⚠⚠⚠ This project has been deprecated in favor of [Trinity](https://github.com/bnjbvr/trinity), a more advanced bot system written in Rust and making use of commands implemented as WebAssembly modules. No more issues or pull requests will be taken against this repository.** 5 | 6 | This is a Matrix bot, with a few features, tuned for Mozilla's needs but could 7 | be useful in other contexts. 8 | 9 | Hack 10 | === 11 | 12 | Make sure that nodejs 10 or more is installed on your machine. 13 | 14 | - Run `npm install` to make sure all your dependencies are up to date. 15 | - Copy `config.json.example` to `config.json` and fill the access token for 16 | your bot as documented there. 17 | - (Optional) Make your code beautiful with `npm run pretty`. 18 | - Start the script with `npm start`. 19 | 20 | Available modules 21 | === 22 | 23 | [See the list](./src/modules). You can refer to a module by its filename in 24 | the modules directory. 25 | 26 | Admin 27 | === 28 | 29 | A user with a power level greater than 50 (administrator or moderator) can 30 | administrate the bot by opening an unencrypted private chat with it, and using 31 | the following commands. The super-admin is a username set in the `config.json` 32 | configuration file. 33 | 34 | - `!admin list`: lists all the known modules, without any information with 35 | their being enabled or not. 36 | - `!admin status`: gives the enabled/disabled status of modules for the current 37 | room. 38 | - `!admin enable uuid`/`!admin disable uuid`: enables/disables the `uuid` for 39 | this room. 40 | - `!admin enable-all uuid`/`!admin disable-all uuid`: (super-admin only) 41 | enables/disables the `uuid` for all the rooms. 42 | - `!admin set MODULE KEY VALUE`: for the given MODULE, sets the given KEY to 43 | the given VALUE (acting as a key-value store). There's a `set-all` variant 44 | for super-admins that will set the values for all the rooms (a per-room value 45 | is preferred, when it's set). 46 | - `!admin get MODULE KEY`: for the given MODULE, returns the current value of 47 | the given KEY. There's a `get-all` variant for super-admins that will set 48 | the values for all the rooms. 49 | 50 | ### Known keys 51 | 52 | #### Gitlab 53 | 54 | - `url` is the key for the full URL of the gitlab repository, including the 55 | instance URL up to the user and repository name. e.g 56 | `https://gitlab.com/ChristianPauly/fluffychat-flutter`. 57 | 58 | #### Github 59 | 60 | - `user-repo` is the key for the user/repo combination, e.g. `bnjbvr/botzilla`. 61 | 62 | #### Confession 63 | 64 | - `userRepo` is the key for the user/repo combination of the Github repository 65 | used to save the confessions, e.g. `robotzilla/histoire`. Note the bot's 66 | configuration must contain Github API keys for a github user who can push to 67 | this particular repository. 68 | 69 | How to create a new module 70 | === 71 | 72 | - Create a new JS file in `./src/modules`. 73 | - It must export an object of the form: 74 | 75 | ```js 76 | { 77 | handler: async function(client, msg) { 78 | // This contains the message's content. 79 | let body = msg.body; 80 | if (body !== '!botsnack') { 81 | return; 82 | } 83 | 84 | // This is the Matrix internal room identifier, not a pretty-printable 85 | // room alias name. 86 | let roomId = msg.room; 87 | 88 | // This contains the full id of the sender, with the form 89 | // nickname@domaine.com. 90 | let sender = msg.sender; 91 | 92 | client.sendText(roomId, `thanks ${sender} for the snack!`); 93 | client.sendNotice(roomId, "i like snacks!"); 94 | }, 95 | 96 | help: "An help message for this module." 97 | } 98 | ``` 99 | 100 | - The module's name is the file name. 101 | - It must be enabled by an admin with `!admin enable moduleName` for a single 102 | room, or `!admin enable-all moduleName`. 103 | - Fun and profit. 104 | 105 | Deploy 106 | === 107 | 108 | A Dockerfile has been set up to ease local and production deployment of this 109 | bot. You can spawn an instance with the following: 110 | 111 | docker run -ti \ 112 | -v /path/to/local/config.json:/config.json \ 113 | -v /path/to/local/data-dir:/app/data \ 114 | bnjbvr/botzilla 115 | 116 | Community 117 | === 118 | 119 | If you want to hang out and talk about botzilla, please join our [Matrix 120 | room](https://matrix.to/#/#botzilla:delire.party). 121 | 122 | There's also a [Matrix room](https://matrix.to/#/#botzilla-tests:delire.party) 123 | to try the bot features live. 124 | -------------------------------------------------------------------------------- /src/modules/confession.ts: -------------------------------------------------------------------------------- 1 | import * as github from "octonode"; 2 | import * as settings from "../settings"; 3 | import * as utils from "../utils"; 4 | 5 | interface GithubRepo { 6 | contentsAsync(path: string): Promise<{ sha: string; content: string }[]>; 7 | updateContentsAsync( 8 | path: string, 9 | commitMessage: string, 10 | content: string, 11 | sha: string 12 | ): Promise; 13 | createContentsAsync( 14 | path: string, 15 | commitMessage: string, 16 | content: string 17 | ): Promise; 18 | } 19 | 20 | interface GithubClient { 21 | repo(name: string): GithubRepo; 22 | } 23 | 24 | let GITHUB_CLIENT: GithubClient | null = null; 25 | 26 | async function init(config) { 27 | GITHUB_CLIENT = github.client(config.githubToken); 28 | } 29 | 30 | const PATH = "users/{USER}/{USER}.{ERA}.txt"; 31 | 32 | const CONFESSION_REGEXP = /^confession:(.*)/gs; 33 | 34 | const COOLDOWN_TIMEOUT = 1000 * 60 * 60; // every 10 minutes 35 | const COOLDOWN_NUM_MESSAGES = 20; 36 | let cooldown = new utils.Cooldown(COOLDOWN_TIMEOUT, COOLDOWN_NUM_MESSAGES); 37 | 38 | interface Update { 39 | path: string; 40 | newLine: string; 41 | commitMessage: string; 42 | } 43 | 44 | let waitingUpdates: Update[] = []; 45 | let emptying = false; 46 | 47 | async function sendOneUpdate( 48 | repo: GithubRepo, 49 | update: Update 50 | ): Promise { 51 | let { commitMessage, path, newLine } = update; 52 | 53 | try { 54 | let resp = await repo.contentsAsync(path); 55 | let sha = resp[0].sha; 56 | 57 | let content = Buffer.from(resp[0].content, "base64").toString(); 58 | content += `\n${newLine}`; 59 | 60 | await repo.updateContentsAsync(path, commitMessage, content, sha); 61 | return true; 62 | } catch (err: any) { 63 | if (err.statusCode && err.statusCode === 404) { 64 | // Create the file. 65 | await repo.createContentsAsync(path, commitMessage, newLine); 66 | return true; 67 | } else { 68 | // Add the confession to the queue and try sending the update later. 69 | waitingUpdates.push(update); 70 | return false; 71 | } 72 | } 73 | } 74 | 75 | async function tryEmptyQueue(repo) { 76 | if (emptying || waitingUpdates.length === 0) { 77 | return; 78 | } 79 | emptying = true; 80 | while (waitingUpdates.length) { 81 | let update = waitingUpdates.shift(); 82 | utils.assert(typeof update !== "undefined", "length is nonzero"); 83 | if (!(await sendOneUpdate(repo, update))) { 84 | break; 85 | } 86 | } 87 | emptying = false; 88 | } 89 | 90 | async function handler(client, msg, extra) { 91 | if (GITHUB_CLIENT === null) { 92 | return; 93 | } 94 | 95 | let userRepo = await settings.getOption(msg.room, "confession", "userRepo"); 96 | if (!userRepo) { 97 | return; 98 | } 99 | 100 | let repo = GITHUB_CLIENT.repo(userRepo); 101 | await tryEmptyQueue(repo); 102 | 103 | cooldown.onNewMessage(msg.room); 104 | 105 | CONFESSION_REGEXP.lastIndex = 0; 106 | let match = CONFESSION_REGEXP.exec(msg.body); 107 | if (match === null) { 108 | return; 109 | } 110 | 111 | let confession = match[1].trim(); 112 | if (!confession.length) { 113 | return; 114 | } 115 | 116 | confession = confession.replace(/(?:\r\n|\r|\n)/g, "\\n"); 117 | 118 | let now = (Date.now() / 1000) | 0; // for ye ol' asm.js days. 119 | 120 | // Find the million second period (~1.5 weeks) containing this timestamp. 121 | let era = now - (now % 1000000); 122 | 123 | // Remove prefix '@'. 124 | let from = msg.sender.substr(1, msg.sender.length); 125 | 126 | let roomAlias = await utils.getRoomAlias(client, msg.room); 127 | if (!roomAlias) { 128 | // Probably a personal room. 129 | roomAlias = "confession"; 130 | } 131 | 132 | let path = PATH.replace(/\{USER\}/g, from).replace("{ERA}", era.toString()); 133 | let newLine = `${now} ${roomAlias} ${confession}`; 134 | let commitMessage = `update from ${from}`; 135 | 136 | let done = await sendOneUpdate(repo, { 137 | path, 138 | newLine, 139 | commitMessage, 140 | }); 141 | 142 | if (done) { 143 | await utils.sendSeen(client, msg); 144 | if (cooldown.check(msg.room)) { 145 | let split = userRepo.split("/"); 146 | let user = split[0]; 147 | let project = split[1]; 148 | await client.sendText( 149 | msg.room, 150 | `Seen! Your update will eventually appear on https://${user}.github.io/${project}` 151 | ); 152 | cooldown.didAnswer(msg.room); 153 | } 154 | } 155 | } 156 | 157 | module.exports = { 158 | handler, 159 | init, 160 | help: 161 | "Notes confessions on the mrgiggles/histoire repository. They'll eventually appear on https://mrgiggles.github.io/histoire.", 162 | }; 163 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { promisify } from "util"; 2 | import requestModule from "request"; 3 | import { MatrixClient } from "matrix-bot-sdk"; 4 | 5 | export interface Message { 6 | body: string; 7 | sender: string; 8 | room: string; 9 | event: object; 10 | } 11 | 12 | export interface ModuleHandler { 13 | // The function that's called when a message is received. 14 | handler: (client: MatrixClient, msg: Message, extra: object) => void; 15 | 16 | // An help message for the given module. 17 | help: string; 18 | 19 | // An initialization function that's passed the content of the config.json 20 | // file. 21 | init?: (config: object) => void; 22 | } 23 | 24 | export function assert(test: boolean, msg: string): asserts test { 25 | if (!test) { 26 | throw new Error("assertion error: " + msg); 27 | } 28 | } 29 | 30 | export const request = promisify(requestModule); 31 | 32 | export async function requestJson(url) { 33 | let options = { 34 | uri: url, 35 | headers: { 36 | accept: "application/json", 37 | }, 38 | }; 39 | let response = await request(options); 40 | return JSON.parse(response.body); 41 | } 42 | 43 | let aliasCache = {}; 44 | export async function getRoomAlias(client: MatrixClient, roomId) { 45 | // TODO make this a service accessible to all the modules. This finds 46 | // possible aliases for a given roomId. 47 | let roomAlias = aliasCache[roomId]; 48 | if (!roomAlias) { 49 | try { 50 | let resp = await client.getRoomStateEvent( 51 | roomId, 52 | "m.room.canonical_alias", 53 | "" 54 | ); 55 | if (resp && resp.alias) { 56 | let alias = resp.alias; 57 | let resolvedRoomId = await client.resolveRoom(alias); 58 | if (resolvedRoomId === roomId) { 59 | aliasCache[roomId] = alias; 60 | roomAlias = alias; 61 | } 62 | } 63 | } catch (err) { 64 | // Ignore. 65 | } 66 | } 67 | // May be undefined. 68 | return roomAlias; 69 | } 70 | 71 | let reactionId = 0; 72 | export async function sendReaction(client: MatrixClient, msg, emoji = "👀") { 73 | let encodedRoomId = encodeURIComponent(msg.room); 74 | 75 | let body = { 76 | "m.relates_to": { 77 | rel_type: "m.annotation", 78 | event_id: msg.event.event_id, 79 | key: emoji, 80 | }, 81 | }; 82 | 83 | let now = (Date.now() / 1000) | 0; 84 | let transactionId = now + "_botzilla_emoji" + reactionId++; 85 | let resp = await client.doRequest( 86 | "PUT", 87 | `/_matrix/client/r0/rooms/${encodedRoomId}/send/m.reaction/${transactionId}`, 88 | null, // qs 89 | body 90 | ); 91 | } 92 | 93 | export async function sendThumbsUp(client: MatrixClient, msg) { 94 | return sendReaction(client, msg, "👍️"); 95 | } 96 | export async function sendSeen(client: MatrixClient, msg) { 97 | return sendReaction(client, msg, "👀"); 98 | } 99 | 100 | async function isMatrixAdmin( 101 | client: MatrixClient, 102 | roomId: string, 103 | userId: string 104 | ): Promise { 105 | let powerLevels = await client.getRoomStateEvent( 106 | roomId, 107 | "m.room.power_levels", 108 | "" 109 | ); 110 | return ( 111 | typeof powerLevels.users !== "undefined" && 112 | typeof powerLevels.users[userId] === "number" && 113 | powerLevels.users[userId] >= 50 114 | ); 115 | } 116 | 117 | export function isSuperAdmin(userId, extra) { 118 | return extra.owner === userId; 119 | } 120 | 121 | export async function isAdmin( 122 | client: MatrixClient, 123 | roomId: string, 124 | userId: string, 125 | extra 126 | ): Promise { 127 | return ( 128 | isSuperAdmin(userId, extra) || (await isMatrixAdmin(client, roomId, userId)) 129 | ); 130 | } 131 | 132 | interface CooldownEntry { 133 | timer: NodeJS.Timeout | null; 134 | numMessages: number; 135 | } 136 | 137 | export class Cooldown { 138 | timeout: number; 139 | numMessages: number; 140 | map: { [roomId: string]: CooldownEntry }; 141 | 142 | constructor(timeout, numMessages) { 143 | this.timeout = timeout; 144 | this.numMessages = numMessages; 145 | this.map = {}; 146 | } 147 | 148 | _ensureEntry(key) { 149 | if (typeof this.map[key] === "undefined") { 150 | this.map[key] = { 151 | numMessages: 0, 152 | timer: null, 153 | }; 154 | } 155 | return this.map[key]; 156 | } 157 | 158 | didAnswer(key) { 159 | let entry = this._ensureEntry(key); 160 | if (this.timeout !== null) { 161 | if (entry.timer !== null) { 162 | clearTimeout(entry.timer); 163 | } 164 | entry.timer = setTimeout(() => { 165 | entry.timer = null; 166 | }, this.timeout); 167 | } 168 | if (this.numMessages !== null) { 169 | entry.numMessages = this.numMessages; 170 | } 171 | } 172 | 173 | onNewMessage(key) { 174 | if (this.numMessages !== null) { 175 | let entry = this._ensureEntry(key); 176 | if (entry.numMessages > 0) { 177 | entry.numMessages -= 1; 178 | } 179 | } 180 | } 181 | 182 | check(key) { 183 | let entry = this._ensureEntry(key); 184 | return entry.timer === null && entry.numMessages === 0; 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "tests", 4 | "build", 5 | ], 6 | "compilerOptions": { 7 | /* Basic Options */ 8 | "target": "es2018", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ 9 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 10 | "lib": ["es6"], /* Specify library files to be included in the compilation. */ 11 | "allowJs": false, /* Allow javascript files to be compiled. */ 12 | "checkJs": false, /* Report errors in .js files. */ 13 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 14 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "./build", /* Redirect output structure to the directory. */ 18 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true, /* Enable all strict type-checking options. */ 27 | "noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */ 28 | // "strictNullChecks": true, /* Enable strict null checks. */ 29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 31 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 32 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 33 | 34 | /* Additional Checks */ 35 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 36 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 37 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 38 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 39 | 40 | /* Module Resolution Options */ 41 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 42 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 43 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 44 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 45 | // "typeRoots": [], /* List of folders to include type definitions from. */ 46 | "types": ["node"], /* Type declaration files to be included in compilation. */ 47 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 48 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 49 | //"resolveJsonModule": true, 50 | "downlevelIteration": true, 51 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 52 | 53 | /* Source Map Options */ 54 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 55 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 56 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 57 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 58 | 59 | /* Experimental Options */ 60 | "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 61 | "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 62 | "skipLibCheck": true 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/modules/admin.ts: -------------------------------------------------------------------------------- 1 | import * as settings from "../settings"; 2 | import * as utils from "../utils"; 3 | import { ModuleHandler } from "../utils"; 4 | import { MatrixClient } from "matrix-bot-sdk"; 5 | 6 | // All the admin commands must start with !admin. 7 | const ENABLE_REGEXP = /!admin (enable|disable)(-all)? ([a-zA-Z-]+)/g; 8 | 9 | // set MODULE_NAME KEY VALUE 10 | const SET_REGEXP = /!admin set(-all)? ([a-zA-Z-]+) ([a-zA-Z-_]+) (.*)/g; 11 | // get MODULE_NAME KEY 12 | const GET_REGEXP = /!admin get(-all)? ([a-zA-Z-]+) ([a-zA-Z-_]+)/g; 13 | 14 | function moduleExists(moduleName, extra) { 15 | return extra.handlerNames.indexOf(moduleName) !== -1; 16 | } 17 | 18 | async function tryEnable(client: MatrixClient, msg: utils.Message, extra) { 19 | ENABLE_REGEXP.lastIndex = 0; 20 | 21 | let match = ENABLE_REGEXP.exec(msg.body); 22 | if (match === null) { 23 | return false; 24 | } 25 | 26 | let room; 27 | if (typeof match[2] !== "undefined") { 28 | if (!utils.isSuperAdmin(msg.sender, extra)) { 29 | return true; 30 | } 31 | room = "*"; 32 | } else { 33 | room = msg.room; 34 | } 35 | 36 | let enabled = match[1] === "enable"; 37 | let moduleName = match[3]; 38 | 39 | if (!moduleExists(moduleName, extra)) { 40 | client.sendText(msg.room, "Unknown module."); 41 | return true; 42 | } 43 | 44 | await settings.enableModule(room, moduleName, enabled); 45 | await utils.sendThumbsUp(client, msg); 46 | return true; 47 | } 48 | 49 | async function tryList(client: MatrixClient, msg: utils.Message, extra) { 50 | if (msg.body.trim() !== "!admin list") { 51 | return false; 52 | } 53 | let response = extra.handlerNames.join(", "); 54 | client.sendText(msg.room, response); 55 | return true; 56 | } 57 | 58 | function enabledModulesInRoom(status, roomId): string | null { 59 | let enabledModules = Object.keys(status[roomId]) 60 | .map((key) => { 61 | if ( 62 | typeof status[roomId] !== "undefined" && 63 | typeof status[roomId][key] === "object" && 64 | typeof status[roomId][key].enabled !== "undefined" 65 | ) { 66 | return status[roomId][key].enabled ? key : "!" + key; 67 | } 68 | return undefined; 69 | }) 70 | .filter((x) => x !== undefined); 71 | 72 | if (!enabledModules.length) { 73 | return null; 74 | } 75 | let enabledModulesString = enabledModules.join(", "); 76 | return enabledModulesString; 77 | } 78 | 79 | async function tryEnabledStatus( 80 | client: MatrixClient, 81 | msg: utils.Message, 82 | extra 83 | ) { 84 | if (msg.body.trim() !== "!admin status") { 85 | return false; 86 | } 87 | 88 | let response = ""; 89 | 90 | let status = await settings.getSettings(); 91 | 92 | if (utils.isSuperAdmin(msg.sender, extra)) { 93 | // For the super admin, include information about all the rooms. 94 | for (const roomId in status) { 95 | let roomText; 96 | if (roomId === "*") { 97 | roomText = "all"; 98 | } else { 99 | roomText = await utils.getRoomAlias(client, roomId); 100 | if (!roomText) { 101 | roomText = roomId; 102 | } 103 | } 104 | 105 | let enabledModulesString = enabledModulesInRoom(status, roomId); 106 | if (enabledModulesString === null) { 107 | continue; 108 | } 109 | response += `${roomText}: ${enabledModulesString}\n`; 110 | } 111 | } else { 112 | // Only include information about this room. 113 | if (msg.room in status) { 114 | let roomText = await utils.getRoomAlias(client, msg.room); 115 | if (!roomText) { 116 | roomText = msg.room; 117 | } 118 | 119 | let enabledModulesString = enabledModulesInRoom(status, msg.room); 120 | if (enabledModulesString !== null) { 121 | response += `${roomText}: ${enabledModulesString}\n`; 122 | } 123 | } 124 | } 125 | 126 | if (!response.length) { 127 | return true; 128 | } 129 | 130 | client.sendText(msg.room, response); 131 | return true; 132 | } 133 | 134 | async function trySet(client: MatrixClient, msg: utils.Message, extra) { 135 | SET_REGEXP.lastIndex = 0; 136 | 137 | let match = SET_REGEXP.exec(msg.body); 138 | if (match === null) { 139 | return false; 140 | } 141 | 142 | let roomId, whichRoom; 143 | if (typeof match[1] !== "undefined") { 144 | if (!utils.isSuperAdmin(msg.sender, extra)) { 145 | return true; 146 | } 147 | roomId = "*"; 148 | } else { 149 | roomId = msg.room; 150 | } 151 | 152 | let moduleName = match[2]; 153 | if (!moduleExists(moduleName, extra)) { 154 | client.sendText(msg.room, "Unknown module"); 155 | return true; 156 | } 157 | 158 | let key = match[3]; 159 | let value = match[4]; 160 | await settings.setOption(roomId, moduleName, key, value); 161 | 162 | await utils.sendThumbsUp(client, msg); 163 | return true; 164 | } 165 | 166 | async function tryGet(client: MatrixClient, msg: utils.Message, extra) { 167 | GET_REGEXP.lastIndex = 0; 168 | 169 | let match = GET_REGEXP.exec(msg.body); 170 | if (match === null) { 171 | return false; 172 | } 173 | 174 | let roomId, whichRoom; 175 | if (typeof match[1] !== "undefined") { 176 | if (!utils.isSuperAdmin(msg.sender, extra)) { 177 | return true; 178 | } 179 | roomId = "*"; 180 | whichRoom = "all the rooms"; 181 | } else { 182 | roomId = msg.room; 183 | whichRoom = "this room"; 184 | } 185 | 186 | let moduleName = match[2]; 187 | if (!moduleExists(moduleName, extra)) { 188 | client.sendText(msg.room, "Unknown module"); 189 | return true; 190 | } 191 | 192 | let key = match[3]; 193 | let read = await settings.getOption(msg.room, moduleName, key); 194 | client.sendText(msg.room, `${key}'s' value in ${whichRoom} is ${read}`); 195 | return true; 196 | } 197 | 198 | async function handler( 199 | client: MatrixClient, 200 | msg: utils.Message, 201 | extra: object 202 | ) { 203 | if (!msg.body.startsWith("!admin")) { 204 | return; 205 | } 206 | if (!(await utils.isAdmin(client, msg.room, msg.sender, extra))) { 207 | return; 208 | } 209 | if (await tryEnable(client, msg, extra)) { 210 | return; 211 | } 212 | if (await tryEnabledStatus(client, msg, extra)) { 213 | return; 214 | } 215 | if (await tryList(client, msg, extra)) { 216 | return; 217 | } 218 | if (await trySet(client, msg, extra)) { 219 | return; 220 | } 221 | if (await tryGet(client, msg, extra)) { 222 | return; 223 | } 224 | client.sendText( 225 | msg.room, 226 | "unknown admin command; possible commands are: 'enable|disable|enable-all|disable-all|list|status|set|get.'" 227 | ); 228 | } 229 | 230 | const AdminModule: ModuleHandler = { 231 | handler, 232 | help: `Helps administrator configure the current Botzilla instance. 233 | Possible commands are: enable (module)|disable (module)|enable-all (module)|disable-all (module)|list|status|set|get`, 234 | }; 235 | 236 | module.exports = AdminModule; 237 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MatrixClient, 3 | SimpleFsStorageProvider, 4 | AutojoinRoomsMixin, 5 | LogService, 6 | LogLevel, 7 | } from "matrix-bot-sdk"; 8 | 9 | import * as fs from "fs"; 10 | import * as path from "path"; 11 | import * as util from "util"; 12 | 13 | import * as settings from "./settings"; 14 | import { Message } from "./utils"; 15 | import AutojoinUpgradedRooms from "./autojoin-upgraded-rooms"; 16 | 17 | let fsReadDir = util.promisify(fs.readdir); 18 | 19 | // A place where to store the client, for cleanup purposes. 20 | let CLIENT: MatrixClient | null = null; 21 | 22 | let roomJoinedAt: { [roomId: string]: number } = {}; 23 | 24 | interface Handler { 25 | moduleName: string; 26 | handler: () => any; 27 | } 28 | 29 | async function loadConfig(fileName: string) { 30 | let config = JSON.parse(fs.readFileSync(fileName).toString()); 31 | 32 | const handlers: Handler[] = []; 33 | const handlerNames: string[] = []; 34 | const helpMessages = {}; 35 | 36 | let moduleNames = await fsReadDir(path.join(__dirname, "modules")); 37 | moduleNames = moduleNames.map((filename) => filename.split(".js")[0]); 38 | 39 | for (let moduleName of moduleNames) { 40 | let mod = require("./" + path.join("modules", moduleName)); 41 | if (mod.init) { 42 | await mod.init(config); 43 | } 44 | handlerNames.push(moduleName); 45 | handlers.push({ 46 | moduleName, 47 | handler: mod.handler, 48 | }); 49 | helpMessages[moduleName] = mod.help || "No help for this module."; 50 | } 51 | 52 | return { 53 | homeserverUrl: config.homeserver, 54 | accessToken: config.accessToken, 55 | handlers, 56 | extra: { 57 | handlerNames, 58 | helpMessages, 59 | owner: config.owner, 60 | logLevel: config.logLevel || "warn", 61 | }, 62 | }; 63 | } 64 | 65 | function makeHandleCommand(client, config) { 66 | let startTime = Date.now(); 67 | return async function handleCommand(room, event) { 68 | console.log("Received event: ", JSON.stringify(event)); 69 | 70 | // Don't handle events that don't have contents (they were probably redacted) 71 | let content = event.content; 72 | if (!content) { 73 | return; 74 | } 75 | 76 | // Ignore messages published before we started or joined the room. 77 | let eventTime = parseInt(event.origin_server_ts); 78 | if (eventTime < startTime) { 79 | return; 80 | } 81 | if ( 82 | typeof roomJoinedAt[room] === "number" && 83 | eventTime < roomJoinedAt[room] 84 | ) { 85 | return; 86 | } 87 | 88 | // Don't handle non-text events 89 | if (content.msgtype !== "m.text") { 90 | return; 91 | } 92 | 93 | // Make sure that the event looks like a command we're expecting 94 | let body = content.body; 95 | if (!body) { 96 | return; 97 | } 98 | 99 | // Strip answer content in replies. 100 | if (typeof content["m.relates_to"] !== "undefined") { 101 | if (typeof content["m.relates_to"]["m.in_reply_to"] !== "undefined") { 102 | let lines = body.split("\n"); 103 | while ( 104 | lines.length && 105 | (lines[0].startsWith("> ") || !lines[0].trim().length) 106 | ) { 107 | lines.shift(); 108 | } 109 | body = lines.join("\n"); 110 | } 111 | } 112 | 113 | // Filter out events sent by the bot itself. 114 | let sender = event.sender; 115 | if (sender === (await client.getUserId())) { 116 | return; 117 | } 118 | 119 | body = body.trim(); 120 | if (!body.length) { 121 | return; 122 | } 123 | 124 | let msg: Message = { 125 | body, 126 | sender, 127 | room, 128 | event, 129 | }; 130 | 131 | for (let { moduleName, handler } of config.handlers) { 132 | let extra = Object.assign({}, config.extra); 133 | 134 | if (moduleName !== "admin") { 135 | let enabled = await settings.isModuleEnabled(room, moduleName); 136 | if (!enabled) { 137 | continue; 138 | } 139 | } 140 | 141 | try { 142 | await handler(client, msg, extra); 143 | } catch (err) { 144 | console.error("Handler error: ", err); 145 | } 146 | } 147 | }; 148 | } 149 | 150 | async function createClient(configFilename: string) { 151 | const config = await loadConfig(configFilename); 152 | 153 | switch (config.extra.logLevel) { 154 | case "trace": 155 | break; 156 | case "debug": 157 | LogService.setLevel(LogLevel.DEBUG); 158 | break; 159 | case "info": 160 | LogService.setLevel(LogLevel.INFO); 161 | break; 162 | case "warn": 163 | default: 164 | LogService.setLevel(LogLevel.WARN); 165 | break; 166 | case "error": 167 | LogService.setLevel(LogLevel.ERROR); 168 | break; 169 | } 170 | 171 | const prefix = configFilename.replace(".json", "").replace("config-", ""); 172 | 173 | const storageDir = path.join("data", prefix); 174 | if (!fs.existsSync("./data")) { 175 | fs.mkdirSync("./data"); 176 | } 177 | if (!fs.existsSync(storageDir)) { 178 | fs.mkdirSync(storageDir); 179 | } 180 | 181 | // We'll want to make sure the bot doesn't have to do an initial sync every 182 | // time it restarts, so we need to prepare a storage provider. Here we use 183 | // a simple JSON database. 184 | const storage = new SimpleFsStorageProvider( 185 | path.join(storageDir, "matrix.json") 186 | ); 187 | 188 | await require("./db").init(storageDir); 189 | 190 | // Now we can create the client and set it up to automatically join rooms. 191 | const client = new MatrixClient( 192 | config.homeserverUrl, 193 | config.accessToken, 194 | storage 195 | ); 196 | AutojoinRoomsMixin.setupOnClient(client); 197 | 198 | AutojoinUpgradedRooms.setupOnClient(client); 199 | 200 | client.on("room.join", (roomId: string) => { 201 | roomJoinedAt[roomId] = Date.now(); 202 | console.log("joined room", roomId, "at", roomJoinedAt[roomId]); 203 | }); 204 | 205 | // We also want to make sure we can receive events - this is where we will 206 | // handle our command. 207 | client.on("room.message", makeHandleCommand(client, config)); 208 | 209 | // Now that the client is all set up and the event handler is registered, start the 210 | // client up. This will start it syncing. 211 | await client.start(); 212 | console.log("Client started!"); 213 | 214 | CLIENT = client; 215 | await client.setPresenceStatus("online", "bot has been started"); 216 | } 217 | 218 | async function main() { 219 | let argv = process.argv; 220 | 221 | // Remove node executable name + script name. 222 | while (argv.length && argv[0] !== __filename) { 223 | argv = argv.splice(1); 224 | } 225 | argv = argv.splice(1); 226 | 227 | // Remove script name. 228 | const cliArgs = argv; 229 | 230 | for (let arg of cliArgs) { 231 | if (arg === "-h" || arg === "--help") { 232 | console.log(`USAGE: [cmd] CONFIG1.json CONFIG2.json 233 | 234 | -h, --help: Displays this message. 235 | 236 | CONFIG[n] files are config.json files based on config.json.example. 237 | `); 238 | process.exit(0); 239 | } 240 | } 241 | 242 | let configFilename = cliArgs.length ? cliArgs[0] : "config.json"; 243 | 244 | await createClient(configFilename); 245 | } 246 | 247 | // No top-level await, alright. 248 | main().catch((err) => { 249 | console.error("Error in main:", err.stack); 250 | }); 251 | 252 | function wait(ms) { 253 | return new Promise((ok) => { 254 | setTimeout(ok, ms); 255 | }); 256 | } 257 | 258 | async function exitHandler(options = {}) { 259 | if (CLIENT === null) { 260 | return; 261 | } 262 | 263 | let _client = CLIENT; 264 | CLIENT = null; 265 | 266 | console.log("setting bot presence to offline..."); 267 | 268 | _client.stop(); 269 | await wait(1000); 270 | await _client.setPresenceStatus("offline", "bot exited"); 271 | 272 | while (true) { 273 | let presence = (await _client.getPresenceStatus()).state; 274 | if (presence !== "online") { 275 | break; 276 | } 277 | console.log("waiting, status is", presence); 278 | await wait(11000); 279 | } 280 | console.log("Done, ciao!"); 281 | process.exit(0); 282 | } 283 | 284 | // Misc signals (Ctrl+C, kill, etc.). 285 | process.on("SIGINT", exitHandler); 286 | process.on("SIGHUP", exitHandler); 287 | process.on("SIGQUIT", exitHandler); 288 | process.on("SIGTERM", exitHandler); 289 | --------------------------------------------------------------------------------