├── 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(" ")}${tag}>`;
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 |
--------------------------------------------------------------------------------