diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..a3f3abbe4f7d20436aab6cb5bb67ea98b6156059 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +node_modules + +# This is just stuff we don't need +.travis.yml +.gitignore +build +*.db +discord-registration.yaml +*.png +README.md +test +docs +config.yaml diff --git a/.gitignore b/.gitignore index d1894073bf08a2107d4074c6d8c8828dcdc235ea..33f4a0ad64ae56bb17d14283e912901e2cf6bed9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,12 @@ +/.idea + # Logs logs *.log +*.log.* npm-debug.log* +.audit.json +*-audit.json # Runtime data pids @@ -43,3 +48,4 @@ discord-registration.yaml build *.db +*.db.backup diff --git a/.nycrc b/.nycrc new file mode 100644 index 0000000000000000000000000000000000000000..63c2cde21ed0827e32a29ec7d50a1454d5a1e282 --- /dev/null +++ b/.nycrc @@ -0,0 +1,5 @@ +{ + "extends": "@istanbuljs/nyc-config-typescript", + "reporter": ["text", "text-summary", "lcov"], + "all": true +} diff --git a/.travis.yml b/.travis.yml index a887f5af47aa2ffbe445002982b716f0531dfdac..4f63cd409b8b585b53a94d3c717eb09b0fa74e0f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,14 +1,32 @@ +dist: xenial language: node_js -node_js: - - "6.9.0" - - "6.0.0" -script: - - npm test - - npm run-script lint -notifications: - webhooks: - urls: - - "https://scalar.vector.im/api/neb/services/hooks/dHJhdmlzLWNpLyU0MEhhbGYtU2hvdCUzQWhhbGYtc2hvdC51ay8lMjFxUE5PblVzTnNaclRvRlpxeEIlM0FoYWxmLXNob3QudWs" - on_success: change # always|never|change - on_failure: always - on_start: never +install: npm install + +cache: + directories: + - node_modules + +jobs: + include: + # Lint doesn't need to build + - stage: lint + script: npm run lint + node_js: "12" + - stage: unit tests + # Test already builds + script: npm run test + node_js: "10" + - node_js: "12" + - stage: coverage + # Coverage does NOT build + script: npm run build && npm run coverage + node_js: "12" + +# NOTE: This is unused atm +# notifications: +# webhooks: +# urls: +# - "https://scalar.vector.im/api/neb/services/hooks/dHJhdmlzLWNpLyU0MEhhbGYtU2hvdCUzQWhhbGYtc2hvdC51ay8lMjFxUE5PblVzTnNaclRvRlpxeEIlM0FoYWxmLXNob3QudWs" +# on_success: change # always|never|change +# on_failure: always +# on_start: never diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000000000000000000000000000000000..f4704038f6c853f52ef2e1b14bf593299edd718e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,88 @@ +Hello! It's awesome that you want to contribute to the Discord bridge, by doing +so you are helping our users and the wider Matrix ecosystem. This document +lists the requirements for your work to be accepted into our repository. + +For clarity reasons, work can either be a pull request or an issue. We find +both quite valuable to the project. Please note that your work must abide by +the Apache 2 license found in the LICENSE file. + +Most importantly we are a welcoming project and will be happy to review any +works, no matter the skill level of the submitter. Everyone must start +somewhere! + +## TL;DR + +* Always work off the **develop** branch. We won't accept merges into any other branches. + +* We follow the [Matrix Code of Conduct](https://matrix.org/docs/guides/code_of_conduct.html) and will not accept or entertain works from individuals who break it. We believe in considerate and respectful members above accepting works, regardless of quality. + * This includes possible bans from any room(s) involved with the project. +* We are limited to accepting work over Github. Please use its interface when submitting work items. +* Discussion of ideas for the bridge and work items should be in [#discord:half-shot.uk](https://matrix.to/#/#discord:half-shot.uk). +* Everything submitted as a PR should have at least one test, the only exception being non-code items. + +## Overview of the Bridge + +The bridge runs as a standalone server that connects to both the Discord API +network and a local Matrix homeserver over the [application service +protocol](https://matrix.org/docs/spec/application_service/unstable.html). +Primarily it syncs events and users from Matrix to Discord and vice versa. + +While the bridge is constantly evolving and we can't keep this section updated +with each component, we follow the principle of handler and processor classes +and each part of the functionality of the bridge will be in a seperate class. +For example, the processing of Matrix events destined for Discord are handled +inside the `MatrixEventProcessor` class. + +## Setting up + +* You will need to [setup the bridge](https://github.com/Half-Shot/matrix-appservice-discord/tree/develop#setup-the-bridge) similarly to how we describe, + but you should setup a homeserver locally on your development machine. We would recommend [Synapse](https://github.com/matrix-org/synapse#id11). + +## Writing an issue + +When writing an issue, please be as verbose as you can. Remember the issue is +there to either document a feature request, or report a bug so it can be fixed. +The issue board is NOT there to complain about a broken or missing feature. + +We leave it to the author's discretion to decide what to include rather than +provide a template, but good items are: + * Shorter titles are better than long rambling ones, but please don't make it too vague. + * A good example is "Ability to bridge an existing matrix room into a discord channel" + * A brief description of the problem. + * If you are a user of another person's bridge, please can you let us know the name of the service provider. + * If you would like to keep the details private, please PM @Half-Shot:half-shot.uk or @sorunome:sorunome.de discreetly. + * Relevant logging from Synapse/your homeserver AND the bridge (if applicable). + * The more verbose, the better but please don't include sensitive details like access tokens. + * A screenshot is always useful. + * Please mention which direction a failure is in, e.g. Matrix -> Discord, if applicable. + +We will assign each issue a tag, which will allow us to categorise the problems. + +While we realise some issues are more important than others, please do not "demand" +for an issue to be fixed. Issues will be worked on in the timeframe that best fits +the needs of the team. + + +## PR Process + +We've tried our best to keep the PR process relatively simple: + +* Create a new branch based off the `develop` branch. + * This can be done with `git checkout develop` followed by `git checkout -b featurename`. +* Create a PR on Github, making sure to give a brief discription of your changes and link to the issue it fixes, if any. + * If your change is not complete but you would like feedback, create it as a draft. +* Ensure the linter and tests are not failing, as we will not accept code that breaks either. + * If your tests fail and are having trouble fixing them, you may push your changes and we will help you fix them. + * Github automatically pokes TravisCI to run both linting and tests. +* Someone from the team will review your work and decide what to do with the PR. + * Usually we will have feedback for the PR and will submit more comments. + * We may decide to reject it, if a feature does not fit with the project goals. + +## Testing + +Testing the bridge is easy enough, you just need to run `npm run build`, +`npm run lint` and `npm run test`. If all pass without errors, congratulations! + +Please bear in mind that you will need to cover the whole, or a reasonable +degree of your code. You can check to see if you have with `npm run +coverage`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..83cecfca0628a4313caf1025f2ff1dc88da3b2be --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM node:alpine AS BUILD +COPY . /tmp/src +# install some dependencies needed for the build process +RUN apk add --no-cache -t build-deps make gcc g++ python ca-certificates libc-dev wget git +RUN cd /tmp/src \ + && npm install \ + && npm run build + +FROM node:alpine +ENV NODE_ENV=production +COPY --from=BUILD /tmp/src/build /build +COPY --from=BUILD /tmp/src/config /config +COPY --from=BUILD /tmp/src/node_modules /node_modules +RUN sh -c 'cd /build/tools; for TOOL in *.js; do LINK="/usr/bin/$(basename $TOOL .js)"; echo -e "#!/bin/sh\ncd /data;\nnode /build/tools/$TOOL \$@" > $LINK; chmod +x $LINK; done' +CMD node /build/src/discordas.js -p 9005 -c /data/config.yaml -f /data/discord-registration.yaml +EXPOSE 9005 +VOLUME ["/data"] diff --git a/README.md b/README.md index ca64606bed3a954e1348f8f8221cedbe48bd12f2..c1ce1e7afbf55a5c276424fd3423eeafd6b3d411 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,17 @@ # Matrix Discord Bridge A bridge between [Matrix](http://matrix.org/) and [Discord](https://discordapp.com/). -Currently the bridge is alpha quality, but is usable. +Currently the bridge is in **Beta** and quite usable for everyday +bridging, with one or two bugs cropping up.  + ## Helping out + [](https://travis-ci.org/Half-Shot/matrix-appservice-discord) +[](https://matrix.to/#/#discord:half-shot.uk) ### PRs PRs are graciously accepted, so please come talk to us in [#discord-bridge:matrix.org](https://matrix.to/#/#discord-bridge:matrix.org) @@ -15,62 +19,129 @@ about any neat ideas you might have. If you are going to make a change, please m ### Issues You can also file bug reports/ feature requests on Github Issues which also helps a ton. Please remember to include logs. -Please also be aware that this is an unoffical project worked on in my (Half-Shot) spare time. +Please also be aware that this is an unoffical project worked on in our spare time. ## Setting up -(These instructions were tested against Node.js v6.9.5 and the Synapse homeserver) +The bridge has been tested against the [Synapse](https://github.com/matrix-org/synapse) homeserver, although any homeserver +that implements the [AS API](https://matrix.org/docs/spec/application_service/r0.1.0.html) should work with this bridge. + +The bridge supports any version of Node.js >= v10.X, including all [current releases](https://nodejs.org/en/about/releases/). ### Setup the bridge * Run ``npm install`` to grab the dependencies. -* Run ``npm build`` to build the typescript. +* Run ``npm run build`` to build the typescript into javascript. * Copy ``config/config.sample.yaml`` to ``config.yaml`` and edit it to reflect your setup. -* Run ``node build/discordas.js -r -u "http://localhost:9005/" -c config.yaml`` + * Note that you are expected to set ``domain`` and ``homeserverURL`` to your **public** host name. + While localhost would work, it does not resolve correctly with Webhooks/Avatars. + Please note that a self-signed SSL certificate won't work, either. + + ```yaml + bridge: + domain: "example.com" + homeserverUrl: "https://example.com" + ``` + +* Run ``node build/src/discordas.js -r -u "http://localhost:9005" -c config.yaml`` * Modify your HSs appservices config so that it includes the generated file. + * e.g. On synapse, adding to ``app_service_config_files`` array in ``homeserver.yaml`` + + ```yaml + app_service_config_files: + - "discord-registration.yaml" + ``` + + * Copy ``discord-registration.yaml`` to your Synapse's directory. + +#### Docker + +Following the instructions above, generate a registration file. The file may also be hand-crafted if you're familiar with the layout. You'll need this file to use the Docker image. + +```shell +# Create the volume where we'll keep the bridge's files +mkdir -p /matrix-appservice-discord + +# Create the configuration file. Use the sample configuration file as a template. +# Be sure to set the database paths to something like this: +# database: +# filename: "/data/discord.db" +# userStorePath: "/data/user-store.db" +# roomStorePath: "/data/room-store.db" +nano /matrix-appservice-discord/config.yaml + +# Copy the registration file to the volume +cp discord-registration.yaml /matrix-appservice-discord/discord-registration.yaml + +# Optional: Build the container yourself (requires a git clone, and to be in the root of the project) +docker build -t halfshot/matrix-appservice-discord . + +# Run the container +docker run -v /matrix-appservice-discord:/data -p 9005:9005 halfshot/matrix-appservice-discord +``` + +#### 3PID Protocol Support + +This bridge support searching for rooms within networks via the 3pid system +used in clients like [Riot](https://riot.im). Any new servers/guilds you bridge +should show up in the network list on Riot and other clients. ### Setting up Discord -* Create a new application via https://discordapp.com/developers/applications/me/create +* Create a new application via https://discordapp.com/developers/applications * Make sure to create a bot user. Fill in ``config.yaml`` -* Run ``npm run-script getbotlink`` to get a authorisation link. +* Run ``npm run addbot`` to get a authorisation link. * Give this link to owners of the guilds you plan to bridge. * Finally, you can join a room with ``#_discord_guildid_channelid`` - * These can be taken from the url ("/$GUILDID/$CHANNELID") when you are in a channel. + * These can be taken from the url ("/$GUILDID/$CHANNELID") when you are in a channel. * Riot (and other clients with third party protocol support) users can directly join channels from the room directory. +* You can use Webhooks to make messages relayed by the bridge not nested by the bot user. This will also display the avatar of the user speaking on matrix with their messages. + * The bot should create this automatically, but if not perform the following: + * Enable ``Manage Webhooks`` on the role added by the bot. + * Add the ``_matrix`` Webhook for each channel you'd like to enable this feature on. + +### Running the Bridge + +* For the bot to appear online on Discord you need to run the bridge itself. +* ``npm start`` + +[Howto](./docs/howto.md) ## Features and Roadmap In a vague order of what is coming up next - - [x] Group messages - - [ ] Direct messages - - [ ] Recieving - - [ ] Initiating - Matrix -> Discord - - [x] Text content - - [x] Image content - - [x] Audio/Video content - - [ ] Typing notifs (**Not supported, requires syncing**) - - [x] User Profiles + - [x] Text content + - [x] Image content + - [x] Audio/Video content + - [ ] Typing notifs (**Not supported, requires syncing**) + - [x] User Profiles - Discord -> Matrix - - [x] Text content - - [x] Image content - - [x] Audio/Video content - - [x] Typing notifs - - [x] User Profiles - - [x] Presence (Synapse currently squashes presence, waiting on future spec) + - [x] Text content + - [x] Image content + - [x] Audio/Video content + - [x] Typing notifs + - [x] User Profiles + - [x] Presence + - [x] Per-guild display names. + - [x] Group messages - [ ] Third Party Lookup - - [x] Rooms - - [ ] Users + - [x] Rooms + - [ ] Users - [ ] Puppet a user's real Discord account. - - [ ] Rooms react to Discord updates - - [ ] Integrate Discord into existing rooms. - - [ ] Manage channel from Matrix - - [ ] Authorise admin rights from Discord to Matrix users - - [ ] Topic - - [ ] Room Name (possibly) + - [x] Sending messages + - [ ] Direct messages + - [ ] UI for setup + - [x] Rooms react to Discord updates + - [ ] Integrate Discord into existing rooms + - [x] Feature + - [ ] UI + - [ ] Manage channel from Matrix (possibly) + - [ ] Authorise admin rights from Discord to Matrix users + - [ ] Topic + - [ ] Room Name - [ ] Provisioning API - - [ ] Webhooks (allows for prettier messages to discord) + - [x] Webhooks (allows for prettier messages to discord) - [ ] VOIP (**Hard** | Unlikely to be finished anytime soon) diff --git a/config/config.sample.yaml b/config/config.sample.yaml index 8fb83dcb1bc2d4fae32d7f47b17691b3fa783c5c..10f6d2fb97e7f9ca24fff97f962683894245c11b 100644 --- a/config/config.sample.yaml +++ b/config/config.sample.yaml @@ -1,7 +1,96 @@ +# This is a sample of the config file showing all avaliable options. +# Where possible we have documented what they do, and all values are the +# default values. + bridge: - domain: localhost - homeserverUrl: http://localhost:8008 + # Domain part of the bridge, e.g. matrix.org + domain: "localhost" + # This should be your publically facing URL because Discord may use it to + # fetch media from the media store. + homeserverUrl: "http://localhost:8008" + # Interval at which to process users in the 'presence queue'. If you have + # 5 users, one user will be processed every 500 milliseconds according to the + # value below. This has a minimum value of 250. + # WARNING: This has a high chance of spamming the homeserver with presence + # updates since it will send one each time somebody changes state or is online. + presenceInterval: 500 + # Disable setting presence for 'ghost users' which means Discord users on Matrix + # will not be shown as away or online. + disablePresence: false + # Disable sending typing notifications when somebody on Discord types. + disableTypingNotifications: false + # Disable deleting messages on Discord if a message is redacted on Matrix. + disableDeletionForwarding: false + # Enable users to bridge rooms using !discord commands. See + # https://t2bot.io/discord for instructions. + enableSelfServiceBridging: false + # Disable sending of read receipts for Matrix events which have been + # successfully bridged to Discord. + disableReadReceipts: false + # Disable Join Leave echos from matrix + disableJoinLeaveNotifications: false +# Authentication configuration for the discord bot. auth: - clientID: 12345 # Get from discord - secret: blah - botToken: foobar + clientID: "12345" + botToken: "foobar" +logging: + # What level should the logger output to the console at. + console: "warn" #silly, verbose, info, http, warn, error, silent + lineDateFormat: "MMM-D HH:mm:ss.SSS" # This is in moment.js format + files: + - file: "debug.log" + disable: + - "PresenceHandler" # Will not capture presence logging + - file: "warn.log" # Will capture warnings + level: "warn" + - file: "botlogs.log" # Will capture logs from DiscordBot + level: "info" + enable: + - "DiscordBot" +database: + userStorePath: "user-store.db" + roomStorePath: "room-store.db" + # You may either use SQLite or Postgresql for the bridge database, which contains + # important mappings for events and user puppeting configurations. + # Use the filename option for SQLite, or connString for Postgresql. + # If you are migrating, see https://github.com/Half-Shot/matrix-appservice-discord/blob/master/docs/howto.md#migrate-to-postgres-from-sqlite + # WARNING: You will almost certainly be fine with sqlite unless your bridge + # is in heavy demand and you suffer from IO slowness. + filename: "discord.db" + # connString: "postgresql://user:password@localhost/database_name" +room: + # Set the default visibility of alias rooms, defaults to "public". + # One of: "public", "private" + defaultVisibility: "public" +channel: + # Pattern of the name given to bridged rooms. + # Can use :guild for the guild name and :name for the channel name. + namePattern: "[Discord] :guild :name" + # Changes made to rooms when a channel is deleted. + deleteOptions: + # Prefix the room name with a string. + #namePrefix: "[Deleted]" + # Prefix the room topic with a string. + #topicPrefix: "This room has been deleted" + # Disable people from talking in the room by raising the event PL to 50 + disableMessaging: false + # Remove the discord alias from the room. + unsetRoomAlias: true + # Remove the room from the directory. + unlistFromDirectory: true + # Set the room to be unavaliable for joining without an invite. + setInviteOnly: true + # Make all the discord users leave the room. + ghostsLeave: true +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 + # fininished handling it, causing us to echo it back to the room) + discordSendDelay: 750 +ghosts: + # Pattern for the ghosts nick, available is :nick, :username, :tag and :id + nickPattern: ":nick" + # Pattern for the ghosts username, available is :username, :tag and :id + usernamePattern: ":username#:tag" diff --git a/config/config.schema.yaml b/config/config.schema.yaml index 798771b14f4ebc5eb57f82021970728d0055f2eb..7439ef2852c7af065ee69f2d91c8a476420b01da 100644 --- a/config/config.schema.yaml +++ b/config/config.schema.yaml @@ -10,13 +10,109 @@ properties: type: "string" homeserverUrl: type: "string" + presenceInterval: + type: "number" + disablePresence: + type: "boolean" + disableTypingNotifications: + type: "boolean" + disableDeletionForwarding: + type: "boolean" + enableSelfServiceBridging: + type: "boolean" + disableReadReceipts: + type: "boolean" auth: type: "object" - required: ["botToken"] + required: ["botToken", "clientID"] properties: clientID: type: "string" - secret: - type: "string" botToken: type: "string" + logging: + type: "object" + properties: + console: + type: "string" + enum: ["error", "warn", "info", "verbose", "silly", "silent"] + lineDateFormat: + type: "string" + files: + type: "array" + items: + type: "object" + required: ["file"] + properties: + file: + type: "string" + level: + type: "string" + enum: ["error", "warn", "info", "verbose", "silly"] + maxFiles: + type: "string" + maxSize: + type: ["number", "string"] + datePattern: + type: "string" + enabled: + type: "array" + items: + type: "string" + disabled: + type: "array" + items: + type: "string" + database: + type: "object" + properties: + connString: + type: "string" + filename: + type: "string" + userStorePath: + type: "string" + roomStorePath: + type: "string" + room: + type: "object" + properties: + defaultVisibility: + type: "string" + enum: ["public", "private"] + limits: + type: "object" + properties: + roomGhostJoinDelay: + type: "number" + discordSendDelay: + type: "number" + channel: + type: "object" + properties: + namePattern: + type: "string" + deleteOptions: + type: "object" + properties: + namePrefix: + type: "string" + topicPrefix: + type: "string" + disableMessaging: + type: "boolean" + unsetRoomAlias: + type: "boolean" + unlistFromDirectory: + type: "boolean" + setInviteOnly: + type: "boolean" + ghostsLeave: + type: "boolean" + ghosts: + type: "object" + properties: + nickPattern: + type: "string" + usernamePattern: + type: "string" diff --git a/docs/howto.md b/docs/howto.md new file mode 100644 index 0000000000000000000000000000000000000000..cbdee791fc771018dbbf0481cb07da5d15c3357b --- /dev/null +++ b/docs/howto.md @@ -0,0 +1,36 @@ + +### Join a room + +The default format for room aliases (which are automatically resolved, whether the room exists on Matrix or not) is: + +``#_discord_guildid_channelid`` + +You can find these on discord in the browser where: + +``https://discordapp.com/channels/282616294245662720/282616372591329281`` + +is formatted as https://discordapp.com/channels/``guildid``/``channelid`` + +### Set privileges on bridge managed rooms + +* 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'`` + * Run ``npm run adminme -- -h`` for usage. + +Please note that `!AbcdefghijklmnopqR:example.com` is the internal room id and will always begin with `!`. +You can find this internal id in the room settings in Riot. + +### Migrate to postgres from sqlite +* Stop the bridge. +* Create a new database on postgres and create a user for it with a password. + * We will call the database `discord_bridge` and the the user `discord`. +* Install `pgloader` if you do not have it. +* Run `pgloader ./discord.db postgresql://discord:password@localhost/discord_bridge` +* Change the config so that the config contains: + +```yaml +database: + connString: "postgresql://discord:password@localhost/discord_bridge" +``` +* All done! diff --git a/docs/puppeting.md b/docs/puppeting.md new file mode 100644 index 0000000000000000000000000000000000000000..8ff7601e113e370a0dff3e606b9ab80fee278c01 --- /dev/null +++ b/docs/puppeting.md @@ -0,0 +1,43 @@ +# Puppeting + +This docs describes the method to puppet yourself with the bridge, so you can +interact with the bridge as if you were using the real Discord client. This +has the benefits of (not all of these may be implemented): + * Talking as yourself, rather than as the bot. + * DM channels + * Able to use your Discord permissions, as well as joining rooms limited to + your roles as on Discord. + +## Caveats & Disclaimer + +Discord is currently __not__ offering any way to authenticate on behalf +of a user _and_ interact on their behalf. The OAuth system does not allow +remote access beyond reading information about the users. While [developers have +expressed a wish for this](https://feedback.discordapp.com/forums/326712-discord-dream-land/suggestions/16753837-support-custom-clients) +,it is my opinion that Discord are unlikely to support this any time soon. With +all this said, Discord will not be banning users or the bridge itself for acting +on the behalf of the user. + +Therefore while I loathe to do it, we have to store login tokens for *full +permissions* on the user's account (excluding things such as changing passwords + and e-mail which require re-authenication, thankfully). + +The tokens will be stored by the bridge and are valid until the user +changes their password, so please be careful not to give the token to anything +that you wouldn't trust with your password. + +I accept no responsibility if Discord ban your IP, Account or even your details on +their system. They have never given official support on custom clients (and + by extension, puppeting bridges). If you are in any doubt, stick to the + bot which is within the rules. + +## How to Puppet an Account +~~*2FA does not work with bridging, please do not try it.*~~ +You should be able to puppet with 2FA enabled on your account + +*You must also be a bridge admin to add or remove puppets at the moment* + +* Follow https://discordhelp.net/discord-token to find your discord token. +* Stop the bridge, if it is running. +* Run `npm run usertool -- --add` and follow the instructions. +* If all is well, you can start the bridge. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..8990ae98a097da0b5bd3cd745519e4ff5d9382b7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4551 @@ +{ + "name": "matrix-appservice-discord", + "version": "0.5.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@babel/code-frame": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.0.0.tgz", + "integrity": "sha512-OfC2uemaknXr87bdLUkWog7nYuliM9Ij5HUcajsVcMCpQrcLmtxRbVFTIqmcSkSeYRBFBRxs2FiUqFJDLdiebA==", + "dev": true, + "requires": { + "@babel/highlight": "^7.0.0" + } + }, + "@babel/generator": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.4.4.tgz", + "integrity": "sha512-53UOLK6TVNqKxf7RUh8NE851EHRxOOeVXKbK2bivdb+iziMyk03Sr4eaE9OELCbyZAAafAKPDwF2TPUES5QbxQ==", + "dev": true, + "requires": { + "@babel/types": "^7.4.4", + "jsesc": "^2.5.1", + "lodash": "^4.17.11", + "source-map": "^0.5.0", + "trim-right": "^1.0.1" + }, + "dependencies": { + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + } + } + }, + "@babel/helper-function-name": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.1.0.tgz", + "integrity": "sha512-A95XEoCpb3TO+KZzJ4S/5uW5fNe26DjBGqf1o9ucyLyCmi1dXq/B3c8iaWTfBk3VvetUxl16e8tIrd5teOCfGw==", + "dev": true, + "requires": { + "@babel/helper-get-function-arity": "^7.0.0", + "@babel/template": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-get-function-arity": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0.tgz", + "integrity": "sha512-r2DbJeg4svYvt3HOS74U4eWKsUAMRH01Z1ds1zx8KNTPtpTL5JAsdFv8BNyOpVqdFhHkkRDIg5B4AsxmkjAlmQ==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.4.4.tgz", + "integrity": "sha512-Ro/XkzLf3JFITkW6b+hNxzZ1n5OQ80NvIUdmHspih1XAhtN3vPTuUFT4eQnela+2MaZ5ulH+iyP513KJrxbN7Q==", + "dev": true, + "requires": { + "@babel/types": "^7.4.4" + } + }, + "@babel/highlight": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.0.0.tgz", + "integrity": "sha512-UFMC4ZeFC48Tpvj7C8UgLvtkaUuovQX+5xNWrsIoMG8o2z+XFKjKaN9iVmS84dPwVN00W4wPmqvYoZF3EGAsfw==", + "dev": true, + "requires": { + "chalk": "^2.0.0", + "esutils": "^2.0.2", + "js-tokens": "^4.0.0" + }, + "dependencies": { + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + } + } + }, + "@babel/parser": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.4.4.tgz", + "integrity": "sha512-5pCS4mOsL+ANsFZGdvNLybx4wtqAZJ0MJjMHxvzI3bvIsz6sQvzW8XX92EYIkiPtIvcfG3Aj+Ir5VNyjnZhP7w==", + "dev": true + }, + "@babel/template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.4.4.tgz", + "integrity": "sha512-CiGzLN9KgAvgZsnivND7rkA+AeJ9JB0ciPOD4U59GKbQP2iQl+olF1l76kJOupqidozfZ32ghwBEJDhnk9MEcw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/parser": "^7.4.4", + "@babel/types": "^7.4.4" + } + }, + "@babel/traverse": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.4.4.tgz", + "integrity": "sha512-Gw6qqkw/e6AGzlyj9KnkabJX7VcubqPtkUQVAwkc0wUMldr3A/hezNB3Rc5eIvId95iSGkGIOe5hh1kMKf951A==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "@babel/generator": "^7.4.4", + "@babel/helper-function-name": "^7.1.0", + "@babel/helper-split-export-declaration": "^7.4.4", + "@babel/parser": "^7.4.4", + "@babel/types": "^7.4.4", + "debug": "^4.1.0", + "globals": "^11.1.0", + "lodash": "^4.17.11" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + } + } + }, + "@babel/types": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.4.4.tgz", + "integrity": "sha512-dOllgYdnEFOebhkKCjzSVFqw/PmmB8pH6RGOWkY4GsboQNd47b1fBThBSwlHAq9alF9vc1M3+6oqR47R50L0tQ==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.11", + "to-fast-properties": "^2.0.0" + } + }, + "@istanbuljs/nyc-config-typescript": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/nyc-config-typescript/-/nyc-config-typescript-0.1.3.tgz", + "integrity": "sha512-EzRFg92bRSD1W/zeuNkeGwph0nkWf+pP2l/lYW4/5hav7RjKKBN5kV1Ix7Tvi0CMu3pC4Wi/U7rNisiJMR3ORg==", + "dev": true + }, + "@types/chai": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-3.5.2.tgz", + "integrity": "sha1-wRzSgX06QBt7oPWkIPNcVhObHB4=", + "dev": true + }, + "@types/events": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/events/-/events-1.2.0.tgz", + "integrity": "sha512-KEIlhXnIutzKwRbQkGWb/I4HFqBuUykAdHgDED6xqwXJfONCjF5VoE0cXEiurh3XauygxzeDzgtXUqvLkxFzzA==", + "dev": true + }, + "@types/mocha": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-5.2.5.tgz", + "integrity": "sha512-lAVp+Kj54ui/vLUFxsJTMtWvZraZxum3w3Nwkble2dNuV5VnPA+Mi2oGX9XYJAaIvZi3tn3cbjS/qcJXRb6Bww==", + "dev": true + }, + "@types/node": { + "version": "10.14.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.14.6.tgz", + "integrity": "sha512-Fvm24+u85lGmV4hT5G++aht2C5I4Z4dYlWZIh62FAfFO/TfzXtPpoLI6I7AuBWkIFqZCnhFOoTT7RjjaIL5Fjg==", + "dev": true + }, + "@types/sqlite3": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/sqlite3/-/sqlite3-3.1.3.tgz", + "integrity": "sha512-BgGToABnI/8/HnZtZz2Qac6DieU2Dm/j3rtbMmUlDVo4T/uLu8cuVfU/n2UkHowiiwXb6/7h/CmSqBIVKgcTMA==", + "dev": true, + "requires": { + "@types/events": "*", + "@types/node": "*" + } + }, + "abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" + }, + "accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", + "requires": { + "mime-types": "~2.1.24", + "negotiator": "0.6.2" + } + }, + "acorn": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.3.tgz", + "integrity": "sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==", + "dev": true + }, + "acorn-jsx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-3.0.1.tgz", + "integrity": "sha1-r9+UiPsezvyDSPb7IvRk4ypYs2s=", + "dev": true, + "requires": { + "acorn": "^3.0.4" + }, + "dependencies": { + "acorn": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz", + "integrity": "sha1-ReN/s56No/JbruP/U2niu18iAXo=", + "dev": true + } + } + }, + "ajv": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.0.tgz", + "integrity": "sha512-nffhOpkymDECQyR0mnsUtoCE8RlX38G0rYP+wgLWFyZuUyuuojSSvi/+euOiQBIn63whYwYVIIH1TvE3tu4OEg==", + "requires": { + "fast-deep-equal": "^2.0.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-1.5.1.tgz", + "integrity": "sha1-MU3QpLM2j609/NxU7eYXG4htrzw=", + "dev": true + }, + "amdefine": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", + "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=", + "dev": true, + "optional": true + }, + "another-json": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz", + "integrity": "sha1-tfQBnJc7bdXGUGotk0acttMq7tw=" + }, + "ansi-escape-sequences": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ansi-escape-sequences/-/ansi-escape-sequences-4.0.0.tgz", + "integrity": "sha512-v+0wW9Wezwsyb0uF4aBVCjmSqit3Ru7PZFziGF0o2KwTvN2zWfTi3BRLq9EkJFdg3eBbyERXGTntVpBxH1J68Q==", + "requires": { + "array-back": "^2.0.0" + } + }, + "ansi-escapes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz", + "integrity": "sha1-06ioOzGapneTZisT52HHkRQiMG4=", + "dev": true + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "requires": { + "color-convert": "^1.9.0" + } + }, + "append-transform": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-1.0.0.tgz", + "integrity": "sha512-P009oYkeHyU742iSZJzZZywj4QRJdnTWffaKuJQLablCZ1uz6/cW4yaRgcDaoQ+uwOxxnt0gRUcwfsNP2ri0gw==", + "dev": true, + "requires": { + "default-require-extensions": "^2.0.0" + } + }, + "archy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=", + "dev": true + }, + "arg": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz", + "integrity": "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==", + "dev": true + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "array-back": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-2.0.0.tgz", + "integrity": "sha512-eJv4pLLufP3g5kcZry0j6WXpIbzYw9GUB4mVJZno9wfwiBxbizTnHCw3VJb07cBihbFX48Y7oSrW9y+gt4glyw==", + "requires": { + "typical": "^2.6.1" + } + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + }, + "array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=", + "dev": true, + "requires": { + "array-uniq": "^1.0.1" + } + }, + "array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=", + "dev": true + }, + "arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=", + "dev": true + }, + "asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", + "requires": { + "safer-buffer": "~2.1.0" + } + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + }, + "assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true + }, + "async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E=" + }, + "async-limiter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.0.tgz", + "integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==" + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + }, + "aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" + }, + "aws4": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz", + "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==" + }, + "babel-code-frame": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", + "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=", + "requires": { + "chalk": "^1.1.3", + "esutils": "^2.0.2", + "js-tokens": "^3.0.2" + }, + "dependencies": { + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=" + }, + "chalk": { + "version": "1.1.3", + "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=" + } + } + }, + "babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", + "requires": { + "core-js": "^2.4.0", + "regenerator-runtime": "^0.11.0" + } + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" + }, + "base-x": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.4.tgz", + "integrity": "sha512-UYOadoSIkEI/VrRGSG6qp93rp2WdokiAiNYDfGW5qURAY8GiAQkvMbwNNSDYiVJopqv4gCna7xqf4rrNGp+5AA==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "requires": { + "safe-buffer": "5.1.2" + } + }, + "bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "requires": { + "tweetnacl": "^0.14.3" + }, + "dependencies": { + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" + } + } + }, + "better-sqlite3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-5.0.1.tgz", + "integrity": "sha512-dyZk+gDYNPw14maYX5LG/2SCUTiB7jCvETd+bBYqhFyji3oG+UQFN452sUWSjCHCmfg1JtMbLT7WmqB8GLq8Gw==", + "requires": { + "integer": "^2.1.0", + "tar": "^4.4.6" + } + }, + "binary-search-tree": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/binary-search-tree/-/binary-search-tree-0.2.5.tgz", + "integrity": "sha1-fbs7IQ/coIJFDa0jNMMErzm9x4Q=", + "requires": { + "underscore": "~1.4.4" + } + }, + "bintrees": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.1.tgz", + "integrity": "sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ=" + }, + "bluebird": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz", + "integrity": "sha1-U0uQM8AiyVecVro7Plpcqvu2UOE=" + }, + "body-parser": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz", + "integrity": "sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==", + "requires": { + "bytes": "3.1.0", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.2", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "on-finished": "~2.3.0", + "qs": "6.7.0", + "raw-body": "2.4.0", + "type-is": "~1.6.17" + } + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "browser-request": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/browser-request/-/browser-request-0.3.3.tgz", + "integrity": "sha1-ns5bWsqJopkyJC4Yv5M975h2zBc=" + }, + "browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true + }, + "bs58": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", + "integrity": "sha1-vhYedsNU9veIrkBx9j806MTwpCo=", + "requires": { + "base-x": "^3.0.2" + } + }, + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", + "dev": true + }, + "buffer-writer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-1.0.1.tgz", + "integrity": "sha1-Iqk2kB4wKa/NdUfrRIfOtpejvwg=" + }, + "builtin-modules": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", + "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=" + }, + "bytes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz", + "integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==" + }, + "caching-transform": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-3.0.2.tgz", + "integrity": "sha512-Mtgcv3lh3U0zRii/6qVgQODdPA4G3zhG+jtbCWj39RXuUFTMzH0vcdMtaJS1jPowd+It2Pqr6y3NJMQqOqCE2w==", + "dev": true, + "requires": { + "hasha": "^3.0.0", + "make-dir": "^2.0.0", + "package-hash": "^3.0.0", + "write-file-atomic": "^2.4.2" + } + }, + "caller-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-0.1.0.tgz", + "integrity": "sha1-lAhe9jWB7NPaqSREqP6U6CV3dR8=", + "dev": true, + "requires": { + "callsites": "^0.2.0" + } + }, + "callsites": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-0.2.0.tgz", + "integrity": "sha1-r6uWJikQp/M8GaV3WCXGnzTjUMo=", + "dev": true + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" + }, + "chai": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-3.5.0.tgz", + "integrity": "sha1-TQJjewZ/6Vi9v906QOxW/vc3Mkc=", + "dev": true, + "requires": { + "assertion-error": "^1.0.1", + "deep-eql": "^0.1.3", + "type-detect": "^1.0.0" + } + }, + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "chownr": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.1.tgz", + "integrity": "sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g==" + }, + "circular-json": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.3.3.tgz", + "integrity": "sha512-UZK3NBx2Mca+b5LsG7bY183pHWt5Y1xts4P3Pz7ENTwGVnJOUWbRb3ocjvX7hx9tq/yTAdclXm9sZ38gNuem4A==", + "dev": true + }, + "cli-cursor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-1.0.2.tgz", + "integrity": "sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc=", + "dev": true, + "requires": { + "restore-cursor": "^1.0.1" + } + }, + "cli-width": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.0.tgz", + "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=", + "dev": true + }, + "cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dev": true, + "requires": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", + "dev": true + }, + "code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", + "dev": true + }, + "color": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/color/-/color-3.0.0.tgz", + "integrity": "sha512-jCpd5+s0s0t7p3pHQKpnJ0TpQKKdleP71LWcA0aqiljpiuAkOSUFN/dyH8ZwF0hRmFlrIuRhufds1QyEP9EB+w==", + "requires": { + "color-convert": "^1.9.1", + "color-string": "^1.5.2" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + }, + "color-string": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.5.3.tgz", + "integrity": "sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw==", + "requires": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "colornames": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/colornames/-/colornames-1.1.1.tgz", + "integrity": "sha1-+IiQMGhcfE/54qVZ9Qd+t2qBb5Y=" + }, + "colors": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.3.2.tgz", + "integrity": "sha512-rhP0JSBGYvpcNQj4s5AdShMeE5ahMop96cTeDl/v9qQQm2fYClE2QXZRi8wLzc+GmXSxdIqqbOIAhyObEXDbfQ==" + }, + "colorspace": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.1.tgz", + "integrity": "sha512-pI3btWyiuz7Ken0BWh9Elzsmv2bM9AhA7psXib4anUXy/orfZ/E0MbQwhSOG/9L8hLlalqrU0UhOuqxW1YjmVw==", + "requires": { + "color": "3.0.x", + "text-hex": "1.0.x" + } + }, + "combined-stream": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.7.tgz", + "integrity": "sha512-brWl9y6vOB1xYPZcpZde3N9zDByXTosAeMDo4p1wzo6UMOX4vumB+TP1RZ76sfE6Md68Q0NJSrE/gbezd4Ul+w==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "command-line-args": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/command-line-args/-/command-line-args-4.0.7.tgz", + "integrity": "sha512-aUdPvQRAyBvQd2n7jXcsMDz68ckBJELXNzBybCHOibUWEg0mWTnaYCSRU8h9R+aNRSvDihJtssSRCiDRpLaezA==", + "requires": { + "array-back": "^2.0.0", + "find-replace": "^1.0.3", + "typical": "^2.6.1" + } + }, + "command-line-usage": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/command-line-usage/-/command-line-usage-4.1.0.tgz", + "integrity": "sha512-MxS8Ad995KpdAC0Jopo/ovGIroV/m0KHwzKfXxKag6FHOkGsH8/lv5yjgablcRxCJJC0oJeUMuO/gmaq+Wq46g==", + "requires": { + "ansi-escape-sequences": "^4.0.0", + "array-back": "^2.0.0", + "table-layout": "^0.4.2", + "typical": "^2.6.1" + } + }, + "commander": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.19.0.tgz", + "integrity": "sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==" + }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" + }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ=" + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, + "convert-source-map": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.6.0.tgz", + "integrity": "sha512-eFu7XigvxdZ1ETfbgPBohgyQ/Z++C0eEhTor0qRwBw9unw+L0/6V8wkSuGgzdThkiS5lSpdptOQPD8Ak40a+7A==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.1" + } + }, + "cookie": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", + "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=" + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" + }, + "core-js": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.5.tgz", + "integrity": "sha512-klh/kDpwX8hryYL14M9w/xei6vrv6sE8gTHDG7/T/+SEovB/G4ejwcfE/CBzO6Edsu+OETZMZ3wcX/EjUkrl5A==" + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + }, + "cp-file": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cp-file/-/cp-file-6.2.0.tgz", + "integrity": "sha512-fmvV4caBnofhPe8kOcitBwSn2f39QLjnAnGq3gO9dfd75mUytzKNZB1hde6QHunW2Rt+OwuBOMc3i1tNElbszA==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "make-dir": "^2.0.0", + "nested-error-stacks": "^2.0.0", + "pify": "^4.0.1", + "safe-buffer": "^5.0.1" + }, + "dependencies": { + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + } + } + }, + "cross-spawn": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-4.0.2.tgz", + "integrity": "sha1-e5JHYhwjrf3ThWAEqCPL45dCTUE=", + "dev": true, + "requires": { + "lru-cache": "^4.0.1", + "which": "^1.2.9" + } + }, + "cycle": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cycle/-/cycle-1.0.3.tgz", + "integrity": "sha1-IegLK+hYD5i0aPN5QwZisEbDStI=" + }, + "d": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.0.tgz", + "integrity": "sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8=", + "dev": true, + "requires": { + "es5-ext": "^0.10.9" + } + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "requires": { + "assert-plus": "^1.0.0" + } + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", + "dev": true + }, + "deep-eql": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-0.1.3.tgz", + "integrity": "sha1-71WKyrjeJSBs1xOQbXTlaTDrafI=", + "dev": true, + "requires": { + "type-detect": "0.1.1" + }, + "dependencies": { + "type-detect": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-0.1.1.tgz", + "integrity": "sha1-C6XsKohWQORw6k6FBZcZANrFiCI=", + "dev": true + } + } + }, + "deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==" + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", + "dev": true + }, + "default-require-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-2.0.0.tgz", + "integrity": "sha1-9fj7sYp9bVCyH2QfZJ67Uiz+JPc=", + "dev": true, + "requires": { + "strip-bom": "^3.0.0" + } + }, + "del": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/del/-/del-2.2.2.tgz", + "integrity": "sha1-wSyYHQZ4RshLyvhiz/kw2Qf/0ag=", + "dev": true, + "requires": { + "globby": "^5.0.0", + "is-path-cwd": "^1.0.0", + "is-path-in-cwd": "^1.0.0", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0", + "rimraf": "^2.2.8" + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + }, + "depd": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", + "integrity": "sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=" + }, + "destroy": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", + "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" + }, + "diagnostics": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/diagnostics/-/diagnostics-1.1.1.tgz", + "integrity": "sha512-8wn1PmdunLJ9Tqbx+Fx/ZEuHfJf4NKSN2ZBj7SJC/OWRWha843+WsTjqMe1B5E3p28jqBlp+mJ2fPVxPyNgYKQ==", + "requires": { + "colorspace": "1.1.x", + "enabled": "1.0.x", + "kuler": "1.0.x" + } + }, + "diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "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==", + "requires": { + "highlight.js": "^9.13.1", + "simple-markdown": "^0.4.2" + } + }, + "discord.js": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-11.5.0.tgz", + "integrity": "sha512-7TAyr2p1ZP9k4gaIhQWOxZpybznT6ND55HbhntUqwQqXkAjIxU3ATbwiOSuCMd4AXz7L9wk1rioRL0sOjXs5CA==", + "requires": { + "long": "^4.0.0", + "prism-media": "^0.0.3", + "snekfetch": "^3.6.4", + "tweetnacl": "^1.0.0", + "ws": "^6.0.0" + } + }, + "doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "requires": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" + }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, + "enabled": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-1.0.2.tgz", + "integrity": "sha1-ll9lE9LC0cX0ZStkouM5ZGf8L5M=", + "requires": { + "env-variable": "0.0.x" + } + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=" + }, + "end-of-stream": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", + "integrity": "sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q==", + "dev": true, + "requires": { + "once": "^1.4.0" + } + }, + "env-variable": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/env-variable/-/env-variable-0.0.5.tgz", + "integrity": "sha512-zoB603vQReOFvTg5xMl9I1P2PnHsHQQKTEowsKKD7nseUfJq6UWzK+4YtlWUO1nhiQUxe6XMkk+JleSZD1NZFA==" + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + }, + "dependencies": { + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + } + } + }, + "es5-ext": { + "version": "0.10.46", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.46.tgz", + "integrity": "sha512-24XxRvJXNFwEMpJb3nOkiRJKRoupmjYmOPVlI65Qy2SrtxwOTB+g6ODjBKOtwEHbYrhWRty9xxOWLNdClT2djw==", + "dev": true, + "requires": { + "es6-iterator": "~2.0.3", + "es6-symbol": "~3.1.1", + "next-tick": "1" + } + }, + "es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true + }, + "es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=", + "dev": true, + "requires": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "es6-map": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/es6-map/-/es6-map-0.1.5.tgz", + "integrity": "sha1-kTbgUD3MBqMBaQ8LsU/042TpSfA=", + "dev": true, + "requires": { + "d": "1", + "es5-ext": "~0.10.14", + "es6-iterator": "~2.0.1", + "es6-set": "~0.1.5", + "es6-symbol": "~3.1.1", + "event-emitter": "~0.3.5" + } + }, + "es6-set": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/es6-set/-/es6-set-0.1.5.tgz", + "integrity": "sha1-0rPsXU2ADO2BjbU40ol02wpzzLE=", + "dev": true, + "requires": { + "d": "1", + "es5-ext": "~0.10.14", + "es6-iterator": "~2.0.1", + "es6-symbol": "3.1.1", + "event-emitter": "~0.3.5" + } + }, + "es6-symbol": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.1.tgz", + "integrity": "sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc=", + "dev": true, + "requires": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "es6-weak-map": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.2.tgz", + "integrity": "sha1-XjqzIlH/0VOKH45f+hNXdy+S2W8=", + "dev": true, + "requires": { + "d": "1", + "es5-ext": "^0.10.14", + "es6-iterator": "^2.0.1", + "es6-symbol": "^3.1.1" + } + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" + }, + "escodegen": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.8.1.tgz", + "integrity": "sha1-WltTr0aTEQvrsIZ6o0MN07cKEBg=", + "dev": true, + "requires": { + "esprima": "^2.7.1", + "estraverse": "^1.9.1", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.2.0" + }, + "dependencies": { + "esprima": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.3.tgz", + "integrity": "sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE=", + "dev": true + }, + "estraverse": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-1.9.3.tgz", + "integrity": "sha1-r2fy3JIlgkFZUJJgkaQAXSnJu0Q=", + "dev": true + } + } + }, + "escope": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/escope/-/escope-3.6.0.tgz", + "integrity": "sha1-4Bl16BJ4GhY6ba392AOY3GTIicM=", + "dev": true, + "requires": { + "es6-map": "^0.1.3", + "es6-weak-map": "^2.0.1", + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" + } + }, + "eslint": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-3.19.0.tgz", + "integrity": "sha1-yPxiAcf0DdCJQbh8CFdnOGpnmsw=", + "dev": true, + "requires": { + "babel-code-frame": "^6.16.0", + "chalk": "^1.1.3", + "concat-stream": "^1.5.2", + "debug": "^2.1.1", + "doctrine": "^2.0.0", + "escope": "^3.6.0", + "espree": "^3.4.0", + "esquery": "^1.0.0", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "file-entry-cache": "^2.0.0", + "glob": "^7.0.3", + "globals": "^9.14.0", + "ignore": "^3.2.0", + "imurmurhash": "^0.1.4", + "inquirer": "^0.12.0", + "is-my-json-valid": "^2.10.0", + "is-resolvable": "^1.0.0", + "js-yaml": "^3.5.1", + "json-stable-stringify": "^1.0.0", + "levn": "^0.3.0", + "lodash": "^4.0.0", + "mkdirp": "^0.5.0", + "natural-compare": "^1.4.0", + "optionator": "^0.8.2", + "path-is-inside": "^1.0.1", + "pluralize": "^1.2.1", + "progress": "^1.1.8", + "require-uncached": "^1.0.2", + "shelljs": "^0.7.5", + "strip-bom": "^3.0.0", + "strip-json-comments": "~2.0.1", + "table": "^3.7.8", + "text-table": "~0.2.0", + "user-home": "^2.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + } + } + }, + "espree": { + "version": "3.5.4", + "resolved": "http://registry.npmjs.org/espree/-/espree-3.5.4.tgz", + "integrity": "sha512-yAcIQxtmMiB/jL32dzEp2enBeidsB7xWPLNiw3IIkpVds1P+h7qF9YwJq1yUNzp2OKXgAprs4F61ih66UsoD1A==", + "dev": true, + "requires": { + "acorn": "^5.5.0", + "acorn-jsx": "^3.0.0" + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" + }, + "esquery": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.0.1.tgz", + "integrity": "sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA==", + "dev": true, + "requires": { + "estraverse": "^4.0.0" + } + }, + "esrecurse": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.1.tgz", + "integrity": "sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==", + "dev": true, + "requires": { + "estraverse": "^4.1.0" + } + }, + "estraverse": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.2.0.tgz", + "integrity": "sha1-De4/7TH81GlhjOc0IJn8GvoL2xM=", + "dev": true + }, + "esutils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", + "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=" + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" + }, + "event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=", + "dev": true, + "requires": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==" + }, + "execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "requires": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "dependencies": { + "cross-spawn": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", + "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "dev": true, + "requires": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + } + } + }, + "exit-hook": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz", + "integrity": "sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g=", + "dev": true + }, + "express": { + "version": "4.16.4", + "resolved": "https://registry.npmjs.org/express/-/express-4.16.4.tgz", + "integrity": "sha512-j12Uuyb4FMrd/qQAm6uCHAkPtO8FDTRJZBDd5D2KOL2eLaz1yUNdUB/NOIyq0iU4q4cFarsUCrnFDPBcnksuOg==", + "requires": { + "accepts": "~1.3.5", + "array-flatten": "1.1.1", + "body-parser": "1.18.3", + "content-disposition": "0.5.2", + "content-type": "~1.0.4", + "cookie": "0.3.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~1.1.2", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.1.1", + "fresh": "0.5.2", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "~2.3.0", + "parseurl": "~1.3.2", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.4", + "qs": "6.5.2", + "range-parser": "~1.2.0", + "safe-buffer": "5.1.2", + "send": "0.16.2", + "serve-static": "1.13.2", + "setprototypeof": "1.1.0", + "statuses": "~1.4.0", + "type-is": "~1.6.16", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "dependencies": { + "body-parser": { + "version": "1.18.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.18.3.tgz", + "integrity": "sha1-WykhmP/dVTs6DyDe0FkrlWlVyLQ=", + "requires": { + "bytes": "3.0.0", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "~1.1.2", + "http-errors": "~1.6.3", + "iconv-lite": "0.4.23", + "on-finished": "~2.3.0", + "qs": "6.5.2", + "raw-body": "2.3.3", + "type-is": "~1.6.16" + } + }, + "bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=" + }, + "http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + } + }, + "iconv-lite": { + "version": "0.4.23", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.23.tgz", + "integrity": "sha512-neyTUVFtahjf0mB3dZT77u+8O0QB89jFdnBkd5P1JgYPbPaia3gXXOVL2fq8VyU2gMMD7SaN7QukTB/pmXYvDA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" + }, + "raw-body": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.3.3.tgz", + "integrity": "sha512-9esiElv1BrZoI3rCDuOuKCBRbuApGGaDPQfjSflGxdy4oyzqghxu6klEkkVIvBje+FF0BX9coEv8KqW6X/7njw==", + "requires": { + "bytes": "3.0.0", + "http-errors": "1.6.3", + "iconv-lite": "0.4.23", + "unpipe": "1.0.0" + } + }, + "setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" + }, + "statuses": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", + "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==" + } + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" + }, + "fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=" + }, + "fast-json-stable-stringify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=" + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "fast-safe-stringify": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.0.6.tgz", + "integrity": "sha512-q8BZ89jjc+mz08rSxROs8VsrBBcn1SIw1kq9NjolL509tkABRk9io01RAjSaEv1Xb2uFLt8VtRiZbGp5H8iDtg==" + }, + "fecha": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-2.3.3.tgz", + "integrity": "sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg==" + }, + "figures": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz", + "integrity": "sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4=", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5", + "object-assign": "^4.1.0" + } + }, + "file-entry-cache": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-2.0.0.tgz", + "integrity": "sha1-w5KZDD5oR4PYOLjISkXYoEhFg2E=", + "dev": true, + "requires": { + "flat-cache": "^1.2.1", + "object-assign": "^4.0.1" + } + }, + "file-stream-rotator": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/file-stream-rotator/-/file-stream-rotator-0.4.1.tgz", + "integrity": "sha512-W3aa3QJEc8BS2MmdVpQiYLKHj3ijpto1gMDlsgCRSKfIUe6MwkcpODGPQ3vZfb0XvCeCqlu9CBQTN7oQri2TZQ==", + "requires": { + "moment": "^2.11.2" + } + }, + "fill-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz", + "integrity": "sha1-mo+jb06K1jTjv2tPPIiCVRRS6yA=", + "dev": true, + "requires": { + "is-object": "~1.0.1", + "merge-descriptors": "~1.0.0" + } + }, + "finalhandler": { + "version": "1.1.1", + "resolved": "http://registry.npmjs.org/finalhandler/-/finalhandler-1.1.1.tgz", + "integrity": "sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.2", + "statuses": "~1.4.0", + "unpipe": "~1.0.0" + }, + "dependencies": { + "statuses": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", + "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==" + } + } + }, + "find-cache-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-2.1.0.tgz", + "integrity": "sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^2.0.0", + "pkg-dir": "^3.0.0" + } + }, + "find-replace": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/find-replace/-/find-replace-1.0.3.tgz", + "integrity": "sha1-uI5zZNLZyVlVnziMZmcNYTBEH6A=", + "requires": { + "array-back": "^1.0.4", + "test-value": "^2.1.0" + }, + "dependencies": { + "array-back": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-1.0.4.tgz", + "integrity": "sha1-ZEun8JX3/898Q7Xw3DnTwfA8Bjs=", + "requires": { + "typical": "^2.6.0" + } + } + } + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, + "flat-cache": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-1.3.0.tgz", + "integrity": "sha1-0wMLMrOBVPTjt+nHCfSQ9++XxIE=", + "dev": true, + "requires": { + "circular-json": "^0.3.1", + "del": "^2.0.2", + "graceful-fs": "^4.1.2", + "write": "^0.2.1" + } + }, + "foreground-child": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-1.5.6.tgz", + "integrity": "sha1-T9ca0t/elnibmApcCilZN8svXOk=", + "dev": true, + "requires": { + "cross-spawn": "^4", + "signal-exit": "^3.0.0" + } + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" + }, + "form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, + "forwarded": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", + "integrity": "sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=" + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=" + }, + "fs-minipass": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.5.tgz", + "integrity": "sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==", + "requires": { + "minipass": "^2.2.1" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=" + }, + "generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "requires": { + "is-property": "^1.0.2" + } + }, + "generate-object-property": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.2.0.tgz", + "integrity": "sha1-nA4cQDCM6AT0eDYYuTf6iPmdUNA=", + "requires": { + "is-property": "^1.0.0" + } + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, + "get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "requires": { + "pump": "^3.0.0" + } + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "requires": { + "assert-plus": "^1.0.0" + } + }, + "glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "globals": { + "version": "9.18.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-9.18.0.tgz", + "integrity": "sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==", + "dev": true + }, + "globby": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-5.0.0.tgz", + "integrity": "sha1-69hGZ8oNuzMLmbz8aOrCvFQ3Dg0=", + "dev": true, + "requires": { + "array-union": "^1.0.1", + "arrify": "^1.0.0", + "glob": "^7.0.3", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + }, + "graceful-fs": { + "version": "4.1.15", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.15.tgz", + "integrity": "sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==", + "dev": true + }, + "growl": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", + "integrity": "sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA==", + "dev": true + }, + "handlebars": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.1.2.tgz", + "integrity": "sha512-nvfrjqvt9xQ8Z/w0ijewdD/vvWDTOweBUm96NTr66Wfvo1mJenBLwcYmPs3TIBP5ruzYGD7Hx/DaM9RmhroGPw==", + "dev": true, + "requires": { + "neo-async": "^2.6.0", + "optimist": "^0.6.1", + "source-map": "^0.6.1", + "uglify-js": "^3.1.4" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" + }, + "har-validator": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz", + "integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==", + "requires": { + "ajv": "^6.5.5", + "har-schema": "^2.0.0" + } + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" + }, + "hasha": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-3.0.0.tgz", + "integrity": "sha1-UqMvq4Vp1BymmmH/GiFPjrfIvTk=", + "dev": true, + "requires": { + "is-stream": "^1.0.1" + } + }, + "he": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz", + "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==" + }, + "hosted-git-info": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.7.1.tgz", + "integrity": "sha512-7T/BxH19zbcCTa8XkMlbK5lTo1WtgkFi3GvdWEyNuc4Vex7/9Dqbnpsf4JMydcfj9HCg4zUWFTL3Za6lapg5/w==", + "dev": true + }, + "http-errors": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.7.2.tgz", + "integrity": "sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.1", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.0" + } + }, + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "requires": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ignore": { + "version": "3.3.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.10.tgz", + "integrity": "sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug==", + "dev": true + }, + "immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=" + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" + }, + "inquirer": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-0.12.0.tgz", + "integrity": "sha1-HvK/1jUE3wvHV4X/+MLEHfEvB34=", + "dev": true, + "requires": { + "ansi-escapes": "^1.1.0", + "ansi-regex": "^2.0.0", + "chalk": "^1.0.0", + "cli-cursor": "^1.0.1", + "cli-width": "^2.0.0", + "figures": "^1.3.5", + "lodash": "^4.3.0", + "readline2": "^1.0.1", + "run-async": "^0.1.0", + "rx-lite": "^3.1.2", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.0", + "through": "^2.3.6" + }, + "dependencies": { + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + } + } + }, + "integer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/integer/-/integer-2.1.0.tgz", + "integrity": "sha512-vBtiSgrEiNocWvvZX1RVfeOKa2mCHLZQ2p9nkQkQZ/BvEiY+6CcUz0eyjvIiewjJoeNidzg2I+tpPJvpyspL1w==" + }, + "interpret": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.1.0.tgz", + "integrity": "sha1-ftGxQQxqDg94z5XTuEQMY/eLhhQ=", + "dev": true + }, + "invert-kv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-2.0.0.tgz", + "integrity": "sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==", + "dev": true + }, + "ipaddr.js": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.0.tgz", + "integrity": "sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA==" + }, + "is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "is-my-ip-valid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-my-ip-valid/-/is-my-ip-valid-1.0.0.tgz", + "integrity": "sha512-gmh/eWXROncUzRnIa1Ubrt5b8ep/MGSnfAUI3aRp+sqTCs1tv1Isl8d8F6JmkN3dXKc3ehZMrtiPN9eL03NuaQ==" + }, + "is-my-json-valid": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.19.0.tgz", + "integrity": "sha512-mG0f/unGX1HZ5ep4uhRaPOS8EkAY8/j6mDRMJrutq4CqhoJWYp7qAlonIPy3TV7p3ju4TK9fo/PbnoksWmsp5Q==", + "requires": { + "generate-function": "^2.0.0", + "generate-object-property": "^1.1.0", + "is-my-ip-valid": "^1.0.0", + "jsonpointer": "^4.0.0", + "xtend": "^4.0.0" + } + }, + "is-object": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.1.tgz", + "integrity": "sha1-iVJojF7C/9awPsyF52ngKQMINHA=", + "dev": true + }, + "is-path-cwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz", + "integrity": "sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0=", + "dev": true + }, + "is-path-in-cwd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz", + "integrity": "sha512-FjV1RTW48E7CWM7eE/J2NJvAEEVektecDBVBE5Hh3nM1Jd0kvhHtX68Pr3xsDf857xt3Y4AkwVULK1Vku62aaQ==", + "dev": true, + "requires": { + "is-path-inside": "^1.0.0" + } + }, + "is-path-inside": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.1.tgz", + "integrity": "sha1-jvW33lBDej/cprToZe96pVy0gDY=", + "dev": true, + "requires": { + "path-is-inside": "^1.0.1" + } + }, + "is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha1-V/4cTkhHTt1lsJkR8msc1Ald2oQ=" + }, + "is-resolvable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-resolvable/-/is-resolvable-1.1.0.tgz", + "integrity": "sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg==", + "dev": true + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" + }, + "istanbul": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/istanbul/-/istanbul-0.4.5.tgz", + "integrity": "sha1-ZcfXPUxNqE1POsMQuRj7C4Azczs=", + "dev": true, + "requires": { + "abbrev": "1.0.x", + "async": "1.x", + "escodegen": "1.8.x", + "esprima": "2.7.x", + "glob": "^5.0.15", + "handlebars": "^4.0.1", + "js-yaml": "3.x", + "mkdirp": "0.5.x", + "nopt": "3.x", + "once": "1.x", + "resolve": "1.1.x", + "supports-color": "^3.1.0", + "which": "^1.1.1", + "wordwrap": "^1.0.0" + }, + "dependencies": { + "abbrev": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.9.tgz", + "integrity": "sha1-kbR5JYinc4wl813W9jdSovh3YTU=", + "dev": true + }, + "async": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", + "integrity": "sha1-7GphrlZIDAw8skHJVhjiCJL5Zyo=", + "dev": true + }, + "esprima": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.3.tgz", + "integrity": "sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE=", + "dev": true + }, + "glob": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz", + "integrity": "sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=", + "dev": true, + "requires": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", + "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=", + "dev": true + }, + "resolve": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz", + "integrity": "sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=", + "dev": true + }, + "supports-color": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", + "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=", + "dev": true, + "requires": { + "has-flag": "^1.0.0" + } + } + } + }, + "istanbul-lib-coverage": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz", + "integrity": "sha512-8aXznuEPCJvGnMSRft4udDRDtb1V3pkQkMMI5LI+6HuQz5oQ4J2UFn1H82raA3qJtyOLkkwVqICBQkjnGtn5mA==", + "dev": true + }, + "istanbul-lib-hook": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-2.0.7.tgz", + "integrity": "sha512-vrRztU9VRRFDyC+aklfLoeXyNdTfga2EI3udDGn4cZ6fpSXpHLV9X6CHvfoMCPtggg8zvDDmC4b9xfu0z6/llA==", + "dev": true, + "requires": { + "append-transform": "^1.0.0" + } + }, + "istanbul-lib-instrument": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-3.3.0.tgz", + "integrity": "sha512-5nnIN4vo5xQZHdXno/YDXJ0G+I3dAm4XgzfSVTPLQpj/zAV2dV6Juy0yaf10/zrJOJeHoN3fraFe+XRq2bFVZA==", + "dev": true, + "requires": { + "@babel/generator": "^7.4.0", + "@babel/parser": "^7.4.3", + "@babel/template": "^7.4.0", + "@babel/traverse": "^7.4.3", + "@babel/types": "^7.4.0", + "istanbul-lib-coverage": "^2.0.5", + "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.0.0.tgz", + "integrity": "sha512-0UewU+9rFapKFnlbirLi3byoOuhrSsli/z/ihNnvM24vgF+8sNBiI1LZPBSH9wJKUwaUbw+s3hToDLCXkrghrQ==", + "dev": true + } + } + }, + "istanbul-lib-report": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-2.0.8.tgz", + "integrity": "sha512-fHBeG573EIihhAblwgxrSenp0Dby6tJMFR/HvlerBsrCTD5bkUuoNtn3gVh29ZCS824cGGBPn7Sg7cNk+2xUsQ==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^2.0.5", + "make-dir": "^2.1.0", + "supports-color": "^6.1.0" + }, + "dependencies": { + "supports-color": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", + "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "istanbul-lib-source-maps": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-3.0.6.tgz", + "integrity": "sha512-R47KzMtDJH6X4/YW9XTx+jrLnZnscW4VpNN+1PViSYTejLVPWv7oov+Duf8YQSPyVRUvueQqz1TcsC6mooZTXw==", + "dev": true, + "requires": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^2.0.5", + "make-dir": "^2.1.0", + "rimraf": "^2.6.3", + "source-map": "^0.6.1" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + }, + "rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "istanbul-reports": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-2.2.6.tgz", + "integrity": "sha512-SKi4rnMyLBKe0Jy2uUdx28h8oG7ph2PPuQPvIAh31d+Ci+lSiEu4C+h3oBPuJ9+mPKhOyW0M8gY4U5NM1WLeXA==", + "dev": true, + "requires": { + "handlebars": "^4.1.2" + } + }, + "js-tokens": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", + "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=" + }, + "js-yaml": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", + "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, + "json-parse-better-errors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", + "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", + "dev": true + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "json-stable-stringify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz", + "integrity": "sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8=", + "dev": true, + "requires": { + "jsonify": "~0.0.0" + } + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" + }, + "jsonify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", + "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=", + "dev": true + }, + "jsonpointer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.0.1.tgz", + "integrity": "sha1-T9kss04OnbPInIYi7PUfm5eMbLk=" + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + } + }, + "kuler": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-1.0.1.tgz", + "integrity": "sha512-J9nVUucG1p/skKul6DU3PUZrhs0LPulNaeUOox0IyXDi8S4CztTHs1gQphhuZmzXG7VOQSf6NJfKuzteQLv9gQ==", + "requires": { + "colornames": "^1.1.1" + } + }, + "lcid": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-2.0.0.tgz", + "integrity": "sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA==", + "dev": true, + "requires": { + "invert-kv": "^2.0.0" + } + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, + "lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=", + "requires": { + "immediate": "~3.0.5" + } + }, + "load-json-file": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", + "integrity": "sha1-L19Fq5HjMhYjT9U62rZo607AmTs=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^4.0.0", + "pify": "^3.0.0", + "strip-bom": "^3.0.0" + }, + "dependencies": { + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + } + } + }, + "localforage": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.7.3.tgz", + "integrity": "sha512-1TulyYfc4udS7ECSBT2vwJksWbkwwTX8BzeUIiq8Y07Riy7bDAAnxDaPU/tWyOVmQAcWJIEIFP9lPfBGqVoPgQ==", + "requires": { + "lie": "3.1.1" + } + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "lodash": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==" + }, + "lodash.flattendeep": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", + "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=", + "dev": true + }, + "lodash.padend": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.padend/-/lodash.padend-4.6.1.tgz", + "integrity": "sha1-U8y6BH0G4VjTEfRdpiX05J5vFm4=" + }, + "logform": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-1.10.0.tgz", + "integrity": "sha512-em5ojIhU18fIMOw/333mD+ZLE2fis0EzXl1ZwHx4iQzmpQi6odNiY/t+ITNr33JZhT9/KEaH+UPIipr6a9EjWg==", + "requires": { + "colors": "^1.2.1", + "fast-safe-stringify": "^2.0.4", + "fecha": "^2.3.3", + "ms": "^2.1.1", + "triple-beam": "^1.2.0" + }, + "dependencies": { + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + } + } + }, + "loglevel": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.6.1.tgz", + "integrity": "sha1-4PyVEztu8nbNyIh82vJKpvFW+Po=" + }, + "long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==" + }, + "lru-cache": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", + "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "dev": true, + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + }, + "dependencies": { + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", + "dev": true + } + } + }, + "make-dir": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", + "integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==", + "dev": true, + "requires": { + "pify": "^4.0.1", + "semver": "^5.6.0" + }, + "dependencies": { + "pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true + } + } + }, + "make-error": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.5.tgz", + "integrity": "sha512-c3sIjNUow0+8swNwVpqoH4YCShKNFkMaw6oH1mNS2haDZQqkeZFlHS3dhoeEbKKmJB4vXpJucU6oH75aDYeE9g==", + "dev": true + }, + "manakin": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/manakin/-/manakin-0.5.2.tgz", + "integrity": "sha512-pfDSB7QYoVg0Io4KMV9hhPoXpj6p0uBscgtyUSKCOFZe8bqgbpStfgnKIbF/ulnr6U3ICu4OqdyxAqBgOhZwBQ==" + }, + "map-age-cleaner": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz", + "integrity": "sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==", + "dev": true, + "requires": { + "p-defer": "^1.0.0" + } + }, + "matrix-appservice": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/matrix-appservice/-/matrix-appservice-0.3.5.tgz", + "integrity": "sha512-oQcxlpERcUj90QbGjV7t5Ly5/Aze/sUwB9ZrIt1UMFwuNT+CgEzA7cxLDHAiJkXfgoNzFvjVnKJ3203oIuLONQ==", + "requires": { + "body-parser": "^1.18.3", + "express": "^4.16.3", + "js-yaml": "^3.2.7", + "morgan": "^1.9.1", + "request": "^2.88.0" + } + }, + "matrix-appservice-bridge": { + "version": "github:matrix-org/matrix-appservice-bridge#8a7288edf1d1d1d1395a83d330d836d9c9bf1e76", + "from": "github:matrix-org/matrix-appservice-bridge#8a7288edf1d1d1d1395a83d330d836d9c9bf1e76", + "requires": { + "bluebird": "^2.9.34", + "chalk": "^2.4.1", + "extend": "^3.0.0", + "is-my-json-valid": "^2.19.0", + "js-yaml": "^3.12.0", + "matrix-appservice": "0.3.5", + "matrix-js-sdk": "^1.0.1", + "nedb": "^1.1.3", + "nopt": "^3.0.3", + "prom-client": "^11.1.1", + "request": "^2.61.0", + "winston": "^3.1.0", + "winston-daily-rotate-file": "^3.3.3" + } + }, + "matrix-js-sdk": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-1.1.0.tgz", + "integrity": "sha512-ECoMN6DkwPdKiMa/jSoMkSDngFCo6x7oH84rLd1NtD7lBPl3Ejj6ARa0iIELE7u0OUO6J0FzdWh7Hd0ZnVTmww==", + "requires": { + "another-json": "^0.2.0", + "babel-runtime": "^6.26.0", + "base-x": "3.0.4", + "bluebird": "^3.5.0", + "browser-request": "^0.3.3", + "bs58": "^4.0.1", + "content-type": "^1.0.2", + "loglevel": "1.6.1", + "qs": "^6.5.2", + "request": "^2.88.0", + "unhomoglyph": "^1.0.2" + }, + "dependencies": { + "bluebird": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.4.tgz", + "integrity": "sha512-FG+nFEZChJrbQ9tIccIfZJBz3J7mLrAhxakAbnrJWn8d7aKOC+LWifa0G+p4ZqKp4y13T7juYvdhq9NzKdsrjw==" + } + } + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=" + }, + "mem": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/mem/-/mem-4.3.0.tgz", + "integrity": "sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w==", + "dev": true, + "requires": { + "map-age-cleaner": "^0.1.1", + "mimic-fn": "^2.0.0", + "p-is-promise": "^2.0.0" + } + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=" + }, + "merge-source-map": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/merge-source-map/-/merge-source-map-1.1.0.tgz", + "integrity": "sha512-Qkcp7P2ygktpMPh2mCQZaf3jhN6D3Z/qVZHSdWvQ+2Ef5HgRAPBO57A77+ENm0CPx2+1Ce/MYKi3ymqdfuqibw==", + "dev": true, + "requires": { + "source-map": "^0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=" + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, + "mime-db": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz", + "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==" + }, + "mime-types": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.24.tgz", + "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==", + "requires": { + "mime-db": "1.40.0" + } + }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" + }, + "minipass": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.3.5.tgz", + "integrity": "sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==", + "requires": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + }, + "minizlib": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.1.1.tgz", + "integrity": "sha512-TrfjCjk4jLhcJyGMYymBH6oTXcWjYbUAXTHDbtnWHjZC25h0cdajHuPE1zxb4DVmu8crfh+HwH/WMuyLG0nHBg==", + "requires": { + "minipass": "^2.2.1" + } + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "requires": { + "minimist": "0.0.8" + } + }, + "mocha": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-5.2.0.tgz", + "integrity": "sha512-2IUgKDhc3J7Uug+FxMXuqIyYzH7gJjXECKe/w43IGgQHTSj3InJi+yAA7T24L9bQMRKiUEHxEX37G5JpVUGLcQ==", + "dev": true, + "requires": { + "browser-stdout": "1.3.1", + "commander": "2.15.1", + "debug": "3.1.0", + "diff": "3.5.0", + "escape-string-regexp": "1.0.5", + "glob": "7.1.2", + "growl": "1.10.5", + "he": "1.1.1", + "minimatch": "3.0.4", + "mkdirp": "0.5.1", + "supports-color": "5.4.0" + }, + "dependencies": { + "commander": { + "version": "2.15.1", + "resolved": "http://registry.npmjs.org/commander/-/commander-2.15.1.tgz", + "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==", + "dev": true + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "glob": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz", + "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "supports-color": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", + "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "module-not-found-error": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz", + "integrity": "sha1-z4tP9PKWQGdNbN0CsOO8UjwrvcA=", + "dev": true + }, + "moment": { + "version": "2.22.2", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.22.2.tgz", + "integrity": "sha1-PCV/mDn8DpP/UxSWMiOeuQeD/2Y=" + }, + "morgan": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.9.1.tgz", + "integrity": "sha512-HQStPIV4y3afTiCYVxirakhlCfGkI161c76kKFca7Fk1JusM//Qeo1ej2XaMniiNeaZklMVrh3vTtIzpzwbpmA==", + "requires": { + "basic-auth": "~2.0.0", + "debug": "2.6.9", + "depd": "~1.1.2", + "on-finished": "~2.3.0", + "on-headers": "~1.0.1" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + }, + "mute-stream": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.5.tgz", + "integrity": "sha1-j7+rsKmKJT0xhDMfno3rc3L6xsA=", + "dev": true + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "dev": true + }, + "nedb": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/nedb/-/nedb-1.8.0.tgz", + "integrity": "sha1-DjUCzYLABNU1WkPJ5VV3vXvZHYg=", + "requires": { + "async": "0.2.10", + "binary-search-tree": "0.2.5", + "localforage": "^1.3.0", + "mkdirp": "~0.5.1", + "underscore": "~1.4.4" + } + }, + "negotiator": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", + "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" + }, + "neo-async": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.0.tgz", + "integrity": "sha512-MFh0d/Wa7vkKO3Y3LlacqAEeHK0mckVqzDieUKTT+KGxi+zIpeVsFxymkIiRpbpDziHc290Xr9A1O4Om7otoRA==", + "dev": true + }, + "nested-error-stacks": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.1.0.tgz", + "integrity": "sha512-AO81vsIO1k1sM4Zrd6Hu7regmJN1NSiAja10gc4bX3F0wd+9rQmcuHQaHVQCYIEC8iFXnE+mavh23GOt7wBgug==", + "dev": true + }, + "next-tick": { + "version": "1.0.0", + "resolved": "http://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", + "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=", + "dev": true + }, + "nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "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==", + "requires": { + "he": "1.1.1" + } + }, + "nopt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", + "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", + "requires": { + "abbrev": "1" + } + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + }, + "dependencies": { + "resolve": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.11.0.tgz", + "integrity": "sha512-WL2pBDjqT6pGUNSUzMw00o4T7If+z4H2x3Gz893WoUQ5KW8Vr9txp00ykiP16VBaZF5+j/OcXJHZ9+PCvdiDKw==", + "dev": true, + "requires": { + "path-parse": "^1.0.6" + } + } + } + }, + "npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=", + "dev": true, + "requires": { + "path-key": "^2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", + "dev": true + }, + "nyc": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-14.1.1.tgz", + "integrity": "sha512-OI0vm6ZGUnoGZv/tLdZ2esSVzDwUC88SNs+6JoSOMVxA+gKMB8Tk7jBwgemLx4O40lhhvZCVw1C+OYLOBOPXWw==", + "dev": true, + "requires": { + "archy": "^1.0.0", + "caching-transform": "^3.0.2", + "convert-source-map": "^1.6.0", + "cp-file": "^6.2.0", + "find-cache-dir": "^2.1.0", + "find-up": "^3.0.0", + "foreground-child": "^1.5.6", + "glob": "^7.1.3", + "istanbul-lib-coverage": "^2.0.5", + "istanbul-lib-hook": "^2.0.7", + "istanbul-lib-instrument": "^3.3.0", + "istanbul-lib-report": "^2.0.8", + "istanbul-lib-source-maps": "^3.0.6", + "istanbul-reports": "^2.2.4", + "js-yaml": "^3.13.1", + "make-dir": "^2.1.0", + "merge-source-map": "^1.1.0", + "resolve-from": "^4.0.0", + "rimraf": "^2.6.3", + "signal-exit": "^3.0.2", + "spawn-wrap": "^1.4.2", + "test-exclude": "^5.2.3", + "uuid": "^3.3.2", + "yargs": "^13.2.2", + "yargs-parser": "^13.0.0" + }, + "dependencies": { + "resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true + }, + "rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "dev": true, + "requires": { + "glob": "^7.1.3" + } + } + } + }, + "oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true + }, + "object-hash": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-1.3.0.tgz", + "integrity": "sha512-05KzQ70lSeGSrZJQXE5wNDiTkBJDlUT/myi6RX9dVIvz7a7Qh4oH93BQdiPMn27nldYvVQCKMUaM83AfizZlsQ==" + }, + "on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=", + "requires": { + "ee-first": "1.1.1" + } + }, + "on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==" + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "requires": { + "wrappy": "1" + } + }, + "one-time": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-0.0.4.tgz", + "integrity": "sha1-+M33eISCb+Tf+T46nMN7HkSAdC4=" + }, + "onetime": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", + "integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=", + "dev": true + }, + "optimist": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", + "integrity": "sha1-2j6nRob6IaGaERwybpDrFaAZZoY=", + "dev": true, + "requires": { + "minimist": "~0.0.1", + "wordwrap": "~0.0.2" + }, + "dependencies": { + "wordwrap": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", + "integrity": "sha1-o9XabNXAvAAI03I0u68b7WMFkQc=", + "dev": true + } + } + }, + "optionator": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz", + "integrity": "sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=", + "dev": true, + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.4", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "wordwrap": "~1.0.0" + } + }, + "os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", + "dev": true + }, + "os-locale": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-3.1.0.tgz", + "integrity": "sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q==", + "dev": true, + "requires": { + "execa": "^1.0.0", + "lcid": "^2.0.0", + "mem": "^4.0.0" + } + }, + "p-defer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", + "integrity": "sha1-n26xgvbJqozXQwBKfU+WsZaw+ww=", + "dev": true + }, + "p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", + "dev": true + }, + "p-is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-2.1.0.tgz", + "integrity": "sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg==", + "dev": true + }, + "p-limit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.0.tgz", + "integrity": "sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-queue": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-5.0.0.tgz", + "integrity": "sha512-6QfeouDf236N+MAxHch0CVIy8o/KBnmhttKjxZoOkUlzqU+u9rZgEyXH3OdckhTgawbqf5rpzmyR+07+Lv0+zg==", + "requires": { + "eventemitter3": "^3.1.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "package-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-3.0.0.tgz", + "integrity": "sha512-lOtmukMDVvtkL84rJHI7dpTYq+0rli8N2wlnqUcBuDWCfVhRUfOmnR9SsoHFMLpACvEV60dX7rd0rFaYDZI+FA==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.15", + "hasha": "^3.0.0", + "lodash.flattendeep": "^4.4.0", + "release-zalgo": "^1.0.0" + } + }, + "packet-reader": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/packet-reader/-/packet-reader-0.3.1.tgz", + "integrity": "sha1-zWLmCvjX/qinBexP+ZCHHEaHHyc=" + }, + "parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", + "dev": true, + "requires": { + "error-ex": "^1.3.1", + "json-parse-better-errors": "^1.0.1" + } + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=" + }, + "path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=", + "dev": true + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", + "dev": true + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==" + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=" + }, + "path-type": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz", + "integrity": "sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==", + "dev": true, + "requires": { + "pify": "^3.0.0" + }, + "dependencies": { + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + } + } + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + }, + "pg": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-7.5.0.tgz", + "integrity": "sha512-VFyAnp8xsMZp8nwZnMp7lmU5QcWDOZSI3IDNcWv6pblsiOXis5o7lD7/zzVK1Z1JTBiIDDGQAMbFMkiUzCL59A==", + "requires": { + "buffer-writer": "1.0.1", + "packet-reader": "0.3.1", + "pg-connection-string": "0.1.3", + "pg-pool": "~2.0.3", + "pg-types": "~1.12.1", + "pgpass": "1.x", + "semver": "4.3.2" + }, + "dependencies": { + "semver": { + "version": "4.3.2", + "resolved": "http://registry.npmjs.org/semver/-/semver-4.3.2.tgz", + "integrity": "sha1-x6BxWKgL7dBSNVt3DYLWZA+AO+c=" + } + } + }, + "pg-connection-string": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-0.1.3.tgz", + "integrity": "sha1-2hhHsglA5C7hSSvq9l1J2RskXfc=" + }, + "pg-minify": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/pg-minify/-/pg-minify-0.5.5.tgz", + "integrity": "sha512-7Pf9h6nV1RFqED1hkRosePqvpPwNUUtW06TT4+lHwzesxa5gffxkShTjYH6JXV5sSSfh5+2yHOTTWEkCyCQ0Eg==" + }, + "pg-pool": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-2.0.3.tgz", + "integrity": "sha1-wCIDLIlJ8xKk+R+2QJzgQHa+Mlc=" + }, + "pg-promise": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/pg-promise/-/pg-promise-8.5.1.tgz", + "integrity": "sha512-ddqTaE8a1WRssfy1Clpgpa7nKEzdtQ8rxrUYw8FD9F6Gkdi1qyUheiBQ2KqERVeML32obSm7TfRixWsQb542mw==", + "requires": { + "manakin": "0.5.2", + "pg": "7.5.0", + "pg-minify": "0.5.5", + "spex": "2.1.0" + } + }, + "pg-types": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-1.12.1.tgz", + "integrity": "sha1-1kCH45A7WP+q0nnnWVxSIIoUw9I=", + "requires": { + "postgres-array": "~1.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.0", + "postgres-interval": "^1.1.0" + } + }, + "pgpass": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.2.tgz", + "integrity": "sha1-Knu0G2BltnkH6R2hsHwYR8h3swY=", + "requires": { + "split": "^1.0.0" + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", + "dev": true + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "dev": true, + "requires": { + "pinkie": "^2.0.0" + } + }, + "pkg-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-3.0.0.tgz", + "integrity": "sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==", + "dev": true, + "requires": { + "find-up": "^3.0.0" + } + }, + "pluralize": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-1.2.1.tgz", + "integrity": "sha1-0aIUg/0iu0HlihL6NCGCMUCJfEU=", + "dev": true + }, + "postgres-array": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-1.0.3.tgz", + "integrity": "sha512-5wClXrAP0+78mcsNX3/ithQ5exKvCyK5lr5NEEEeGwwM6NJdQgzIJBVxLvRW+huFpX92F2QnZ5CcokH0VhK2qQ==" + }, + "postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha1-AntTPAqokOJtFy1Hz5zOzFIazTU=" + }, + "postgres-date": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.3.tgz", + "integrity": "sha1-4tiXAu/bJY/52c7g/pG9BpdSV6g=" + }, + "postgres-interval": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.1.2.tgz", + "integrity": "sha512-fC3xNHeTskCxL1dC8KOtxXt7YeFmlbTYtn7ul8MkVERuTmf7pI4DrkAxcw3kh1fQ9uz4wQmd03a1mRiXUZChfQ==", + "requires": { + "xtend": "^4.0.0" + } + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", + "dev": true + }, + "prism-media": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/prism-media/-/prism-media-0.0.3.tgz", + "integrity": "sha512-c9KkNifSMU/iXT8FFTaBwBMr+rdVcN+H/uNv1o+CuFeTThNZNTOrQ+RgXA1yL/DeLk098duAeRPP3QNPNbhxYQ==" + }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==" + }, + "progress": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/progress/-/progress-1.1.8.tgz", + "integrity": "sha1-4mDHj2Fhzdmw5WzD4Khd4Xx6V74=", + "dev": true + }, + "prom-client": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-11.3.0.tgz", + "integrity": "sha512-OqSf5WOvpGZXkfqPXUHNHpjrbEE/q8jxjktO0i7zg1cnULAtf0ET67/J5R4e4iA4MZx2260tzTzSFSWgMdTZmQ==", + "requires": { + "tdigest": "^0.1.1" + } + }, + "proxy-addr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.5.tgz", + "integrity": "sha512-t/7RxHXPH6cJtP0pRG6smSr9QJidhB+3kXu0KgXnbGYMgzEnUxRQ4/LDdfOwZEMyIh3/xHb8PX3t+lfL9z+YVQ==", + "requires": { + "forwarded": "~0.1.2", + "ipaddr.js": "1.9.0" + } + }, + "proxyquire": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-1.8.0.tgz", + "integrity": "sha1-AtUUpb7ZhvBMuyCTrxZ0FTX3ntw=", + "dev": true, + "requires": { + "fill-keys": "^1.0.2", + "module-not-found-error": "^1.0.0", + "resolve": "~1.1.7" + }, + "dependencies": { + "resolve": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz", + "integrity": "sha1-IDEU2CrSxe2ejgQRs5ModeiJ6Xs=", + "dev": true + } + } + }, + "pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", + "dev": true + }, + "psl": { + "version": "1.1.31", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.1.31.tgz", + "integrity": "sha512-/6pt4+C+T+wZUieKR620OpzN/LlnNKuWjy1iFLQ/UG35JqHlR/89MP1d96dUfkf6Dne3TuLQzOYEYshJ+Hx8mw==" + }, + "pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + }, + "qs": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", + "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "raw-body": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.0.tgz", + "integrity": "sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==", + "requires": { + "bytes": "3.1.0", + "http-errors": "1.7.2", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, + "read-pkg": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", + "integrity": "sha1-nLxoaXj+5l0WwA4rGcI3/Pbjg4k=", + "dev": true, + "requires": { + "load-json-file": "^4.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^3.0.0" + } + }, + "read-pkg-up": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-4.0.0.tgz", + "integrity": "sha512-6etQSH7nJGsK0RbG/2TeDzZFa8shjQ1um+SwQQ5cwKy0dhSXdOncEhb1CPpvQG4h7FyOV6EB6YlV0yJvZQNAkA==", + "dev": true, + "requires": { + "find-up": "^3.0.0", + "read-pkg": "^3.0.0" + } + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "readline2": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/readline2/-/readline2-1.0.1.tgz", + "integrity": "sha1-QQWWCP/BVHV7cV2ZidGZ/783LjU=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "mute-stream": "0.0.5" + } + }, + "rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=", + "dev": true, + "requires": { + "resolve": "^1.1.6" + } + }, + "reduce-flatten": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/reduce-flatten/-/reduce-flatten-1.0.1.tgz", + "integrity": "sha1-JYx479FT3fk8tWEjf2EYTzaW4yc=" + }, + "regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==" + }, + "release-zalgo": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", + "integrity": "sha1-CXALflB0Mpc5Mw5TXFqQ+2eFFzA=", + "dev": true, + "requires": { + "es6-error": "^4.0.1" + } + }, + "request": { + "version": "2.88.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz", + "integrity": "sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==", + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.0", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.4.3", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "dependencies": { + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" + } + } + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", + "dev": true + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "require-uncached": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/require-uncached/-/require-uncached-1.0.3.tgz", + "integrity": "sha1-Tg1W1slmL9MeQwEcS5WqSZVUIdM=", + "dev": true, + "requires": { + "caller-path": "^0.1.0", + "resolve-from": "^1.0.0" + } + }, + "resolve": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.8.1.tgz", + "integrity": "sha512-AicPrAC7Qu1JxPCZ9ZgCZlY35QgFnNqc+0LtbRNxnVw4TXvjQ72wnuL9JQcEBgXkI9JM8MsT9kaQoHcpCRJOYA==", + "requires": { + "path-parse": "^1.0.5" + } + }, + "resolve-from": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-1.0.1.tgz", + "integrity": "sha1-Jsv+k10a7uq7Kbw/5a6wHpPUQiY=", + "dev": true + }, + "restore-cursor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz", + "integrity": "sha1-NGYfRohjJ/7SmRR5FSJS35LapUE=", + "dev": true, + "requires": { + "exit-hook": "^1.0.0", + "onetime": "^1.0.0" + } + }, + "rimraf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.2.tgz", + "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==", + "dev": true, + "requires": { + "glob": "^7.0.5" + } + }, + "run-async": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-0.1.0.tgz", + "integrity": "sha1-yK1KXhEGYeQCp9IbUw4AnyX444k=", + "dev": true, + "requires": { + "once": "^1.3.0" + } + }, + "rx-lite": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/rx-lite/-/rx-lite-3.1.2.tgz", + "integrity": "sha1-Gc5QLKVyZl87ZHsQk5+X/RYV8QI=", + "dev": true + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "semver": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz", + "integrity": "sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==" + }, + "send": { + "version": "0.16.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.16.2.tgz", + "integrity": "sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw==", + "requires": { + "debug": "2.6.9", + "depd": "~1.1.2", + "destroy": "~1.0.4", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "~1.6.2", + "mime": "1.4.1", + "ms": "2.0.0", + "on-finished": "~2.3.0", + "range-parser": "~1.2.0", + "statuses": "~1.4.0" + }, + "dependencies": { + "http-errors": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", + "integrity": "sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=", + "requires": { + "depd": "~1.1.2", + "inherits": "2.0.3", + "setprototypeof": "1.1.0", + "statuses": ">= 1.4.0 < 2" + } + }, + "mime": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz", + "integrity": "sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==" + }, + "setprototypeof": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", + "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==" + }, + "statuses": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz", + "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==" + } + } + }, + "serve-static": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.13.2.tgz", + "integrity": "sha512-p/tdJrO4U387R9oMjb1oj7qSMaMfmOyd4j9hOFoxZe2baQszgHcSWjuya/CiT5kgZZKRudHNOA0pYXOl8rQ5nw==", + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.2", + "send": "0.16.2" + } + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", + "dev": true + }, + "setprototypeof": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", + "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", + "dev": true + }, + "shelljs": { + "version": "0.7.8", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.7.8.tgz", + "integrity": "sha1-3svPh0sNHl+3LhSxZKloMEjprLM=", + "dev": true, + "requires": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + } + }, + "signal-exit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", + "dev": true + }, + "simple-markdown": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/simple-markdown/-/simple-markdown-0.4.4.tgz", + "integrity": "sha512-ZmlNUGR1KI12sPHeQ7dQY1qM5KfOgFqClNNVO8zQ9Pg6u7gHLCPFGD+VC7MCwpGDMd1uw3Bb2TfFfR8d6bB34A==" + }, + "simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=", + "requires": { + "is-arrayish": "^0.3.1" + } + }, + "slice-ansi": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz", + "integrity": "sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU=", + "dev": true + }, + "snekfetch": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/snekfetch/-/snekfetch-3.6.4.tgz", + "integrity": "sha512-NjxjITIj04Ffqid5lqr7XdgwM7X61c/Dns073Ly170bPQHLm6jkmelye/eglS++1nfTWktpP6Y2bFXjdPlQqdw==" + }, + "source-map": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.2.0.tgz", + "integrity": "sha1-2rc/vPwrqBm03gO9b26qSBZLP50=", + "dev": true, + "optional": true, + "requires": { + "amdefine": ">=0.0.4" + } + }, + "source-map-support": { + "version": "0.5.12", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.12.tgz", + "integrity": "sha512-4h2Pbvyy15EE02G+JOZpUCmqWJuqrs+sEkzewTm++BPi7Hvn/HwcqLAcNxYAyI0x13CpPPn+kMjl+hplXMHITQ==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "spawn-wrap": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-1.4.2.tgz", + "integrity": "sha512-vMwR3OmmDhnxCVxM8M+xO/FtIp6Ju/mNaDfCMMW7FDcLRTPFWUswec4LXJHTJE2hwTI9O0YBfygu4DalFl7Ylg==", + "dev": true, + "requires": { + "foreground-child": "^1.5.6", + "mkdirp": "^0.5.0", + "os-homedir": "^1.0.1", + "rimraf": "^2.6.2", + "signal-exit": "^3.0.2", + "which": "^1.3.0" + } + }, + "spdx-correct": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.0.tgz", + "integrity": "sha512-lr2EZCctC2BNR7j7WzJ2FpDznxky1sjfxvvYEyzxNyb6lZXHODmEoJeFu4JupYlkfha1KZpJyoqiJ7pgA1qq8Q==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz", + "integrity": "sha512-2XQACfElKi9SlVb1CYadKDXvoajPgBVPn/gOQLrTvHdElaVhr7ZEbqJaRnJLVNeaI4cMEAgVCeBMKF6MWRDCRA==", + "dev": true + }, + "spdx-expression-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz", + "integrity": "sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.4.tgz", + "integrity": "sha512-7j8LYJLeY/Yb6ACbQ7F76qy5jHkp0U6jgBfJsk97bwWlVUnUWsAgpyaCvo17h0/RQGnQ036tVDomiwoI4pDkQA==", + "dev": true + }, + "spex": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/spex/-/spex-2.1.0.tgz", + "integrity": "sha512-nZ1LA8v1o0Maf9pdWKUXuUM855EqyE+DP0NT0ddZqXqXmr9xKlXjYWN97w+yWehTbM+Ox0aEvQ8Ufqk/OuLCOQ==" + }, + "split": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", + "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", + "requires": { + "through": "2" + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" + }, + "sshpk": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", + "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "dependencies": { + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" + } + } + }, + "stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=" + }, + "stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha1-Gsig2Ug4SNFpXkGLbQMaPDzmjjs=", + "dev": true + }, + "statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=" + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + }, + "strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=", + "dev": true + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "requires": { + "has-flag": "^3.0.0" + } + }, + "table": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/table/-/table-3.8.3.tgz", + "integrity": "sha1-K7xULw/amGGnVdOUf+/Ys/UThV8=", + "dev": true, + "requires": { + "ajv": "^4.7.0", + "ajv-keywords": "^1.0.0", + "chalk": "^1.1.1", + "lodash": "^4.0.0", + "slice-ansi": "0.0.4", + "string-width": "^2.0.0" + }, + "dependencies": { + "ajv": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-4.11.8.tgz", + "integrity": "sha1-gv+wKynmYq5TvcIK8VlHcGc5xTY=", + "dev": true, + "requires": { + "co": "^4.6.0", + "json-stable-stringify": "^1.0.1" + } + }, + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "chalk": { + "version": "1.1.3", + "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + }, + "dependencies": { + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + } + } + }, + "table-layout": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/table-layout/-/table-layout-0.4.4.tgz", + "integrity": "sha512-uNaR3SRMJwfdp9OUr36eyEi6LLsbcTqTO/hfTsNviKsNeyMBPICJCC7QXRF3+07bAP6FRwA8rczJPBqXDc0CkQ==", + "requires": { + "array-back": "^2.0.0", + "deep-extend": "~0.6.0", + "lodash.padend": "^4.6.1", + "typical": "^2.6.1", + "wordwrapjs": "^3.0.0" + } + }, + "tar": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.7.tgz", + "integrity": "sha512-mR3MzsCdN0IEWjZRuF/J9gaWHnTwOvzjqPTcvi1xXgfKTDQRp39gRETPQEfPByAdEOGmZfx1HrRsn8estaEvtA==", + "requires": { + "chownr": "^1.1.1", + "fs-minipass": "^1.2.5", + "minipass": "^2.3.4", + "minizlib": "^1.1.1", + "mkdirp": "^0.5.0", + "safe-buffer": "^5.1.2", + "yallist": "^3.0.2" + } + }, + "tdigest": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.1.tgz", + "integrity": "sha1-Ljyyw56kSeVdHmzZEReszKRYgCE=", + "requires": { + "bintrees": "1.0.1" + } + }, + "test-exclude": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-5.2.3.tgz", + "integrity": "sha512-M+oxtseCFO3EDtAaGH7iiej3CBkzXqFMbzqYAACdzKui4eZA+pq3tZEwChvOdNfa7xxy8BfbmgJSIr43cC/+2g==", + "dev": true, + "requires": { + "glob": "^7.1.3", + "minimatch": "^3.0.4", + "read-pkg-up": "^4.0.0", + "require-main-filename": "^2.0.0" + } + }, + "test-value": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/test-value/-/test-value-2.1.0.tgz", + "integrity": "sha1-Edpv9nDzRxpztiXKTz/c97t0gpE=", + "requires": { + "array-back": "^1.0.3", + "typical": "^2.6.0" + }, + "dependencies": { + "array-back": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/array-back/-/array-back-1.0.4.tgz", + "integrity": "sha1-ZEun8JX3/898Q7Xw3DnTwfA8Bjs=", + "requires": { + "typical": "^2.6.0" + } + } + } + }, + "text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=" + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true + }, + "toidentifier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.0.tgz", + "integrity": "sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==" + }, + "tough-cookie": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz", + "integrity": "sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==", + "requires": { + "psl": "^1.1.24", + "punycode": "^1.4.1" + }, + "dependencies": { + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" + } + } + }, + "trim-right": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz", + "integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=", + "dev": true + }, + "triple-beam": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.3.0.tgz", + "integrity": "sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==" + }, + "ts-node": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-8.1.0.tgz", + "integrity": "sha512-34jpuOrxDuf+O6iW1JpgTRDFynUZ1iEqtYruBqh35gICNjN8x+LpVcPAcwzLPi9VU6mdA3ym+x233nZmZp445A==", + "dev": true, + "requires": { + "arg": "^4.1.0", + "diff": "^3.1.0", + "make-error": "^1.1.1", + "source-map-support": "^0.5.6", + "yn": "^3.0.0" + } + }, + "tslib": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz", + "integrity": "sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==" + }, + "tslint": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/tslint/-/tslint-5.11.0.tgz", + "integrity": "sha1-mPMMAurjzecAYgHkwzywi0hYHu0=", + "requires": { + "babel-code-frame": "^6.22.0", + "builtin-modules": "^1.1.1", + "chalk": "^2.3.0", + "commander": "^2.12.1", + "diff": "^3.2.0", + "glob": "^7.1.1", + "js-yaml": "^3.7.0", + "minimatch": "^3.0.4", + "resolve": "^1.3.2", + "semver": "^5.3.0", + "tslib": "^1.8.0", + "tsutils": "^2.27.2" + } + }, + "tsutils": { + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-2.29.0.tgz", + "integrity": "sha512-g5JVHCIJwzfISaXpXE1qvNalca5Jwob6FjI4AoPlqMusJ6ftFE7IkkFoMhVLRgK+4Kx3gkzb8UZK5t5yTTvEmA==", + "requires": { + "tslib": "^1.8.1" + } + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "tweetnacl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.1.tgz", + "integrity": "sha512-kcoMoKTPYnoeS50tzoqjPY3Uv9axeuuFAZY9M/9zFnhoVvRfxz9K29IMPD7jGmt2c8SW7i3gT9WqDl2+nV7p4A==" + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2" + } + }, + "type-detect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-1.0.0.tgz", + "integrity": "sha1-diIXzAbbJY7EiQihKY6LlRIejqI=", + "dev": true + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", + "dev": true + }, + "typescript": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.1.6.tgz", + "integrity": "sha512-tDMYfVtvpb96msS1lDX9MEdHrW4yOuZ4Kdc4Him9oU796XldPYF/t2+uKoX0BBa0hXXwDlqYQbXY5Rzjzc5hBA==" + }, + "typical": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/typical/-/typical-2.6.1.tgz", + "integrity": "sha1-XAgOXWYcu+OCWdLnCjxyU+hziB0=" + }, + "uglify-js": { + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.4.9.tgz", + "integrity": "sha512-8CJsbKOtEbnJsTyv6LE6m6ZKniqMiFWmm9sRbopbkGs3gMPPfd3Fh8iIA4Ykv5MgaTbqHr4BaoGLJLZNhsrW1Q==", + "dev": true, + "optional": true, + "requires": { + "commander": "~2.17.1", + "source-map": "~0.6.1" + }, + "dependencies": { + "commander": { + "version": "2.17.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz", + "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==", + "dev": true, + "optional": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "optional": true + } + } + }, + "underscore": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.4.4.tgz", + "integrity": "sha1-YaajIBBiKvoHljvzJSA88SI51gQ=" + }, + "unhomoglyph": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.2.tgz", + "integrity": "sha1-1p5fWmocayEZQaCIm4HrqGWVwlM=" + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=" + }, + "uri-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", + "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "requires": { + "punycode": "^2.1.0" + } + }, + "user-home": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/user-home/-/user-home-2.0.0.tgz", + "integrity": "sha1-nHC/2Babwdy/SGBODwS4tJzenp8=", + "dev": true, + "requires": { + "os-homedir": "^1.0.0" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=" + }, + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + }, + "validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", + "dev": true + }, + "why-is-node-running": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.0.3.tgz", + "integrity": "sha512-XmzbFN2T859avcs5qAsiiK1iu0nUpSUXRgiGsoHPcNijxhIlp1bPQWQk6ANUljDWqBtAbIR2jF1HxR0y2l2kCA==", + "dev": true, + "requires": { + "stackback": "0.0.2" + } + }, + "winston": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.1.0.tgz", + "integrity": "sha512-FsQfEE+8YIEeuZEYhHDk5cILo1HOcWkGwvoidLrDgPog0r4bser1lEIOco2dN9zpDJ1M88hfDgZvxe5z4xNcwg==", + "requires": { + "async": "^2.6.0", + "diagnostics": "^1.1.1", + "is-stream": "^1.1.0", + "logform": "^1.9.1", + "one-time": "0.0.4", + "readable-stream": "^2.3.6", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.2.0" + }, + "dependencies": { + "async": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.1.tgz", + "integrity": "sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ==", + "requires": { + "lodash": "^4.17.10" + } + } + } + }, + "winston-compat": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/winston-compat/-/winston-compat-0.1.4.tgz", + "integrity": "sha512-mMEfFsSm6GmkFF+f4/0UJtG4N1vSaczGmXLVJYmS/+u2zUaIPcw2ZRuwUg2TvVBjswgiraN+vNnAG8z4fRUZ4w==", + "requires": { + "cycle": "~1.0.3", + "logform": "^1.6.0", + "triple-beam": "^1.2.0" + } + }, + "winston-daily-rotate-file": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/winston-daily-rotate-file/-/winston-daily-rotate-file-3.4.1.tgz", + "integrity": "sha512-1OzcNY0AmfNFGQJIF4Q4Z024NwQSzog4H0vDnrg9Q2pHtTRvbDbhL/OFYEFa5ZQ0He7Wcq+kY2kMazVeqYctDA==", + "requires": { + "file-stream-rotator": "^0.4.1", + "object-hash": "^1.3.0", + "semver": "^5.6.0", + "triple-beam": "^1.3.0", + "winston-compat": "^0.1.4", + "winston-transport": "^4.2.0" + } + }, + "winston-transport": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.2.0.tgz", + "integrity": "sha512-0R1bvFqxSlK/ZKTH86nymOuKv/cT1PQBMuDdA7k7f0S9fM44dNH6bXnuxwXPrN8lefJgtZq08BKdyZ0DZIy/rg==", + "requires": { + "readable-stream": "^2.3.6", + "triple-beam": "^1.2.0" + } + }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", + "dev": true + }, + "wordwrapjs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/wordwrapjs/-/wordwrapjs-3.0.0.tgz", + "integrity": "sha512-mO8XtqyPvykVCsrwj5MlOVWvSnCdT+C+QVbm6blradR7JExAhbkZ7hZ9A+9NUtwzSqrlUo9a67ws0EiILrvRpw==", + "requires": { + "reduce-flatten": "^1.0.1", + "typical": "^2.6.1" + } + }, + "wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" + }, + "write": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/write/-/write-0.2.1.tgz", + "integrity": "sha1-X8A4KOJkzqP+kUVUdvejxWbLB1c=", + "dev": true, + "requires": { + "mkdirp": "^0.5.1" + } + }, + "write-file-atomic": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-2.4.2.tgz", + "integrity": "sha512-s0b6vB3xIVRLWywa6X9TOMA7k9zio0TMOsl9ZnDkliA/cfJlpHXAscj0gbHVJiTdIuAYpIyqS5GW91fqm6gG5g==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.11", + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.2" + } + }, + "ws": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.1.tgz", + "integrity": "sha512-GIyAXC2cB7LjvpgMt9EKS2ldqr0MTrORaleiOno6TweZ6r3TKtoFQWay/2PceJ3RuBasOHzXNn5Lrw1X0bEjqA==", + "requires": { + "async-limiter": "~1.0.0" + } + }, + "xtend": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", + "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=" + }, + "y18n": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", + "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "dev": true + }, + "yallist": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.2.tgz", + "integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=" + }, + "yargs": { + "version": "13.2.4", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.2.4.tgz", + "integrity": "sha512-HG/DWAJa1PAnHT9JAhNa8AbAv3FPaiLzioSjCcmuXXhP8MlpHO5vwls4g4j6n30Z74GVQj8Xa62dWVx1QCGklg==", + "dev": true, + "requires": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "os-locale": "^3.1.0", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.0" + }, + "dependencies": { + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + } + } + }, + "yargs-parser": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.0.tgz", + "integrity": "sha512-Yq+32PrijHRri0vVKQEm+ys8mbqWjLiwQkMFNXEENutzLPP0bE4Lcd4iA3OQY5HF+GD3xXxf0MEHb8E4/SA3AA==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + }, + "yn": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.0.tgz", + "integrity": "sha512-kKfnnYkbTfrAdd0xICNFw7Atm8nKpLcLv9AZGEt+kczL/WQVai4e2V6ZN8U/O+iI6WrNuJjNNOyu4zfhl9D3Hg==", + "dev": true + } + } +} diff --git a/package.json b/package.json index 5566300968bf18cd7cef48723e7aad801484f95e..4e19eaafab2e26daad71163d7d7bdf4ea1839f68 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,20 @@ { "name": "matrix-appservice-discord", - "version": "0.0.1", + "version": "0.5.0-rc2", "description": "A bridge between Matrix and Discord", "main": "discordas.js", "scripts": { - "test": "npm run-script build && mocha --opts test/mocha.opts build/test", - "lint": "tslint --project ./tsconfig.json", - "coverage": "istanbul --include-all-sources cover -x build/src/discordas.js _mocha -- build/test/ -R spec", + "test": "npm run-script build && mocha --opts test/mocha.opts", + "lint": "tslint --project ./tsconfig.json -t stylish", + "coverage": "tsc && nyc mocha", "build": "tsc", "start": "npm run-script build && node ./build/src/discordas.js -p 9005 -c config.yaml", - "getbotlink": "node ./tools/addbot.js" + "addbot": "node ./build/tools/addbot.js", + "adminme": "node ./build/tools/adminme.js", + "usertool": "node ./build/tools/userClientTools.js", + "directoryfix": "node ./build/tools/addRoomsToDirectory.js", + "ghostfix": "node ./build/tools/ghostfix.js", + "chanfix": "node ./build/tools/chanfix.js" }, "repository": { "type": "git", @@ -23,32 +28,44 @@ "as" ], "author": "Half-Shot", - "license": "MIT", + "license": "Apache-2.0", "bugs": { "url": "https://github.com/Half-Shot/matrix-appservice-discord/issues" }, "homepage": "https://github.com/Half-Shot/matrix-appservice-discord#readme", "dependencies": { - "@types/node": "^7.0.5", - "bluebird": "^3.4.7", - "discord.js": "^11.0.0", - "js-yaml": "^3.8.1", - "marked": "^0.3.6", - "matrix-appservice-bridge": "^1.3.5", - "mime": "^1.3.4", - "npmlog": "^4.0.2", - "tslint": "^4.4.2", - "typescript": "^2.1.6" + "better-sqlite3": "^5.0.1", + "command-line-args": "^4.0.1", + "command-line-usage": "^4.1.0", + "discord-markdown": "^2.0.0", + "discord.js": "^11.5.0", + "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", + "p-queue": "^5.0.0", + "pg-promise": "^8.5.1", + "tslint": "^5.11.0", + "typescript": "^3.1.3", + "winston": "^3.0.0", + "winston-daily-rotate-file": "^3.3.0" }, "devDependencies": { + "@istanbuljs/nyc-config-typescript": "^0.1.3", "@types/chai": "^3.4.35", - "@types/chai-as-promised": "0.0.29", - "@types/mocha": "^2.2.39", + "@types/mocha": "^5.2.5", + "@types/node": "^10.12.0", + "@types/sqlite3": "^3.1.3", "chai": "^3.5.0", - "chai-as-promised": "^6.0.0", "eslint": "^3.8.1", "istanbul": "^0.4.5", - "mocha": "^3.1.2", - "proxyquire": "^1.7.11" + "mocha": "^5.2.0", + "nyc": "^14.1.1", + "proxyquire": "^1.7.11", + "source-map-support": "^0.5.12", + "ts-node": "^8.1.0", + "why-is-node-running": "^2.0.3" } } diff --git a/screenshot.png b/screenshot.png index 68c159a3cb2f100472f6ea05a75ae672c48d8303..1e87219314bcbc0eacbb6969e98bef6d14b0b0f5 100644 Binary files a/screenshot.png and b/screenshot.png differ diff --git a/src/bot.ts b/src/bot.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c7ec056e84340912f46988dd067875841472b48 --- /dev/null +++ b/src/bot.ts @@ -0,0 +1,943 @@ +/* +Copyright 2017 - 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 { DiscordBridgeConfig } from "./config"; +import { DiscordClientFactory } from "./clientfactory"; +import { DiscordStore } from "./store"; +import { DbEmoji } from "./db/dbdataemoji"; +import { DbEvent } from "./db/dbdataevent"; +import { MatrixUser, RemoteUser, Bridge, Entry, Intent } from "matrix-appservice-bridge"; +import { Util } from "./util"; +import { + DiscordMessageProcessor, + DiscordMessageProcessorOpts, + DiscordMessageProcessorResult, +} from "./discordmessageprocessor"; +import { MatrixEventProcessor, MatrixEventProcessorOpts, IMatrixEventProcessorResult } from "./matrixeventprocessor"; +import { PresenceHandler } from "./presencehandler"; +import { Provisioner } from "./provisioner"; +import { UserSyncroniser } from "./usersyncroniser"; +import { ChannelSyncroniser } from "./channelsyncroniser"; +import { MatrixRoomHandler } from "./matrixroomhandler"; +import { Log } from "./log"; +import * as Discord from "discord.js"; +import * as mime from "mime"; +import { IMatrixEvent, IMatrixMediaInfo } from "./matrixtypes"; +import { DiscordCommandHandler } from "./discordcommandhandler"; + +const log = new Log("DiscordBot"); + +const MIN_PRESENCE_UPDATE_DELAY = 250; +const CACHE_LIFETIME = 90000; + +// TODO: This is bad. We should be serving the icon from the own homeserver. +const MATRIX_ICON_URL = "https://matrix.org/_matrix/media/r0/download/matrix.org/mlxoESwIsTbJrfXyAAogrNxA"; +class ChannelLookupResult { + public channel: Discord.TextChannel; + public botUser: boolean; +} + +interface IThirdPartyLookupField { + channel_id: string; + channel_name: string; + guild_id: string; +} + +interface IThirdPartyLookup { + alias: string; + fields: IThirdPartyLookupField; + protocol: string; +} + +export class DiscordBot { + private clientFactory: DiscordClientFactory; + private bot: Discord.Client; + private presenceInterval: number; + private sentMessages: string[]; + private lastEventIds: { [channelId: string]: string }; + private discordMsgProcessor: DiscordMessageProcessor; + private mxEventProcessor: MatrixEventProcessor; + private presenceHandler: PresenceHandler; + private userSync!: UserSyncroniser; + private channelSync: ChannelSyncroniser; + private roomHandler: MatrixRoomHandler; + private provisioner: Provisioner; + private discordCommandHandler: DiscordCommandHandler; + /* Caches */ + private roomIdsForGuildCache: Map<string, {roomIds: string[], ts: number}> = new Map(); + + /* 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<{}>>; + constructor( + private botUserId: string, + private config: DiscordBridgeConfig, + private bridge: Bridge, + private store: DiscordStore, + ) { + + // create handlers + this.clientFactory = new DiscordClientFactory(store, config.auth); + this.discordMsgProcessor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts(config.bridge.domain, this), + ); + this.presenceHandler = new PresenceHandler(this); + this.roomHandler = new MatrixRoomHandler(this, config, this.provisioner, bridge, store.roomStore); + this.channelSync = new ChannelSyncroniser(bridge, config, this, store.roomStore); + this.provisioner = new Provisioner(store.roomStore, this.channelSync); + this.mxEventProcessor = new MatrixEventProcessor( + new MatrixEventProcessorOpts(config, bridge, this), + ); + this.discordCommandHandler = new DiscordCommandHandler(bridge, this); + // init vars + this.sentMessages = []; + this.discordMessageQueue = {}; + this.channelLocks = new Map(); + this.channelLockPromises = new Map(); + this.lastEventIds = {}; + } + + get ClientFactory(): DiscordClientFactory { + return this.clientFactory; + } + + get UserSyncroniser(): UserSyncroniser { + return this.userSync; + } + + get ChannelSyncroniser(): ChannelSyncroniser { + return this.channelSync; + } + + get BotUserId(): string { + return this.botUserId; + } + + get RoomHandler(): MatrixRoomHandler { + return this.roomHandler; + } + + get MxEventProcessor(): MatrixEventProcessor { + return this.mxEventProcessor; + } + + get Provisioner(): Provisioner { + 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_ + const name = member instanceof Discord.User ? member.username : member.user.username; + const nameId = new MatrixUser(`@${name}`).localpart; + return this.bridge.getIntentFromLocalpart(`_discord_${webhookID}_${nameId}`); + } + return this.bridge.getIntentFromLocalpart(`_discord_${member.id}`); + } + + public async init(): Promise<void> { + await this.clientFactory.init(); + // This immediately pokes UserStore, so it must be created after the bridge has started. + this.userSync = new UserSyncroniser(this.bridge, this.config, this, this.store.userStore); + } + + public async run(): Promise<void> { + const client = await this.clientFactory.getClient(); + if (!this.config.bridge.disableTypingNotifications) { + client.on("typingStart", async (c, u) => { + try { + await this.OnTyping(c, u, true); + } catch (err) { log.warning("Exception thrown while handling \"typingStart\" event", err); } + }); + client.on("typingStop", async (c, u) => { + try { + await this.OnTyping(c, u, false); + } catch (err) { log.warning("Exception thrown while handling \"typingStop\" event", err); } + }); + } + if (!this.config.bridge.disablePresence) { + client.on("presenceUpdate", (_, newMember: Discord.GuildMember) => { + try { + this.presenceHandler.EnqueueUser(newMember.user); + } catch (err) { log.warning("Exception thrown while handling \"presenceUpdate\" event", err); } + }); + } + client.on("channelUpdate", async (_, newChannel) => { + try { + await this.channelSync.OnUpdate(newChannel); + } catch (err) { log.error("Exception thrown while handling \"channelUpdate\" event", err); } + }); + client.on("channelDelete", async (channel) => { + try { + await this.channelSync.OnDelete(channel); + } catch (err) { log.error("Exception thrown while handling \"channelDelete\" event", err); } + }); + client.on("guildUpdate", async (_, newGuild) => { + try { + await this.channelSync.OnGuildUpdate(newGuild); + } catch (err) { log.error("Exception thrown while handling \"guildUpdate\" event", err); } + }); + client.on("guildDelete", async (guild) => { + try { + await this.channelSync.OnGuildDelete(guild); + } catch (err) { log.error("Exception thrown while handling \"guildDelete\" event", err); } + }); + + // Due to messages often arriving before we get a response from the send call, + // messages get delayed from discord. We use Util.DelayedPromise to handle this. + + client.on("messageDelete", async (msg: Discord.Message) => { + try { + await this.waitUnlock(msg.channel); + this.discordMessageQueue[msg.channel.id] = (async () => { + await (this.discordMessageQueue[msg.channel.id] || Promise.resolve()); + try { + await this.DeleteDiscordMessage(msg); + } catch (err) { + log.error("Caught while handing 'messageDelete'", err); + } + })(); + } catch (err) { + log.error("Exception thrown while handling \"messageDelete\" event", err); + } + }); + client.on("messageDeleteBulk", async (msgs: Discord.Collection<Discord.Snowflake, Discord.Message>) => { + try { + await Util.DelayedPromise(this.config.limits.discordSendDelay); + const promiseArr: (() => Promise<void>)[] = []; + msgs.forEach((msg) => { + promiseArr.push(async () => { + try { + await this.waitUnlock(msg.channel); + await this.DeleteDiscordMessage(msg); + } catch (err) { + log.error("Caught while handling 'messageDeleteBulk'", err); + } + }); + }); + await Promise.all(promiseArr); + } catch (err) { + log.error("Exception thrown while handling \"messageDeleteBulk\" event", err); + } + }); + client.on("messageUpdate", async (oldMessage: Discord.Message, newMessage: Discord.Message) => { + try { + await this.waitUnlock(newMessage.channel); + this.discordMessageQueue[newMessage.channel.id] = (async () => { + await (this.discordMessageQueue[newMessage.channel.id] || Promise.resolve()); + try { + await this.OnMessageUpdate(oldMessage, newMessage); + } catch (err) { + log.error("Caught while handing 'messageUpdate'", err); + } + })(); + } catch (err) { + log.error("Exception thrown while handling \"messageUpdate\" event", err); + } + }); + client.on("message", async (msg: Discord.Message) => { + try { + await this.waitUnlock(msg.channel); + this.discordMessageQueue[msg.channel.id] = (async () => { + await (this.discordMessageQueue[msg.channel.id] || Promise.resolve()); + try { + await this.OnMessage(msg); + } catch (err) { + log.error("Caught while handing 'message'", err); + } + })(); + } catch (err) { + log.error("Exception thrown while handling \"message\" event", err); + } + }); + const jsLog = new Log("discord.js"); + + client.on("userUpdate", async (_, user) => { + try { + await this.userSync.OnUpdateUser(user); + } catch (err) { log.error("Exception thrown while handling \"userUpdate\" event", err); } + }); + client.on("guildMemberAdd", async (user) => { + try { + await this.userSync.OnAddGuildMember(user); + } catch (err) { log.error("Exception thrown while handling \"guildMemberAdd\" event", err); } + }); + client.on("guildMemberRemove", async (user) => { + try { + await this.userSync.OnRemoveGuildMember(user); + } catch (err) { log.error("Exception thrown while handling \"guildMemberRemove\" event", err); } + }); + client.on("guildMemberUpdate", async (_, member) => { + try { + await this.userSync.OnUpdateGuildMember(member); + } catch (err) { log.error("Exception thrown while handling \"guildMemberUpdate\" event", err); } + }); + client.on("debug", (msg) => { jsLog.verbose(msg); }); + client.on("error", (msg) => { jsLog.error(msg); }); + client.on("warn", (msg) => { jsLog.warn(msg); }); + log.info("Discord bot client logged in."); + this.bot = client; + + if (!this.config.bridge.disablePresence) { + if (!this.config.bridge.presenceInterval) { + this.config.bridge.presenceInterval = MIN_PRESENCE_UPDATE_DELAY; + } + this.bot.guilds.forEach((guild) => { + guild.members.forEach((member) => { + if (member.id !== this.GetBotId()) { + this.presenceHandler.EnqueueUser(member.user); + } + }); + }); + await this.presenceHandler.Start( + Math.max(this.config.bridge.presenceInterval, MIN_PRESENCE_UPDATE_DELAY), + ); + } + } + + public GetBotId(): string { + return this.bot.user.id; + } + + public GetGuilds(): Discord.Guild[] { + return this.bot.guilds.array(); + } + + public ThirdpartySearchForChannels(guildId: string, channelName: string): IThirdPartyLookup[] { + if (channelName.startsWith("#")) { + channelName = channelName.substr(1); + } + if (this.bot.guilds.has(guildId) ) { + const guild = this.bot.guilds.get(guildId); + return guild!.channels.filter((channel) => { + return channel.name.toLowerCase() === channelName.toLowerCase(); // Implement searching in the future. + }).map((channel) => { + return { + alias: `#_discord_${guild!.id}_${channel.id}:${this.config.bridge.domain}`, + fields: { + channel_id: channel.id, + channel_name: channel.name, + guild_id: guild!.id, + }, + protocol: "discord", + } as IThirdPartyLookup; + }); + } else { + log.info("Tried to do a third party lookup for a channel, but the guild did not exist"); + return []; + } + } + + public async LookupRoom(server: string, room: string, sender?: string): Promise<ChannelLookupResult> { + const hasSender = sender !== null && sender !== undefined; + try { + const client = await this.clientFactory.getClient(sender); + const guild = client.guilds.get(server); + if (!guild) { + throw new Error(`Guild "${server}" not found`); + } + const channel = guild.channels.get(room); + if (channel && channel.type === "text") { + const lookupResult = new ChannelLookupResult(); + lookupResult.channel = channel as Discord.TextChannel; + lookupResult.botUser = this.bot.user.id === client.user.id; + return lookupResult; + } + throw new Error(`Channel "${room}" not found`); + } catch (err) { + log.verbose("LookupRoom => ", err); + if (hasSender) { + log.verbose(`Couldn't find guild/channel under user account. Falling back.`); + return await this.LookupRoom(server, room); + } + throw err; + } + } + + public async sendAsBot(msg: string, channel: Discord.TextChannel, event: IMatrixEvent): Promise<void> { + if (!msg) { + return; + } + this.lockChannel(channel); + const res = await channel.send(msg); + await this.StoreMessagesSent(res, channel, event); + this.unlockChannel(channel); + } + + public async send( + embedSet: IMatrixEventProcessorResult, + opts: Discord.MessageOptions, + roomLookup: ChannelLookupResult, + event: IMatrixEvent, + ): Promise<void> { + const chan = roomLookup.channel; + const botUser = roomLookup.botUser; + const embed = embedSet.messageEmbed; + + let msg: Discord.Message | null | (Discord.Message | null)[] = null; + let hook: Discord.Webhook | undefined; + if (botUser) { + const webhooks = await chan.fetchWebhooks(); + hook = webhooks.filterArray((h) => h.name === "_matrix").pop(); + // Create a new webhook if none already exists + try { + if (!hook) { + hook = await chan.createWebhook( + "_matrix", + MATRIX_ICON_URL, + "Matrix Bridge: Allow rich user messages"); + } + } catch (err) { + log.error("Unable to create \"_matrix\" webhook. ", err); + } + } + try { + this.lockChannel(chan); + if (!botUser) { + // NOTE: Don't send replies to discord if we are a puppet. + msg = await chan.send(embed.description, opts); + } else if (hook) { + msg = await hook.send(embed.description, { + avatarURL: embed!.author!.icon_url, + embeds: embedSet.replyEmbed ? [embedSet.replyEmbed] : undefined, + files: opts.file ? [opts.file] : undefined, + username: embed!.author!.name, + } as Discord.WebhookMessageOptions); + } else { + if (embedSet.replyEmbed) { + embed.addField("Replying to", embedSet.replyEmbed!.author!.name); + embed.addField("Reply text", embedSet.replyEmbed.description); + } + opts.embed = embed; + msg = await chan.send("", opts); + } + await this.StoreMessagesSent(msg, chan, event); + this.unlockChannel(chan); + } catch (err) { + log.error("Couldn't send message. ", err); + } + } + + public async ProcessMatrixRedact(event: IMatrixEvent) { + if (this.config.bridge.disableDeletionForwarding) { + return; + } + log.info(`Got redact request for ${event.redacts}`); + log.verbose(`Event:`, event); + + const storeEvent = await this.store.Get(DbEvent, {matrix_id: `${event.redacts};${event.room_id}`}); + + if (!storeEvent || !storeEvent.Result) { + log.warn(`Could not redact because the event was not in the store.`); + return; + } + log.info(`Redact event matched ${storeEvent.ResultCount} entries`); + while (storeEvent.Next()) { + log.info(`Deleting discord msg ${storeEvent.DiscordId}`); + const result = await this.LookupRoom(storeEvent.GuildId, storeEvent.ChannelId, event.sender); + const chan = result.channel; + + const msg = await chan.fetchMessage(storeEvent.DiscordId); + try { + this.lockChannel(msg.channel); + await msg.delete(); + this.unlockChannel(msg.channel); + log.info(`Deleted message`); + } catch (ex) { + log.warn(`Failed to delete message`, ex); + } + } + } + + public OnUserQuery(userId: string): boolean { + return false; + } + + public async GetDiscordUserOrMember( + userId: Discord.Snowflake, guildId?: Discord.Snowflake, + ): Promise<Discord.User|Discord.GuildMember|undefined> { + try { + if (guildId && this.bot.guilds.has(guildId)) { + return await this.bot.guilds.get(guildId)!.fetchMember(userId); + } + return await this.bot.fetchUser(userId); + } catch (ex) { + log.warn(`Could not fetch user data for ${userId} (guild: ${guildId})`); + return undefined; + } + } + + public async GetChannelFromRoomId(roomId: string, client?: Discord.Client): Promise<Discord.Channel> { + const entries = await this.store.roomStore.getEntriesByMatrixId( + roomId, + ); + + if (!client) { + client = this.bot; + } + + if (entries.length === 0) { + log.verbose(`Couldn"t find channel for roomId ${roomId}.`); + throw Error("Room(s) not found."); + } + const entry = entries[0]; + if (!entry.remote) { + throw Error("Room had no remote component"); + } + const guild = client.guilds.get(entry.remote!.get("discord_guild") as string); + if (guild) { + const channel = client.channels.get(entry.remote!.get("discord_channel") as string); + if (channel) { + return channel; + } + throw Error("Channel given in room entry not found"); + } + throw Error("Guild given in room entry not found"); + } + + public async GetEmoji(name: string, animated: boolean, id: string): Promise<string> { + if (!id.match(/^\d+$/)) { + throw new Error("Non-numerical ID"); + } + const dbEmoji = await this.store.Get(DbEmoji, {emoji_id: id}); + if (!dbEmoji) { + throw new Error("Couldn't fetch from store"); + } + if (!dbEmoji.Result) { + const url = `https://cdn.discordapp.com/emojis/${id}${animated ? ".gif" : ".png"}`; + const intent = this.bridge.getIntent(); + const mxcUrl = (await Util.UploadContentFromUrl(url, intent, name)).mxcUrl; + dbEmoji.EmojiId = id; + dbEmoji.Name = name; + dbEmoji.Animated = animated; + dbEmoji.MxcUrl = mxcUrl; + await this.store.Insert(dbEmoji); + } + return dbEmoji.MxcUrl; + } + + public async GetRoomIdsFromGuild( + guild: Discord.Guild, member?: Discord.GuildMember, useCache: boolean = true): Promise<string[]> { + if (useCache) { + const res = this.roomIdsForGuildCache.get(`${guild.id}:${member ? member.id : ""}`); + if (res && res.ts > Date.now() - CACHE_LIFETIME) { + return res.roomIds; + } + } + + if (member) { + let rooms: string[] = []; + await Util.AsyncForEach(guild.channels.array(), async (channel) => { + if (channel.type !== "text" || !channel.members.has(member.id)) { + return; + } + try { + rooms = rooms.concat(await this.channelSync.GetRoomIdsFromChannel(channel)); + } catch (e) { } // no bridged rooms for this channel + }); + if (rooms.length === 0) { + log.verbose(`No rooms were found for this guild and member (guild:${guild.id} member:${member.id})`); + throw new Error("Room(s) not found."); + } + this.roomIdsForGuildCache.set(`${guild.id}:${guild.member}`, {roomIds: rooms, ts: Date.now()}); + return rooms; + } else { + const rooms = await this.store.roomStore.getEntriesByRemoteRoomData({ + discord_guild: guild.id, + }); + if (rooms.length === 0) { + log.verbose(`Couldn't find room(s) for guild id:${guild.id}.`); + throw new Error("Room(s) not found."); + } + const roomIds = rooms.map((room) => room.matrix!.getId()); + this.roomIdsForGuildCache.set(`${guild.id}:`, {roomIds, ts: Date.now()}); + return roomIds; + } + } + + public async HandleMatrixKickBan( + roomId: string, kickeeUserId: string, kicker: string, kickban: "leave"|"ban", + previousState: string, reason?: string, + ) { + const restore = kickban === "leave" && previousState === "ban"; + const client = await this.clientFactory.getClient(kicker); + let channel: Discord.Channel; + try { + channel = await this.GetChannelFromRoomId(roomId, client); + } catch (ex) { + log.error("Failed to get channel for ", roomId, ex); + return; + } + if (channel.type !== "text") { + log.warn("Channel was not a text channel"); + return; + } + const tchan = (channel as Discord.TextChannel); + const kickeeUser = (await this.GetDiscordUserOrMember( + new MatrixUser(kickeeUserId.replace("@", "")).localpart.substring("_discord".length), + tchan.guild.id, + )); + if (!kickeeUser) { + log.error("Could not find discord user for", kickeeUserId); + return; + } + const kickee = kickeeUser as Discord.GuildMember; + let res: Discord.Message; + const botChannel = await this.GetChannelFromRoomId(roomId) as Discord.TextChannel; + if (restore) { + await tchan.overwritePermissions(kickee, + { + SEND_MESSAGES: null, + VIEW_CHANNEL: null, + /* tslint:disable-next-line no-any */ + } as any, // XXX: Discord.js typings are wrong. + `Unbanned.`); + this.lockChannel(botChannel); + res = await botChannel.send( + `${kickee} was unbanned from this channel by ${kicker}.`, + ) as Discord.Message; + this.sentMessages.push(res.id); + this.unlockChannel(botChannel); + return; + } + const existingPerms = tchan.memberPermissions(kickee); + if (existingPerms && existingPerms.has(Discord.Permissions.FLAGS.VIEW_CHANNEL as number) === false ) { + log.warn("User isn't allowed to read anyway."); + return; + } + const word = `${kickban === "ban" ? "banned" : "kicked"}`; + this.lockChannel(botChannel); + 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); + log.info(`${word} ${kickee}`); + + await tchan.overwritePermissions(kickee, + { + SEND_MESSAGES: false, + VIEW_CHANNEL: false, + }, + `Matrix user was ${word} by ${kicker}`); + if (kickban === "leave") { + // Kicks will let the user back in after ~30 seconds. + setTimeout(async () => { + log.info(`Kick was lifted for ${kickee.displayName}`); + await tchan.overwritePermissions(kickee, + { + SEND_MESSAGES: null, + VIEW_CHANNEL: null, + /* tslint:disable: no-any */ + } as any, // XXX: Discord.js typings are wrong. + `Lifting kick since duration expired.`); + }, this.config.room.kickFor); + } + } + + public async GetEmojiByMxc(mxc: string): Promise<DbEmoji> { + const dbEmoji = await this.store.Get(DbEmoji, {mxc_url: mxc}); + if (!dbEmoji || !dbEmoji.Result) { + throw new Error("Couldn't fetch from store"); + } + return dbEmoji; + } + + private async SendMatrixMessage(matrixMsg: DiscordMessageProcessorResult, chan: Discord.Channel, + guild: Discord.Guild, author: Discord.User, + msgID: string): Promise<boolean> { + const rooms = await this.channelSync.GetRoomIdsFromChannel(chan); + const intent = this.GetIntentFromDiscordMember(author); + + await Util.AsyncForEach(rooms, async (room) => { + const res = await intent.sendMessage(room, { + body: matrixMsg.body, + format: "org.matrix.custom.html", + formatted_body: matrixMsg.formattedBody, + msgtype: "m.text", + }); + this.lastEventIds[room] = res.event_id; + const evt = new DbEvent(); + evt.MatrixId = `${res.event_id};${room}`; + evt.DiscordId = msgID; + evt.ChannelId = chan.id; + evt.GuildId = guild.id; + await this.store.Insert(evt); + }); + + // Sending was a success + return true; + } + + private async OnTyping(channel: Discord.Channel, user: Discord.User, isTyping: boolean) { + const rooms = await this.channelSync.GetRoomIdsFromChannel(channel); + try { + const intent = this.GetIntentFromDiscordMember(user); + await Promise.all(rooms.map((room) => { + return intent.sendTyping(room, isTyping); + })); + } catch (err) { + log.warn("Failed to send typing indicator.", err); + } + } + + private async OnMessage(msg: Discord.Message) { + const indexOfMsg = this.sentMessages.indexOf(msg.id); + if (indexOfMsg !== -1) { + log.verbose("Got repeated message, ignoring."); + delete this.sentMessages[indexOfMsg]; + return; // Skip *our* messages + } + const chan = msg.channel as Discord.TextChannel; + if (msg.author.id === this.bot.user.id) { + // We don't support double bridging. + return; + } + // Test for webhooks + if (msg.webhookID) { + const webhook = (await chan.fetchWebhooks()) + .filterArray((h) => h.name === "_matrix").pop(); + if (webhook && msg.webhookID === webhook.id) { + // Filter out our own webhook messages. + return; + } + } + + // check if it is a command to process by the bot itself + if (msg.content.startsWith("!matrix")) { + await this.discordCommandHandler.Process(msg); + return; + } + + // Update presence because sometimes discord misses people. + await this.userSync.OnUpdateUser(msg.author, msg.webhookID); + let rooms; + try { + rooms = await this.channelSync.GetRoomIdsFromChannel(msg.channel); + } catch (err) { + log.verbose("No bridged rooms to send message to. Oh well."); + return null; + } + try { + if (rooms === null) { + return null; + } + const intent = this.GetIntentFromDiscordMember(msg.author, msg.webhookID); + // Check Attachements + await Util.AsyncForEach(msg.attachments.array(), async (attachment) => { + const content = await Util.UploadContentFromUrl(attachment.url, intent, attachment.filename); + const fileMime = mime.lookup(attachment.filename); + const type = fileMime.split("/")[0]; + let msgtype = { + audio: "m.audio", + image: "m.image", + video: "m.video", + }[type]; + if (!msgtype) { + msgtype = "m.file"; + } + const info = { + mimetype: fileMime, + size: attachment.filesize, + } as IMatrixMediaInfo; + if (msgtype === "m.image" || msgtype === "m.video") { + info.w = attachment.width; + info.h = attachment.height; + } + await Util.AsyncForEach(rooms, async (room) => { + const res = await intent.sendMessage(room, { + body: attachment.filename, + external_url: attachment.url, + info, + msgtype, + url: content.mxcUrl, + }); + this.lastEventIds[room] = res.event_id; + const evt = new DbEvent(); + evt.MatrixId = `${res.event_id};${room}`; + evt.DiscordId = msg.id; + evt.ChannelId = msg.channel.id; + evt.GuildId = msg.guild.id; + await this.store.Insert(evt); + }); + }); + if (msg.content === null) { + return; + } + const result = await this.discordMsgProcessor.FormatMessage(msg); + if (!result.body) { + return; + } + await Util.AsyncForEach(rooms, async (room) => { + const trySend = async () => intent.sendMessage(room, { + body: result.body, + format: "org.matrix.custom.html", + formatted_body: result.formattedBody, + msgtype: result.msgtype, + }); + const afterSend = async (re) => { + this.lastEventIds[room] = re.event_id; + const evt = new DbEvent(); + evt.MatrixId = `${re.event_id};${room}`; + evt.DiscordId = msg.id; + evt.ChannelId = msg.channel.id; + evt.GuildId = msg.guild.id; + await this.store.Insert(evt); + }; + let res; + try { + res = await trySend(); + await afterSend(res); + } catch (e) { + if (e.errcode !== "M_FORBIDDEN" && e.errcode !== "M_GUEST_ACCESS_FORBIDDEN") { + log.error("Failed to send message into room.", e); + return; + } + if (msg.member) { + await this.userSync.JoinRoom(msg.member, room); + } else { + await this.userSync.JoinRoom(msg.author, room, msg.webhookID); + } + res = await trySend(); + await afterSend(res); + } + }); + } catch (err) { + log.verbose("Failed to send message into room.", err); + } + } + + private async OnMessageUpdate(oldMsg: Discord.Message, newMsg: Discord.Message) { + // Check if an edit was actually made + if (oldMsg.content === newMsg.content) { + return; + } + log.info(`Got edit event for ${newMsg.id}`); + let link = ""; + const storeEvent = await this.store.Get(DbEvent, {discord_id: oldMsg.id}); + if (storeEvent && storeEvent.Result) { + while (storeEvent.Next()) { + const matrixIds = storeEvent.MatrixId.split(";"); + if (matrixIds[0] === this.lastEventIds[matrixIds[1]]) { + log.info("Immediate edit, deleting and re-sending"); + await this.DeleteDiscordMessage(oldMsg); + await this.OnMessage(newMsg); + return; + } + link = `https://matrix.to/#/${matrixIds[1]}/${matrixIds[0]}`; + } + } + + // Create a new edit message using the old and new message contents + const editedMsg = await this.discordMsgProcessor.FormatEdit(oldMsg, newMsg, link); + + // Send the message to all bridged matrix rooms + if (!await this.SendMatrixMessage(editedMsg, newMsg.channel, newMsg.guild, newMsg.author, newMsg.id)) { + log.error("Unable to announce message edit for msg id:", newMsg.id); + } + } + + private async DeleteDiscordMessage(msg: Discord.Message) { + log.info(`Got delete event for ${msg.id}`); + const storeEvent = await this.store.Get(DbEvent, {discord_id: msg.id}); + if (!storeEvent || !storeEvent.Result) { + log.warn(`Could not redact because the event was not in the store.`); + return; + } + while (storeEvent.Next()) { + log.info(`Deleting discord msg ${storeEvent.DiscordId}`); + const intent = this.GetIntentFromDiscordMember(msg.author, msg.webhookID); + const matrixIds = storeEvent.MatrixId.split(";"); + try { + await intent.getClient().redactEvent(matrixIds[1], matrixIds[0]); + } catch (ex) { + log.warn(`Failed to delete ${storeEvent.DiscordId}, retrying as bot`); + try { + await this.bridge.getIntent().getClient().redactEvent(matrixIds[1], matrixIds[0]); + } catch (ex) { + log.warn(`Failed to delete ${storeEvent.DiscordId}, giving up`); + } + } + } + } + + private async StoreMessagesSent( + msg: Discord.Message | null | (Discord.Message | null)[], + chan: Discord.TextChannel, + event: IMatrixEvent, + ) { + if (!Array.isArray(msg)) { + msg = [msg]; + } + await Util.AsyncForEach(msg, async (m: Discord.Message) => { + if (!m) { + return; + } + log.verbose("Sent ", m.id); + this.sentMessages.push(m.id); + this.lastEventIds[event.room_id] = event.event_id; + try { + const evt = new DbEvent(); + evt.MatrixId = `${event.event_id};${event.room_id}`; + evt.DiscordId = m.id; + evt.GuildId = chan.guild.id; + evt.ChannelId = chan.id; + await this.store.Insert(evt); + } catch (err) { + log.error(`Failed to insert sent event (${event.event_id};${event.room_id}) into store`, err); + } + }); + } +} diff --git a/src/channelsyncroniser.ts b/src/channelsyncroniser.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c3a434beb14a63614a35c6cbcdaa26f458d184f --- /dev/null +++ b/src/channelsyncroniser.ts @@ -0,0 +1,390 @@ +/* +Copyright 2018, 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 * as Discord from "discord.js"; +import { DiscordBot } from "./bot"; +import { Util } from "./util"; +import { DiscordBridgeConfig, DiscordBridgeConfigChannelDeleteOptions } from "./config"; +import { Bridge } from "matrix-appservice-bridge"; +import { Log } from "./log"; +import { DbRoomStore, IRoomStoreEntry } from "./db/roomstore"; + +const log = new Log("ChannelSync"); + +const POWER_LEVEL_MESSAGE_TALK = 50; + +const DEFAULT_CHANNEL_STATE = { + iconMxcUrl: null, + id: null, + mxChannels: [], +}; + +const DEFAULT_SINGLECHANNEL_STATE = { + iconId: null, + iconUrl: null, + mxid: null, + name: null, + removeIcon: false, + topic: null, +}; + +export interface ISingleChannelState { + mxid: string; + name: string | null; + topic: string | null; + iconUrl: string | null; + iconId: string | null; + removeIcon: boolean; +} + +export interface IChannelState { + id: string; + mxChannels: ISingleChannelState[]; + iconMxcUrl: string | null; +} + +export class ChannelSyncroniser { + constructor( + private bridge: Bridge, + private config: DiscordBridgeConfig, + private bot: DiscordBot, + private roomStore: DbRoomStore, + ) { + + } + + public async OnUpdate(channel: Discord.Channel) { + if (channel.type !== "text") { + return; // Not supported for now + } + const channelState = await this.GetChannelUpdateState(channel as Discord.TextChannel); + try { + await this.ApplyStateToChannel(channelState); + } catch (e) { + log.error("Failed to update channels", e); + } + } + + public async OnGuildUpdate(guild: Discord.Guild, force = false) { + log.verbose(`Got guild update for guild ${guild.id}`); + const channelStates: IChannelState[] = []; + for (const [_, channel] of guild.channels) { + if (channel.type !== "text") { + continue; // not supported for now + } + try { + const channelState = await this.GetChannelUpdateState(channel as Discord.TextChannel, force); + channelStates.push(channelState); + } catch (e) { + log.error("Failed to get channel state", e); + } + } + + let iconMxcUrl: string | null = null; + for (const channelState of channelStates) { + channelState.iconMxcUrl = channelState.iconMxcUrl || iconMxcUrl; + try { + await this.ApplyStateToChannel(channelState); + } catch (e) { + log.error("Failed to update channels", e); + } + iconMxcUrl = channelState.iconMxcUrl; + } + } + + public async OnUnbridge(channel: Discord.Channel, roomId: string) { + try { + const entry = (await this.roomStore.getEntriesByMatrixId(roomId))[0]; + const opts = new DiscordBridgeConfigChannelDeleteOptions(); + opts.namePrefix = null; + opts.topicPrefix = null; + opts.ghostsLeave = true; + await this.handleChannelDeletionForRoom(channel as Discord.TextChannel, roomId, entry); + log.info(`Channel ${channel.id} has been unbridged.`); + } catch (e) { + log.error(`Failed to unbridge channel from room: ${e}`); + } + } + + public async OnDelete(channel: Discord.Channel) { + if (channel.type !== "text") { + log.info(`Channel ${channel.id} was deleted but isn't a text channel, so ignoring.`); + return; + } + log.info(`Channel ${channel.id} has been deleted.`); + let roomids; + let entries: IRoomStoreEntry[]; + try { + roomids = await this.GetRoomIdsFromChannel(channel); + entries = await this.roomStore.getEntriesByMatrixIds(roomids); + } catch (e) { + log.warn(`Couldn't find roomids for deleted channel ${channel.id}`); + return; + } + for (const roomid of roomids) { + try { + await this.handleChannelDeletionForRoom(channel as Discord.TextChannel, roomid, entries[roomid][0]); + } catch (e) { + log.error(`Failed to delete channel from room: ${e}`); + } + } + } + + public async OnGuildDelete(guild: Discord.Guild) { + for (const [_, channel] of guild.channels) { + try { + await this.OnDelete(channel); + } catch (e) { + log.error(`Failed to delete guild channel`); + } + } + } + + public async GetRoomIdsFromChannel(channel: Discord.Channel): Promise<string[]> { + const rooms = await this.roomStore.getEntriesByRemoteRoomData({ + discord_channel: channel.id, + }); + if (rooms.length === 0) { + log.verbose(`Couldn't find room(s) for channel ${channel.id}.`); + return Promise.reject("Room(s) not found."); + } + return rooms.map((room) => room.matrix!.getId() as string); + } + + public async GetAliasFromChannel(channel: Discord.Channel): Promise<string | null> { + let rooms: string[] = []; + try { + rooms = await this.GetRoomIdsFromChannel(channel); + } catch (err) { } // do nothing, our rooms array will just be empty + for (const room of rooms) { + try { + const al = (await this.bridge.getIntent().getClient() + .getStateEvent(room, "m.room.canonical_alias")).alias; + if (al) { + return al; // we are done, we found an alias + } + } catch (err) { } // do nothing, as if we error we just roll over to the next entry + } + const guildChannel = channel as Discord.TextChannel; + if (!guildChannel.guild) { + return null; // we didn't pass a guild, so we have no way of bridging this room, thus no alias + } + // at last, no known canonical aliases and we are ag uild....so we know an alias! + return `#_discord_${guildChannel.guild.id}_${channel.id}:${this.config.bridge.domain}`; + } + + public async GetChannelUpdateState(channel: Discord.TextChannel, forceUpdate = false): Promise<IChannelState> { + log.verbose(`State update request for ${channel.id}`); + const channelState: IChannelState = Object.assign({}, DEFAULT_CHANNEL_STATE, { + id: channel.id, + mxChannels: [], + }); + + const remoteRooms = await this.roomStore.getEntriesByRemoteRoomData({discord_channel: channel.id}); + if (remoteRooms.length === 0) { + log.verbose(`Could not find any channels in room store.`); + return channelState; + } + + const name: string = Util.ApplyPatternString(this.config.channel.namePattern, { + guild: channel.guild.name, + name: "#" + channel.name, + }); + const topic = channel.topic; + const icon = channel.guild.icon; + let iconUrl: string | null = null; + if (icon) { + iconUrl = `https://cdn.discordapp.com/icons/${channel.guild.id}/${icon}.png`; + } + remoteRooms.forEach((remoteRoom) => { + const mxid = remoteRoom.matrix!.getId(); + const singleChannelState: ISingleChannelState = Object.assign({}, DEFAULT_SINGLECHANNEL_STATE, { + mxid, + }); + + const oldName = remoteRoom.remote!.get("discord_name"); + if (remoteRoom.remote!.get("update_name") && (forceUpdate || oldName !== name)) { + log.verbose(`Channel ${mxid} name should be updated`); + singleChannelState.name = name; + } + + const oldTopic = remoteRoom.remote!.get("discord_topic"); + if (remoteRoom.remote!.get("update_topic") && (forceUpdate || oldTopic !== topic)) { + log.verbose(`Channel ${mxid} topic should be updated`); + singleChannelState.topic = topic; + } + + const oldIconUrl = remoteRoom.remote!.get("discord_iconurl"); + // no force on icon update as we don't want to duplicate ALL the icons + if (remoteRoom.remote!.get("update_icon") && oldIconUrl !== iconUrl) { + log.verbose(`Channel ${mxid} icon should be updated`); + if (iconUrl !== null) { + singleChannelState.iconUrl = iconUrl; + singleChannelState.iconId = icon; + } else { + singleChannelState.removeIcon = oldIconUrl !== null; + } + } + channelState.mxChannels.push(singleChannelState); + }); + return channelState; + } + + public async EnsureState(channel: Discord.TextChannel) { + const state = await this.GetChannelUpdateState(channel, true); + log.info(`Ensuring ${state.id} to be correct`); + await this.ApplyStateToChannel(state); + } + + private async ApplyStateToChannel(channelsState: IChannelState) { + const intent = this.bridge.getIntent(); + for (const channelState of channelsState.mxChannels) { + let roomUpdated = false; + const remoteRoom = (await this.roomStore.getEntriesByMatrixId(channelState.mxid))[0]; + if (!remoteRoom.remote) { + log.warn("Remote room not set for this room"); + return; + } + if (channelState.name !== null) { + log.verbose(`Updating channelname for ${channelState.mxid} to "${channelState.name}"`); + await intent.setRoomName(channelState.mxid, channelState.name); + remoteRoom.remote.set("discord_name", channelState.name); + roomUpdated = true; + } + + if (channelState.topic !== null) { + log.verbose(`Updating channeltopic for ${channelState.mxid} to "${channelState.topic}"`); + await intent.setRoomTopic(channelState.mxid, channelState.topic); + remoteRoom.remote.set("discord_topic", channelState.topic); + roomUpdated = true; + } + + if (channelState.iconUrl !== null && channelState.iconId !== null) { + log.verbose(`Updating icon_url for ${channelState.mxid} to "${channelState.iconUrl}"`); + if (channelsState.iconMxcUrl === null) { + const iconMxc = await Util.UploadContentFromUrl( + channelState.iconUrl, + intent, + channelState.iconId, + ); + channelsState.iconMxcUrl = iconMxc.mxcUrl; + } + await intent.setRoomAvatar(channelState.mxid, channelsState.iconMxcUrl); + remoteRoom.remote.set("discord_iconurl", channelState.iconUrl); + remoteRoom.remote.set("discord_iconurl_mxc", channelsState.iconMxcUrl); + roomUpdated = true; + } + + if (channelState.removeIcon) { + log.verbose(`Clearing icon_url for ${channelState.mxid}`); + await intent.setRoomAvatar(channelState.mxid, null); + remoteRoom.remote.set("discord_iconurl", null); + remoteRoom.remote.set("discord_iconurl_mxc", null); + roomUpdated = true; + } + + if (roomUpdated) { + await this.roomStore.upsertEntry(remoteRoom); + } + } + } + + private async handleChannelDeletionForRoom( + channel: Discord.TextChannel, + roomId: string, + entry: IRoomStoreEntry, + overrideOptions?: DiscordBridgeConfigChannelDeleteOptions): Promise<void> { + log.info(`Deleting ${channel.id} from ${roomId}.`); + const intent = await this.bridge.getIntent(); + const options = overrideOptions || this.config.channel.deleteOptions; + const plumbed = entry.remote!.get("plumbed"); + + await this.roomStore.upsertEntry(entry); + if (options.ghostsLeave) { + for (const member of channel.members.array()) { + const mIntent = await this.bot.GetIntentFromDiscordMember(member); + // Not awaiting this because we want to do this in the background. + mIntent.leave(roomId).then(() => { + log.verbose(`${member.id} left ${roomId}.`); + }).catch(() => { + log.warn(`Failed to make ${member.id} leave.`); + }); + } + } + if (options.namePrefix) { + try { + const name = await intent.getClient().getStateEvent(roomId, "m.room.name"); + name.name = options.namePrefix + name.name; + await intent.getClient().setRoomName(roomId, name.name); + } catch (e) { + log.error(`Failed to set name of room ${roomId} ${e}`); + } + } + if (options.topicPrefix) { + try { + const topic = await intent.getClient().getStateEvent(roomId, "m.room.topic"); + topic.topic = options.topicPrefix + topic.topic; + await intent.getClient().setRoomTopic(roomId, topic.topic); + } catch (e) { + log.error(`Failed to set topic of room ${roomId} ${e}`); + } + } + + if (plumbed !== true) { + if (options.unsetRoomAlias) { + try { + const alias = `#_${entry.remote!.roomId}:${this.config.bridge.domain}`; + const canonicalAlias = await intent.getClient().getStateEvent(roomId, "m.room.canonical_alias"); + if (canonicalAlias.alias === alias) { + await intent.getClient().sendStateEvent(roomId, "m.room.canonical_alias", {}); + } + await intent.getClient().deleteAlias(alias); + } catch (e) { + log.error(`Couldn't remove alias of ${roomId} ${e}`); + } + } + + if (options.unlistFromDirectory) { + try { + await intent.getClient().setRoomDirectoryVisibility(roomId, "private"); + } catch (e) { + log.error(`Couldn't remove ${roomId} from room directory ${e}`); + } + + } + + if (options.setInviteOnly) { + try { + await intent.getClient().sendStateEvent(roomId, "m.room.join_rules", {join_role: "invite"}); + } catch (e) { + log.error(`Couldn't set ${roomId} to private ${e}`); + } + } + + if (options.disableMessaging) { + try { + const state = await intent.getClient().getStateEvent(roomId, "m.room.power_levels"); + state.events_default = POWER_LEVEL_MESSAGE_TALK; + await intent.getClient().sendStateEvent(roomId, "m.room.power_levels", state); + } catch (e) { + log.error(`Couldn't disable messaging for ${roomId} ${e}`); + } + } + } + + await this.roomStore.removeEntriesByMatrixRoomId(roomId); + } +} diff --git a/src/clientfactory.ts b/src/clientfactory.ts new file mode 100644 index 0000000000000000000000000000000000000000..54e2694ca422b4a611867f2eccb2c21dee170303 --- /dev/null +++ b/src/clientfactory.ts @@ -0,0 +1,113 @@ +/* +Copyright 2017 - 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 { DiscordBridgeConfigAuth } from "./config"; +import { DiscordStore } from "./store"; +import { Client as DiscordClient } from "discord.js"; +import { Log } from "./log"; +import { Util } from "./util"; + +const log = new Log("ClientFactory"); + +const READY_TIMEOUT = 30000; + +export class DiscordClientFactory { + private config: DiscordBridgeConfigAuth; + private store: DiscordStore; + private botClient: DiscordClient; + private clients: Map<string, DiscordClient>; + constructor(store: DiscordStore, config?: DiscordBridgeConfigAuth) { + this.config = config!; + this.clients = new Map(); + this.store = store; + } + + public async init(): Promise<void> { + if (this.config === undefined) { + return Promise.reject("Client config not supplied."); + } + // We just need to make sure we have a bearer token. + // Create a new Bot client. + this.botClient = new DiscordClient({ + fetchAllMembers: true, + messageCacheLifetime: 5, + sync: true, + }); + + try { + await this.botClient.login(this.config.botToken); + } catch (err) { + log.error("Could not login as the bot user. This is bad!", err); + throw err; + } + + } + + public async getDiscordId(token: string): Promise<string> { + const client = new DiscordClient({ + fetchAllMembers: false, + messageCacheLifetime: 5, + sync: false, + }); + + await client.login(token); + const id = client.user.id; + + // This can be done asynchronously, because we don't need to block to return the id. + client.destroy().catch((err) => { + log.warn("Failed to destroy client ", id); + }); + return id; + } + + public async getClient(userId: string | null = null): Promise<DiscordClient> { + if (userId === null) { + return this.botClient; + } + + if (this.clients.has(userId)) { + log.verbose("Returning cached user client for", userId); + return this.clients.get(userId) as DiscordClient; + } + + const discordIds = await this.store.getUserDiscordIds(userId); + if (discordIds.length === 0) { + return this.botClient; + } + // TODO: Select a profile based on preference, not the first one. + const token = await this.store.getToken(discordIds[0]); + const client = new DiscordClient({ + fetchAllMembers: true, + messageCacheLifetime: 5, + sync: true, + }); + + const jsLog = new Log("discord.js-ppt"); + client.on("debug", (msg) => { jsLog.verbose(msg); }); + client.on("error", (msg) => { jsLog.error(msg); }); + client.on("warn", (msg) => { jsLog.warn(msg); }); + + try { + await client.login(token); + log.verbose("Logged in. Storing ", userId); + this.clients.set(userId, client); + return client; + } catch (err) { + log.warn(`Could not log ${userId} in. Returning bot user for now.`, err); + return this.botClient; + } + } +} diff --git a/src/config.ts b/src/config.ts index d2c7779424f11a5d3296eedbc0b8858fc3b3387c..166604317accbb842d349afe1ca26eb5c4500247 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,23 +1,117 @@ -/** Type annotations for config/config.schema.yaml */ +/* +Copyright 2017 - 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. +*/ + +/** Type annotations for config/config.schema.yaml */ export class DiscordBridgeConfig { - public bridge: DiscordBridgeConfigBridge; - public auth: DiscordBridgeConfigAuth; - public guilds: DiscordBridgeConfigGuilds[]; + public bridge: DiscordBridgeConfigBridge = new DiscordBridgeConfigBridge(); + public auth: DiscordBridgeConfigAuth = new DiscordBridgeConfigAuth(); + public logging: DiscordBridgeConfigLogging = new DiscordBridgeConfigLogging(); + public database: DiscordBridgeConfigDatabase = new DiscordBridgeConfigDatabase(); + public room: DiscordBridgeConfigRoom = new DiscordBridgeConfigRoom(); + public channel: DiscordBridgeConfigChannel = new DiscordBridgeConfigChannel(); + public limits: DiscordBridgeConfigLimits = new DiscordBridgeConfigLimits(); + public ghosts: DiscordBridgeConfigGhosts = new DiscordBridgeConfigGhosts(); + + /** + * Apply a set of keys and values over the default config. + * @param _config Config keys + * @param configLayer Private parameter + */ + // tslint:disable-next-line no-any + public ApplyConfig(newConfig: {[key: string]: any}, configLayer: any = this) { + Object.keys(newConfig).forEach((key) => { + if ( typeof(configLayer[key]) === "object" && + !Array.isArray(configLayer[key])) { + this.ApplyConfig(newConfig[key], this[key]); + return; + } + configLayer[key] = newConfig[key]; + }); + } } class DiscordBridgeConfigBridge { - public domain: string; - public homeserverUrl: string; + public domain: string; + public homeserverUrl: string; + public presenceInterval: number = 500; + public disablePresence: boolean; + public disableTypingNotifications: boolean; + public disableDiscordMentions: boolean; + public disableDeletionForwarding: boolean; + public enableSelfServiceBridging: boolean; + public disableReadReceipts: boolean; + public disableEveryoneMention: boolean = false; + public disableHereMention: boolean = false; + public disableJoinLeaveNotifications: boolean = false; +} + +export class DiscordBridgeConfigDatabase { + public connString: string; + public filename: string; + public userStorePath: string; + public roomStorePath: string; +} + +export class DiscordBridgeConfigAuth { + public clientID: string; + public botToken: string; +} + +export class DiscordBridgeConfigLogging { + public console: string = "info"; + public lineDateFormat: string = "MMM-D HH:mm:ss.SSS"; + public files: LoggingFile[] = []; +} + +class DiscordBridgeConfigRoom { + public defaultVisibility: string; + public kickFor: number = 30000; +} + +class DiscordBridgeConfigChannel { + public namePattern: string = "[Discord] :guild :name"; + public deleteOptions = new DiscordBridgeConfigChannelDeleteOptions(); +} + +export class DiscordBridgeConfigChannelDeleteOptions { + public namePrefix: string | null = null; + public topicPrefix: string | null = null; + public disableMessaging: boolean = false; + public unsetRoomAlias: boolean = true; + public unlistFromDirectory: boolean = true; + public setInviteOnly: boolean = true; + public ghostsLeave: boolean = true; +} + +class DiscordBridgeConfigLimits { + public roomGhostJoinDelay: number = 6000; + public discordSendDelay: number = 750; } -class DiscordBridgeConfigAuth { - public clientID: string; - public secret: string; - public botToken: string; +export class LoggingFile { + public file: string; + public level: string = "info"; + public maxFiles: string = "14d"; + public maxSize: string|number = "50m"; + public datePattern: string = "YYYY-MM-DD"; + public enabled: string[] = []; + public disabled: string[] = []; } -class DiscordBridgeConfigGuilds { - public id: string; - public aliasName: string; +class DiscordBridgeConfigGhosts { + public nickPattern: string = ":nick"; + public usernamePattern: string = ":username#:tag"; } diff --git a/src/db/connector.ts b/src/db/connector.ts new file mode 100644 index 0000000000000000000000000000000000000000..64fd220ee0efaa856cd7f3e9cb6862d8f0603fbb --- /dev/null +++ b/src/db/connector.ts @@ -0,0 +1,34 @@ +/* +Copyright 2018, 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. +*/ + +type SQLTYPES = number | boolean | string | null; + +export interface ISqlCommandParameters { + [paramKey: string]: SQLTYPES | Promise<SQLTYPES>; +} + +export interface ISqlRow { + [key: string]: SQLTYPES; +} + +export interface IDatabaseConnector { + Open(): void; + Get(sql: string, parameters?: ISqlCommandParameters): Promise<ISqlRow|null>; + All(sql: string, parameters?: ISqlCommandParameters): Promise<ISqlRow[]>; + Run(sql: string, parameters?: ISqlCommandParameters): Promise<void>; + Close(): Promise<void>; + Exec(sql: string): Promise<void>; +} diff --git a/src/db/dbdataemoji.ts b/src/db/dbdataemoji.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b076b6c407c9fb41291a6a42d80111203623c7c --- /dev/null +++ b/src/db/dbdataemoji.ts @@ -0,0 +1,94 @@ +/* +Copyright 2017 - 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 { DiscordStore } from "../store"; +import { IDbData } from "./dbdatainterface"; +import { ISqlCommandParameters } from "./connector"; + +export class DbEmoji implements IDbData { + public EmojiId: string; + public Name: string; + public Animated: boolean; + public MxcUrl: string; + public CreatedAt: number; + public UpdatedAt: number; + public Result: boolean; + + public async RunQuery(store: DiscordStore, params: ISqlCommandParameters): Promise<void> { + let query = ` + SELECT * + FROM emoji + WHERE emoji_id = $id`; + if (params.mxc_url) { + query = ` + SELECT * + FROM emoji + WHERE mxc_url = $mxc`; + } + const row = await store.db.Get(query, { + id: params.emoji_id, + mxc: params.mxc_url, + }); + this.Result = Boolean(row); // check if row exists + if (this.Result && row) { + this.EmojiId = row.emoji_id as string; + this.Name = row.name as string; + this.Animated = Boolean(row.animated); + this.MxcUrl = row.mxc_url as string; + this.CreatedAt = row.created_at as number; + this.UpdatedAt = row.updated_at as number; + } + } + + public async Insert(store: DiscordStore): Promise<void> { + this.CreatedAt = new Date().getTime(); + this.UpdatedAt = this.CreatedAt; + await store.db.Run(` + INSERT INTO emoji + (emoji_id,name,animated,mxc_url,created_at,updated_at) + VALUES ($emoji_id,$name,$animated,$mxc_url,$created_at,$updated_at);`, { + animated: Number(this.Animated), + created_at: this.CreatedAt, + emoji_id: this.EmojiId, + mxc_url: this.MxcUrl, + name: this.Name, + updated_at: this.UpdatedAt, + }); + } + + public async Update(store: DiscordStore): Promise<void> { + // Ensure this has incremented by 1 for Insert+Update operations. + this.UpdatedAt = new Date().getTime() + 1; + await store.db.Run(` + UPDATE emoji + SET name = $name, + animated = $animated, + mxc_url = $mxc_url, + updated_at = $updated_at + WHERE + emoji_id = $emoji_id`, { + animated: Number(this.Animated), + emoji_id: this.EmojiId, + mxc_url: this.MxcUrl, + name: this.Name, + updated_at: this.UpdatedAt, + }); + } + + public async Delete(store: DiscordStore): Promise<void> { + throw new Error("Delete is not implemented"); + } +} diff --git a/src/db/dbdataevent.ts b/src/db/dbdataevent.ts new file mode 100644 index 0000000000000000000000000000000000000000..46c8a9387548a5fde822980ba157307c0985d035 --- /dev/null +++ b/src/db/dbdataevent.ts @@ -0,0 +1,135 @@ +/* +Copyright 2017, 2018 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 { DiscordStore } from "../store"; +import { IDbDataMany } from "./dbdatainterface"; +import { ISqlCommandParameters } from "./connector"; + +export class DbEvent implements IDbDataMany { + public MatrixId: string; + public DiscordId: string; + public GuildId: string; + public ChannelId: string; + public Result: boolean; + // tslint:disable-next-line no-any + private rows: any[]; + + get ResultCount(): number { + return this.rows.length; + } + + public async RunQuery(store: DiscordStore, params: ISqlCommandParameters): Promise<void> { + this.rows = []; + // tslint:disable-next-line no-any + let rowsM: any[] | null = null; + if (params.matrix_id) { + rowsM = await store.db.All(` + SELECT * + FROM event_store + WHERE matrix_id = $id`, { + id: params.matrix_id, + }); + } else if (params.discord_id) { + rowsM = await store.db.All(` + SELECT * + FROM event_store + WHERE discord_id = $id`, { + id: params.discord_id, + }); + } else { + throw new Error("Unknown/incorrect id given as a param"); + } + + for (const rowM of rowsM) { + const row = { + discord_id: rowM.discord_id, + matrix_id: rowM.matrix_id, + }; + for (const rowD of await store.db.All(` + SELECT * + FROM discord_msg_store + WHERE msg_id = $id`, { + id: rowM.discord_id, + })) { + // tslint:disable-next-line no-any + const insertRow: any = Object.assign({}, row); + insertRow.guild_id = rowD.guild_id; + insertRow.channel_id = rowD.channel_id; + this.rows.push(insertRow); + } + } + this.Result = this.rows.length !== 0; + } + + public Next(): boolean { + if (!this.Result || this.ResultCount === 0) { + return false; + } + const item = this.rows.shift(); + this.MatrixId = item.matrix_id; + this.DiscordId = item.discord_id; + this.GuildId = item.guild_id; + this.ChannelId = item.channel_id; + return true; + } + + public async Insert(store: DiscordStore): Promise<void> { + await store.db.Run(` + INSERT INTO event_store + (matrix_id,discord_id) + VALUES ($matrix_id,$discord_id);`, { + discord_id: this.DiscordId, + matrix_id: this.MatrixId, + }); + // Check if the discord item exists? + const msgExists = await store.db.Get(` + SELECT * + FROM discord_msg_store + WHERE msg_id = $id`, { + id: this.DiscordId, + }) != null; + if (msgExists) { + return; + } + return store.db.Run(` + INSERT INTO discord_msg_store + (msg_id, guild_id, channel_id) + VALUES ($msg_id, $guild_id, $channel_id);`, { + channel_id: this.ChannelId, + guild_id: this.GuildId, + msg_id: this.DiscordId, + }); + } + + public async Update(store: DiscordStore): Promise<void> { + throw new Error("Update is not implemented"); + } + + public async Delete(store: DiscordStore): Promise<void> { + await store.db.Run(` + DELETE FROM event_store + WHERE matrix_id = $matrix_id + AND discord_id = $discord_id;`, { + discord_id: this.DiscordId, + matrix_id: this.MatrixId, + }); + return store.db.Run(` + DELETE FROM discord_msg_store + WHERE msg_id = $discord_id;`, { + discord_id: this.DiscordId, + }); + } +} diff --git a/src/db/dbdatainterface.ts b/src/db/dbdatainterface.ts new file mode 100644 index 0000000000000000000000000000000000000000..043bec08ba887e8b927a3b35eea6a7be7cc110a7 --- /dev/null +++ b/src/db/dbdatainterface.ts @@ -0,0 +1,31 @@ +/* +Copyright 2017, 2018 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 { DiscordStore } from "../store"; + +export interface IDbData { + Result: boolean; + // tslint:disable-next-line no-any + RunQuery(store: DiscordStore, params: any): Promise<void|Error>; + Insert(store: DiscordStore): Promise<void|Error>; + Update(store: DiscordStore): Promise<void|Error>; + Delete(store: DiscordStore): Promise<void|Error>; +} + +export interface IDbDataMany extends IDbData { + ResultCount: number; + Next(): boolean; +} diff --git a/src/db/postgres.ts b/src/db/postgres.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b41112e95378845c20e3d9d18a84705f5f6e549 --- /dev/null +++ b/src/db/postgres.ts @@ -0,0 +1,78 @@ +/* +Copyright 2018, 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 * as pgPromise from "pg-promise"; +import { Log } from "../log"; +import { IDatabaseConnector, ISqlCommandParameters, ISqlRow } from "./connector"; +const log = new Log("Postgres"); + +const pgp: pgPromise.IMain = pgPromise({ + // Initialization Options +}); + +export class Postgres implements IDatabaseConnector { + public static ParameterizeSql(sql: string): string { + return sql.replace(/\$((\w|\d|_)+)+/g, (k) => { + return `\${${k.substr("$".length)}}`; + }); + } + + // tslint:disable-next-line no-any + private db: pgPromise.IDatabase<any>; + constructor(private connectionString: string) { + + } + public Open() { + // Hide username:password + const logConnString = this.connectionString.substr( + this.connectionString.indexOf("@") || 0, + ); + log.info(`Opening ${logConnString}`); + this.db = pgp(this.connectionString); + } + + public async Get(sql: string, parameters?: ISqlCommandParameters): Promise<ISqlRow|null> { + log.silly("Get:", sql); + return this.db.oneOrNone(Postgres.ParameterizeSql(sql), parameters); + } + + public async All(sql: string, parameters?: ISqlCommandParameters): Promise<ISqlRow[]> { + log.silly("All:", sql); + try { + return await this.db.many(Postgres.ParameterizeSql(sql), parameters); + } catch (ex) { + if (ex.code === pgPromise.errors.queryResultErrorCode.noData ) { + return []; + } + throw ex; + } + } + + public async Run(sql: string, parameters?: ISqlCommandParameters): Promise<void> { + log.silly("Run:", sql); + return this.db.oneOrNone(Postgres.ParameterizeSql(sql), parameters).then(() => {}); + } + + public async Close(): Promise<void> { + // Postgres doesn't support disconnecting. + } + + public async Exec(sql: string): Promise<void> { + log.silly("Exec:", sql); + await this.db.none(sql); + return; + } +} diff --git a/src/db/roomstore.ts b/src/db/roomstore.ts new file mode 100644 index 0000000000000000000000000000000000000000..c04230e0fa56c75046a628935f598454b7c6fc7c --- /dev/null +++ b/src/db/roomstore.ts @@ -0,0 +1,375 @@ +/* +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 { Log } from "../log"; +import { IDatabaseConnector } from "./connector"; +import { Util } from "../util"; + +import * as uuid from "uuid/v4"; +import { Postgres } from "./postgres"; + +const log = new Log("DbRoomStore"); + +/** + * A RoomStore compatible with + * https://github.com/matrix-org/matrix-appservice-bridge/blob/master/lib/components/room-bridge-store.js + * that accesses the database instead. + */ + +interface IRemoteRoomData extends IRemoteRoomDataLazy { + discord_guild: string; + discord_channel: string; +} + +interface IRemoteRoomDataLazy { + discord_guild?: string; + discord_channel?: string; + discord_name?: string|null; + discord_topic?: string|null; + discord_type?: string|null; + discord_iconurl?: string|null; + discord_iconurl_mxc?: string|null; + update_name?: number|boolean|null; + update_topic?: number|boolean|null; + update_icon?: number|boolean|null; + plumbed?: number|boolean|null; +} + +export class RemoteStoreRoom { + public data: IRemoteRoomDataLazy; + constructor(public readonly roomId: string, data: IRemoteRoomDataLazy) { + for (const k of ["discord_guild", "discord_channel", "discord_name", + "discord_topic", "discord_iconurl", "discord_iconurl_mxc", "discord_type"]) { + data[k] = typeof(data[k]) === "number" ? String(data[k]) : data[k] || null; + } + for (const k of ["update_name", "update_topic", "update_icon", "plumbed"]) { + data[k] = Number(data[k]) || 0; + } + this.data = data; + } + + public getId() { + return this.roomId; + } + + public get(key: string): string|boolean|null { + return this.data[key]; + } + + public set(key: string, value: string|boolean|null) { + this.data[key] = typeof(value) === "boolean" ? Number(value) : value; + } +} + +export class MatrixStoreRoom { + constructor(public readonly roomId: string) { } + + public getId() { + return this.roomId; + } +} + +export interface IRoomStoreEntry { + id: string; + matrix: MatrixStoreRoom|null; + remote: RemoteStoreRoom|null; +} + +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}>; + constructor(private db: IDatabaseConnector) { + this.entriesMatrixIdCache = new Map(); + } + + public async upsertEntry(entry: IRoomStoreEntry) { + const promises: Promise<void>[] = []; + + const row = (await this.db.Get("SELECT * FROM room_entries WHERE id = $id", {id: entry.id})) || {}; + + if (!row.id) { + // Doesn't exist at all, create the room_entries row. + const values = { + id: entry.id, + matrix: entry.matrix ? entry.matrix.roomId : null, + remote: entry.remote ? entry.remote.roomId : null, + }; + try { + await this.db.Run(`INSERT INTO room_entries VALUES ($id, $matrix, $remote)`, values); + log.verbose("Created new entry " + entry.id); + } catch (ex) { + log.error("Failed to insert room entry", ex); + throw Error("Failed to insert room entry"); + } + } + + const matrixId = entry.matrix ? entry.matrix.roomId : null; + const remoteId = entry.remote ? entry.remote.roomId : null; + const mxIdDifferent = matrixId !== row.matrix_id; + const rmIdDifferent = remoteId !== row.remote_id; + // Did the room ids change? + if (mxIdDifferent || rmIdDifferent) { + if (matrixId) { + this.entriesMatrixIdCache.delete(matrixId); + } + const items: string[] = []; + + if (mxIdDifferent) { + items.push("matrix_id = $matrixId"); + } + + if (rmIdDifferent) { + items.push("remote_id = $remoteId"); + } + + await this.db.Run(`UPDATE room_entries SET ${items.join(", ")} WHERE id = $id`, + { + id: entry.id, + matrixId: matrixId as string|null, + remoteId: remoteId as string|null, + }, + ); + } + + // Matrix room doesn't store any data. + if (entry.remote) { + await this.upsertRoom(entry.remote); + } + } + + public async getEntriesByMatrixId(matrixId: string): Promise<IRoomStoreEntry[]> { + const cached = this.entriesMatrixIdCache.get(matrixId); + if (cached && cached.ts + ENTRY_CACHE_LIMETIME > Date.now()) { + return cached.e; + } + const entries = await this.db.All( + "SELECT * FROM room_entries WHERE matrix_id = $id", {id: matrixId}, + ); + const res: IRoomStoreEntry[] = []; + for (const entry of entries) { + let remote: RemoteStoreRoom|null = null; + if (entry.remote_id) { + const remoteId = entry.remote_id as string; + const row = await this.db.Get( + "SELECT * FROM remote_room_data WHERE room_id = $remoteId", + {remoteId}, + ); + if (row) { + // tslint:disable-next-line no-any + remote = new RemoteStoreRoom(remoteId, row as any); + } + } + if (remote) { + // Only push rooms with a remote + res.push({ + id: (entry.id as string), + matrix: new MatrixStoreRoom(matrixId), + remote, + }); + } + } + if (res.length > 0) { + this.entriesMatrixIdCache.set(matrixId, {e: res, ts: Date.now()}); + } + return res; + } + + public async getEntriesByMatrixIds(matrixIds: string[]): Promise<IRoomStoreEntry[]> { + const mxIdMap = { }; + matrixIds.forEach((mxId, i) => mxIdMap[i] = mxId); + const sql = `SELECT * FROM room_entries WHERE matrix_id IN (${matrixIds.map((_, id) => `\$${id}`).join(", ")})`; + const entries = await this.db.All(sql, mxIdMap); + const res: IRoomStoreEntry[] = []; + for (const entry of entries) { + let remote: RemoteStoreRoom|null = null; + const matrixId = entry.matrix_id as string || ""; + const remoteId = entry.remote_id as string; + if (remoteId) { + const row = await this.db.Get( + "SELECT * FROM remote_room_data WHERE room_id = $rid", + {rid: remoteId}, + ); + if (row) { + // tslint:disable-next-line no-any + remote = new RemoteStoreRoom(remoteId, row as any); + } + } + if (remote) { + // Only push rooms with a remote + res.push({ + id: (entry.id as string), + matrix: matrixId ? new MatrixStoreRoom(matrixId) : null, + remote, + }); + } + } + return res; + } + + public async linkRooms(matrixRoom: MatrixStoreRoom, remoteRoom: RemoteStoreRoom) { + await this.upsertRoom(remoteRoom); + + const values = { + id: uuid(), + matrix: matrixRoom.roomId, + remote: remoteRoom.roomId, + }; + + try { + await this.db.Run(`INSERT INTO room_entries VALUES ($id, $matrix, $remote)`, values); + log.verbose("Created new entry " + values.id); + } catch (ex) { + log.error("Failed to insert room entry", ex); + throw Error("Failed to insert room entry"); + } + } + + public async setMatrixRoom(matrixRoom: MatrixStoreRoom) { + // This no-ops, because we don't store anything interesting. + } + + public async getEntriesByRemoteRoomData(data: IRemoteRoomDataLazy): Promise<IRoomStoreEntry[]> { + Object.keys(data).filter((k) => typeof(data[k]) === "boolean").forEach((k) => { + data[k] = Number(data[k]); + }); + + const whereClaues = Object.keys(data).map((key) => { + return `${key} = $${key}`; + }).join(" AND "); + const sql = ` + SELECT * FROM remote_room_data + INNER JOIN room_entries ON remote_room_data.room_id = room_entries.remote_id + WHERE ${whereClaues}`; + // tslint:disable-next-line no-any + return (await this.db.All(sql, data as any)).map((row) => { + const id = row.id as string; + const matrixId = row.matrix_id; + const remoteId = row.room_id; + return { + id, + matrix: matrixId ? new MatrixStoreRoom(matrixId as string) : null, + // tslint:disable-next-line no-any + remote: matrixId ? new RemoteStoreRoom(remoteId as string, row as any) : null, + }; + }); + } + + public async removeEntriesByRemoteRoomId(remoteId: string) { + await this.db.Run(`DELETE FROM room_entries WHERE remote_id = $remoteId`, {remoteId}); + await this.db.Run(`DELETE FROM remote_room_data WHERE room_id = $remoteId`, {remoteId}); + } + + public async removeEntriesByMatrixRoomId(matrixId: string) { + const entries = (await this.db.All(`SELECT * FROM room_entries WHERE matrix_id = $matrixId`, {matrixId})) || []; + await Util.AsyncForEach(entries, async (entry) => { + if (entry.remote_id) { + await this.removeEntriesByRemoteRoomId(entry.remote_id as string); + } else if (entry.matrix_id) { + await this.db.Run(`DELETE FROM room_entries WHERE matrix_id = $matrixId`, {matrixId: entry.matrix_id}); + } + }); + } + + private async upsertRoom(room: RemoteStoreRoom) { + if (!room.data) { + throw new Error("Tried to upsert a room with undefined data"); + } + + const existingRow = await this.db.Get( + "SELECT * FROM remote_room_data WHERE room_id = $id", + {id: room.roomId}, + ); + + const data = { + discord_channel: room.data.discord_channel, + discord_guild: room.data.discord_guild, + discord_iconurl: room.data.discord_iconurl, + discord_iconurl_mxc: room.data.discord_iconurl_mxc, + discord_name: room.data.discord_name, + discord_topic: room.data.discord_topic, + discord_type: room.data.discord_type, + plumbed: Number(room.data.plumbed || 0), + update_icon: Number(room.data.update_icon || 0), + update_name: Number(room.data.update_name || 0), + update_topic: Number(room.data.update_topic || 0), + } as IRemoteRoomData; + + if (!existingRow) { + // Insert new data. + await this.db.Run( + `INSERT INTO remote_room_data VALUES ( + $id, + $discord_guild, + $discord_channel, + $discord_name, + $discord_topic, + $discord_type, + $discord_iconurl, + $discord_iconurl_mxc, + $update_name, + $update_topic, + $update_icon, + $plumbed + ) + `, + { + id: room.roomId, + // tslint:disable-next-line no-any + ...data as any, + }); + return; + } + + const keysToUpdate = { } as IRemoteRoomDataLazy; + + // New keys + Object.keys(room.data).filter( + (k: string) => existingRow[k] === null).forEach((key) => { + const val = room.data[key]; + keysToUpdate[key] = typeof val === "boolean" ? Number(val) : val; + }); + + // Updated keys + Object.keys(room.data).filter( + (k: string) => existingRow[k] !== room.data[k]).forEach((key) => { + const val = room.data[key]; + keysToUpdate[key] = typeof val === "boolean" ? Number(val) : val; + }); + + if (Object.keys(keysToUpdate).length === 0) { + return; + } + + const setStatement = Object.keys(keysToUpdate).map((k) => { + return `${k} = $${k}`; + }).join(", "); + + try { + await this.db.Run(`UPDATE remote_room_data SET ${setStatement} WHERE room_id = $id`, + { + id: room.roomId, + // tslint:disable-next-line no-any + ...keysToUpdate as any, + }); + log.verbose("Upserted room " + room.roomId); + } catch (ex) { + log.error("Failed to upsert room", ex); + throw Error("Failed to upsert room"); + } + } +} diff --git a/src/db/schema/dbschema.ts b/src/db/schema/dbschema.ts new file mode 100644 index 0000000000000000000000000000000000000000..132e7c003155f0175ddb8b8891e78d863273baea --- /dev/null +++ b/src/db/schema/dbschema.ts @@ -0,0 +1,23 @@ +/* +Copyright 2017, 2018 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 { DiscordStore } from "../../store"; +import { DiscordBridgeConfigDatabase } from "../../config"; +export interface IDbSchema { + description: string; + run(store: DiscordStore): Promise<null|void|Error|Error[]>; + rollBack(store: DiscordStore): Promise<null|void|Error|Error[]>; +} diff --git a/src/db/schema/v1.ts b/src/db/schema/v1.ts new file mode 100644 index 0000000000000000000000000000000000000000..f7737ddf17bbbeb167809947b093612fc58dca26 --- /dev/null +++ b/src/db/schema/v1.ts @@ -0,0 +1,39 @@ +/* +Copyright 2017, 2018 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 {IDbSchema} from "./dbschema"; +import {DiscordStore} from "../../store"; +export class Schema implements IDbSchema { + public description = "Schema, Client Auth Table"; + public async run(store: DiscordStore): Promise<void> { + await store.createTable(` + CREATE TABLE schema ( + version INTEGER UNIQUE NOT NULL + );`, "schema"); + await store.db.Exec("INSERT INTO schema VALUES (0);"); + await store.createTable(` + CREATE TABLE user_tokens ( + userId TEXT UNIQUE NOT NULL, + token TEXT UNIQUE NOT NULL + );`, "user_tokens"); + } + public async rollBack(store: DiscordStore): Promise<void> { + await store.db.Exec( + `DROP TABLE IF EXISTS schema; + DROP TABLE IF EXISTS user_tokens`, + ); + } +} diff --git a/src/db/schema/v10.ts b/src/db/schema/v10.ts new file mode 100644 index 0000000000000000000000000000000000000000..6042be9ccee4a745da196386471ac3a7be694ec8 --- /dev/null +++ b/src/db/schema/v10.ts @@ -0,0 +1,54 @@ +/* +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 {IDbSchema} from "./dbschema"; +import {DiscordStore} from "../../store"; +import { Log } from "../../log"; + +const log = new Log("SchemaV10"); + +export class Schema implements IDbSchema { + public description = "create indexes on tables"; + private readonly INDEXES = { + idx_discord_msg_store_msgid: ["discord_msg_store", "msg_id"], + idx_emoji_id: ["emoji", "emoji_id"], + idx_emoji_mxc_url: ["emoji", "mxc_url"], + idx_event_store_discord_id: ["event_store", "discord_id"], + idx_event_store_matrix_id: ["event_store", "matrix_id"], + idx_remote_room_data_room_id: ["remote_room_data", "room_id"], + idx_room_entries_id: ["room_entries", "id"], + idx_room_entries_matrix_id: ["room_entries", "matrix_id"], + idx_room_entries_remote_id: ["room_entries", "remote_id"], + }; + + public async run(store: DiscordStore): Promise<void> { + try { + await Promise.all(Object.keys(this.INDEXES).map(async (indexId: string) => { + const ids = this.INDEXES[indexId]; + return store.db.Exec(`CREATE INDEX ${indexId} ON ${ids[0]}(${ids[1]})`); + })); + } catch (ex) { + log.error("Failed to apply indexes:", ex); + } + + } + + public async rollBack(store: DiscordStore): Promise<void> { + await Promise.all(Object.keys(this.INDEXES).map(async (indexId: string) => { + return store.db.Exec(`DROP INDEX ${indexId}`); + })); + } +} diff --git a/src/db/schema/v2.ts b/src/db/schema/v2.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a6ff55c4fc8f309b64a3f9831849352e0927c07 --- /dev/null +++ b/src/db/schema/v2.ts @@ -0,0 +1,42 @@ +/* +Copyright 2017, 2018 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 {IDbSchema} from "./dbschema"; +import {DiscordStore} from "../../store"; +export class Schema implements IDbSchema { + public description = "Create DM Table, User Options"; + public async run(store: DiscordStore): Promise<void> { + await Promise.all([ + store.createTable(` + CREATE TABLE dm_rooms ( + discord_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + room_id TEXT UNIQUE NOT NULL + );`, "dm_rooms"), + store.createTable(` + CREATE TABLE client_options ( + discord_id TEXT UNIQUE NOT NULL, + options INTEGER NOT NULL + );`, "client_options", + )]); + } + public async rollBack(store: DiscordStore): Promise<void> { + await store.db.Exec( + `DROP TABLE IF EXISTS dm_rooms; + DROP TABLE IF EXISTS client_options;`, + ); + } +} diff --git a/src/db/schema/v3.ts b/src/db/schema/v3.ts new file mode 100644 index 0000000000000000000000000000000000000000..a24a13943ea97285fdccd25b7456d98b518e2043 --- /dev/null +++ b/src/db/schema/v3.ts @@ -0,0 +1,111 @@ +/* +Copyright 2017, 2018 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 {IDbSchema} from "./dbschema"; +import {DiscordStore} from "../../store"; +import {DiscordClientFactory} from "../../clientfactory"; +import { Log } from "../../log"; + +const log = new Log("SchemaV3"); + +export class Schema implements IDbSchema { + public description = "user_tokens split into user_id_discord_id"; + public async run(store: DiscordStore): Promise<void> { + await Promise.all([store.createTable(` + CREATE TABLE user_id_discord_id ( + discord_id TEXT NOT NULL, + user_id TEXT NOT NULL, + PRIMARY KEY(discord_id, user_id) + );`, "user_id_discord_id"), + store.createTable(` + CREATE TABLE discord_id_token ( + discord_id TEXT UNIQUE NOT NULL, + token TEXT NOT NULL, + PRIMARY KEY(discord_id) + );`, "discord_id_token", + )]); + + // Backup before moving data. + await store.backupDatabase(); + + // Move old data to new tables. + await this.moveUserIds(store); + + // Drop old table. + await store.db.Run( + `DROP TABLE IF EXISTS user_tokens;`, + ); + } + + public async rollBack(store: DiscordStore): Promise <void> { + await Promise.all([store.db.Run( + `DROP TABLE IF EXISTS user_id_discord_id;`, + ), store.db.Run( + `DROP TABLE IF EXISTS discord_id_token;`, + )]); + } + + private async moveUserIds(store: DiscordStore): Promise <void> { + log.info("Performing one time moving of tokens to new table. Please wait."); + let rows; + try { + rows = await store.db.All(`SELECT * FROM user_tokens`); + } catch (err) { + log.error(` +Could not select users from 'user_tokens'.It is possible that the table does +not exist on your database in which case you can proceed safely. Otherwise +a copy of the database before the schema update has been placed in the root +directory.`); + log.error(err); + return; + } + const promises = []; + const clientFactory = new DiscordClientFactory(store); + for (const row of rows) { + log.info("Moving ", row.userId); + try { + const client = await clientFactory.getClient(row.token); + const dId = client.user.id; + if (dId === null) { + continue; + } + log.verbose("INSERT INTO discord_id_token."); + await store.db.Run( + ` + INSERT INTO discord_id_token (discord_id,token) + VALUES ($discordId,$token); + ` + , { + $discordId: dId, + $token: row.token, + }); + log.verbose("INSERT INTO user_id_discord_id."); + await store.db.Run( + ` + INSERT INTO user_id_discord_id (discord_id,user_id) + VALUES ($discordId,$userId); + ` + , { + $discordId: dId, + $userId: row.userId, + }); + } catch (err) { + log.error(`Couldn't move ${row.userId}'s token into new table.`); + log.error(err); + } + } + } +} diff --git a/src/db/schema/v4.ts b/src/db/schema/v4.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a44d19a9719593f15da96a3727fa96fac5adef2 --- /dev/null +++ b/src/db/schema/v4.ts @@ -0,0 +1,40 @@ +/* +Copyright 2017, 2018 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 {IDbSchema} from "./dbschema"; +import {DiscordStore} from "../../store"; + +export class Schema implements IDbSchema { + public description = "create guild emoji table"; + public async run(store: DiscordStore): Promise<void> { + await store.createTable(` + CREATE TABLE guild_emoji ( + emoji_id TEXT NOT NULL, + guild_id TEXT NOT NULL, + name TEXT NOT NULL, + mxc_url TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY(emoji_id, guild_id) + );`, "guild_emoji"); + } + + public async rollBack(store: DiscordStore): Promise <void> { + await store.db.Run( + `DROP TABLE IF EXISTS guild_emoji;`, + ); + } +} diff --git a/src/db/schema/v5.ts b/src/db/schema/v5.ts new file mode 100644 index 0000000000000000000000000000000000000000..675bbfaa69b11808b05dac85c0b1565b4ace2dc1 --- /dev/null +++ b/src/db/schema/v5.ts @@ -0,0 +1,36 @@ +/* +Copyright 2017, 2018 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 {IDbSchema} from "./dbschema"; +import {DiscordStore} from "../../store"; + +export class Schema implements IDbSchema { + public description = "create event_store table"; + public async run(store: DiscordStore): Promise<void> { + await store.createTable(` + CREATE TABLE event_store ( + matrix_id TEXT NOT NULL, + discord_id TEXT NOT NULL, + PRIMARY KEY(matrix_id, discord_id) + );`, "event_store"); + } + + public async rollBack(store: DiscordStore): Promise<void> { + await store.db.Run( + `DROP TABLE IF EXISTS event_store;`, + ); + } +} diff --git a/src/db/schema/v6.ts b/src/db/schema/v6.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce9f8f0fe0a1cbc1e7195f718b336dad0a418648 --- /dev/null +++ b/src/db/schema/v6.ts @@ -0,0 +1,47 @@ +/* +Copyright 2017, 2018 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 {IDbSchema} from "./dbschema"; +import {DiscordStore} from "../../store"; + +export class Schema implements IDbSchema { + public description = "create event_store and discord_msg_store tables"; + public async run(store: DiscordStore): Promise<void> { + await store.db.Run( + `DROP TABLE IF EXISTS event_store;`, + ); + await store.createTable(` + CREATE TABLE event_store ( + matrix_id TEXT NOT NULL, + discord_id TEXT NOT NULL, + PRIMARY KEY(matrix_id, discord_id) + );`, "event_store"); + await store.createTable(` + CREATE TABLE discord_msg_store ( + msg_id TEXT NOT NULL, + guild_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + PRIMARY KEY(msg_id) + );`, "discord_msg_store"); + } + + public async rollBack(store: DiscordStore): Promise<void> { + await store.db.Exec( + `DROP TABLE IF EXISTS event_store;` + + `DROP TABLE IF EXISTS discord_msg_store;`, + ); + } +} diff --git a/src/db/schema/v7.ts b/src/db/schema/v7.ts new file mode 100644 index 0000000000000000000000000000000000000000..9caaa668a09e008b62f2ae97d2d03921097dfa01 --- /dev/null +++ b/src/db/schema/v7.ts @@ -0,0 +1,55 @@ +/* +Copyright 2018 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 {IDbSchema} from "./dbschema"; +import {DiscordStore} from "../../store"; +import { Log } from "../../log"; + +const log = new Log("SchemaV7"); + +export class Schema implements IDbSchema { + public description = "create guild emoji table"; + public async run(store: DiscordStore): Promise<void> { + await store.createTable(` + CREATE TABLE emoji ( + emoji_id TEXT NOT NULL, + name TEXT NOT NULL, + animated INTEGER NOT NULL, + mxc_url TEXT NOT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + PRIMARY KEY(emoji_id) + );`, "emoji"); + + // migrate existing emoji + try { + await store.db.Run(` + INSERT INTO emoji + (emoji_id, name, animated, mxc_url, created_at, updated_at) + SELECT emoji_id, name, 0 AS animated, mxc_url, created_at, updated_at FROM guild_emoji; + `); + } catch (e) { + // ignore errors + log.warning("Failed to migrate old data to new table"); + } + } + + public async rollBack(store: DiscordStore): Promise<void> { + await store.db.Run( + `DROP TABLE IF EXISTS emoji;`, + ); + } +} diff --git a/src/db/schema/v8.ts b/src/db/schema/v8.ts new file mode 100644 index 0000000000000000000000000000000000000000..de611de92a16e18fd383ed252d983e9cf094b072 --- /dev/null +++ b/src/db/schema/v8.ts @@ -0,0 +1,96 @@ +/* +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 {IDbSchema} from "./dbschema"; +import {DiscordStore} from "../../store"; +import { Log } from "../../log"; +import { + RoomStore, +} from "matrix-appservice-bridge"; +import { RemoteStoreRoom, MatrixStoreRoom } from "../roomstore"; +const log = new Log("SchemaV8"); + +export class Schema implements IDbSchema { + public description = "create room store tables"; + + constructor(private roomStore: RoomStore|null) { + + } + + public async run(store: DiscordStore): Promise<void> { + await store.createTable(` + CREATE TABLE remote_room_data ( + room_id TEXT NOT NULL, + discord_guild TEXT NOT NULL, + discord_channel TEXT NOT NULL, + discord_name TEXT DEFAULT NULL, + discord_topic TEXT DEFAULT NULL, + discord_type TEXT DEFAULT NULL, + discord_iconurl TEXT DEFAULT NULL, + discord_iconurl_mxc TEXT DEFAULT NULL, + update_name NUMERIC DEFAULT 0, + update_topic NUMERIC DEFAULT 0, + update_icon NUMERIC DEFAULT 0, + plumbed NUMERIC DEFAULT 0, + PRIMARY KEY(room_id) + );`, "remote_room_data"); + + await store.createTable(` + CREATE TABLE room_entries ( + id TEXT NOT NULL, + matrix_id TEXT, + remote_id TEXT, + PRIMARY KEY(id) + );`, "room_entries"); + + if (this.roomStore === null) { + log.warn("Not migrating rooms from room store, room store is null"); + return; + } + log.warn("Migrating rooms from roomstore, this may take a while..."); + const rooms = await this.roomStore.select({}); + log.info(`Found ${rooms.length} rooms in the DB`); + // Matrix room only entrys are useless. + const entrys = rooms.filter((r) => r.remote); + log.info(`Filtered out rooms without remotes. Have ${entrys.length} entries`); + let migrated = 0; + for (const e of entrys) { + const matrix = new MatrixStoreRoom(e.matrix_id); + try { + const remote = new RemoteStoreRoom(e.remote_id, e.remote); + await store.roomStore.linkRooms(matrix, remote); + log.info(`Migrated ${matrix.roomId}`); + migrated++; + } catch (ex) { + log.error(`Failed to link ${matrix.roomId}: `, ex); + } + } + if (migrated !== entrys.length) { + log.error(`Didn't migrate all rooms, ${entrys.length - migrated} failed to be migrated.`); + } else { + log.info("Migrated all rooms successfully"); + } + } + + public async rollBack(store: DiscordStore): Promise<void> { + await store.db.Run( + `DROP TABLE IF EXISTS remote_room_data;`, + ); + await store.db.Run( + `DROP TABLE IF EXISTS room_entries;`, + ); + } +} diff --git a/src/db/schema/v9.ts b/src/db/schema/v9.ts new file mode 100644 index 0000000000000000000000000000000000000000..3fddce0ffb4cb32640dfbd4715738d1e7a78d24c --- /dev/null +++ b/src/db/schema/v9.ts @@ -0,0 +1,120 @@ +/* +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 { IDbSchema } from "./dbschema"; +import { DiscordStore } from "../../store"; +import { Log } from "../../log"; +import { + UserStore, +} from "matrix-appservice-bridge"; +import { RemoteUser } from "../userstore"; +import * as Queue from "p-queue"; +const log = new Log("SchemaV9"); + +export class Schema implements IDbSchema { + public description = "create user store tables"; + + constructor(private userStore: UserStore|null) { + + } + + public async run(store: DiscordStore): Promise<void> { + await store.createTable(` + CREATE TABLE remote_user_guild_nicks ( + remote_id TEXT NOT NULL, + guild_id TEXT NOT NULL, + nick TEXT NOT NULL, + PRIMARY KEY(remote_id, guild_id) + );`, "remote_user_guild_nicks"); + + await store.createTable(` + CREATE TABLE remote_user_data ( + remote_id TEXT NOT NULL, + displayname TEXT, + avatarurl TEXT, + avatarurl_mxc TEXT, + PRIMARY KEY(remote_id) + );`, "remote_user_data"); + + await store.createTable(` + CREATE TABLE user_entries ( + matrix_id TEXT, + remote_id TEXT, + PRIMARY KEY(matrix_id, remote_id) + );`, "user_entries"); + + if (this.userStore === null) { + log.warn("Not migrating users from users store, users store is null"); + return; + } + log.warn("Migrating users from userstore, this may take a while..."); + const remoteUsers = await this.userStore.select({type: "remote"}); + log.info(`Found ${remoteUsers.length} remote users in the DB`); + let migrated = 0; + const processQueue = new Queue({ + autoStart: true, + concurrency: 100, + }); + for (const user of remoteUsers) { + const matrixIds = await this.userStore.getMatrixLinks(user.id); + if (!matrixIds || matrixIds.length === 0) { + log.warn(`Not migrating ${user.id}, has no linked matrix user`); + continue; + } else if (matrixIds.length > 1) { + log.warn(`Multiple matrix ids for ${user.id}, using first`); + } + const matrixId = matrixIds[0]; + try { + const remote = new RemoteUser(user.id); + remote.avatarurl = user.data.avatarurl; + remote.avatarurlMxc = user.data.avatarurl_mxc; + remote.displayname = user.data.displayname; + Object.keys(user.data).filter((k) => k.startsWith("nick_")).forEach((k) => { + remote.guildNicks.set(k.substr("nick_".length), user.data[k]); + }); + processQueue.add(async () => { + await store.userStore.linkUsers(matrixId, remote.id); + return store.userStore.setRemoteUser(remote); + }).then(() => { + log.info(`Migrated ${matrixId}, ${processQueue.pending} to go.`); + migrated++; + }).catch((err) => { + log.error(`Failed to migrate ${matrixId} ${err}`); + }); + } catch (ex) { + log.error(`Failed to link ${matrixId}: `, ex); + } + } + await processQueue.onIdle(); + if (migrated !== remoteUsers.length) { + log.error(`Didn't migrate all users, ${remoteUsers.length - migrated} failed to be migrated.`); + } else { + log.info("Migrated all users successfully"); + } + } + + public async rollBack(store: DiscordStore): Promise<void> { + await store.db.Run( + `DROP TABLE IF EXISTS remote_user_guild_nicks;`, + ); + await store.db.Run( + `DROP TABLE IF EXISTS remote_user_data;`, + ); + await store.db.Run( + `DROP TABLE IF EXISTS user_entries;`, + ); + } +} diff --git a/src/db/sqlite3.ts b/src/db/sqlite3.ts new file mode 100644 index 0000000000000000000000000000000000000000..9076798fe57a9661b80ae610ef9ea8276577ddf7 --- /dev/null +++ b/src/db/sqlite3.ts @@ -0,0 +1,56 @@ +/* +Copyright 2018 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 * as Database from "better-sqlite3"; +import { Log } from "../log"; +import { IDatabaseConnector, ISqlCommandParameters, ISqlRow } from "./connector"; +const log = new Log("SQLite3"); + +export class SQLite3 implements IDatabaseConnector { + private db: Database; + constructor(private filename: string) { + + } + + public async Open() { + log.info(`Opening ${this.filename}`); + this.db = new Database(this.filename); + } + + public async Get(sql: string, parameters?: ISqlCommandParameters): Promise<ISqlRow|null> { + log.silly("Get:", sql); + return this.db.prepare(sql).get(parameters || []); + } + + public async All(sql: string, parameters?: ISqlCommandParameters): Promise<ISqlRow[]> { + log.silly("All:", sql); + return this.db.prepare(sql).all(parameters || []); + } + + public async Run(sql: string, parameters?: ISqlCommandParameters): Promise<void> { + log.silly("Run:", sql); + return this.db.prepare(sql).run(parameters || []); + } + + public async Close(): Promise<void> { + this.db.close(); + } + + public async Exec(sql: string): Promise<void> { + log.silly("Exec:", sql); + return this.db.exec(sql); + } +} diff --git a/src/db/userstore.ts b/src/db/userstore.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb1251e28c1f08d86e74ba47aa7695da00319474 --- /dev/null +++ b/src/db/userstore.ts @@ -0,0 +1,169 @@ +/* +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 { IDatabaseConnector } from "./connector"; +import * as uuid from "uuid/v4"; +import { Log } from "../log"; + +/** + * A UserStore compatible with + * https://github.com/matrix-org/matrix-appservice-bridge/blob/master/lib/components/user-bridge-store.js + * that accesses the database instead. + */ + +const ENTRY_CACHE_LIMETIME = 30000; + +export class RemoteUser { + public displayname: string|null = null; + public avatarurl: string|null = null; + public avatarurlMxc: string|null = null; + public guildNicks: Map<string, string> = new Map(); + constructor(public readonly id: string) { + + } +} + +const log = new Log("DbUserStore"); + +export interface IUserStoreEntry { + id: string; + matrix: string|null; + remote: RemoteUser|null; +} + +export class DbUserStore { + private remoteUserCache: Map<string, {e: RemoteUser, ts: number}>; + + constructor(private db: IDatabaseConnector) { + this.remoteUserCache = new Map(); + } + + public async getRemoteUser(remoteId: string): Promise<RemoteUser|null> { + const cached = this.remoteUserCache.get(remoteId); + if (cached && cached.ts + ENTRY_CACHE_LIMETIME > Date.now()) { + return cached.e; + } + const row = await this.db.Get( + "SELECT * FROM user_entries WHERE remote_id = $id", {id: remoteId}, + ); + if (!row) { + return null; + } + const remoteUser = new RemoteUser(remoteId); + const data = await this.db.Get( + "SELECT * FROM remote_user_data WHERE remote_id = $remoteId", + {remoteId}, + ); + if (data) { + remoteUser.avatarurl = data.avatarurl as string|null; + remoteUser.displayname = data.displayname as string|null; + remoteUser.avatarurlMxc = data.avatarurl_mxc as string|null; + } + const nicks = await this.db.All( + "SELECT guild_id, nick FROM remote_user_guild_nicks WHERE remote_id = $remoteId", + {remoteId}, + ); + if (nicks) { + nicks.forEach(({nick, guild_id}) => { + remoteUser.guildNicks.set(guild_id as string, nick as string); + }); + } + this.remoteUserCache.set(remoteId, {e: remoteUser, ts: Date.now()}); + return remoteUser; + } + + public async setRemoteUser(user: RemoteUser) { + this.remoteUserCache.delete(user.id); + const existingData = await this.db.Get( + "SELECT * FROM remote_user_data WHERE remote_id = $remoteId", + {remoteId: user.id}, + ); + if (!existingData) { + await this.db.Run( + `INSERT INTO remote_user_data VALUES ( + $remote_id, + $displayname, + $avatarurl, + $avatarurl_mxc + )`, + { + avatarurl: user.avatarurl, + avatarurl_mxc: user.avatarurlMxc, + displayname: user.displayname, + remote_id: user.id, + }); + } else { + await this.db.Run( +`UPDATE remote_user_data SET displayname = $displayname, +avatarurl = $avatarurl, +avatarurl_mxc = $avatarurl_mxc WHERE remote_id = $remote_id`, + { + avatarurl: user.avatarurl, + avatarurl_mxc: user.avatarurlMxc, + displayname: user.displayname, + remote_id: user.id, + }); + } + const existingNicks = {}; + (await this.db.All( + "SELECT guild_id, nick FROM remote_user_guild_nicks WHERE remote_id = $remoteId", + {remoteId: user.id}, + )).forEach(({guild_id, nick}) => existingNicks[guild_id as string] = nick); + for (const guildId of user.guildNicks.keys()) { + const nick = user.guildNicks.get(guildId) || null; + if (existingData) { + if (existingNicks[guildId] === nick) { + return; + } else if (existingNicks[guildId]) { + await this.db.Run( +`UPDATE remote_user_guild_nicks SET nick = $nick +WHERE remote_id = $remote_id +AND guild_id = $guild_id`, + { + guild_id: guildId, + nick, + remote_id: user.id, + }); + return; + } + } + await this.db.Run( + `INSERT INTO remote_user_guild_nicks VALUES ( + $remote_id, + $guild_id, + $nick + )`, + { + guild_id: guildId, + nick, + remote_id: user.id, + }); + } + + } + + public async linkUsers(matrixId: string, remoteId: string) { + // This is used ONCE in the bridge to link two IDs, so do not UPSURT data. + try { + await this.db.Run(`INSERT INTO user_entries VALUES ($matrixId, $remoteId)`, { + matrixId, + remoteId, + }); + } catch (ex) { + log.verbose("Failed to insert into user_entries, entry probably exists:", ex); + } + } +} diff --git a/src/discordas.ts b/src/discordas.ts index 9a2fab766f336c77d7916695e5c4e962ff191f91..0f4b5e11d6a46c238e1e259316d23b11e06c4da2 100644 --- a/src/discordas.ts +++ b/src/discordas.ts @@ -1,81 +1,199 @@ -import { Cli, Bridge, AppServiceRegistration, ClientFactory } from "matrix-appservice-bridge"; -import * as log from "npmlog"; +/* +Copyright 2017 - 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 { Cli, Bridge, AppServiceRegistration, ClientFactory, BridgeContext } from "matrix-appservice-bridge"; import * as yaml from "js-yaml"; import * as fs from "fs"; import { DiscordBridgeConfig } from "./config"; -import { DiscordBot } from "./discordbot"; -import { MatrixRoomHandler } from "./matrixroomhandler"; +import { DiscordBot } from "./bot"; +import { DiscordStore } from "./store"; +import { Log } from "./log"; +import "source-map-support/register"; + +const log = new Log("DiscordAS"); const cli = new Cli({ - bridgeConfig: { - affectsRegistration: true, - schema: "./config/config.schema.yaml", - }, - registrationPath: "discord-registration.yaml", - generateRegistration, - run, + bridgeConfig: { + affectsRegistration: true, + schema: "./config/config.schema.yaml", + }, + generateRegistration, + registrationPath: "discord-registration.yaml", + run, }); try { - cli.run(); + cli.run(); } catch (err) { - console.error("Init", "Failed to start bridge."); // eslint-disable-line no-console - console.error("Init", err); // eslint-disable-line no-console + log.error("Failed to start bridge."); + log.error(err); } function generateRegistration(reg, callback) { - reg.setId(AppServiceRegistration.generateToken()); - reg.setHomeserverToken(AppServiceRegistration.generateToken()); - reg.setAppServiceToken(AppServiceRegistration.generateToken()); - reg.setSenderLocalpart("_discord_bot"); - reg.addRegexPattern("users", "@_discord_.*", true); - reg.addRegexPattern("aliases", "#_discord_.*", true); - callback(reg); + reg.setId(AppServiceRegistration.generateToken()); + reg.setHomeserverToken(AppServiceRegistration.generateToken()); + reg.setAppServiceToken(AppServiceRegistration.generateToken()); + reg.setSenderLocalpart("_discord_bot"); + reg.addRegexPattern("users", "@_discord_.*", true); + reg.addRegexPattern("aliases", "#_discord_.*", true); + reg.setRateLimited(false); + reg.setProtocols(["discord"]); + callback(reg); } -function run (port: number, config: DiscordBridgeConfig) { - log.info("discordas", "Starting Discord AS"); - const yamlConfig = yaml.safeLoad(fs.readFileSync("discord-registration.yaml", "utf8")); - const registration = AppServiceRegistration.fromObject(yamlConfig); - if (registration === null) { - throw new Error("Failed to parse registration file"); - } - const botUserId = "@" + registration.sender_localpart + ":" + config.bridge.domain; - const clientFactory = new ClientFactory({ - appServiceUserId: botUserId, - token: registration.as_token, - url: config.bridge.homeserverUrl, - }); - const discordbot = new DiscordBot(config); - const roomhandler = new MatrixRoomHandler(discordbot, config, botUserId); - - const bridge = new Bridge({ - clientFactory, - controller: { - // onUserQuery: userQuery, - onAliasQuery: (alias, aliasLocalpart) => { - return roomhandler.OnAliasQuery(alias, aliasLocalpart); - }, - onEvent: roomhandler.OnEvent.bind(roomhandler), - onAliasQueried: roomhandler.OnAliasQueried.bind(roomhandler), - thirdPartyLookup: roomhandler.ThirdPartyLookup, - // onLog: function (line, isError) { - // if(isError) { - // if(line.indexOf("M_USER_IN_USE") === -1) {//QUIET! - // log.warn("matrix-appservice-bridge", line); - // } - // } - // } - }, - domain: config.bridge.domain, - homeserverUrl: config.bridge.homeserverUrl, - registration, - }); - roomhandler.setBridge(bridge); - discordbot.setBridge(bridge); +// tslint:disable-next-line no-any +type callbackFn = (...args: any[]) => Promise<any>; + +async function run(port: number, fileConfig: DiscordBridgeConfig) { + const config = new DiscordBridgeConfig(); + config.ApplyConfig(fileConfig); + Log.Configure(config.logging); + log.info("Starting Discord AS"); + const yamlConfig = yaml.safeLoad(fs.readFileSync(cli.opts.registrationPath, "utf8")); + const registration = AppServiceRegistration.fromObject(yamlConfig); + if (registration === null) { + throw new Error("Failed to parse registration file"); + } + + const botUserId = `@${registration.sender_localpart}:${config.bridge.domain}`; + const clientFactory = new ClientFactory({ + appServiceUserId: botUserId, + token: registration.as_token, + url: config.bridge.homeserverUrl, + }); + const store = new DiscordStore(config.database); + + const callbacks: { [id: string]: callbackFn; } = {}; + + const bridge = new Bridge({ + clientFactory, + controller: { + // onUserQuery: userQuery, + onAliasQueried: async (alias: string, roomId: string) => { + try { + return await callbacks.onAliasQueried(alias, roomId); + } catch (err) { log.error("Exception thrown while handling \"onAliasQueried\" event", err); } + }, + onAliasQuery: async (alias: string, aliasLocalpart: string) => { + try { + return await callbacks.onAliasQuery(alias, aliasLocalpart); + } catch (err) { log.error("Exception thrown while handling \"onAliasQuery\" event", err); } + }, + onEvent: async (request) => { + try { + // Build our own context. + if (!store.roomStore) { + log.warn("Discord store not ready yet, dropping message"); + return; + } + const roomId = request.getData().room_id; + + const context: BridgeContext = { + rooms: {}, + }; + + if (roomId) { + const entries = await store.roomStore.getEntriesByMatrixId(roomId); + context.rooms = entries[0] || {}; + } + + await request.outcomeFrom(callbacks.onEvent(request, context)); + } catch (err) { + log.error("Exception thrown while handling \"onEvent\" event", err); + await request.outcomeFrom(Promise.reject("Failed to handle")); + } + }, + onLog: (line, isError) => { + log.verbose("matrix-appservice-bridge", line); + }, + thirdPartyLookup: async () => { + try { + return await callbacks.thirdPartyLookup(); + } catch (err) { + log.error("Exception thrown while handling \"thirdPartyLookup\" event", err); + } + }, + }, + disableContext: true, + domain: config.bridge.domain, + homeserverUrl: config.bridge.homeserverUrl, + intentOptions: { + clients: { + dontJoin: true, // handled manually + }, + }, + // To avoid out of order message sending. + queue: { + perRequest: true, + type: "per_room", + }, + registration, + // These must be kept for a while yet since we use them for migrations. + roomStore: config.database.roomStorePath, + userStore: config.database.userStorePath, + }); + + if (config.database.roomStorePath) { + log.warn("[DEPRECATED] The room store is now part of the SQL database." + + "The config option roomStorePath no longer has any use."); + } + + if (config.database.userStorePath) { + log.warn("[DEPRECATED] The user store is now part of the SQL database." + + "The config option userStorePath no longer has any use."); + } + + await bridge.run(port, config); + log.info(`Started listening on port ${port}`); + + try { + await store.init(undefined, bridge.getRoomStore(), bridge.getUserStore()); + } catch (ex) { + log.error("Failed to init database. Exiting.", ex); + process.exit(1); + } + + const discordbot = new DiscordBot(botUserId, config, bridge, store); + const roomhandler = discordbot.RoomHandler; + const eventProcessor = discordbot.MxEventProcessor; + + try { + callbacks.onAliasQueried = roomhandler.OnAliasQueried.bind(roomhandler); + callbacks.onAliasQuery = roomhandler.OnAliasQuery.bind(roomhandler); + callbacks.onEvent = eventProcessor.OnEvent.bind(eventProcessor); + callbacks.thirdPartyLookup = async () => { + return roomhandler.ThirdPartyLookup; + }; + } catch (err) { + log.error("Failed to register callbacks. Exiting.", err); + process.exit(1); + } - log.info("AppServ", "Started listening on port %s at %s", port, new Date().toUTCString() ); - bridge.run(port, config); - discordbot.run(); + log.info("Initing bridge"); + try { + log.info("Initing store."); + await discordbot.init(); + log.info(`Started listening on port ${port}.`); + log.info("Initing bot."); + await discordbot.run(); + log.info("Discordbot started successfully"); + } catch (err) { + log.error(err); + log.error("Failure during startup. Exiting"); + process.exit(1); + } } diff --git a/src/discordbot.ts b/src/discordbot.ts deleted file mode 100644 index 7d63d8c23c8f7603afeadda9865d0a01968a05a5..0000000000000000000000000000000000000000 --- a/src/discordbot.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { DiscordBridgeConfig } from "./config"; -import * as Discord from "discord.js"; -import * as log from "npmlog"; -import { MatrixUser, RemoteUser, Bridge, RemoteRoom } from "matrix-appservice-bridge"; -import { Util } from "./util"; -import * as Bluebird from "bluebird"; -import * as mime from "mime"; -import * as marked from "marked"; - -export class DiscordBot { - private config: DiscordBridgeConfig; - private bot: Discord.Client; - private discordUser: Discord.ClientUser; - private bridge: Bridge; - constructor(config: DiscordBridgeConfig) { - this.config = config; - } - - public setBridge(bridge: Bridge) { - this.bridge = bridge; - } - - public run (): Promise<null> { - this.bot = Bluebird.promisifyAll(new Discord.Client()); - this.bot.on("typingStart", (c, u) => { this.OnTyping(c, u, true); }); - this.bot.on("typingStop", (c, u) => { this.OnTyping(c, u, false); }); - this.bot.on("userUpdate", (_, newUser) => { this.UpdateUser(newUser); }); - this.bot.on("channelUpdate", (_, newChannel) => { this.UpdateRoom(<Discord.TextChannel> newChannel); }); - this.bot.on("presenceUpdate", (_, newMember) => { this.UpdatePresence(newMember); }); - this.bot.on("message", this.OnMessage.bind(this)); - const promise = (this.bot as any).onAsync("ready"); - this.bot.login(this.config.auth.botToken); - - return promise; - } - - public GetBot (): Discord.Client { - return this.bot; - } - - public GetGuilds(): Discord.Guild[] { - return this.bot.guilds.array(); - } - - public ThirdpartySearchForChannels(guildId: string, channelName: string): any[] { - if (channelName.startsWith("#")) { - channelName = channelName.substr(1); - } - if (this.bot.guilds.has(guildId) ) { - const guild = this.bot.guilds.get(guildId); - return guild.channels.filter((channel) => { - return channel.name.toLowerCase() === channelName.toLowerCase(); // Implement searching in the future. - }).map((channel) => { - return { - alias: `#_discord_${guild.id}_${channel.id}:${this.config.bridge.domain}`, - protocol: "discord", - fields: { - guild_id: guild.id, - channel_name: channel.name, - channel_id: channel.id, - }, - }; - }); - } else { - log.warn("DiscordBot", "Tried to do a third party lookup for a channel, but the guild did not exist"); - return []; - } - } - - public LookupRoom (server: string, room: string): Promise<Discord.TextChannel> { - const guild = this.bot.guilds.find((g) => { - return (g.id === server); - }); - if (!guild) { - return Promise.reject(`Guild "${server}" not found`); - } - - const channel = guild.channels.find((c) => { - return (c.id === room); - }); - - if (!channel) { - return Promise.reject(`Channel "${room}" not found`); - } - return Promise.resolve(channel); - } - - public ProcessMatrixMsgEvent(event, guildId: string, channelId: string): Promise<any> { - let chan; - let embed; - const mxClient = this.bridge.getClientFactory().getClientAs(); - return this.LookupRoom(guildId, channelId).then((channel) => { - chan = channel; - return mxClient.getProfileInfo(event.sender); - }).then((profile) => { - if (!profile.displayname) { - profile.displayname = event.sender; - } - if (profile.avatar_url) { - profile.avatar_url = mxClient.mxcUrlToHttp(profile.avatar_url); - } - embed = new Discord.RichEmbed({ - author: { - name: profile.displayname, - icon_url: profile.avatar_url, - url: `https://matrix.to/#/${event.sender}`, - // TODO: Avatar - }, - description: event.content.body, - }); - if (["m.image", "m.audio", "m.video", "m.file"].indexOf(event.content.msgtype) !== -1) { - return Util.DownloadFile(mxClient.mxcUrlToHttp(event.content.url)); - } - return Promise.resolve(null); - }).then((attachment) => { - if (attachment !== null) { - return { - file : { - name: event.content.body, - attachment, - }, - }; - } - return {}; - }).then((opts) => { - chan.sendEmbed(embed, opts); - }).catch((err) => { - log.error("DiscordBot", "Couldn't send message. ", err); - }); - } - - public OnUserQuery (userId: string): any { - return false; - } - - private GetRoomIdFromChannel(channel: Discord.Channel): Promise<string> { - return this.bridge.getRoomStore().getEntriesByRemoteRoomData({ - discord_channel: channel.id, - }).then((rooms) => { - if (rooms.length === 0) { - log.warn("DiscordBot", `Got message but couldn"t find room chan id:${channel.id} for it.`); - return Promise.reject("Room not found."); - } - return rooms[0].matrix.getId(); - }); - } - - private UpdateRoom(discordChannel: Discord.TextChannel): Promise<null> { - const intent = this.bridge.getIntent(); - const roomStore = this.bridge.getRoomStore(); - let entry: RemoteRoom; - let roomId = null; - return this.GetRoomIdFromChannel(discordChannel).then((r) => { - roomId = r; - return roomStore.getEntriesByMatrixId(roomId); - }).then((entries) => { - if (entries.length === 0) { - return Promise.reject("Couldn't update room for channel, no assoicated entry in roomstore."); - } - entry = entries[0]; - return; - }).then(() => { - const name = `[Discord] ${discordChannel.guild.name} #${discordChannel.name}`; - if (entry.remote.get("discord_name") !== name) { - return intent.setRoomName(roomId, name).then(() => { - entry.remote.set("discord_name", name); - return roomStore.upsertEntry(entry); - }); - } - }).then(() => { - if (entry.remote.get("discord_topic") !== discordChannel.topic) { - return intent.setRoomTopic(roomId, discordChannel.topic).then(() => { - entry.remote.set("discord_topic", discordChannel.topic); - return roomStore.upsertEntry(entry); - }); - } - }); - } - - private UpdateUser(discordUser: Discord.User) { - let remoteUser: RemoteUser; - const displayName = discordUser.username + "#" + discordUser.discriminator; - const id = `_discord_${discordUser.id}:${this.config.bridge.domain}`; - const intent = this.bridge.getIntent("@" + id); - const userStore = this.bridge.getUserStore(); - - return userStore.getRemoteUser(discordUser.id).then((u) => { - remoteUser = u; - if (remoteUser === null) { - remoteUser = new RemoteUser(discordUser.id); - return userStore.linkUsers( - new MatrixUser(id), - remoteUser, - ); - } - return Promise.resolve(); - }).then(() => { - if (remoteUser.get("displayname") !== displayName) { - return intent.setDisplayName(displayName).then(() => { - remoteUser.set("displayname", displayName); - return userStore.setRemoteUser(remoteUser); - }); - } - return true; - }).then(() => { - if (remoteUser.get("avatarurl") !== discordUser.avatarURL && discordUser.avatarURL !== null) { - return Util.UploadContentFromUrl( - this.bridge, - discordUser.avatarURL, - intent, - discordUser.avatar, - ).then((avatar) => { - intent.setAvatarUrl(avatar.mxc_url).then(() => { - remoteUser.set("avatarurl", discordUser.avatarURL); - return userStore.setRemoteUser(remoteUser); - }); - }); - } - return true; - }); - } - - private UpdatePresence(guildMember: Discord.GuildMember) { - log.info("DiscordBot", `Updating presence for ${guildMember.user.username}#${guildMember.user.discriminator}`); - const intent = this.bridge.getIntentFromLocalpart(`_discord_${guildMember.id}`); - try { - let presence = guildMember.presence.status; - if (presence === "idle" || presence === "dnd") { - presence = "unavailable"; - } - intent.getClient().setPresence({ - presence, - }); - } catch (err) { - log.info("DiscordBot", "Couldn't set presence ", err); - } - // TODO: Set nicknames inside the scope of guild chats. - } - - private OnTyping(channel: Discord.Channel, user: Discord.User, isTyping: boolean) { - return this.GetRoomIdFromChannel(channel).then((room) => { - const intent = this.bridge.getIntentFromLocalpart(`_discord_${user.id}`); - intent.sendTyping(room, isTyping); - }); - } - - private OnMessage(msg: Discord.Message) { - if (msg.author.id === this.bot.user.id) { - return; // Skip *our* messages - } - this.UpdateUser(msg.author).then(() => { - return this.GetRoomIdFromChannel(msg.channel); - }).then((room) => { - const intent = this.bridge.getIntentFromLocalpart(`_discord_${msg.author.id}`); - // Check Attachements - msg.attachments.forEach((attachment) => { - Util.UploadContentFromUrl(this.bridge, attachment.url, intent, attachment.filename).then((content) => { - const fileMime = mime.lookup(attachment.filename); - const msgtype = attachment.height ? "m.image" : "m.file"; - const info = { - mimetype: fileMime, - size: attachment.filesize, - w: null, - h: null, - }; - if (msgtype === "m.image") { - info.w = attachment.width; - info.h = attachment.height; - } - intent.sendMessage(room, { - body: attachment.filename, - info, - msgtype, - url: content.mxc_url, - }); - }); - }); - if (msg.content !== null && msg.content !== "") { - // Replace mentions. - const content = msg.content.replace(/<@[0-9]*>/g, (item) => { - const id = item.substr(2, item.length - 3); - const member = msg.guild.members.get(id); - if (member) { - return member.user.username; - } else { - return `@_discord_${id}:${this.config.bridge.domain}`; - } - }); - intent.sendMessage(room, { - body: content, - msgtype: "m.text", - formatted_body: marked(content), - format: "org.matrix.custom.html", - }); - } - }); - } -} diff --git a/src/discordcommandhandler.ts b/src/discordcommandhandler.ts new file mode 100644 index 0000000000000000000000000000000000000000..c27bb819b081893e10e9ef4bd8679cad30a76945 --- /dev/null +++ b/src/discordcommandhandler.ts @@ -0,0 +1,162 @@ +/* +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 { DiscordBot } from "./bot"; +import * as Discord from "discord.js"; +import { Util, ICommandActions, ICommandParameters, CommandPermissonCheck } from "./util"; +import { Bridge } from "matrix-appservice-bridge"; +import { Log } from "./log"; + +const log = new Log("DiscordCommandHandler"); + +export class DiscordCommandHandler { + constructor( + private bridge: Bridge, + private discord: DiscordBot, + ) { } + + public async Process(msg: Discord.Message) { + const chan = msg.channel as Discord.TextChannel; + if (!chan.guild) { + await msg.channel.send("**ERROR:** only available for guild channels"); + return; + } + + const intent = this.bridge.getIntent(); + + const actions: ICommandActions = { + approve: { + description: "Approve a pending bridge request", + params: [], + permission: "MANAGE_WEBHOOKS", + run: async () => { + if (await this.discord.Provisioner.MarkApproved(chan, msg.member, true)) { + return "Thanks for your response! The matrix bridge has been approved"; + } else { + return "Thanks for your response, however" + + "the time for responses has expired - sorry!"; + } + }, + }, + ban: { + description: "Bans a user on the matrix side", + params: ["name"], + permission: "BAN_MEMBERS", + run: this.ModerationActionGenerator(chan, "ban"), + }, + deny: { + description: "Deny a pending bridge request", + params: [], + permission: "MANAGE_WEBHOOKS", + run: async () => { + if (await this.discord.Provisioner.MarkApproved(chan, msg.member, false)) { + return "Thanks for your response! The matrix bridge has been declined"; + } else { + return "Thanks for your response, however" + + "the time for responses has expired - sorry!"; + } + }, + }, + kick: { + description: "Kicks a user on the matrix side", + params: ["name"], + permission: "KICK_MEMBERS", + run: this.ModerationActionGenerator(chan, "kick"), + }, + unban: { + description: "Unbans a user on the matrix side", + params: ["name"], + permission: "BAN_MEMBERS", + run: this.ModerationActionGenerator(chan, "unban"), + }, + unbridge: { + description: "Unbridge matrix rooms from this channel", + params: [], + permission: ["MANAGE_WEBHOOKS", "MANAGE_CHANNELS"], + run: async () => this.UnbridgeChannel(chan), + }, + }; + + const parameters: ICommandParameters = { + name: { + description: "The display name or mxid of a matrix user", + get: async (name) => { + const channelMxids = await this.discord.ChannelSyncroniser.GetRoomIdsFromChannel(msg.channel); + const mxUserId = await Util.GetMxidFromName(intent, name, channelMxids); + return mxUserId; + }, + }, + }; + + const permissionCheck: CommandPermissonCheck = async (permission: string|string[]) => { + if (!Array.isArray(permission)) { + permission = [permission]; + } + return permission.every((p) => msg.member.hasPermission(p as Discord.PermissionResolvable)); + }; + + const reply = await Util.ParseCommand("!matrix", msg.content, actions, parameters, permissionCheck); + await msg.channel.send(reply); + } + + private ModerationActionGenerator(discordChannel: Discord.TextChannel, funcKey: "kick"|"ban"|"unban") { + return async ({name}) => { + let allChannelMxids: string[] = []; + await Promise.all(discordChannel.guild.channels.map(async (chan) => { + try { + const chanMxids = await this.discord.ChannelSyncroniser.GetRoomIdsFromChannel(chan); + allChannelMxids = allChannelMxids.concat(chanMxids); + } catch (e) { + // pass, non-text-channel + } + })); + let errorMsg = ""; + await Promise.all(allChannelMxids.map(async (chanMxid) => { + const intent = this.bridge.getIntent(); + try { + await intent[funcKey](chanMxid, name); + } catch (e) { + // maybe we don't have permission to kick/ban/unban...? + errorMsg += `\nCouldn't ${funcKey} ${name} from ${chanMxid}`; + } + })); + if (errorMsg) { + throw Error(errorMsg); + } + const action = { + ban: "Banned", + kick: "Kicked", + unban: "Unbanned", + }[funcKey]; + return `${action} ${name}`; + }; + } + + private async UnbridgeChannel(channel: Discord.TextChannel): Promise<string> { + try { + await this.discord.Provisioner.UnbridgeChannel(channel); + return "This channel has been unbridged"; + } catch (err) { + if (err.message === "Channel is not bridged") { + return "This channel is not bridged to a plumbed matrix room"; + } + log.error("Error while unbridging room " + channel.id); + log.error(err); + return "There was an error unbridging this room. " + + "Please try again later or contact the bridge operator."; + } + } +} diff --git a/src/discordmessageprocessor.ts b/src/discordmessageprocessor.ts new file mode 100644 index 0000000000000000000000000000000000000000..8e9dd555e7004de1bc79192ebdd165b0ef094573 --- /dev/null +++ b/src/discordmessageprocessor.ts @@ -0,0 +1,350 @@ +/* +Copyright 2017 - 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 * as Discord from "discord.js"; +import * as markdown from "discord-markdown"; +import { DiscordBot } from "./bot"; +import * as escapeHtml from "escape-html"; +import { Util } from "./util"; +import { Bridge } from "matrix-appservice-bridge"; + +import { Log } from "./log"; +const log = new Log("DiscordMessageProcessor"); + +const MATRIX_TO_LINK = "https://matrix.to/#/"; +// somehow the regex works properly if it isn't global +// as we replace the match fully anyways this shouldn't be an issue +const MXC_INSERT_REGEX = /\x01emoji\x01(\w+)\x01([01])\x01([0-9]*)\x01/; +const NAME_MXC_INSERT_REGEX_GROUP = 1; +const ANIMATED_MXC_INSERT_REGEX_GROUP = 2; +const ID_MXC_INSERT_REGEX_GROUP = 3; +const EMOJI_SIZE = 32; +const MAX_EDIT_MSG_LENGTH = 50; + +// same as above, no global flag here, too +const CHANNEL_INSERT_REGEX = /\x01chan\x01([0-9]*)\x01/; +const ID_CHANNEL_INSERT_REGEX = 1; + +export class DiscordMessageProcessorOpts { + constructor(readonly domain: string, readonly bot?: DiscordBot) { + + } +} + +export class DiscordMessageProcessorResult { + public formattedBody: string; + public body: string; + public msgtype: string; +} + +interface IDiscordNode { + id: string; +} + +interface IEmojiNode extends IDiscordNode { + animated: boolean; + name: string; +} + +export class DiscordMessageProcessor { + private readonly opts: DiscordMessageProcessorOpts; + constructor(opts: DiscordMessageProcessorOpts, bot: DiscordBot | null = null) { + // Backwards compat + if (bot !== null) { + this.opts = new DiscordMessageProcessorOpts(opts.domain, bot); + } else { + this.opts = opts; + } + } + + public async FormatMessage(msg: Discord.Message): Promise<DiscordMessageProcessorResult> { + const result = new DiscordMessageProcessorResult(); + + let content = msg.content; + + // for the formatted body we need to parse markdown first + // as else it'll HTML escape the result of the discord syntax + let contentPostmark = markdown.toHTML(content, { + discordCallback: this.getDiscordParseCallbacksHTML(msg), + }); + + // parse the plain text stuff + content = markdown.toHTML(content, { + discordCallback: this.getDiscordParseCallbacks(msg), + discordOnly: true, + escapeHTML: false, + }); + content = this.InsertEmbeds(content, msg); + content = await this.InsertMxcImages(content, msg); + content = await this.InsertChannelPills(content, msg); + + // parse postmark stuff + contentPostmark = this.InsertEmbedsPostmark(contentPostmark, msg); + contentPostmark = await this.InsertMxcImages(contentPostmark, msg, true); + contentPostmark = await this.InsertChannelPills(contentPostmark, msg, true); + + result.body = content; + result.formattedBody = contentPostmark; + result.msgtype = msg.author.bot ? "m.notice" : "m.text"; + return result; + } + + public async FormatEdit( + oldMsg: Discord.Message, + newMsg: Discord.Message, + link?: string, + ): Promise<DiscordMessageProcessorResult> { + oldMsg.embeds = []; // we don't want embeds on old msg + const oldMsgParsed = await this.FormatMessage(oldMsg); + const newMsgParsed = await this.FormatMessage(newMsg); + const result = new DiscordMessageProcessorResult(); + result.body = `*edit:* ~~${oldMsgParsed.body}~~ -> ${newMsgParsed.body}`; + result.msgtype = newMsgParsed.msgtype; + oldMsg.content = `*edit:* ~~${oldMsg.content}~~ -> ${newMsg.content}`; + const linkStart = link ? `<a href="${escapeHtml(link)}">` : ""; + const linkEnd = link ? "</a>" : ""; + if (oldMsg.content.includes("\n") || newMsg.content.includes("\n") + || newMsg.content.length > MAX_EDIT_MSG_LENGTH) { + result.formattedBody = `<p>${linkStart}<em>edit:</em>${linkEnd}</p><p><del>${oldMsgParsed.formattedBody}` + + `</del></p><hr><p>${newMsgParsed.formattedBody}</p>`; + } else { + result.formattedBody = `${linkStart}<em>edit:</em>${linkEnd} <del>${oldMsgParsed.formattedBody}</del>` + + ` -> ${newMsgParsed.formattedBody}`; + } + return result; + } + + public InsertEmbeds(content: string, msg: Discord.Message): string { + for (const embed of msg.embeds) { + if (embed.title === undefined && embed.description === undefined) { + continue; + } + if (this.isEmbedInBody(msg, embed)) { + continue; + } + let embedContent = "\n\n----"; // Horizontal rule. Two to make sure the content doesn't become a title. + const embedTitle = embed.url ? `[${embed.title}](${embed.url})` : embed.title; + if (embedTitle) { + embedContent += "\n##### " + embedTitle; // h5 is probably best. + } + if (embed.description) { + embedContent += "\n" + markdown.toHTML(embed.description, { + discordCallback: this.getDiscordParseCallbacks(msg), + discordOnly: true, + escapeHTML: false, + }); + } + if (embed.fields) { + for (const field of embed.fields) { + embedContent += `\n**${field.name}**\n`; + embedContent += markdown.toHTML(field.value, { + discordCallback: this.getDiscordParseCallbacks(msg), + discordOnly: true, + escapeHTML: false, + }); + } + } + if (embed.image) { + embedContent += "\nImage: " + embed.image.url; + } + if (embed.footer) { + embedContent += "\n" + markdown.toHTML(embed.footer.text, { + discordCallback: this.getDiscordParseCallbacks(msg), + discordOnly: true, + escapeHTML: false, + }); + } + content += embedContent; + } + return content; + } + + public InsertEmbedsPostmark(content: string, msg: Discord.Message): string { + for (const embed of msg.embeds) { + if (embed.title === undefined && embed.description === undefined) { + continue; + } + if (this.isEmbedInBody(msg, embed)) { + continue; + } + let embedContent = "<hr>"; // Horizontal rule. Two to make sure the content doesn't become a title. + const embedTitle = embed.url ? + `<a href="${escapeHtml(embed.url)}">${escapeHtml(embed.title)}</a>` + : (embed.title ? escapeHtml(embed.title) : undefined); + if (embedTitle) { + embedContent += `<h5>${embedTitle}</h5>`; // h5 is probably best. + } + if (embed.description) { + embedContent += "<p>"; + embedContent += markdown.toHTML(embed.description, { + discordCallback: this.getDiscordParseCallbacksHTML(msg), + embed: true, + }) + "</p>"; + } + if (embed.fields) { + for (const field of embed.fields) { + embedContent += `<p><strong>${escapeHtml(field.name)}</strong><br>`; + embedContent += markdown.toHTML(field.value, { + discordCallback: this.getDiscordParseCallbacks(msg), + embed: true, + }) + "</p>"; + } + } + if (embed.image) { + const imgUrl = escapeHtml(embed.image.url); + embedContent += `<p>Image: <a href="${imgUrl}">${imgUrl}</a></p>`; + } + if (embed.footer) { + embedContent += "<p>"; + embedContent += markdown.toHTML(embed.footer.text, { + discordCallback: this.getDiscordParseCallbacksHTML(msg), + embed: true, + }) + "</p>"; + } + content += embedContent; + } + return content; + } + + public InsertUser(node: IDiscordNode, msg: Discord.Message, html: boolean = false): string { + const id = node.id; + const member = msg.guild.members.get(id); + const memberId = `@_discord_${id}:${this.opts.domain}`; + const memberName = member ? member.displayName : memberId; + if (!html) { + return memberName; + } + return `<a href="${MATRIX_TO_LINK}${escapeHtml(memberId)}">${escapeHtml(memberName)}</a>`; + } + + 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 + const FLAG = "\x01"; + return `${FLAG}chan${FLAG}${node.id}${FLAG}`; + } + + public InsertRole(node: IDiscordNode, msg: Discord.Message, html: boolean = false): string { + const id = node.id; + const role = msg.guild.roles.get(id); + if (!role) { + return html ? `<@&${id}>` : `<@&${id}>`; + } + if (!html) { + return `@${role.name}`; + } + const color = Util.NumberToHTMLColor(role.color); + return `<span data-mx-color="${color}"><strong>@${escapeHtml(role.name)}</strong></span>`; + } + + public InsertEmoji(node: IEmojiNode): string { + // unfortunately these callbacks are sync, so we flag our url with some special stuff + // and later on grab the real url async + const FLAG = "\x01"; + return `${FLAG}emoji${FLAG}${node.name}${FLAG}${node.animated ? 1 : 0}${FLAG}${node.id}${FLAG}`; + } + + public InsertRoom(msg: Discord.Message, def: string): string { + return msg.mentions.everyone ? "@room" : def; + } + + public async InsertMxcImages(content: string, msg: Discord.Message, html: boolean = false): Promise<string> { + let results = MXC_INSERT_REGEX.exec(content); + while (results !== null) { + const name = results[NAME_MXC_INSERT_REGEX_GROUP]; + const animated = results[ANIMATED_MXC_INSERT_REGEX_GROUP] === "1"; + const id = results[ID_MXC_INSERT_REGEX_GROUP]; + let replace = ""; + const nameHtml = escapeHtml(name); + try { + const mxcUrl = await this.opts.bot!.GetEmoji(name, animated, id); + if (html) { + replace = `<img alt="${nameHtml}" title="${nameHtml}" ` + + `height="${EMOJI_SIZE}" src="${mxcUrl}" />`; + } else { + replace = `:${name}:`; + } + } catch (ex) { + log.warn( + `Could not insert emoji ${id} for msg ${msg.id} in guild ${msg.guild.id}: ${ex}`, + ); + if (html) { + replace = `<${animated ? "a" : ""}:${nameHtml}:${id}>`; + } else { + replace = `<${animated ? "a" : ""}:${name}:${id}>`; + } + } + content = content.replace(results[0], replace); + results = MXC_INSERT_REGEX.exec(content); + } + return content; + } + + public async InsertChannelPills(content: string, msg: Discord.Message, html: boolean = false): Promise<string> { + let results = CHANNEL_INSERT_REGEX.exec(content); + while (results !== null) { + const id = results[ID_CHANNEL_INSERT_REGEX]; + let replace = ""; + const channel = msg.guild.channels.get(id); + if (channel) { + const alias = await this.opts.bot!.ChannelSyncroniser.GetAliasFromChannel(channel); + if (alias) { + const name = "#" + channel.name; + replace = html ? `<a href="${MATRIX_TO_LINK}${escapeHtml(alias)}">${escapeHtml(name)}</a>` : name; + } + } + if (!replace) { + replace = html ? `<#${escapeHtml(id)}>` : `<#${id}>`; + } + content = content.replace(results[0], replace); + results = CHANNEL_INSERT_REGEX.exec(content); + } + return content; + } + + private isEmbedInBody(msg: Discord.Message, embed: Discord.MessageEmbed): boolean { + if (!embed.url) { + return false; + } + let url = embed.url; + if (url.substr(url.length - 1) === "/") { + url = url.substr(0, url.length - 1); + } + return msg.content.includes(url); + } + + private getDiscordParseCallbacks(msg: Discord.Message) { + return { + channel: (node) => this.InsertChannel(node), // are post-inserted + emoji: (node) => this.InsertEmoji(node), // are post-inserted + everyone: (_) => this.InsertRoom(msg, "@everyone"), + here: (_) => this.InsertRoom(msg, "@here"), + role: (node) => this.InsertRole(node, msg), + user: (node) => this.InsertUser(node, msg), + }; + } + + private getDiscordParseCallbacksHTML(msg: Discord.Message) { + return { + channel: (node) => this.InsertChannel(node), // are post-inserted + emoji: (node) => this.InsertEmoji(node), // are post-inserted + everyone: (_) => this.InsertRoom(msg, "@everyone"), + here: (_) => this.InsertRoom(msg, "@here"), + role: (node) => this.InsertRole(node, msg, true), + user: (node) => this.InsertUser(node, msg, true), + }; + } +} diff --git a/src/log.ts b/src/log.ts new file mode 100644 index 0000000000000000000000000000000000000000..0477d8a1af5e77d3e47289f56a034a6afc3248e3 --- /dev/null +++ b/src/log.ts @@ -0,0 +1,141 @@ +/* +Copyright 2018 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 { createLogger, Logger, format, transports } from "winston"; +import { DiscordBridgeConfigLogging, LoggingFile} from "./config"; +import { inspect } from "util"; +import "winston-daily-rotate-file"; + +const FORMAT_FUNC = format.printf((info) => { + return `${info.timestamp} [${info.module}] ${info.level}: ${info.message}`; +}); + +export class Log { + public static get level() { + return this.logger.level; + } + + public static set level(level) { + this.logger.level = level; + } + + public static Configure(config: DiscordBridgeConfigLogging) { + // Merge defaults. + Log.config = Object.assign(new DiscordBridgeConfigLogging(), config); + Log.setupLogger(); + } + + public static ForceSilent() { + new Log("Log").warn("Log set to silent"); + Log.logger.silent = true; + } + + private static config: DiscordBridgeConfigLogging; + private static logger: Logger; + + private static setupLogger() { + if (Log.logger) { + Log.logger.close(); + } + const tsports: transports.StreamTransportInstance[] = Log.config.files.map((file) => + Log.setupFileTransport(file), + ); + tsports.push(new transports.Console({ + level: Log.config.console, + })); + Log.logger = createLogger({ + format: format.combine( + format.timestamp({ + format: Log.config.lineDateFormat, + }), + format.colorize(), + FORMAT_FUNC, + ), + transports: tsports, + }); + } + + private static setupFileTransport(config: LoggingFile): transports.FileTransportInstance { + config = Object.assign(new LoggingFile(), config); + const filterOutMods = format((info, _) => { + if (config.disabled.includes(info.module) && + config.enabled.length > 0 && + !config.enabled.includes(info.module) + ) { + return false; + } + return info; + }); + + const opts = { + datePattern: config.datePattern, + filename: config.file, + format: format.combine( + filterOutMods(), + FORMAT_FUNC, + ), + level: config.level, + maxFiles: config.maxFiles, + maxSize: config.maxSize, + }; + + // tslint:disable-next-line no-any + return new (transports as any).DailyRotateFile(opts); + } + + public warning = this.warn; + + constructor(private module: string) { } + + // tslint:disable-next-line no-any + public error(...msg: any[]) { + this.log("error", msg); + } + + // tslint:disable-next-line no-any + public warn(...msg: any[]) { + this.log("warn", msg); + } + + // tslint:disable-next-line no-any + public info(...msg: any[]) { + this.log("info", msg); + } + + // tslint:disable-next-line no-any + public verbose(...msg: any[]) { + this.log("verbose", msg); + } + + // tslint:disable-next-line no-any + public silly(...msg: any[]) { + this.log("silly", msg); + } + + // tslint:disable-next-line no-any + private log(level: string, msg: any[]) { + if (!Log.logger) { + // We've not configured the logger yet, so create a basic one. + Log.config = new DiscordBridgeConfigLogging(); + Log.setupLogger(); + } + const msgStr = msg.map((item) => { + return typeof(item) === "string" ? item : inspect(item); + }).join(" "); + + Log.logger.log(level, msgStr, {module: this.module}); + } +} diff --git a/src/matrixcommandhandler.ts b/src/matrixcommandhandler.ts new file mode 100644 index 0000000000000000000000000000000000000000..792dc2633b49d5a0c5e0dfd530d0fbd149906391 --- /dev/null +++ b/src/matrixcommandhandler.ts @@ -0,0 +1,216 @@ +/* +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 { DiscordBot } from "./bot"; +import { Log } from "./log"; +import { DiscordBridgeConfig } from "./config"; +import { Bridge, BridgeContext } from "matrix-appservice-bridge"; +import { IMatrixEvent } from "./matrixtypes"; +import { Provisioner } from "./provisioner"; +import { Util, ICommandActions, ICommandParameters, CommandPermissonCheck } from "./util"; +import * as Discord from "discord.js"; +import * as markdown from "discord-markdown"; +import { RemoteStoreRoom } from "./db/roomstore"; +const log = new Log("MatrixCommandHandler"); + +/* tslint:disable:no-magic-numbers */ +const PROVISIONING_DEFAULT_POWER_LEVEL = 50; +const PROVISIONING_DEFAULT_USER_POWER_LEVEL = 0; +const ROOM_CACHE_MAXAGE_MS = 15 * 60 * 1000; +/* tslint:enable:no-magic-numbers */ + +export class MatrixCommandHandler { + private botJoinedRooms: Set<string> = new Set(); // roomids + private botJoinedRoomsCacheUpdatedAt = 0; + private provisioner: Provisioner; + constructor( + private discord: DiscordBot, + private bridge: Bridge, + private config: DiscordBridgeConfig, + ) { + this.provisioner = this.discord.Provisioner; + } + + public async HandleInvite(event: IMatrixEvent) { + log.info(`Received invite for ${event.state_key} in room ${event.room_id}`); + await this.bridge.getIntent().join(event.room_id); + this.botJoinedRooms.add(event.room_id); + } + + public async Process(event: IMatrixEvent, context: BridgeContext) { + if (!(await this.isBotInRoom(event.room_id))) { + log.warn(`Bot is not in ${event.room_id}. Ignoring command`); + return; + } + + const actions: ICommandActions = { + bridge: { + description: "Bridges this room to a Discord channel", + // tslint:disable prefer-template + help: "How to bridge a Discord guild:\n" + + "1. Invite the bot to your Discord guild using this link: " + Util.GetBotLink(this.config) + "\n" + + "2. Invite me to the matrix room you'd like to bridge\n" + + "3. Open the Discord channel you'd like to bridge in a web browser\n" + + "4. In the matrix room, send the message `!discord bridge <guild id> <channel id>` " + + "(without the backticks)\n" + + " Note: The Guild ID and Channel ID can be retrieved from the URL in your web browser.\n" + + " The URL is formatted as https://discordapp.com/channels/GUILD_ID/CHANNEL_ID\n" + + "5. Enjoy your new bridge!", + // tslint:enable prefer-template + params: ["guildId", "channelId"], + permission: { + cat: "events", + level: PROVISIONING_DEFAULT_POWER_LEVEL, + selfService: true, + subcat: "m.room.power_levels", + }, + run: async ({guildId, channelId}) => { + if (context.rooms.remote) { + return "This room is already bridged to a Discord guild."; + } + if (!guildId || !channelId) { + return "Invalid syntax. For more information try `!discord help bridge`"; + } + try { + const discordResult = await this.discord.LookupRoom(guildId, channelId); + const channel = discordResult.channel as Discord.TextChannel; + + log.info(`Bridging matrix room ${event.room_id} to ${guildId}/${channelId}`); + this.bridge.getIntent().sendMessage(event.room_id, { + body: "I'm asking permission from the guild administrators to make this bridge.", + msgtype: "m.notice", + }); + + await this.provisioner.AskBridgePermission(channel, event.sender); + await this.provisioner.BridgeMatrixRoom(channel, event.room_id); + return "I have bridged this room to your channel"; + } catch (err) { + if (err.message === "Timed out waiting for a response from the Discord owners" + || err.message === "The bridge has been declined by the Discord guild") { + return err.message; + } + + log.error(`Error bridging ${event.room_id} to ${guildId}/${channelId}`); + log.error(err); + return "There was a problem bridging that channel - has the guild owner approved the bridge?"; + } + }, + }, + unbridge: { + description: "Unbridges a Discord channel from this room", + params: [], + permission: { + cat: "events", + level: PROVISIONING_DEFAULT_POWER_LEVEL, + selfService: true, + subcat: "m.room.power_levels", + }, + run: async () => { + const remoteRoom = context.rooms.remote as RemoteStoreRoom; + if (!remoteRoom) { + return "This room is not bridged."; + } + if (!remoteRoom.data.plumbed) { + return "This room cannot be unbridged."; + } + const res = await this.discord.LookupRoom( + remoteRoom.data.discord_guild!, + remoteRoom.data.discord_channel!, + ); + try { + await this.provisioner.UnbridgeChannel(res.channel, event.room_id); + return "This room has been unbridged"; + } catch (err) { + log.error("Error while unbridging room " + event.room_id); + log.error(err); + return "There was an error unbridging this room. " + + "Please try again later or contact the bridge operator."; + } + }, + }, + }; + + /* + We hack together that "guildId/channelId" is the same as "guildId channelId". + We do this by assuming that guildId is parsed first, and split at "/" + The first element is returned, the second one is passed on to channelId, if applicable. + */ + let guildIdRemainder: string | undefined; + const parameters: ICommandParameters = { + channelId: { + description: "The ID of a channel on discord", + get: async (s) => { + if (!s && guildIdRemainder) { + return guildIdRemainder; + } + return s; + }, + }, + guildId: { + description: "The ID of a guild/server on discord", + get: async (s) => { + if (!s) { + return s; + } + const parts = s.split("/"); + guildIdRemainder = parts[1]; + return parts[0]; + }, + }, + }; + + const permissionCheck: CommandPermissonCheck = async (permission) => { + if (permission.selfService && !this.config.bridge.enableSelfServiceBridging) { + return "The owner of this bridge does not permit self-service bridging."; + } + return await Util.CheckMatrixPermission( + this.bridge.getIntent().getClient(), + event.sender, + event.room_id, + permission.level, + permission.cat, + permission.subcat, + ); + }; + + const reply = await Util.ParseCommand("!discord", event.content!.body!, actions, parameters, permissionCheck); + const formattedReply = markdown.toHTML(reply); + + await this.bridge.getIntent().sendMessage(event.room_id, { + body: reply, + format: "org.matrix.custom.html", + formatted_body: formattedReply, + msgtype: "m.notice", + }); + } + + private async isBotInRoom(roomId: string): Promise<boolean> { + // Update the room cache, if not done already. + if (Date.now () - this.botJoinedRoomsCacheUpdatedAt > ROOM_CACHE_MAXAGE_MS) { + log.verbose("Updating room cache for bot..."); + try { + log.verbose("Got new room cache for bot"); + this.botJoinedRoomsCacheUpdatedAt = Date.now(); + const rooms = (await this.bridge.getBot().getJoinedRooms()) as string[]; + this.botJoinedRooms = new Set(rooms); + } catch (e) { + log.error("Failed to get room cache for bot, ", e); + return false; + } + } + return this.botJoinedRooms.has(roomId); + } +} diff --git a/src/matrixeventprocessor.ts b/src/matrixeventprocessor.ts new file mode 100644 index 0000000000000000000000000000000000000000..5751c8d962edca82431fd663075cfe55a41dd18f --- /dev/null +++ b/src/matrixeventprocessor.ts @@ -0,0 +1,457 @@ +/* +Copyright 2018, 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 * as Discord from "discord.js"; +import { DiscordBot } from "./bot"; +import { DiscordBridgeConfig } from "./config"; +import * as escapeStringRegexp from "escape-string-regexp"; +import { Util } from "./util"; +import * as path from "path"; +import * as mime from "mime"; +import { MatrixUser, Bridge, BridgeContext } from "matrix-appservice-bridge"; +import { Client as MatrixClient } from "matrix-js-sdk"; +import { IMatrixEvent, IMatrixEventContent, IMatrixMessage } from "./matrixtypes"; +import { MatrixMessageProcessor, IMatrixMessageProcessorParams } from "./matrixmessageprocessor"; +import { MatrixCommandHandler } from "./matrixcommandhandler"; + +import { Log } from "./log"; +const log = new Log("MatrixEventProcessor"); + +const MaxFileSize = 8000000; +const MIN_NAME_LENGTH = 2; +const MAX_NAME_LENGTH = 32; +const DISCORD_AVATAR_WIDTH = 128; +const DISCORD_AVATAR_HEIGHT = 128; +const ROOM_NAME_PARTS = 2; +const AGE_LIMIT = 900000; // 15 * 60 * 1000 + +export class MatrixEventProcessorOpts { + constructor( + readonly config: DiscordBridgeConfig, + readonly bridge: Bridge, + readonly discord: DiscordBot, + ) { + + } +} + +export interface IMatrixEventProcessorResult { + messageEmbed: Discord.RichEmbed; + replyEmbed?: Discord.RichEmbed; +} + +export class MatrixEventProcessor { + private config: DiscordBridgeConfig; + private bridge: Bridge; + private discord: DiscordBot; + private matrixMsgProcessor: MatrixMessageProcessor; + private mxCommandHandler: MatrixCommandHandler; + + constructor(opts: MatrixEventProcessorOpts, cm?: MatrixCommandHandler) { + this.config = opts.config; + this.bridge = opts.bridge; + this.discord = opts.discord; + this.matrixMsgProcessor = new MatrixMessageProcessor(this.discord); + if (cm) { + this.mxCommandHandler = cm; + } else { + this.mxCommandHandler = new MatrixCommandHandler(this.discord, this.bridge, this.config); + } + } + + public async OnEvent(request, context: BridgeContext): Promise<void> { + const event = request.getData() as IMatrixEvent; + if (event.unsigned.age > AGE_LIMIT) { + log.warn(`Skipping event due to age ${event.unsigned.age} > ${AGE_LIMIT}`); + return; + } + if ( + event.type === "m.room.member" && + event.content!.membership === "invite" && + event.state_key === this.bridge.getClientFactory()._botUserId + ) { + await this.mxCommandHandler.HandleInvite(event); + return; + } else if (event.type === "m.room.member" && this.bridge.getBot().isRemoteUser(event.state_key)) { + if (["leave", "ban"].includes(event.content!.membership!) && event.sender !== event.state_key) { + // Kick/Ban handling + let prevMembership = ""; + if (event.content!.membership === "leave") { + const intent = this.bridge.getIntent(); + prevMembership = (await intent.getEvent(event.room_id, event.replaces_state)).content.membership; + } + await this.discord.HandleMatrixKickBan( + event.room_id, + event.state_key, + event.sender, + event.content!.membership as "leave"|"ban", + prevMembership, + event.content!.reason, + ); + } + return; + } else if (["m.room.member", "m.room.name", "m.room.topic"].includes(event.type)) { + await this.ProcessStateEvent(event); + return; + } else if (event.type === "m.room.redaction" && context.rooms.remote) { + await this.discord.ProcessMatrixRedact(event); + return; + } else if (event.type === "m.room.message" || event.type === "m.sticker") { + log.verbose(`Got ${event.type} event`); + const isBotCommand = event.type === "m.room.message" && + event.content!.body && + event.content!.body!.startsWith("!discord"); + if (isBotCommand) { + await this.mxCommandHandler.Process(event, context); + return; + } else if (context.rooms.remote) { + const srvChanPair = context.rooms.remote.roomId.substr("_discord".length).split("_", ROOM_NAME_PARTS); + try { + await this.ProcessMsgEvent(event, srvChanPair[0], srvChanPair[1]); + return; + } catch (err) { + log.warn("There was an error sending a matrix event", err); + return; + } + } + } else if (event.type === "m.room.encryption" && context.rooms.remote) { + try { + await this.HandleEncryptionWarning(event.room_id); + return; + } catch (err) { + throw new Error(`Failed to handle encrypted room, ${err}`); + } + } else { + log.verbose("Got non m.room.message event"); + } + log.verbose("Event not processed by bridge"); + } + + public async HandleEncryptionWarning(roomId: string): Promise<void> { + const intent = this.bridge.getIntent(); + log.info(`User has turned on encryption in ${roomId}, so leaving.`); + /* N.B 'status' is not specced but https://github.com/matrix-org/matrix-doc/pull/828 + has been open for over a year with no resolution. */ + const sendPromise = intent.sendMessage(roomId, { + body: "You have turned on encryption in this room, so the service will not bridge any new messages.", + msgtype: "m.notice", + status: "critical", + }); + const channel = await this.discord.GetChannelFromRoomId(roomId); + await (channel as Discord.TextChannel).send( + "Someone on Matrix has turned on encryption in this room, so the service will not bridge any new messages", + ); + await sendPromise; + await intent.leave(roomId); + await this.bridge.getRoomStore().removeEntriesByMatrixRoomId(roomId); + } + + public async ProcessMsgEvent(event: IMatrixEvent, guildId: string, channelId: string) { + const mxClient = this.bridge.getClientFactory().getClientAs(); + 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); + if (typeof(file) === "string") { + embedSet.messageEmbed.description += " " + file; + } else { + opts.file = file; + } + + await this.discord.send(embedSet, opts, roomLookup, event); + await this.sendReadReceipt(event); + } + + public async ProcessStateEvent(event: IMatrixEvent) { + log.verbose(`Got state event from ${event.room_id} ${event.type}`); + const channel = await this.discord.GetChannelFromRoomId(event.room_id) as Discord.TextChannel; + + const SUPPORTED_EVENTS = ["m.room.member", "m.room.name", "m.room.topic"]; + if (!SUPPORTED_EVENTS.includes(event.type)) { + log.verbose(`${event.event_id} ${event.type} is not displayable.`); + return; + } + + if (event.sender === this.bridge.getIntent().getClient().getUserId()) { + log.verbose(`${event.event_id} ${event.type} is by our bot user, ignoring.`); + return; + } + + 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") { + msg += `set the name to \`${event.content!.name}\``; + } else if (event.type === "m.room.topic") { + 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) { + msg += "joined the room"; + } else if (membership === "invite") { + msg += `invited \`${event.state_key}\` to the room`; + } else if (membership === "leave" && event.state_key !== event.sender) { + msg += `kicked \`${event.state_key}\` from the room`; + } else if (membership === "leave" && allowJoinLeave) { + msg += "left the room"; + } else if (membership === "ban") { + msg += `banned \`${event.state_key}\` from the room`; + } else { + // Ignore anything else + return; + } + } + + msg += " on Matrix."; + await this.discord.sendAsBot(msg, channel, event); + await this.sendReadReceipt(event); + } + + public async EventToEmbed( + 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 params = { + mxClient, + roomId: event.room_id, + userId: event.sender, + } as IMatrixMessageProcessorParams; + if (profile) { + params.displayname = profile.displayname; + } + + let body: string = ""; + if (event.type !== "m.sticker") { + body = await this.matrixMsgProcessor.FormatMessage(event.content as IMatrixMessage, channel.guild, params); + } + + const messageEmbed = new Discord.RichEmbed(); + messageEmbed.setDescription(body); + await this.SetEmbedAuthor(messageEmbed, event.sender, profile); + const replyEmbed = getReply ? (await this.GetEmbedForReply(event, channel)) : undefined; + if (replyEmbed && replyEmbed.fields) { + for (let i = 0; i < replyEmbed.fields.length; i++) { + const f = replyEmbed.fields[i]; + if (f.name === "ping") { + messageEmbed.description += `\n(${f.value})`; + replyEmbed.fields.splice(i, 1); + break; + } + } + } + return { + messageEmbed, + replyEmbed, + }; + } + + public async HandleAttachment(event: IMatrixEvent, mxClient: MatrixClient): Promise<string|Discord.FileOptions> { + if (!this.HasAttachment(event)) { + return ""; + } + + if (!event.content) { + event.content = {}; + } + + 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}; + } + + if (!event.content.url) { + log.info("Event was an attachment type but was missing a content.url"); + return ""; + } + + let size = event.content.info.size || 0; + const url = mxClient.mxcUrlToHttp(event.content.url); + const name = this.GetFilenameForMediaEvent(event.content); + if (size < MaxFileSize) { + const attachment = await Util.DownloadFile(url); + size = attachment.byteLength; + if (size < MaxFileSize) { + return { + attachment, + name, + } as Discord.FileOptions; + } + } + return `[${name}](${url})`; + } + + public async GetEmbedForReply( + event: IMatrixEvent, + channel: Discord.TextChannel, + ): Promise<Discord.RichEmbed|undefined> { + if (!event.content) { + event.content = {}; + } + + const relatesTo = event.content["m.relates_to"]; + let eventId = ""; + if (relatesTo && relatesTo["m.in_reply_to"]) { + eventId = relatesTo["m.in_reply_to"].event_id; + } else { + return; + } + + const intent = this.bridge.getIntent(); + // Try to get the event. + try { + const sourceEvent = await intent.getEvent(event.room_id, eventId); + sourceEvent.content.body = sourceEvent.content.body || "Reply with unknown content"; + const replyEmbed = (await this.EventToEmbed(sourceEvent, channel, false)).messageEmbed; + + // if we reply to a discord member, ping them! + if (this.bridge.getBot().isRemoteUser(sourceEvent.sender)) { + const uid = new MatrixUser(sourceEvent.sender.replace("@", "")).localpart.substring("_discord".length); + replyEmbed.addField("ping", `<@${uid}>`); + } + + replyEmbed.setTimestamp(new Date(sourceEvent.origin_server_ts)); + + if (this.HasAttachment(sourceEvent)) { + const mxClient = this.bridge.getClientFactory().getClientAs(); + const url = mxClient.mxcUrlToHttp(sourceEvent.content.url); + if (["m.image", "m.sticker"].includes(sourceEvent.content.msgtype as string) + || sourceEvent.type === "m.sticker") { + // we have an image reply + replyEmbed.setImage(url); + } else { + const name = this.GetFilenameForMediaEvent(sourceEvent.content); + replyEmbed.description = `[${name}](${url})`; + } + } + return replyEmbed; + } catch (ex) { + log.warn("Failed to handle reply, showing a unknown embed:", ex); + } + // For some reason we failed to get the event, so using fallback. + const embed = new Discord.RichEmbed(); + embed.setDescription("Reply with unknown content"); + embed.setAuthor("Unknown"); + return embed; + } + + private async sendReadReceipt(event: IMatrixEvent) { + if (!this.config.bridge.disableReadReceipts) { + try { + await this.bridge.getIntent().sendReadReceipt(event.room_id, event.event_id); + } catch (err) { + log.error(`Failed to send read receipt for ${event}. `, err); + } + } + } + + private HasAttachment(event: IMatrixEvent): boolean { + if (!event.content) { + event.content = {}; + } + + const hasAttachment = [ + "m.image", + "m.audio", + "m.video", + "m.file", + "m.sticker", + ].includes(event.content.msgtype as string) || [ + "m.sticker", + ].includes(event.type); + return hasAttachment; + } + + private async SetEmbedAuthor(embed: Discord.RichEmbed, sender: string, profile?: IMatrixEvent | null) { + const intent = this.bridge.getIntent(); + let displayName = sender; + let avatarUrl; + + // Are they a discord user. + if (this.bridge.getBot().isRemoteUser(sender)) { + const localpart = new MatrixUser(sender.replace("@", "")).localpart; + const userOrMember = await this.discord.GetDiscordUserOrMember(localpart.substring("_discord".length)); + if (userOrMember instanceof Discord.User) { + embed.setAuthor( + userOrMember.username, + userOrMember.avatarURL, + ); + return; + } else if (userOrMember instanceof Discord.GuildMember) { + embed.setAuthor( + userOrMember.displayName, + userOrMember.user.avatarURL, + ); + return; + } + // 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 && + profile.displayname.length >= MIN_NAME_LENGTH && + profile.displayname.length <= MAX_NAME_LENGTH) { + displayName = profile.displayname; + } + + if (profile.avatar_url) { + const mxClient = this.bridge.getClientFactory().getClientAs(); + avatarUrl = mxClient.mxcUrlToHttp(profile.avatar_url, DISCORD_AVATAR_WIDTH, DISCORD_AVATAR_HEIGHT); + } + } + embed.setAuthor( + displayName.substr(0, MAX_NAME_LENGTH), + avatarUrl, + `https://matrix.to/#/${sender}`, + ); + } + + private GetFilenameForMediaEvent(content: IMatrixEventContent): string { + if (content.body) { + if (path.extname(content.body) !== "") { + return content.body; + } + return `${path.basename(content.body)}.${mime.extension(content.info.mimetype)}`; + } + return "matrix-media." + mime.extension(content.info.mimetype); + } +} diff --git a/src/matrixmessageprocessor.ts b/src/matrixmessageprocessor.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff95b4588d3c221e75970a6fb70d7d05ec6eb90c --- /dev/null +++ b/src/matrixmessageprocessor.ts @@ -0,0 +1,359 @@ +/* +Copyright 2018, 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 * as Discord from "discord.js"; +import { IMatrixMessage, IMatrixEvent } from "./matrixtypes"; +import * as Parser from "node-html-parser"; +import { Util } from "./util"; +import { DiscordBot } from "./bot"; +import { Client as MatrixClient } from "matrix-js-sdk"; + +const MIN_NAME_LENGTH = 2; +const MAX_NAME_LENGTH = 32; +const MATRIX_TO_LINK = "https://matrix.to/#/"; +const DEFAULT_ROOM_NOTIFY_POWER_LEVEL = 50; + +export interface IMatrixMessageProcessorParams { + displayname?: string; + mxClient?: MatrixClient; + roomId?: string; + userId?: string; +} + +export class MatrixMessageProcessor { + private guild: Discord.Guild; + private listDepth: number = 0; + private listBulletPoints: string[] = ["●", "○", "■", "‣"]; + private params?: IMatrixMessageProcessorParams; + constructor(public bot: DiscordBot) { } + public async FormatMessage( + msg: IMatrixMessage, + guild: Discord.Guild, + params?: IMatrixMessageProcessorParams, + ): Promise<string> { + this.guild = guild; + this.listDepth = 0; + this.params = params; + let reply = ""; + if (msg.formatted_body) { + // parser needs everything wrapped in html elements + // so we wrap everything in <div> just to be sure stuff is wrapped + // as <div> will be un-touched anyways + const parsed = Parser.parse(`<div>${msg.formatted_body}</div>`, { + lowerCaseTagName: true, + pre: true, + // tslint:disable-next-line no-any + } as any); + reply = await this.walkNode(parsed); + reply = reply.replace(/\s*$/, ""); // trim off whitespace at end + } else { + reply = await this.escapeDiscord(msg.body); + } + + if (msg.msgtype === "m.emote") { + if (params && + params.displayname && + params.displayname.length >= MIN_NAME_LENGTH && + params.displayname.length <= MAX_NAME_LENGTH) { + reply = `_${await this.escapeDiscord(params.displayname)} ${reply}_`; + } else { + reply = `_${reply}_`; + } + } + return reply; + } + + private async canNotifyRoom() { + if (!this.params || !this.params.mxClient || !this.params.roomId || !this.params.userId) { + return false; + } + return await Util.CheckMatrixPermission( + this.params.mxClient, + this.params.userId, + this.params.roomId, + DEFAULT_ROOM_NOTIFY_POWER_LEVEL, + "notifications", + "room", + ); + } + + private async escapeDiscord(msg: string): Promise<string> { + // \u200B is the zero-width space --> they still look the same but don't mention + msg = msg.replace(/@everyone/g, "@\u200Beveryone"); + msg = msg.replace(/@here/g, "@\u200Bhere"); + + // Check the Matrix permissions to see if this user has the required + // power level to notify with @room; if so, replace it with @here. + if (msg.includes("@room") && await this.canNotifyRoom()) { + msg = msg.replace(/@room/g, "@here"); + } + const escapeChars = ["\\", "*", "_", "~", "`", "|"]; + msg = msg.split(" ").map((s) => { + if (s.match(/^https?:\/\//)) { + return s; + } + escapeChars.forEach((char) => { + s = s.replace(new RegExp("\\" + char, "g"), "\\" + char); + }); + return s; + }).join(" "); + return msg; + } + + private parsePreContent(node: Parser.HTMLElement): string { + let text = node.text; + const match = text.match(/^<code([^>]*)>/i); + if (!match) { + if (text[0] !== "\n") { + text = "\n" + text; + } + return text; + } + // remove <code> opening-tag + text = text.substr(match[0].length); + // remove </code> closing tag + text = text.replace(/<\/code>$/i, ""); + if (text[0] !== "\n") { + text = "\n" + text; + } + const language = match[1].match(/language-(\w*)/i); + if (language) { + text = language[1] + text; + } + return text; + } + + private parseUser(id: string): string { + const USER_REGEX = /^@_discord_([0-9]*)/; + const match = id.match(USER_REGEX); + if (!match || !this.guild.members.get(match[1])) { + return ""; + } + return `<@${match[1]}>`; + } + + private async parseChannel(id: string): Promise<string> { + const CHANNEL_REGEX = /^#_discord_[0-9]*_([0-9]*):/; + const match = id.match(CHANNEL_REGEX); + if (!match || !this.guild.channels.get(match[1])) { + /* + This isn't formatted in #_discord_, so let's fetch the internal room ID + and see if it is still a bridged room! + */ + if (this.params && this.params.mxClient) { + try { + const resp = await this.params.mxClient.getRoomIdForAlias(id); + if (resp && resp.room_id) { + const roomId = resp.room_id; + const channel = await this.bot.GetChannelFromRoomId(roomId); + return `<#${channel.id}>`; + } + } catch (err) { } // ignore, room ID wasn't found + } + return ""; + } + return `<#${match[1]}>`; + } + + private async parseLinkContent(node: Parser.HTMLElement): Promise<string> { + const attrs = node.attributes; + const content = await this.walkChildNodes(node); + if (!attrs.href || content === attrs.href) { + return content; + } + return `[${content}](${attrs.href})`; + } + + private async parsePillContent(node: Parser.HTMLElement): Promise<string> { + const attrs = node.attributes; + if (!attrs.href || !attrs.href.startsWith(MATRIX_TO_LINK)) { + return await this.parseLinkContent(node); + } + const id = attrs.href.replace(MATRIX_TO_LINK, ""); + let reply = ""; + switch (id[0]) { + case "@": + // user pill + reply = this.parseUser(id); + break; + case "#": + reply = await this.parseChannel(id); + break; + } + if (!reply) { + return await this.parseLinkContent(node); + } + return reply; + } + + private async parseImageContent(node: Parser.HTMLElement): Promise<string> { + const EMOTE_NAME_REGEX = /^:?(\w+):?/; + const attrs = node.attributes; + const name = attrs.alt || attrs.title || ""; + let emoji: Discord.Emoji | null = null; + // first check for matching mxc url + if (attrs.src) { + let id = ""; + try { + const emojiDb = await this.bot.GetEmojiByMxc(attrs.src); + id = emojiDb.EmojiId; + emoji = this.guild.emojis.find((e) => e.id === id); + } catch (e) { + emoji = null; + } + } + // nexc check for matching alt text / title + if (!emoji) { + const match = name.match(EMOTE_NAME_REGEX); + let emojiName = ""; + if (match) { + emojiName = match[1]; + emoji = this.guild.emojis.find((e) => e.name === emojiName); + } + } + + if (!emoji) { + const content = await this.escapeDiscord(name); + const url = this.params && this.params.mxClient ? this.params.mxClient.mxcUrlToHttp(attrs.src) : attrs.src; + return attrs.src ? `[${content}](${url})` : content; + } + return `<${emoji.animated ? "a" : ""}:${emoji.name}:${emoji.id}>`; + } + + private async parseBlockquoteContent(node: Parser.HTMLElement): Promise<string> { + let msg = await this.walkChildNodes(node); + + msg = msg.split("\n").map((s) => { + return "> " + s; + }).join("\n"); + msg = msg + "\n\n"; + return msg; + } + + private async parseUlContent(node: Parser.HTMLElement): Promise<string> { + this.listDepth++; + const entries = await this.arrayChildNodes(node, ["li"]); + this.listDepth--; + const bulletPoint = this.listBulletPoints[this.listDepth % this.listBulletPoints.length]; + + let msg = entries.map((s) => { + return `${" ".repeat(this.listDepth)}${bulletPoint} ${s}`; + }).join("\n"); + + if (this.listDepth === 0) { + msg = `\n${msg}\n\n`; + } + return msg; + } + + private async parseOlContent(node: Parser.HTMLElement): Promise<string> { + this.listDepth++; + const entries = await this.arrayChildNodes(node, ["li"]); + this.listDepth--; + let entry = 0; + const attrs = node.attributes; + if (attrs.start && attrs.start.match(/^[0-9]+$/)) { + entry = parseInt(attrs.start, 10) - 1; + } + + let msg = entries.map((s) => { + entry++; + return `${" ".repeat(this.listDepth)}${entry}. ${s}`; + }).join("\n"); + + if (this.listDepth === 0) { + msg = `\n${msg}\n\n`; + } + return msg; + } + + private async arrayChildNodes(node: Parser.Node, types: string[] = []): Promise<string[]> { + const replies: string[] = []; + await Util.AsyncForEach(node.childNodes, async (child) => { + if (types.length && ( + child.nodeType === Parser.NodeType.TEXT_NODE + || !types.includes((child as Parser.HTMLElement).tagName) + )) { + return; + } + replies.push(await this.walkNode(child)); + }); + return replies; + } + + private async walkChildNodes(node: Parser.Node): Promise<string> { + let reply = ""; + await Util.AsyncForEach(node.childNodes, async (child) => { + reply += await this.walkNode(child); + }); + return reply; + } + + private async walkNode(node: Parser.Node): Promise<string> { + if (node.nodeType === Parser.NodeType.TEXT_NODE) { + // ignore \n between single nodes + if ((node as Parser.TextNode).text === "\n") { + return ""; + } + return await this.escapeDiscord((node as Parser.TextNode).text); + } else if (node.nodeType === Parser.NodeType.ELEMENT_NODE) { + const nodeHtml = node as Parser.HTMLElement; + switch (nodeHtml.tagName) { + case "em": + case "i": + return `*${await this.walkChildNodes(nodeHtml)}*`; + case "strong": + case "b": + return `**${await this.walkChildNodes(nodeHtml)}**`; + case "u": + return `__${await this.walkChildNodes(nodeHtml)}__`; + case "del": + return `~~${await this.walkChildNodes(nodeHtml)}~~`; + case "code": + return `\`${nodeHtml.text}\``; + case "pre": + return `\`\`\`${this.parsePreContent(nodeHtml)}\`\`\``; + case "a": + return await this.parsePillContent(nodeHtml); + case "img": + return await this.parseImageContent(nodeHtml); + case "br": + return "\n"; + case "blockquote": + return await this.parseBlockquoteContent(nodeHtml); + case "ul": + return await this.parseUlContent(nodeHtml); + case "ol": + return await this.parseOlContent(nodeHtml); + case "mx-reply": + return ""; + case "hr": + return "\n----------\n"; + case "h1": + case "h2": + case "h3": + case "h4": + case "h5": + case "h6": + const level = parseInt(nodeHtml.tagName[1], 10); + return `**${"#".repeat(level)} ${await this.walkChildNodes(nodeHtml)}**\n`; + default: + return await this.walkChildNodes(nodeHtml); + } + } + return ""; + } +} diff --git a/src/matrixroomhandler.ts b/src/matrixroomhandler.ts index c2545348b59bb0ecc42fb823cb7afb242aa2fdb6..a91d4c76eb22b7e366a73a8e8ee093f76d85783e 100644 --- a/src/matrixroomhandler.ts +++ b/src/matrixroomhandler.ts @@ -1,179 +1,287 @@ -import { DiscordBot } from "./discordbot"; +/* +Copyright 2018, 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 { DiscordBot } from "./bot"; import { - Bridge, - RemoteRoom, - thirdPartyLookup, - thirdPartyProtocolResult, - thirdPartyUserResult, - thirdPartyLocationResult, - } from "matrix-appservice-bridge"; + Bridge, + RemoteRoom, + thirdPartyLookup, + thirdPartyProtocolResult, + thirdPartyUserResult, + thirdPartyLocationResult, + ProvisionedRoom, + Intent, +} from "matrix-appservice-bridge"; import { DiscordBridgeConfig } from "./config"; import * as Discord from "discord.js"; -import * as log from "npmlog"; +import { Util } from "./util"; +import { Provisioner } from "./provisioner"; +import { Log } from "./log"; +const log = new Log("MatrixRoomHandler"); +import { IMatrixEvent } from "./matrixtypes"; +import { DbRoomStore, MatrixStoreRoom, RemoteStoreRoom } from "./db/roomstore"; const ICON_URL = "https://matrix.org/_matrix/media/r0/download/matrix.org/mlxoESwIsTbJrfXyAAogrNxA"; +/* tslint:disable:no-magic-numbers */ +const HTTP_UNSUPPORTED = 501; +const ROOM_NAME_PARTS = 2; +const PROVISIONING_DEFAULT_POWER_LEVEL = 50; +const PROVISIONING_DEFAULT_USER_POWER_LEVEL = 0; +const USERSYNC_STATE_DELAY_MS = 5000; +const ROOM_CACHE_MAXAGE_MS = 15 * 60 * 1000; + +// Note: The schedule must not have duplicate values to avoid problems in positioning. +// Disabled because it complains about the values in the array +const JOIN_ROOM_SCHEDULE = [ + 0, // Right away + 1000, // 1 second + 30000, // 30 seconds + 300000, // 5 minutes + 900000, // 15 minutes +]; +/* tslint:enable:no-magic-numbers */ export class MatrixRoomHandler { - private config: DiscordBridgeConfig; - private bridge: Bridge; - private discord: DiscordBot; - private botUserId: string; - constructor (discord: DiscordBot, config: DiscordBridgeConfig, botUserId: string) { - this.discord = discord; - this.config = config; - this.botUserId = botUserId; - } - - public get ThirdPartyLookup(): thirdPartyLookup { - return { - protocols: ["discord"], - getProtocol: this.tpGetProtocol.bind(this), - getLocation: this.tpGetLocation.bind(this), - parseLocation: this.tpParseLocation.bind(this), - getUser: this.tpGetUser.bind(this), - parseUser: this.tpParseUser.bind(this), - }; - } - - public setBridge(bridge: Bridge) { - this.bridge = bridge; - } - - public OnAliasQueried (alias: string, roomId: string) { - return; // We don't use this. - } - - public OnEvent (request, context) { - const event = request.getData(); - if (event.type === "m.room.message" && context.rooms.remote) { - let srvChanPair = context.rooms.remote.roomId.substr("_discord".length).split("_", 2); - this.discord.ProcessMatrixMsgEvent(event, srvChanPair[0], srvChanPair[1]); + private botUserId: string; + private botJoinedRooms: Set<string>; // roomids + private botJoinedRoomsCacheUpdatedAt = 0; + constructor( + private discord: DiscordBot, + private config: DiscordBridgeConfig, + private provisioner: Provisioner, + private bridge: Bridge, + private roomStore: DbRoomStore) { + this.botUserId = this.discord.BotUserId; + this.botJoinedRooms = new Set(); } - } - public OnAliasQuery (alias: string, aliasLocalpart: string): Promise<any> { - let srvChanPair = aliasLocalpart.substr("_discord_".length).split("_", 2); - if (srvChanPair.length < 2 || srvChanPair[0] === "" || srvChanPair[1] === "") { - log.warn("MatrixRoomHandler", `Alias '${aliasLocalpart}' was missing a server and/or a channel`); - return; + public get ThirdPartyLookup(): thirdPartyLookup { + return { + getLocation: this.tpGetLocation.bind(this), + getProtocol: this.tpGetProtocol.bind(this), + getUser: this.tpGetUser.bind(this), + parseLocation: this.tpParseLocation.bind(this), + parseUser: this.tpParseUser.bind(this), + protocols: ["discord"], + }; } - return this.discord.LookupRoom(srvChanPair[0], srvChanPair[1]).then((channel) => { - return this.createMatrixRoom(channel, aliasLocalpart); - }).catch((err) => { - log.error("MatrixRoomHandler", `Couldn't find discord room '${aliasLocalpart}'.`, err); - }); - } - - public tpGetProtocol(protocol: string): Promise<thirdPartyProtocolResult> { - return Promise.resolve({ - user_fields: ["username", "discriminator"], - location_fields: ["guild_id", "channel_name"], - field_types: { - // guild_name: { - // regexp: "\S.{0,98}\S", - // placeholder: "Guild", - // }, - guild_id: { - regexp: "[0-9]*", - placeholder: "", - }, - channel_id: { - regexp: "[0-9]*", - placeholder: "", - }, - channel_name: { - regexp: "[A-Za-z0-9_\-]{2,100}", - placeholder: "#Channel", - }, - username: { - regexp: "[A-Za-z0-9_\-]{2,100}", - placeholder: "Username", - }, - discriminator: { - regexp: "[0-9]{4}", - placeholder: "1234", - }, - }, - instances: this.discord.GetGuilds().map((guild) => { + + public async OnAliasQueried(alias: string, roomId: string) { + log.verbose(`Got OnAliasQueried for ${alias} ${roomId}`); + let channel: Discord.GuildChannel; + try { + // We previously stored the room as an alias. + const entry = (await this.roomStore.getEntriesByMatrixId(alias))[0]; + if (!entry) { + throw new Error("Entry was not found"); + } + // Remove the old entry + await this.roomStore.removeEntriesByMatrixRoomId( + entry.matrix!.roomId, + ); + await this.roomStore.linkRooms( + new MatrixStoreRoom(roomId), + entry.remote!, + ); + channel = await this.discord.GetChannelFromRoomId(roomId) as Discord.GuildChannel; + } catch (err) { + log.error(`Cannot find discord channel for ${alias} ${roomId}`, err); + throw err; + } + + // Fire and forget RoomDirectory mapping + this.bridge.getIntent().getClient().setRoomDirectoryVisibilityAppService( + channel.guild.id, + roomId, + "public", + ); + await this.discord.ChannelSyncroniser.OnUpdate(channel); + const promiseList: Promise<void>[] = []; + // Join a whole bunch of users. + // We delay the joins to give some implementations a chance to breathe + let delay = this.config.limits.roomGhostJoinDelay; + for (const member of (channel as Discord.TextChannel).members.array()) { + if (member.id === this.discord.GetBotId()) { + continue; + } + promiseList.push((async () => { + await Util.DelayedPromise(delay); + log.info(`UserSyncing ${member.id}`); + try { + // Ensure the profile is up to date. + await this.discord.UserSyncroniser.OnUpdateUser(member.user); + } catch (err) { + log.warn(`Failed to update profile of user ${member.id}`, err); + } + log.info(`Joining ${member.id} to ${roomId}`); + + await this.joinRoom(this.discord.GetIntentFromDiscordMember(member), roomId, member); + })()); + delay += this.config.limits.roomGhostJoinDelay; + } + await Promise.all(promiseList); + } + + public async OnAliasQuery(alias: string, aliasLocalpart: string): Promise<ProvisionedRoom> { + log.info("Got request for #", aliasLocalpart); + const srvChanPair = aliasLocalpart.substr("_discord_".length).split("_", ROOM_NAME_PARTS); + if (srvChanPair.length < ROOM_NAME_PARTS || srvChanPair[0] === "" || srvChanPair[1] === "") { + log.warn(`Alias '${aliasLocalpart}' was missing a server and/or a channel`); + return; + } + try { + const result = await this.discord.LookupRoom(srvChanPair[0], srvChanPair[1]); + log.info("Creating #", aliasLocalpart); + return this.createMatrixRoom(result.channel, alias, aliasLocalpart); + } catch (err) { + log.error(`Couldn't find discord room '${aliasLocalpart}'.`, err); + } + + } + + public async tpGetProtocol(protocol: string): Promise<thirdPartyProtocolResult> { return { - network_id: guild.id, - bot_user_id: this.botUserId, - desc: guild.name, - icon: guild.iconURL || ICON_URL, // TODO: Use icons from our content repo. Potential security risk. - fields: { - guild_id: guild.id, - }, + field_types: { + // guild_name: { + // regexp: "\S.{0,98}\S", + // placeholder: "Guild", + // }, + channel_id: { + placeholder: "", + regexp: "[0-9]*", + }, + channel_name: { + placeholder: "#Channel", + regexp: "[A-Za-z0-9_\-]{2,100}", + }, + discriminator: { + placeholder: "1234", + regexp: "[0-9]{4}", + }, + guild_id: { + placeholder: "", + regexp: "[0-9]*", + }, + username: { + placeholder: "Username", + regexp: "[A-Za-z0-9_\-]{2,100}", + }, + }, + instances: this.discord.GetGuilds().map((guild) => { + return { + bot_user_id: this.botUserId, + desc: guild.name, + fields: { + guild_id: guild.id, + }, + icon: guild.iconURL || ICON_URL, // TODO: Use icons from our content repo. Potential security risk. + network_id: guild.id, + }; + }), + location_fields: ["guild_id", "channel_name"], + user_fields: ["username", "discriminator"], }; - }), - }); - } - - public tpGetLocation(protocol: string, fields: any): Promise<thirdPartyLocationResult[]> { - log.info("MatrixRoomHandler", "Got location request ", protocol, fields); - const chans = this.discord.ThirdpartySearchForChannels(fields.guild_id, fields.channel_name); - console.log(chans); - return Promise.resolve(chans); - } - - public tpParseLocation(alias: string): Promise<thirdPartyLocationResult[]> { - return Promise.reject({err: "Unsupported", code: 501}); - } - - public tpGetUser(protocol: string, fields: any): Promise<thirdPartyUserResult[]> { - log.info("MatrixRoomHandler", "Got user request ", protocol, fields); - return Promise.reject({err: "Unsupported", code: 501}); - } - - public tpParseUser(userid: string): Promise<thirdPartyUserResult[]> { - return Promise.reject({err: "Unsupported", code: 501}); - } - - private createMatrixRoom (channel: Discord.TextChannel, alias: string) { - const botID = this.bridge.getBot().getUserId(); - // const roomOwner = "@_discord_" + user.id_str + ":" + this._bridge.opts.domain; - const users = {}; - users[botID] = 100; - // users[roomOwner] = 75; - // var powers = util.roomPowers(users); - const remote = new RemoteRoom(`discord_${channel.guild.id}_${channel.id}`); - remote.set("discord_type", "text"); - remote.set("discord_guild", channel.guild.id); - remote.set("discord_channel", channel.id); - - const gname = channel.guild.name.replace(" ", "-"); - const cname = channel.name.replace(" ", "-"); - - const creationOpts = { - visibility: "public", - room_alias_name: alias, - name: `[Discord] ${channel.guild.name} #${channel.name}`, - topic: channel.topic ? channel.topic : "", - // invite: [roomOwner], - initial_state: [ - // powers, - { - type: "m.room.join_rules", - content: { - join_rule: "public", - }, - state_key: "", - }, - // }, { - // type: "org.matrix.twitter.data", - // content: user, - // state_key: "" - // }, { - // type: "m.room.avatar", - // state_key: "", - // content: { - // url: avatar - // } - ], - }; - return { - creationOpts, - remote, - }; - } + } + + // tslint:disable-next-line no-any + public async tpGetLocation(protocol: string, fields: any): Promise<thirdPartyLocationResult[]> { + log.info("Got location request ", protocol, fields); + const chans = this.discord.ThirdpartySearchForChannels(fields.guild_id, fields.channel_name); + return chans; + } + + public async tpParseLocation(alias: string): Promise<thirdPartyLocationResult[]> { + throw {err: "Unsupported", code: HTTP_UNSUPPORTED}; + } + + // tslint:disable-next-line no-any + public async tpGetUser(protocol: string, fields: any): Promise<thirdPartyUserResult[]> { + log.info("Got user request ", protocol, fields); + throw {err: "Unsupported", code: HTTP_UNSUPPORTED}; + } + + public async tpParseUser(userid: string): Promise<thirdPartyUserResult[]> { + throw {err: "Unsupported", code: HTTP_UNSUPPORTED}; + } + + private async joinRoom(intent: Intent, roomIdOrAlias: string, member?: Discord.GuildMember): Promise<void> { + let currentSchedule = JOIN_ROOM_SCHEDULE[0]; + const doJoin = async () => { + await Util.DelayedPromise(currentSchedule); + if (member) { + await this.discord.UserSyncroniser.JoinRoom(member, roomIdOrAlias); + } else { + await intent.getClient().joinRoom(roomIdOrAlias); + } + }; + const errorHandler = async (err) => { + log.error(`Error joining room ${roomIdOrAlias} as ${intent.getClient().getUserId()}`); + log.error(err); + const idx = JOIN_ROOM_SCHEDULE.indexOf(currentSchedule); + if (idx === JOIN_ROOM_SCHEDULE.length - 1) { + log.warn(`Cannot join ${roomIdOrAlias} as ${intent.getClient().getUserId()}`); + throw new Error(err); + } else { + currentSchedule = JOIN_ROOM_SCHEDULE[idx + 1]; + try { + await doJoin(); + } catch (e) { + await errorHandler(e); + } + } + }; + + try { + await doJoin(); + } catch (e) { + await errorHandler(e); + } + } + + private async createMatrixRoom(channel: Discord.TextChannel, + alias: string, aliasLocalpart: string): ProvisionedRoom { + const remote = new RemoteStoreRoom(`discord_${channel.guild.id}_${channel.id}`, { + discord_channel: channel.id, + discord_guild: channel.guild.id, + discord_type: "text", + update_icon: 1, + update_name: 1, + update_topic: 1, + }); + const creationOpts = { + initial_state: [ + { + content: { + join_rule: "public", + }, + state_key: "", + type: "m.room.join_rules", + }, + ], + room_alias_name: aliasLocalpart, + visibility: this.config.room.defaultVisibility, + }; + // We need to tempoarily store this until we know the room_id. + await this.roomStore.linkRooms( + new MatrixStoreRoom(alias), + remote, + ); + return { + creationOpts, + } as ProvisionedRoom; + } } diff --git a/src/matrixtypes.ts b/src/matrixtypes.ts new file mode 100644 index 0000000000000000000000000000000000000000..7535b59a89063a02e1f1946321550287f483dd94 --- /dev/null +++ b/src/matrixtypes.ts @@ -0,0 +1,62 @@ +/* +Copyright 2018, 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. +*/ + +export interface IMatrixEventContent { + body?: string; + info?: any; // tslint:disable-line no-any + name?: string; + topic?: string; + membership?: string; + msgtype?: string; + url?: string; + displayname?: string; + reason?: string; + "m.relates_to"?: any; // tslint:disable-line no-any +} + +export interface IMatrixEvent { + event_id: string; + state_key: string; + type: string; + sender: string; + room_id: string; + membership?: string; + avatar_url?: string; + displayname?: string; + redacts?: string; + replaces_state?: string; + content?: IMatrixEventContent; + unsigned?: any; // tslint:disable-line no-any + origin_server_ts?: number; + users?: any; // tslint:disable-line no-any + users_default?: any; // tslint:disable-line no-any + notifications?: any; // tslint:disable-line no-any +} + +export interface IMatrixMessage { + body: string; + msgtype: string; + formatted_body?: string; + format?: string; +} + +export interface IMatrixMediaInfo { + w?: number; + h?: number; + mimetype: string; + size: number; + duration?: number; +} diff --git a/src/presencehandler.ts b/src/presencehandler.ts new file mode 100644 index 0000000000000000000000000000000000000000..16fa4a974510cf405e931a02a278789b3f89e08a --- /dev/null +++ b/src/presencehandler.ts @@ -0,0 +1,147 @@ +/* +Copyright 2017 - 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 { User, Presence } from "discord.js"; +import { DiscordBot } from "./bot"; +import { Log } from "./log"; +const log = new Log("PresenceHandler"); + +export class PresenceHandlerStatus { + /* One of: ["online", "offline", "unavailable"] */ + public Presence: string; + public StatusMsg: string; + public ShouldDrop: boolean = false; +} + +interface IMatrixPresence { + presence?: string; + status_msg?: string; +} + +export class PresenceHandler { + private presenceQueue: User[]; + private interval: NodeJS.Timeout | null; + constructor(private bot: DiscordBot) { + this.presenceQueue = []; + } + + get QueueCount(): number { + return this.presenceQueue.length; + } + + public async Start(intervalTime: number) { + if (this.interval) { + log.info("Restarting presence handler..."); + this.Stop(); + } + log.info(`Starting presence handler with new interval ${intervalTime}ms`); + this.interval = setInterval(await this.processIntervalThread.bind(this), + intervalTime); + } + + public Stop() { + if (!this.interval) { + log.info("Can not stop interval, not running."); + return; + } + log.info("Stopping presence handler"); + clearInterval(this.interval); + this.interval = null; + } + + public EnqueueUser(user: User) { + if (user.id !== this.bot.GetBotId() && this.presenceQueue.find((u) => u.id === user.id) === undefined) { + log.info(`Adding ${user.id} (${user.username}) to the presence queue`); + this.presenceQueue.push(user); + } + } + + public DequeueUser(user: User) { + const index = this.presenceQueue.findIndex((item) => { + return user.id === item.id; + }); + if (index !== -1) { + this.presenceQueue.splice(index, 1); + } else { + log.warn( + `Tried to remove ${user.id} from the presence queue but it could not be found`, + ); + } + } + + public async ProcessUser(user: User): Promise<boolean> { + const status = this.getUserPresence(user.presence); + await this.setMatrixPresence(user, status); + return status.ShouldDrop; + } + + private async processIntervalThread() { + const user = this.presenceQueue.shift(); + if (user) { + const proccessed = await this.ProcessUser(user); + if (!proccessed) { + this.presenceQueue.push(user); + } else { + log.info(`Dropping ${user.id} from the presence queue.`); + } + } + } + + private getUserPresence(presence: Presence): PresenceHandlerStatus { + const status = new PresenceHandlerStatus(); + + if (presence.game) { + status.StatusMsg = `${presence.game.streaming ? "Streaming" : "Playing"} ${presence.game.name}`; + if (presence.game.url) { + status.StatusMsg += ` | ${presence.game.url}`; + } + } + + if (presence.status === "online") { + status.Presence = "online"; + } else if (presence.status === "dnd") { + status.Presence = "online"; + status.StatusMsg = status.StatusMsg ? "Do not disturb | " + status.StatusMsg : "Do not disturb"; + } else if (presence.status === "offline") { + status.Presence = "offline"; + status.ShouldDrop = true; // Drop until we recieve an update. + } else { // idle + status.Presence = "unavailable"; + } + return status; + } + + private async setMatrixPresence(user: User, status: PresenceHandlerStatus) { + const intent = this.bot.GetIntentFromDiscordMember(user); + const statusObj: IMatrixPresence = {presence: status.Presence}; + if (status.StatusMsg) { + statusObj.status_msg = status.StatusMsg; + } + try { + await intent.getClient().setPresence(statusObj); + } catch (ex) { + if (ex.errcode !== "M_FORBIDDEN") { + log.warn(`Could not update Matrix presence for ${user.id}`); + return; + } + try { + await this.bot.UserSyncroniser.OnUpdateUser(user); + } catch (err) { + log.warn(`Could not register new Matrix user for ${user.id}`); + } + } + } +} diff --git a/src/provisioner.ts b/src/provisioner.ts new file mode 100644 index 0000000000000000000000000000000000000000..46b7624f102c5652f444b13e7950882820855e7c --- /dev/null +++ b/src/provisioner.ts @@ -0,0 +1,133 @@ +/* +Copyright 2018, 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 * as Discord from "discord.js"; +import { DbRoomStore, RemoteStoreRoom, MatrixStoreRoom } from "./db/roomstore"; +import { ChannelSyncroniser } from "./channelsyncroniser"; +import { Log } from "./log"; + +const PERMISSION_REQUEST_TIMEOUT = 300000; // 5 minutes + +const log = new Log("Provisioner"); + +export class Provisioner { + + private pendingRequests: Map<string, (approved: boolean) => void> = new Map(); // [channelId]: resolver fn + + constructor(private roomStore: DbRoomStore, private channelSync: ChannelSyncroniser) { } + + public async BridgeMatrixRoom(channel: Discord.TextChannel, roomId: string) { + const remote = new RemoteStoreRoom(`discord_${channel.guild.id}_${channel.id}_bridged`, { + discord_channel: channel.id, + discord_guild: channel.guild.id, + discord_type: "text", + plumbed: true, + }); + + const local = new MatrixStoreRoom(roomId); + return this.roomStore.linkRooms(local, remote); + } + + public async UnbridgeChannel(channel: Discord.TextChannel, rId?: string) { + const roomsRes = await this.roomStore.getEntriesByRemoteRoomData({ + discord_channel: channel.id, + discord_guild: channel.guild.id, + plumbed: true, + }); + if (roomsRes.length === 0) { + throw Error("Channel is not bridged"); + } + const remoteRoom = roomsRes[0].remote as RemoteStoreRoom; + let roomsToUnbridge: string[] = []; + if (rId) { + roomsToUnbridge = [rId]; + } else { + // Kill em all. + roomsToUnbridge = roomsRes.map((entry) => entry.matrix!.roomId); + } + await Promise.all(roomsToUnbridge.map( async (roomId) => { + try { + await this.channelSync.OnUnbridge(channel, roomId); + } catch (ex) { + log.error(`Failed to cleanly unbridge ${channel.id} ${channel.guild} from ${roomId}`, ex); + } + })); + await this.roomStore.removeEntriesByRemoteRoomId(remoteRoom.getId()); + } + + public async AskBridgePermission( + channel: Discord.TextChannel, + requestor: string, + timeout: number = PERMISSION_REQUEST_TIMEOUT): Promise<string> { + const channelId = `${channel.guild.id}/${channel.id}`; + + let responded = false; + let resolve: (msg: string) => void; + let reject: (err: Error) => void; + const deferP: Promise<string> = new Promise((res, rej) => {resolve = res; reject = rej; }); + + const approveFn = (approved: boolean, expired = false) => { + if (responded) { + return; + } + + responded = true; + this.pendingRequests.delete(channelId); + if (approved) { + resolve("Approved"); + } else { + if (expired) { + reject(Error("Timed out waiting for a response from the Discord owners")); + } else { + reject(Error("The bridge has been declined by the Discord guild")); + } + } + }; + + this.pendingRequests.set(channelId, approveFn); + setTimeout(() => approveFn(false, true), timeout); + + await channel.send(`${requestor} on matrix would like to bridge this channel. Someone with permission` + + " to manage webhooks please reply with `!matrix approve` or `!matrix deny` in the next 5 minutes"); + return await deferP; + + } + + public HasPendingRequest(channel: Discord.TextChannel): boolean { + const channelId = `${channel.guild.id}/${channel.id}`; + return this.pendingRequests.has(channelId); + } + + public async MarkApproved( + channel: Discord.TextChannel, + member: Discord.GuildMember, + allow: boolean, + ): Promise<boolean> { + const channelId = `${channel.guild.id}/${channel.id}`; + if (!this.pendingRequests.has(channelId)) { + return false; // no change, so false + } + + const perms = channel.permissionsFor(member); + if (!perms || !perms.has(Discord.Permissions.FLAGS.MANAGE_WEBHOOKS as Discord.PermissionResolvable)) { + // Missing permissions, so just reject it + throw new Error("You do not have permission to manage webhooks in this channel"); + } + + this.pendingRequests.get(channelId)!(allow); + return true; // replied, so true + } +} diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000000000000000000000000000000000000..db1b9f09cde2cc4fc43aab2c456c79f077921111 --- /dev/null +++ b/src/store.ts @@ -0,0 +1,309 @@ +/* +Copyright 2017 - 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 * as fs from "fs"; +import { IDbSchema } from "./db/schema/dbschema"; +import { IDbData} from "./db/dbdatainterface"; +import { SQLite3 } from "./db/sqlite3"; +import { Log } from "./log"; +import { DiscordBridgeConfigDatabase } from "./config"; +import { Postgres } from "./db/postgres"; +import { IDatabaseConnector } from "./db/connector"; +import { DbRoomStore } from "./db/roomstore"; +import { DbUserStore } from "./db/userstore"; +import { + RoomStore, UserStore, +} from "matrix-appservice-bridge"; + +const log = new Log("DiscordStore"); +export const CURRENT_SCHEMA = 10; +/** + * Stores data for specific users and data not specific to rooms. + */ +export class DiscordStore { + public db: IDatabaseConnector; + private config: DiscordBridgeConfigDatabase; + private pRoomStore: DbRoomStore; + private pUserStore: DbUserStore; + constructor(configOrFile: DiscordBridgeConfigDatabase|string) { + if (typeof(configOrFile) === "string") { + this.config = new DiscordBridgeConfigDatabase(); + this.config.filename = configOrFile; + } else { + this.config = configOrFile; + } + } + + get roomStore() { + return this.pRoomStore; + } + + get userStore() { + return this.pUserStore; + } + + public async backupDatabase(): Promise<void|{}> { + if (this.config.filename == null) { + log.warn("Backups not supported on non-sqlite connector"); + return; + } + if (this.config.filename === ":memory:") { + log.info("Can't backup a :memory: database."); + return; + } + const BACKUP_NAME = this.config.filename + ".backup"; + + return new Promise((resolve, reject) => { + // Check to see if a backup file already exists. + fs.access(BACKUP_NAME, (err) => { + return resolve(err === null); + }); + }).then(async (result) => { + return new Promise((resolve, reject) => { + if (!result) { + log.warn("NOT backing up database while a file already exists"); + resolve(true); + } + const rd = fs.createReadStream(this.config.filename); + rd.on("error", reject); + const wr = fs.createWriteStream(BACKUP_NAME); + wr.on("error", reject); + wr.on("close", resolve); + rd.pipe(wr); + }); + }); + } + + /** + * Checks the database has all the tables needed. + */ + public async init( + overrideSchema: number = 0, roomStore: RoomStore = null, userStore: UserStore = null, + ): Promise<void> { + const SCHEMA_ROOM_STORE_REQUIRED = 8; + const SCHEMA_USER_STORE_REQUIRED = 9; + log.info("Starting DB Init"); + await this.openDatabase(); + let version = await this.getSchemaVersion(); + const targetSchema = overrideSchema || CURRENT_SCHEMA; + log.info(`Database schema version is ${version}, latest version is ${targetSchema}`); + while (version < targetSchema) { + version++; + const schemaClass = require(`./db/schema/v${version}.js`).Schema; + let schema: IDbSchema; + if (version === SCHEMA_ROOM_STORE_REQUIRED) { // 8 requires access to the roomstore. + schema = (new schemaClass(roomStore) as IDbSchema); + } else if (version === SCHEMA_USER_STORE_REQUIRED) { + schema = (new schemaClass(userStore) as IDbSchema); + } else { + schema = (new schemaClass() as IDbSchema); + } + log.info(`Updating database to v${version}, "${schema.description}"`); + try { + await schema.run(this); + log.info("Updated database to version ", version); + } catch (ex) { + log.error("Couldn't update database to schema ", version); + log.error(ex); + log.info("Rolling back to version ", version - 1); + try { + await schema.rollBack(this); + } catch (ex) { + log.error(ex); + throw Error("Failure to update to latest schema. And failed to rollback."); + } + throw Error("Failure to update to latest schema."); + } + await this.setSchemaVersion(version); + } + log.info("Updated database to the latest schema"); + } + + public async close() { + await this.db.Close(); + } + + public async createTable(statement: string, tablename: string): Promise<void|Error> { + try { + await this.db.Exec(statement); + log.info("Created table", tablename); + } catch (err) { + throw new Error(`Error creating '${tablename}': ${err}`); + } + } + + public async addUserToken(userId: string, discordId: string, token: string): Promise<void> { + log.silly("SQL", "addUserToken => ", userId); + try { + await Promise.all([ + this.db.Run( + ` + INSERT INTO user_id_discord_id (discord_id,user_id) VALUES ($discordId,$userId); + ` + , { + discordId, + userId, + }), + this.db.Run( + ` + INSERT INTO discord_id_token (discord_id,token) VALUES ($discordId,$token); + ` + , { + discordId, + token, + }), + ]); + } catch (err) { + log.error("Error storing user token ", err); + throw err; + } + } + + public async deleteUserToken(discordId: string): Promise<void> { + log.silly("SQL", "deleteUserToken => ", discordId); + try { + await Promise.all([ + this.db.Run( + ` + DELETE FROM user_id_discord_id WHERE discord_id = $id; + ` + , { + $id: discordId, + }), + this.db.Run( + ` + DELETE FROM discord_id_token WHERE discord_id = $id; + ` + , { + $id: discordId, + }), + ]); + } catch (err) { + log.error("Error deleting user token ", err); + throw err; + } + } + + public async getUserDiscordIds(userId: string): Promise<string[]> { + log.silly("SQL", "getUserDiscordIds => ", userId); + try { + const rows = await this.db.All( + ` + SELECT discord_id + FROM user_id_discord_id + WHERE user_id = $userId; + ` + , { + userId, + }); + if (rows != null) { + return rows.map((row) => row.discord_id as string); + } else { + return []; + } + } catch (err) { + log.error("Error getting discord ids: ", err.Error); + throw err; + } + } + + public async getToken(discordId: string): Promise<string> { + log.silly("SQL", "discord_id_token => ", discordId); + try { + const row = await this.db.Get( + ` + SELECT token + FROM discord_id_token + WHERE discord_id = $discordId + ` + , { + discordId, + }); + return row ? row.token as string : ""; + } catch (err) { + log.error("Error getting discord ids ", err.Error); + throw err; + } + } + // tslint:disable-next-line no-any + public async Get<T extends IDbData>(dbType: {new(): T; }, params: any): Promise<T|null> { + const dType = new dbType(); + log.silly(`get <${dType.constructor.name} with params ${params}>`); + try { + await dType.RunQuery(this, params); + log.silly(`Finished query with ${dType.Result ? "Results" : "No Results"}`); + return dType; + } catch (ex) { + log.warn(`get <${dType.constructor.name} with params ${params} FAILED with exception ${ex}>`); + return null; + } + } + + public async Insert<T extends IDbData>(data: T): Promise<void> { + log.silly(`insert <${data.constructor.name}>`); + await data.Insert(this); + } + + public async Update<T extends IDbData>(data: T): Promise<void> { + log.silly(`insert <${data.constructor.name}>`); + await data.Update(this); + } + + public async Delete<T extends IDbData>(data: T): Promise<void> { + log.silly(`insert <${data.constructor.name}>`); + await data.Delete(this); + } + + private async getSchemaVersion( ): Promise<number> { + log.silly("_get_schema_version"); + let version = 0; + try { + const versionReply = await this.db.Get(`SELECT version FROM schema`); + version = versionReply!.version as number; + } catch (er) { + log.warn("Couldn't fetch schema version, defaulting to 0"); + } + return version; + } + + private async setSchemaVersion(ver: number): Promise<void> { + log.silly("_set_schema_version => ", ver); + await this.db.Run( + ` + UPDATE schema + SET version = $ver + `, {ver}, + ); + } + + private async openDatabase(): Promise<void|Error> { + if (this.config.filename) { + log.info("Filename present in config, using sqlite"); + this.db = new SQLite3(this.config.filename); + } else if (this.config.connString) { + log.info("connString present in config, using postgres"); + this.db = new Postgres(this.config.connString); + } + try { + this.db.Open(); + this.pRoomStore = new DbRoomStore(this.db); + this.pUserStore = new DbUserStore(this.db); + } catch (ex) { + log.error("Error opening database:", ex); + throw new Error("Couldn't open database. The appservice won't be able to continue."); + } + } +} diff --git a/src/usersyncroniser.ts b/src/usersyncroniser.ts new file mode 100644 index 0000000000000000000000000000000000000000..f34dd2e887ba20cf324e4853973d6c2ae592432b --- /dev/null +++ b/src/usersyncroniser.ts @@ -0,0 +1,421 @@ +/* +Copyright 2018, 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 { User, GuildMember } from "discord.js"; +import { DiscordBot } from "./bot"; +import { Util } from "./util"; +import { Bridge, Intent, MatrixUser } from "matrix-appservice-bridge"; +import { DiscordBridgeConfig } from "./config"; +import { Log } from "./log"; +import { IMatrixEvent } from "./matrixtypes"; +import { DbUserStore, RemoteUser } from "./db/userstore"; + +const log = new Log("UserSync"); + +const DEFAULT_USER_STATE = { + avatarId: "", + avatarUrl: null, + createUser: false, + displayName: null, + id: null, + mxUserId: null, + removeAvatar: false, +}; + +const DEFAULT_GUILD_STATE = { + displayName: "", + id: null, + mxUserId: null, + roles: [], +}; + +export interface IUserState { + avatarId: string; + avatarUrl: string | null; + createUser: boolean; + displayName: string | null; + id: string; + mxUserId: string; + removeAvatar: boolean; // If the avatar has been removed from the user. +} + +export interface IGuildMemberRole { + name: string; + color: number; + position: number; +} + +export interface IGuildMemberState { + bot: boolean; + displayColor?: number; + displayName: string; + id: string; + mxUserId: string; + roles: IGuildMemberRole[]; + username: string; +} + +/** + * Class that syncronises Discord users with their bridge ghost counterparts. + * Also handles member events that may occur when using guild nicknames. + */ +export class UserSyncroniser { + + public static readonly ERR_NO_ERROR = ""; + public static readonly ERR_USER_NOT_FOUND = "user_not_found"; + public static readonly ERR_CHANNEL_MEMBER_NOT_FOUND = "channel_or_member_not_found"; + public static readonly ERR_NEWER_EVENT = "newer_state_event_arrived"; + + // roomId+userId => ev + public userStateHold: Map<string, IMatrixEvent>; + constructor( + private bridge: Bridge, + private config: DiscordBridgeConfig, + private discord: DiscordBot, + private userStore: DbUserStore) { + this.userStateHold = new Map<string, IMatrixEvent>(); + } + + /** + * Should be called when the discord user is updated. + * @param {module:discord.js.User} Old user object. If not used, new user object. + * @param {module:discord.js.User} New user object + * @returns {Promise<void>} + * @constructor + */ + public async OnUpdateUser(discordUser: User, webhookID?: string) { + const userState = await this.GetUserUpdateState(discordUser, webhookID); + try { + await this.ApplyStateToProfile(userState); + } catch (e) { + log.error("Failed to update user's profile", e); + } + } + + public async ApplyStateToProfile(userState: IUserState) { + const intent = this.bridge.getIntent(userState.mxUserId); + let userUpdated = false; + let remoteUser: RemoteUser; + if (userState.createUser) { + /* NOTE: Setting the displayname/avatar will register the user if they don't exist */ + log.info(`Creating new user ${userState.mxUserId}`); + remoteUser = new RemoteUser(userState.id); + await this.userStore.linkUsers( + userState.mxUserId.substr("@".length), + userState.id, + ); + + } else { + const rUser = await this.userStore.getRemoteUser(userState.id); + remoteUser = rUser ? rUser : new RemoteUser(userState.id); + } + + if (userState.displayName !== null) { + log.verbose(`Updating displayname for ${userState.mxUserId} to "${userState.displayName}"`); + await intent.setDisplayName(userState.displayName); + remoteUser.displayname = userState.displayName; + userUpdated = true; + } + + if (userState.avatarUrl !== null) { + log.verbose(`Updating avatar_url for ${userState.mxUserId} to "${userState.avatarUrl}"`); + const avatarMxc = await Util.UploadContentFromUrl( + userState.avatarUrl, + intent, + userState.avatarId, + ); + await intent.setAvatarUrl(avatarMxc.mxcUrl); + remoteUser.avatarurl = userState.avatarUrl; + remoteUser.avatarurlMxc = avatarMxc.mxcUrl; + userUpdated = true; + } + + if (userState.removeAvatar) { + log.verbose(`Clearing avatar_url for ${userState.mxUserId} to "${userState.avatarUrl}"`); + await intent.setAvatarUrl(null); + remoteUser.avatarurl = null; + remoteUser.avatarurlMxc = null; + userUpdated = true; + } + + if (userUpdated) { + await this.userStore.setRemoteUser(remoteUser); + await this.UpdateStateForGuilds(remoteUser); + } + } + + public async JoinRoom(member: GuildMember | User, roomId: string, webhookID?: string) { + let state: IGuildMemberState; + if (member instanceof User) { + state = await this.GetUserStateForDiscordUser(member, webhookID); + } else { + state = await this.GetUserStateForGuildMember(member); + } + log.info(`Joining ${state.id} in ${roomId}`); + const guildId = member instanceof User ? undefined : member.guild.id; + try { + await this.ApplyStateToRoom(state, roomId, guildId); + } catch (e) { + if (e.errcode !== "M_FORBIDDEN") { + log.error(`Failed to join ${state.id} to ${roomId}`, e); + throw e; + } else { + log.info(`User not in room ${roomId}, inviting`); + try { + await this.bridge.getIntent().invite(roomId, state.mxUserId); + await this.ApplyStateToRoom(state, roomId, guildId); + } catch (e) { + log.error(`Failed to join ${state.id} to ${roomId}`, e); + throw e; + } + } + } + } + + public async SetRoomState(member: GuildMember, roomId: string) { + const state = await this.GetUserStateForGuildMember(member); + log.info(`Setting room state for ${state.id} in ${roomId}`); + await this.ApplyStateToRoom(state, roomId, member.guild.id); + } + + public async ApplyStateToRoom(memberState: IGuildMemberState, roomId: string, guildId?: string) { + log.info(`Applying new room state for ${memberState.mxUserId} to ${roomId}`); + if (!memberState.displayName) { + // Nothing to do. Quitting + return; + } + const remoteUser = await this.userStore.getRemoteUser(memberState.id); + let avatar = ""; + if (remoteUser) { + avatar = remoteUser.avatarurlMxc || ""; + } else { + log.warn("Remote user wasn't found, using blank avatar"); + } + const intent = this.bridge.getIntent(memberState.mxUserId); + /* The intent class tries to be smart and deny a state update for <PL50 users. + Obviously a user can change their own state so we use the client instead. */ + await intent.getClient().sendStateEvent(roomId, "m.room.member", { + "avatar_url": avatar, + "displayname": memberState.displayName, + "membership": "join", + "uk.half-shot.discord.member": { + bot: memberState.bot, + displayColor: memberState.displayColor, + id: memberState.id, + roles: memberState.roles, + username: memberState.username, + }, + }, memberState.mxUserId); + + if (remoteUser) { + if (guildId) { + remoteUser.guildNicks.set(guildId, memberState.displayName); + } + await this.userStore.setRemoteUser(remoteUser); + } + } + + public async GetUserUpdateState(discordUser: User, webhookID?: string): Promise<IUserState> { + log.verbose(`State update requested for ${discordUser.id}`); + let mxidExtra = ""; + if (webhookID) { + // no need to escape as this mxid is only used to create an intent + mxidExtra = `_${new MatrixUser(`@${webhookID}`).localpart}`; + } + const userState: IUserState = Object.assign({}, DEFAULT_USER_STATE, { + id: discordUser.id, + mxUserId: `@_discord_${discordUser.id}${mxidExtra}:${this.config.bridge.domain}`, + }); + const displayName = Util.ApplyPatternString(this.config.ghosts.usernamePattern, { + id: discordUser.id, + tag: discordUser.discriminator, + username: discordUser.username, + }); + // Determine if the user exists. + const remoteId = discordUser.id + mxidExtra; + const remoteUser = await this.userStore.getRemoteUser(remoteId); + if (remoteUser === null) { + log.verbose(`Could not find user in remote user store.`); + userState.createUser = true; + userState.displayName = displayName; + userState.avatarUrl = discordUser.avatarURL; + userState.avatarId = discordUser.avatar; + return userState; + } + + const oldDisplayName = remoteUser.displayname; + if (oldDisplayName !== displayName) { + log.verbose(`User ${discordUser.id} displayname should be updated`); + userState.displayName = displayName; + } + + const oldAvatarUrl = remoteUser.avatarurl; + if (oldAvatarUrl !== discordUser.avatarURL) { + log.verbose(`User ${discordUser.id} avatarurl should be updated`); + if (discordUser.avatarURL !== null) { + userState.avatarUrl = discordUser.avatarURL; + userState.avatarId = discordUser.avatar; + } else { + userState.removeAvatar = oldAvatarUrl !== null; + } + } + + return userState; + } + + public async GetUserStateForGuildMember( + newMember: GuildMember, + ): Promise<IGuildMemberState> { + const name = Util.ApplyPatternString(this.config.ghosts.nickPattern, { + id: newMember.user.id, + nick: newMember.displayName, + tag: newMember.user.discriminator, + username: newMember.user.username, + }); + const guildState: IGuildMemberState = Object.assign({}, DEFAULT_GUILD_STATE, { + bot: newMember.user.bot, + displayColor: newMember.displayColor, + displayName: name, + id: newMember.id, + mxUserId: `@_discord_${newMember.id}:${this.config.bridge.domain}`, + roles: newMember.roles.map((role) => { return { + color: role.color, + name: role.name, + position: role.position, + }; }), + username: newMember.user.tag, + }); + return guildState; + } + + public async GetUserStateForDiscordUser( + user: User, + webhookID?: string, + ): Promise<IGuildMemberState> { + let mxidExtra = ""; + if (webhookID) { + // 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, + mxUserId: `@_discord_${user.id}${mxidExtra}:${this.config.bridge.domain}`, + roles: [], + username: user.tag, + }); + return guildState; + } + + public async OnAddGuildMember(member: GuildMember) { + log.info(`Joining ${member.id} to all rooms for guild ${member.guild.id}`); + await this.OnUpdateGuildMember(member, true, false); + } + + public async OnRemoveGuildMember(member: GuildMember) { + /* NOTE: This can be because of a kick, ban or the user just leaving. Discord doesn't tell us. */ + log.info(`Leaving ${member.id} to all rooms for guild ${member.guild.id}`); + const rooms = await this.discord.GetRoomIdsFromGuild(member.guild, undefined, false); + const intent = this.discord.GetIntentFromDiscordMember(member); + return Promise.all( + rooms.map( + async (roomId) => this.leave(intent, roomId, false), + ), + ); + } + + public async OnUpdateGuildMember(member: GuildMember, doJoin: boolean = false, useCache: boolean = true) { + log.info(`Got update for ${member.id} (${member.user.username}).`); + const state = await this.GetUserStateForGuildMember(member); + let wantRooms: string[] = []; + try { + wantRooms = await this.discord.GetRoomIdsFromGuild(member.guild, member, useCache); + } catch (err) { } // no want rooms + let allRooms: string[] = []; + try { + allRooms = await this.discord.GetRoomIdsFromGuild(member.guild, undefined, useCache); + } catch (err) { } // no all rooms + + const leaveRooms: string[] = []; + await Util.AsyncForEach(allRooms, async (r) => { + if (wantRooms.includes(r)) { + return; + } + leaveRooms.push(r); + }); + + await Promise.all( + wantRooms.map( + async (roomId) => { + try { + if (doJoin) { + await this.JoinRoom(member, roomId); + } else { + await this.ApplyStateToRoom(state, roomId, member.guild.id); + } + } catch (err) { + log.error(`Failed to update ${member.id} (${member.user.username}) in ${roomId}`, err); + } + }, + ), + ); + const userId = state.mxUserId; + const intent = this.bridge.getIntent(userId); + await Promise.all( + leaveRooms.map( + async (roomId) => { + try { + await this.leave(intent, roomId, true); + } catch (e) { } // not in room + }, + ), + ); + } + + public async UpdateStateForGuilds(remoteUser: RemoteUser) { + const id = remoteUser.id; + log.info(`Got update for ${id}.`); + + await Util.AsyncForEach(this.discord.GetGuilds(), async (guild) => { + if (guild.members.has(id)) { + log.info(`Updating user ${id} in guild ${guild.id}.`); + const member = guild.members.get(id); + try { + const state = await this.GetUserStateForGuildMember(member!); + const rooms = await this.discord.GetRoomIdsFromGuild(guild, member!); + await Promise.all( + rooms.map( + async (roomId) => this.ApplyStateToRoom(state, roomId, guild.id), + ), + ); + } catch (err) { + log.warn(`Failed to update user ${id} in guild ${guild.id}`, err); + } + } + }); + } + + private async leave(intent: Intent, roomId: string, checkCache: boolean = true) { + const userId = intent.getClient().getUserId(); + if (checkCache && ![null, "join", "invite"] + .includes(intent.opts.backingStore.getMembership(roomId, userId))) { + return; + } + await intent.leave(roomId); + intent.opts.backingStore.setMembership(roomId, userId, "leave"); + } +} diff --git a/src/util.ts b/src/util.ts index 20f99b702a3d7586fab0beedc585774a551f821e..3cbf8b381baa00506a8e09d64cd00427c2f9d850 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,105 +1,423 @@ +/* +Copyright 2018, 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 * as http from "http"; import * as https from "https"; -import { Bridge, Intent } from "matrix-appservice-bridge"; +import { Intent } from "matrix-appservice-bridge"; import { Buffer } from "buffer"; -import * as log from "npmlog"; import * as mime from "mime"; +import { Permissions } from "discord.js"; +import { DiscordBridgeConfig } from "./config"; +import { Client as MatrixClient } from "matrix-js-sdk"; +import { IMatrixEvent } from "./matrixtypes"; + +const HTTP_OK = 200; + +import { Log } from "./log"; +const log = new Log("Util"); + +type PERMISSIONTYPES = any | any[]; // tslint:disable-line no-any + +export interface ICommandAction { + description?: string; + help?: string; + params: string[]; + permission?: PERMISSIONTYPES; + run(params: any): Promise<any>; // tslint:disable-line no-any +} + +export interface ICommandActions { + [index: string]: ICommandAction; +} + +export interface ICommandParameter { + description?: string; + get?(param: string): Promise<any>; // tslint:disable-line no-any +} + +export interface ICommandParameters { + [index: string]: ICommandParameter; +} + +export type CommandPermissonCheck = (permission: PERMISSIONTYPES) => Promise<boolean | string>; + +export interface IPatternMap { + [index: string]: string; +} export class Util { + /** + * downloadFile - This function will take a URL and store the resulting data into + * a buffer. + */ + public static async DownloadFile(url: string): Promise<Buffer> { + return new Promise((resolve, reject) => { + let ht; + if (url.startsWith("https")) { + ht = https; + } else { + ht = http; + } + const req = ht.get((url), (res) => { + let buffer = Buffer.alloc(0); + if (res.statusCode !== HTTP_OK) { + reject(`Non 200 status code (${res.statusCode})`); + } - /** - * downloadFile - This function will take a URL and store the resulting data into - * a buffer. - */ - public static DownloadFile (url: string): Promise<Buffer> { - return new Promise((resolve, reject) => { - let ht; - if (url.startsWith("https")) { - ht = https; - } else { - ht = http; - } - const req = ht.get((url), (res) => { - let buffer = Buffer.alloc(0); - if (res.statusCode !== 200) { - reject(`Non 200 status code (${res.statusCode})`); - } - - res.on("data", (d) => { - buffer = Buffer.concat([buffer, d]); - }); + res.on("data", (d) => { + buffer = Buffer.concat([buffer, d]); + }); - res.on("end", () => { - resolve(buffer); - }); - }); - req.on("error", (err) => { - reject(`Failed to download. ${err.code}`); - }); - }); - } - /** - * uploadContentFromUrl - Upload content from a given URL to the homeserver - * and return a MXC URL. - */ - public static UploadContentFromUrl(bridge: Bridge, url: string, id: string | Intent, name: string): Promise<any> { - let contenttype; - let size; - id = id || null; - name = name || null; - return new Promise((resolve, reject) => { - let ht; - if (url.startsWith("https")) { - ht = https; - } else { - ht = http; - } - const req = ht.get( url, (res) => { - let buffer = Buffer.alloc(0); - - if (res.headers.hasOwnProperty("content-type")) { - contenttype = res.headers["content-type"]; - } else { - log.verbose("UploadContent", "No content-type given by server, guessing based on file name."); - contenttype = mime.lookup(url); - } - - if (name === null) { - let names = url.split("/"); - name = names[names.length - 1]; - } - - res.on("data", (d) => { - buffer = Buffer.concat([buffer, d]); - }); + res.on("end", () => { + resolve(buffer); + }); + }); + req.on("error", (err) => { + reject(`Failed to download. ${err.code}`); + }); + }) as Promise<Buffer>; + } + /** + * uploadContentFromUrl - Upload content from a given URL to the homeserver + * and return a MXC URL. + */ + public static async UploadContentFromUrl(url: string, intent: Intent, name: string | null): Promise<IUploadResult> { + let contenttype; + name = name || null; + try { + const bufferRet = (await (new Promise((resolve, reject) => { + let ht; + if (url.startsWith("https")) { + ht = https; + } else { + ht = http; + } + const req = ht.get( url, (res) => { + let buffer = Buffer.alloc(0); + + if (res.headers.hasOwnProperty("content-type")) { + contenttype = res.headers["content-type"]; + } else { + log.verbose("No content-type given by server, guessing based on file name."); + contenttype = mime.lookup(url); + } + + if (name === null) { + const names = url.split("/"); + name = names[names.length - 1]; + } + + res.on("data", (d) => { + buffer = Buffer.concat([buffer, d]); + }); - res.on("end", () => { - resolve(buffer); + res.on("end", () => { + resolve(buffer); + }); + }); + req.on("error", (err) => { + reject(`Failed to download. ${err.code}`); + }); + }))) as Buffer; + const size = bufferRet.length; + const contentUri = await intent.getClient().uploadContent(bufferRet, { + name, + onlyContentUri: true, + rawResponse: false, + type: contenttype, + }); + log.verbose("Media uploaded to ", contentUri); + return { + mxcUrl: contentUri, + size, + }; + } catch (reason) { + log.error("Failed to upload content:\n", reason); + throw reason; + } + } + + /** + * Gets a promise that will resolve after the given number of milliseconds + * @param {number} duration The number of milliseconds to wait + * @returns {Promise<any>} The promise + */ + public static async DelayedPromise(duration: number): Promise<void> { + return new Promise<void>((resolve, reject) => { + setTimeout(resolve, duration); }); - }); - req.on("error", (err) => { - reject(`Failed to download. ${err.code}`); - }); - }).then((buffer: Buffer) => { - size = buffer.length; - if (id === null || typeof id === "string") { - id = bridge.getIntent(id); - } - return id.getClient().uploadContent(buffer, { - name, - type: contenttype, - onlyContentUri: true, - rawResponse: false, - }); - }).then((contentUri) => { - log.verbose("UploadContent", "Media uploaded to %s", contentUri); - return { - mxc_url: contentUri, - size, - }; - }).catch((reason) => { - log.error("UploadContent", "Failed to upload content:\n%s", reason); - throw reason; - }); - } + } + + public static GetBotLink(config: DiscordBridgeConfig): string { + /* tslint:disable:no-bitwise */ + const perms = Permissions.FLAGS.READ_MESSAGES! | + Permissions.FLAGS.SEND_MESSAGES! | + Permissions.FLAGS.CHANGE_NICKNAME! | + Permissions.FLAGS.CONNECT! | + Permissions.FLAGS.SPEAK! | + Permissions.FLAGS.EMBED_LINKS! | + Permissions.FLAGS.ATTACH_FILES! | + Permissions.FLAGS.READ_MESSAGE_HISTORY! | + Permissions.FLAGS.MANAGE_WEBHOOKS! | + Permissions.FLAGS.MANAGE_MESSAGES!; + /* tslint:enable:no-bitwise */ + + const clientId = config.auth.clientID; + + return `https://discordapp.com/api/oauth2/authorize?client_id=${clientId}&scope=bot&permissions=${perms}`; + } + + public static async GetMxidFromName(intent: Intent, name: string, channelMxids: string[]) { + if (name[0] === "@" && name.includes(":")) { + return name; + } + const client = intent.getClient(); + const matrixUsers = {}; + let matches = 0; + await Promise.all(channelMxids.map((chan) => { + // we would use this.bridge.getBot().getJoinedMembers() + // but we also want to be able to search through banned members + // so we gotta roll our own thing + return client._http.authedRequestWithPrefix( + undefined, + "GET", + `/rooms/${encodeURIComponent(chan)}/members`, + undefined, + undefined, + "/_matrix/client/r0", + ).then((res) => { + res.chunk.forEach((member) => { + if (member.membership !== "join" && member.membership !== "ban") { + return; + } + const mxid = member.state_key; + if (mxid.startsWith("@_discord_")) { + return; + } + let displayName = member.content.displayname; + if (!displayName && member.unsigned && member.unsigned.prev_content && + member.unsigned.prev_content.displayname) { + displayName = member.unsigned.prev_content.displayname; + } + if (!displayName) { + displayName = mxid.substring(1, mxid.indexOf(":")); + } + if (name.toLowerCase() === displayName.toLowerCase() || name === mxid) { + matrixUsers[mxid] = displayName; + matches++; + } + }); + }); + })); + if (matches === 0) { + throw Error(`No users matching ${name} found`); + } + if (matches > 1) { + let errStr = "Multiple matching users found:\n"; + for (const mxid of Object.keys(matrixUsers)) { + errStr += `${matrixUsers[mxid]} (\`${mxid}\`)\n`; + } + throw Error(errStr); + } + return Object.keys(matrixUsers)[0]; + } + + public static async HandleHelpCommand( + prefix: string, + actions: ICommandActions, + parameters: ICommandParameters, + args: string[], + permissionCheck?: CommandPermissonCheck, + ): Promise<string> { + let reply = ""; + if (args[0]) { + const actionKey = args[0]; + const action = actions[actionKey]; + if (!actions[actionKey]) { + return `**ERROR:** unknown command! Try \`${prefix} help\` to see all commands`; + } + if (action.permission !== undefined && permissionCheck) { + const permCheck = await permissionCheck(action.permission); + if (typeof permCheck === "string") { + return `**ERROR:** ${permCheck}`; + } + if (!permCheck) { + return `**ERROR:** permission denied! Try \`${prefix} help\` to see all available commands`; + } + } + reply += `\`${prefix} ${actionKey}`; + for (const param of action.params) { + reply += ` <${param}>`; + } + reply += `\`: ${action.description}\n`; + if (action.help) { + reply += action.help; + } + return reply; + } + reply += "Available Commands:\n"; + for (const actionKey of Object.keys(actions)) { + const action = actions[actionKey]; + if (action.permission !== undefined && permissionCheck) { + const permCheck = await permissionCheck(action.permission); + if (typeof permCheck === "string" || !permCheck) { + continue; + } + } + reply += ` - \`${prefix} ${actionKey}`; + for (const param of action.params) { + reply += ` <${param}>`; + } + reply += `\`: ${action.description}\n`; + } + reply += "\nParameters:\n"; + for (const parameterKey of Object.keys(parameters)) { + const parameter = parameters[parameterKey]; + reply += ` - \`<${parameterKey}>\`: ${parameter.description}\n`; + } + return reply; + } + + public static async ParseCommand( + prefix: string, + msg: string, + actions: ICommandActions, + parameters: ICommandParameters, + permissionCheck?: CommandPermissonCheck, + ): Promise<string> { + const {command, args} = Util.MsgToArgs(msg, prefix); + if (command === "help") { + return await Util.HandleHelpCommand(prefix, actions, parameters, args, permissionCheck); + } + + if (!actions[command]) { + return `**ERROR:** unknown command. Try \`${prefix} help\` to see all commands`; + } + const action = actions[command]; + if (action.permission !== undefined && permissionCheck) { + const permCheck = await permissionCheck(action.permission); + if (typeof permCheck === "string") { + return `**ERROR:** ${permCheck}`; + } + if (!permCheck) { + return `**ERROR:** insufficiant permissions to use this command! ` + + `Try \`${prefix} help\` to see all available commands`; + } + } + if (action.params.length === 1) { + args[0] = args.join(" "); + } + try { + const params = {}; + let i = 0; + for (const param of action.params) { + if (parameters[param].get !== undefined) { + params[param] = await parameters[param].get!(args[i]); + } else { + params[param] = args[i]; + } + i++; + } + + const retStr = await action.run(params); + return retStr; + } catch (e) { + log.error("Error processing command"); + log.error(e); + return `**ERROR:** ${e.message}`; + } + } + + public static MsgToArgs(msg: string, prefix: string) { + prefix += " "; + let command = "help"; + let args: string[] = []; + if (msg.length >= prefix.length) { + const allArgs = msg.substring(prefix.length).split(" "); + if (allArgs.length && allArgs[0] !== "") { + command = allArgs[0]; + allArgs.splice(0, 1); + args = allArgs; + } + } + return {command, args}; + } + + public static async AsyncForEach(arr, callback) { + for (let i = 0; i < arr.length; i++) { + await callback(arr[i], i, arr); + } + } + + public static NumberToHTMLColor(color: number): string { + const HEX_BASE = 16; + const COLOR_MAX = 0xFFFFFF; + if (color > COLOR_MAX) { + color = COLOR_MAX; + } + if (color < 0) { + color = 0; + } + const colorHex = color.toString(HEX_BASE); + const pad = "#000000"; + const htmlColor = pad.substring(0, pad.length - colorHex.length) + colorHex; + return htmlColor; + } + + public static ApplyPatternString(str: string, patternMap: IPatternMap): string { + for (const p of Object.keys(patternMap)) { + str = str.replace(new RegExp(":" + p, "g"), patternMap[p]); + } + return str; + } + + public static async CheckMatrixPermission( + mxClient: MatrixClient, + userId: string, + roomId: string, + defaultLevel: number, + cat: string, + subcat?: string, + ) { + const res: IMatrixEvent = await mxClient.getStateEvent(roomId, "m.room.power_levels"); + let requiredLevel = defaultLevel; + if (res && (res[cat] || !subcat)) { + if (subcat) { + if (res[cat][subcat] !== undefined) { + requiredLevel = res[cat][subcat]; + } + } else { + if (res[cat] !== undefined) { + requiredLevel = res[cat]; + } + } + } + + let haveLevel = 0; + if (res && res.users_default) { + haveLevel = res.users_default; + } + if (res && res.users && res.users[userId] !== undefined) { + haveLevel = res.users[userId]; + } + return haveLevel >= requiredLevel; + } +} + +interface IUploadResult { + mxcUrl: string; + size: number; } diff --git a/test/config.ts b/test/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..b6d7f447d8cf09471281abc3d3c84107f5fe1b2b --- /dev/null +++ b/test/config.ts @@ -0,0 +1,32 @@ +/* +Copyright 2018 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 { 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(); +} + +after(() => { + WhyRunning(); +}); diff --git a/test/db/test_roomstore.ts b/test/db/test_roomstore.ts new file mode 100644 index 0000000000000000000000000000000000000000..fea079ebd855053468ffcb166036c80cc3b04b50 --- /dev/null +++ b/test/db/test_roomstore.ts @@ -0,0 +1,267 @@ +/* +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 * as Chai from "chai"; +// import * as Proxyquire from "proxyquire"; +import { DiscordStore, CURRENT_SCHEMA } from "../../src/store"; +import { RemoteStoreRoom, MatrixStoreRoom } from "../../src/db/roomstore"; + +// we are a test file and thus need those +/* tslint:disable: no-any no-unused-expression */ + +const expect = Chai.expect; + +// const assert = Chai.assert; +let store: DiscordStore; +describe("RoomStore", () => { + before(async () => { + store = new DiscordStore(":memory:"); + await store.init(); + }); + describe("upsertEntry|getEntriesByMatrixId", () => { + it("will create a new entry", async () => { + await store.roomStore.upsertEntry({ + id: "test1", + matrix: new MatrixStoreRoom("!abc:def.com"), + remote: new RemoteStoreRoom("123456_789", {discord_guild: "123", discord_channel: "456"}), + }); + const entry = (await store.roomStore.getEntriesByMatrixId("!abc:def.com"))[0]; + expect(entry.id).to.equal("test1"); + expect(entry.matrix!.roomId).to.equal("!abc:def.com"); + expect(entry.remote!.roomId).to.equal("123456_789"); + expect(entry.remote!.get("discord_guild")).to.equal("123"); + expect(entry.remote!.get("discord_channel")).to.equal("456"); + }); + it("will update an existing entry's rooms", async () => { + await store.roomStore.upsertEntry({ + id: "test2", + matrix: new MatrixStoreRoom("test2_m"), + remote: new RemoteStoreRoom("test2_r", {discord_guild: "123", discord_channel: "456"}), + }); + await store.roomStore.upsertEntry({ + id: "test2", + matrix: new MatrixStoreRoom("test2_2m"), + remote: new RemoteStoreRoom("test2_2r", {discord_guild: "555", discord_channel: "999"}), + }); + const entry = (await store.roomStore.getEntriesByMatrixId("test2_2m"))[0]; + expect(entry.id).to.equal("test2"); + expect(entry.matrix!.roomId).to.equal("test2_2m"); + expect(entry.remote!.roomId).to.equal("test2_2r"); + expect(entry.remote!.get("discord_guild")).to.equal("555"); + expect(entry.remote!.get("discord_channel")).to.equal("999"); + }); + it("will add new data to an existing entry", async () => { + await store.roomStore.upsertEntry({ + id: "test3", + matrix: new MatrixStoreRoom("test3_m"), + remote: new RemoteStoreRoom("test3_r", {discord_guild: "123", discord_channel: "456"}), + }); + await store.roomStore.upsertEntry({ + id: "test3", + matrix: new MatrixStoreRoom("test3_m"), + remote: new RemoteStoreRoom("test3_r", {discord_guild: "123", discord_channel: "456", update_topic: 1}), + }); + const entry = (await store.roomStore.getEntriesByMatrixId("test3_m"))[0]; + expect(entry.id).to.equal("test3"); + expect(entry.matrix!.roomId).to.equal("test3_m"); + expect(entry.remote!.roomId).to.equal("test3_r"); + expect(entry.remote!.get("update_topic")).to.equal(1); + }); + it("will replace data on an existing entry", async () => { + await store.roomStore.upsertEntry({ + id: "test3.1", + matrix: new MatrixStoreRoom("test3.1_m"), + remote: new RemoteStoreRoom("test3.1_r", {discord_guild: "123", discord_channel: "456"}), + }); + await store.roomStore.upsertEntry({ + id: "test3.1", + matrix: new MatrixStoreRoom("test3.1_m"), + remote: new RemoteStoreRoom("test3.1_r", {discord_guild: "-100", discord_channel: "seventythousand"}), + }); + const entry = (await store.roomStore.getEntriesByMatrixId("test3.1_m"))[0]; + expect(entry.id).to.equal("test3.1"); + expect(entry.matrix!.roomId).to.equal("test3.1_m"); + expect(entry.remote!.roomId).to.equal("test3.1_r"); + expect(entry.remote!.get("discord_guild")).to.equal("-100"); + expect(entry.remote!.get("discord_channel")).to.equal("seventythousand"); + }); + it("will delete data on an existing entry", async () => { + await store.roomStore.upsertEntry({ + id: "test3.2", + matrix: new MatrixStoreRoom("test3.2_m"), + remote: new RemoteStoreRoom("test3.2_r", { + discord_channel: "456", discord_guild: "123", update_icon: true, + }), + }); + await store.roomStore.upsertEntry({ + id: "test3.2", + matrix: new MatrixStoreRoom("test3.2_m"), + remote: new RemoteStoreRoom("test3.2_r", {discord_guild: "123", discord_channel: "456"}), + }); + const entry = (await store.roomStore.getEntriesByMatrixId("test3.2_m"))[0]; + expect(entry.id).to.equal("test3.2"); + expect(entry.matrix!.roomId).to.equal("test3.2_m"); + expect(entry.remote!.roomId).to.equal("test3.2_r"); + expect(entry.remote!.get("update_icon")).to.be.eq(0); + }); + }); + describe("getEntriesByMatrixIds", () => { + it("will get multiple entries", async () => { + const EXPECTED_ROOMS = 2; + await store.roomStore.upsertEntry({ + id: "test4_1", + matrix: new MatrixStoreRoom("!test_mOne:eggs.com"), + remote: new RemoteStoreRoom("test4_r", {discord_guild: "five", discord_channel: "five"}), + }); + await store.roomStore.upsertEntry({ + id: "test4_2", + matrix: new MatrixStoreRoom("!test_mTwo:eggs.com"), + remote: new RemoteStoreRoom("test4_r", {discord_guild: "nine", discord_channel: "nine"}), + }); + const entries = await store.roomStore.getEntriesByMatrixIds(["!test_mOne:eggs.com", "!test_mTwo:eggs.com"]); + expect(entries).to.have.lengthOf(EXPECTED_ROOMS); + expect(entries[0].id).to.equal("test4_1"); + expect(entries[0].matrix!.roomId).to.equal("!test_mOne:eggs.com"); + expect(entries[1].id).to.equal("test4_2"); + expect(entries[1].matrix!.roomId).to.equal("!test_mTwo:eggs.com"); + }); + }); + describe("linkRooms", () => { + it("will link a room", async () => { + const matrix = new MatrixStoreRoom("test5_m"); + const remote = new RemoteStoreRoom("test5_r", {discord_guild: "five", discord_channel: "five"}); + await store.roomStore.linkRooms(matrix, remote); + const entries = await store.roomStore.getEntriesByMatrixId("test5_m"); + expect(entries[0].matrix!.roomId).to.equal("test5_m"); + expect(entries[0].remote!.roomId).to.equal("test5_r"); + expect(entries[0].remote!.get("discord_guild")).to.equal("five"); + expect(entries[0].remote!.get("discord_channel")).to.equal("five"); + }); + }); + describe("getEntriesByRemoteRoomData", () => { + it("will get an entry", async () => { + await store.roomStore.upsertEntry({ + id: "test6", + matrix: new MatrixStoreRoom("test6_m"), + remote: new RemoteStoreRoom("test6_r", {discord_guild: "find", discord_channel: "this"}), + }); + const entries = await store.roomStore.getEntriesByRemoteRoomData({ + discord_channel: "this", + discord_guild: "find", + }); + expect(entries[0].matrix!.roomId).to.equal("test6_m"); + expect(entries[0].remote!.roomId).to.equal("test6_r"); + expect(entries[0].remote!.get("discord_guild")).to.equal("find"); + expect(entries[0].remote!.get("discord_channel")).to.equal("this"); + }); + }); + describe("removeEntriesByRemoteRoomId", () => { + it("will remove a room", async () => { + await store.roomStore.upsertEntry({ + id: "test7", + matrix: new MatrixStoreRoom("test7_m"), + remote: new RemoteStoreRoom("test7_r", {discord_guild: "find", discord_channel: "this"}), + }); + await store.roomStore.removeEntriesByRemoteRoomId("test7_r"); + const entries = await store.roomStore.getEntriesByMatrixId("test7_m"); + expect(entries).to.be.empty; + }); + }); + describe("removeEntriesByMatrixRoomId", () => { + it("will remove a room", async () => { + await store.roomStore.upsertEntry({ + id: "test8", + matrix: new MatrixStoreRoom("test8_m"), + remote: new RemoteStoreRoom("test8_r", {discord_guild: "find", discord_channel: "this"}), + }); + await store.roomStore.removeEntriesByRemoteRoomId("test8_m"); + const entries = await store.roomStore.getEntriesByMatrixId("test8_r"); + expect(entries).to.be.empty; + }); + }); +}); +describe("RoomStore.schema.v8", () => { + it("will successfully migrate rooms", async () => { + const SCHEMA_VERSION = 8; + store = new DiscordStore(":memory:"); + const roomStore = { + select: () => { + return [ + { + _id: "DGFUYs4hlXNDmmw0", + id: "123", + matrix: {extras: {}}, + matrix_id: "!badroom:localhost", + }, + { + _id: "Dd37MWDw57dAQz5p", + data: {}, + id: "!xdnLTCNErGnwsGnmnm:localhost discord_282616294245662720_514843269599985674_bridged", + matrix: { + extras: {}, + }, + matrix_id: "!bridged1:localhost", + remote: { + discord_channel: "514843269599985674", + discord_guild: "282616294245662720", + discord_type: "text", + plumbed: false, + }, + remote_id: "discord_282616294245662720_514843269599985674_bridged", + }, + { + _id: "H3XEftQWj8BZYuCe", + data: {}, + id: "!oGkfjmeNEkJdFasVRF:localhost discord_282616294245662720_520332167952334849", + matrix: { + extras: {}, + }, + matrix_id: "!bridged2:localhost", + remote: { + discord_channel: "514843269599985674", + discord_guild: "282616294245662720", + discord_type: "text", + plumbed: true, + update_icon: true, + update_name: false, + update_topic: true, + }, + remote_id: "discord_282616294245662720_520332167952334849", + }, + ]; + }, + }; + await store.init(SCHEMA_VERSION, roomStore); + expect(await store.roomStore.getEntriesByMatrixId("!badroom:localhost")).to.be.empty; + const bridge1 = (await store.roomStore.getEntriesByMatrixId("!bridged1:localhost"))[0]; + expect(bridge1).to.exist; + expect(bridge1.remote).to.not.be.null; + expect(bridge1.remote!.data.discord_channel).to.be.equal("514843269599985674"); + expect(bridge1.remote!.data.discord_guild).to.be.equal("282616294245662720"); + expect(bridge1.remote!.data.discord_type).to.be.equal("text"); + expect(!!bridge1.remote!.data.plumbed).to.be.false; + const bridge2 = (await store.roomStore.getEntriesByMatrixId("!bridged2:localhost"))[0]; + expect(bridge2).to.exist; + expect(bridge2.remote).to.not.be.null; + expect(bridge2.remote!.data.discord_channel).to.be.equal("514843269599985674"); + expect(bridge2.remote!.data.discord_guild).to.be.equal("282616294245662720"); + expect(bridge2.remote!.data.discord_type).to.be.equal("text"); + expect(!!bridge2.remote!.data.plumbed).to.be.true; + expect(!!bridge2.remote!.data.update_icon).to.be.true; + expect(!!bridge2.remote!.data.update_name).to.be.false; + expect(!!bridge2.remote!.data.update_topic).to.be.true; + }); +}); diff --git a/test/mocha.opts b/test/mocha.opts index a93481d22320c95def1406620b32c228b27f0268..4d294c2e54e018b2c6abf7044831f47ef012eacd 100644 --- a/test/mocha.opts +++ b/test/mocha.opts @@ -1,3 +1,7 @@ --reporter list --ui bdd +--require ts-node/register +--require source-map-support/register --recursive +build/test/config.js +build/test diff --git a/test/mocks/channel.ts b/test/mocks/channel.ts new file mode 100644 index 0000000000000000000000000000000000000000..438bdc568c4db8b49cdc669a99f912f81805b233 --- /dev/null +++ b/test/mocks/channel.ts @@ -0,0 +1,42 @@ +/* +Copyright 2018 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 {MockMember} from "./member"; +import {MockCollection} from "./collection"; +import {Permissions, PermissionResolvable} from "discord.js"; + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +// Mocking TextChannel +export class MockChannel { + public members = new MockCollection<string, MockMember>(); + constructor( + public id: string = "", + public guild: any = null, + public type: string = "text", + public name: string = "", + public topic: string = "", + ) { } + + public async send(data: any): Promise<any> { + return data; + } + + public permissionsFor(member: MockMember) { + return new Permissions(Permissions.FLAGS.MANAGE_WEBHOOKS as PermissionResolvable); + } +} diff --git a/test/mocks/collection.ts b/test/mocks/collection.ts new file mode 100644 index 0000000000000000000000000000000000000000..ad830450b01ef44724ff4ec26a254112a035804f --- /dev/null +++ b/test/mocks/collection.ts @@ -0,0 +1,30 @@ +/* +Copyright 2017, 2018 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 { Collection } from "discord.js"; + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +export class MockCollection<T1, T2> extends Collection<T1, T2> { + public array(): T2[] { + return [...this.values()]; + } + + public keyArray(): T1[] { + return [...this.keys()]; + } +} diff --git a/test/mocks/discordclient.ts b/test/mocks/discordclient.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3a9e41918422f840189b9e874bc3d62cbed4ca4 --- /dev/null +++ b/test/mocks/discordclient.ts @@ -0,0 +1,75 @@ +/* +Copyright 2017, 2018 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 {MockCollection} from "./collection"; +import {MockGuild} from "./guild"; +import {MockUser} from "./user"; +import { EventEmitter } from "events"; + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +export class MockDiscordClient { + public guilds = new MockCollection<string, MockGuild>(); + public user: MockUser; + private testLoggedIn: boolean = false; + private testCallbacks: Map<string, (...data: any[]) => void> = new Map(); + + constructor() { + const channels = [ + { + id: "321", + name: "achannel", + type: "text", + }, + { + id: "654", + name: "a-channel", + type: "text", + }, + { + id: "987", + name: "a channel", + type: "text", + }, + ]; + this.guilds.set("123", new MockGuild("MyGuild", channels)); + this.guilds.set("456", new MockGuild("My Spaces Gui", channels)); + this.guilds.set("789", new MockGuild("My Dash-Guild", channels)); + this.user = new MockUser("12345"); + } + + public on(event: string, callback: (...data: any[]) => void) { + this.testCallbacks.set(event, callback); + } + + public async emit(event: string, ...data: any[]) { + return await this.testCallbacks.get(event)!.apply(this, data); + } + + public async login(token: string): Promise<void> { + if (token !== "passme") { + throw new Error("Mock Discord Client only logins with the token 'passme'"); + } + this.testLoggedIn = true; + if (this.testCallbacks.has("ready")) { + this.testCallbacks.get("ready")!(); + } + return; + } + + public async destroy() { } // no-op +} diff --git a/test/mocks/discordclientfactory.ts b/test/mocks/discordclientfactory.ts new file mode 100644 index 0000000000000000000000000000000000000000..803dedc118f7cacbb9c44443840dee85c06c4c4b --- /dev/null +++ b/test/mocks/discordclientfactory.ts @@ -0,0 +1,34 @@ +/* +Copyright 2017, 2018 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 {MockDiscordClient} from "./discordclient"; + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +export class DiscordClientFactory { + private botClient: MockDiscordClient; + constructor(config: any, store: any) { } + + public async init(): Promise<void> { } + + public async getClient(userId?: string): Promise<MockDiscordClient> { + if (!userId && !this.botClient) { + this.botClient = new MockDiscordClient(); + } + return this.botClient; + } +} diff --git a/test/mocks/emoji.ts b/test/mocks/emoji.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e7400e1f0a3d6ef1865acb1bc1d4fb7368b19e5 --- /dev/null +++ b/test/mocks/emoji.ts @@ -0,0 +1,22 @@ +/* +Copyright 2018 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. +*/ + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +export class MockEmoji { + constructor(public id: string = "", public name = "", public animated = false) { } +} diff --git a/test/mocks/guild.ts b/test/mocks/guild.ts new file mode 100644 index 0000000000000000000000000000000000000000..228decc0ca384b8f03d485946ba7f412272c5a48 --- /dev/null +++ b/test/mocks/guild.ts @@ -0,0 +1,52 @@ +/* +Copyright 2017, 2018 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 {MockCollection} from "./collection"; +import {MockMember} from "./member"; +import {MockEmoji} from "./emoji"; +import {Channel} from "discord.js"; +import {MockRole} from "./role"; + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +export class MockGuild { + public channels = new MockCollection<string, Channel>(); + public members = new MockCollection<string, MockMember>(); + public emojis = new MockCollection<string, MockEmoji>(); + public roles = new MockCollection<string, MockRole>(); + public id: string; + public name: string; + public icon: string; + constructor(id: string, channels: any[] = [], name: string = "") { + this.id = id; + this.name = name || id; + channels.forEach((item) => { + this.channels.set(item.id, item); + }); + } + + public async fetchMember(id: string): Promise<MockMember|Error> { + if (this.members.has(id)) { + return this.members.get(id)!; + } + throw new Error("Member not in this guild"); + } + + public _mockAddMember(member: MockMember) { + this.members.set(member.id, member); + } +} diff --git a/test/mocks/member.ts b/test/mocks/member.ts new file mode 100644 index 0000000000000000000000000000000000000000..df3b22cb30848e8af65a2e8b914402d75529b08e --- /dev/null +++ b/test/mocks/member.ts @@ -0,0 +1,41 @@ +/* +Copyright 2017, 2018 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 {MockCollection} from "./collection"; +import {MockUser} from "./user"; +import {MockRole} from "./role"; +import * as Discord from "discord.js"; + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +export class MockMember { + public id = ""; + public presence: Discord.Presence; + public user: MockUser; + public nickname: string; + public roles = new MockCollection<string, MockRole>(); + constructor(id: string, username: string, public guild: any = null, public displayName: string = username) { + this.id = id; + this.presence = new Discord.Presence({}, {} as any); + this.user = new MockUser(this.id, username); + this.nickname = displayName; + } + + public MockSetPresence(presence: Discord.Presence) { + this.presence = presence; + } +} diff --git a/test/mocks/message.ts b/test/mocks/message.ts new file mode 100644 index 0000000000000000000000000000000000000000..148a743e81b98fc7a77743f68868d30a14104426 --- /dev/null +++ b/test/mocks/message.ts @@ -0,0 +1,40 @@ +/* +Copyright 2018 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 * as Discord from "discord.js"; +import { MockUser } from "./user"; +import { MockCollection } from "./collection"; + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +export class MockMessage { + public attachments = new MockCollection<string, any>(); + public embeds: any[] = []; + public content = ""; + public channel: Discord.TextChannel | undefined; + public guild: Discord.Guild | undefined; + public author: MockUser; + public mentions: any = {}; + constructor(channel?: Discord.TextChannel) { + this.mentions.everyone = false; + this.channel = channel; + if (channel && channel.guild) { + this.guild = channel.guild; + } + this.author = new MockUser("123456"); + } +} diff --git a/test/mocks/role.ts b/test/mocks/role.ts new file mode 100644 index 0000000000000000000000000000000000000000..5318a07676aaab55cc94a18b9f62d76d9f2e4d08 --- /dev/null +++ b/test/mocks/role.ts @@ -0,0 +1,21 @@ +/* +Copyright 2018 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. +*/ + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ +export class MockRole { + constructor(public id: string = "", public name = "", public color = 0, public position = 0) { } +} diff --git a/test/mocks/store.ts b/test/mocks/store.ts new file mode 100644 index 0000000000000000000000000000000000000000..6d9e25860997762ec828a31b7dd219e3512c21ca --- /dev/null +++ b/test/mocks/store.ts @@ -0,0 +1,18 @@ +/* +Copyright 2018 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. +*/ + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ diff --git a/test/mocks/user.ts b/test/mocks/user.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef127130107013e0407db9a28dcea1c4b9e5134e --- /dev/null +++ b/test/mocks/user.ts @@ -0,0 +1,36 @@ +/* +Copyright 2017, 2018 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 { Presence } from "discord.js"; + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +export class MockUser { + public presence: Presence; + constructor( + public id: string, + public username: string = "", + public discriminator: string = "", + public avatarURL: string | null = "", + public avatar: string | null = "", + public bot: boolean = false, + ) { } + + public MockSetPresence(presence: Presence) { + this.presence = presence; + } +} diff --git a/test/test_channelsyncroniser.ts b/test/test_channelsyncroniser.ts new file mode 100644 index 0000000000000000000000000000000000000000..cbc338f4dd0a326d8f1d52e828fae54b59e33e48 --- /dev/null +++ b/test/test_channelsyncroniser.ts @@ -0,0 +1,544 @@ +/* +Copyright 2018, 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 * as Chai from "chai"; +import * as Discord from "discord.js"; +import * as Proxyquire from "proxyquire"; + +import { ISingleChannelState, IChannelState, ChannelSyncroniser } from "../src/channelsyncroniser"; +import { DiscordBot } from "../src/bot"; +import { MockGuild } from "./mocks/guild"; +import { MockMember } from "./mocks/member"; +import { MatrixEventProcessor, MatrixEventProcessorOpts } from "../src/matrixeventprocessor"; +import { DiscordBridgeConfig } from "../src/config"; +import { Util } from "../src/util"; +import { MockChannel } from "./mocks/channel"; +import { Bridge, MatrixRoom, RemoteRoom } from "matrix-appservice-bridge"; +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +const expect = Chai.expect; + +let UTIL_UPLOADED_AVATAR: any = null; +let REMOTECHANNEL_SET: any = false; +let REMOTECHANNEL_REMOVED: any = false; +let ROOM_NAME_SET: any = null; +let ROOM_TOPIC_SET: any = null; +let ROOM_AVATAR_SET: any = null; +let STATE_EVENT_SENT: any = false; +let ALIAS_DELETED: any = false; +let ROOM_DIRECTORY_VISIBILITY: any = null; + +const ChannelSync = (Proxyquire("../src/channelsyncroniser", { + "./util": { + Util: { + ApplyPatternString: Util.ApplyPatternString, + UploadContentFromUrl: async () => { + UTIL_UPLOADED_AVATAR = true; + return {mxcUrl: "avatarset"}; + }, + }, + }, +})).ChannelSyncroniser; + +class Entry { + public id: any; + public matrix: MatrixRoom; + public remote: RemoteRoom; + public data: any; + constructor(doc: any = {}) { + this.matrix = doc.matrix_id ? new MatrixRoom(doc.matrix_id, doc.matrix) : undefined; + this.remote = doc.remote_id ? new RemoteRoom(doc.remote_id, doc.remote) : undefined; + this.data = doc.data; + } +} + +function CreateChannelSync(remoteChannels: any[] = []): ChannelSyncroniser { + UTIL_UPLOADED_AVATAR = false; + const bridge: any = { + getIntent: (id) => { + ROOM_NAME_SET = null; + ROOM_TOPIC_SET = null; + ROOM_AVATAR_SET = null; + STATE_EVENT_SENT = false; + ALIAS_DELETED = false; + ROOM_DIRECTORY_VISIBILITY = null; + return { + getClient: () => { + return { + deleteAlias: async (alias) => { + ALIAS_DELETED = true; + }, + getStateEvent: async (mxid, event) => { + if (event === "m.room.canonical_alias") { + if (mxid === "!valid:localhost") { + return { + alias: "#alias:localhost", + }; + } else { + return null; + } + } + return event; + }, + sendStateEvent: async (mxid, event, data) => { + STATE_EVENT_SENT = true; + }, + setRoomDirectoryVisibility: async (mxid, visibility) => { + ROOM_DIRECTORY_VISIBILITY = visibility; + }, + setRoomName: async (mxid, name) => { + ROOM_NAME_SET = name; + }, + setRoomTopic: async (mxid, topic) => { + ROOM_TOPIC_SET = topic; + }, + }; + }, + setRoomAvatar: async (mxid, mxc) => { + ROOM_AVATAR_SET = mxc; + }, + setRoomName: async (mxid, name) => { + ROOM_NAME_SET = name; + }, + setRoomTopic: async (mxid, topic) => { + ROOM_TOPIC_SET = topic; + }, + }; + }, + }; + REMOTECHANNEL_REMOVED = false; + REMOTECHANNEL_SET = false; + const roomStore = { + getEntriesByMatrixId: (roomid) => { + const entries: any[] = []; + remoteChannels.forEach((c) => { + const mxid = c.matrix.getId(); + if (roomid === mxid) { + entries.push(c); + } + }); + return entries; + }, + getEntriesByMatrixIds: (roomids) => { + const entries = {}; + remoteChannels.forEach((c) => { + const mxid = c.matrix.getId(); + if (roomids.includes(mxid)) { + if (!entries[mxid]) { + entries[mxid] = []; + } + entries[mxid].push(c); + } + }); + return entries; + }, + getEntriesByRemoteRoomData: (data) => { + return remoteChannels.filter((c) => { + for (const d of Object.keys(data)) { + if (c.remote.get(d) !== data[d]) { + return false; + } + } + return true; + }); + }, + removeEntriesByMatrixRoomId: (room) => { + REMOTECHANNEL_REMOVED = true; + }, + upsertEntry: (room) => { + REMOTECHANNEL_SET = true; + }, + }; + const discordbot: any = { + + }; + const config = new DiscordBridgeConfig(); + config.bridge.domain = "localhost"; + config.channel.namePattern = "[Discord] :guild :name"; + const cs = new ChannelSync(bridge as Bridge, config, discordbot, roomStore) as ChannelSyncroniser; + return cs; +} + +describe("ChannelSyncroniser", () => { + describe("HandleChannelDelete", () => { + it("will not delete non-text channels", async () => { + const chan = new MockChannel(); + chan.id = "blah"; + chan.type = "voice"; + const testStore = [ + new Entry({ + id: "1", + matrix_id: "!1:localhost", + remote: { + discord_channel: chan.id, + }, + remote_id: "111", + }), + ]; + + const channelSync = CreateChannelSync(testStore); + await channelSync.OnDelete(chan as any); + + expect(REMOTECHANNEL_REMOVED).is.false; + }); + it("will delete text channels", async () => { + const chan = new MockChannel(); + chan.id = "blah"; + chan.type = "text"; + const testStore = [ + new Entry({ + id: "1", + matrix_id: "!1:localhost", + remote: { + discord_channel: chan.id, + }, + remote_id: "111", + }), + ]; + + const channelSync = CreateChannelSync(testStore); + await channelSync.OnDelete(chan as any); + + expect(REMOTECHANNEL_REMOVED).is.true; + }); + }); + describe("GetRoomIdsFromChannel", () => { + it("should get one room ID", async () => { + const chan = new MockChannel(); + chan.id = "blah"; + const testStore = [ + new Entry({ + id: "1", + matrix_id: "!1:localhost", + remote: { + discord_channel: chan.id, + }, + remote_id: "111", + }), + ]; + + const channelSync = CreateChannelSync(testStore); + const chans = await channelSync.GetRoomIdsFromChannel(chan as any); + + expect(chans.length).equals(1); + expect(chans[0]).equals("!1:localhost"); + }); + it("should get multiple room IDs", async () => { + const chan = new MockChannel(); + chan.id = "blah"; + const testStore = [ + new Entry({ + id: "1", + matrix_id: "!1:localhost", + remote: { + discord_channel: chan.id, + }, + remote_id: "111", + }), + new Entry({ + id: "2", + matrix_id: "!2:localhost", + remote: { + discord_channel: chan.id, + }, + remote_id: "111", + }), + new Entry({ + id: "3", + matrix_id: "!3:localhost", + remote: { + discord_channel: "no", + }, + remote_id: "false", + }), + ]; + + const channelSync = CreateChannelSync(testStore); + const chans = await channelSync.GetRoomIdsFromChannel(chan as any); + /* tslint:disable:no-magic-numbers */ + expect(chans.length).equals(2); + /* tslint:enable:no-magic-numbers */ + expect(chans[0]).equals("!1:localhost"); + expect(chans[1]).equals("!2:localhost"); + }); + it("should reject on no rooms", async () => { + const chan = new MockChannel(); + chan.id = "blah"; + const channelSync = CreateChannelSync(); + try { + await channelSync.GetRoomIdsFromChannel(chan as any); + throw new Error("didn't fail"); + } catch (e) { + expect(e.message).to.not.equal("didn't fail"); + } + }); + }); + describe("GetAliasFromChannel", () => { + const getIds = async (chan) => { + if (chan.id === "678") { + return ["!valid:localhost"]; + } + throw new Error("invalid"); + }; + it("Should get one canonical alias for a room", async () => { + const chan = new MockChannel(); + chan.id = "678"; + const channelSync = CreateChannelSync(); + channelSync.GetRoomIdsFromChannel = getIds; + const alias = await channelSync.GetAliasFromChannel(chan as any); + + expect(alias).to.equal("#alias:localhost"); + }); + it("Should return null if no alias found and no guild present", async () => { + const chan = new MockChannel(); + chan.id = "123"; + const channelSync = CreateChannelSync(); + channelSync.GetRoomIdsFromChannel = getIds; + const alias = await channelSync.GetAliasFromChannel(chan as any); + + expect(alias).to.equal(null); + }); + it("Should return a #_discord_ alias if a guild is present", async () => { + const chan = new MockChannel(); + const guild = new MockGuild("123"); + chan.id = "123"; + chan.guild = guild; + const channelSync = CreateChannelSync(); + channelSync.GetRoomIdsFromChannel = getIds; + const alias = await channelSync.GetAliasFromChannel(chan as any); + + expect(alias).to.equal("#_discord_123_123:localhost"); + }); + }); + describe("GetChannelUpdateState", () => { + it("will do nothing on no rooms", async () => { + const chan = new MockChannel(); + chan.type = "text"; + chan.id = "blah"; + + const channelSync = CreateChannelSync(); + const state = await channelSync.GetChannelUpdateState(chan as any); + expect(state.id).equals(chan.id); + expect(state.mxChannels.length).equals(0); + }); + it("will update name and topic", async () => { + const guild = new MockGuild("654321", [], "newGuild"); + const chan = new MockChannel(); + chan.type = "text"; + chan.id = "blah"; + chan.name = "newName"; + chan.topic = "newTopic"; + chan.guild = guild; + + const testStore = [ + new Entry({ + id: "1", + matrix_id: "!1:localhost", + remote: { + discord_channel: chan.id, + discord_name: "[Discord] oldGuild oldName", + discord_topic: "oldTopic", + update_name: true, + update_topic: true, + }, + remote_id: "111", + }), + ]; + + const channelSync = CreateChannelSync(testStore); + const state = await channelSync.GetChannelUpdateState(chan as any); + expect(state.mxChannels.length).equals(1); + expect(state.mxChannels[0].name).equals("[Discord] newGuild #newName"); + expect(state.mxChannels[0].topic).equals("newTopic"); + }); + it("won't update name and topic if props not set", async () => { + const guild = new MockGuild("654321", [], "newGuild"); + const chan = new MockChannel(); + chan.type = "text"; + chan.id = "blah"; + chan.name = "newName"; + chan.topic = "newTopic"; + chan.guild = guild; + + const testStore = [ + new Entry({ + id: "1", + matrix_id: "!1:localhost", + remote: { + discord_channel: chan.id, + discord_name: "[Discord] oldGuild oldName", + discord_topic: "oldTopic", + }, + remote_id: "111", + }), + ]; + + const channelSync = CreateChannelSync(testStore); + const state = await channelSync.GetChannelUpdateState(chan as any); + expect(state.mxChannels.length).equals(1); + expect(state.mxChannels[0].name).is.null; + expect(state.mxChannels[0].topic).is.null; + }); + it("won't update name and topic if not changed", async () => { + const guild = new MockGuild("654321", [], "newGuild"); + const chan = new MockChannel(); + chan.type = "text"; + chan.id = "blah"; + chan.name = "newName"; + chan.topic = "newTopic"; + chan.guild = guild; + + const testStore = [ + new Entry({ + id: "1", + matrix_id: "!1:localhost", + remote: { + discord_channel: chan.id, + discord_name: "[Discord] newGuild #newName", + discord_topic: "newTopic", + update_name: true, + update_topic: true, + }, + remote_id: "111", + }), + ]; + + const channelSync = CreateChannelSync(testStore); + const state = await channelSync.GetChannelUpdateState(chan as any); + expect(state.mxChannels.length).equals(1); + expect(state.mxChannels[0].name).is.null; + expect(state.mxChannels[0].topic).is.null; + }); + it("will update the icon", async () => { + const guild = new MockGuild("654321", [], "newGuild"); + guild.icon = "new_icon"; + const chan = new MockChannel(); + chan.type = "text"; + chan.id = "blah"; + chan.guild = guild; + + const testStore = [ + new Entry({ + id: "1", + matrix_id: "!1:localhost", + remote: { + discord_channel: chan.id, + discord_iconurl: "https://cdn.discordapp.com/icons/654321/old_icon.png", + update_icon: true, + }, + remote_id: "111", + }), + ]; + + const channelSync = CreateChannelSync(testStore); + const state = await channelSync.GetChannelUpdateState(chan as any); + expect(state.mxChannels.length).equals(1); + expect(state.mxChannels[0].iconUrl).equals("https://cdn.discordapp.com/icons/654321/new_icon.png"); + expect(state.mxChannels[0].iconId).equals("new_icon"); + }); + it("won't update the icon", async () => { + const guild = new MockGuild("654321", [], "newGuild"); + guild.icon = "new_icon"; + const chan = new MockChannel(); + chan.type = "text"; + chan.id = "blah"; + chan.guild = guild; + + const testStore = [ + new Entry({ + id: "1", + matrix_id: "!1:localhost", + remote: { + discord_channel: chan.id, + discord_iconurl: "https://cdn.discordapp.com/icons/654321/new_icon.png", + update_icon: true, + }, + remote_id: "111", + }), + ]; + + const channelSync = CreateChannelSync(testStore); + const state = await channelSync.GetChannelUpdateState(chan as any); + expect(state.mxChannels.length).equals(1); + expect(state.mxChannels[0].iconUrl).is.null; + expect(state.mxChannels[0].iconId).is.null; + }); + it("will delete the icon", async () => { + const guild = new MockGuild("654321", [], "newGuild"); + guild.icon = ""; + const chan = new MockChannel(); + chan.type = "text"; + chan.id = "blah"; + chan.guild = guild; + + const testStore = [ + new Entry({ + id: "1", + matrix_id: "!1:localhost", + remote: { + discord_channel: chan.id, + discord_iconurl: "https://cdn.discordapp.com/icons/654321/icon.png", + update_icon: true, + }, + remote_id: "111", + }), + ]; + + const channelSync = CreateChannelSync(testStore); + const state = await channelSync.GetChannelUpdateState(chan as any); + expect(state.mxChannels.length).equals(1); + expect(state.mxChannels[0].removeIcon).is.true; + }); + }); + describe("OnUpdate", () => { + it("Will update a room", async () => { + const guild = new MockGuild("654321", [], "newGuild"); + guild.icon = "new_icon"; + const chan = new MockChannel(); + chan.type = "text"; + chan.id = "blah"; + chan.name = "newName"; + chan.topic = "newTopic"; + chan.guild = guild; + + const testStore = [ + new Entry({ + id: "1", + matrix_id: "!1:localhost", + remote: { + discord_channel: chan.id, + discord_iconurl: "https://cdn.discordapp.com/icons/654321/old_icon.png", + discord_name: "[Discord] oldGuild #oldName", + discord_topic: "oldTopic", + update_icon: true, + update_name: true, + update_topic: true, + }, + remote_id: "111", + }), + ]; + + const channelSync = CreateChannelSync(testStore); + const state = await channelSync.OnUpdate(chan as any); + expect(ROOM_NAME_SET).equals("[Discord] newGuild #newName"); + expect(ROOM_TOPIC_SET).equals("newTopic"); + expect(ROOM_AVATAR_SET).equals("avatarset"); + expect(REMOTECHANNEL_SET).is.true; + expect(UTIL_UPLOADED_AVATAR).is.true; + }); + }); +}); diff --git a/test/test_clientfactory.ts b/test/test_clientfactory.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3d6df755287c1cdfeebd53042c506c68b014a8f --- /dev/null +++ b/test/test_clientfactory.ts @@ -0,0 +1,136 @@ +/* +Copyright 2018 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 * as Chai from "chai"; +import * as Proxyquire from "proxyquire"; +import {DiscordBridgeConfigAuth} from "../src/config"; +import {MockDiscordClient} from "./mocks/discordclient"; + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +const expect = Chai.expect; + +const DiscordClientFactory = Proxyquire("../src/clientfactory", { + "discord.js": { Client: require("./mocks/discordclient").MockDiscordClient }, +}).DiscordClientFactory; + +const STORE = { + getToken: async (discordid: string) => { + if (discordid === "12345") { + return "passme"; + } else if (discordid === "1234555") { + return "failme"; + } + throw new Error("Token not found"); + }, + getUserDiscordIds: async (userid: string) => { + if (userid === "@valid:localhost") { + return ["12345"]; + } else if (userid === "@invalid:localhost") { + return ["1234555"]; + } + return []; + }, +}; + +describe("ClientFactory", () => { + describe("init", () => { + it ("should start successfully", async () => { + const config = new DiscordBridgeConfigAuth(); + config.botToken = "passme"; + const cf = new DiscordClientFactory(null, config); + await cf.init(); + }); + it ("should fail if a config is not supplied", async () => { + const cf = new DiscordClientFactory(null); + try { + await cf.init(); + throw new Error("didn't fail"); + } catch (e) { + expect(e.message).to.not.equal("didn't fail"); + } + }); + it ("should fail if the bot fails to connect", async () => { + const config = new DiscordBridgeConfigAuth(); + config.botToken = "failme"; + const cf = new DiscordClientFactory(null, config); + try { + await cf.init(); + throw new Error("didn't fail"); + } catch (e) { + expect(e.message).to.not.equal("didn't fail"); + } + }); + }); + describe("getDiscordId", () => { + it("should fetch id successfully", async () => { + const config = new DiscordBridgeConfigAuth(); + const cf = new DiscordClientFactory(null); + const discordId = await cf.getDiscordId("passme"); + expect(discordId).equals("12345"); + }); + it("should fail if the token is not recognised", async () => { + const config = new DiscordBridgeConfigAuth(); + const cf = new DiscordClientFactory(null); + try { + await cf.getDiscordId("failme"); + throw new Error("didn't fail"); + } catch (e) { + expect(e.message).to.not.equal("didn't fail"); + } + }); + }); + describe("getClient", () => { + it("should fetch bot client successfully", async () => { + const config = new DiscordBridgeConfigAuth(); + config.botToken = "passme"; + const cf = new DiscordClientFactory(null, config); + cf.botClient = 1; + const client = await cf.getClient(); + expect(client).equals(cf.botClient); + }); + it("should return cached client", async () => { + const config = new DiscordBridgeConfigAuth(); + const cf = new DiscordClientFactory(null); + cf.clients.set("@user:localhost", "testclient"); + const client = await cf.getClient("@user:localhost"); + expect(client).equals("testclient"); + }); + it("should fetch bot client if userid doesn't match", async () => { + const config = new DiscordBridgeConfigAuth(); + const cf = new DiscordClientFactory(STORE); + cf.botClient = 1; + const client = await cf.getClient("@user:localhost"); + expect(client).equals(cf.botClient); + }); + it("should fetch user client if userid matches", async () => { + const config = new DiscordBridgeConfigAuth(); + const cf = new DiscordClientFactory(STORE); + const client = await cf.getClient("@valid:localhost"); + expect(client).is.not.null; + expect(cf.clients.has("@valid:localhost")).to.be.true; + }); + it("should fail if the user client cannot log in", async () => { + const config = new DiscordBridgeConfigAuth(); + const cf = new DiscordClientFactory(STORE); + cf.botClient = 1; + const client = await cf.getClient("@invalid:localhost"); + expect(client).to.equal(cf.botClient); + expect(cf.clients.has("@invalid:localhost")).to.be.false; + }); + }); +}); diff --git a/test/test_config.ts b/test/test_config.ts new file mode 100644 index 0000000000000000000000000000000000000000..0979897b55ea157ff94e02386674097ab0516645 --- /dev/null +++ b/test/test_config.ts @@ -0,0 +1,63 @@ +/* +Copyright 2018 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 * as Chai from "chai"; +import { DiscordBridgeConfig } from "../src/config"; + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +const expect = Chai.expect; + +describe("DiscordBridgeConfig.ApplyConfig", () => { + it("should merge configs correctly", () => { + const config = new DiscordBridgeConfig(); + config.ApplyConfig({ + bridge: { + disableDeletionForwarding: true, + disableDiscordMentions: false, + disableJoinLeaveNotifications: true, + disableTypingNotifications: true, + enableSelfServiceBridging: false, + homeserverUrl: "blah", + }, + logging: { + console: "warn", + }, + }); + expect(config.bridge.homeserverUrl, "blah"); + expect(config.bridge.disableTypingNotifications).to.be.true; + expect(config.bridge.disableDiscordMentions).to.be.false; + expect(config.bridge.disableDeletionForwarding).to.be.true; + expect(config.bridge.enableSelfServiceBridging).to.be.false; + expect(config.bridge.disableJoinLeaveNotifications).to.be.true; + expect(config.logging.console, "warn"); + }); + it("should merge logging.files correctly", () => { + const config = new DiscordBridgeConfig(); + config.ApplyConfig({ + logging: { + console: "silent", + files: [ + { + file: "./bacon.log", + }, + ], + }, + }); + expect(config.logging.files[0].file, "./bacon.log"); + }); +}); diff --git a/test/test_configschema.ts b/test/test_configschema.ts new file mode 100644 index 0000000000000000000000000000000000000000..e81019de7cf370442690f34736f6054fb626f126 --- /dev/null +++ b/test/test_configschema.ts @@ -0,0 +1,41 @@ +/* +Copyright 2018 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 * as yaml from "js-yaml"; +import * as Chai from "chai"; +import { ConfigValidator } from "matrix-appservice-bridge"; + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +const expect = Chai.expect; + +describe("ConfigSchema", () => { + const validator = new ConfigValidator("./config/config.schema.yaml"); + it("should successfully validate a minimal config", () => { + const yamlConfig = yaml.safeLoad(` + bridge: + domain: localhost + homeserverUrl: "http://localhost:8008" + auth: + clientID: foo + botToken: foobar`); + validator.validate(yamlConfig); + }); + it("should successfully validate the sample config", () => { + validator.validate("./config/config.sample.yaml"); + }); +}); diff --git a/test/test_discordbot.ts b/test/test_discordbot.ts index 40914bc440369da90e512ddfc5724de8a08bb781..f6208be1d18c3d8d32377f56c27a94458ddcde8f 100644 --- a/test/test_discordbot.ts +++ b/test/test_discordbot.ts @@ -1,187 +1,484 @@ +/* +Copyright 2017 - 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 * as Chai from "chai"; -import * as ChaiAsPromised from "chai-as-promised"; import * as Proxyquire from "proxyquire"; -import { DiscordBridgeConfig } from "../src/config"; +import * as Discord from "discord.js"; +import { Log } from "../src/log"; + +import { MockGuild } from "./mocks/guild"; +import { MockMember } from "./mocks/member"; +import { DiscordBot } from "../src/bot"; +import { MockDiscordClient } from "./mocks/discordclient"; +import { MockMessage } from "./mocks/message"; +import { Util } from "../src/util"; +import { MockChannel } from "./mocks/channel"; -Chai.use(ChaiAsPromised); +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +const expect = Chai.expect; const assert = Chai.assert; -const should = Chai.should as any; -class DiscordClient { - public guilds: any; - private testLoggedIn: boolean = false; - private testCallbacks: Array<() => void> = []; - constructor() { - let channels = [ - { - id: "321", - name: "achannel", - type: "text", - }, { - id: "654", - name: "a-channel", - type: "text", - }, { - id: "987", - name: "a channel", - type: "text", - }, - ]; - let guilds = [ - { - id: "123", - name: "MyGuild", - channels, - }, - { - id: "456", - name: "My Spaces Guild", - channels, - }, - { - id: "789", - name: "My Dash-Guild", - channels, - }, - ]; - this.guilds = guilds; - - } - - public on(event: string, callback: () => void) { - if (event === "ready") { - this.testCallbacks[0] = callback; - } - } - - public login(token: string) { - this.testLoggedIn = true; - this.testCallbacks[0](); - } -} - -const mockDiscord = { - Client: DiscordClient, -}; +// const should = Chai.should as any; const mockBridge = { - getRoomStore: () => { - return { - getEntriesByRemoteRoomData: (data) => { - if (data.discord_channel === "321") { - return Promise.resolve([{ - matrix: { - getId: () => {return "foobar:example.com"; }, + getIntentFromLocalpart: (localpart: string) => { + return { + sendTyping: (room: string, isTyping: boolean) => { + return; }, - }]); - } - return Promise.resolve([]); - }, - }; - }, - getIntentFromLocalpart: (localpart: string) => { - return{ - sendTyping: (room: string, isTyping: boolean) => { - return; - }, - }; - }, + }; + }, + getRoomStore: () => { + return { + getEntriesByRemoteRoomData: async (data) => { + if (data.discord_channel === "321") { + return [{ + matrix: { + getId: () => "foobar:example.com", + }, + }]; + } + return []; + }, + }; + }, + getUserStore: () => { + return {}; + }, }; -const modDiscordBot = Proxyquire("../src/discordbot", { - "discord.js": mockDiscord, +const modDiscordBot = Proxyquire("../src/bot", { + "./clientfactory": require("./mocks/discordclientfactory"), + "./util": { + Util: { + AsyncForEach: Util.AsyncForEach, + DelayedPromise: Util.DelayedPromise, + UploadContentFromUrl: async () => { + return {mxcUrl: "uploaded"}; + }, + }, + }, }); - describe("DiscordBot", () => { - const config = { - auth: { - botToken: "blah", - }, - }; - describe("run()", () => { - it("should start ok.", () => { - const discordBot = new modDiscordBot.DiscordBot( - config, - mockBridge, - ); - assert.doesNotThrow(discordBot.run.bind(discordBot)); + let discordBot; + const config = { + auth: { + botToken: "blah", + }, + bridge: { + disablePresence: true, + domain: "localhost", + }, + limits: { + discordSendDelay: 50, + }, + }; + describe("run()", () => { + it("should resolve when ready.", async () => { + discordBot = new modDiscordBot.DiscordBot( + "", + config, + mockBridge, + {}, + ); + await discordBot.run(); + }); }); - it("should resolve when ready.", () => { - const discordBot = new modDiscordBot.DiscordBot( - config, - mockBridge, - ); - return discordBot.run(); + + describe("LookupRoom()", () => { + beforeEach( async () => { + discordBot = new modDiscordBot.DiscordBot( + "", + config, + mockBridge, + {}, + ); + await discordBot.run(); + }); + it("should reject a missing guild.", async () => { + try { + await discordBot.LookupRoom("541", "321"); + throw new Error("didn't fail"); + } catch (e) { + expect(e.message).to.not.equal("didn't fail"); + } + }); + + it("should reject a missing channel.", async () => { + try { + await discordBot.LookupRoom("123", "666"); + throw new Error("didn't fail"); + } catch (e) { + expect(e.message).to.not.equal("didn't fail"); + } + }); + + it("should resolve a guild and channel id.", async () => { + await discordBot.LookupRoom("123", "321"); + }); }); - }); - describe("LookupRoom()", () => { - const discordBot = new modDiscordBot.DiscordBot( - config, - mockBridge, - ); - discordBot.run(); - it("should reject a missing guild.", () => { - return assert.isRejected(discordBot.LookupRoom("MyMissingGuild", "achannel")); + describe("OnMessage()", () => { + let SENT_MESSAGE = false; + let HANDLE_COMMAND = false; + let ATTACHMENT = {} as any; + let MSGTYPE = ""; + function getDiscordBot() { + SENT_MESSAGE = false; + HANDLE_COMMAND = false; + ATTACHMENT = {}; + MSGTYPE = ""; + const discord = new modDiscordBot.DiscordBot( + "", + config, + mockBridge, + {}, + ); + discord.bot = { user: { id: "654" } }; + discord.GetIntentFromDiscordMember = (_) => {return { + sendMessage: async (room, msg) => { + SENT_MESSAGE = true; + if (msg.info) { + ATTACHMENT = msg.info; + } + MSGTYPE = msg.msgtype; + return { + event_id: "$fox:localhost", + }; + }, + }; }; + discord.userSync = { + OnUpdateUser: async (user) => { }, + }; + discord.channelSync = { + GetRoomIdsFromChannel: async (chan) => ["!asdf:localhost"], + }; + discord.discordCommandHandler = { + Process: async (msg) => { HANDLE_COMMAND = true; }, + }; + discord.store = { + Insert: async (_) => { }, + }; + return discord; + } + it("ignores own messages", async () => { + discordBot = getDiscordBot(); + const guild: any = new MockGuild("123", []); + const author = new MockMember("654", "TestUsername"); + guild._mockAddMember(author); + const channel = new Discord.TextChannel(guild, {} as any); + const msg = new MockMessage(channel) as any; + msg.author = author; + msg.content = "Hi!"; + await discordBot.OnMessage(msg); + Chai.assert.equal(SENT_MESSAGE, false); + }); + it("Passes on !matrix commands", async () => { + discordBot = getDiscordBot(); + const channel = new Discord.TextChannel({} as any, {} as any); + const msg = new MockMessage(channel) as any; + msg.content = "!matrix test"; + await discordBot.OnMessage(msg); + Chai.assert.equal(HANDLE_COMMAND, true); + }); + it("skips empty messages", async () => { + discordBot = getDiscordBot(); + const channel = new Discord.TextChannel({} as any, {} as any); + const msg = new MockMessage(channel) as any; + msg.content = ""; + await discordBot.OnMessage(msg); + Chai.assert.equal(SENT_MESSAGE, false); + }); + it("sends normal messages", async () => { + discordBot = getDiscordBot(); + const channel = new Discord.TextChannel({} as any, {} as any); + const msg = new MockMessage(channel) as any; + msg.content = "Foxies are amazing!"; + await discordBot.OnMessage(msg); + Chai.assert.equal(SENT_MESSAGE, true); + }); + it("uploads images", async () => { + discordBot = getDiscordBot(); + const channel = new Discord.TextChannel({} as any, {} as any); + const msg = new MockMessage(channel) as any; + msg.attachments.set("1234", { + filename: "someimage.png", + filesize: 42, + height: 0, + url: "asdf", + width: 0, + }); + await discordBot.OnMessage(msg); + Chai.assert.equal(MSGTYPE, "m.image"); + Chai.assert.equal(ATTACHMENT.mimetype, "image/png"); + }); + it("uploads videos", async () => { + discordBot = getDiscordBot(); + const channel = new Discord.TextChannel({} as any, {} as any); + const msg = new MockMessage(channel) as any; + msg.attachments.set("1234", { + filename: "foxes.mov", + filesize: 42, + height: 0, + url: "asdf", + width: 0, + }); + await discordBot.OnMessage(msg); + Chai.assert.equal(MSGTYPE, "m.video"); + Chai.assert.equal(ATTACHMENT.mimetype, "video/quicktime"); + }); + it("uploads audio", async () => { + discordBot = getDiscordBot(); + const channel = new Discord.TextChannel({} as any, {} as any); + const msg = new MockMessage(channel) as any; + msg.attachments.set("1234", { + filename: "meow.mp3", + filesize: 42, + height: 0, + url: "asdf", + width: 0, + }); + await discordBot.OnMessage(msg); + Chai.assert.equal(MSGTYPE, "m.audio"); + Chai.assert.equal(ATTACHMENT.mimetype, "audio/mpeg"); + }); + it("uploads other files", async () => { + discordBot = getDiscordBot(); + const channel = new Discord.TextChannel({} as any, {} as any); + const msg = new MockMessage(channel) as any; + msg.attachments.set("1234", { + filename: "meow.zip", + filesize: 42, + height: 0, + url: "asdf", + width: 0, + }); + await discordBot.OnMessage(msg); + Chai.assert.equal(MSGTYPE, "m.file"); + Chai.assert.equal(ATTACHMENT.mimetype, "application/zip"); + }); }); + describe("OnMessageUpdate()", () => { + it("should return on an unchanged message", async () => { + discordBot = new modDiscordBot.DiscordBot( + "", + config, + mockBridge, + {}, + ); - it("should resolve a guild.", () => { - return assert.isFulfilled(discordBot.LookupRoom("MyGuild", "achannel")); - }); + const guild: any = new MockGuild("123", []); + guild._mockAddMember(new MockMember("12345", "TestUsername")); + const channel = new Discord.TextChannel(guild, {} as any); + const oldMsg = new MockMessage(channel) as any; + const newMsg = new MockMessage(channel) as any; + oldMsg.embeds = []; + newMsg.embeds = []; - it("should resolve a guild with an id.", () => { - return assert.isFulfilled(discordBot.LookupRoom("123", "achannel")); - }); + // Content updated but not changed + oldMsg.content = "a"; + newMsg.content = "a"; - it("should resolve a guild with spaces.", () => { - return assert.isFulfilled(discordBot.LookupRoom("My-Spaces-Guild", "achannel")); - }); + // Mock the SendMatrixMessage method to check if it is called + let checkMsgSent = false; + discordBot.SendMatrixMessage = (...args) => checkMsgSent = true; - it("should resolve a guild with dashes.", () => { - return assert.isFulfilled(discordBot.LookupRoom("My-Dash-Guild", "achannel")); - }); + await discordBot.OnMessageUpdate(oldMsg, newMsg); + Chai.assert.equal(checkMsgSent, false); + }); + it("should send a matrix message on an edited discord message", async () => { + discordBot = new modDiscordBot.DiscordBot( + "", + config, + mockBridge, + {}, + ); + discordBot.store.Get = (a, b) => null; - it("should reject a missing channel.", () => { - return assert.isRejected(discordBot.LookupRoom("MyGuild", "amissingchannel")); - }); + const guild: any = new MockGuild("123", []); + guild._mockAddMember(new MockMember("12345", "TestUsername")); + const channel = new Discord.TextChannel(guild, {} as any); + const oldMsg = new MockMessage(channel) as any; + const newMsg = new MockMessage(channel) as any; + oldMsg.embeds = []; + newMsg.embeds = []; - it("should resolve a channel with spaces.", () => { - return assert.isFulfilled(discordBot.LookupRoom("MyGuild", "a channel")); - }); + // Content updated and edited + oldMsg.content = "a"; + newMsg.content = "b"; - it("should resolve a channel with dashes.", () => { - return assert.isFulfilled(discordBot.LookupRoom("MyGuild", "a-channel")); - }); + // Mock the SendMatrixMessage method to check if it is called + let checkMsgSent = false; + discordBot.SendMatrixMessage = (...args) => checkMsgSent = true; + + await discordBot.OnMessageUpdate(oldMsg, newMsg); + Chai.assert.equal(checkMsgSent, true); + }); + it("should delete and re-send if it is the newest message", async () => { + discordBot = new modDiscordBot.DiscordBot( + "", + config, + mockBridge, + {}, + ); + discordBot.store.Get = (a, b) => { return { + MatrixId: "$event:localhost;!room:localhost", + Next: () => true, + Result: true, + }; }; + discordBot.lastEventIds["!room:localhost"] = "$event:localhost"; + + const guild: any = new MockGuild("123", []); + guild._mockAddMember(new MockMember("12345", "TestUsername")); + const channel = new Discord.TextChannel(guild, {} as any); + const oldMsg = new MockMessage(channel) as any; + const newMsg = new MockMessage(channel) as any; + oldMsg.embeds = []; + newMsg.embeds = []; + + // Content updated and edited + oldMsg.content = "a"; + newMsg.content = "b"; + + let deletedMessage = false; + discordBot.DeleteDiscordMessage = async (_) => { deletedMessage = true; }; + let sentMessage = false; + discordBot.OnMessage = async (_) => { sentMessage = true; }; - it("should resolve a channel with an id.", () => { - return assert.isFulfilled(discordBot.LookupRoom("MyGuild", "321")); + await discordBot.OnMessageUpdate(oldMsg, newMsg); + Chai.assert.equal(deletedMessage, true); + Chai.assert.equal(sentMessage, true); + }); }); - }); - // describe("ProcessMatrixMsgEvent()", () => { - // - // }); - // describe("UpdateRoom()", () => { - // - // }); - // describe("UpdateUser()", () => { - // - // }); - // describe("UpdatePresence()", () => { - // - // }); - describe("OnTyping()", () => { - const discordBot = new modDiscordBot.DiscordBot( - config, - mockBridge, - ); - discordBot.run(); - it("should reject an unknown room.", () => { - return assert.isRejected(discordBot.OnTyping( {id: "512"}, {id: "12345"}, true)); + describe("event:message", () => { + it("should delay messages so they arrive in order", async () => { + discordBot = new modDiscordBot.DiscordBot( + "", + config, + mockBridge, + {}, + ); + let expected = 0; + discordBot.OnMessage = async (msg: any) => { + assert.equal(msg.n, expected); + expected++; + }; + const client: MockDiscordClient = (await discordBot.ClientFactory.getClient()) as MockDiscordClient; + await discordBot.run(); + const ITERATIONS = 25; + const CHANID = 123; + // Send delay of 50ms, 2 seconds / 50ms - 5 for safety. + for (let i = 0; i < ITERATIONS; i++) { + await client.emit("message", { channel: { guild: { id: CHANID }, id: CHANID} }); + } + await discordBot.discordMessageQueue[CHANID]; + }); + it("should handle messages that reject in the queue", async () => { + discordBot = new modDiscordBot.DiscordBot( + "", + config, + mockBridge, + {}, + ); + let expected = 0; + const THROW_EVERY = 5; + discordBot.OnMessage = async (msg: any) => { + assert.equal(msg.n, expected); + expected++; + if (expected % THROW_EVERY === 0) { + return Promise.reject("Deliberate throw in test"); + } + return Promise.resolve(); + }; + const client: MockDiscordClient = (await discordBot.ClientFactory.getClient()) as MockDiscordClient; + await discordBot.run(); + const ITERATIONS = 25; + const CHANID = 123; + // Send delay of 50ms, 2 seconds / 50ms - 5 for safety. + for (let n = 0; n < ITERATIONS; n++) { + await client.emit("message", { n, channel: { guild: { id: CHANID }, id: CHANID} }); + } + await discordBot.discordMessageQueue[CHANID]; + assert.equal(expected, ITERATIONS); + }); }); - it("should resolve a known room.", () => { - return assert.isFulfilled(discordBot.OnTyping( {id: "321"}, {id: "12345"}, true)); + 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 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; + expect(diff).to.be.greaterThan(SHORTDELAY - 1); + }); }); - }); - // describe("OnMessage()", () => { - // // }); + // describe("ProcessMatrixMsgEvent()", () => { + // + // }); + // describe("UpdateRoom()", () => { + // + // }); + // describe("UpdateUser()", () => { + // + // }); + // describe("UpdatePresence()", () => { + // + // }); + // describe("OnTyping()", () => { + // const discordBot = new modDiscordBot.DiscordBot( + // config, + // ); + // discordBot.run(); + // it("should reject an unknown room.", () => { + // return assert.isRejected(discordBot.OnTyping( {id: "512"}, {id: "12345"}, true)); + // }); + // it("should resolve a known room.", () => { + // return assert.isFulfilled(discordBot.OnTyping( {id: "321"}, {id: "12345"}, true)); + // }); + // }); }); diff --git a/test/test_discordcommandhandler.ts b/test/test_discordcommandhandler.ts new file mode 100644 index 0000000000000000000000000000000000000000..a66439530ac0a2a4cfc91791f9753858f3088125 --- /dev/null +++ b/test/test_discordcommandhandler.ts @@ -0,0 +1,227 @@ +/* +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 * as Chai from "chai"; +import * as Proxyquire from "proxyquire"; + +import { DiscordCommandHandler } from "../src/discordcommandhandler"; +import { MockChannel } from "./mocks/channel"; +import { MockMember } from "./mocks/member"; +import { MockGuild } from "./mocks/guild"; +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 */ + +const expect = Chai.expect; + +let USERSJOINED = 0; +let USERSKICKED = 0; +let USERSBANNED = 0; +let USERSUNBANNED = 0; +let ROOMSUNBRIDGED = 0; +let MESSAGESENT: any = {}; +let MARKED = -1; +function createCH(opts: any = {}) { + USERSJOINED = 0; + USERSKICKED = 0; + USERSBANNED = 0; + USERSUNBANNED = 0; + ROOMSUNBRIDGED = 0; + MESSAGESENT = {}; + MARKED = -1; + const bridge = { + getIntent: () => { + return { + ban: async () => { USERSBANNED++; }, + getEvent: () => ({ content: { } }), + join: () => { USERSJOINED++; }, + kick: async () => { USERSKICKED++; }, + leave: () => { }, + sendMessage: async (roomId, content) => { MESSAGESENT = content; return content; }, + unban: async () => { USERSUNBANNED++; }, + }; + }, + }; + const cs = { + GetRoomIdsFromChannel: async (chan) => { + return [`#${chan.id}:localhost`]; + }, + }; + const discord = { + ChannelSyncroniser: cs, + Provisioner: { + HasPendingRequest: (chan) => true, + MarkApproved: async (chan, member, approved) => { + MARKED = approved ? 1 : 0; + return approved; + }, + UnbridgeChannel: () => { + ROOMSUNBRIDGED++; + }, + }, + }; + const discordCommandHndlr = (Proxyquire("../src/discordcommandhandler", { + "./util": { + Util: { + GetMxidFromName: () => { + return "@123456:localhost"; + }, + ParseCommand: Util.ParseCommand, + }, + }, + })).DiscordCommandHandler; + return new discordCommandHndlr(bridge as any, discord as any); +} + +describe("DiscordCommandHandler", () => { + it("will kick a member", async () => { + const handler: any = createCH(); + const channel = new MockChannel("123"); + const guild = new MockGuild("456", [channel]); + channel.guild = guild; + const member: any = new MockMember("123456", "blah"); + member.hasPermission = () => { + return true; + }; + const message = { + channel, + content: "!matrix kick someuser", + member, + }; + await handler.Process(message); + expect(USERSKICKED).equals(1); + }); + it("will kick a member in all guild rooms", async () => { + const handler: any = createCH(); + const channel = new MockChannel("123"); + const guild = new MockGuild("456", [channel, (new MockChannel("456"))]); + channel.guild = guild; + const member: any = new MockMember("123456", "blah"); + member.hasPermission = () => { + return true; + }; + const message = { + channel, + content: "!matrix kick someuser", + member, + }; + await handler.Process(message); + // tslint:disable-next-line:no-magic-numbers + expect(USERSKICKED).equals(2); + }); + it("will deny permission", async () => { + const handler: any = createCH(); + const channel = new MockChannel("123"); + const guild = new MockGuild("456", [channel]); + channel.guild = guild; + const member: any = new MockMember("123456", "blah"); + member.hasPermission = () => { + return false; + }; + const message = { + channel, + content: "!matrix kick someuser", + member, + }; + await handler.Process(message); + expect(USERSKICKED).equals(0); + }); + it("will ban a member", async () => { + const handler: any = createCH(); + const channel = new MockChannel("123"); + const guild = new MockGuild("456", [channel]); + channel.guild = guild; + const member: any = new MockMember("123456", "blah"); + member.hasPermission = () => { + return true; + }; + const message = { + channel, + content: "!matrix ban someuser", + member, + }; + await handler.Process(message); + expect(USERSBANNED).equals(1); + }); + it("will unban a member", async () => { + const handler: any = createCH(); + const channel = new MockChannel("123"); + const guild = new MockGuild("456", [channel]); + channel.guild = guild; + const member: any = new MockMember("123456", "blah"); + member.hasPermission = () => { + return true; + }; + const message = { + channel, + content: "!matrix unban someuser", + member, + }; + await handler.Process(message); + expect(USERSUNBANNED).equals(1); + }); + it("handles !matrix approve", async () => { + const handler: any = createCH(); + const channel = new MockChannel("123"); + const guild = new MockGuild("456", [channel]); + channel.guild = guild; + const member: any = new MockMember("123456", "blah"); + member.hasPermission = () => { + return true; + }; + const message = { + channel, + content: "!matrix approve", + member, + }; + await handler.Process(message); + expect(MARKED).equals(1); + }); + it("handles !matrix deny", async () => { + const handler: any = createCH(); + const channel = new MockChannel("123"); + const guild = new MockGuild("456", [channel]); + channel.guild = guild; + const member: any = new MockMember("123456", "blah"); + member.hasPermission = () => { + return true; + }; + const message = { + channel, + content: "!matrix deny", + member, + }; + await handler.Process(message); + expect(MARKED).equals(0); + }); + it("handles !matrix unbridge", async () => { + const handler: any = createCH(); + const channel = new MockChannel("123"); + const guild = new MockGuild("456", [channel]); + channel.guild = guild; + const member: any = new MockMember("123456", "blah"); + member.hasPermission = () => { + return true; + }; + const message = { + channel, + content: "!matrix unbridge", + member, + }; + await handler.Process(message); + expect(ROOMSUNBRIDGED).equals(1); + }); +}); diff --git a/test/test_discordmessageprocessor.ts b/test/test_discordmessageprocessor.ts new file mode 100644 index 0000000000000000000000000000000000000000..f78d66abded7dacaa4451b546796459cf7776e64 --- /dev/null +++ b/test/test_discordmessageprocessor.ts @@ -0,0 +1,738 @@ +/* +Copyright 2017 - 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 * as Chai from "chai"; +import * as Discord from "discord.js"; +import { DiscordMessageProcessor, DiscordMessageProcessorOpts } from "../src/discordmessageprocessor"; +import { DiscordBot } from "../src/bot"; +import { MockGuild } from "./mocks/guild"; +import { MockMember } from "./mocks/member"; +import { MockMessage } from "./mocks/message"; +import { MockRole } from "./mocks/role"; + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +const bot = { + ChannelSyncroniser: { + GetAliasFromChannel: async (chan) => { + if (chan.id === "456") { + return "#_discord_123_456:localhost"; + } + return null; + }, + }, + GetEmoji: async (name: string, animated: boolean, id: string): Promise<string> => { + if (id === "3333333") { + return "mxc://image"; + } else { + throw new Error("Emoji not found"); + } + }, +}; + +describe("DiscordMessageProcessor", () => { + describe("init", () => { + it("constructor", () => { + const mp = new DiscordMessageProcessor(new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + }); + }); + describe("FormatMessage", () => { + it("processes plain text messages correctly", async () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const msg = new MockMessage() as any; + msg.embeds = []; + msg.content = "Hello World!"; + const result = await processor.FormatMessage(msg); + Chai.assert(result.body, "Hello World!"); + Chai.assert(result.formattedBody, "Hello World!"); + }); + it("processes markdown messages correctly.", async () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const msg = new MockMessage() as any; + msg.embeds = []; + msg.content = "Hello *World*!"; + const result = await processor.FormatMessage(msg); + Chai.assert.equal(result.body, "Hello *World*!"); + Chai.assert.equal(result.formattedBody, "Hello <em>World</em>!"); + }); + it("processes non-discord markdown correctly.", async () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const msg = new MockMessage() as any; + msg.embeds = []; + msg.content = "> inb4 tests"; + let result = await processor.FormatMessage(msg); + Chai.assert.equal(result.body, "> inb4 tests"); + Chai.assert.equal(result.formattedBody, "> inb4 tests"); + + msg.embeds = []; + msg.content = "[test](http://example.com)"; + result = await processor.FormatMessage(msg); + Chai.assert.equal(result.body, "[test](http://example.com)"); + Chai.assert.equal(result.formattedBody, + "[test](<a href=\"http://example.com\">http://example.com</a>)"); + }); + it("processes discord-specific markdown correctly.", async () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const msg = new MockMessage() as any; + msg.embeds = []; + msg.content = "_ italic _"; + const result = await processor.FormatMessage(msg); + Chai.assert.equal(result.body, "_ italic _"); + Chai.assert.equal(result.formattedBody, "<em> italic </em>"); + }); + it("replaces @everyone correctly", async () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const msg = new MockMessage() as any; + msg.embeds = []; + msg.content = "hey @everyone!"; + let result = await processor.FormatMessage(msg); + Chai.assert.equal(result.body, "hey @everyone!"); + Chai.assert.equal(result.formattedBody, "hey @everyone!"); + + msg.mentions.everyone = true; + result = await processor.FormatMessage(msg); + Chai.assert.equal(result.body, "hey @room!"); + Chai.assert.equal(result.formattedBody, "hey @room!"); + }); + it("replaces @here correctly", async () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const msg = new MockMessage() as any; + msg.embeds = []; + msg.content = "hey @here!"; + let result = await processor.FormatMessage(msg); + Chai.assert.equal(result.body, "hey @here!"); + Chai.assert.equal(result.formattedBody, "hey @here!"); + + msg.mentions.everyone = true; + result = await processor.FormatMessage(msg); + Chai.assert.equal(result.body, "hey @room!"); + Chai.assert.equal(result.formattedBody, "hey @room!"); + }); + }); + describe("FormatEmbeds", () => { + it("should format embeds correctly", async () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const msg = new MockMessage() as any; + msg.embeds = [ + { + author: {} as any, + client: {} as any, + color: {} as any, + createdAt: {} as any, + createdTimestamp: {} as any, + description: "Description", + fields: [] as any, + footer: undefined as any, + hexColor: {} as any, + image: undefined as any, + message: {} as any, + provider: {} as any, + thumbnail: {} as any, + title: "Title", + type: {} as any, + url: "http://example.com", + video: {} as any, + }, + ]; + msg.content = "message"; + const result = await processor.FormatMessage(msg); + Chai.assert.equal(result.body, "message\n\n----\n##### [Title](http://example.com)\nDescription"); + Chai.assert.equal(result.formattedBody, "message<hr><h5><a href=\"http://example.com\">Title</a>" + + "</h5><p>Description</p>"); + }); + it("should ignore same-url embeds", async () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const msg = new MockMessage() as any; + msg.embeds = [ + { + author: {} as any, + client: {} as any, + color: {} as any, + createdAt: {} as any, + createdTimestamp: {} as any, + description: "Description", + fields: [] as any, + footer: {} as any, + hexColor: {} as any, + image: {} as any, + message: {} as any, + provider: {} as any, + thumbnail: {} as any, + title: "Title", + type: {} as any, + url: "http://example.com", + video: {} as any, + }, + ]; + msg.content = "message http://example.com"; + const result = await processor.FormatMessage(msg); + Chai.assert.equal(result.body, "message http://example.com"); + Chai.assert.equal(result.formattedBody, "message <a href=\"http://example.com\">" + + "http://example.com</a>"); + }); + it("should ignore same-url embeds with trailing slash", async () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const msg = new MockMessage() as any; + msg.embeds = [ + { + author: {} as any, + client: {} as any, + color: {} as any, + createdAt: {} as any, + createdTimestamp: {} as any, + description: "Description", + fields: [] as any, + footer: {} as any, + hexColor: {} as any, + image: {} as any, + message: {} as any, + provider: {} as any, + thumbnail: {} as any, + title: "Title", + type: {} as any, + url: "http://example.com/", + video: {} as any, + }, + ]; + msg.content = "message http://example.com"; + const result = await processor.FormatMessage(msg); + Chai.assert.equal(result.body, "message http://example.com"); + Chai.assert.equal(result.formattedBody, "message <a href=\"http://example.com\">" + + "http://example.com</a>"); + }); + }); + describe("FormatEdit", () => { + it("should format basic edits appropriately", async () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const oldMsg = new MockMessage() as any; + const newMsg = new MockMessage() as any; + oldMsg.embeds = []; + newMsg.embeds = []; + + // Content updated but not changed + oldMsg.content = "a"; + newMsg.content = "b"; + + const result = await processor.FormatEdit(oldMsg, newMsg); + Chai.assert.equal(result.body, "*edit:* ~~a~~ -> b"); + Chai.assert.equal(result.formattedBody, "<em>edit:</em> <del>a</del> -> b"); + }); + it("should format markdown heavy edits apropriately", async () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const oldMsg = new MockMessage() as any; + const newMsg = new MockMessage() as any; + oldMsg.embeds = []; + newMsg.embeds = []; + + // Content updated but not changed + oldMsg.content = "a slice of **cake**"; + newMsg.content = "*a* slice of cake"; + + const result = await processor.FormatEdit(oldMsg, newMsg); + Chai.assert.equal(result.body, "*edit:* ~~a slice of **cake**~~ -> *a* slice of cake"); + Chai.assert.equal(result.formattedBody, "<em>edit:</em> <del>a slice of <strong>" + + "cake</strong></del> -> <em>a</em> slice of cake"); + }); + it("should format discord fail edits correctly", async () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const oldMsg = new MockMessage() as any; + const newMsg = new MockMessage() as any; + oldMsg.embeds = []; + newMsg.embeds = []; + + // Content updated but not changed + oldMsg.content = "~~fail~"; + newMsg.content = "~~fail~~"; + + const result = await processor.FormatEdit(oldMsg, newMsg); + Chai.assert.equal(result.body, "*edit:* ~~~~fail~~~ -> ~~fail~~"); + Chai.assert.equal(result.formattedBody, "<em>edit:</em> <del>~~fail~</del> -> <del>fail</del>"); + }); + it("should format multiline edits correctly", async () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const oldMsg = new MockMessage() as any; + const newMsg = new MockMessage() as any; + oldMsg.embeds = []; + newMsg.embeds = []; + + // Content updated but not changed + oldMsg.content = "multi\nline"; + newMsg.content = "multi\nline\nfoxies"; + + const result = await processor.FormatEdit(oldMsg, newMsg); + Chai.assert.equal(result.body, "*edit:* ~~multi\nline~~ -> multi\nline\nfoxies"); + Chai.assert.equal(result.formattedBody, "<p><em>edit:</em></p><p><del>multi<br>line</del></p><hr>" + + "<p>multi<br>line<br>foxies</p>"); + }); + it("should add old message link", async () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const oldMsg = new MockMessage() as any; + const newMsg = new MockMessage() as any; + oldMsg.embeds = []; + newMsg.embeds = []; + + // Content updated but not changed + oldMsg.content = "fox"; + newMsg.content = "foxies"; + + const result = await processor.FormatEdit(oldMsg, newMsg, "https://matrix.to/#/old"); + Chai.assert.equal(result.body, "*edit:* ~~fox~~ -> foxies"); + Chai.assert.equal(result.formattedBody, "<a href=\"https://matrix.to/#/old\"><em>edit:</em></a>" + + " <del>fox</del> -> foxies"); + }); + }); + + describe("InsertUser / HTML", () => { + it("processes members missing from the guild correctly", () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const guild: any = new MockGuild("123", []); + const channel = new Discord.TextChannel(guild, {}); + const msg = new MockMessage(channel) as any; + const content = { id: "12345" }; + let reply = processor.InsertUser(content, msg); + Chai.assert.equal(reply, "@_discord_12345:localhost"); + + reply = processor.InsertUser(content, msg, true); + Chai.assert.equal(reply, + "<a href=\"https://matrix.to/#/@_discord_12345:localhost\">@_discord_12345:localhost</a>"); + }); + it("processes members with usernames correctly", () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const guild: any = new MockGuild("123", []); + guild._mockAddMember(new MockMember("12345", "TestUsername")); + const channel = new Discord.TextChannel(guild, {}); + const msg = new MockMessage(channel) as any; + const content = { id: "12345" }; + let reply = processor.InsertUser(content, msg); + Chai.assert.equal(reply, "TestUsername"); + + reply = processor.InsertUser(content, msg, true); + Chai.assert.equal(reply, + "<a href=\"https://matrix.to/#/@_discord_12345:localhost\">TestUsername</a>"); + }); + it("processes members with nickname correctly", () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const guild: any = new MockGuild("123", []); + guild._mockAddMember(new MockMember("12345", "TestUsername", null, "TestNickname")); + const channel = new Discord.TextChannel(guild, {}); + const msg = new MockMessage(channel) as any; + const content = { id: "12345" }; + let reply = processor.InsertUser(content, msg); + Chai.assert.equal(reply, "TestNickname"); + + reply = processor.InsertUser(content, msg, true); + Chai.assert.equal(reply, + "<a href=\"https://matrix.to/#/@_discord_12345:localhost\">TestNickname</a>"); + }); + }); + describe("InsertRole / HTML", () => { + it("ignores unknown roles", () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const guild: any = new MockGuild("123", []); + const channel = new Discord.TextChannel(guild, {id: "456", name: "TestChannel"}); + guild.channels.set("456", channel); + const role = new MockRole("5678", "role"); + guild.roles.set("5678", role); + const msg = new MockMessage(channel) as any; + const content = { id: "1234" }; + let reply = processor.InsertRole(content, msg); + Chai.assert.equal(reply, "<@&1234>"); + + reply = processor.InsertRole(content, msg, true); + Chai.assert.equal(reply, "<@&1234>"); + }); + it("parses known roles", () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const guild: any = new MockGuild("123", []); + const channel = new Discord.TextChannel(guild, {id: "456", name: "TestChannel"}); + guild.channels.set("456", channel); + const ROLE_COLOR = 0xDEAD88; + const role = new MockRole("1234", "role", ROLE_COLOR); + guild.roles.set("1234", role); + const msg = new MockMessage(channel) as any; + const content = { id: "1234" }; + let reply = processor.InsertRole(content, msg); + Chai.assert.equal(reply, "@role"); + + reply = processor.InsertRole(content, msg, true); + Chai.assert.equal(reply, "<span data-mx-color=\"#dead88\"><strong>@role</strong></span>"); + }); + }); + describe("InsertEmoji", () => { + it("inserts static emojis to their post-parse flag", () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const content = { + animated: false, + id: "1234", + name: "blah", + }; + const reply = processor.InsertEmoji(content); + Chai.assert.equal(reply, "\x01emoji\x01blah\x010\x011234\x01"); + }); + it("inserts animated emojis to their post-parse flag", () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const content = { + animated: true, + id: "1234", + name: "blah", + }; + const reply = processor.InsertEmoji(content); + Chai.assert.equal(reply, "\x01emoji\x01blah\x011\x011234\x01"); + }); + }); + describe("InsertChannel", () => { + it("inserts channels to their post-parse flag", () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const content = { + id: "1234", + }; + const reply = processor.InsertChannel(content); + Chai.assert.equal(reply, "\x01chan\x011234\x01"); + }); + }); + describe("InsertMxcImages / HTML", () => { + it("processes unknown emoji correctly", async () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const guild: any = new MockGuild("123", []); + const channel = new Discord.TextChannel(guild, {id: "456", name: "TestChannel"}); + const msg = new MockMessage(channel) as any; + const content = "Hello \x01emoji\x01hello\x010\x01123456789\x01"; + let reply = await processor.InsertMxcImages(content, msg); + Chai.assert.equal(reply, "Hello <:hello:123456789>"); + + reply = await processor.InsertMxcImages(content, msg, true); + Chai.assert.equal(reply, "Hello <:hello:123456789>"); + }); + it("processes emoji correctly", async () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const guild: any = new MockGuild("123", []); + const channel = new Discord.TextChannel(guild, {id: "456", name: "TestChannel"}); + guild.channels.set("456", channel); + const msg = new MockMessage(channel) as any; + const content = "Hello \x01emoji\x01hello\x010\x013333333\x01"; + let reply = await processor.InsertMxcImages(content, msg); + Chai.assert.equal(reply, "Hello :hello:"); + + reply = await processor.InsertMxcImages(content, msg, true); + Chai.assert.equal(reply, "Hello <img alt=\"hello\" title=\"hello\" height=\"32\" src=\"mxc://image\" />"); + }); + it("processes double-emoji correctly", async () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const guild: any = new MockGuild("123", []); + const channel = new Discord.TextChannel(guild, {id: "456", name: "TestChannel"}); + guild.channels.set("456", channel); + const msg = new MockMessage(channel) as any; + const content = "Hello \x01emoji\x01hello\x010\x013333333\x01 \x01emoji\x01hello\x010\x013333333\x01"; + let reply = await processor.InsertMxcImages(content, msg); + Chai.assert.equal(reply, "Hello :hello: :hello:"); + + reply = await processor.InsertMxcImages(content, msg, true); + Chai.assert.equal(reply, "Hello <img alt=\"hello\" title=\"hello\" height=\"32\" src=\"mxc://image\" /> " + + "<img alt=\"hello\" title=\"hello\" height=\"32\" src=\"mxc://image\" />"); + }); + }); + describe("InsertChannelPills / HTML", () => { + it("processes unknown channel correctly", async () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const guild: any = new MockGuild("123", []); + const channel = new Discord.TextChannel(guild, {id: "456", name: "TestChannel"}); + guild.channels.set("456", channel); + const msg = new MockMessage(channel) as any; + const content = "Hello \x01chan\x013333333\x01"; + let reply = await processor.InsertChannelPills(content, msg); + Chai.assert.equal(reply, "Hello <#3333333>"); + + reply = await processor.InsertChannelPills(content, msg, true); + Chai.assert.equal(reply, "Hello <#3333333>"); + }); + it("processes channels correctly", async () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const guild: any = new MockGuild("123", []); + const channel = new Discord.TextChannel(guild, {id: "456", name: "TestChannel"}); + guild.channels.set("456", channel); + const msg = new MockMessage(channel) as any; + const content = "Hello \x01chan\x01456\x01"; + let reply = await processor.InsertChannelPills(content, msg); + Chai.assert.equal(reply, "Hello #TestChannel"); + + reply = await processor.InsertChannelPills(content, msg, true); + Chai.assert.equal(reply, "Hello <a href=\"https://matrix.to/#/#_discord_123" + + "_456:localhost\">#TestChannel</a>"); + }); + it("processes multiple channels correctly", async () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const guild: any = new MockGuild("123", []); + const channel = new Discord.TextChannel(guild, {id: "456", name: "TestChannel"}); + guild.channels.set("456", channel); + const msg = new MockMessage(channel) as any; + const content = "Hello \x01chan\x01456\x01 \x01chan\x01456\x01"; + let reply = await processor.InsertChannelPills(content, msg); + Chai.assert.equal(reply, "Hello #TestChannel #TestChannel"); + + reply = await processor.InsertChannelPills(content, msg, true); + Chai.assert.equal(reply, "Hello <a href=\"https://matrix.to/#/#_discord_123" + + "_456:localhost\">#TestChannel</a> <a href=\"https://matrix.to/#/#_discord_123" + + "_456:localhost\">#TestChannel</a>"); + }); + it("processes channels without alias correctly", async () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const guild: any = new MockGuild("123", []); + const channel = new Discord.TextChannel(guild, {id: "678", name: "TestChannel"}); + guild.channels.set("678", channel); + const msg = new MockMessage(channel) as any; + const content = "Hello \x01chan\x01678\x01"; + let reply = await processor.InsertChannelPills(content, msg); + Chai.assert.equal(reply, "Hello <#678>"); + + reply = await processor.InsertChannelPills(content, msg, true); + Chai.assert.equal(reply, "Hello <#678>"); + }); + }); + describe("InsertEmbeds", () => { + it("processes titleless embeds properly", () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const msg = new MockMessage() as any; + msg.embeds = [ + new Discord.MessageEmbed(msg, { + description: "TestDescription", + }), + ]; + const inContent = ""; + const content = processor.InsertEmbeds(inContent, msg); + Chai.assert.equal(content, "\n\n----\nTestDescription"); + }); + it("processes urlless embeds properly", () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const msg = new MockMessage() as any; + msg.embeds = [ + new Discord.MessageEmbed(msg, { + description: "TestDescription", + title: "TestTitle", + }), + ]; + const inContent = ""; + const content = processor.InsertEmbeds(inContent, msg); + Chai.assert.equal(content, "\n\n----\n##### TestTitle\nTestDescription"); + }); + it("processes linked embeds properly", () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const msg = new MockMessage() as any; + msg.embeds = [ + new Discord.MessageEmbed(msg, { + description: "TestDescription", + title: "TestTitle", + url: "testurl", + }), + ]; + const inContent = ""; + const content = processor.InsertEmbeds(inContent, msg); + Chai.assert.equal(content, "\n\n----\n##### [TestTitle](testurl)\nTestDescription"); + }); + it("rejects titleless and descriptionless embeds", () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const msg = new MockMessage() as any; + msg.embeds = [ + new Discord.MessageEmbed(msg, { + url: "testurl", + }), + ]; + const inContent = "Some content..."; + const content = processor.InsertEmbeds(inContent, msg); + Chai.assert.equal(content, "Some content..."); + }); + it("processes multiple embeds properly", () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const msg = new MockMessage() as any; + msg.embeds = [ + new Discord.MessageEmbed(msg, { + description: "TestDescription", + title: "TestTitle", + url: "testurl", + }), + new Discord.MessageEmbed(msg, { + description: "TestDescription2", + title: "TestTitle2", + url: "testurl2", + }), + ]; + const inContent = ""; + const content = processor.InsertEmbeds(inContent, msg); + Chai.assert.equal( + content, +"\n\n----\n##### [TestTitle](testurl)\nTestDescription\n\n----\n##### [TestTitle2](testurl2)\nTestDescription2", + ); + }); + it("inserts embeds properly", () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const msg = new MockMessage() as any; + msg.embeds = [ + new Discord.MessageEmbed(msg, { + description: "TestDescription", + title: "TestTitle", + url: "testurl", + }), + ]; + const inContent = "Content that goes in the message"; + const content = processor.InsertEmbeds(inContent, msg); + Chai.assert.equal( + content, +`Content that goes in the message + +---- +##### [TestTitle](testurl) +TestDescription`, + ); + }); + it("adds fields properly", () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const msg = new MockMessage() as any; + msg.embeds = [ + new Discord.MessageEmbed(msg, { + description: "TestDescription", + title: "TestTitle", + url: "testurl", + }), + ]; + msg.embeds[0].fields = [ + { + embed: msg.embeds[0], + inline: false, + name: "fox", + value: "floof", + }, + ] as any; + const inContent = "Content that goes in the message"; + const content = processor.InsertEmbeds(inContent, msg); + Chai.assert.equal( + content, +`Content that goes in the message + +---- +##### [TestTitle](testurl) +TestDescription +**fox** +floof`, + ); + }); + it("adds images properly", () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const msg = new MockMessage() as any; + msg.embeds = [ + new Discord.MessageEmbed(msg, { + description: "TestDescription", + title: "TestTitle", + url: "testurl", + }), + ]; + msg.embeds[0].image = { url: "http://example.com" } as any; + const inContent = "Content that goes in the message"; + const content = processor.InsertEmbeds(inContent, msg); + Chai.assert.equal( + content, +`Content that goes in the message + +---- +##### [TestTitle](testurl) +TestDescription +Image: http://example.com`, + ); + }); + it("adds a footer properly", () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const msg = new MockMessage() as any; + msg.embeds = [ + new Discord.MessageEmbed(msg, { + description: "TestDescription", + title: "TestTitle", + url: "testurl", + }), + ]; + msg.embeds[0].footer = { text: "footer" } as any; + const inContent = "Content that goes in the message"; + const content = processor.InsertEmbeds(inContent, msg); + Chai.assert.equal( + content, +`Content that goes in the message + +---- +##### [TestTitle](testurl) +TestDescription +footer`, + ); + }); + }); + describe("Message Type", () => { + it("sets non-bot messages as m.text", async () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const msg = new MockMessage() as any; + msg.embeds = []; + msg.content = "no bot"; + msg.author.bot = false; + const result = await processor.FormatMessage(msg); + Chai.assert.equal(result.msgtype, "m.text"); + }); + it("sets bot messages as m.notice", async () => { + const processor = new DiscordMessageProcessor( + new DiscordMessageProcessorOpts("localhost"), bot as DiscordBot); + const msg = new MockMessage() as any; + msg.embeds = []; + msg.content = "a bot"; + msg.author.bot = true; + const result = await processor.FormatMessage(msg); + Chai.assert.equal(result.msgtype, "m.notice"); + }); + }); +}); diff --git a/test/test_log.ts b/test/test_log.ts new file mode 100644 index 0000000000000000000000000000000000000000..411169b4a89bf593fe3a04c50c93bb05d2da848c --- /dev/null +++ b/test/test_log.ts @@ -0,0 +1,95 @@ +/* +Copyright 2018 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 * as Chai from "chai"; +import * as Proxyquire from "proxyquire"; +import * as RealLog from "../src/log"; + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +const expect = Chai.expect; + +let createdLogger: any = null; +let loggerClosed: any = false; +let loggedMessages: any[] = []; + +const WinstonMock = { + createLogger: (format, transports) => { + return createdLogger = { + close: () => { + loggerClosed = true; + }, + format, + log: (type, ...msg) => { + loggedMessages = loggedMessages.concat(msg); + }, + silent: false, + transports, + }; + }, +}; + +const Log = (Proxyquire("../src/log", { + winston: WinstonMock, +}).Log); + +describe("Log", () => { + + beforeEach(() => { + loggerClosed = false; + loggedMessages = []; + }); + + describe("Configure", () => { + it("should pass if config is empty", () => { + Log.Configure({}); + }); + it("should set basic log options", () => { + Log.Configure({ + console: "warn", + lineDateFormat: "HH:mm:ss", + }); + expect(Log.config.console).to.equal("warn"); + expect(Log.config.lineDateFormat).to.equal("HH:mm:ss"); + expect(Log.config.files).to.be.empty; + }); + it("should setup file logging", () => { + Log.Configure({ + files: [ + { + file: "./logfile.log", + }, + ], + }); + expect(Log.config.files).to.not.be.empty; + expect(Log.config.files[0].file).to.equal("./logfile.log"); + }); + }); + describe("ForceSilent", () => { + it("should be silent", () => { + Log.ForceSilent(); + expect(createdLogger.silent).to.be.true; + expect(loggedMessages).to.contain("Log set to silent"); + }); + }); + describe("instance", () => { + it("should log without configuring", () => { + new Log("test").info("hi"); + expect(loggedMessages).to.contain("hi"); + }); + }); +}); diff --git a/test/test_matrixcommandhandler.ts b/test/test_matrixcommandhandler.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ca7aec41469f3356b45feaf9108d2fbb6dced38 --- /dev/null +++ b/test/test_matrixcommandhandler.ts @@ -0,0 +1,275 @@ +/* +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 * as Chai from "chai"; +import { Util } from "../src/util"; +import { DiscordBridgeConfig } from "../src/config"; +import { MockChannel } from "./mocks/channel"; +import * as Proxyquire from "proxyquire"; + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +const expect = Chai.expect; + +let USERSJOINED = 0; +let USERSKICKED = 0; +let USERSBANNED = 0; +let USERSUNBANNED = 0; +let MESSAGESENT: any = {}; + +function createCH(opts: any = {}) { + USERSJOINED = 0; + USERSKICKED = 0; + USERSBANNED = 0; + USERSUNBANNED = 0; + MESSAGESENT = {}; + + const bridge = { + getBot: () => { + return { + getJoinedRooms: () => ["!123:localhost"], + isRemoteUser: (id) => { + return id !== undefined && id.startsWith("@_discord_"); + }, + }; + }, + getIntent: () => { + return { + ban: async () => { USERSBANNED++; }, + getClient: () => mxClient, + join: () => { USERSJOINED++; }, + joinRoom: async () => { USERSJOINED++; }, + kick: async () => { USERSKICKED++; }, + leave: () => { }, + sendMessage: async (roomId, content) => { MESSAGESENT = content; return content; }, + unban: async () => { USERSUNBANNED++; }, + }; + }, + }; + + const config = new DiscordBridgeConfig(); + config.limits.roomGhostJoinDelay = 0; + if (opts.disableSS) { + config.bridge.enableSelfServiceBridging = false; + } else { + config.bridge.enableSelfServiceBridging = true; + } + const mxClient = { + getUserId: () => "@user:localhost", + joinRoom: async () => { + USERSJOINED++; + }, + sendReadReceipt: async () => { }, + setRoomDirectoryVisibilityAppService: async () => { }, + }; + const provisioner = { + AskBridgePermission: async () => { + if (opts.denyBridgePermission) { + throw new Error("The bridge has been declined by the Discord guild"); + } + }, + BridgeMatrixRoom: () => { + if (opts.failBridgeMatrix) { + throw new Error("Test failed matrix bridge"); + } + }, + UnbridgeChannel: async () => { + if (opts.failUnbridge) { + throw new Error("Test failed unbridge"); + } + }, + }; + const bot = { + GetBotId: () => "@botuser:localhost", + LookupRoom: async (guildid, discordid) => { + if (guildid !== "123") { + throw new Error("Guild not found"); + } else if (discordid !== "456") { + throw new Error("Channel not found"); + } + const channel = new MockChannel(); + return {channel, botUser: true }; + }, + Provisioner: provisioner, + }; + + const MatrixCommandHndl = (Proxyquire("../src/matrixcommandhandler", { + "./util": { + Util: { + CheckMatrixPermission: async () => { + return opts.power !== undefined ? opts.power : true; + }, + GetBotLink: Util.GetBotLink, + ParseCommand: Util.ParseCommand, + }, + }, + })).MatrixCommandHandler; + return new MatrixCommandHndl(bot as any, bridge, config); +} + +function createEvent(msg: string, room?: string, userId?: string) { + return { + content: { + body: msg, + }, + room_id: room ? room : "!123:localhost", + sender: userId, + }; +} + +function createContext(remoteData?: any) { + return { + rooms: { + remote: remoteData, + }, + }; +} + +describe("MatrixCommandHandler", () => { + describe("Process", () => { + it("should not process command if not in room", async () => { + const handler: any = createCH({disableSS: true}); + await handler.Process(createEvent("", "!666:localhost"), createContext()); + expect(MESSAGESENT.body).to.equal(undefined); + }); + it("should warn if self service is disabled", async () => { + const handler: any = createCH({disableSS: true}); + await handler.Process(createEvent("!discord bridge"), createContext()); + expect(MESSAGESENT.body).to.equal("**ERROR:** The owner of this bridge does " + + "not permit self-service bridging."); + }); + it("should warn if user is not powerful enough", async () => { + const handler: any = createCH({ + power: false, + }); + await handler.Process(createEvent("!discord bridge"), createContext()); + expect(MESSAGESENT.body).to.equal("**ERROR:** insufficiant permissions to use this " + + "command! Try `!discord help` to see all available commands"); + }); + describe("!discord bridge", () => { + it("will bridge a new room, and ask for permissions", async () => { + const handler: any = createCH(); + await handler.Process(createEvent("!discord bridge 123 456"), createContext()); + expect(MESSAGESENT.body).to.equal("I have bridged this room to your channel"); + }); + it("will fail to bridge if permissions were denied", async () => { + const handler: any = createCH({ + denyBridgePermission: true, + }); + await handler.Process(createEvent("!discord bridge 123 456"), createContext()); + expect(MESSAGESENT.body).to.equal("The bridge has been declined by the Discord guild"); + }); + it("will fail to bridge if permissions were failed", async () => { + const handler: any = createCH({ + failBridgeMatrix: true, + }); + const evt = await handler.Process(createEvent("!discord bridge 123 456"), createContext()); + expect(MESSAGESENT.body).to.equal("There was a problem bridging that channel - has " + + "the guild owner approved the bridge?"); + }); + it("will not bridge if a link already exists", async () => { + const handler: any = createCH(); + const evt = await handler.Process(createEvent("!discord bridge 123 456"), createContext(true)); + expect(MESSAGESENT.body).to.equal("This room is already bridged to a Discord guild."); + }); + it("will not bridge without required args", async () => { + const handler: any = createCH(); + const evt = await handler.Process(createEvent("!discord bridge"), createContext()); + expect(MESSAGESENT.body).to.contain("Invalid syntax"); + }); + it("will bridge with x/y syntax", async () => { + const handler: any = createCH({powerLevels: { + users_default: 100, + }}); + const evt = await handler.Process(createEvent("!discord bridge 123/456"), createContext()); + expect(MESSAGESENT.body).equals("I have bridged this room to your channel"); + }); + }); + describe("!discord unbridge", () => { + it("will unbridge", async () => { + const handler: any = createCH(); + await handler.Process(createEvent("!discord unbridge"), createContext( + { + data: { + discord_channel: "456", + discord_guild: "123", + plumbed: true, + }, + }, + )); + expect(MESSAGESENT.body).equals("This room has been unbridged"); + }); + it("will not unbridge if a link does not exist", async () => { + const handler: any = createCH(); + await handler.Process(createEvent("!discord unbridge"), createContext()); + expect(MESSAGESENT.body).equals("This room is not bridged."); + }); + it("will not unbridge non-plumbed rooms", async () => { + const handler: any = createCH(); + await handler.Process(createEvent("!discord unbridge"), createContext( + { + data: { + discord_channel: "456", + discord_guild: "123", + plumbed: false, + }, + }, + )); + expect(MESSAGESENT.body).equals("This room cannot be unbridged."); + }); + it("will show error if unbridge fails", async () => { + const handler: any = createCH({ + failUnbridge: true, + }); + await handler.Process(createEvent("!discord unbridge"), createContext( + { + data: { + discord_channel: "456", + discord_guild: "123", + plumbed: true, + }, + }, + )); + expect(MESSAGESENT.body).to.contain("There was an error unbridging this room."); + }); + }); + }); + describe("HandleInvite", () => { + it("should accept invite for bot user", async () => { + const handler: any = createCH(); + let joinedRoom = false; + handler.joinRoom = async () => { + joinedRoom = true; + }; + await handler.HandleInvite({ + state_key: "@botuser:localhost", + }); + expect(USERSJOINED).to.equal(1); + }); + it("should deny invite for other users", async () => { + const handler: any = createCH(); + let joinedRoom = false; + handler.joinRoom = async () => { + joinedRoom = true; + }; + await handler.HandleInvite({ + state_key: "@user:localhost", + }); + expect(joinedRoom).to.be.false; + }); + }); +}); diff --git a/test/test_matrixeventprocessor.ts b/test/test_matrixeventprocessor.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5dbf0c9f14582bd47369391de7938d4f568a628 --- /dev/null +++ b/test/test_matrixeventprocessor.ts @@ -0,0 +1,973 @@ +/* +Copyright 2018, 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 * as Chai from "chai"; +import * as Discord from "discord.js"; +import * as Proxyquire from "proxyquire"; + +import { PresenceHandler } from "../src/presencehandler"; +import { DiscordBot } from "../src/bot"; +import { MockGuild } from "./mocks/guild"; +import { MockCollection } from "./mocks/collection"; +import { MockMember } from "./mocks/member"; +import { MockEmoji } from "./mocks/emoji"; +import { MatrixEventProcessor, MatrixEventProcessorOpts } from "../src/matrixeventprocessor"; +import { DiscordBridgeConfig } from "../src/config"; +import { MockChannel } from "./mocks/channel"; +import { IMatrixEvent } from "../src/matrixtypes"; + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +const TEST_TIMESTAMP = 1337; + +const expect = Chai.expect; +// const assert = Chai.assert; +function buildRequest(eventData) { + if (eventData.unsigned === undefined) { + eventData.unsigned = {age: 0}; + } + return { + getData: () => eventData, + }; +} +const bot = { + GetIntentFromDiscordMember: (member) => { + return { + getClient: () => { + return { + + }; + }, + }; + }, +}; + +const mxClient = { + getStateEvent: async (roomId, stateType, stateKey) => { + if (stateType === "m.room.member") { + switch (stateKey) { + case "@test:localhost": + return { + avatar_url: "mxc://localhost/avatarurl", + displayname: "Test User", + }; + case "@test_short:localhost": + return { + avatar_url: "mxc://localhost/avatarurl", + displayname: "t", + }; + case "@test_long:localhost": + return { + avatar_url: "mxc://localhost/avatarurl", + displayname: "this is a very very long displayname that should be capped", + }; + } + return null; + } + return { }; + }, + mxcUrlToHttp: (url) => { + return url.replace("mxc://", "https://"); + }, +}; + +let STATE_EVENT_MSG = ""; +let USERSYNC_HANDLED = false; +let MESSAGE_PROCCESS = ""; +let KICKBAN_HANDLED = false; + +function createMatrixEventProcessor(): MatrixEventProcessor { + USERSYNC_HANDLED = false; + STATE_EVENT_MSG = ""; + MESSAGE_PROCCESS = ""; + KICKBAN_HANDLED = false; + const bridge = { + getBot: () => { + return { + isRemoteUser: (s) => s.includes("@_discord_"), + }; + }, + getClientFactory: () => { + return { + _botUserId: "@botuser:localhost", + getClientAs: () => { + return mxClient; + }, + }; + }, + getIntent: () => { + return { + getClient: () => { + return { + getUserId: () => { + return "@botuser:localhost"; + }, + }; + }, + getEvent: async (_, eventId: string) => { + if (eventId === "$goodEvent:localhost") { + return { + content: { + body: "Hello!", + }, + origin_server_ts: TEST_TIMESTAMP, + sender: "@doggo:localhost", + }; + } else if (eventId === "$reply:localhost") { + return { + content: { + "body": `> <@doggo:localhost> This is the original body + + This is the first reply`, + "formatted_body": ` +<mx-reply><blockquote><a>In Reply to</a> <a>@doggo:localhost</a> +<br>This is the original body</blockquote></mx-reply>This is the first reply`, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$goodEvent:localhost", + }, + }, + }, + sender: "@doggo:localhost", + }; + } else if (eventId === "$nontext:localhost") { + return { + content: { + something: "not texty", + }, + sender: "@doggo:localhost", + }; + } else if (eventId === "$discord:localhost") { + return { + content: { + body: "Foxies", + }, + sender: "@_discord_1234:localhost", + }; + } else if (eventId === "$image:localhost") { + return { + content: { + body: "fox.jpg", + msgtype: "m.image", + url: "mxc://fox/localhost", + }, + sender: "@fox:localhost", + }; + } else if (eventId === "$file:localhost") { + return { + content: { + body: "package.zip", + msgtype: "m.file", + url: "mxc://package/localhost", + }, + sender: "@fox:localhost", + }; + } + return { + content: {}, + }; + }, + getProfileInfo: async (userId: string) => { + if (userId !== "@doggo:localhost") { + return null; + } + return { + avatar_url: "mxc://fakeurl.com", + displayname: "Doggo!", + }; + }, + }; + }, + }; + const us = { + OnMemberState: async () => { + USERSYNC_HANDLED = true; + }, + OnUpdateUser: async () => { }, + }; + const config = new DiscordBridgeConfig(); + + const Util = Object.assign(require("../src/util").Util, { + DownloadFile: (name: string) => { + const size = parseInt(name.substring(name.lastIndexOf("/") + 1), undefined); + return Buffer.alloc(size); + }, + }); + const discordbot = { + GetBotId: () => "@botuser:localhost", + GetChannelFromRoomId: async (roomId) => { + return new MockChannel("123456"); + }, + GetDiscordUserOrMember: async (s) => { + return new Discord.User({ } as any, { username: "Someuser" }); + }, + HandleMatrixKickBan: () => { + KICKBAN_HANDLED = true; + }, + ProcessMatrixRedact: async (evt) => { + MESSAGE_PROCCESS = "redacted"; + }, + UserSyncroniser: us, + sendAsBot: async (msg, channel, event) => { + STATE_EVENT_MSG = msg; + }, + }; + + const ch = Object.assign(new (require("../src/matrixcommandhandler").MatrixCommandHandler)(bot as any, config), { + HandleInvite: async (evt) => { + MESSAGE_PROCCESS = "invited"; + }, + Process: async (evt) => { + MESSAGE_PROCCESS = "command_processed"; + }, + }); + + return new (Proxyquire("../src/matrixeventprocessor", { + "./util": { + Util, + }, + })).MatrixEventProcessor( + new MatrixEventProcessorOpts( + config, + bridge, + discordbot as any, + ), ch); +} +const mockChannel = new MockChannel(); +mockChannel.members.set("12345", new MockMember("12345", "testuser2")); + +describe("MatrixEventProcessor", () => { + describe("ProcessStateEvent", () => { + it("Should ignore unhandled states", async () => { + const processor = createMatrixEventProcessor(); + const event = { + room_id: "!someroom:localhost", + sender: "@user:localhost", + type: "m.room.nonexistant", + } as IMatrixEvent; + await processor.ProcessStateEvent(event); + expect(STATE_EVENT_MSG).to.equal(""); + }); + it("Should ignore bot user states", async () => { + const processor = createMatrixEventProcessor(); + const event = { + sender: "@botuser:localhost", + type: "m.room.member", + } as IMatrixEvent; + await processor.ProcessStateEvent(event); + expect(STATE_EVENT_MSG).to.equal(""); + }); + it("Should echo name changes", async () => { + const processor = createMatrixEventProcessor(); + const event = { + content: { + name: "Test Name", + }, + sender: "@user:localhost", + type: "m.room.name", + } as IMatrixEvent; + await processor.ProcessStateEvent(event); + expect(STATE_EVENT_MSG).to.equal("`@user:localhost` set the name to `Test Name` on Matrix."); + }); + it("Should echo topic changes", async () => { + const processor = createMatrixEventProcessor(); + const event = { + content: { + topic: "Test Topic", + }, + sender: "@user:localhost", + type: "m.room.topic", + } as IMatrixEvent; + await processor.ProcessStateEvent(event); + expect(STATE_EVENT_MSG).to.equal("`@user:localhost` set the topic to `Test Topic` on Matrix."); + }); + it("Should echo joins", async () => { + const processor = createMatrixEventProcessor(); + const event = { + content: { + membership: "join", + }, + sender: "@user:localhost", + type: "m.room.member", + unsigned: {}, + } as IMatrixEvent; + await processor.ProcessStateEvent(event); + expect(STATE_EVENT_MSG).to.equal("`@user:localhost` joined the room on Matrix."); + }); + it("Should echo invites", async () => { + const processor = createMatrixEventProcessor(); + const event = { + content: { + membership: "invite", + }, + sender: "@user:localhost", + state_key: "@user2:localhost", + type: "m.room.member", + unsigned: {}, + } as IMatrixEvent; + await processor.ProcessStateEvent(event); + expect(STATE_EVENT_MSG).to.equal("`@user:localhost` invited `@user2:localhost` to the room on Matrix."); + }); + it("Should echo kicks", async () => { + const processor = createMatrixEventProcessor(); + const event = { + content: { + membership: "leave", + }, + sender: "@user:localhost", + state_key: "@user2:localhost", + type: "m.room.member", + unsigned: {}, + } as IMatrixEvent; + await processor.ProcessStateEvent(event); + expect(STATE_EVENT_MSG).to.equal("`@user:localhost` kicked `@user2:localhost` from the room on Matrix."); + }); + it("Should echo leaves", async () => { + const processor = createMatrixEventProcessor(); + const event = { + content: { + membership: "leave", + }, + sender: "@user:localhost", + state_key: "@user:localhost", + type: "m.room.member", + unsigned: {}, + } as IMatrixEvent; + await processor.ProcessStateEvent(event); + expect(STATE_EVENT_MSG).to.equal("`@user:localhost` left the room on Matrix."); + }); + it("Should echo bans", async () => { + const processor = createMatrixEventProcessor(); + const event = { + content: { + membership: "ban", + }, + sender: "@user:localhost", + state_key: "@user2:localhost", + type: "m.room.member", + unsigned: {}, + } as IMatrixEvent; + await processor.ProcessStateEvent(event); + expect(STATE_EVENT_MSG).to.equal("`@user:localhost` banned `@user2:localhost` from the room on Matrix."); + }); + }); + describe("EventToEmbed", () => { + it("Should contain a profile.", async () => { + const processor = createMatrixEventProcessor(); + const embeds = await processor.EventToEmbed({ + content: { + body: "testcontent", + }, + sender: "@test:localhost", + } as IMatrixEvent, mockChannel as any); + const author = embeds.messageEmbed.author; + Chai.assert.equal(author!.name, "Test User"); + Chai.assert.equal(author!.icon_url, "https://localhost/avatarurl"); + Chai.assert.equal(author!.url, "https://matrix.to/#/@test:localhost"); + }); + + it("Should contain the users displayname if it exists.", async () => { + const processor = createMatrixEventProcessor(); + const embeds = await processor.EventToEmbed({ + content: { + body: "testcontent", + }, + sender: "@test:localhost", + } as IMatrixEvent, mockChannel as any); + const author = embeds.messageEmbed.author; + Chai.assert.equal(author!.name, "Test User"); + Chai.assert.equal(author!.icon_url, "https://localhost/avatarurl"); + Chai.assert.equal(author!.url, "https://matrix.to/#/@test:localhost"); + }); + + it("Should contain the users userid if the displayname is not set", async () => { + const processor = createMatrixEventProcessor(); + const embeds = await processor.EventToEmbed({ + content: { + body: "testcontent", + }, + sender: "@test_nonexistant:localhost", + } as IMatrixEvent, mockChannel as any); + const author = embeds.messageEmbed.author; + Chai.assert.equal(author!.name, "@test_nonexistant:localhost"); + Chai.assert.isUndefined(author!.icon_url); + Chai.assert.equal(author!.url, "https://matrix.to/#/@test_nonexistant:localhost"); + }); + + it("Should use the userid when the displayname is too short", async () => { + const processor = createMatrixEventProcessor(); + const embeds = await processor.EventToEmbed({ + content: { + body: "testcontent", + }, + sender: "@test_short:localhost", + } as IMatrixEvent, mockChannel as any); + const author = embeds.messageEmbed.author; + Chai.assert.equal(author!.name, "@test_short:localhost"); + }); + + it("Should use the userid when displayname is too long", async () => { + const processor = createMatrixEventProcessor(); + const embeds = await processor.EventToEmbed({ + content: { + body: "testcontent", + }, + sender: "@test_long:localhost", + } as IMatrixEvent, mockChannel as any); + const author = embeds.messageEmbed.author; + Chai.assert.equal(author!.name, "@test_long:localhost"); + }); + + it("Should cap the sender name if it is too long", async () => { + const processor = createMatrixEventProcessor(); + const embeds = await processor.EventToEmbed({ + content: { + body: "testcontent", + }, + sender: "@testwithalottosayaboutitselfthatwillgoonandonandonandon:localhost", + } as IMatrixEvent, mockChannel as any); + const author = embeds.messageEmbed.author; + Chai.assert.equal(author!.name, "@testwithalottosayaboutitselftha"); + }); + + it("Should contain the users avatar if it exists.", async () => { + const processor = createMatrixEventProcessor(); + const embeds = await processor.EventToEmbed({ + content: { + body: "testcontent", + }, + sender: "@test:localhost", + } as IMatrixEvent, mockChannel as any); + const author = embeds.messageEmbed.author; + Chai.assert.equal(author!.name, "Test User"); + Chai.assert.equal(author!.icon_url, "https://localhost/avatarurl"); + Chai.assert.equal(author!.url, "https://matrix.to/#/@test:localhost"); + }); + + it("Should remove everyone mentions.", async () => { + const processor = createMatrixEventProcessor(); + const embeds = await processor.EventToEmbed({ + content: { + body: "@everyone Hello!", + }, + sender: "@test:localhost", + } as IMatrixEvent, mockChannel as any); + Chai.assert.equal(embeds.messageEmbed.description, "@\u200Beveryone Hello!"); + }); + + it("Should remove here mentions.", async () => { + const processor = createMatrixEventProcessor(); + const embeds = await processor.EventToEmbed({ + content: { + body: "@here Hello!", + }, + sender: "@test:localhost", + } as IMatrixEvent, mockChannel as any); + Chai.assert.equal(embeds.messageEmbed.description, "@\u200Bhere Hello!"); + }); + + it("Should replace /me with * displayname, and italicize message", async () => { + const processor = createMatrixEventProcessor(); + const embeds = await processor.EventToEmbed({ + content: { + body: "likes puppies", + msgtype: "m.emote", + }, + sender: "@test:localhost", + } as IMatrixEvent, mockChannel as any); + Chai.assert.equal( + embeds.messageEmbed.description, + "_Test User likes puppies_", + ); + }); + it("Should handle stickers.", async () => { + const processor = createMatrixEventProcessor(); + const embeds = await processor.EventToEmbed({ + content: { + body: "Bunnies", + url: "mxc://bunny", + }, + sender: "@test:localhost", + type: "m.sticker", + } as IMatrixEvent, mockChannel as any); + Chai.assert.equal(embeds.messageEmbed.description, ""); + }); + it("Should ping the user on discord replies", async () => { + const processor = createMatrixEventProcessor(); + const embeds = await processor.EventToEmbed({ + content: { + "body": "Bunnies", + "m.relates_to": { + "m.in_reply_to": { + event_id: "$discord:localhost", + }, + }, + "url": "mxc://bunny", + }, + sender: "@test:localhost", + type: "m.room.member", + } as IMatrixEvent, mockChannel as any); + Chai.assert.equal(embeds.messageEmbed.description, "Bunnies\n(<@1234>)"); + }); + }); + describe("HandleAttachment", () => { + const SMALL_FILE = 200; + it("message without an attachment", async () => { + const processor = createMatrixEventProcessor(); + const ret = await processor.HandleAttachment({ + content: { + msgtype: "m.text", + }, + } as IMatrixEvent, mxClient); + expect(ret).equals(""); + }); + it("message without an info", async () => { + const processor = createMatrixEventProcessor(); + const attachment = (await processor.HandleAttachment({ + content: { + body: "filename.webm", + msgtype: "m.video", + url: "mxc://localhost/200", + }, + } as IMatrixEvent, mxClient)) as Discord.FileOptions; + expect(attachment.name).to.eq("filename.webm"); + expect(attachment.attachment.length).to.eq(SMALL_FILE); + }); + it("message without a url", async () => { + const processor = createMatrixEventProcessor(); + const ret = await processor.HandleAttachment({ + content: { + info: { + size: 1, + }, + msgtype: "m.video", + }, + } as IMatrixEvent, mxClient); + expect(ret).equals(""); + }); + it("message with a large info.size", async () => { + const LARGE_FILE = 8000000; + const processor = createMatrixEventProcessor(); + const ret = await processor.HandleAttachment({ + content: { + body: "filename.webm", + info: { + size: LARGE_FILE, + }, + msgtype: "m.video", + url: "mxc://localhost/8000000", + }, + } as IMatrixEvent, mxClient); + expect(ret).equals("[filename.webm](https://localhost/8000000)"); + }); + it("message with a small info.size", async () => { + const processor = createMatrixEventProcessor(); + const attachment = (await processor.HandleAttachment({ + content: { + body: "filename.webm", + info: { + size: SMALL_FILE, + }, + msgtype: "m.video", + url: "mxc://localhost/200", + }, + } as IMatrixEvent, mxClient)) as Discord.FileOptions; + expect(attachment.name).to.eq("filename.webm"); + expect(attachment.attachment.length).to.eq(SMALL_FILE); + }); + it("message with a small info.size but a larger file", async () => { + const processor = createMatrixEventProcessor(); + const ret = await processor.HandleAttachment({ + content: { + body: "filename.webm", + info: { + size: 200, + }, + msgtype: "m.video", + url: "mxc://localhost/8000000", + }, + } as IMatrixEvent, mxClient); + expect(ret).equals("[filename.webm](https://localhost/8000000)"); + }); + it("Should handle stickers.", async () => { + const processor = createMatrixEventProcessor(); + const attachment = (await processor.HandleAttachment({ + content: { + body: "Bunnies", + info: { + mimetype: "image/png", + }, + url: "mxc://bunny/500", + }, + sender: "@test:localhost", + type: "m.sticker", + } as IMatrixEvent, mxClient)) as Discord.FileOptions; + expect(attachment.name).to.eq("Bunnies.png"); + }); + }); + describe("GetEmbedForReply", () => { + it("should handle reply-less events", async () => { + const processor = createMatrixEventProcessor(); + const result = await processor.GetEmbedForReply({ + content: { + body: "Test", + }, + sender: "@test:localhost", + type: "m.room.message", + } as IMatrixEvent, mockChannel as any); + expect(result).to.be.undefined; + }); + it("should handle replies without a fallback", async () => { + const processor = createMatrixEventProcessor(); + const result = await processor.GetEmbedForReply({ + content: { + "body": "Test", + "m.relates_to": { + "m.in_reply_to": { + event_id: "$goodEvent:localhost", + }, + }, + }, + sender: "@test:localhost", + type: "m.room.message", + } as IMatrixEvent, mockChannel as any); + expect(result!.description).to.be.equal("Hello!"); + expect(result!.author!.name).to.be.equal("Doggo!"); + expect(result!.author!.icon_url).to.be.equal("https://fakeurl.com"); + expect(result!.author!.url).to.be.equal("https://matrix.to/#/@doggo:localhost"); + }); + it("should handle replies with a missing event", async () => { + const processor = createMatrixEventProcessor(); + const result = await processor.GetEmbedForReply({ + content: { + "body": `> <@doggo:localhost> This is the fake body + +This is where the reply goes`, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$event:thing", + }, + }, + }, + sender: "@test:localhost", + type: "m.room.message", + } as IMatrixEvent, mockChannel as any); + expect(result!.description).to.be.equal("Reply with unknown content"); + expect(result!.author!.name).to.be.equal("Unknown"); + expect(result!.author!.icon_url).to.be.undefined; + expect(result!.author!.url).to.be.undefined; + }); + it("should handle replies with a valid reply event", async () => { + const processor = createMatrixEventProcessor(); + const result = await processor.GetEmbedForReply({ + content: { + "body": `> <@doggo:localhost> This is the original body + +This is where the reply goes`, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$goodEvent:localhost", + }, + }, + }, + sender: "@test:localhost", + type: "m.room.message", + } as IMatrixEvent, mockChannel as any); + expect(result!.description).to.be.equal("Hello!"); + expect(result!.author!.name).to.be.equal("Doggo!"); + expect(result!.author!.icon_url).to.be.equal("https://fakeurl.com"); + expect(result!.author!.url).to.be.equal("https://matrix.to/#/@doggo:localhost"); + }); + it("should handle replies on top of replies", async () => { + const processor = createMatrixEventProcessor(); + const result = await processor.GetEmbedForReply({ + content: { + "body": `> <@doggo:localhost> This is the first reply + +This is the second reply`, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$reply:localhost", + }, + }, + }, + sender: "@test:localhost", + type: "m.room.message", + } as IMatrixEvent, mockChannel as any); + expect(result!.description).to.be.equal("This is the first reply"); + expect(result!.author!.name).to.be.equal("Doggo!"); + expect(result!.author!.icon_url).to.be.equal("https://fakeurl.com"); + expect(result!.author!.url).to.be.equal("https://matrix.to/#/@doggo:localhost"); + }); + it("should handle replies with non text events", async () => { + const processor = createMatrixEventProcessor(); + const result = await processor.GetEmbedForReply({ + content: { + "body": `> <@doggo:localhost> sent an image. + +This is the reply`, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$nontext:localhost", + }, + }, + }, + sender: "@test:localhost", + type: "m.room.message", + } as IMatrixEvent, mockChannel as any); + expect(result!.description).to.be.equal("Reply with unknown content"); + expect(result!.author!.name).to.be.equal("Doggo!"); + expect(result!.author!.icon_url).to.be.equal("https://fakeurl.com"); + expect(result!.author!.url).to.be.equal("https://matrix.to/#/@doggo:localhost"); + }); + it("should add the reply time", async () => { + const processor = createMatrixEventProcessor(); + const result = await processor.GetEmbedForReply({ + content: { + "body": "Test", + "m.relates_to": { + "m.in_reply_to": { + event_id: "$goodEvent:localhost", + }, + }, + }, + sender: "@test:localhost", + type: "m.room.message", + } as IMatrixEvent, mockChannel as any); + // NOTE: Due to https://github.com/discordjs/discord.js/issues/3283, the typing is wrong here. + expect(result!.timestamp!).to.be.equal(TEST_TIMESTAMP); + }); + it("should add field for discord replies", async () => { + const processor = createMatrixEventProcessor(); + const result = await processor.GetEmbedForReply({ + content: { + "body": "foxfoxfox", + "m.relates_to": { + "m.in_reply_to": { + event_id: "$discord:localhost", + }, + }, + }, + sender: "@test:localhost", + type: "m.room.message", + } as IMatrixEvent, mockChannel as any); + let foundField = false; + for (const f of result!.fields!) { + if (f.name === "ping") { + foundField = true; + expect(f.value).to.be.equal("<@1234>"); + break; + } + } + expect(foundField).to.be.true; + }); + it("should handle replies to images", async () => { + const processor = createMatrixEventProcessor(); + const result = await processor.GetEmbedForReply({ + content: { + "body": "Test", + "m.relates_to": { + "m.in_reply_to": { + event_id: "$image:localhost", + }, + }, + }, + sender: "@test:localhost", + type: "m.room.message", + } as IMatrixEvent, mockChannel as any); + expect(result!.image!.url!).to.be.equal("https://fox/localhost"); + expect(result!.description).to.be.equal("fox.jpg"); + }); + it("should handle replies to files", async () => { + const processor = createMatrixEventProcessor(); + const result = await processor.GetEmbedForReply({ + content: { + "body": "Test", + "m.relates_to": { + "m.in_reply_to": { + event_id: "$file:localhost", + }, + }, + }, + sender: "@test:localhost", + type: "m.room.message", + } as IMatrixEvent, mockChannel as any); + expect(result!.description).to.be.equal("[package.zip](https://package/localhost)"); + }); + }); + describe("OnEvent", () => { + it("should reject old events", async () => { + const AGE = 900001; // 15 * 60 * 1000 + const processor = createMatrixEventProcessor(); + await processor.OnEvent(buildRequest({unsigned: {age: AGE}}), null); + expect(MESSAGE_PROCCESS).equals(""); + }); + it("should reject un-processable events", async () => { + const AGE = 900000; // 15 * 60 * 1000 + const processor = createMatrixEventProcessor(); + // check if nothing is thrown + await processor.OnEvent(buildRequest({ + content: {}, + type: "m.potato", + unsigned: {age: AGE}}), null); + expect(MESSAGE_PROCCESS).equals(""); + }); + it("should handle own invites", async () => { + const processor = createMatrixEventProcessor(); + await processor.OnEvent(buildRequest({ + content: {membership: "invite"}, + state_key: "@botuser:localhost", + type: "m.room.member"}), null); + expect(MESSAGE_PROCCESS).to.equal("invited"); + }); + it("should handle kicks to own members", async () => { + const processor = createMatrixEventProcessor(); + await processor.OnEvent(buildRequest({ + content: {membership: "leave"}, + sender: "@badboy:localhost", + state_key: "@_discord_12345:localhost", + type: "m.room.member"}), null); + expect(KICKBAN_HANDLED).to.be.true; + }); + it("should handle bans to own members", async () => { + const processor = createMatrixEventProcessor(); + await processor.OnEvent(buildRequest({ + content: {membership: "ban"}, + sender: "@badboy:localhost", + state_key: "@_discord_12345:localhost", + type: "m.room.member"}), null); + expect(KICKBAN_HANDLED).to.be.true; + }); + it("should pass other member types to state event", async () => { + const processor = createMatrixEventProcessor(); + let stateevent = false; + processor.ProcessStateEvent = async (ev) => { + stateevent = true; + }; + await processor.OnEvent(buildRequest({ + content: {membership: "join"}, + state_key: "@bacon:localhost", + type: "m.room.member"}), null); + expect(MESSAGE_PROCCESS).to.equal(""); + expect(stateevent).to.be.true; + }); + it("should handle redactions with existing rooms", async () => { + const processor = createMatrixEventProcessor(); + const context = { + rooms: { + remote: true, + }, + }; + await processor.OnEvent(buildRequest({ + type: "m.room.redaction"}), context); + expect(MESSAGE_PROCCESS).equals("redacted"); + }); + it("should ignore redactions with no linked room", async () => { + const processor = createMatrixEventProcessor(); + const context = { + rooms: { + remote: null, + }, + }; + await processor.OnEvent(buildRequest({ + type: "m.room.redaction"}), context); + expect(MESSAGE_PROCCESS).equals(""); + }); + it("should process regular messages", async () => { + const processor = createMatrixEventProcessor(); + const context = { + rooms: { + remote: { + roomId: "_discord_123_456", + }, + }, + }; + let processed = false; + processor.ProcessMsgEvent = async (evt, _, __) => { + processed = true; + }; + await processor.OnEvent(buildRequest({ + content: {body: "abc"}, + type: "m.room.message", + }), context); + expect(MESSAGE_PROCCESS).to.equal(""); + expect(processed).to.be.true; + }); + it("should alert if encryption is turned on", async () => { + const processor = createMatrixEventProcessor(); + const context = { + rooms: { + remote: { + roomId: "_discord_123_456", + }, + }, + }; + let encrypt = false; + processor.HandleEncryptionWarning = async (evt) => { + encrypt = true; + }; + await processor.OnEvent(buildRequest({ + room_id: "!accept:localhost", + type: "m.room.encryption", + }), context); + expect(encrypt).to.be.true; + }); + it("should process !discord commands", async () => { + const processor = createMatrixEventProcessor(); + await processor.OnEvent(buildRequest({ + content: {body: "!discord cmd"}, + type: "m.room.message", + }), null); + expect(MESSAGE_PROCCESS).to.equal("command_processed"); + }); + it("should ignore regular messages with no linked room", async () => { + const processor = createMatrixEventProcessor(); + const context = { + rooms: { + remote: null, + }, + }; + await processor.OnEvent(buildRequest({ + content: {body: "abc"}, + type: "m.room.message", + }), context); + expect(MESSAGE_PROCCESS).equals(""); + }); + it("should process stickers", async () => { + const processor = createMatrixEventProcessor(); + const context = { + rooms: { + remote: { + roomId: "_discord_123_456", + }, + }, + }; + let processed = false; + processor.ProcessMsgEvent = async (evt, _, __) => { + processed = true; + }; + await processor.OnEvent(buildRequest({ + content: { + body: "abc", + url: "mxc://abc", + }, + type: "m.sticker", + }), context); + expect(processed).to.be.true; + }); + }); +}); diff --git a/test/test_matrixmessageprocessor.ts b/test/test_matrixmessageprocessor.ts new file mode 100644 index 0000000000000000000000000000000000000000..d154bfa98c55de25c00b4510d49ff08b487d3bca --- /dev/null +++ b/test/test_matrixmessageprocessor.ts @@ -0,0 +1,688 @@ +/* +Copyright 2018, 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 * as Chai from "chai"; +import * as Discord from "discord.js"; +import { MockGuild } from "./mocks/guild"; +import { MockMember } from "./mocks/member"; +import { MockChannel } from "./mocks/channel"; +import { MockEmoji } from "./mocks/emoji"; +import { DiscordBot } from "../src/bot"; +import { DbEmoji } from "../src/db/dbdataemoji"; +import { MatrixMessageProcessor } from "../src/matrixmessageprocessor"; + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +const expect = Chai.expect; + +const bot = { + GetChannelFromRoomId: async (roomId: string): Promise<MockChannel> => { + if (roomId !== "!bridged:localhost") { + throw new Error("Not bridged"); + } + return new MockChannel("1234"); + }, + GetEmojiByMxc: async (mxc: string): Promise<DbEmoji> => { + if (mxc === "mxc://real_emote:localhost") { + const emoji = new DbEmoji(); + emoji.Name = "real_emote"; + emoji.EmojiId = "123456"; + emoji.Animated = false; + emoji.MxcUrl = mxc; + return emoji; + } + throw new Error("Couldn't fetch from store"); + }, +} as any; + +function getPlainMessage(msg: string, msgtype: string = "m.text") { + return { + body: msg, + msgtype, + }; +} + +function getHtmlMessage(msg: string, msgtype: string = "m.text") { + return { + body: msg, + formatted_body: msg, + msgtype, + }; +} + +describe("MatrixMessageProcessor", () => { + describe("FormatMessage / body / simple", () => { + it("leaves blank stuff untouched", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getPlainMessage("hello world!"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("hello world!"); + }); + it("escapes simple stuff", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getPlainMessage("hello *world* how __are__ you?"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("hello \\*world\\* how \\_\\_are\\_\\_ you?"); + }); + it("escapes more complex stuff", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getPlainMessage("wow \\*this\\* is cool"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("wow \\\\\\*this\\\\\\* is cool"); + }); + it("escapes ALL the stuff", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getPlainMessage("\\ * _ ~ ` |"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("\\\\ \\* \\_ \\~ \\` \\|"); + }); + }); + describe("FormatMessage / formatted_body / simple", () => { + it("leaves blank stuff untouched", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("hello world!"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("hello world!"); + }); + it("un-escapes simple stuff", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("foxes & foxes"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("foxes & foxes"); + }); + it("converts italic formatting", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("this text is <em>italic</em> and so is <i>this one</i>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("this text is *italic* and so is *this one*"); + }); + it("converts bold formatting", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("wow some <b>bold</b> and <strong>more</strong> boldness!"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("wow some **bold** and **more** boldness!"); + }); + it("converts underline formatting", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("to be <u>underlined</u> or not to be?"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("to be __underlined__ or not to be?"); + }); + it("converts strike formatting", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("does <del>this text</del> exist?"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("does ~~this text~~ exist?"); + }); + it("converts code", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("WOW this is <code>some awesome</code> code"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("WOW this is `some awesome` code"); + }); + it("converts multiline-code", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<p>here</p><pre><code>is\ncode\n</code></pre><p>yay</p>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("here```\nis\ncode\n```yay"); + }); + it("converts multiline language code", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage(`<p>here</p> +<pre><code class="language-js">is +code +</code></pre> +<p>yay</p>`); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("here```js\nis\ncode\n```yay"); + }); + it("handles linebreaks", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("line<br>break"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("line\nbreak"); + }); + it("handles <hr>", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("test<hr>foxes"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("test\n----------\nfoxes"); + }); + it("handles headings", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage(`<h1>fox</h1> +<h2>floof</h2> +<h3>pony</h3> +<h4>hooves</h4> +<h5>tail</h5> +<h6>foxies</h6>`); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal(`**# fox** +**## floof** +**### pony** +**#### hooves** +**##### tail** +**###### foxies**`); + }); + }); + describe("FormatMessage / formatted_body / complex", () => { + it("html unescapes stuff inside of code", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<code>is <em>italic</em>?</code>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("`is <em>italic</em>?`"); + }); + it("html unescapes inside of pre", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<pre><code>wow &</code></pre>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("```\nwow &```"); + }); + it("doesn't parse inside of code", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<code>*yay*</code>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("`*yay*`"); + }); + it("doesn't parse inside of pre", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<pre><code>*yay*</code></pre>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("```\n*yay*```"); + }); + it("parses new lines", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<em>test</em><br><strong>ing</strong>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("*test*\n**ing**"); + }); + it("drops mx-reply", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<mx-reply><blockquote>message</blockquote></mx-reply>test reply"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("test reply"); + }); + it("parses links", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<a href=\"http://example.com\">link</a>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("[link](http://example.com)"); + }); + it("parses links with same content", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<a href=\"http://example.com\">http://example.com</a>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("http://example.com"); + }); + it("doesn't discord-escape links", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<a href=\"http://example.com/_blah_/\">link</a>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("[link](http://example.com/_blah_/)"); + }); + it("doesn't discord-escape links with same content", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<a href=\"http://example.com/_blah_/\">http://example.com/_blah_/</a>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("http://example.com/_blah_/"); + }); + }); + describe("FormatMessage / formatted_body / discord", () => { + it("Parses user pills", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const member = new MockMember("12345", "TestUsername", guild); + guild.members.set("12345", member); + const msg = getHtmlMessage("<a href=\"https://matrix.to/#/@_discord_12345:localhost\">TestUsername</a>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("<@12345>"); + }); + it("Ignores invalid user pills", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const member = new MockMember("12345", "TestUsername", guild); + guild.members.set("12345", member); + const msg = getHtmlMessage("<a href=\"https://matrix.to/#/@_discord_789:localhost\">TestUsername</a>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("[TestUsername](https://matrix.to/#/@_discord_789:localhost)"); + }); + it("Parses channel pills", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const channel = new MockChannel("12345", guild, "text", "SomeChannel"); + guild.channels.set("12345", channel as any); + const msg = getHtmlMessage("<a href=\"https://matrix.to/#/#_discord_1234_12345:" + + "localhost\">#SomeChannel</a>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("<#12345>"); + }); + it("Handles invalid channel pills", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const channel = new MockChannel("12345", guild, "text", "SomeChannel"); + guild.channels.set("12345", channel as any); + const msg = getHtmlMessage("<a href=\"https://matrix.to/#/#_discord_1234_789:localhost\">#SomeChannel</a>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("[#SomeChannel](https://matrix.to/#/#_discord_1234_789:localhost)"); + }); + it("Handles external channel pills", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<a href=\"https://matrix.to/#/#matrix:matrix.org\">#SomeChannel</a>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("[#SomeChannel](https://matrix.to/#/#matrix:matrix.org)"); + }); + it("Handles external channel pills of rooms that are actually bridged", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<a href=\"https://matrix.to/#/#matrix:matrix.org\">#SomeChannel</a>"); + + const result = await mp.FormatMessage(msg, guild as any, { + mxClient: { + getRoomIdForAlias: async () => { + return { + room_id: "!bridged:localhost", + }; + }, + }, + }); + expect(result).is.equal("<#1234>"); + }); + it("Ignores links without href", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<a><em>yay?</em></a>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("*yay?*"); + }); + it("Ignores links with non-matrix href", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<a href=\"http://example.com\"><em>yay?</em></a>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("[*yay?*](http://example.com)"); + }); + }); + describe("FormatMessage / formatted_body / emoji", () => { + it("Inserts emoji by name", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const emoji = new MockEmoji("123456", "test_emoji"); + guild.emojis.set("123456", emoji); + const msg = getHtmlMessage("<img alt=\"test_emoji\">"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("<:test_emoji:123456>"); + }); + it("Inserts emojis by mxc url", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const emoji = new MockEmoji("123456", "test_emoji"); + guild.emojis.set("123456", emoji); + const msg = getHtmlMessage("<img src=\"mxc://real_emote:localhost\">"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("<:test_emoji:123456>"); + }); + it("parses unknown mxc urls", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const emoji = new MockEmoji("123456", "test_emoji"); + guild.emojis.set("123456", emoji); + const msg = getHtmlMessage("<img alt=\"yay\" src=\"mxc://unreal_emote:localhost\">"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("[yay](mxc://unreal_emote:localhost)"); + }); + it("ignores with no alt / title, too", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const emoji = new MockEmoji("123456", "test_emoji"); + guild.emojis.set("123456", emoji); + const msg = getHtmlMessage("<img>"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal(""); + }); + }); + describe("FormatMessage / formatted_body / matrix", () => { + /** + * Returns a mocked matrix client that mocks the m.room.power_levels + * event to test @room notifications. + * + * @param roomNotificationLevel the power level required to @room + * (if undefined, does not include notifications.room in + * m.room.power_levels) + */ + function getMxClient(roomNotificationLevel?: number) { + return { + getStateEvent: async (roomId, stateType, _) => { + if (stateType === "m.room.power_levels") { + return { + // Only include notifications.room when + // roomNotificationLevel is undefined + ...roomNotificationLevel !== undefined && { + notifications: { + room: roomNotificationLevel, + }, + }, + users: { + "@nopower:localhost": 0, + "@power:localhost": 100, + }, + }; + } + return null; + }, + }; + } + + /** + * Explicit power level required to notify @room. + * + * Essentially, we want to test two code paths - one where the explicit + * power level is set and one where it isn't, to see if the bridge can + * fall back to a default level (of 50). This is the explicit value we + * will set. + */ + const ROOM_NOTIFICATION_LEVEL = 50; + + it("escapes @everyone", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getPlainMessage("hey @everyone"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("hey @\u200Beveryone"); + }); + it("escapes @here", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getPlainMessage("hey @here"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("hey @\u200Bhere"); + }); + it("converts @room to @here, if sufficient power", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getPlainMessage("hey @room"); + let params = { + mxClient: getMxClient(ROOM_NOTIFICATION_LEVEL), + roomId: "!123456:localhost", + userId: "@power:localhost", + }; + let result = await mp.FormatMessage(msg, guild as any, params as any); + expect(result).is.equal("hey @here"); + + // Test again using an unset notifications.room value in + // m.room.power_levels, to ensure it falls back to a default + // requirement. + params = { + mxClient: getMxClient(), + roomId: "!123456:localhost", + userId: "@power:localhost", + }; + result = await mp.FormatMessage(msg, guild as any, params as any); + expect(result).is.equal("hey @here"); + }); + it("ignores @room to @here conversion, if insufficient power", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getPlainMessage("hey @room"); + let params = { + mxClient: getMxClient(ROOM_NOTIFICATION_LEVEL), + roomId: "!123456:localhost", + userId: "@nopower:localhost", + }; + let result = await mp.FormatMessage(msg, guild as any, params as any); + expect(result).is.equal("hey @room"); + + // Test again using an unset notifications.room value in + // m.room.power_levels, to ensure it falls back to a default + // requirement. + params = { + mxClient: getMxClient(), + roomId: "!123456:localhost", + userId: "@nopower:localhost", + }; + result = await mp.FormatMessage(msg, guild as any, params as any); + expect(result).is.equal("hey @room"); + }); + it("handles /me for normal names", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getPlainMessage("floofs", "m.emote"); + const params = { + displayname: "fox", + }; + const result = await mp.FormatMessage(msg, guild as any, params as any); + expect(result).is.equal("_fox floofs_"); + }); + it("handles /me for short names", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getPlainMessage("floofs", "m.emote"); + const params = { + displayname: "f", + }; + const result = await mp.FormatMessage(msg, guild as any, params as any); + expect(result).is.equal("_floofs_"); + }); + it("handles /me for long names", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getPlainMessage("floofs", "m.emote"); + const params = { + displayname: "foxfoxfoxfoxfoxfoxfoxfoxfoxfoxfoxfox", + }; + const result = await mp.FormatMessage(msg, guild as any, params as any); + expect(result).is.equal("_floofs_"); + }); + it("discord escapes nicks in /me", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getPlainMessage("floofs", "m.emote"); + const params = { + displayname: "fox_floof", + }; + const result = await mp.FormatMessage(msg, guild as any, params as any); + expect(result).is.equal("_fox\\_floof floofs_"); + }); + }); + describe("FormatMessage / formatted_body / blockquotes", () => { + it("parses single blockquotes", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<blockquote>hey</blockquote>there"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("> hey\n\nthere"); + }); + it("parses double blockquotes", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<blockquote><blockquote>hey</blockquote>you</blockquote>there"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("> > hey\n> \n> you\n\nthere"); + }); + it("parses blockquotes with <p>", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage("<blockquote>\n<p>spoky</p>\n</blockquote>\n<p>test</p>\n"); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("> spoky\n\ntest"); + }); + it("parses double blockquotes with <p>", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage(`<blockquote> +<blockquote> +<p>spoky</p> +</blockquote> +<p>testing</p> +</blockquote> +<p>test</p> +`); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("> > spoky\n> \n> testing\n\ntest"); + }); + }); + describe("FormatMessage / formatted_body / lists", () => { + it("parses simple unordered lists", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage(`<p>soru</p> +<ul> +<li>test</li> +<li>ing</li> +</ul> +<p>more</p> +`); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("soru\n● test\n● ing\n\nmore"); + }); + it("parses nested unordered lists", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage(`<p>foxes</p> +<ul> +<li>awesome</li> +<li>floofy +<ul> +<li>fur</li> +<li>tail</li> +</ul> +</li> +</ul> +<p>yay!</p> +`); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("foxes\n● awesome\n● floofy\n ○ fur\n ○ tail\n\nyay!"); + }); + it("parses more nested unordered lists", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage(`<p>foxes</p> +<ul> +<li>awesome</li> +<li>floofy +<ul> +<li>fur</li> +<li>tail</li> +</ul> +</li> +<li>cute</li> +</ul> +<p>yay!</p> +`); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("foxes\n● awesome\n● floofy\n ○ fur\n ○ tail\n● cute\n\nyay!"); + }); + }); + it("parses simple ordered lists", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage(`<p>oookay</p> +<ol> +<li>test</li> +<li>test more</li> +</ol> +<p>ok?</p> +`); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("oookay\n1. test\n2. test more\n\nok?"); + }); + it("parses nested ordered lists", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage(`<p>and now</p> +<ol> +<li>test</li> +<li>test more +<ol> +<li>and more</li> +<li>more?</li> +</ol> +</li> +<li>done!</li> +</ol> +<p>ok?</p> +`); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("and now\n1. test\n2. test more\n 1. and more\n 2. more?\n3. done!\n\nok?"); + }); + it("parses ordered lists with different start", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage(`<ol start="5"> +<li>test</li> +<li>test more</li> +</ol>`); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("\n5. test\n6. test more"); + }); + it("parses ul in ol", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage(`<ol> +<li>test</li> +<li>test more +<ul> +<li>asdf</li> +<li>jklö</li> +</ul> +</li> +</ol>`); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("\n1. test\n2. test more\n ○ asdf\n ○ jklö"); + }); + it("parses ol in ul", async () => { + const mp = new MatrixMessageProcessor(bot); + const guild = new MockGuild("1234"); + const msg = getHtmlMessage(`<ul> +<li>test</li> +<li>test more +<ol> +<li>asdf</li> +<li>jklö</li> +</ol> +</li> +</ul>`); + const result = await mp.FormatMessage(msg, guild as any); + expect(result).is.equal("\n● test\n● test more\n 1. asdf\n 2. jklö"); + }); +}); diff --git a/test/test_matrixroomhandler.ts b/test/test_matrixroomhandler.ts new file mode 100644 index 0000000000000000000000000000000000000000..d8fa8d04ab87923b35586e366cb775d55d3fe894 --- /dev/null +++ b/test/test_matrixroomhandler.ts @@ -0,0 +1,358 @@ +/* +Copyright 2018, 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 * as Chai from "chai"; +import * as Proxyquire from "proxyquire"; +import { DiscordBridgeConfig } from "../src/config"; +import { MockChannel } from "./mocks/channel"; +import { MockMember } from "./mocks/member"; +import { MockGuild } from "./mocks/guild"; +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 */ + +const expect = Chai.expect; + +const RoomHandler = (Proxyquire("../src/matrixroomhandler", { + "./util": { + Util: { + DelayedPromise: Util.DelayedPromise, + GetMxidFromName: () => { + return "@123456:localhost"; + }, + MsgToArgs: Util.MsgToArgs, + ParseCommand: Util.ParseCommand, + }, + }, +})).MatrixRoomHandler; + +let USERSJOINED = 0; +let USERSKICKED = 0; +let USERSBANNED = 0; +let USERSUNBANNED = 0; +let MESSAGESENT: any = {}; +let USERSYNC_HANDLED = false; +let KICKBAN_HANDLED = false; +let MESSAGE_PROCCESS = ""; + +function createRH(opts: any = {}) { + USERSJOINED = 0; + USERSKICKED = 0; + USERSBANNED = 0; + USERSUNBANNED = 0; + MESSAGESENT = {}; + USERSYNC_HANDLED = false; + KICKBAN_HANDLED = false; + MESSAGE_PROCCESS = ""; + const bridge = { + getBot: () => { + return { + getJoinedRooms: () => ["!123:localhost"], + isRemoteUser: (id) => { + return id !== undefined && id.startsWith("@_discord_"); + }, + }; + }, + getIntent: () => { + return { + ban: async () => { USERSBANNED++; }, + getClient: () => mxClient, + getEvent: () => ({ content: { } }), + join: () => { USERSJOINED++; }, + kick: async () => { USERSKICKED++; }, + leave: () => { }, + sendMessage: async (roomId, content) => { MESSAGESENT = content; return content; }, + unban: async () => { USERSUNBANNED++; }, + }; + }, + }; + const us = { + JoinRoom: async () => { USERSJOINED++; }, + OnMemberState: async () => { + USERSYNC_HANDLED = true; + }, + OnUpdateUser: async () => { }, + }; + const cs = { + GetRoomIdsFromChannel: async (chan) => { + return [`#${chan.id}:localhost`]; + }, + OnUpdate: async () => { }, + }; + const bot = { + BotUserId: "@botuser:localhost", + ChannelSyncroniser: cs, + GetBotId: () => "bot12345", + GetChannelFromRoomId: async (roomid: string) => { + if (roomid === "!accept:localhost") { + const guild = new MockGuild("666666"); + const chan = new MockChannel("777777", guild); + if (opts.createMembers) { + chan.members.set("12345", new MockMember("12345", "testuser1")); + chan.members.set("54321", new MockMember("54321", "testuser2")); + chan.members.set("bot12345", new MockMember("bot12345", "botuser")); + } + guild.members = chan.members; + return chan; + } else { + throw new Error("Roomid not found"); + } + }, + GetGuilds: () => [new MockGuild("123", [])], + GetIntentFromDiscordMember: () => { + return bridge.getIntent(); + }, + HandleMatrixKickBan: async () => { + KICKBAN_HANDLED = true; + }, + LookupRoom: async (guildid, discordid) => { + if (guildid !== "123") { + throw new Error("Guild not found"); + } else if (discordid !== "456") { + throw new Error("Channel not found"); + } + const channel = new MockChannel(); + return {channel, botUser: true }; + }, + ProcessMatrixMsgEvent: async () => { + MESSAGE_PROCCESS = "processed"; + }, + ProcessMatrixRedact: async () => { + MESSAGE_PROCCESS = "redacted"; + }, + ProcessMatrixStateEvent: async () => { + MESSAGE_PROCCESS = "stateevent"; + }, + ThirdpartySearchForChannels: () => { + return []; + }, + UserSyncroniser: us, + }; + const config = new DiscordBridgeConfig(); + config.limits.roomGhostJoinDelay = 0; + if (opts.disableSS) { + config.bridge.enableSelfServiceBridging = false; + } else { + config.bridge.enableSelfServiceBridging = true; + } + const mxClient = { + getStateEvent: async () => { + return opts.powerLevels || {}; + }, + getUserId: () => "@user:localhost", + joinRoom: async () => { + USERSJOINED++; + }, + sendReadReceipt: async () => { }, + setRoomDirectoryVisibilityAppService: async () => { }, + }; + const provisioner = { + AskBridgePermission: async () => { + if (opts.denyBridgePermission) { + throw new Error("The bridge has been declined by the Discord guild"); + } + }, + BridgeMatrixRoom: () => { + if (opts.failBridgeMatrix) { + throw new Error("Test failed matrix bridge"); + } + }, + UnbridgeRoom: async () => { + if (opts.failUnbridge) { + throw new Error("Test failed unbridge"); + } + }, + }; + const store = { + getEntriesByMatrixId: (matrixId) => { + return [{ + matrix: {}, + remote: {}, + }]; + }, + linkRooms: () => { + + }, + removeEntriesByMatrixRoomId: () => { + + }, + }; + const handler = new RoomHandler(bot as any, config, provisioner as any, bridge as any, store); + return handler; +} + +describe("MatrixRoomHandler", () => { + describe("OnAliasQueried", () => { + it("should join successfully", async () => { + const handler = createRH(); + await handler.OnAliasQueried("#accept:localhost", "!accept:localhost"); + }); + it("should join successfully and create ghosts", async () => { + const EXPECTEDUSERS = 2; + const handler = createRH({createMembers: true}); + await handler.OnAliasQueried("#accept:localhost", "!accept:localhost"); + expect(USERSJOINED).to.equal(EXPECTEDUSERS); + }); + it("should not join successfully", async () => { + const handler = createRH(); + try { + await handler.OnAliasQueried("#reject:localhost", "!reject:localhost"); + throw new Error("didn't fail"); + } catch (e) { + expect(e.message).to.not.equal("didn't fail"); + } + }); + }); + describe("OnAliasQuery", () => { + it("will create room", async () => { + const handler: any = createRH({}); + handler.createMatrixRoom = () => true; + const ret = await handler.OnAliasQuery( + "_discord_123_456:localhost", + "_discord_123_456"); + expect(ret).to.be.true; + }); + it("will not create room if guild cannot be found", async () => { + const handler: any = createRH({}); + handler.createMatrixRoom = () => true; + const ret = await handler.OnAliasQuery( + "_discord_111_456:localhost", + "_discord_111_456"); + expect(ret).to.be.undefined; + }); + it("will not create room if channel cannot be found", async () => { + const handler: any = createRH({}); + handler.createMatrixRoom = () => true; + const ret = await handler.OnAliasQuery( + "_discord_123_444:localhost", + "_discord_123_444"); + expect(ret).to.be.undefined; + }); + it("will not create room if alias is wrong", async () => { + const handler: any = createRH({}); + handler.createMatrixRoom = () => true; + const ret = await handler.OnAliasQuery( + "_discord_123:localhost", + "_discord_123"); + expect(ret).to.be.undefined; + }); + }); + describe("tpGetProtocol", () => { + it("will return an object", async () => { + const handler: any = createRH({}); + const protocol = await handler.tpGetProtocol(""); + expect(protocol).to.not.be.null; + expect(protocol.instances[0].network_id).to.equal("123"); + expect(protocol.instances[0].bot_user_id).to.equal("@botuser:localhost"); + expect(protocol.instances[0].desc).to.equal("123"); + expect(protocol.instances[0].network_id).to.equal("123"); + }); + }); + describe("tpGetLocation", () => { + it("will return an array", async () => { + const handler: any = createRH({}); + const channels = await handler.tpGetLocation("", { + channel_name: "", + guild_id: "", + }); + expect(channels).to.be.a("array"); + }); + }); + describe("tpParseLocation", () => { + it("will reject", async () => { + const handler: any = createRH({}); + try { + await handler.tpParseLocation("alias"); + throw new Error("didn't fail"); + } catch (e) { + expect(e.message).to.not.equal("didn't fail"); + } + }); + }); + describe("tpGetUser", () => { + it("will reject", async () => { + const handler: any = createRH({}); + try { + await handler.tpGetUser("", {}); + throw new Error("didn't fail"); + } catch (e) { + expect(e.message).to.not.equal("didn't fail"); + } + }); + }); + describe("tpParseUser", () => { + it("will reject", async () => { + const handler: any = createRH({}); + try { + await handler.tpParseUser("alias"); + throw new Error("didn't fail"); + } catch (e) { + expect(e.message).to.not.equal("didn't fail"); + } + }); + }); + describe("joinRoom", () => { + it("will join immediately", async () => { + const handler: any = createRH({}); + const intent = { + getClient: () => { + return { + joinRoom: async () => { }, + }; + }, + }; + const startTime = Date.now(); + const MAXTIME = 1000; + await handler.joinRoom(intent, "#test:localhost"); + expect(1).to.satisfy(() => { + return (Date.now() - startTime) < MAXTIME; + }); + }); + it("will fail first, join after", async () => { + const handler: any = createRH({}); + let shouldFail = true; + const intent = { + getClient: () => { + return { + getUserId: () => "@test:localhost", + joinRoom: async () => { + if (shouldFail) { + shouldFail = false; + throw new Error("Test failed first time"); + } + }, + }; + }, + }; + const startTime = Date.now(); + const MINTIME = 1000; + await handler.joinRoom(intent, "#test:localhost"); + expect(shouldFail).to.be.false; + expect(1).to.satisfy(() => { + return (Date.now() - startTime) > MINTIME; + }); + }); + }); + describe("createMatrixRoom", () => { + it("will return an object", async () => { + const handler: any = createRH({}); + const channel = new MockChannel("123", new MockGuild("456")); + const roomOpts = await handler.createMatrixRoom(channel, "#test:localhost"); + expect(roomOpts.creationOpts).to.exist; + }); + }); +}); diff --git a/test/test_presencehandler.ts b/test/test_presencehandler.ts new file mode 100644 index 0000000000000000000000000000000000000000..fbd373ad23f8c3dba86cb38a8bff11040bc0ff3a --- /dev/null +++ b/test/test_presencehandler.ts @@ -0,0 +1,186 @@ +/* +Copyright 2017, 2018 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 * as Chai from "chai"; +import * as Discord from "discord.js"; +import * as Proxyquire from "proxyquire"; + +import { PresenceHandler } from "../src/presencehandler"; +import { DiscordBot } from "../src/bot"; +import { MockUser } from "./mocks/user"; + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +const expect = Chai.expect; +const INTERVAL = 250; +let lastStatus = null; +// const assert = Chai.assert; +const bot = { + GetBotId: () => { + return "1234"; + }, + GetIntentFromDiscordMember: (member) => { + return { + getClient: () => { + return { + setPresence: async (status) => { + lastStatus = status; + }, + }; + }, + }; + }, +}; + +describe("PresenceHandler", () => { + describe("init", () => { + it("constructor", () => { + const handler = new PresenceHandler(bot as DiscordBot); + }); + }); + describe("Stop", () => { + it("should start and stop without errors", async () => { + const handler = new PresenceHandler(bot as DiscordBot); + await handler.Start(INTERVAL); + handler.Stop(); + }); + }); + describe("EnqueueUser", () => { + it("adds a user properly", () => { + const handler = new PresenceHandler(bot as DiscordBot); + const COUNT = 2; + handler.EnqueueUser(new MockUser("abc", "def") as any); + handler.EnqueueUser(new MockUser("123", "ghi") as any); + Chai.assert.equal(handler.QueueCount, COUNT); + }); + it("does not add duplicate users", () => { + const handler = new PresenceHandler(bot as DiscordBot); + handler.EnqueueUser(new MockUser("abc", "def") as any); + handler.EnqueueUser(new MockUser("abc", "def") as any); + Chai.assert.equal(handler.QueueCount, 1); + }); + it("does not add the bot user", () => { + const handler = new PresenceHandler(bot as DiscordBot); + handler.EnqueueUser(new MockUser("1234", "def") as any); + Chai.assert.equal(handler.QueueCount, 0); + }); + }); + describe("DequeueUser", () => { + it("removes users properly", () => { + const handler = new PresenceHandler(bot as DiscordBot); + const members = [ + new MockUser("abc", "def") as any, + new MockUser("def", "ghi") as any, + new MockUser("ghi", "wew") as any, + ]; + handler.EnqueueUser(members[0]); + handler.EnqueueUser(members[1]); + handler.EnqueueUser(members[members.length - 1]); + + handler.DequeueUser(members[members.length - 1]); + Chai.assert.equal(handler.QueueCount, members.length - 1); + handler.DequeueUser(members[1]); + Chai.assert.equal(handler.QueueCount, 1); + handler.DequeueUser(members[0]); + Chai.assert.equal(handler.QueueCount, 0); + }); + }); + describe("ProcessUser", () => { + it("processes an online user", async () => { + lastStatus = null; + const handler = new PresenceHandler(bot as DiscordBot); + const member = new MockUser("abc", "def") as any; + member.MockSetPresence(new Discord.Presence({ + status: "online", + }, {} as any)); + await handler.ProcessUser(member); + Chai.assert.deepEqual(lastStatus, { + presence: "online", + }); + }); + it("processes an offline user", async () => { + lastStatus = null; + const handler = new PresenceHandler(bot as DiscordBot); + const member = new MockUser("abc", "def") as any; + member.MockSetPresence(new Discord.Presence({ + status: "offline", + }, {} as any)); + await handler.ProcessUser(member); + Chai.assert.deepEqual(lastStatus, { + presence: "offline", + }); + + }); + it("processes an idle user", async () => { + lastStatus = null; + const handler = new PresenceHandler(bot as DiscordBot); + const member = new MockUser("abc", "def") as any; + member.MockSetPresence(new Discord.Presence({ + status: "idle", + }, {} as any)); + await handler.ProcessUser(member); + Chai.assert.deepEqual(lastStatus, { + presence: "unavailable", + }); + }); + it("processes an dnd user", async () => { + lastStatus = null; + const handler = new PresenceHandler(bot as DiscordBot); + const member = new MockUser("abc", "def") as any; + member.MockSetPresence(new Discord.Presence({ + status: "dnd", + }, {} as any)); + await handler.ProcessUser(member); + Chai.assert.deepEqual(lastStatus, { + presence: "online", + status_msg: "Do not disturb", + }); + member.MockSetPresence(new Discord.Presence({ + game: new Discord.Game({name: "Test Game"}, {} as any), + status: "dnd", + }, {} as any)); + await handler.ProcessUser(member); + Chai.assert.deepEqual(lastStatus, { + presence: "online", + status_msg: "Do not disturb | Playing Test Game", + }); + }); + it("processes a user playing games", async () => { + lastStatus = null; + const handler = new PresenceHandler(bot as DiscordBot); + const member = new MockUser("abc", "def") as any; + member.MockSetPresence(new Discord.Presence({ + game: new Discord.Game({name: "Test Game"}, {} as any), + status: "online", + }, {} as any)); + await handler.ProcessUser(member); + Chai.assert.deepEqual(lastStatus, { + presence: "online", + status_msg: "Playing Test Game", + }); + member.MockSetPresence(new Discord.Presence({ + game: new Discord.Game({name: "Test Game", type: 1}, {} as any), + status: "online", + }, {} as any)); + await handler.ProcessUser(member); + Chai.assert.deepEqual(lastStatus, { + presence: "online", + status_msg: "Streaming Test Game", + }); + }); + }); +}); diff --git a/test/test_provisioner.ts b/test/test_provisioner.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f595e40416734dd5de6e189b5630b9a2264ea00 --- /dev/null +++ b/test/test_provisioner.ts @@ -0,0 +1,76 @@ +/* +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 * as Chai from "chai"; +import { Provisioner } from "../src/provisioner"; +import { MockChannel } from "./mocks/channel"; +import { MockMember } from "./mocks/member"; + +// we are a test file and thus need those +/* tslint:disable:no-any */ + +const expect = Chai.expect; + +const TIMEOUT_MS = 1000; + +describe("Provisioner", () => { + describe("AskBridgePermission", () => { + it("should fail to bridge a room that timed out", async () => { + const p = new Provisioner({} as any, {} as any); + const startAt = Date.now(); + try { + await p.AskBridgePermission( + new MockChannel("foo", "bar") as any, + "Mark", + TIMEOUT_MS, + ); + throw Error("Should have thrown an error"); + } catch (err) { + expect(err.message).to.eq("Timed out waiting for a response from the Discord owners"); + const delay = Date.now() - startAt; + if (delay < TIMEOUT_MS) { + throw Error(`Should have waited for timeout before resolving, waited: ${delay}ms`); + } + } + }); + it("should fail to bridge a room that was declined", async () => { + const p = new Provisioner({} as any, {} as any); + const promise = p.AskBridgePermission( + new MockChannel("foo", "bar") as any, + "Mark", + TIMEOUT_MS, + ); + await p.MarkApproved(new MockChannel("foo", "bar") as any, new MockMember("abc", "Mark") as any, false); + try { + await promise; + throw Error("Should have thrown an error"); + } catch (err) { + expect(err.message).to.eq("The bridge has been declined by the Discord guild"); + } + + }); + it("should bridge a room that was approved", async () => { + const p = new Provisioner({} as any, {} as any); + const promise = p.AskBridgePermission( + new MockChannel("foo", "bar") as any, + "Mark", + TIMEOUT_MS, + ); + await p.MarkApproved(new MockChannel("foo", "bar") as any, new MockMember("abc", "Mark") as any, true); + expect(await promise).to.eq("Approved"); + }); + }); +}); diff --git a/test/test_store.ts b/test/test_store.ts new file mode 100644 index 0000000000000000000000000000000000000000..6c286408ef2d516dc2ec1467addfab42029a58c8 --- /dev/null +++ b/test/test_store.ts @@ -0,0 +1,180 @@ +/* +Copyright 2017, 2018 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 * as Chai from "chai"; +// import * as Proxyquire from "proxyquire"; +import { DiscordStore, CURRENT_SCHEMA } from "../src/store"; +import { DbEmoji } from "../src/db/dbdataemoji"; +import { DbEvent } from "../src/db/dbdataevent"; +import { Log } from "../src/log"; + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +const expect = Chai.expect; + +const TEST_SCHEMA = CURRENT_SCHEMA; + +// const assert = Chai.assert; + +describe("DiscordStore", () => { + describe("init", () => { + it("can create a db", async () => { + const store = new DiscordStore(":memory:"); + return store.init(); + }); + }); + describe("addUserToken", () => { + it("should not throw when adding an entry", async () => { + const store = new DiscordStore(":memory:"); + await store.init(); + await store.addUserToken("userid", "token", "discordid"); + }); + }); + describe("Get|Insert|Update<DbEmoji>", () => { + it("should insert successfully", async () => { + const store = new DiscordStore(":memory:"); + await store.init(); + const emoji = new DbEmoji(); + emoji.EmojiId = "123"; + emoji.Animated = false; + emoji.Name = "TestEmoji"; + emoji.MxcUrl = "TestUrl"; + await store.Insert(emoji); + }); + it("should get successfully", async () => { + const store = new DiscordStore(":memory:"); + await store.init(); + const insertEmoji = new DbEmoji(); + insertEmoji.EmojiId = "123"; + insertEmoji.Animated = false; + insertEmoji.Name = "TestEmoji"; + insertEmoji.MxcUrl = "TestUrl"; + await store.Insert(insertEmoji); + const getEmoji = await store.Get(DbEmoji, {emoji_id: "123"}); + Chai.assert.equal(getEmoji!.Name, "TestEmoji"); + Chai.assert.equal(getEmoji!.MxcUrl, "TestUrl"); + }); + it("should not return nonexistant emoji", async () => { + const store = new DiscordStore(":memory:"); + await store.init(); + const getEmoji = await store.Get(DbEmoji, {emoji_id: "123"}); + Chai.assert.isFalse(getEmoji!.Result); + }); + it("should update successfully", async () => { + const store = new DiscordStore(":memory:"); + await store.init(); + const insertEmoji = new DbEmoji(); + insertEmoji.EmojiId = "123"; + insertEmoji.Animated = false; + insertEmoji.Name = "TestEmoji"; + insertEmoji.MxcUrl = "TestUrl"; + await store.Insert(insertEmoji); + insertEmoji.EmojiId = "123"; + insertEmoji.Animated = false; + insertEmoji.Name = "TestEmoji2"; + insertEmoji.MxcUrl = "NewURL"; + await store.Update(insertEmoji); + const getEmoji = await store.Get(DbEmoji, {emoji_id: "123"}); + Chai.assert.equal(getEmoji!.Name, "TestEmoji2"); + Chai.assert.equal(getEmoji!.MxcUrl, "NewURL"); + Chai.assert.notEqual(getEmoji!.CreatedAt, getEmoji!.UpdatedAt); + }); + }); + describe("Get|Insert|Delete<DbEvent>", () => { + it("should insert successfully", async () => { + const store = new DiscordStore(":memory:"); + await store.init(); + const event = new DbEvent(); + event.MatrixId = "123"; + event.DiscordId = "456"; + event.GuildId = "123"; + event.ChannelId = "123"; + await store.Insert(event); + }); + it("should get successfully", async () => { + const store = new DiscordStore(":memory:"); + await store.init(); + const event = new DbEvent(); + event.MatrixId = "123"; + event.DiscordId = "456"; + event.GuildId = "123"; + event.ChannelId = "123"; + await store.Insert(event); + const getEventDiscord = await store.Get(DbEvent, {discord_id: "456"}); + getEventDiscord!.Next(); + Chai.assert.equal(getEventDiscord!.MatrixId, "123"); + Chai.assert.equal(getEventDiscord!.DiscordId, "456"); + Chai.assert.equal(getEventDiscord!.GuildId, "123"); + Chai.assert.equal(getEventDiscord!.ChannelId, "123"); + const getEventMatrix = await store.Get(DbEvent, {matrix_id: "123"}); + getEventMatrix!.Next(); + Chai.assert.equal(getEventMatrix!.MatrixId, "123"); + Chai.assert.equal(getEventMatrix!.DiscordId, "456"); + Chai.assert.equal(getEventMatrix!.GuildId, "123"); + Chai.assert.equal(getEventMatrix!.ChannelId, "123"); + }); + const MSG_COUNT = 5; + it("should get multiple discord msgs successfully", async () => { + const store = new DiscordStore(":memory:"); + await store.init(); + for (let i = 0; i < MSG_COUNT; i++) { + const event = new DbEvent(); + event.MatrixId = "123"; + event.DiscordId = "456" + i; + event.GuildId = "123"; + event.ChannelId = "123"; + await store.Insert(event); + } + const getEventDiscord = await store.Get(DbEvent, {matrix_id: "123"}); + Chai.assert.equal(getEventDiscord!.ResultCount, MSG_COUNT); + }); + it("should get multiple matrix msgs successfully", async () => { + const store = new DiscordStore(":memory:"); + await store.init(); + for (let i = 0; i < MSG_COUNT; i++) { + const event = new DbEvent(); + event.MatrixId = "123" + i; + event.DiscordId = "456"; + event.GuildId = "123"; + event.ChannelId = "123"; + await store.Insert(event); + } + const getEventMatrix = await store.Get(DbEvent, {discord_id: "456"}); + Chai.assert.equal(getEventMatrix!.ResultCount, MSG_COUNT); + }); + it("should not return nonexistant event", async () => { + const store = new DiscordStore(":memory:"); + await store.init(); + const getMessage = await store.Get(DbEvent, {matrix_id: "123"}); + Chai.assert.isFalse(getMessage!.Result); + }); + it("should delete successfully", async () => { + const store = new DiscordStore(":memory:"); + await store.init(); + const event = new DbEvent(); + event.MatrixId = "123"; + event.DiscordId = "456"; + event.GuildId = "123"; + event.ChannelId = "123"; + await store.Insert(event); + await store.Delete(event); + const getEvent = await store.Get(DbEvent, {matrix_id: "123"}); + getEvent!.Next(); + Chai.assert.isFalse(getEvent!.Result); + }); + }); +}); diff --git a/test/test_usersyncroniser.ts b/test/test_usersyncroniser.ts new file mode 100644 index 0000000000000000000000000000000000000000..531f3e86e1ded68fb2848882d750c9e195f128b9 --- /dev/null +++ b/test/test_usersyncroniser.ts @@ -0,0 +1,603 @@ +/* +Copyright 2018 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 * as Chai from "chai"; +import { Bridge } from "matrix-appservice-bridge"; +import {IGuildMemberState, IUserState, UserSyncroniser} from "../src/usersyncroniser"; +import {MockUser} from "./mocks/user"; +import {DiscordBridgeConfig} from "../src/config"; +import * as Proxyquire from "proxyquire"; +import {MockMember} from "./mocks/member"; +import {MockGuild} from "./mocks/guild"; +import { MockChannel } from "./mocks/channel"; +import { MockRole } from "./mocks/role"; +import { IMatrixEvent } from "../src/matrixtypes"; +import { Util } from "../src/util"; +import { RemoteUser } from "../src/db/userstore"; + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ +const expect = Chai.expect; + +let DISPLAYNAME_SET: any = null; +let AVATAR_SET: any = null; +let REMOTEUSER_SET: any = null; +let INTENT_ID: any = null; +let LINK_MX_USER: any = null; +let LINK_RM_USER: any = null; +let UTIL_UPLOADED_AVATAR: any = false; + +let SEV_ROOM_ID: any = null; +let SEV_CONTENT: any = null; +let SEV_KEY: any = null; +let JOIN_ROOM_ID: any = null; +let LEAVE_ROOM_ID: any = null; +let JOINS: any = 0; +let LEAVES: any = 0; +let SEV_COUNT: any = 0; + +const GUILD_ROOM_IDS = ["!abc:localhost", "!def:localhost", "!ghi:localhost"]; +const GUILD_ROOM_IDS_WITH_ROLE = ["!abc:localhost", "!def:localhost"]; + +const UserSync = (Proxyquire("../src/usersyncroniser", { + "./util": { + Util: { + ApplyPatternString: Util.ApplyPatternString, + AsyncForEach: Util.AsyncForEach, + UploadContentFromUrl: async () => { + UTIL_UPLOADED_AVATAR = true; + return {mxcUrl: "avatarset"}; + }, + }, + }, +})).UserSyncroniser; + +function CreateUserSync(remoteUsers: RemoteUser[] = [], ghostConfig: any = {}): UserSyncroniser { + UTIL_UPLOADED_AVATAR = false; + SEV_ROOM_ID = null; + SEV_CONTENT = null; + SEV_KEY = null; + SEV_COUNT = 0; + const bridge: any = { + getIntent: (id) => { + DISPLAYNAME_SET = null; + AVATAR_SET = null; + INTENT_ID = id; + JOIN_ROOM_ID = null; + JOINS = 0; + LEAVES = 0; + return { + getClient: () => { + return { + getUserId: () => "@user:localhost", + sendStateEvent: (roomId, type, content, key) => { + SEV_ROOM_ID = roomId; + SEV_CONTENT = content; + SEV_KEY = key; + SEV_COUNT++; + }, + }; + }, + join: (roomId) => { + JOIN_ROOM_ID = roomId; + JOINS++; + }, + leave: (roomId) => { + LEAVE_ROOM_ID = roomId; + LEAVES++; + }, + opts: { + backingStore: { + getMembership: (roomId, userId) => "join", + setMembership: (roomId, userId, membership) => { }, + }, + }, + setAvatarUrl: async (ava) => { + AVATAR_SET = ava; + }, + setDisplayName: async (dn) => { + DISPLAYNAME_SET = dn; + }, + }; + }, + }; + const discordbot: any = { + GetChannelFromRoomId: (id) => { + if (id === "!found:localhost") { + const guild = new MockGuild("666666"); + guild.members.set("123456", new MockMember("123456", "fella", guild)); + const chan = new MockChannel("543345", guild); + guild.channels.set("543345", chan as any); + return chan; + } + throw new Error("Channel not found"); + }, + GetGuilds: () => { + return []; + }, + GetIntentFromDiscordMember: (id) => { + return bridge.getIntent(id); + }, + GetRoomIdsFromGuild: async (guild, member?) => { + if (member && member.roles.get("1234")) { + return GUILD_ROOM_IDS_WITH_ROLE; + } + return GUILD_ROOM_IDS; + }, + }; + REMOTEUSER_SET = null; + LINK_RM_USER = null; + LINK_MX_USER = null; + const userStore = { + getRemoteUser: (id) => remoteUsers.find((u) => u.id === id) || null, + getRemoteUsersFromMatrixId: (id) => remoteUsers.filter((u) => u.id === id), + linkUsers: (mxUser, remoteUser) => { + LINK_MX_USER = mxUser; + LINK_RM_USER = remoteUser; + }, + setRemoteUser: async (remoteUser) => { + REMOTEUSER_SET = remoteUser; + }, + }; + const config = new DiscordBridgeConfig(); + config.bridge.domain = "localhost"; + config.ghosts = Object.assign({}, config.ghosts, ghostConfig); + return new UserSync(bridge as Bridge, config, discordbot, userStore as any); +} + +describe("UserSyncroniser", () => { + describe("GetUserUpdateState", () => { + it("Will create a new user", async () => { + const userSync = CreateUserSync(); + const user = new MockUser( + "123456", + "TestUsername", + "6969", + "test.jpg", + "111", + ); + const state = await userSync.GetUserUpdateState(user as any); + expect(state.createUser).is.true; + expect(state.removeAvatar).is.false; + expect(state.displayName).equals("TestUsername#6969"); + expect(state.mxUserId).equals("@_discord_123456:localhost"); + expect(state.avatarId).equals("111"); + expect(state.avatarUrl).equals("test.jpg"); + }); + it("Will change display names", async () => { + const remoteUser = new RemoteUser("123456"); + remoteUser.avatarurl = "test.jpg"; + remoteUser.displayname = "TestUsername"; + + const userSync = CreateUserSync([remoteUser]); + const user = new MockUser( + "123456", + "TestUsername", + "6969", + "test.jpg", + "111", + ); + const state = await userSync.GetUserUpdateState(user as any); + expect(state.createUser, "CreateUser").is.false; + expect(state.removeAvatar, "RemoveAvatar").is.false; + expect(state.displayName, "DisplayName").equals("TestUsername#6969"); + expect(state.mxUserId , "UserId").equals("@_discord_123456:localhost"); + expect(state.avatarId, "AvatarID").is.empty; + expect(state.avatarUrl, "AvatarUrl").is.null; + }); + it("Will obay name patterns", async () => { + const remoteUser = new RemoteUser("123456"); + remoteUser.avatarurl = "test.jpg"; + remoteUser.displayname = "TestUsername"; + + const userSync = CreateUserSync([remoteUser], {usernamePattern: ":username#:tag (Discord)"}); + const user = new MockUser( + "123456", + "TestUsername", + "6969", + "test.jpg", + "111", + ); + const state = await userSync.GetUserUpdateState(user as any); + expect(state.createUser, "CreateUser").is.false; + expect(state.removeAvatar, "RemoveAvatar").is.false; + expect(state.displayName, "DisplayName").equals("TestUsername#6969 (Discord)"); + expect(state.mxUserId , "UserId").equals("@_discord_123456:localhost"); + expect(state.avatarId, "AvatarID").is.empty; + expect(state.avatarUrl, "AvatarUrl").is.null; + }); + it("Will change avatars", async () => { + const remoteUser = new RemoteUser("123456"); + remoteUser.avatarurl = "test.jpg"; + remoteUser.displayname = "TestUsername#6969"; + + const userSync = CreateUserSync([remoteUser]); + const user = new MockUser( + "123456", + "TestUsername", + "6969", + "test2.jpg", + "111", + ); + const state = await userSync.GetUserUpdateState(user as any); + expect(state.createUser, "CreateUser").is.false; + expect(state.removeAvatar, "RemoveAvatar").is.false; + expect(state.avatarUrl, "AvatarUrl").equals("test2.jpg"); + expect(state.mxUserId , "UserId").equals("@_discord_123456:localhost"); + expect(state.avatarId, "AvatarID").is.equals("111"); + expect(state.displayName, "DisplayName").is.null; + }); + it("Will remove avatars", async () => { + const remoteUser = new RemoteUser("123456"); + remoteUser.avatarurl = "test.jpg"; + remoteUser.displayname = "TestUsername#6969"; + + const userSync = CreateUserSync([remoteUser]); + const user = new MockUser( + "123456", + "TestUsername", + "6969", + null, + null, + ); + const state = await userSync.GetUserUpdateState(user as any); + expect(state.createUser, "CreateUser").is.false; + expect(state.removeAvatar, "RemoveAvatar").is.true; + expect(state.avatarUrl, "AvatarUrl").is.null; + expect(state.mxUserId , "UserId").equals("@_discord_123456:localhost"); + expect(state.avatarId, "AvatarID").is.empty; + expect(state.displayName, "DisplayName").is.null; + }); + }); + describe("ApplyStateToProfile", () => { + it("Will create a new user", async () => { + const userSync = CreateUserSync(); + const state: IUserState = { + avatarId: "", + avatarUrl: null, // Nullable + createUser: true, + displayName: null, // Nullable + id: "123456", + mxUserId: "@_discord_123456:localhost", + removeAvatar: false, + }; + await userSync.ApplyStateToProfile(state); + expect(LINK_MX_USER).is.not.null; + expect(LINK_RM_USER).is.not.null; + expect(REMOTEUSER_SET).is.null; + }); + it("Will set a display name", async () => { + const userSync = CreateUserSync(); + const state: IUserState = { + avatarId: "", + avatarUrl: null, // Nullable + createUser: true, + displayName: "123456", // Nullable + id: "123456", + mxUserId: "@_discord_123456:localhost", + removeAvatar: false, + }; + await userSync.ApplyStateToProfile(state); + expect(LINK_MX_USER).is.not.null; + expect(LINK_RM_USER).is.not.null; + expect(REMOTEUSER_SET).is.not.null; + expect(DISPLAYNAME_SET).equal("123456"); + expect(REMOTEUSER_SET.displayname).equal("123456"); + expect(AVATAR_SET).is.null; + expect(REMOTEUSER_SET.avatarurl).is.null; + }); + it("Will set an avatar", async () => { + const userSync = CreateUserSync(); + const state: IUserState = { + avatarId: "", + avatarUrl: "654321", // Nullable + createUser: true, + displayName: null, // Nullable + id: "123456", + mxUserId: "@_discord_123456:localhost", + removeAvatar: false, + }; + await userSync.ApplyStateToProfile(state); + expect(LINK_MX_USER).is.not.null; + expect(LINK_RM_USER).is.not.null; + expect(AVATAR_SET).equal("avatarset"); + expect(UTIL_UPLOADED_AVATAR).to.be.true; + expect(REMOTEUSER_SET).is.not.null; + expect(REMOTEUSER_SET.avatarurl).equal("654321"); + expect(REMOTEUSER_SET.displayname).is.null; + expect(DISPLAYNAME_SET).is.null; + }); + it("Will remove an avatar", async () => { + const userSync = CreateUserSync(); + const state: IUserState = { + avatarId: "", + avatarUrl: null, // Nullable + createUser: true, + displayName: null, // Nullable + id: "123456", + mxUserId: "@_discord_123456:localhost", + removeAvatar: true, + }; + await userSync.ApplyStateToProfile(state); + expect(LINK_MX_USER).is.not.null; + expect(LINK_RM_USER).is.not.null; + expect(AVATAR_SET).is.null; + expect(UTIL_UPLOADED_AVATAR).to.be.false; + expect(REMOTEUSER_SET).is.not.null; + expect(REMOTEUSER_SET.avatarurl).is.null; + expect(REMOTEUSER_SET.displayname).is.null; + expect(DISPLAYNAME_SET).is.null; + }); + it("will do nothing if nothing needs to be done", async () => { + const userSync = CreateUserSync([new RemoteUser("123456")]); + const state: IUserState = { + avatarId: "", + avatarUrl: null, // Nullable + createUser: false, + displayName: null, // Nullable + id: "123456", + mxUserId: "@_discord_123456:localhost", + removeAvatar: false, + }; + await userSync.ApplyStateToProfile(state); + expect(LINK_MX_USER).is.null; + expect(LINK_RM_USER).is.null; + expect(AVATAR_SET).is.null; + expect(REMOTEUSER_SET).is.null; + expect(DISPLAYNAME_SET).is.null; + }); + }); + describe("ApplyStateToRoom", () => { + it("Will apply a new nick", async () => { + const userSync = CreateUserSync([new RemoteUser("123456")]); + const state: IGuildMemberState = { + bot: false, + displayColor: 0, + displayName: "Good Boy", + id: "123456", + mxUserId: "@_discord_123456:localhost", + roles: [], + username: "", + }; + await userSync.ApplyStateToRoom(state, "!abc:localhost", "123456"); + expect(REMOTEUSER_SET).is.not.null; + expect(REMOTEUSER_SET.guildNicks.get("123456")).is.equal("Good Boy"); + expect(SEV_ROOM_ID).is.equal("!abc:localhost"); + expect(SEV_CONTENT.displayname).is.equal("Good Boy"); + expect(SEV_KEY).is.equal("@_discord_123456:localhost"); + }); + it("Will not apply unchanged nick", async () => { + const userSync = CreateUserSync([new RemoteUser("123456")]); + const state: IGuildMemberState = { + bot: false, + displayColor: 0, + displayName: "", + id: "123456", + mxUserId: "@_discord_123456:localhost", + roles: [], + username: "", + }; + await userSync.ApplyStateToRoom(state, "!abc:localhost", "123456"); + expect(REMOTEUSER_SET).is.null; + expect(SEV_ROOM_ID).is.null; + expect(SEV_CONTENT).is.null; + expect(SEV_KEY).is.null; + }); + it("Will apply roles", async () => { + const userSync = CreateUserSync([new RemoteUser("123456")]); + const TESTROLE_NAME = "testrole"; + const TESTROLE_COLOR = 1337; + const TESTROLE_POSITION = 42; + const state: IGuildMemberState = { + bot: false, + displayColor: 0, + displayName: "Good Boy", + id: "123456", + mxUserId: "@_discord_123456:localhost", + roles: [ + { + color: TESTROLE_COLOR, + name: TESTROLE_NAME, + position: TESTROLE_POSITION, + }, + ], + username: "", + }; + await userSync.ApplyStateToRoom(state, "!abc:localhost", "12345678"); + const custKey = SEV_CONTENT["uk.half-shot.discord.member"]; + const roles = custKey.roles; + expect(custKey.id).is.equal("123456"); + expect(roles.length).is.equal(1); + expect(roles[0].name).is.equal(TESTROLE_NAME); + expect(roles[0].color).is.equal(TESTROLE_COLOR); + expect(roles[0].position).is.equal(TESTROLE_POSITION); + }); + it("Will set bot correctly", async () => { + const userSync = CreateUserSync([new RemoteUser("123456")]); + const state: IGuildMemberState = { + bot: false, + displayColor: 0, + displayName: "Good Boy", + id: "123456", + mxUserId: "@_discord_123456:localhost", + roles: [ ], + username: "", + }; + await userSync.ApplyStateToRoom(state, "!abc:localhost", "12345678"); + let custKey = SEV_CONTENT["uk.half-shot.discord.member"]; + expect(custKey.bot).is.false; + + state.bot = true; + await userSync.ApplyStateToRoom(state, "!abc:localhost", "12345678"); + custKey = SEV_CONTENT["uk.half-shot.discord.member"]; + expect(custKey.bot).is.true; + }); + it("Will set the displayColor correctly", async () => { + const TEST_COLOR = 1234; + const userSync = CreateUserSync([new RemoteUser("123456")]); + const state: IGuildMemberState = { + bot: false, + displayColor: TEST_COLOR, + displayName: "Good Boy", + id: "123456", + mxUserId: "@_discord_123456:localhost", + roles: [ ], + username: "", + }; + await userSync.ApplyStateToRoom(state, "!abc:localhost", "12345678"); + const custKey = SEV_CONTENT["uk.half-shot.discord.member"]; + expect(custKey.displayColor).is.equal(TEST_COLOR); + }); + it("Will set username correctly", async () => { + const userSync = CreateUserSync([new RemoteUser("123456")]); + const state: IGuildMemberState = { + bot: false, + displayColor: 0, + displayName: "Good Boy", + id: "123456", + mxUserId: "@_discord_123456:localhost", + roles: [ ], + username: "user#1234", + }; + await userSync.ApplyStateToRoom(state, "!abc:localhost", "12345678"); + const custKey = SEV_CONTENT["uk.half-shot.discord.member"]; + expect(custKey.username).is.equal("user#1234"); + }); + }); + describe("GetUserStateForGuildMember", () => { + it("Will apply a new nick", async () => { + const userSync = CreateUserSync([new RemoteUser("123456")]); + const guild = new MockGuild( + "654321"); + const member = new MockMember( + "123456", + "username", + guild, + "BestDog"); + const state = await userSync.GetUserStateForGuildMember(member as any); + expect(state.displayName).to.be.equal("BestDog"); + }); + it("Will will obay nick pattern", async () => { + const userSync = CreateUserSync([new RemoteUser("123456")], { nickPattern: ":nick (Discord)" }); + const guild = new MockGuild( + "654321"); + const member = new MockMember( + "123456", + "username", + guild, + "BestDog"); + const state = await userSync.GetUserStateForGuildMember(member as any); + expect(state.displayName).to.be.equal("BestDog (Discord)"); + }); + it("Will correctly add roles", async () => { + const userSync = CreateUserSync([new RemoteUser("123456")]); + const guild = new MockGuild( + "654321"); + const member = new MockMember( + "123456", + "username", + guild, + "BestDog"); + const TESTROLE_NAME = "testrole"; + const TESTROLE_COLOR = 1337; + const TESTROLE_POSITION = 42; + const role = new MockRole("123", TESTROLE_NAME, TESTROLE_COLOR, TESTROLE_POSITION); + member.roles.set("123", role); + const state = await userSync.GetUserStateForGuildMember(member as any); + expect(state.roles.length).to.be.equal(1); + expect(state.roles[0].name).to.be.equal(TESTROLE_NAME); + expect(state.roles[0].color).to.be.equal(TESTROLE_COLOR); + expect(state.roles[0].position).to.be.equal(TESTROLE_POSITION); + }); + }); + describe("GetUserStateForDiscordUser", () => { + it("Will apply a new nick", async () => { + const userSync = CreateUserSync([new RemoteUser("123456")]); + const member = new MockUser( + "123456", + "username", + "1234"); + const state = await userSync.GetUserStateForDiscordUser(member as any); + expect(state.displayName).to.be.equal("username"); + }); + it("Will handle webhooks", async () => { + const userSync = CreateUserSync([new RemoteUser("123456")]); + const member = new MockUser( + "123456", + "username", + "1234"); + const state = await userSync.GetUserStateForDiscordUser(member as any, "654321"); + expect(state.displayName).to.be.equal("username"); + expect(state.mxUserId).to.be.equal("@_discord_123456_username:localhost"); + }); + }); + describe("OnAddGuildMember", () => { + it("will update user and join to rooms", async () => { + const userSync = CreateUserSync([new RemoteUser("123456")]); + const guild = new MockGuild( + "654321"); + const member = new MockMember( + "123456", + "username", + guild); + await userSync.OnAddGuildMember(member as any); + expect(SEV_COUNT).to.equal(GUILD_ROOM_IDS.length); + }); + }); + describe("OnRemoveGuildMember", () => { + it("will leave users from rooms", async () => { + const userSync = CreateUserSync([new RemoteUser("123456")]); + const guild = new MockGuild( + "654321"); + const member = new MockMember( + "123456", + "username", + guild); + await userSync.OnRemoveGuildMember(member as any); + expect(LEAVES).to.equal(GUILD_ROOM_IDS.length); + }); + }); + describe("OnUpdateGuildMember", () => { + it("will update state for rooms", async () => { + const userSync = CreateUserSync([new RemoteUser("123456")]); + const guild = new MockGuild( + "654321"); + const newMember = new MockMember( + "123456", + "username", + guild, + "FiddleDee"); + await userSync.OnUpdateGuildMember(newMember as any); + expect(SEV_COUNT).to.equal(GUILD_ROOM_IDS.length); + }); + it("will part rooms based on role removal", async () => { + const userSync = CreateUserSync([new RemoteUser("123456")]); + const role = new MockRole("1234", "role"); + const guild = new MockGuild( + "654321"); + const newMember = new MockMember( + "123456", + "username", + guild, + "FiddleDee"); + newMember.roles.set("1234", role); + await userSync.OnUpdateGuildMember(newMember as any); + expect(SEV_COUNT).to.equal(GUILD_ROOM_IDS_WITH_ROLE.length); + expect(LEAVES).to.equal(GUILD_ROOM_IDS.length - GUILD_ROOM_IDS_WITH_ROLE.length); + expect(LEAVE_ROOM_ID).to.equal("!ghi:localhost"); + }); + }); +}); diff --git a/test/test_util.ts b/test/test_util.ts new file mode 100644 index 0000000000000000000000000000000000000000..2e4ca1a3f0e8421f78e5400a6a2a587a1b6ec00d --- /dev/null +++ b/test/test_util.ts @@ -0,0 +1,314 @@ +/* +Copyright 2018, 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 * as Chai from "chai"; + +import { Util, ICommandActions, ICommandParameters } from "../src/util"; + +// we are a test file and thus need those +/* tslint:disable:no-unused-expression max-file-line-count no-any */ + +const expect = Chai.expect; + +function CreateMockIntent(members) { + return { + getClient: () => { + return { + _http: { + authedRequestWithPrefix: async (_, __, url, ___, ____, _____) => { + const ret: any[] = []; + for (const member of members[url]) { + ret.push({ + content: { + displayname: member.displayname, + }, + membership: member.membership, + state_key: member.mxid, + }); + } + return { + chunk: ret, + }; + }, + }, + }; + }, + }; +} + +describe("Util", () => { + describe("MsgToArgs", () => { + it("parses arguments", () => { + const {command, args} = Util.MsgToArgs("!matrix command arg1 arg2", "!matrix"); + Chai.assert.equal(command, "command"); + // tslint:disable-next-line:no-magic-numbers + Chai.assert.equal(args.length, 2); + Chai.assert.equal(args[0], "arg1"); + Chai.assert.equal(args[1], "arg2"); + }); + }); + describe("Command Stuff", () => { + const actions: ICommandActions = { + action: { + description: "floof", + help: "Fox goes floof!", + params: ["param1", "param2"], + run: async ({param1, param2}) => { + return `param1: ${param1}\nparam2: ${param2}`; + }, + }, + }; + const parameters: ICommandParameters = { + param1: { + description: "1", + get: async (param: string) => { + return "param1_" + param; + }, + }, + param2: { + description: "2", + get: async (param: string) => { + return "param2_" + param; + }, + }, + }; + describe("HandleHelpCommand", () => { + it("parses general help message", async () => { + const {command, args} = Util.MsgToArgs("!fox help", "!fox"); + const retStr = await Util.HandleHelpCommand( + "!fox", + actions, + parameters, + args, + ); + expect(retStr).to.equal( +`Available Commands: + - \`!fox action <param1> <param2>\`: floof + +Parameters: + - \`<param1>\`: 1 + - \`<param2>\`: 2 +`); + }); + it("parses specific help message", async () => { + const {command, args} = Util.MsgToArgs("!fox help action", "!fox"); + const retStr = await Util.HandleHelpCommand( + "!fox", + actions, + parameters, + args, + ); + expect(retStr).to.equal( +`\`!fox action <param1> <param2>\`: floof +Fox goes floof!`); + }); + }); + describe("ParseCommand", () => { + it("parses commands", async () => { + const retStr = await Util.ParseCommand( + "!fox", + "!fox action hello world", + actions, + parameters, + ); + expect(retStr).equal("param1: param1_hello\nparam2: param2_world"); + }); + }); + }); + describe("GetMxidFromName", () => { + it("Finds a single member", async () => { + const mockRooms = { + "/rooms/abc/members": [ + { + displayname: "GoodBoy", + membership: "join", + mxid: "@123:localhost", + }, + ], + }; + const intent = CreateMockIntent(mockRooms); + const mxid = await Util.GetMxidFromName(intent, "goodboy", ["abc"]); + expect(mxid).equal("@123:localhost"); + }); + it("Errors on multiple members", async () => { + const mockRooms = { + "/rooms/abc/members": [ + { + displayname: "GoodBoy", + membership: "join", + mxid: "@123:localhost", + }, + { + displayname: "GoodBoy", + membership: "join", + mxid: "@456:localhost", + }, + ], + }; + const intent = CreateMockIntent(mockRooms); + try { + await Util.GetMxidFromName(intent, "goodboy", ["abc"]); + throw new Error("didn't fail"); + } catch (e) { + expect(e.message).to.not.equal("didn't fail"); + } + }); + it("Errors on no member", async () => { + const mockRooms = { + "/rooms/abc/members": [ + { + displayname: "GoodBoy", + membership: "join", + mxid: "@123:localhost", + }, + ], + }; + const intent = CreateMockIntent(mockRooms); + try { + await Util.GetMxidFromName(intent, "badboy", ["abc"]); + throw new Error("didn't fail"); + } catch (e) { + expect(e.message).to.not.equal("didn't fail"); + } + }); + }); + describe("NumberToHTMLColor", () => { + it("Should handle valid colors", () => { + const COLOR = 0xdeadaf; + const reply = Util.NumberToHTMLColor(COLOR); + expect(reply).to.equal("#deadaf"); + }); + it("Should reject too large colors", () => { + const COLOR = 0xFFFFFFFF; + const reply = Util.NumberToHTMLColor(COLOR); + expect(reply).to.equal("#ffffff"); + }); + it("Should reject too small colors", () => { + const COLOR = -1; + const reply = Util.NumberToHTMLColor(COLOR); + expect(reply).to.equal("#000000"); + }); + }); + describe("ApplyPatternString", () => { + it("Should apply simple patterns", () => { + const reply = Util.ApplyPatternString(":name likes :animal", { + animal: "Foxies", + name: "Sorunome", + }); + expect(reply).to.equal("Sorunome likes Foxies"); + }); + it("Should ignore unused tags", () => { + const reply = Util.ApplyPatternString(":name is :thing", { + name: "Sorunome", + }); + expect(reply).to.equal("Sorunome is :thing"); + }); + it("Should do multi-replacements", () => { + const reply = Util.ApplyPatternString(":animal, :animal and :animal", { + animal: "fox", + }); + expect(reply).to.equal("fox, fox and fox"); + }); + }); + describe("DelayedPromise", () => { + it("delays for some time", async () => { + const DELAY_FOR = 250; + const t = Date.now(); + await Util.DelayedPromise(DELAY_FOR); + expect(Date.now()).to.be.greaterThan(t + DELAY_FOR - 1); + }); + }); + describe("CheckMatrixPermission", () => { + const PERM_LEVEL = 50; + it("should deny", async () => { + const ret = await Util.CheckMatrixPermission( + { + getStateEvent: async () => { + return { + blah: { + blubb: PERM_LEVEL, + }, + }; + }, + } as any, + "@user:localhost", + "", + PERM_LEVEL, + "blah", + "blubb", + ); + expect(ret).to.be.false; + }); + it("should allow cat/subcat", async () => { + const ret = await Util.CheckMatrixPermission( + { + getStateEvent: async () => { + return { + blah: { + blubb: PERM_LEVEL, + }, + users: { + "@user:localhost": PERM_LEVEL, + }, + }; + }, + } as any, + "@user:localhost", + "", + PERM_LEVEL, + "blah", + "blubb", + ); + expect(ret).to.be.true; + }); + it("should allow cat", async () => { + const ret = await Util.CheckMatrixPermission( + { + getStateEvent: async () => { + return { + blah: PERM_LEVEL, + users: { + "@user:localhost": PERM_LEVEL, + }, + }; + }, + } as any, + "@user:localhost", + "", + PERM_LEVEL, + "blah", + ); + expect(ret).to.be.true; + }); + it("should allow based on default", async () => { + const ret = await Util.CheckMatrixPermission( + { + getStateEvent: async () => { + return { + blah: PERM_LEVEL, + users_default: PERM_LEVEL, + }; + }, + } as any, + "@user:localhost", + "", + PERM_LEVEL, + "blah", + ); + expect(ret).to.be.true; + }); + }); +}); diff --git a/tools/addRoomsToDirectory.ts b/tools/addRoomsToDirectory.ts new file mode 100644 index 0000000000000000000000000000000000000000..200bebb500f7e85ece31b7cd5575334106aa2518 --- /dev/null +++ b/tools/addRoomsToDirectory.ts @@ -0,0 +1,129 @@ +/* +Copyright 2018 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. +*/ + +/* tslint:disable:no-console */ +/** + * Allows you to become an admin for a room the bot is in control of. + */ + +import { AppServiceRegistration, ClientFactory, Bridge } from "matrix-appservice-bridge"; +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 { DiscordBridgeConfig } from "../src/config"; +import { Log } from "../src/log"; +import { Util } from "../src/util"; +import { DiscordStore } from "../src/store"; +const log = new Log("AddRoomsToDirectory"); +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>", + }, + { + alias: "s", + defaultValue: "room-store.db", + description: "The location of the room store.", + name: "store", + type: String, + }, +]; + +const options = args(optionDefinitions); + +if (options.help) { + /* tslint:disable:no-console */ + console.log(usage([ + { + content: "A tool to set all the bridged rooms to visible in the directory.", + header: "Add rooms to directory", + }, + { + header: "Options", + optionList: optionDefinitions, + }, + ])); + process.exit(0); +} +const yamlConfig = yaml.safeLoad(fs.readFileSync("./discord-registration.yaml", "utf8")); +const registration = AppServiceRegistration.fromObject(yamlConfig); +const config: DiscordBridgeConfig = yaml.safeLoad(fs.readFileSync(options.config, "utf8")) as DiscordBridgeConfig; + +if (registration === null) { + throw new Error("Failed to parse registration file"); +} + +const clientFactory = new ClientFactory({ + appServiceUserId: `@${registration.sender_localpart}:${config.bridge.domain}`, + token: registration.as_token, + url: config.bridge.homeserverUrl, +}); + +const bridge = new Bridge({ + controller: { + onEvent: () => { }, + }, + domain: "rubbish", + homeserverUrl: true, + registration: true, +}); + +const discordstore = new DiscordStore(config.database ? config.database.filename : "discord.db"); + +async function run() { + try { + await discordstore.init(); + } catch (e) { + log.error(`Failed to load database`, e); + } + let rooms = await discordstore.roomStore.getEntriesByRemoteRoomData({ + discord_type: "text", + }); + rooms = rooms.filter((r) => r.remote && r.remote.get("plumbed") !== true ); + const client = clientFactory.getClientAs(); + log.info(`Got ${rooms.length} rooms to set`); + try { + await Util.AsyncForEach(rooms, async (room) => { + const guild = room.remote.get("discord_guild"); + const roomId = room.matrix.getId(); + try { + await client.setRoomDirectoryVisibilityAppService( + guild, + roomId, + "public", + ); + log.info(`Set ${roomId} to visible in ${guild}'s directory`); + } catch (e) { + log.error(`Failed to set ${roomId} to visible in ${guild}'s directory`, e); + } + }); + } catch (e) { + log.error(`Failed to run script`, e); + } +} + +run(); // tslint:disable-line no-floating-promises diff --git a/tools/addbot.js b/tools/addbot.js deleted file mode 100644 index 86d55daca8199114718094ba074c18c8d06e1463..0000000000000000000000000000000000000000 --- a/tools/addbot.js +++ /dev/null @@ -1,18 +0,0 @@ -const yaml = require("js-yaml"); -const fs = require("fs"); -const flags = require("../node_modules/discord.js/src/util/Constants.js").PermissionFlags; -const yamlConfig = yaml.safeLoad(fs.readFileSync("config.yaml", "utf8")); -if (yamlConfig === null) { - console.error("You have an error in your discord config."); -} -const client_id = yamlConfig.auth.clientID; -const perms = flags.READ_MESSAGES | - flags.SEND_MESSAGES | - flags.CHANGE_NICKNAME | - flags.CONNECT | - flags.SPEAK | - flags.EMBED_LINKS | - flags.ATTACH_FILES | - flags.READ_MESSAGE_HISTORY; - -console.log(`Go to https://discordapp.com/api/oauth2/authorize?client_id=${client_id}&scope=bot&permissions=${perms} to invite the bot into a guild.`); diff --git a/tools/addbot.ts b/tools/addbot.ts new file mode 100644 index 0000000000000000000000000000000000000000..b8ef769b7ad9c5e5e9fe4e372f370531290d81cd --- /dev/null +++ b/tools/addbot.ts @@ -0,0 +1,31 @@ +/* +Copyright 2017, 2018 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. +*/ + +/* tslint:disable:no-bitwise no-console no-var-requires */ +/** + * Generates a URL you can use to authorize a bot with a guild. + */ +import * as yaml from "js-yaml"; +import * as fs from "fs"; +import { Util } from "../src/util"; + +const yamlConfig = yaml.safeLoad(fs.readFileSync("config.yaml", "utf8")); +if (yamlConfig === null) { + console.error("You have an error in your discord config."); +} + +const url = Util.GetBotLink(yamlConfig); +console.log(`Go to ${url} to invite the bot into a guild.`); diff --git a/tools/adminme.ts b/tools/adminme.ts new file mode 100644 index 0000000000000000000000000000000000000000..a45887968beffc0bee9d2039680531f1f3b90506 --- /dev/null +++ b/tools/adminme.ts @@ -0,0 +1,120 @@ +/* +Copyright 2017, 2018 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. +*/ + +/* tslint:disable:no-console */ +/** + * Allows you to become an admin for a room the bot is in control of. + */ + +import { AppServiceRegistration, ClientFactory, Intent } from "matrix-appservice-bridge"; +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 { DiscordBridgeConfig } from "../src/config"; + +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>", + }, + { + alias: "r", + description: "The roomid to modify", + name: "roomid", + type: String, + }, + { + alias: "u", + description: "The userid to give powers", + name: "userid", + type: String, + }, + { + alias: "p", + defaultValue: 100, + description: "The power to set", + name: "power", + type: Number, + typeLabel: "<0-100>", + }, +]; + +const options = args(optionDefinitions); + +if (options.help) { + /* tslint:disable:no-console */ + console.log(usage([ + { + content: "A tool to give a user a power level in a bot user controlled room.", + header: "Admin Me", + }, + { + header: "Options", + optionList: optionDefinitions, + }, + ])); + process.exit(0); +} + +if (!options.roomid) { + console.error("Missing roomid parameter. Check -h"); + process.exit(1); +} + +if (!options.userid) { + console.error("Missing userid parameter. Check -h"); + process.exit(1); +} + +const yamlConfig = yaml.safeLoad(fs.readFileSync("discord-registration.yaml", "utf8")); +const registration = AppServiceRegistration.fromObject(yamlConfig); +const config: DiscordBridgeConfig = yaml.safeLoad(fs.readFileSync(options.config, "utf8")) as DiscordBridgeConfig; + +if (registration === null) { + throw new Error("Failed to parse registration file"); +} + +const clientFactory = new ClientFactory({ + appServiceUserId: `@${registration.sender_localpart}:${config.bridge.domain}`, + token: registration.as_token, + url: config.bridge.homeserverUrl, +}); +const client = clientFactory.getClientAs(); +const intent = new Intent(client, client, {registered: true}); + +async function run() { + try { + await intent.setPowerLevel(options.roomid, options.userid, options.power); + console.log("Power levels set"); + process.exit(0); + } catch (err) { + console.error("Could not apply power levels to room:", err); + process.exit(1); + } +} + +run(); // tslint:disable-line no-floating-promises diff --git a/tools/chanfix.ts b/tools/chanfix.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d4f4abf9b8510b3ef666184ee640170836fda11 --- /dev/null +++ b/tools/chanfix.ts @@ -0,0 +1,156 @@ +/* +Copyright 2018 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 { AppServiceRegistration, ClientFactory, Bridge, Intent } from "matrix-appservice-bridge"; +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 { ChannelSyncroniser } from "../src/channelsyncroniser"; +import { DiscordBridgeConfig } from "../src/config"; +import { DiscordBot } from "../src/bot"; +import { DiscordStore } from "../src/store"; +import { Provisioner } from "../src/provisioner"; +import { Log } from "../src/log"; +import { Util } from "../src/util"; + +const log = new Log("ChanFix"); + +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 fix channels of rooms already bridged " + + "to matrix, to make sure their names, icons etc. are correctly.", + header: "Fix bridged channels", + }, + { + header: "Options", + optionList: optionDefinitions, + }, + ])); + process.exit(0); +} + +const yamlConfig = yaml.safeLoad(fs.readFileSync("./discord-registration.yaml", "utf8")); +const registration = AppServiceRegistration.fromObject(yamlConfig); +const config = new DiscordBridgeConfig(); +config.ApplyConfig(yaml.safeLoad(fs.readFileSync(options.config, "utf8")) as DiscordBridgeConfig); + +if (registration === null) { + throw new Error("Failed to parse registration file"); +} + +const botUserId = `@${registration.sender_localpart}:${config.bridge.domain}`; +const clientFactory = new ClientFactory({ + appServiceUserId: botUserId, + token: registration.as_token, + url: config.bridge.homeserverUrl, +}); + +const bridge = new Bridge({ + clientFactory, + controller: { + onEvent: () => { }, + }, + domain: config.bridge.domain, + homeserverUrl: config.bridge.homeserverUrl, + intentOptions: { + clients: { + dontJoin: true, // handled manually + }, + }, + registration, + roomStore: config.database.roomStorePath, + userStore: config.database.userStorePath, +}); + +async function run() { + await bridge.loadDatabases(); + const store = new DiscordStore(config.database); + await store.init(undefined, bridge.getRoomStore()); + const discordbot = new DiscordBot(botUserId, config, bridge, store); + await discordbot.init(); + + bridge._clientFactory = clientFactory; + bridge._botClient = bridge._clientFactory.getClientAs(); + bridge._botIntent = new Intent(bridge._botClient, bridge._botClient, { registered: true }); + await discordbot.ClientFactory.init(); + const client = await discordbot.ClientFactory.getClient(); + + // first set update_icon to true if needed + const mxRoomEntries = await bridge.getRoomStore().getEntriesByRemoteRoomData({ + update_name: true, + update_topic: true, + }); + + const promiseList: Promise<void>[] = []; + mxRoomEntries.forEach((entry) => { + if (entry.remote.get("plumbed")) { + return; // skipping plumbed rooms + } + const updateIcon = entry.remote.get("update_icon"); + if (updateIcon !== undefined && updateIcon !== null) { + return; // skipping because something was set manually + } + entry.remote.set("update_icon", true); + promiseList.push(bridge.getRoomStore().upsertEntry(entry)); + }); + await Promise.all(promiseList); + + // now it is time to actually run the updates + const promiseList2: Promise<void>[] = []; + + let curDelay = config.limits.roomGhostJoinDelay; // we'll just re-use this + client.guilds.forEach((guild) => { + promiseList2.push((async () => { + await Util.DelayedPromise(curDelay); + try { + await discordbot.ChannelSyncroniser.OnGuildUpdate(guild, true); + } catch (err) { + log.warn(`Couldn't update rooms of guild ${guild.id}`, err); + } + })()); + curDelay += config.limits.roomGhostJoinDelay; + }); + try { + await Promise.all(promiseList2); + } catch (err) { + log.error(err); + } + process.exit(0); +} + +run(); // tslint:disable-line no-floating-promises diff --git a/tools/ghostfix.ts b/tools/ghostfix.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ce61d6d6102b4bf61566a0379aaae92b085c884 --- /dev/null +++ b/tools/ghostfix.ts @@ -0,0 +1,166 @@ +/* +Copyright 2018 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 { AppServiceRegistration, ClientFactory, Bridge } from "matrix-appservice-bridge"; +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 { DiscordBridgeConfig } from "../src/config"; +import { Log } from "../src/log"; +import { Util } from "../src/util"; +import { DiscordBot } from "../src/bot"; +import { DiscordStore } from "../src/store"; + +const log = new Log("GhostFix"); + +// Note: The schedule must not have duplicate values to avoid problems in positioning. +/* tslint:disable:no-magic-numbers */ // Disabled because it complains about the values in the array +const JOIN_ROOM_SCHEDULE = [ + 0, // Right away + 1000, // 1 second + 30000, // 30 seconds + 300000, // 5 minutes + 900000, // 15 minutes +]; +/* tslint:enable:no-magic-numbers */ + +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 fix usernames of ghosts already in " + + "matrix rooms, to make sure they represent the correct discord usernames.", + header: "Fix usernames of joined ghosts", + }, + { + header: "Options", + optionList: optionDefinitions, + }, + ])); + process.exit(0); +} + +const yamlConfig = yaml.safeLoad(fs.readFileSync("./discord-registration.yaml", "utf8")); +const registration = AppServiceRegistration.fromObject(yamlConfig); +const config = new DiscordBridgeConfig(); +config.ApplyConfig(yaml.safeLoad(fs.readFileSync(options.config, "utf8")) as DiscordBridgeConfig); + +if (registration === null) { + throw new Error("Failed to parse registration file"); +} + +const botUserId = `@${registration.sender_localpart}:${config.bridge.domain}`; +const clientFactory = new ClientFactory({ + appServiceUserId: botUserId, + token: registration.as_token, + url: config.bridge.homeserverUrl, +}); + +const bridge = new Bridge({ + clientFactory, + controller: { + onEvent: () => { }, + }, + domain: config.bridge.domain, + homeserverUrl: config.bridge.homeserverUrl, + intentOptions: { + clients: { + dontJoin: true, // handled manually + }, + }, + registration, + roomStore: config.database.roomStorePath, + userStore: config.database.userStorePath, +}); + +async function run() { + await bridge.loadDatabases(); + const store = new DiscordStore(config.database); + await store.init(undefined, bridge.getRoomStore()); + const discordbot = new DiscordBot(botUserId, config, bridge, store); + await discordbot.init(); + bridge._clientFactory = clientFactory; + const client = await discordbot.ClientFactory.getClient(); + + const promiseList: Promise<void>[] = []; + let curDelay = config.limits.roomGhostJoinDelay; + try { + client.guilds.forEach((guild) => { + guild.members.forEach((member) => { + if (member.id === client.user.id) { + return; + } + promiseList.push((async () => { + await Util.DelayedPromise(curDelay); + let currentSchedule = JOIN_ROOM_SCHEDULE[0]; + const doJoin = async () => { + await Util.DelayedPromise(currentSchedule); + await discordbot.UserSyncroniser.OnUpdateGuildMember(member, true, false); + }; + const errorHandler = async (err) => { + log.error(`Error joining rooms for ${member.id}`); + log.error(err); + const idx = JOIN_ROOM_SCHEDULE.indexOf(currentSchedule); + if (idx === JOIN_ROOM_SCHEDULE.length - 1) { + log.warn(`Cannot join rooms for ${member.id}`); + throw new Error(err); + } else { + currentSchedule = JOIN_ROOM_SCHEDULE[idx + 1]; + try { + await doJoin(); + } catch (e) { + await errorHandler(e); + } + } + }; + try { + await doJoin(); + } catch (e) { + await errorHandler(e); + } + })()); + curDelay += config.limits.roomGhostJoinDelay; + }); + }); + + await Promise.all(promiseList); + } catch (err) { + log.error(err); + } + process.exit(0); +} + +run(); // tslint:disable-line no-floating-promises diff --git a/tools/userClientTools.ts b/tools/userClientTools.ts new file mode 100644 index 0000000000000000000000000000000000000000..0aadc222ea6f8f4e4aa73d43429e922de2e8e409 --- /dev/null +++ b/tools/userClientTools.ts @@ -0,0 +1,126 @@ +/* +Copyright 2017, 2018 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 * 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 * as readline from "readline"; +import * as process from "process"; + +import { DiscordClientFactory } from "../src/clientfactory"; +import { DiscordBridgeConfig } from "../src/config"; +import { DiscordStore } from "../src/store"; +import { Log } from "../src/log"; +const log = new Log("UserClientTools"); +const PUPPETING_DOC_URL = "https://github.com/Half-Shot/matrix-appservice-discord/blob/develop/docs/puppeting.md"; + +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>", + }, + { + description: "Add the user to the database.", + name: "add", + type: Boolean, + }, + { + description: "Remove the user from the database.", + name: "remove", + type: Boolean, + }, +]; + +const options = args(optionDefinitions); +if (options.help || (options.add && options.remove) || !(options.add || options.remove)) { + /* tslint:disable:no-console */ + console.log(usage([ + { + content: "A tool to give a user a power level in a bot user controlled room.", + header: "User Client Tools", + }, + { + header: "Options", + optionList: optionDefinitions, + }, + ])); + process.exit(0); +} + +const config: DiscordBridgeConfig = yaml.safeLoad(fs.readFileSync(options.config, "utf8")); +const discordstore = new DiscordStore(config.database ? config.database : "discord.db"); +discordstore.init().then(() => { + log.info("Loaded database."); + handleUI(); +}).catch((err) => { + log.info("Couldn't load database. Cannot continue.", err); + log.info("Ensure the bridge is not running while using this command."); + process.exit(1); +}); + +function handleUI() { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + let userid = ""; + let token = ""; + + rl.question("Please enter your UserID ( ex @Half-Shot:half-shot.uk, @username:matrix.org)", (answeru) => { + userid = answeru; + if (options.add) { + rl.question(` +Please enter your Discord Token +(Instructions for this are on ${PUPPETING_DOC_URL})`, (answert) => { + token = answert; + rl.close(); + addUserToken(userid, token).then(() => { + log.info("Completed successfully"); + process.exit(0); + }).catch((err) => { + log.info("Failed to add, $s", err); + process.exit(1); + }); + }); + } else if (options.remove) { + rl.close(); + discordstore.deleteUserToken(userid).then(() => { + log.info("Completed successfully"); + process.exit(0); + }).catch((err) => { + log.info("Failed to delete, $s", err); + process.exit(1); + }); + } + }); +} + +async function addUserToken(userid: string, token: string): Promise<void> { + const clientFactory = new DiscordClientFactory(discordstore); + const discordid = await clientFactory.getDiscordId(token); + await discordstore.addUserToken(userid, discordid, token); +} diff --git a/tsconfig.json b/tsconfig.json index c5651a0591da08bfac5cd8395e06276590a4c28c..d94b712abdf9a2b83aca7796907f9c0edb519cf0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,16 +1,18 @@ { "compilerOptions": { "module": "commonjs", - "moduleResolution": "Node", - "target": "ES6", + "moduleResolution": "node", + "target": "es2016", "noImplicitAny": false, - "sourceMap": false, + "inlineSourceMap": true, "outDir": "./build", - "types": ["mocha", "node"] + "types": ["mocha", "node"], + "strictNullChecks": true }, "compileOnSave": true, "include": [ "src/**/*", - "test/**/*" + "test/**/*", + "tools/**/*" ] } diff --git a/tslint.json b/tslint.json index c49a430912b2ce1a84844f77da03771404e0ad6c..cd7de561c3b3ba02450df7cb5bf011afd9c73be2 100644 --- a/tslint.json +++ b/tslint.json @@ -1,9 +1,39 @@ { "extends": "tslint:recommended", "rules": { - "ordered-imports": "off", - "no-trailing-whitespace": "off", - "max-classes-per-file": "off", - "object-literal-sort-keys": "off" + "ordered-imports": false, + "no-trailing-whitespace": "error", + "max-classes-per-file": { + "severity": "warning" + }, + "object-literal-sort-keys": "off", + "no-any": true, + "arrow-return-shorthand": true, + "no-magic-numbers": true, + "prefer-for-of": true, + "typedef": { + "severity": "warning" + }, + "await-promise": [true], + "curly": true, + "no-empty": false, + "no-invalid-this": true, + "no-string-throw": { + "severity": "warning" + }, + "no-unused-expression": true, + "prefer-const": true, + "indent": [true, "spaces", 4], + "max-file-line-count": { + "severity": "warning", + "options": [500] + }, + "no-duplicate-imports": true, + "array-type": [true, "array"], + "promise-function-async": true, + "no-bitwise": true, + "no-debugger": true, + "no-floating-promises": true, + "prefer-template": [true, "allow-single-concat"] } }