diff --git a/config/config.sample.yaml b/config/config.sample.yaml index 10f6d2fb97e7f9ca24fff97f962683894245c11b..d000b2d3902c8bcc25326d68c1ac6426b613a4f1 100644 --- a/config/config.sample.yaml +++ b/config/config.sample.yaml @@ -31,6 +31,7 @@ bridge: disableJoinLeaveNotifications: false # Authentication configuration for the discord bot. auth: + # This MUST be a string (wrapped in quotes) clientID: "12345" botToken: "foobar" logging: @@ -85,10 +86,12 @@ channel: limits: # Delay in milliseconds between discord users joining a room. roomGhostJoinDelay: 6000 - # Delay in milliseconds before sending messages to discord to avoid echos. - # (Copies of a sent message may arrive from discord before we've + # Lock timeout in milliseconds before seinding messages to discord to avoid + # echos. Default is rather high as the lock will most likely time out + # before anyways. + # echos = (Copies of a sent message may arrive from discord before we've # fininished handling it, causing us to echo it back to the room) - discordSendDelay: 750 + discordSendDelay: 1500 ghosts: # Pattern for the ghosts nick, available is :nick, :username, :tag and :id nickPattern: ":nick" diff --git a/docs/howto.md b/docs/howto.md index cbdee791fc771018dbbf0481cb07da5d15c3357b..f43902f5b683228d426dabb541354ff0b4538d32 100644 --- a/docs/howto.md +++ b/docs/howto.md @@ -15,7 +15,7 @@ is formatted as https://discordapp.com/channels/``guildid``/``channelid`` * The ``adminme`` script is provided to set Admin/Moderator or any other custom power level to a specific user. * e.g. To set Alice to Admin on her ``example.com`` HS on default config. (``config.yaml``) - * ``npm run adminme -- -r '!AbcdefghijklmnopqR:example.com' -u '@Alice:example.com' -p '100'`` + * ``npm run adminme -- -m '!AbcdefghijklmnopqR:example.com' -u '@Alice:example.com' -p '100'`` * Run ``npm run adminme -- -h`` for usage. Please note that `!AbcdefghijklmnopqR:example.com` is the internal room id and will always begin with `!`. diff --git a/package-lock.json b/package-lock.json index 5b4d459714685ae6345e3623bf35dd2cdd120b2e..739385918256357755dbf2bcc3febc36e1b1751c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "matrix-appservice-discord", - "version": "0.5.0-rc2", + "version": "0.5.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -164,7 +164,7 @@ }, "@types/events": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@types/events/-/events-1.2.0.tgz", + "resolved": "http://registry.npmjs.org/@types/events/-/events-1.2.0.tgz", "integrity": "sha512-KEIlhXnIutzKwRbQkGWb/I4HFqBuUykAdHgDED6xqwXJfONCjF5VoE0cXEiurh3XauygxzeDzgtXUqvLkxFzzA==", "dev": true }, @@ -182,7 +182,7 @@ }, "@types/sqlite3": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/sqlite3/-/sqlite3-3.1.3.tgz", + "resolved": "http://registry.npmjs.org/@types/sqlite3/-/sqlite3-3.1.3.tgz", "integrity": "sha512-BgGToABnI/8/HnZtZz2Qac6DieU2Dm/j3rtbMmUlDVo4T/uLu8cuVfU/n2UkHowiiwXb6/7h/CmSqBIVKgcTMA==", "dev": true, "requires": { @@ -212,7 +212,7 @@ }, "acorn-jsx": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-3.0.1.tgz", + "resolved": "http://registry.npmjs.org/acorn-jsx/-/acorn-jsx-3.0.1.tgz", "integrity": "sha1-r9+UiPsezvyDSPb7IvRk4ypYs2s=", "dev": true, "requires": { @@ -221,7 +221,7 @@ "dependencies": { "acorn": { "version": "3.3.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz", + "resolved": "http://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz", "integrity": "sha1-ReN/s56No/JbruP/U2niu18iAXo=", "dev": true } @@ -266,7 +266,7 @@ }, "ansi-escapes": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz", + "resolved": "http://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz", "integrity": "sha1-06ioOzGapneTZisT52HHkRQiMG4=", "dev": true }, @@ -367,7 +367,7 @@ }, "async": { "version": "0.2.10", - "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "resolved": "http://registry.npmjs.org/async/-/async-0.2.10.tgz", "integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E=" }, "async-limiter": { @@ -602,7 +602,7 @@ }, "chai": { "version": "3.5.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-3.5.0.tgz", + "resolved": "http://registry.npmjs.org/chai/-/chai-3.5.0.tgz", "integrity": "sha1-TQJjewZ/6Vi9v906QOxW/vc3Mkc=", "dev": true, "requires": { @@ -888,7 +888,7 @@ }, "d": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.0.tgz", + "resolved": "http://registry.npmjs.org/d/-/d-1.0.0.tgz", "integrity": "sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8=", "dev": true, "requires": { @@ -919,7 +919,7 @@ }, "deep-eql": { "version": "0.1.3", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", + "resolved": "http://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", "integrity": "sha1-71WKyrjeJSBs1xOQbXTlaTDrafI=", "dev": true, "requires": { @@ -1000,12 +1000,11 @@ "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==" }, "discord-markdown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/discord-markdown/-/discord-markdown-2.0.0.tgz", - "integrity": "sha512-uiDNjFrMjAFK50Q+z7qI4siF6U3XBx6gZ6GywjrQnoiBmWK5d/wJDoIT051bOD4xXBHuSENXsReF9zBvbCjDGQ==", + "version": "git://github.com/Sorunome/discord-markdown.git#5086d4ccb10a90bcdc7656606f5b3f5430bffee9", + "from": "git://github.com/Sorunome/discord-markdown.git#5086d4ccb10a90bcdc7656606f5b3f5430bffee9", "requires": { "highlight.js": "^9.13.1", - "simple-markdown": "^0.4.2" + "simple-markdown": "^0.4.4" } }, "discord.js": { @@ -1051,7 +1050,7 @@ }, "enabled": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/enabled/-/enabled-1.0.2.tgz", + "resolved": "http://registry.npmjs.org/enabled/-/enabled-1.0.2.tgz", "integrity": "sha1-ll9lE9LC0cX0ZStkouM5ZGf8L5M=", "requires": { "env-variable": "0.0.x" @@ -1528,7 +1527,7 @@ }, "fecha": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fecha/-/fecha-2.3.3.tgz", + "resolved": "http://registry.npmjs.org/fecha/-/fecha-2.3.3.tgz", "integrity": "sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg==" }, "figures": { @@ -1571,7 +1570,7 @@ }, "finalhandler": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz", + "resolved": "http://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz", "integrity": "sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg==", "requires": { "debug": "2.6.9", @@ -1835,9 +1834,9 @@ "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0=" }, "highlight.js": { - "version": "9.13.1", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-9.13.1.tgz", - "integrity": "sha512-Sc28JNQNDzaH6PORtRLMvif9RSn1mYuOoX3omVjnb0+HbpPygU2ALBI0R/wsiqCb4/fcp07Gdo8g+fhtFrQl6A==" + "version": "9.15.8", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-9.15.8.tgz", + "integrity": "sha512-RrapkKQWwE+wKdF73VsOa2RQdIoO3mxwJ4P8mhbI6KYJUraUHRKM5w5zQQKXNk0xNL4UVRdulV9SBJcmzJNzVA==" }, "hosted-git-info": { "version": "2.7.1", @@ -1908,7 +1907,7 @@ }, "inquirer": { "version": "0.12.0", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-0.12.0.tgz", + "resolved": "http://registry.npmjs.org/inquirer/-/inquirer-0.12.0.tgz", "integrity": "sha1-HvK/1jUE3wvHV4X/+MLEHfEvB34=", "dev": true, "requires": { @@ -2104,7 +2103,7 @@ }, "async": { "version": "1.5.2", - "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "resolved": "http://registry.npmjs.org/async/-/async-1.5.2.tgz", "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", "dev": true }, @@ -2573,7 +2572,7 @@ }, "media-typer": { "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "resolved": "http://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" }, "mem": { @@ -2670,7 +2669,7 @@ }, "mkdirp": { "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "requires": { "minimist": "0.0.8" @@ -2817,9 +2816,9 @@ "dev": true }, "node-html-parser": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-1.1.11.tgz", - "integrity": "sha512-KOjvmbk0yWuy/cN8uqk6bVYS0Lue+jVWcLO/zmnCtz8FPXhj00apBN376FoM6QmFMMbJwXQdKf5ko6G1S6bnrw==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-1.1.15.tgz", + "integrity": "sha512-wFlLCOdUrnMTXrc/70dBg1G3YCunQZzsLXshaqQfGmT8Ffu6z9PeoaPIK5QvmDY/6yfBjOP1SDFASWq0ELBtDA==", "requires": { "he": "1.1.1" } @@ -2964,7 +2963,7 @@ }, "onetime": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", + "resolved": "http://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", "integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=", "dev": true }, @@ -3230,7 +3229,7 @@ }, "pify": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "resolved": "http://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", "dev": true }, @@ -3305,7 +3304,7 @@ }, "progress": { "version": "1.1.8", - "resolved": "https://registry.npmjs.org/progress/-/progress-1.1.8.tgz", + "resolved": "http://registry.npmjs.org/progress/-/progress-1.1.8.tgz", "integrity": "sha1-4mDHj2Fhzdmw5WzD4Khd4Xx6V74=", "dev": true }, @@ -3702,7 +3701,7 @@ }, "slice-ansi": { "version": "0.0.4", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz", + "resolved": "http://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz", "integrity": "sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU=", "dev": true }, @@ -3863,7 +3862,7 @@ }, "strip-ansi": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "resolved": "http://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "requires": { "ansi-regex": "^2.0.0" @@ -3897,7 +3896,7 @@ }, "table": { "version": "3.8.3", - "resolved": "https://registry.npmjs.org/table/-/table-3.8.3.tgz", + "resolved": "http://registry.npmjs.org/table/-/table-3.8.3.tgz", "integrity": "sha1-K7xULw/amGGnVdOUf+/Ys/UThV8=", "dev": true, "requires": { @@ -4057,7 +4056,7 @@ }, "through": { "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "resolved": "http://registry.npmjs.org/through/-/through-2.3.8.tgz", "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" }, "to-fast-properties": { diff --git a/package.json b/package.json index 60894a217ea7ef37461fb92a331d9ba5775a8a8d..8ad5a1e7ee9012cdc52cfcd82a1e913f794831f1 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "coverage": "tsc && nyc mocha", "build": "tsc", "start": "npm run-script build && node ./build/src/discordas.js -p 9005 -c config.yaml", + "debug": "npm run-script build && node --inspect ./build/src/discordas.js -p 9005 -c config.yaml", "addbot": "node ./build/tools/addbot.js", "adminme": "node ./build/tools/adminme.js", "usertool": "node ./build/tools/userClientTools.js", @@ -37,14 +38,14 @@ "better-sqlite3": "^5.0.1", "command-line-args": "^4.0.1", "command-line-usage": "^4.1.0", - "discord-markdown": "^2.0.0", + "discord-markdown": "git://github.com/Sorunome/discord-markdown#5086d4ccb10a90bcdc7656606f5b3f5430bffee9", "discord.js": "^11.5.1", "escape-html": "^1.0.3", "escape-string-regexp": "^1.0.5", "js-yaml": "^3.13.1", "matrix-appservice-bridge": "matrix-org/matrix-appservice-bridge#8a7288edf1d1d1d1395a83d330d836d9c9bf1e76", "mime": "^1.6.0", - "node-html-parser": "^1.1.11", + "node-html-parser": "^1.1.15", "p-queue": "^6.0.1", "pg-promise": "^8.5.1", "prom-client": "^11.3.0", diff --git a/src/bot.ts b/src/bot.ts index 7e4d5b5e5db9d2e49a77e7f1a40b009f4d16e17c..2cfc3c06d773c591be8d8778ecf1914d986437a8 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -45,6 +45,7 @@ import * as mime from "mime"; import { IMatrixEvent, IMatrixMediaInfo } from "./matrixtypes"; import { DiscordCommandHandler } from "./discordcommandhandler"; import { MetricPeg } from "./metrics"; +import { Lock } from "./structures/lock"; const log = new Log("DiscordBot"); @@ -56,6 +57,7 @@ const MATRIX_ICON_URL = "https://matrix.org/_matrix/media/r0/download/matrix.org class ChannelLookupResult { public channel: Discord.TextChannel; public botUser: boolean; + public canSendEmbeds: boolean; } interface IThirdPartyLookupField { @@ -89,8 +91,7 @@ export class DiscordBot { /* Handles messages queued up to be sent to matrix from discord. */ private discordMessageQueue: { [channelId: string]: Promise<void> }; - private channelLocks: Map<string, {i: NodeJS.Timeout|null, r: (() => void)|null}>; - private channelLockPromises: Map<string, Promise<{}>>; + private channelLock: Lock<string>; constructor( private botUserId: string, private config: DiscordBridgeConfig, @@ -114,8 +115,7 @@ export class DiscordBot { // init vars this.sentMessages = []; this.discordMessageQueue = {}; - this.channelLocks = new Map(); - this.channelLockPromises = new Map(); + this.channelLock = new Lock(this.config.limits.discordSendDelay); this.lastEventIds = {}; } @@ -147,43 +147,6 @@ export class DiscordBot { return this.provisioner; } - public lockChannel(channel: Discord.Channel) { - if (this.channelLocks.has(channel.id)) { - return; - } - - this.channelLocks[channel.id] = {i: null, r: null, p: null}; - const p = new Promise<{}>((resolve) => { - const i = setTimeout(() => { - log.warn(`Lock on channel ${channel.id} expired. Discord is lagging behind?`); - this.unlockChannel(channel); - }, this.config.limits.discordSendDelay); - const o = Object.assign({r: resolve, i, p: null}, this.channelLocks.get(channel.id) || {}); - this.channelLocks.set(channel.id, o); - }); - this.channelLockPromises.set(channel.id, p); - } - - public unlockChannel(channel: Discord.Channel) { - if (!this.channelLocks.has(channel.id)) { - return; - } - const lock = this.channelLocks.get(channel.id)!; - if (lock.i !== null) { - lock.r!(); - clearTimeout(lock.i); - } - this.channelLocks.delete(channel.id); - this.channelLockPromises.delete(channel.id); - } - - public async waitUnlock(channel: Discord.Channel) { - const promise = this.channelLockPromises.get(channel.id); - if (promise) { - await promise; - } - } - public GetIntentFromDiscordMember(member: Discord.GuildMember | Discord.User, webhookID?: string): Intent { if (webhookID) { // webhookID and user IDs are the same, they are unique, so no need to prefix _webhook_ @@ -247,7 +210,7 @@ export class DiscordBot { client.on("messageDelete", async (msg: Discord.Message) => { try { - await this.waitUnlock(msg.channel); + await this.channelLock.wait(msg.channel.id); this.clientFactory.bindMetricsToChannel(msg.channel as Discord.TextChannel); this.discordMessageQueue[msg.channel.id] = (async () => { await (this.discordMessageQueue[msg.channel.id] || Promise.resolve()); @@ -268,7 +231,7 @@ export class DiscordBot { msgs.forEach((msg) => { promiseArr.push(async () => { try { - await this.waitUnlock(msg.channel); + await this.channelLock.wait(msg.channel.id); this.clientFactory.bindMetricsToChannel(msg.channel as Discord.TextChannel); await this.DeleteDiscordMessage(msg); } catch (err) { @@ -283,7 +246,7 @@ export class DiscordBot { }); client.on("messageUpdate", async (oldMessage: Discord.Message, newMessage: Discord.Message) => { try { - await this.waitUnlock(newMessage.channel); + await this.channelLock.wait(newMessage.channel.id); this.clientFactory.bindMetricsToChannel(newMessage.channel as Discord.TextChannel); this.discordMessageQueue[newMessage.channel.id] = (async () => { await (this.discordMessageQueue[newMessage.channel.id] || Promise.resolve()); @@ -300,7 +263,7 @@ export class DiscordBot { client.on("message", async (msg: Discord.Message) => { try { MetricPeg.get.registerRequest(msg.id); - await this.waitUnlock(msg.channel); + await this.channelLock.wait(msg.channel.id); this.clientFactory.bindMetricsToChannel(msg.channel as Discord.TextChannel); this.discordMessageQueue[msg.channel.id] = (async () => { await (this.discordMessageQueue[msg.channel.id] || Promise.resolve()); @@ -407,6 +370,7 @@ export class DiscordBot { const lookupResult = new ChannelLookupResult(); lookupResult.channel = channel as Discord.TextChannel; lookupResult.botUser = this.bot.user.id === client.user.id; + lookupResult.canSendEmbeds = client.user.bot; // only bots can send embeds return lookupResult; } throw new Error(`Channel "${room}" not found`); @@ -424,10 +388,10 @@ export class DiscordBot { if (!msg) { return; } - this.lockChannel(channel); + this.channelLock.set(channel.id); const res = await channel.send(msg); await this.StoreMessagesSent(res, channel, event); - this.unlockChannel(channel); + this.channelLock.release(channel.id); } /** @@ -462,19 +426,56 @@ export class DiscordBot { } } try { - this.lockChannel(chan); - if (!botUser) { - // NOTE: Don't send replies to discord if we are a puppet. + this.channelLock.set(chan.id); + if (!roomLookup.canSendEmbeds) { + // NOTE: Don't send replies to discord if we are a puppet user. + let addText = ""; + if (embedSet.replyEmbed) { + for (const line of embedSet.replyEmbed.description!.split("\n")) { + addText += "\n> " + line; + } + } + msg = await chan.send(embed.description + addText, opts); + } else if (!botUser) { + if (embedSet.imageEmbed || embedSet.replyEmbed) { + let sendEmbed = new Discord.RichEmbed(); + if (embedSet.imageEmbed) { + if (!embedSet.replyEmbed) { + sendEmbed = embedSet.imageEmbed; + } else { + sendEmbed.setImage(embedSet.imageEmbed.image!.url); + } + } + if (embedSet.replyEmbed) { + if (!embedSet.imageEmbed) { + sendEmbed = embedSet.replyEmbed; + } else { + sendEmbed.addField("Replying to", embedSet.replyEmbed!.author!.name); + sendEmbed.addField("Reply text", embedSet.replyEmbed.description); + } + } + opts.embed = sendEmbed; + } msg = await chan.send(embed.description, opts); } else if (hook) { MetricPeg.get.remoteCall("hook.send"); + const embeds: Discord.RichEmbed[] = []; + if (embedSet.imageEmbed) { + embeds.push(embedSet.imageEmbed); + } + if (embedSet.replyEmbed) { + embeds.push(embedSet.replyEmbed); + } msg = await hook.send(embed.description, { avatarURL: embed!.author!.icon_url, - embeds: embedSet.replyEmbed ? [embedSet.replyEmbed] : undefined, + embeds, files: opts.file ? [opts.file] : undefined, username: embed!.author!.name, } as Discord.WebhookMessageOptions); } else { + if (embedSet.imageEmbed) { + embed.setImage(embedSet.imageEmbed.image!.url); + } if (embedSet.replyEmbed) { embed.addField("Replying to", embedSet.replyEmbed!.author!.name); embed.addField("Reply text", embedSet.replyEmbed.description); @@ -482,8 +483,13 @@ export class DiscordBot { opts.embed = embed; msg = await chan.send("", opts); } - await this.StoreMessagesSent(msg, chan, event); - this.unlockChannel(chan); + // Don't block on this. + this.StoreMessagesSent(msg, chan, event).then(() => { + this.channelLock.release(chan.id); + }).catch(() => { + log.warn("Failed to store sent message for ", event.event_id); + }); + } catch (err) { throw wrapError(err, Unstable.ForeignNetworkError, "Couldn't send message"); } @@ -510,9 +516,9 @@ export class DiscordBot { const msg = await chan.fetchMessage(storeEvent.DiscordId); try { - this.lockChannel(msg.channel); + this.channelLock.set(msg.channel.id); await msg.delete(); - this.unlockChannel(msg.channel); + this.channelLock.release(msg.channel.id); log.info(`Deleted message`); } catch (ex) { log.warn(`Failed to delete message`, ex); @@ -664,12 +670,12 @@ export class DiscordBot { /* tslint:disable-next-line no-any */ } as any, // XXX: Discord.js typings are wrong. `Unbanned.`); - this.lockChannel(botChannel); + this.channelLock.set(botChannel.id); res = await botChannel.send( `${kickee} was unbanned from this channel by ${kicker}.`, ) as Discord.Message; this.sentMessages.push(res.id); - this.unlockChannel(botChannel); + this.channelLock.release(botChannel.id); return; } const existingPerms = tchan.memberPermissions(kickee); @@ -678,13 +684,13 @@ export class DiscordBot { return; } const word = `${kickban === "ban" ? "banned" : "kicked"}`; - this.lockChannel(botChannel); + this.channelLock.set(botChannel.id); res = await botChannel.send( `${kickee} was ${word} from this channel by ${kicker}.` + (reason ? ` Reason: ${reason}` : ""), ) as Discord.Message; this.sentMessages.push(res.id); - this.unlockChannel(botChannel); + this.channelLock.release(botChannel.id); log.info(`${word} ${kickee}`); await tchan.overwritePermissions(kickee, @@ -787,7 +793,7 @@ export class DiscordBot { } // Update presence because sometimes discord misses people. - await this.userSync.OnUpdateUser(msg.author, msg.webhookID); + await this.userSync.OnUpdateUser(msg.author, Boolean(msg.webhookID)); let rooms; try { rooms = await this.channelSync.GetRoomIdsFromChannel(msg.channel); @@ -871,10 +877,10 @@ export class DiscordBot { log.error("Failed to send message into room.", e); return; } - if (msg.member) { + if (msg.member && !msg.webhookID) { await this.userSync.JoinRoom(msg.member, room); } else { - await this.userSync.JoinRoom(msg.author, room, msg.webhookID); + await this.userSync.JoinRoom(msg.author, room, Boolean(msg.webhookID)); } res = await trySend(); await afterSend(res); diff --git a/src/config.ts b/src/config.ts index 2da5b7734e721eca3d77126750cf16957349deb4..9d966b544b8f4dee85f80121e30da15c1bec477b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -133,7 +133,7 @@ export class DiscordBridgeConfigChannelDeleteOptions { class DiscordBridgeConfigLimits { public roomGhostJoinDelay: number = 6000; - public discordSendDelay: number = 750; + public discordSendDelay: number = 1500; } export class LoggingFile { diff --git a/src/db/roomstore.ts b/src/db/roomstore.ts index 2cb9d9f0747244c069a3538a9480d966601233f8..ed311a8ca450258f1aeb83fd2d7fb33ba37cc293 100644 --- a/src/db/roomstore.ts +++ b/src/db/roomstore.ts @@ -18,8 +18,8 @@ import { IDatabaseConnector } from "./connector"; import { Util } from "../util"; import * as uuid from "uuid/v4"; -import { Postgres } from "./postgres"; import { MetricPeg } from "../metrics"; +import { TimedCache } from "../structures/timedcache"; const log = new Log("DbRoomStore"); @@ -93,9 +93,9 @@ const ENTRY_CACHE_LIMETIME = 30000; // XXX: This only implements functions used in the bridge at the moment. export class DbRoomStore { - private entriesMatrixIdCache: Map<string, {e: IRoomStoreEntry[], ts: number}>; + private entriesMatrixIdCache: TimedCache<string, IRoomStoreEntry[]>; constructor(private db: IDatabaseConnector) { - this.entriesMatrixIdCache = new Map(); + this.entriesMatrixIdCache = new TimedCache(ENTRY_CACHE_LIMETIME); } public async upsertEntry(entry: IRoomStoreEntry) { @@ -155,9 +155,9 @@ export class DbRoomStore { public async getEntriesByMatrixId(matrixId: string): Promise<IRoomStoreEntry[]> { const cached = this.entriesMatrixIdCache.get(matrixId); - if (cached && cached.ts + ENTRY_CACHE_LIMETIME > Date.now()) { + if (cached) { MetricPeg.get.storeCall("RoomStore.getEntriesByMatrixId", true); - return cached.e; + return cached; } MetricPeg.get.storeCall("RoomStore.getEntriesByMatrixId", false); const entries = await this.db.All( @@ -187,7 +187,7 @@ export class DbRoomStore { } } if (res.length > 0) { - this.entriesMatrixIdCache.set(matrixId, {e: res, ts: Date.now()}); + this.entriesMatrixIdCache.set(matrixId, res); } return res; } diff --git a/src/db/userstore.ts b/src/db/userstore.ts index 0f307d6158e527ea79dde544fd9dea8e2771add6..7e84dc0a84faf4767a188349e11236ef34586d9c 100644 --- a/src/db/userstore.ts +++ b/src/db/userstore.ts @@ -17,6 +17,7 @@ limitations under the License. import { IDatabaseConnector } from "./connector"; import { Log } from "../log"; import { MetricPeg } from "../metrics"; +import { TimedCache } from "../structures/timedcache"; /** * A UserStore compatible with @@ -45,17 +46,17 @@ export interface IUserStoreEntry { } export class DbUserStore { - private remoteUserCache: Map<string, {e: RemoteUser, ts: number}>; + private remoteUserCache: TimedCache<string, RemoteUser>; constructor(private db: IDatabaseConnector) { - this.remoteUserCache = new Map(); + this.remoteUserCache = new TimedCache(ENTRY_CACHE_LIMETIME); } public async getRemoteUser(remoteId: string): Promise<RemoteUser|null> { const cached = this.remoteUserCache.get(remoteId); - if (cached && cached.ts + ENTRY_CACHE_LIMETIME > Date.now()) { + if (cached) { MetricPeg.get.storeCall("UserStore.getRemoteUser", true); - return cached.e; + return cached; } MetricPeg.get.storeCall("UserStore.getRemoteUser", false); @@ -84,7 +85,7 @@ export class DbUserStore { remoteUser.guildNicks.set(guild_id as string, nick as string); }); } - this.remoteUserCache.set(remoteId, {e: remoteUser, ts: Date.now()}); + this.remoteUserCache.set(remoteId, remoteUser); return remoteUser; } diff --git a/src/discordmessageprocessor.ts b/src/discordmessageprocessor.ts index 8e9dd555e7004de1bc79192ebdd165b0ef094573..f5da03b52eec659b652b5471ee5c86288995dbe6 100644 --- a/src/discordmessageprocessor.ts +++ b/src/discordmessageprocessor.ts @@ -50,6 +50,10 @@ export class DiscordMessageProcessorResult { public msgtype: string; } +interface ISpoilerNode { + content: string; +} + interface IDiscordNode { id: string; } @@ -79,6 +83,7 @@ export class DiscordMessageProcessor { // as else it'll HTML escape the result of the discord syntax let contentPostmark = markdown.toHTML(content, { discordCallback: this.getDiscordParseCallbacksHTML(msg), + noExtraSpanTags: true, }); // parse the plain text stuff @@ -86,6 +91,7 @@ export class DiscordMessageProcessor { discordCallback: this.getDiscordParseCallbacks(msg), discordOnly: true, escapeHTML: false, + noExtraSpanTags: true, }); content = this.InsertEmbeds(content, msg); content = await this.InsertMxcImages(content, msg); @@ -145,6 +151,7 @@ export class DiscordMessageProcessor { discordCallback: this.getDiscordParseCallbacks(msg), discordOnly: true, escapeHTML: false, + noExtraSpanTags: true, }); } if (embed.fields) { @@ -154,6 +161,7 @@ export class DiscordMessageProcessor { discordCallback: this.getDiscordParseCallbacks(msg), discordOnly: true, escapeHTML: false, + noExtraSpanTags: true, }); } } @@ -165,6 +173,7 @@ export class DiscordMessageProcessor { discordCallback: this.getDiscordParseCallbacks(msg), discordOnly: true, escapeHTML: false, + noExtraSpanTags: true, }); } content += embedContent; @@ -192,6 +201,7 @@ export class DiscordMessageProcessor { embedContent += markdown.toHTML(embed.description, { discordCallback: this.getDiscordParseCallbacksHTML(msg), embed: true, + noExtraSpanTags: true, }) + "</p>"; } if (embed.fields) { @@ -200,6 +210,7 @@ export class DiscordMessageProcessor { embedContent += markdown.toHTML(field.value, { discordCallback: this.getDiscordParseCallbacks(msg), embed: true, + noExtraSpanTags: true, }) + "</p>"; } } @@ -212,6 +223,7 @@ export class DiscordMessageProcessor { embedContent += markdown.toHTML(embed.footer.text, { discordCallback: this.getDiscordParseCallbacksHTML(msg), embed: true, + noExtraSpanTags: true, }) + "</p>"; } content += embedContent; @@ -230,6 +242,15 @@ export class DiscordMessageProcessor { return `<a href="${MATRIX_TO_LINK}${escapeHtml(memberId)}">${escapeHtml(memberName)}</a>`; } + public InsertSpoiler(node: ISpoilerNode, html: boolean = false): string { + // matrix spoilers are still in MSC stage + // see https://github.com/matrix-org/matrix-doc/pull/2010 + if (!html) { + return `(Spoiler: ${node.content})`; + } + return `<span data-mx-spoiler>${node.content}</span>`; + } + public InsertChannel(node: IDiscordNode): string { // unfortunately these callbacks are sync, so we flag our channel with some special stuff // and later on grab the real channel pill async @@ -333,6 +354,7 @@ export class DiscordMessageProcessor { everyone: (_) => this.InsertRoom(msg, "@everyone"), here: (_) => this.InsertRoom(msg, "@here"), role: (node) => this.InsertRole(node, msg), + spoiler: (node) => this.InsertSpoiler(node), user: (node) => this.InsertUser(node, msg), }; } @@ -344,6 +366,7 @@ export class DiscordMessageProcessor { everyone: (_) => this.InsertRoom(msg, "@everyone"), here: (_) => this.InsertRoom(msg, "@here"), role: (node) => this.InsertRole(node, msg, true), + spoiler: (node) => this.InsertSpoiler(node, true), user: (node) => this.InsertUser(node, msg, true), }; } diff --git a/src/matrixeventprocessor.ts b/src/matrixeventprocessor.ts index 025d353f06c81b62bc7ef9872af0e6ea3c1c9bb2..5c9efc3816eb4f1c30b8cc3987640e6189d7c0e3 100644 --- a/src/matrixeventprocessor.ts +++ b/src/matrixeventprocessor.ts @@ -35,6 +35,8 @@ import { MatrixMessageProcessor, IMatrixMessageProcessorParams } from "./matrixm import { MatrixCommandHandler } from "./matrixcommandhandler"; import { Log } from "./log"; +import { TimedCache } from "./structures/timedcache"; +import { MetricPeg } from "./metrics"; const log = new Log("MatrixEventProcessor"); const MaxFileSize = 8000000; @@ -44,6 +46,7 @@ const DISCORD_AVATAR_WIDTH = 128; const DISCORD_AVATAR_HEIGHT = 128; const ROOM_NAME_PARTS = 2; const AGE_LIMIT = 900000; // 15 * 60 * 1000 +const PROFILE_CACHE_LIFETIME = 900000; export class MatrixEventProcessorOpts { constructor( @@ -58,6 +61,7 @@ export class MatrixEventProcessorOpts { export interface IMatrixEventProcessorResult { messageEmbed: Discord.RichEmbed; replyEmbed?: Discord.RichEmbed; + imageEmbed?: Discord.RichEmbed; } export class MatrixEventProcessor { @@ -66,12 +70,14 @@ export class MatrixEventProcessor { private discord: DiscordBot; private matrixMsgProcessor: MatrixMessageProcessor; private mxCommandHandler: MatrixCommandHandler; + private mxUserProfileCache: TimedCache<string, {displayname: string, avatar_url: string|undefined}>; constructor(opts: MatrixEventProcessorOpts, cm?: MatrixCommandHandler) { this.config = opts.config; this.bridge = opts.bridge; this.discord = opts.discord; this.matrixMsgProcessor = new MatrixMessageProcessor(this.discord); + this.mxUserProfileCache = new TimedCache(PROFILE_CACHE_LIFETIME); if (cm) { this.mxCommandHandler = cm; } else { @@ -185,21 +191,24 @@ export class MatrixEventProcessor { log.verbose(`Looking up ${guildId}_${channelId}`); const roomLookup = await this.discord.LookupRoom(guildId, channelId, event.sender); const chan = roomLookup.channel; - const botUser = roomLookup.botUser; const embedSet = await this.EventToEmbed(event, chan); const opts: Discord.MessageOptions = {}; - const file = await this.HandleAttachment(event, mxClient); + const file = await this.HandleAttachment(event, mxClient, roomLookup.canSendEmbeds); if (typeof(file) === "string") { embedSet.messageEmbed.description += " " + file; + } else if ((file as Discord.FileOptions).name && (file as Discord.FileOptions).attachment) { + opts.file = file as Discord.FileOptions; } else { - opts.file = file; + embedSet.imageEmbed = file as Discord.RichEmbed; } // Throws an `Unstable.ForeignNetworkError` when sending the message fails. await this.discord.send(embedSet, opts, roomLookup, event); - await this.sendReadReceipt(event); + this.sendReadReceipt(event).catch((ex) => { + log.verbose("Failed to send read reciept for ", event.event_id, ex); + }); } public async ProcessStateEvent(event: IMatrixEvent) { @@ -219,7 +228,6 @@ export class MatrixEventProcessor { let msg = `\`${event.sender}\` `; - const isNew = event.unsigned === undefined || event.unsigned.prev_content === undefined; const allowJoinLeave = !this.config.bridge.disableJoinLeaveNotifications; if (event.type === "m.room.name") { @@ -228,7 +236,22 @@ export class MatrixEventProcessor { msg += `set the topic to \`${event.content!.topic}\``; } else if (event.type === "m.room.member") { const membership = event.content!.membership; - if (membership === "join" && isNew && allowJoinLeave) { + const intent = this.bridge.getIntent(); + const isNewJoin = event.unsigned.replaces_state === undefined ? true : ( + await intent.getEvent(event.room_id, event.unsigned.replaces_state)).content.membership !== "join"; + if (membership === "join") { + this.mxUserProfileCache.delete(`${event.room_id}:${event.sender}`); + this.mxUserProfileCache.delete(event.sender); + if (event.content!.displayname) { + this.mxUserProfileCache.set(`${event.room_id}:${event.sender}`, { + avatar_url: event.content!.avatar_url, + displayname: event.content!.displayname!, + }); + } + // We don't know if the user also updated their profile, but to be safe.. + this.mxUserProfileCache.delete(event.sender); + } + if (membership === "join" && isNewJoin && allowJoinLeave) { msg += "joined the room"; } else if (membership === "invite") { msg += `invited \`${event.state_key}\` to the room`; @@ -253,19 +276,7 @@ export class MatrixEventProcessor { event: IMatrixEvent, channel: Discord.TextChannel, getReply: boolean = true, ): Promise<IMatrixEventProcessorResult> { const mxClient = this.bridge.getClientFactory().getClientAs(); - let profile: IMatrixEvent | null = null; - try { - profile = await mxClient.getStateEvent(event.room_id, "m.room.member", event.sender); - if (!profile) { - profile = await mxClient.getProfileInfo(event.sender); - } - if (!profile) { - log.warn(`User ${event.sender} has no member state and no profile. That's odd.`); - } - } catch (err) { - log.warn(`Trying to fetch member state or profile for ${event.sender} failed`, err); - } - + const profile = await this.GetUserProfileForRoom(event.room_id, event.sender); const params = { mxClient, roomId: event.room_id, @@ -300,7 +311,11 @@ export class MatrixEventProcessor { }; } - public async HandleAttachment(event: IMatrixEvent, mxClient: MatrixClient): Promise<string|Discord.FileOptions> { + public async HandleAttachment( + event: IMatrixEvent, + mxClient: MatrixClient, + sendEmbeds: boolean = false, + ): Promise<string|Discord.FileOptions|Discord.RichEmbed> { if (!this.HasAttachment(event)) { return ""; } @@ -312,7 +327,7 @@ export class MatrixEventProcessor { if (!event.content.info) { // Fractal sends images without an info, which is technically allowed // but super unhelpful: https://gitlab.gnome.org/World/fractal/issues/206 - event.content.info = {size: 0}; + event.content.info = {mimetype: "", size: 0}; } if (!event.content.url) { @@ -333,6 +348,10 @@ export class MatrixEventProcessor { } as Discord.FileOptions; } } + if (sendEmbeds && event.content.info.mimetype.split("/")[0] === "image") { + return new Discord.RichEmbed() + .setImage(url); + } return `[${name}](${url})`; } @@ -390,6 +409,45 @@ export class MatrixEventProcessor { return embed; } + private async GetUserProfileForRoom(roomId: string, userId: string) { + const mxClient = this.bridge.getClientFactory().getClientAs(); + const intent = this.bridge.getIntent(); + let profile: {displayname: string, avatar_url: string|undefined} | undefined; + try { + // First try to pull out the room-specific profile from the cache. + profile = this.mxUserProfileCache.get(`${roomId}:${userId}`); + if (profile) { + return profile; + } + log.verbose(`Profile ${userId}:${roomId} not cached`); + + // Failing that, try fetching the state. + profile = await mxClient.getStateEvent(roomId, "m.room.member", userId); + if (profile) { + this.mxUserProfileCache.set(`${roomId}:${userId}`, profile); + return profile; + } + + // Try fetching the users profile from the cache + profile = this.mxUserProfileCache.get(userId); + if (profile) { + return profile; + } + + // Failing that, try fetching the profile. + log.verbose(`Profile ${userId} not cached`); + profile = await intent.getProfileInfo(userId); + if (profile) { + this.mxUserProfileCache.set(userId, profile); + return profile; + } + log.warn(`User ${userId} has no member state and no profile. That's odd.`); + } catch (err) { + log.warn(`Trying to fetch member state or profile for ${userId} failed`, err); + } + return undefined; + } + private async sendReadReceipt(event: IMatrixEvent) { if (!this.config.bridge.disableReadReceipts) { try { @@ -417,8 +475,9 @@ export class MatrixEventProcessor { return hasAttachment; } - private async SetEmbedAuthor(embed: Discord.RichEmbed, sender: string, profile?: IMatrixEvent | null) { - const intent = this.bridge.getIntent(); + private async SetEmbedAuthor(embed: Discord.RichEmbed, sender: string, profile?: { + displayname: string, + avatar_url: string|undefined }) { let displayName = sender; let avatarUrl; @@ -441,13 +500,6 @@ export class MatrixEventProcessor { } // Let it fall through. } - if (!profile) { - try { - profile = await intent.getProfileInfo(sender); - } catch (ex) { - log.warn(`Failed to fetch profile for ${sender}`, ex); - } - } if (profile) { if (profile.displayname && @@ -469,13 +521,17 @@ export class MatrixEventProcessor { } private GetFilenameForMediaEvent(content: IMatrixEventContent): string { + let ext = ""; + try { + ext = "." + mime.extension(content.info.mimetype); + } catch (err) { } // pass, we don't have an extension if (content.body) { if (path.extname(content.body) !== "") { return content.body; } - return `${path.basename(content.body)}.${mime.extension(content.info.mimetype)}`; + return path.basename(content.body) + ext; } - return "matrix-media." + mime.extension(content.info.mimetype); + return "matrix-media" + ext; } } diff --git a/src/matrixmessageprocessor.ts b/src/matrixmessageprocessor.ts index ff95b4588d3c221e75970a6fb70d7d05ec6eb90c..abace672562091218214c9af8dba02e50150589f 100644 --- a/src/matrixmessageprocessor.ts +++ b/src/matrixmessageprocessor.ts @@ -243,6 +243,21 @@ export class MatrixMessageProcessor { return msg; } + private async parseSpanContent(node: Parser.HTMLElement): Promise<string> { + const content = await this.walkChildNodes(node); + const attrs = node.attributes; + // matrix spoilers are still in MSC stage + // see https://github.com/matrix-org/matrix-doc/pull/2010 + if (attrs["data-mx-spoiler"] !== undefined) { + const spoilerReason = attrs["data-mx-spoiler"]; + if (spoilerReason) { + return `(${spoilerReason})||${content}||`; + } + return `||${content}||`; + } + return content; + } + private async parseUlContent(node: Parser.HTMLElement): Promise<string> { this.listDepth++; const entries = await this.arrayChildNodes(node, ["li"]); @@ -350,6 +365,8 @@ export class MatrixMessageProcessor { case "h6": const level = parseInt(nodeHtml.tagName[1], 10); return `**${"#".repeat(level)} ${await this.walkChildNodes(nodeHtml)}**\n`; + case "span": + return await this.parseSpanContent(nodeHtml); default: return await this.walkChildNodes(nodeHtml); } diff --git a/src/matrixtypes.ts b/src/matrixtypes.ts index 7535b59a89063a02e1f1946321550287f483dd94..f08ae1301c571fdbc866e7aafff94eed21e8229f 100644 --- a/src/matrixtypes.ts +++ b/src/matrixtypes.ts @@ -23,6 +23,7 @@ export interface IMatrixEventContent { msgtype?: string; url?: string; displayname?: string; + avatar_url?: string; reason?: string; "m.relates_to"?: any; // tslint:disable-line no-any } diff --git a/src/metrics.ts b/src/metrics.ts index db1556f3482c7526ec613e7fac7fe9490ef02e06..fce2a989ccb28d8e96d33b2c44d9abdad79a750b 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -133,11 +133,10 @@ export class PrometheusBridgeMetrics implements IBridgeMetrics { public requestOutcome(id: string, isRemote: boolean, outcome: string) { const startTime = this.requestsInFlight.get(id); - this.requestsInFlight.delete(id); if (!startTime) { - log.verbose(`Got "requestOutcome" for ${id}, but this request was never started`); return; } + this.requestsInFlight.delete(id); const duration = Date.now() - startTime; (isRemote ? this.remoteRequest : this.matrixRequest).observe({outcome}, duration / 1000); } diff --git a/src/store.ts b/src/store.ts index 31764b28266f2f71ea5e5c1ba60a9cd91db88a4a..83551c14193752add4f339f3b3cef93393f52ab6 100644 --- a/src/store.ts +++ b/src/store.ts @@ -172,23 +172,30 @@ export class DiscordStore { } } - public async deleteUserToken(discordId: string): Promise<void> { + public async deleteUserToken(mxid: string): Promise<void> { + const res = await this.db.Get("SELECT * from user_id_discord_id WHERE user_id = $id", { + id: mxid, + }); + if (!res) { + return; + } + const discordId = res.discord_id; log.silly("SQL", "deleteUserToken => ", discordId); try { await Promise.all([ this.db.Run( ` - DELETE FROM user_id_discord_id WHERE discord_id = $id; + DELETE FROM user_id_discord_id WHERE discord_id = $id ` , { - $id: discordId, + id: discordId, }), this.db.Run( ` - DELETE FROM discord_id_token WHERE discord_id = $id; + DELETE FROM discord_id_token WHERE discord_id = $id ` , { - $id: discordId, + id: discordId, }), ]); } catch (err) { diff --git a/src/structures/lock.ts b/src/structures/lock.ts new file mode 100644 index 0000000000000000000000000000000000000000..f400aab092a3a6f49381d9c47b43112d0500be9d --- /dev/null +++ b/src/structures/lock.ts @@ -0,0 +1,60 @@ +export class Lock<T> { + private locks: Map<T, {i: NodeJS.Timeout|null, r: (() => void)|null}>; + private lockPromises: Map<T, Promise<{}>>; + constructor( + private timeout: number, + ) { + this.locks = new Map(); + this.lockPromises = new Map(); + } + + public set(key: T) { + // if there is a lock set.....we don't set a second one ontop + if (this.locks.has(key)) { + return; + } + + // set a dummy lock so that if we re-set again before releasing it won't do anthing + this.locks.set(key, {i: null, r: null}); + + const p = new Promise<{}>((resolve) => { + // first we check if the lock has the key....if not, e.g. if it + // got released too quickly, we still want to resolve our promise + if (!this.locks.has(key)) { + resolve(); + return; + } + // create the interval that will release our promise after the timeout + const i = setTimeout(() => { + this.release(key); + }, this.timeout); + // aaand store to our lock + this.locks.set(key, {r: resolve, i}); + }); + this.lockPromises.set(key, p); + } + + public release(key: T) { + // if there is nothing to release then there is nothing to release + if (!this.locks.has(key)) { + return; + } + const lock = this.locks.get(key)!; + if (lock.r !== null) { + lock.r(); + } + if (lock.i !== null) { + clearTimeout(lock.i); + } + this.locks.delete(key); + this.lockPromises.delete(key); + } + + public async wait(key: T) { + // we wait for a lock release only if a promise is present + const promise = this.lockPromises.get(key); + if (promise) { + await promise; + } + } +} diff --git a/src/structures/timedcache.ts b/src/structures/timedcache.ts new file mode 100644 index 0000000000000000000000000000000000000000..93424af9d488d493439bd4294dc969297f79eeee --- /dev/null +++ b/src/structures/timedcache.ts @@ -0,0 +1,106 @@ +interface ITimedValue<V> { + value: V; + ts: number; +} + +export class TimedCache<K, V> implements Map<K, V> { + private readonly map: Map<K, ITimedValue<V>>; + + public constructor(private readonly liveFor: number) { + this.map = new Map(); + } + + public clear(): void { + this.map.clear(); + } + + public delete(key: K): boolean { + return this.map.delete(key); + } + + public forEach(callbackfn: (value: V, key: K, map: Map<K, V>) => void|Promise<void>): void { + for (const item of this) { + callbackfn(item[1], item[0], this); + } + } + + public get(key: K): V | undefined { + const v = this.map.get(key); + if (v === undefined) { + return; + } + const val = this.filterV(v); + if (val !== undefined) { + return val; + } + // Cleanup expired key + this.map.delete(key); + } + + public has(key: K): boolean { + return this.get(key) !== undefined; + } + + public set(key: K, value: V): this { + this.map.set(key, { + ts: Date.now(), + value, + }); + return this; + } + + public get size(): number { + return this.map.size; + } + + public [Symbol.iterator](): IterableIterator<[K, V]> { + let iterator: IterableIterator<[K, ITimedValue<V>]>; + return { + next: () => { + if (!iterator) { + iterator = this.map.entries(); + } + let item: IteratorResult<[K, ITimedValue<V>]>|undefined; + let filteredValue: V|undefined; + // Loop if we have no item, or the item has expired. + while (!item || filteredValue === undefined) { + item = iterator.next(); + // No more items in map. Bye bye. + if (item.done) { + break; + } + filteredValue = this.filterV(item.value[1]); + } + if (item.done) { + // Typscript doesn't like us returning undefined for value, which is dumb. + // tslint:disable-next-line: no-any + return {done: true, value: undefined} as any as IteratorResult<[K, V]>; + } + return {done: false, value: [item.value[0], filteredValue]} as IteratorResult<[K, V]>; + }, + [Symbol.iterator]: () => this[Symbol.iterator](), + }; + } + + public entries(): IterableIterator<[K, V]> { + return this[Symbol.iterator](); + } + + public keys(): IterableIterator<K> { + throw new Error("Method not implemented."); + } + + public values(): IterableIterator<V> { + throw new Error("Method not implemented."); + } + + get [Symbol.toStringTag](): "Map" { + return "Map"; + } + + private filterV(v: ITimedValue<V>): V|undefined { + if (Date.now() - v.ts < this.liveFor) { + return v.value; + } + } +} diff --git a/src/usersyncroniser.ts b/src/usersyncroniser.ts index f34dd2e887ba20cf324e4853973d6c2ae592432b..42bc083537b652da379606dc9174f559c5a70429 100644 --- a/src/usersyncroniser.ts +++ b/src/usersyncroniser.ts @@ -96,8 +96,8 @@ export class UserSyncroniser { * @returns {Promise<void>} * @constructor */ - public async OnUpdateUser(discordUser: User, webhookID?: string) { - const userState = await this.GetUserUpdateState(discordUser, webhookID); + public async OnUpdateUser(discordUser: User, isWebhook: boolean = false) { + const userState = await this.GetUserUpdateState(discordUser, isWebhook); try { await this.ApplyStateToProfile(userState); } catch (e) { @@ -157,10 +157,10 @@ export class UserSyncroniser { } } - public async JoinRoom(member: GuildMember | User, roomId: string, webhookID?: string) { + public async JoinRoom(member: GuildMember | User, roomId: string, isWebhook: boolean = false) { let state: IGuildMemberState; if (member instanceof User) { - state = await this.GetUserStateForDiscordUser(member, webhookID); + state = await this.GetUserStateForDiscordUser(member, isWebhook); } else { state = await this.GetUserStateForGuildMember(member); } @@ -228,15 +228,18 @@ export class UserSyncroniser { } } - public async GetUserUpdateState(discordUser: User, webhookID?: string): Promise<IUserState> { + public async GetUserUpdateState(discordUser: User, isWebhook: boolean = false): Promise<IUserState> { log.verbose(`State update requested for ${discordUser.id}`); let mxidExtra = ""; - if (webhookID) { + if (isWebhook) { + // for webhooks we append the username to the mxid, as webhooks with the same + // id can have multiple different usernames set. This way we don't spam + // userstate changes // no need to escape as this mxid is only used to create an intent - mxidExtra = `_${new MatrixUser(`@${webhookID}`).localpart}`; + mxidExtra = `_${new MatrixUser(`@${discordUser.username}`).localpart}`; } const userState: IUserState = Object.assign({}, DEFAULT_USER_STATE, { - id: discordUser.id, + id: discordUser.id + mxidExtra, mxUserId: `@_discord_${discordUser.id}${mxidExtra}:${this.config.bridge.domain}`, }); const displayName = Util.ApplyPatternString(this.config.ghosts.usernamePattern, { @@ -303,17 +306,20 @@ export class UserSyncroniser { public async GetUserStateForDiscordUser( user: User, - webhookID?: string, + isWebhook: boolean = false, ): Promise<IGuildMemberState> { let mxidExtra = ""; - if (webhookID) { + if (isWebhook) { + // for webhooks we append the username to the mxid, as webhooks with the same + // id can have multiple different usernames set. This way we don't spam + // userstate changes // no need to escape as this mxid is only used to create an Intent mxidExtra = `_${new MatrixUser(`@${user.username}`).localpart}`; } const guildState: IGuildMemberState = Object.assign({}, DEFAULT_GUILD_STATE, { bot: user.bot, displayName: user.username, - id: user.id, + id: user.id + mxidExtra, mxUserId: `@_discord_${user.id}${mxidExtra}:${this.config.bridge.domain}`, roles: [], username: user.tag, diff --git a/src/util.ts b/src/util.ts index 8f4b04347f8db21072be889a296bd555ef837d2a..8f24a7f57aa9cf3207fda444048440fba01732f8 100644 --- a/src/util.ts +++ b/src/util.ts @@ -268,7 +268,11 @@ export class Util { } return reply; } + if (Object.keys(actions).length === 0) { + return "No commands found"; + } reply += "Available Commands:\n"; + let commandsHavePermission = 0; for (const actionKey of Object.keys(actions)) { const action = actions[actionKey]; if (action.permission !== undefined && permissionCheck) { @@ -277,12 +281,16 @@ export class Util { continue; } } + commandsHavePermission++; reply += ` - \`${prefix} ${actionKey}`; for (const param of action.params) { reply += ` <${param}>`; } reply += `\`: ${action.description}\n`; } + if (!commandsHavePermission) { + return "No commands found"; + } reply += "\nParameters:\n"; for (const parameterKey of Object.keys(parameters)) { const parameter = parameters[parameterKey]; diff --git a/test/config.ts b/test/config.ts index b6d7f447d8cf09471281abc3d3c84107f5fe1b2b..13b2ea444cf91e349167587d68f836afcf5252c1 100644 --- a/test/config.ts +++ b/test/config.ts @@ -18,11 +18,6 @@ import { argv } from "process"; import { Log } from "../src/log"; import * as WhyRunning from "why-is-node-running"; -const logger = new Log("MessageProcessor"); - -// we are a test file and thus need those -/* tslint:disable:no-unused-expression max-file-line-count */ - if (!argv.includes("--noisy")) { Log.ForceSilent(); } diff --git a/test/structures/test_lock.ts b/test/structures/test_lock.ts new file mode 100644 index 0000000000000000000000000000000000000000..114ddb9065f0f11a9acb288b43f71c58227ba827 --- /dev/null +++ b/test/structures/test_lock.ts @@ -0,0 +1,45 @@ +/* +Copyright 2019 matrix-appservice-discord + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { expect } from "chai"; +import { Lock } from "../../src/structures/lock"; +import { Util } from "../../src/util"; + +const LOCKTIMEOUT = 300; + +describe("Lock", () => { + it("should lock and unlock", async () => { + const lock = new Lock<string>(LOCKTIMEOUT); + const t = Date.now(); + lock.set("bunny"); + await lock.wait("bunny"); + const diff = Date.now() - t; + expect(diff).to.be.greaterThan(LOCKTIMEOUT - 1); + }); + it("should lock and unlock early, if unlocked", async () => { + const SHORTDELAY = 100; + const DELAY_ACCURACY = 5; + const lock = new Lock<string>(LOCKTIMEOUT); + setTimeout(() => lock.release("fox"), SHORTDELAY); + const t = Date.now(); + lock.set("fox"); + await lock.wait("fox"); + const diff = Date.now() - t; + // accuracy can be off by a few ms soemtimes + expect(diff).to.be.greaterThan(SHORTDELAY - DELAY_ACCURACY); + expect(diff).to.be.lessThan(SHORTDELAY + DELAY_ACCURACY); + }); +}); diff --git a/test/structures/test_timedcache.ts b/test/structures/test_timedcache.ts new file mode 100644 index 0000000000000000000000000000000000000000..f002fc77ee182cb14f839c52a39f3228ce02ef67 --- /dev/null +++ b/test/structures/test_timedcache.ts @@ -0,0 +1,124 @@ +/* +Copyright 2019 matrix-appservice-discord + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { expect } from "chai"; +import { TimedCache } from "../../src/structures/timedcache"; +import { Util } from "../../src/util"; + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +describe("TimedCache", () => { + it("should construct", () => { + const timedCache = new TimedCache<string, number>(1000); + expect(timedCache.size).to.equal(0); + }); + + it("should add and get values", () => { + const timedCache = new TimedCache<string, number>(1000); + timedCache.set("foo", 1); + timedCache.set("bar", -1); + timedCache.set("baz", 0); + expect(timedCache.get("foo")).to.equal(1); + expect(timedCache.get("bar")).to.equal(-1); + expect(timedCache.get("baz")).to.equal(0); + }); + + it("should be able to overwrite values", () => { + const timedCache = new TimedCache<string, number>(1000); + timedCache.set("foo", 1); + expect(timedCache.get("foo")).to.equal(1); + timedCache.set("bar", 0); + timedCache.set("foo", -1); + expect(timedCache.get("bar")).to.equal(0); + expect(timedCache.get("foo")).to.equal(-1); + }); + + it("should be able to check if a value exists", () => { + const timedCache = new TimedCache<string, number>(1000); + expect(timedCache.has("foo")).to.be.false; + timedCache.set("foo", 1); + expect(timedCache.has("foo")).to.be.true; + timedCache.set("bar", 1); + expect(timedCache.has("bar")).to.be.true; + }); + + it("should be able to delete a value", () => { + const timedCache = new TimedCache<string, number>(1000); + timedCache.set("foo", 1); + expect(timedCache.has("foo")).to.be.true; + timedCache.delete("foo"); + expect(timedCache.has("foo")).to.be.false; + expect(timedCache.get("foo")).to.be.undefined; + }); + + it("should expire a value", async () => { + const LIVE_FOR = 50; + const timedCache = new TimedCache<string, number>(LIVE_FOR); + timedCache.set("foo", 1); + expect(timedCache.has("foo")).to.be.true; + expect(timedCache.get("foo")).to.equal(1); + await Util.DelayedPromise(LIVE_FOR); + expect(timedCache.has("foo")).to.be.false; + expect(timedCache.get("foo")).to.be.undefined; + }); + + it("should be able to iterate around a long-lasting collection", () => { + const timedCache = new TimedCache<string, number>(1000); + timedCache.set("foo", 1); + timedCache.set("bar", -1); + timedCache.set("baz", 0); + let i = 0; + for (const iterator of timedCache) { + if (i === 0) { + expect(iterator[0]).to.equal("foo"); + expect(iterator[1]).to.equal(1); + } else if (i === 1) { + expect(iterator[0]).to.equal("bar"); + expect(iterator[1]).to.equal(-1); + } else { + expect(iterator[0]).to.equal("baz"); + expect(iterator[1]).to.equal(0); + } + i++; + } + }); + + it("should be able to iterate around a short-term collection", async () => { + const LIVE_FOR = 100; + const timedCache = new TimedCache<string, number>(LIVE_FOR); + timedCache.set("foo", 1); + timedCache.set("bar", -1); + timedCache.set("baz", 0); + let i = 0; + for (const iterator of timedCache) { + if (i === 0) { + expect(iterator[0]).to.equal("foo"); + expect(iterator[1]).to.equal(1); + } else if (i === 1) { + expect(iterator[0]).to.equal("bar"); + expect(iterator[1]).to.equal(-1); + } else { + expect(iterator[0]).to.equal("baz"); + expect(iterator[1]).to.equal(0); + } + i++; + } + await Util.DelayedPromise(LIVE_FOR); + const vals = [...timedCache.entries()]; + expect(vals).to.be.empty; + }); +}); diff --git a/test/test_discordbot.ts b/test/test_discordbot.ts index 918ff46863b19b6f70a106d96228576ec1cab580..2cfa4cacbe818f0a03577e83d292aba6f38cdf7c 100644 --- a/test/test_discordbot.ts +++ b/test/test_discordbot.ts @@ -416,48 +416,6 @@ describe("DiscordBot", () => { assert.equal(expected, ITERATIONS); }); }); - describe("locks", () => { - it("should lock and unlock a channel", async () => { - const bot = new modDiscordBot.DiscordBot( - "", - config, - mockBridge, - {}, - ) as DiscordBot; - const chan = new MockChannel("123") as any; - const t = Date.now(); - bot.lockChannel(chan); - await bot.waitUnlock(chan); - const diff = Date.now() - t; - expect(diff).to.be.greaterThan(config.limits.discordSendDelay - 1); - }); - it("should lock and unlock a channel early, if unlocked", async () => { - const discordSendDelay = 500; - const SHORTDELAY = 100; - const MINEXPECTEDDELAY = 95; - const bot = new modDiscordBot.DiscordBot( - "", - { - bridge: { - domain: "localhost", - }, - limits: { - discordSendDelay, - }, - }, - mockBridge, - {}, - ) as DiscordBot; - const chan = new MockChannel("123") as any; - setTimeout(() => bot.unlockChannel(chan), SHORTDELAY); - const t = Date.now(); - bot.lockChannel(chan); - await bot.waitUnlock(chan); - const diff = Date.now() - t; - // Date accuracy can be off by a few ms sometimes. - expect(diff).to.be.greaterThan(MINEXPECTEDDELAY); - }); - }); // }); // describe("ProcessMatrixMsgEvent()", () => { // diff --git a/test/test_discordmessageprocessor.ts b/test/test_discordmessageprocessor.ts index d66dd949e2cbe76da2780a1853e5acd6775ee4b6..2938472011f76dedcae9df1de1a2c32f3962289e 100644 --- a/test/test_discordmessageprocessor.ts +++ b/test/test_discordmessageprocessor.ts @@ -391,6 +391,18 @@ describe("DiscordMessageProcessor", () => { Chai.assert.equal(reply, "<span data-mx-color=\"#dead88\"><strong>@role</strong></span>"); }); }); + describe("InsertSpoiler / HTML", () => { + it("parses spoilers", () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const content = { content: "foxies" }; + let reply = processor.InsertSpoiler(content); + Chai.assert.equal(reply, "(Spoiler: foxies)"); + + reply = processor.InsertSpoiler(content, true); + Chai.assert.equal(reply, "<span data-mx-spoiler>foxies</span>"); + }); + }); describe("InsertEmoji", () => { it("inserts static emojis to their post-parse flag", () => { const processor = new DiscordMessageProcessor( diff --git a/test/test_matrixeventprocessor.ts b/test/test_matrixeventprocessor.ts index 26080fbcd3cb65ef6d0405388a40c4b4c51c32c0..107f5273a05c10a88dd4fa3cee6c942630192008 100644 --- a/test/test_matrixeventprocessor.ts +++ b/test/test_matrixeventprocessor.ts @@ -604,6 +604,22 @@ describe("MatrixEventProcessor", () => { } as IMatrixEvent, mxClient); expect(ret).equals("[filename.webm](https://localhost/8000000)"); }); + it("Should reply embeds on large info.size images if set", async () => { + const LARGE_FILE = 8000000; + const processor = createMatrixEventProcessor(); + const ret = await processor.HandleAttachment({ + content: { + body: "filename.jpg", + info: { + mimetype: "image/jpeg", + size: LARGE_FILE, + }, + msgtype: "m.image", + url: "mxc://localhost/8000000", + }, + } as IMatrixEvent, mxClient, true); + expect((ret as Discord.RichEmbed).image!.url).equals("https://localhost/8000000"); + }); it("Should handle stickers.", async () => { const processor = createMatrixEventProcessor(); const attachment = (await processor.HandleAttachment({ @@ -643,6 +659,7 @@ describe("MatrixEventProcessor", () => { }, }, }, + room_id: "!fakeroom:localhost", sender: "@test:localhost", type: "m.room.message", } as IMatrixEvent, mockChannel as any); @@ -664,6 +681,7 @@ This is where the reply goes`, }, }, }, + room_id: "!fakeroom:localhost", sender: "@test:localhost", type: "m.room.message", } as IMatrixEvent, mockChannel as any); @@ -685,6 +703,7 @@ This is where the reply goes`, }, }, }, + room_id: "!fakeroom:localhost", sender: "@test:localhost", type: "m.room.message", } as IMatrixEvent, mockChannel as any); @@ -706,6 +725,7 @@ This is the second reply`, }, }, }, + room_id: "!fakeroom:localhost", sender: "@test:localhost", type: "m.room.message", } as IMatrixEvent, mockChannel as any); @@ -727,6 +747,7 @@ This is the reply`, }, }, }, + room_id: "!fakeroom:localhost", sender: "@test:localhost", type: "m.room.message", } as IMatrixEvent, mockChannel as any); @@ -746,6 +767,7 @@ This is the reply`, }, }, }, + room_id: "!fakeroom:localhost", sender: "@test:localhost", type: "m.room.message", } as IMatrixEvent, mockChannel as any); @@ -763,6 +785,7 @@ This is the reply`, }, }, }, + room_id: "!fakeroom:localhost", sender: "@test:localhost", type: "m.room.message", } as IMatrixEvent, mockChannel as any); @@ -787,6 +810,7 @@ This is the reply`, }, }, }, + room_id: "!fakeroom:localhost", sender: "@test:localhost", type: "m.room.message", } as IMatrixEvent, mockChannel as any); @@ -804,6 +828,7 @@ This is the reply`, }, }, }, + room_id: "!fakeroom:localhost", sender: "@test:localhost", type: "m.room.message", } as IMatrixEvent, mockChannel as any); diff --git a/test/test_matrixmessageprocessor.ts b/test/test_matrixmessageprocessor.ts index d154bfa98c55de25c00b4510d49ff08b487d3bca..bca8537e83c9d0f1c4a9d0777196493821863872 100644 --- a/test/test_matrixmessageprocessor.ts +++ b/test/test_matrixmessageprocessor.ts @@ -194,6 +194,13 @@ code **##### tail** **###### foxies**`); }); + it("strips simple span tags", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<span>bunny</span>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("bunny"); + }); }); describe("FormatMessage / formatted_body / complex", () => { it("html unescapes stuff inside of code", async () => { @@ -342,6 +349,20 @@ code const result = await mp.FormatMessage(msg, guild as any); expect(result).is.equal("[*yay?*](http://example.com)"); }); + it("Handles spoilers", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<span data-mx-spoiler>foxies</span>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("||foxies||"); + }); + it("Handles spoilers with reason", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<span data-mx-spoiler=\"floof\">foxies</span>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("(floof)||foxies||"); + }); }); describe("FormatMessage / formatted_body / emoji", () => { it("Inserts emoji by name", async () => { diff --git a/test/test_usersyncroniser.ts b/test/test_usersyncroniser.ts index 531f3e86e1ded68fb2848882d750c9e195f128b9..00dfa17d5587d03cc77f7a743037997c16a05652 100644 --- a/test/test_usersyncroniser.ts +++ b/test/test_usersyncroniser.ts @@ -539,7 +539,7 @@ describe("UserSyncroniser", () => { "123456", "username", "1234"); - const state = await userSync.GetUserStateForDiscordUser(member as any, "654321"); + const state = await userSync.GetUserStateForDiscordUser(member as any, true); expect(state.displayName).to.be.equal("username"); expect(state.mxUserId).to.be.equal("@_discord_123456_username:localhost"); }); diff --git a/test/test_util.ts b/test/test_util.ts index 2e4ca1a3f0e8421f78e5400a6a2a587a1b6ec00d..30b4301450f4dce6152a249c606dc34846193f2f 100644 --- a/test/test_util.ts +++ b/test/test_util.ts @@ -86,6 +86,16 @@ describe("Util", () => { }, }; describe("HandleHelpCommand", () => { + it("handles empty commands", async () => { + const {command, args} = Util.MsgToArgs("!fox help", "!fox"); + const retStr = await Util.HandleHelpCommand( + "!fox", + {} as any, + {} as any, + args, + ); + expect(retStr).to.equal("No commands found"); + }); it("parses general help message", async () => { const {command, args} = Util.MsgToArgs("!fox help", "!fox"); const retStr = await Util.HandleHelpCommand( diff --git a/tools/addRoomsToDirectory.ts b/tools/addRoomsToDirectory.ts index 200bebb500f7e85ece31b7cd5575334106aa2518..aeb037e7507c89a6598b9d1eb4f45e96c0774d69 100644 --- a/tools/addRoomsToDirectory.ts +++ b/tools/addRoomsToDirectory.ts @@ -44,6 +44,14 @@ const optionDefinitions = [ type: String, typeLabel: "<config.yaml>", }, + { + alias: "r", + defaultValue: "discord-registration.yaml", + description: "The AS registration file.", + name: "registration", + type: String, + typeLabel: "<discord-registration.yaml>", + }, { alias: "s", defaultValue: "room-store.db", @@ -69,7 +77,7 @@ if (options.help) { ])); process.exit(0); } -const yamlConfig = yaml.safeLoad(fs.readFileSync("./discord-registration.yaml", "utf8")); +const yamlConfig = yaml.safeLoad(fs.readFileSync(options.registration, "utf8")); const registration = AppServiceRegistration.fromObject(yamlConfig); const config: DiscordBridgeConfig = yaml.safeLoad(fs.readFileSync(options.config, "utf8")) as DiscordBridgeConfig; diff --git a/tools/addbot.ts b/tools/addbot.ts index b8ef769b7ad9c5e5e9fe4e372f370531290d81cd..2dda777537df95d53e6cbb8bdaed47175e89b2f6 100644 --- a/tools/addbot.ts +++ b/tools/addbot.ts @@ -20,9 +20,45 @@ limitations under the License. */ import * as yaml from "js-yaml"; import * as fs from "fs"; +import * as args from "command-line-args"; +import * as usage from "command-line-usage"; import { Util } from "../src/util"; -const yamlConfig = yaml.safeLoad(fs.readFileSync("config.yaml", "utf8")); +const optionDefinitions = [ + { + alias: "h", + description: "Display this usage guide.", + name: "help", + type: Boolean, + }, + { + alias: "c", + defaultValue: "config.yaml", + description: "The AS config file.", + name: "config", + type: String, + typeLabel: "<config.yaml>", + }, +]; + +const options = args(optionDefinitions); + +if (options.help) { + /* tslint:disable:no-console */ + console.log(usage([ + { + content: "A tool to obtain the Discord bot invitation URL.", + header: "Add bot", + }, + { + header: "Options", + optionList: optionDefinitions, + }, + ])); + process.exit(0); +} + +const yamlConfig = yaml.safeLoad(fs.readFileSync(options.config, "utf8")); if (yamlConfig === null) { console.error("You have an error in your discord config."); } diff --git a/tools/adminme.ts b/tools/adminme.ts index a45887968beffc0bee9d2039680531f1f3b90506..234c161fa34e36d39475e8825548d59102bd9df5 100644 --- a/tools/adminme.ts +++ b/tools/adminme.ts @@ -43,6 +43,14 @@ const optionDefinitions = [ }, { alias: "r", + defaultValue: "discord-registration.yaml", + description: "The AS registration file.", + name: "registration", + type: String, + typeLabel: "<discord-registration.yaml>", + }, + { + alias: "m", description: "The roomid to modify", name: "roomid", type: String, @@ -90,7 +98,7 @@ if (!options.userid) { process.exit(1); } -const yamlConfig = yaml.safeLoad(fs.readFileSync("discord-registration.yaml", "utf8")); +const yamlConfig = yaml.safeLoad(fs.readFileSync(options.registration, "utf8")); const registration = AppServiceRegistration.fromObject(yamlConfig); const config: DiscordBridgeConfig = yaml.safeLoad(fs.readFileSync(options.config, "utf8")) as DiscordBridgeConfig; diff --git a/tools/chanfix.ts b/tools/chanfix.ts index a3f5095231f489dc78aea5c87d4ed20911b9d2f2..5607454c12c0e0b159ff048ca2e8ab13f8c6118b 100644 --- a/tools/chanfix.ts +++ b/tools/chanfix.ts @@ -44,6 +44,14 @@ const optionDefinitions = [ type: String, typeLabel: "<config.yaml>", }, + { + alias: "r", + defaultValue: "discord-registration.yaml", + description: "The AS registration file.", + name: "registration", + type: String, + typeLabel: "<discord-registration.yaml>", + }, ]; const options = args(optionDefinitions); @@ -64,7 +72,7 @@ if (options.help) { process.exit(0); } -const yamlConfig = yaml.safeLoad(fs.readFileSync("./discord-registration.yaml", "utf8")); +const yamlConfig = yaml.safeLoad(fs.readFileSync(options.registration, "utf8")); const registration = AppServiceRegistration.fromObject(yamlConfig); const config = new DiscordBridgeConfig(); config.applyConfig(yaml.safeLoad(fs.readFileSync(options.config, "utf8")) as DiscordBridgeConfig); diff --git a/tools/ghostfix.ts b/tools/ghostfix.ts index b4e99e9dc95eaf4aab792f9ad39c4f99c2bc7588..59e34aa63103e6a1c9cc5f6fa602c23f6d065ef3 100644 --- a/tools/ghostfix.ts +++ b/tools/ghostfix.ts @@ -53,6 +53,14 @@ const optionDefinitions = [ type: String, typeLabel: "<config.yaml>", }, + { + alias: "r", + defaultValue: "discord-registration.yaml", + description: "The AS registration file.", + name: "registration", + type: String, + typeLabel: "<discord-registration.yaml>", + }, ]; const options = args(optionDefinitions); @@ -73,7 +81,7 @@ if (options.help) { process.exit(0); } -const yamlConfig = yaml.safeLoad(fs.readFileSync("./discord-registration.yaml", "utf8")); +const yamlConfig = yaml.safeLoad(fs.readFileSync(options.registration, "utf8")); const registration = AppServiceRegistration.fromObject(yamlConfig); const config = new DiscordBridgeConfig(); config.applyConfig(yaml.safeLoad(fs.readFileSync(options.config, "utf8")) as DiscordBridgeConfig);