├── .github └── workflows │ └── tests.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── docker-run.sh ├── img ├── bot-1.jpg ├── bot-2.jpg ├── bot-3.jpg ├── bot-4.jpg ├── bot-5.jpg └── bot-6.jpg ├── package-lock.json ├── package.json ├── sample.config.yaml ├── src ├── Commands.ts ├── app.ts ├── db │ └── schema │ │ ├── v1.ts │ │ ├── v2.ts │ │ ├── v3.ts │ │ └── v4.ts ├── discord │ ├── DiscordEventHandler.ts │ └── DiscordUtil.ts ├── index.ts ├── matrix │ ├── MatrixEventHandler.ts │ └── MatrixUtil.ts └── store.ts ├── tsconfig.json └── tslint.json /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Testing 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | workflow_dispatch: 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ubuntu-latest 17 | 18 | strategy: 19 | matrix: 20 | node-version: [12.x, 14.x, 16.x] 21 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 22 | 23 | steps: 24 | - uses: actions/checkout@v2 25 | - name: Use Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v2 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | cache: 'npm' 30 | - name: Install dependencies 31 | run: | 32 | sudo apt update 33 | sudo apt install -y libcairo2-dev libjpeg-dev libpango1.0-dev libgif-dev librsvg2-dev 34 | - name: Upgrade to npm@8 35 | run: npm install -g npm@8 36 | - run: npm ci 37 | - run: npm run build --if-present 38 | # For future 39 | # - run: npm test 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.yaml 2 | discord-registration.yaml 3 | node_modules 4 | build 5 | *.db 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12-alpine AS builder 2 | 3 | WORKDIR /opt/mx-puppet-discord 4 | 5 | # run build process as user in case of npm pre hooks 6 | # pre hooks are not executed while running as root 7 | RUN chown node:node /opt/mx-puppet-discord 8 | RUN apk --no-cache add git python3 make g++ pkgconfig \ 9 | build-base \ 10 | cairo-dev \ 11 | jpeg-dev \ 12 | pango-dev \ 13 | musl-dev \ 14 | giflib-dev \ 15 | pixman-dev \ 16 | pangomm-dev \ 17 | libjpeg-turbo-dev \ 18 | freetype-dev 19 | 20 | RUN wget -O /etc/apk/keys/sgerrand.rsa.pub https://alpine-pkgs.sgerrand.com/sgerrand.rsa.pub && \ 21 | wget -O glibc-2.32-r0.apk https://github.com/sgerrand/alpine-pkg-glibc/releases/download/2.32-r0/glibc-2.32-r0.apk && \ 22 | apk add glibc-2.32-r0.apk 23 | 24 | COPY package.json package-lock.json ./ 25 | RUN chown node:node package.json package-lock.json 26 | 27 | USER node 28 | 29 | RUN npm install 30 | 31 | COPY tsconfig.json ./ 32 | COPY src/ ./src/ 33 | RUN npm run build 34 | 35 | 36 | FROM node:12-alpine 37 | 38 | VOLUME /data 39 | 40 | ENV CONFIG_PATH=/data/config.yaml \ 41 | REGISTRATION_PATH=/data/discord-registration.yaml 42 | 43 | # su-exec is used by docker-run.sh to drop privileges 44 | RUN apk add --no-cache su-exec \ 45 | cairo \ 46 | jpeg \ 47 | pango \ 48 | musl \ 49 | giflib \ 50 | pixman \ 51 | pangomm \ 52 | libjpeg-turbo \ 53 | freetype 54 | 55 | 56 | WORKDIR /opt/mx-puppet-discord 57 | COPY docker-run.sh ./ 58 | COPY --from=builder /opt/mx-puppet-discord/node_modules/ ./node_modules/ 59 | COPY --from=builder /opt/mx-puppet-discord/build/ ./build/ 60 | 61 | # change workdir to /data so relative paths in the config.yaml 62 | # point to the persisten volume 63 | WORKDIR /data 64 | ENTRYPOINT ["/opt/mx-puppet-discord/docker-run.sh"] 65 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **THIS PROJECT HAS BEEN MIGRATED OVER TO GITLAB: [mx-puppet-bridge](https://gitlab.com/mx-puppet/discord/mx-puppet-discord)** 2 | 3 | [![Support room on Matrix](https://img.shields.io/matrix/mx-puppet-bridge:sorunome.de.svg?label=%23mx-puppet-bridge%3Asorunome.de&logo=matrix&server_fqdn=sorunome.de)](https://matrix.to/#/#mx-puppet-bridge:sorunome.de) [![donate](https://liberapay.com/assets/widgets/donate.svg)](https://liberapay.com/Sorunome/donate) 4 | 5 | # mx-puppet-discord 6 | This is a discord puppeting bridge for matrix. It handles bridging private and group DMs, as well as Guilds (servers). 7 | It is based on [mx-puppet-bridge](https://github.com/Sorunome/mx-puppet-bridge). 8 | 9 | Also see [matrix-appservice-discord](https://github.com/Half-Shot/matrix-appservice-discord) for an alternative guild-only bridge. 10 | 11 | ## Setup 12 | 13 | You need at least node 12 to be able to run this! 14 | 15 | Clone the repo and install the dependencies: 16 | 17 | ``` 18 | git clone https://github.com/matrix-discord/mx-puppet-discord 19 | cd mx-puppet-discord 20 | npm install 21 | ``` 22 | 23 | Copy and edit the configuration file to your liking: 24 | 25 | ``` 26 | cp sample.config.yaml config.yaml 27 | ... edit config.yaml ... 28 | ``` 29 | 30 | Generate an appservice registration file. Optional parameters are shown in 31 | brackets with default values: 32 | 33 | ``` 34 | npm run start -- -r [-c config.yaml] [-f discord-registration.yaml] 35 | ``` 36 | 37 | Then add the path to the registration file to your synapse `homeserver.yaml` 38 | under `app_service_config_files`, and restart synapse. 39 | 40 | Finally, run the bridge: 41 | 42 | ``` 43 | npm run start 44 | ``` 45 | 46 | ## Usage 47 | 48 | Start a chat with `@_discordpuppet_bot:yourserver.com`. When it joins, type 49 | `help` in the chat to see instructions. 50 | 51 | ### Linking a Discord bot account 52 | 53 | This is the recommended method, and allows Discord users to PM you through a 54 | bot. 55 | 56 | First visit your [Discord Application 57 | Portal](https://discordapp.com/login?redirect_to=%2Fdevelopers%2Fapplications%2Fme). 58 | 59 | 1. Click on 'New Application' 60 | 61 | ![](img/bot-1.jpg) 62 | 63 | 2. Customize your bot how you like 64 | 65 | ![](img/bot-2.jpg) 66 | 67 | 3. Go to ‘**Create Application**’ and scroll down to the next page. Find ‘**Create a Bot User**’ and click on it. 68 | 69 | ![](img/bot-3.jpg) 70 | 71 | 4. Click '**Yes, do it!** 72 | 73 | ![](img/bot-4.jpg) 74 | 75 | 5. Find the bot's token in the '**App Bot User**' section. 76 | 77 | ![](img/bot-5.jpg) 78 | 79 | 6. Click '**Click to Reveal**' 80 | 81 | ![](img/bot-6.jpg) 82 | 83 | Finally, send the appservice bot a message with the contents `link bot 84 | your.token-here`. 85 | 86 | ### Linking your Discord account 87 | 88 | **Warning**: Linking your user account's token is against Discord's Terms of Service. 89 | 90 | First [retrieve your Discord User Token](https://discordhelp.net/discord-token). 91 | If this don't work, use this method: 92 | https://github.com/Tyrrrz/DiscordChatExporter/wiki/Obtaining-Token-and-Channel-IDs#how-to-get-a-user-token 93 | 94 | Then send the bot a message with the contents `link user your.token-here`. 95 | 96 | ### Guild management 97 | 98 | As most users are in many guilds none are bridged by default. You can, however, enable bridging a guild. For that use `listguilds `, e.g. `listguilds 1`. (Puppet ID can be found with `list`.) 99 | 100 | Then, to bridge a guild, type `bridgeguild ` and to unbridge it type `unbridgeguild ` 101 | 102 | ### Friends management 103 | 104 | **IMPORTANT! This is a USER-token ONLY feature, and as such against discords TOS. When developing this test-accounts got softlocked, USE AT YOUR OWN RISK!** 105 | 106 | You first need to enable friends management with `enablefriendsmanagement `. 107 | 108 | You can view all friends and invitation status with `listfriends `. 109 | 110 | You can accept a friends request / send a friends request with `addfriend ` where `` is either the user ID (preferred) or the `username#1234`. 111 | 112 | You can remove friends with `removefriend `. 113 | 114 | ## Docker 115 | 116 | Docker image can be found at https://hub.docker.com/r/sorunome/mx-puppet-discord 117 | 118 | Alternatively build it yourself: 119 | 120 | docker build -t mx-puppet-discord . 121 | 122 | You may want some changes in your config.yaml: 123 | 124 | ```yaml 125 | bindAddress: 0.0.0.0 126 | filename: '/data/database.db' 127 | file: '/data/bridge.log' 128 | ``` 129 | 130 | Once the bridge has generated the `discord-registration.yaml` edit it to fix the 131 | address so that your matrix home server can connect to the bridge: 132 | 133 | ```yaml 134 | url: 'http://discord:8434' 135 | ``` 136 | -------------------------------------------------------------------------------- /docker-run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | if [ ! -f "$CONFIG_PATH" ]; then 4 | echo 'No config found' 5 | exit 1 6 | fi 7 | 8 | args="$@" 9 | 10 | if [ ! -f "$REGISTRATION_PATH" ]; then 11 | echo 'No registration found, generating now' 12 | args="-r" 13 | fi 14 | 15 | 16 | # if no --uid is supplied, prepare files to drop privileges 17 | if [ "$(id -u)" = 0 ]; then 18 | chown node:node /data 19 | 20 | if find *.db > /dev/null 2>&1; then 21 | # make sure sqlite files are writeable 22 | chown node:node *.db 23 | fi 24 | if find *.log.* > /dev/null 2>&1; then 25 | # make sure log files are writeable 26 | chown node:node *.log.* 27 | fi 28 | 29 | su_exec='su-exec node:node' 30 | else 31 | su_exec='' 32 | fi 33 | 34 | # $su_exec is used in case we have to drop the privileges 35 | exec $su_exec /usr/local/bin/node '/opt/mx-puppet-discord/build/index.js' \ 36 | -c "$CONFIG_PATH" \ 37 | -f "$REGISTRATION_PATH" \ 38 | $args 39 | -------------------------------------------------------------------------------- /img/bot-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-discord/mx-puppet-discord/47f57aacb383a62b335b01e8df567dc5c4338247/img/bot-1.jpg -------------------------------------------------------------------------------- /img/bot-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-discord/mx-puppet-discord/47f57aacb383a62b335b01e8df567dc5c4338247/img/bot-2.jpg -------------------------------------------------------------------------------- /img/bot-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-discord/mx-puppet-discord/47f57aacb383a62b335b01e8df567dc5c4338247/img/bot-3.jpg -------------------------------------------------------------------------------- /img/bot-4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-discord/mx-puppet-discord/47f57aacb383a62b335b01e8df567dc5c4338247/img/bot-4.jpg -------------------------------------------------------------------------------- /img/bot-5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-discord/mx-puppet-discord/47f57aacb383a62b335b01e8df567dc5c4338247/img/bot-5.jpg -------------------------------------------------------------------------------- /img/bot-6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-discord/mx-puppet-discord/47f57aacb383a62b335b01e8df567dc5c4338247/img/bot-6.jpg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mx-puppet-discord", 3 | "version": "0.0.0", 4 | "description": "", 5 | "main": "build/index.js", 6 | "scripts": { 7 | "build": "tsc", 8 | "lint": "tslint --project ./tsconfig.json -t stylish", 9 | "start": "npm run-script build && node ./build/index.js", 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "author": "Sorunome", 13 | "dependencies": { 14 | "better-discord.js": "git+https://github.com/Sorunome/better-discord.js.git#5e58e1e7510cf2192f3503ca146dd61a56a75c72", 15 | "command-line-args": "^5.1.1", 16 | "command-line-usage": "^5.0.5", 17 | "escape-html": "^1.0.3", 18 | "events": "^3.0.0", 19 | "expire-set": "^1.0.0", 20 | "js-yaml": "^3.13.1", 21 | "matrix-discord-parser": "^0.1.7", 22 | "mime": "^2.5.0", 23 | "mx-puppet-bridge": "0.1.6", 24 | "node-emoji": "^1.10.0", 25 | "path": "^0.12.7", 26 | "tslint": "^5.17.0", 27 | "typescript": "^3.7.4" 28 | }, 29 | "devDependencies": { 30 | "@types/mime": "^2.0.3", 31 | "@types/node": "^14.6.3" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /sample.config.yaml: -------------------------------------------------------------------------------- 1 | bridge: 2 | # Port to host the bridge on 3 | # Used for communication between the homeserver and the bridge 4 | port: 8434 5 | 6 | # The host connections to the bridge's webserver are allowed from 7 | bindAddress: localhost 8 | 9 | # Public domain of the homeserver 10 | domain: matrix.org 11 | 12 | # Reachable URL of the Matrix homeserver 13 | homeserverUrl: https://matrix.org 14 | 15 | # Optionally specify a different media URL used for the media store 16 | # 17 | # This is where Discord will download user profile pictures and media 18 | # from 19 | #mediaUrl: https://external-url.org 20 | 21 | # Enables automatic double-puppeting when set. Automatic double-puppeting 22 | # allows Discord accounts to control Matrix accounts. So sending a 23 | # a message on Discord would send it on Matrix from your Matrix account 24 | # 25 | # loginSharedSecretMap is simply a map from homeserver URL 26 | # to shared secret. Example: 27 | # 28 | # loginSharedSecretMap: 29 | # matrix.org: "YOUR SHARED SECRET GOES HERE" 30 | # 31 | # See https://github.com/devture/matrix-synapse-shared-secret-auth for 32 | # the necessary server module 33 | #loginSharedSecretMap: 34 | 35 | # Display name of the bridge bot 36 | displayname: Discord Puppet Bridge 37 | 38 | # Avatar URL of the bridge bot 39 | #avatarUrl: mxc://example.com/abcdef12345 40 | 41 | # Whether to create groups for each Discord Server 42 | # 43 | # Note that 'enable_group_creation' must be 'true' in Synapse's config 44 | # for this to work 45 | enableGroupSync: true 46 | 47 | presence: 48 | # Bridge Discord online/offline status 49 | enabled: true 50 | 51 | # How often to send status to the homeserver in milliseconds 52 | interval: 500 53 | 54 | provisioning: 55 | # Regex of Matrix IDs allowed to use the puppet bridge 56 | whitelist: 57 | # Allow a specific user 58 | #- "@user:server\\.com" 59 | 60 | # Allow users on a specific homeserver 61 | - "@.*:server\\.com" 62 | 63 | # Allow anyone 64 | #- ".*" 65 | 66 | # Regex of Matrix IDs forbidden from using the puppet bridge 67 | #blacklist: 68 | # Disallow a specific user 69 | #- "@user:server\\.com" 70 | 71 | # Disallow users on a specific homeserver 72 | #- "@.*:server\\.com" 73 | 74 | relay: 75 | # Regex of Matrix IDs who are allowed to use the bridge in relay mode. 76 | # Relay mode is when a single Discord bot account relays messages of 77 | # multiple Matrix users 78 | # 79 | # Same format as in provisioning 80 | whitelist: 81 | - "@.*:yourserver\\.com" 82 | 83 | #blacklist: 84 | #- "@user:yourserver\\.com" 85 | 86 | selfService: 87 | # Regex of Matrix IDs who are allowed to use bridge self-servicing (plumbed rooms) 88 | # 89 | # Same format as in provisioning 90 | whitelist: 91 | - "@.*:server\\.com" 92 | 93 | #blacklist: 94 | #- "@user:server\\.com" 95 | 96 | # Map of homeserver URLs to their C-S API endpoint 97 | # 98 | # Useful for double-puppeting if .well-known is unavailable for some reason 99 | #homeserverUrlMap: 100 | #yourserver.com: http://localhost:1234 101 | 102 | # Override the default name patterns for users, rooms and groups 103 | # 104 | # Variable names must be prefixed with a ':' 105 | namePatterns: 106 | # The default displayname for a bridged user 107 | # 108 | # Available variables: 109 | # 110 | # name: username of the user 111 | # discriminator: hashtag of the user (ex. #1234) 112 | user: :name 113 | 114 | # A user's guild-specific displayname - if they've set a custom nick in 115 | # a guild 116 | # 117 | # Available variables: 118 | # 119 | # name: username of the user 120 | # discriminator: hashtag of the user (ex. #1234) 121 | # displayname: the user's custom group-specific nick 122 | # channel: the name of the channel 123 | # guild: the name of the guild 124 | userOverride: :displayname 125 | 126 | # Room names for bridged Discord channels 127 | # 128 | # Available variables: 129 | # 130 | # name: name of the channel 131 | # guild: name of the guild 132 | # category: name of the category if existant 133 | room: :name 134 | 135 | # Group names for bridged Discord servers 136 | # 137 | # Available variables: 138 | # 139 | # name: name of the guide 140 | group: :name 141 | 142 | database: 143 | # Use Postgres as a database backend. If set, will be used instead of SQLite3 144 | # 145 | # Connection string to connect to the Postgres instance 146 | # with username "user", password "pass", host "localhost" and database name "dbname". 147 | # 148 | # Modify each value as necessary 149 | #connString: "postgres://user:pass@localhost/dbname?sslmode=disable" 150 | 151 | # Use SQLite3 as a database backend 152 | # 153 | # The name of the database file 154 | filename: database.db 155 | 156 | limits: 157 | # Up to how many users should be auto-joined on room creation? -1 to disable 158 | # auto-join functionality 159 | # 160 | # Defaults to 200 161 | #maxAutojoinUsers: 200 162 | 163 | # How long the delay between two auto-join users should be in milliseconds 164 | # 165 | # Defaults to 5000 166 | #roomUserAutojoinDelay: 5000 167 | 168 | logging: 169 | # Log level of console output 170 | # 171 | # Allowed values starting with most verbose: 172 | # silly, verbose, info, warn, error 173 | console: info 174 | 175 | # Date and time formatting 176 | lineDateFormat: MMM-D HH:mm:ss.SSS 177 | 178 | # Logging files 179 | # 180 | # Log files are rotated daily by default 181 | files: 182 | # Log file path 183 | - file: "bridge.log" 184 | # Log level for this file 185 | # 186 | # Allowed values starting with most verbose: 187 | # silly, debug, verbose, info, warn, error 188 | level: info 189 | 190 | # Date and time formatting 191 | datePattern: YYYY-MM-DD 192 | 193 | # Maximum number of logs to keep. 194 | # 195 | # This can be a number of files or number of days. 196 | # If using days, add 'd' as a suffix 197 | maxFiles: 14d 198 | 199 | # Maximum size of the file after which it will rotate. 200 | # This can be a number of bytes, or units of kb, mb, and gb. 201 | 202 | # If using units, add 'k', 'm', or 'g' as the suffix 203 | maxSize: 50m 204 | -------------------------------------------------------------------------------- /src/Commands.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019, 2020 mx-puppet-discord 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. 12 | */ 13 | 14 | import { App } from "./app"; 15 | import { SendMessageFn, Log } from "mx-puppet-bridge"; 16 | import * as Discord from "better-discord.js"; 17 | import { BridgeableGuildChannel } from "./discord/DiscordUtil"; 18 | 19 | const log = new Log("DiscordPuppet:Commands"); 20 | const MAX_MSG_SIZE = 4000; 21 | 22 | export class Commands { 23 | constructor(private readonly app: App) {} 24 | 25 | public async commandSyncProfile(puppetId: number, param: string, sendMessage: SendMessageFn) { 26 | const p = this.app.puppets[puppetId]; 27 | if (!p) { 28 | await sendMessage("Puppet not found!"); 29 | return; 30 | } 31 | // only bots are allowed to profile sync, for security reasons 32 | const syncProfile = p.client.user!.bot ? param === "1" || param.toLowerCase() === "true" : false; 33 | p.data.syncProfile = syncProfile; 34 | await this.app.puppet.setPuppetData(puppetId, p.data); 35 | if (syncProfile) { 36 | await sendMessage("Syncing discord profile with matrix profile now"); 37 | await this.app.updateUserInfo(puppetId); 38 | } else { 39 | await sendMessage("Stopped syncing discord profile with matrix profile"); 40 | } 41 | } 42 | 43 | public async commandJoinEntireGuild(puppetId: number, param: string, sendMessage: SendMessageFn) { 44 | const p = this.app.puppets[puppetId]; 45 | if (!p) { 46 | await sendMessage("Puppet not found!"); 47 | return; 48 | } 49 | const guild = p.client.guilds.cache.get(param); 50 | if (!guild) { 51 | await sendMessage("Guild not found!"); 52 | return; 53 | } 54 | if (!(await this.app.store.isGuildBridged(puppetId, guild.id))) { 55 | await sendMessage("Guild not bridged!"); 56 | return; 57 | } 58 | for (const chan of guild.channels.cache.array()) { 59 | if (!this.app.discord.isBridgeableGuildChannel(chan)) { 60 | continue; 61 | } 62 | const gchan = chan as BridgeableGuildChannel; 63 | if (gchan.members.has(p.client.user!.id)) { 64 | const remoteChan = this.app.matrix.getRemoteRoom(puppetId, gchan); 65 | await this.app.puppet.bridgeRoom(remoteChan); 66 | } 67 | } 68 | await sendMessage(`Invited to all channels in guild ${guild.name}!`); 69 | } 70 | 71 | public async commandListGuilds(puppetId: number, param: string, sendMessage: SendMessageFn) { 72 | const p = this.app.puppets[puppetId]; 73 | if (!p) { 74 | await sendMessage("Puppet not found!"); 75 | return; 76 | } 77 | const guilds = await this.app.store.getBridgedGuilds(puppetId); 78 | let sendStr = "Guilds:\n"; 79 | for (const guild of p.client.guilds.cache.array()) { 80 | let sendStrPart = ` - ${guild.name} (\`${guild.id}\`)`; 81 | if (guilds.includes(guild.id)) { 82 | sendStrPart += " **bridged!**"; 83 | } 84 | sendStrPart += "\n"; 85 | if (sendStr.length + sendStrPart.length > MAX_MSG_SIZE) { 86 | await sendMessage(sendStr); 87 | sendStr = ""; 88 | } 89 | sendStr += sendStrPart; 90 | } 91 | await sendMessage(sendStr); 92 | } 93 | 94 | public async commandAcceptInvite(puppetId: number, param: string, sendMessage: SendMessageFn) { 95 | const p = this.app.puppets[puppetId]; 96 | if (!p) { 97 | await sendMessage("Puppet not found!"); 98 | return; 99 | } 100 | const matches = param.match(/^(?:https?:\/\/)?(?:discord\.gg\/|discordapp\.com\/invite\/)?([^?\/\s]+)/i); 101 | if (!matches) { 102 | await sendMessage("No invite code found!"); 103 | return; 104 | } 105 | const inviteCode = matches[1]; 106 | try { 107 | const guild = await p.client.acceptInvite(inviteCode); 108 | if (!guild) { 109 | await sendMessage("Something went wrong"); 110 | } else { 111 | await sendMessage(`Accepted invite to guild ${guild.name}!`); 112 | } 113 | } catch (err) { 114 | if (err.message) { 115 | await sendMessage(`Invalid invite code \`${inviteCode}\`: ${err.message}`); 116 | } else { 117 | await sendMessage(`Invalid invite code \`${inviteCode}\``); 118 | } 119 | log.warn(`Invalid invite code ${inviteCode}:`, err); 120 | } 121 | } 122 | 123 | public async commandBridgeGuild(puppetId: number, param: string, sendMessage: SendMessageFn) { 124 | const p = this.app.puppets[puppetId]; 125 | if (!p) { 126 | await sendMessage("Puppet not found!"); 127 | return; 128 | } 129 | const guild = p.client.guilds.cache.get(param); 130 | if (!guild) { 131 | await sendMessage("Guild not found!"); 132 | return; 133 | } 134 | await this.app.store.setBridgedGuild(puppetId, guild.id); 135 | let msg = `Guild ${guild.name} (\`${guild.id}\`) is now being bridged! 136 | 137 | Either type \`joinentireguild ${puppetId} ${guild.id}\` to get invited to all the channels of that guild `; 138 | msg += `or type \`listrooms\` and join that way. 139 | 140 | Additionally you will be invited to guild channels as messages are sent in them.`; 141 | await sendMessage(msg); 142 | } 143 | 144 | public async commandUnbridgeGuild(puppetId: number, param: string, sendMessage: SendMessageFn) { 145 | const p = this.app.puppets[puppetId]; 146 | if (!p) { 147 | await sendMessage("Puppet not found!"); 148 | return; 149 | } 150 | const bridged = await this.app.store.isGuildBridged(puppetId, param); 151 | if (!bridged) { 152 | await sendMessage("Guild wasn't bridged!"); 153 | return; 154 | } 155 | await this.app.store.removeBridgedGuild(puppetId, param); 156 | await sendMessage("Unbridged guild!"); 157 | } 158 | 159 | public async commandBridgeChannel(puppetId: number, param: string, sendMessage: SendMessageFn) { 160 | const p = this.app.puppets[puppetId]; 161 | if (!p) { 162 | await sendMessage("Puppet not found!"); 163 | return; 164 | } 165 | let channel: BridgeableGuildChannel | undefined; 166 | let guild: Discord.Guild | undefined; 167 | for (const g of p.client.guilds.cache.array()) { 168 | channel = g.channels.resolve(param) as BridgeableGuildChannel; 169 | if (this.app.discord.isBridgeableGuildChannel(channel)) { 170 | guild = g; 171 | break; 172 | } 173 | channel = undefined; 174 | } 175 | if (!channel || !guild) { 176 | await sendMessage("Channel not found!"); 177 | return; 178 | } 179 | await this.app.store.setBridgedChannel(puppetId, channel.id); 180 | await sendMessage(`Channel ${channel.name} (\`${channel.id}\`) of guild ${guild.name} is now been bridged!`); 181 | } 182 | 183 | public async commandUnbridgeChannel(puppetId: number, param: string, sendMessage: SendMessageFn) { 184 | const p = this.app.puppets[puppetId]; 185 | if (!p) { 186 | await sendMessage("Puppet not found!"); 187 | return; 188 | } 189 | const bridged = await this.app.store.isChannelBridged(puppetId, param); 190 | if (!bridged) { 191 | await sendMessage("Channel wasn't bridged!"); 192 | return; 193 | } 194 | await this.app.store.removeBridgedChannel(puppetId, param); 195 | await sendMessage("Unbridged channel!"); 196 | } 197 | 198 | public async commandBridgeAll(puppetId: number, param: string, sendMessage: SendMessageFn) { 199 | const p = this.app.puppets[puppetId]; 200 | if (!p) { 201 | await sendMessage("Puppet not found!"); 202 | return; 203 | } 204 | if (param == null || param == undefined) { 205 | await sendMessage("Usage: `bridgeall <1/0>`"); 206 | } 207 | const bridgeAll = param === "1" || param.toLowerCase() === "true"; 208 | p.data.bridgeAll = bridgeAll; 209 | await this.app.puppet.setPuppetData(puppetId, p.data); 210 | if (bridgeAll) { 211 | await sendMessage("Bridging everything now"); 212 | } else { 213 | await sendMessage("Not bridging everything anymore"); 214 | } 215 | } 216 | 217 | public async commandEnableFriendsManagement(puppetId: number, param: string, sendMessage: SendMessageFn) { 218 | const p = this.app.puppets[puppetId]; 219 | if (!p) { 220 | await sendMessage("Puppet not found!"); 221 | return; 222 | } 223 | if (p.data.friendsManagement) { 224 | await sendMessage("Friends management is already enabled."); 225 | return; 226 | } 227 | if (param === "YES I KNOW THE RISKS") { 228 | p.data.friendsManagement = true; 229 | await this.app.puppet.setPuppetData(puppetId, p.data); 230 | await sendMessage("Friends management enabled!"); 231 | return; 232 | } 233 | await sendMessage(`Using user accounts is against discords TOS. As this is required for friends management, you ` + 234 | `will be breaking discords TOS if you enable this feature. Development of it has already softlocked accounts. ` + 235 | `USE AT YOUR OWN RISK!\n\nIf you want to enable friends management type \`enablefriendsmanagement ${puppetId} ` + 236 | `YES I KNOW THE RISKS\``); 237 | } 238 | 239 | public async commandListFriends(puppetId: number, param: string, sendMessage: SendMessageFn) { 240 | const p = this.app.puppets[puppetId]; 241 | if (!p) { 242 | await sendMessage("Puppet not found!"); 243 | return; 244 | } 245 | if (!p.data.friendsManagement) { 246 | await sendMessage(`Friends management is disabled. Please type ` + 247 | `\`enablefriendsmanagement ${puppetId}\` to enable it`); 248 | return; 249 | } 250 | let sendStr = ""; 251 | const friends = p.client.user!.relationships.friends; 252 | if (friends.size > 0) { 253 | sendStr += "Friends:\n"; 254 | for (const user of p.client.user!.relationships.friends.array()) { 255 | const mxid = await this.app.puppet.getMxidForUser({ 256 | puppetId, 257 | userId: user.id, 258 | }); 259 | const sendStrPart = ` - ${user.username} (\`${user.id}\`): [${user.username}](https://matrix.to/#/${mxid})\n`; 260 | if (sendStr.length + sendStrPart.length > MAX_MSG_SIZE) { 261 | await sendMessage(sendStr); 262 | sendStr = ""; 263 | } 264 | sendStr += sendStrPart; 265 | } 266 | } 267 | const incoming = p.client.user!.relationships.incoming; 268 | if (incoming.size > 0) { 269 | sendStr += "\nIncoming friend requests:\n"; 270 | for (const user of incoming.array()) { 271 | const sendStrPart = ` - ${user.username} (\`${user.id}\`)\n`; 272 | if (sendStr.length + sendStrPart.length > MAX_MSG_SIZE) { 273 | await sendMessage(sendStr); 274 | sendStr = ""; 275 | } 276 | sendStr += sendStrPart; 277 | } 278 | } 279 | const outgoing = p.client.user!.relationships.outgoing; 280 | if (outgoing.size > 0) { 281 | sendStr += "\nOutgoing friend requests:\n"; 282 | for (const user of outgoing.array()) { 283 | const sendStrPart = ` - ${user.username} (\`${user.id}\`)\n`; 284 | if (sendStr.length + sendStrPart.length > MAX_MSG_SIZE) { 285 | await sendMessage(sendStr); 286 | sendStr = ""; 287 | } 288 | sendStr += sendStrPart; 289 | } 290 | } 291 | await sendMessage(sendStr); 292 | } 293 | 294 | public async commandAddFriend(puppetId: number, param: string, sendMessage: SendMessageFn) { 295 | const p = this.app.puppets[puppetId]; 296 | if (!p) { 297 | await sendMessage("Puppet not found!"); 298 | return; 299 | } 300 | if (!p.data.friendsManagement) { 301 | await sendMessage(`Friends management is disabled. Please type ` + 302 | `\`enablefriendsmanagement ${puppetId}\` to enable it`); 303 | return; 304 | } 305 | try { 306 | const user = await p.client.user!.relationships.request("friend", param); 307 | if (user) { 308 | await sendMessage(`Added/sent friend request to ${typeof user === "string" ? user : user.username}!`); 309 | } else { 310 | await sendMessage("User not found"); 311 | } 312 | } catch (err) { 313 | await sendMessage("User not found"); 314 | log.warn(`Couldn't find user ${param}:`, err); 315 | } 316 | } 317 | 318 | public async commandRemoveFriend(puppetId: number, param: string, sendMessage: SendMessageFn) { 319 | const p = this.app.puppets[puppetId]; 320 | if (!p) { 321 | await sendMessage("Puppet not found!"); 322 | return; 323 | } 324 | if (!p.data.friendsManagement) { 325 | await sendMessage(`Friends management is disabled. Please type ` + 326 | `\`enablefriendsmanagement ${puppetId}\` to enable it`); 327 | return; 328 | } 329 | try { 330 | const user = await p.client.user!.relationships.remove(param); 331 | if (user) { 332 | await sendMessage(`Removed ${user.username} as friend!`); 333 | } else { 334 | await sendMessage("User not found"); 335 | } 336 | } catch (err) { 337 | await sendMessage("User not found"); 338 | log.warn(`Couldn't find user ${param}:`, err); 339 | } 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable: no-any */ 2 | /* 3 | Copyright 2019, 2020 mx-puppet-discord 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | */ 14 | 15 | import { 16 | PuppetBridge, 17 | Log, 18 | Util, 19 | IRetList, 20 | MessageDeduplicator, 21 | IRemoteRoom, 22 | } from "mx-puppet-bridge"; 23 | import * as Discord from "better-discord.js"; 24 | import { 25 | DiscordMessageParser, 26 | MatrixMessageParser, 27 | } from "matrix-discord-parser"; 28 | import * as path from "path"; 29 | import * as mime from "mime"; 30 | import { DiscordStore } from "./store"; 31 | import { 32 | DiscordUtil, TextGuildChannel, TextChannel, BridgeableGuildChannel, BridgeableChannel, 33 | } from "./discord/DiscordUtil"; 34 | import { MatrixUtil } from "./matrix/MatrixUtil"; 35 | import { Commands } from "./Commands"; 36 | import ExpireSet from "expire-set"; 37 | import * as Emoji from "node-emoji"; 38 | 39 | const log = new Log("DiscordPuppet:App"); 40 | export const AVATAR_SETTINGS: Discord.ImageURLOptions & { dynamic?: boolean | undefined; } 41 | = { format: "png", size: 2048, dynamic: true }; 42 | export const MAXFILESIZE = 8000000; 43 | 44 | export interface IDiscordPuppet { 45 | client: Discord.Client; 46 | data: any; 47 | deletedMessages: ExpireSet; 48 | } 49 | 50 | export interface IDiscordPuppets { 51 | [puppetId: number]: IDiscordPuppet; 52 | } 53 | 54 | export interface IDiscordSendFile { 55 | buffer: Buffer; 56 | filename: string; 57 | url: string; 58 | isImage: boolean; 59 | } 60 | 61 | export class App { 62 | public puppets: IDiscordPuppets = {}; 63 | public discordMsgParser: DiscordMessageParser; 64 | public matrixMsgParser: MatrixMessageParser; 65 | public messageDeduplicator: MessageDeduplicator; 66 | public store: DiscordStore; 67 | public lastEventIds: {[chan: string]: string} = {}; 68 | 69 | public readonly discord: DiscordUtil; 70 | public readonly matrix: MatrixUtil; 71 | public readonly commands: Commands; 72 | 73 | constructor( 74 | public puppet: PuppetBridge, 75 | ) { 76 | this.discordMsgParser = new DiscordMessageParser(); 77 | this.matrixMsgParser = new MatrixMessageParser(); 78 | this.messageDeduplicator = new MessageDeduplicator(); 79 | this.store = new DiscordStore(puppet.store); 80 | 81 | this.discord = new DiscordUtil(this); 82 | this.matrix = new MatrixUtil(this); 83 | this.commands = new Commands(this); 84 | } 85 | 86 | public async init(): Promise { 87 | await this.store.init(); 88 | } 89 | 90 | public async handlePuppetName(puppetId: number, name: string) { 91 | const p = this.puppets[puppetId]; 92 | if (!p || !p.data.syncProfile || !p.client.user!.bot) { 93 | // bots can't change their name 94 | return; 95 | } 96 | try { 97 | await p.client.user!.setUsername(name); 98 | } catch (err) { 99 | log.warn(`Couldn't set name for ${puppetId}`, err); 100 | } 101 | } 102 | 103 | public async handlePuppetAvatar(puppetId: number, url: string, mxc: string) { 104 | const p = this.puppets[puppetId]; 105 | if (!p || !p.data.syncProfile) { 106 | return; 107 | } 108 | try { 109 | const AVATAR_SIZE = 800; 110 | const realUrl = this.puppet.getUrlFromMxc(mxc, AVATAR_SIZE, AVATAR_SIZE, "scale"); 111 | const buffer = await Util.DownloadFile(realUrl); 112 | await p.client.user!.setAvatar(buffer); 113 | } catch (err) { 114 | log.warn(`Couldn't set avatar for ${puppetId}`, err); 115 | } 116 | } 117 | 118 | public async newPuppet(puppetId: number, data: any) { 119 | log.info(`Adding new Puppet: puppetId=${puppetId}`); 120 | if (this.puppets[puppetId]) { 121 | await this.deletePuppet(puppetId); 122 | } 123 | let client: Discord.Client; 124 | if (data.bot || false) { 125 | client = new Discord.Client({ ws: { intents: Discord.Intents.NON_PRIVILEGED }}); 126 | } else { 127 | client = new Discord.Client(); 128 | } 129 | client.on("ready", async () => { 130 | const d = this.puppets[puppetId].data; 131 | d.username = client.user!.tag; 132 | d.id = client.user!.id; 133 | d.bot = client.user!.bot; 134 | await this.puppet.setUserId(puppetId, client.user!.id); 135 | await this.puppet.setPuppetData(puppetId, d); 136 | await this.puppet.sendStatusMessage(puppetId, "connected"); 137 | await this.updateUserInfo(puppetId); 138 | this.puppet.trackConnectionStatus(puppetId, true); 139 | // set initial presence for everyone 140 | for (const user of client.users.cache.array()) { 141 | await this.discord.updatePresence(puppetId, user.presence); 142 | } 143 | }); 144 | client.on("message", async (msg: Discord.Message) => { 145 | try { 146 | await this.discord.events.handleDiscordMessage(puppetId, msg); 147 | } catch (err) { 148 | log.error("Error handling discord message event", err.error || err.body || err); 149 | } 150 | }); 151 | client.on("messageUpdate", async (msg1: Discord.Message, msg2: Discord.Message) => { 152 | try { 153 | await this.discord.events.handleDiscordMessageUpdate(puppetId, msg1, msg2); 154 | } catch (err) { 155 | log.error("Error handling discord messageUpdate event", err.error || err.body || err); 156 | } 157 | }); 158 | client.on("messageDelete", async (msg: Discord.Message) => { 159 | try { 160 | await this.discord.events.handleDiscordMessageDelete(puppetId, msg); 161 | } catch (err) { 162 | log.error("Error handling discord messageDelete event", err.error || err.body || err); 163 | } 164 | }); 165 | client.on("messageDeleteBulk", async (msgs: Discord.Collection) => { 166 | for (const msg of msgs.array()) { 167 | try { 168 | await this.discord.events.handleDiscordMessageDelete(puppetId, msg); 169 | } catch (err) { 170 | log.error("Error handling one discord messageDeleteBulk event", err.error || err.body || err); 171 | } 172 | } 173 | }); 174 | client.on("typingStart", async (chan: Discord.Channel, user: Discord.User) => { 175 | try { 176 | if (!this.discord.isBridgeableChannel(chan)) { 177 | return; 178 | } 179 | const params = this.matrix.getSendParams(puppetId, chan as BridgeableChannel, user); 180 | await this.puppet.setUserTyping(params, true); 181 | } catch (err) { 182 | log.error("Error handling discord typingStart event", err.error || err.body || err); 183 | } 184 | }); 185 | client.on("presenceUpdate", async (_, presence: Discord.Presence) => { 186 | try { 187 | await this.discord.updatePresence(puppetId, presence); 188 | } catch (err) { 189 | log.error("Error handling discord presenceUpdate event", err.error || err.body || err); 190 | } 191 | }); 192 | client.on("messageReactionAdd", async (reaction: Discord.MessageReaction, user: Discord.User) => { 193 | try { 194 | // TODO: filter out echo back? 195 | const chan = reaction.message.channel; 196 | if (!await this.bridgeRoom(puppetId, chan)) { 197 | return; 198 | } 199 | const params = this.matrix.getSendParams(puppetId, chan, user); 200 | if (reaction.emoji.id) { 201 | const mxc = await this.matrix.getEmojiMxc( 202 | puppetId, reaction.emoji.name, reaction.emoji.animated, reaction.emoji.id, 203 | ); 204 | await this.puppet.sendReaction(params, reaction.message.id, mxc || reaction.emoji.name); 205 | } else { 206 | await this.puppet.sendReaction(params, reaction.message.id, this.emojiAddVariant(reaction.emoji.name)); 207 | } 208 | } catch (err) { 209 | log.error("Error handling discord messageReactionAdd event", err.error || err.body || err); 210 | } 211 | }); 212 | client.on("messageReactionRemove", async (reaction: Discord.MessageReaction, user: Discord.User) => { 213 | try { 214 | // TODO: filter out echo back? 215 | const chan = reaction.message.channel; 216 | if (!await this.bridgeRoom(puppetId, chan)) { 217 | return; 218 | } 219 | const params = this.matrix.getSendParams(puppetId, chan, user); 220 | if (reaction.emoji.id) { 221 | const mxc = await this.matrix.getEmojiMxc( 222 | puppetId, reaction.emoji.name, reaction.emoji.animated, reaction.emoji.id, 223 | ); 224 | await this.puppet.removeReaction(params, reaction.message.id, mxc || reaction.emoji.name); 225 | } else { 226 | await this.puppet.removeReaction(params, reaction.message.id, this.emojiAddVariant(reaction.emoji.name)); 227 | } 228 | } catch (err) { 229 | log.error("Error handling discord messageReactionRemove event", err.error || err.body || err); 230 | } 231 | }); 232 | client.on("messageReactionRemoveAll", async (message: Discord.Message) => { 233 | try { 234 | const chan = message.channel; 235 | if (!await this.bridgeRoom(puppetId, chan)) { 236 | return; 237 | } 238 | // alright, let's fetch *an* admin user 239 | let user: Discord.User; 240 | if (this.discord.isBridgeableGuildChannel(chan)) { 241 | const gchan = chan as BridgeableGuildChannel; 242 | user = gchan.guild.owner ? gchan.guild.owner.user : client.user!; 243 | } else if (chan instanceof Discord.DMChannel) { 244 | user = chan.recipient; 245 | } else if (chan instanceof Discord.GroupDMChannel) { 246 | user = chan.owner; 247 | } else { 248 | user = client.user!; 249 | } 250 | const params = this.matrix.getSendParams(puppetId, chan, user); 251 | await this.puppet.removeAllReactions(params, message.id); 252 | } catch (err) { 253 | log.error("Error handling discord messageReactionRemoveAll event", err.error || err.body || err); 254 | } 255 | }); 256 | client.on("channelUpdate", async (_, chan: Discord.Channel) => { 257 | if (!this.discord.isBridgeableChannel(chan)) { 258 | return; 259 | } 260 | const remoteChan = this.matrix.getRemoteRoom(puppetId, chan as BridgeableChannel); 261 | await this.puppet.updateRoom(remoteChan); 262 | }); 263 | client.on("guildMemberUpdate", async (oldMember: Discord.GuildMember, newMember: Discord.GuildMember) => { 264 | const promiseList: Promise[] = []; 265 | if (oldMember.displayName !== newMember.displayName) { 266 | promiseList.push((async () => { 267 | const remoteUser = this.matrix.getRemoteUser(puppetId, newMember); 268 | await this.puppet.updateUser(remoteUser); 269 | })()); 270 | } 271 | // aaaand check for role change 272 | const leaveRooms = new Set(); 273 | const joinRooms = new Set(); 274 | for (const chan of newMember.guild.channels.cache.array()) { 275 | if (!this.discord.isBridgeableGuildChannel(chan)) { 276 | continue; 277 | } 278 | const gchan = chan as BridgeableGuildChannel; 279 | if (gchan.members.has(newMember.id)) { 280 | joinRooms.add(gchan); 281 | } else { 282 | leaveRooms.add(gchan); 283 | } 284 | } 285 | for (const chan of leaveRooms) { 286 | promiseList.push((async () => { 287 | const params = this.matrix.getSendParams(puppetId, chan, newMember); 288 | await this.puppet.removeUser(params); 289 | })()); 290 | } 291 | for (const chan of joinRooms) { 292 | promiseList.push((async () => { 293 | const params = this.matrix.getSendParams(puppetId, chan, newMember); 294 | await this.puppet.addUser(params); 295 | })()); 296 | } 297 | await Promise.all(promiseList); 298 | }); 299 | client.on("userUpdate", async (_, user: Discord.User) => { 300 | const remoteUser = this.matrix.getRemoteUser(puppetId, user); 301 | await this.puppet.updateUser(remoteUser); 302 | }); 303 | client.on("guildUpdate", async (_, guild: Discord.Guild) => { 304 | try { 305 | const remoteGroup = await this.matrix.getRemoteGroup(puppetId, guild); 306 | await this.puppet.updateGroup(remoteGroup); 307 | for (const chan of guild.channels.cache.array()) { 308 | if (!this.discord.isBridgeableGuildChannel(chan)) { 309 | return; 310 | } 311 | const remoteChan = this.matrix.getRemoteRoom(puppetId, chan as BridgeableGuildChannel); 312 | await this.puppet.updateRoom(remoteChan); 313 | } 314 | } catch (err) { 315 | log.error("Error handling discord guildUpdate event", err.error || err.body || err); 316 | } 317 | }); 318 | client.on("relationshipAdd", async (_, relationship: Discord.Relationship) => { 319 | if (relationship.type === "incoming") { 320 | const msg = `New incoming friends request from ${relationship.user.username}! 321 | 322 | Type \`addfriend ${puppetId} ${relationship.user.id}\` to accept it.`; 323 | await this.puppet.sendStatusMessage(puppetId, msg); 324 | } 325 | }); 326 | client.on("guildMemberAdd", async (member: Discord.GuildMember) => { 327 | const promiseList: Promise[] = []; 328 | for (const chan of member.guild.channels.cache.array()) { 329 | if ((await this.bridgeRoom(puppetId, chan)) && chan.members.has(member.id)) { 330 | promiseList.push((async () => { 331 | const params = this.matrix.getSendParams(puppetId, chan as BridgeableGuildChannel, member); 332 | await this.puppet.addUser(params); 333 | })()); 334 | } 335 | } 336 | await Promise.all(promiseList); 337 | }); 338 | client.on("guildMemberRemove", async (member: Discord.GuildMember) => { 339 | const promiseList: Promise[] = []; 340 | for (const chan of member.guild.channels.cache.array()) { 341 | if (this.discord.isBridgeableGuildChannel(chan)) { 342 | promiseList.push((async () => { 343 | const params = this.matrix.getSendParams(puppetId, chan as BridgeableGuildChannel, member); 344 | await this.puppet.removeUser(params); 345 | })()); 346 | } 347 | } 348 | await Promise.all(promiseList); 349 | }); 350 | const TWO_MIN = 120000; 351 | this.puppets[puppetId] = { 352 | client, 353 | data, 354 | deletedMessages: new ExpireSet(TWO_MIN), 355 | }; 356 | try { 357 | await client.login(data.token, data.bot || false); 358 | } catch (e) { 359 | log.error(`Failed to log in puppetId ${puppetId}:`, e); 360 | await this.puppet.sendStatusMessage(puppetId, "Failed to connect: " + e); 361 | this.puppet.trackConnectionStatus(puppetId, false); 362 | } 363 | } 364 | 365 | public async deletePuppet(puppetId: number) { 366 | log.info(`Got signal to quit Puppet: puppetId=${puppetId}`); 367 | const p = this.puppets[puppetId]; 368 | if (!p) { 369 | return; // nothing to do 370 | } 371 | p.client.destroy(); 372 | delete this.puppet[puppetId]; 373 | } 374 | 375 | public async listUsers(puppetId: number): Promise { 376 | const retUsers: IRetList[] = []; 377 | const retGuilds: IRetList[] = []; 378 | const p = this.puppets[puppetId]; 379 | if (!p) { 380 | return []; 381 | } 382 | const blacklistedIds = [p.client.user!.id, "1"]; 383 | for (const guild of p.client.guilds.cache.array()) { 384 | retGuilds.push({ 385 | category: true, 386 | name: guild.name, 387 | }); 388 | for (const member of guild.members.cache.array()) { 389 | if (!blacklistedIds.includes(member.user.id)) { 390 | retGuilds.push({ 391 | name: member.user.username, 392 | id: member.user.id, 393 | }); 394 | } 395 | } 396 | } 397 | 398 | for (const user of p.client.users.cache.array()) { 399 | const found = retGuilds.find((element) => element.id === user.id); 400 | if (!found && !blacklistedIds.includes(user.id)) { 401 | retUsers.push({ 402 | name: user.username, 403 | id: user.id, 404 | }); 405 | } 406 | } 407 | 408 | return retUsers.concat(retGuilds); 409 | } 410 | 411 | public async getUserIdsInRoom(room: IRemoteRoom): Promise | null> { 412 | const chan = await this.discord.getDiscordChan(room); 413 | if (!chan) { 414 | return null; 415 | } 416 | const users = new Set(); 417 | if (chan instanceof Discord.DMChannel) { 418 | users.add(chan.recipient.id); 419 | return users; 420 | } 421 | if (chan instanceof Discord.GroupDMChannel) { 422 | for (const recipient of chan.recipients.array()) { 423 | users.add(recipient.id); 424 | } 425 | return users; 426 | } 427 | if (this.discord.isBridgeableGuildChannel(chan)) { 428 | // chan.members already does a permission check, yay! 429 | const gchan = chan as BridgeableGuildChannel; 430 | for (const member of gchan.members.array()) { 431 | users.add(member.id); 432 | } 433 | return users; 434 | } 435 | return null; 436 | } 437 | 438 | public async updateUserInfo(puppetId: number) { 439 | const p = this.puppets[puppetId]; 440 | if (!p || !p.data.syncProfile) { 441 | return; 442 | } 443 | const userInfo = await this.puppet.getPuppetMxidInfo(puppetId); 444 | if (userInfo) { 445 | if (userInfo.name) { 446 | await this.handlePuppetName(puppetId, userInfo.name); 447 | } 448 | if (userInfo.avatarUrl) { 449 | await this.handlePuppetAvatar(puppetId, userInfo.avatarUrl, userInfo.avatarMxc as string); 450 | } 451 | } 452 | } 453 | 454 | public async bridgeRoom(puppetId: number, chan: Discord.Channel): Promise { 455 | if (["dm", "group"].includes(chan.type)) { 456 | return true; // we handle all dm and group channels 457 | } 458 | if (!this.discord.isBridgeableChannel(chan)) { 459 | return false; // we only handle text and news things 460 | } 461 | if (this.puppets[puppetId] && this.puppets[puppetId].data.bridgeAll) { 462 | return true; // we want to bridge everything anyways, no need to hit the store 463 | } 464 | if (this.discord.isBridgeableGuildChannel(chan)) { 465 | // we have a guild text channel, maybe we handle it! 466 | const gchan = chan as BridgeableGuildChannel; 467 | if (await this.store.isGuildBridged(puppetId, gchan.guild.id)) { 468 | return true; 469 | } 470 | // maybe it is a single channel override? 471 | return await this.store.isChannelBridged(puppetId, gchan.id); 472 | } 473 | return false; 474 | } 475 | 476 | public getFilenameForMedia(filename: string, mimetype: string): string { 477 | let ext = ""; 478 | const mimeExt = mime.getExtension(mimetype); 479 | if (mimeExt) { 480 | ext = "." + ({ 481 | oga: "ogg", 482 | }[mimeExt] || mimeExt); 483 | } 484 | if (filename) { 485 | if (path.extname(filename) !== "") { 486 | return filename; 487 | } 488 | return path.basename(filename) + ext; 489 | } 490 | return "matrix-media" + ext; 491 | } 492 | 493 | public emojiAddVariant(s: string): string { 494 | if (Emoji.find(s + "\ufe0f")) { 495 | return s + "\ufe0f"; 496 | } 497 | return s; 498 | } 499 | } 500 | -------------------------------------------------------------------------------- /src/db/schema/v1.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019 mx-puppet-discord 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. 12 | */ 13 | 14 | import { IDbSchema, Store } from "mx-puppet-bridge"; 15 | 16 | export class Schema implements IDbSchema { 17 | public description = "Schema, Emotestore"; 18 | public async run(store: Store) { 19 | await store.createTable(` 20 | CREATE TABLE discord_schema ( 21 | version INTEGER UNIQUE NOT NULL 22 | );`, "discord_schema"); 23 | await store.db.Exec("INSERT INTO discord_schema VALUES (0);"); 24 | await store.createTable(` 25 | CREATE TABLE discord_emoji ( 26 | emoji_id TEXT NOT NULL, 27 | name TEXT NOT NULL, 28 | animated INTEGER NOT NULL, 29 | mxc_url TEXT NOT NULL, 30 | PRIMARY KEY(emoji_id) 31 | );`, "discord_emoji"); 32 | } 33 | public async rollBack(store: Store) { 34 | await store.db.Exec("DROP TABLE IF EXISTS discord_schema"); 35 | await store.db.Exec("DROP TABLE IF EXISTS discord_emoji"); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/db/schema/v2.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 mx-puppet-discord 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. 12 | */ 13 | 14 | import { IDbSchema, Store } from "mx-puppet-bridge"; 15 | 16 | export class Schema implements IDbSchema { 17 | public description = "Guilds Bridged"; 18 | public async run(store: Store) { 19 | await store.createTable(` 20 | CREATE TABLE discord_bridged_guilds ( 21 | id SERIAL PRIMARY KEY, 22 | puppet_id INTEGER NOT NULL, 23 | guild_id TEXT NOT NULL 24 | );`, "discord_bridged_guilds"); 25 | } 26 | public async rollBack(store: Store) { 27 | await store.db.Exec("DROP TABLE IF EXISTS discord_bridged_guilds"); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/db/schema/v3.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 mx-puppet-discord 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. 12 | */ 13 | 14 | import { IDbSchema, Store } from "mx-puppet-bridge"; 15 | 16 | export class Schema implements IDbSchema { 17 | public description = "Channels Bridged"; 18 | public async run(store: Store) { 19 | await store.createTable(` 20 | CREATE TABLE discord_bridged_channels ( 21 | id SERIAL PRIMARY KEY, 22 | puppet_id INTEGER NOT NULL, 23 | channel_id TEXT NOT NULL 24 | );`, "discord_bridged_channels"); 25 | } 26 | public async rollBack(store: Store) { 27 | await store.db.Exec("DROP TABLE IF EXISTS discord_bridged_channels"); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/db/schema/v4.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 mx-puppet-discord 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. 12 | */ 13 | 14 | import { Log, IDbSchema, Store } from "mx-puppet-bridge"; 15 | 16 | export class Schema implements IDbSchema { 17 | public description = "migrate dm room IDs"; 18 | public async run(store: Store) { 19 | try { 20 | let rows: any[]; 21 | try { 22 | rows = await store.db.All("SELECT * FROM chan_store WHERE room_id LIKE 'dm%'"); 23 | } catch (e) { 24 | rows = await store.db.All("SELECT * FROM room_store WHERE room_id LIKE 'dm%'"); 25 | } 26 | for (const row of rows) { 27 | const parts = (row.room_id as string).split("-"); 28 | row.room_id = `dm-${row.puppet_id}-${parts[1]}`; 29 | try { 30 | await store.db.Run(`UPDATE chan_store SET 31 | room_id = $room_id, 32 | puppet_id = $puppet_id, 33 | name = $name, 34 | avatar_url = $avatar_url, 35 | avatar_mxc = $avatar_mxc, 36 | avatar_hash = $avatar_hash, 37 | topic = $topic, 38 | group_id = $group_id 39 | WHERE mxid = $mxid`, row); 40 | } catch (e) { 41 | await store.db.Run(`UPDATE room_store SET 42 | room_id = $room_id, 43 | puppet_id = $puppet_id, 44 | name = $name, 45 | avatar_url = $avatar_url, 46 | avatar_mxc = $avatar_mxc, 47 | avatar_hash = $avatar_hash, 48 | topic = $topic, 49 | group_id = $group_id 50 | WHERE mxid = $mxid`, row); 51 | } 52 | } 53 | } catch (err) { 54 | const log = new Log("DiscordPuppet::DbUpgrade"); 55 | log.error("Failed to migrate room ID data:", err); 56 | } 57 | } 58 | public async rollBack(store: Store) { } // no rollback 59 | } 60 | -------------------------------------------------------------------------------- /src/discord/DiscordEventHandler.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable: no-any */ 2 | /* 3 | Copyright 2019, 2020 mx-puppet-discord 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | */ 14 | import { App } from "../app"; 15 | import * as Discord from "better-discord.js"; 16 | import { IDiscordMessageParserOpts, DiscordMessageParser } from "matrix-discord-parser"; 17 | import { Log } from "mx-puppet-bridge"; 18 | import { TextGuildChannel, DiscordUtil } from "./DiscordUtil"; 19 | 20 | const log = new Log("DiscordPuppet:DiscordEventHandler"); 21 | 22 | export class DiscordEventHandler { 23 | private discordMsgParser: DiscordMessageParser; 24 | 25 | public constructor(private readonly app: App) { 26 | this.discordMsgParser = this.app.discordMsgParser; 27 | } 28 | 29 | public async handleDiscordMessage(puppetId: number, msg: Discord.Message) { 30 | const p = this.app.puppets[puppetId]; 31 | if (!p) { 32 | return; 33 | } 34 | if (msg.type !== "DEFAULT") { 35 | return; 36 | } 37 | log.info("Received new message!"); 38 | if (!await this.app.bridgeRoom(puppetId, msg.channel)) { 39 | log.verbose("Unhandled channel, dropping message..."); 40 | return; 41 | } 42 | const params = this.app.matrix.getSendParams(puppetId, msg); 43 | const lockKey = `${puppetId};${msg.channel.id}`; 44 | const dedupeMsg = msg.attachments.first() ? `file:${msg.attachments.first()!.name}` : msg.content; 45 | if (await this.app.messageDeduplicator.dedupe(lockKey, msg.author.id, msg.id, dedupeMsg)) { 46 | // dedupe message 47 | log.verbose("Deduping message, dropping..."); 48 | return; 49 | } 50 | if (msg.webhookID && this.app.discord.isTextGuildChannel(msg.channel)) { 51 | // maybe we are a webhook from our webhook? 52 | const chan = msg.channel as TextGuildChannel; 53 | const hook = await this.app.discord.getOrCreateWebhook(chan); 54 | if (hook && msg.webhookID === hook.id) { 55 | log.verbose("Message sent from our webhook, deduping..."); 56 | return; 57 | } 58 | } 59 | // if we are a bot we can safely ignore all our own messages sent 60 | if (p.client.user!.bot && msg.author.id === p.client.user!.id && !msg.content && msg.embeds.length > 0) { 61 | log.verbose("Message sent from our own bot, deduplicating..."); 62 | return; 63 | } 64 | this.app.lastEventIds[msg.channel.id] = msg.id; 65 | if (msg.content || msg.embeds.length > 0) { 66 | const opts: IDiscordMessageParserOpts = { 67 | callbacks: this.app.discord.getDiscordMsgParserCallbacks(puppetId), 68 | }; 69 | const reply = await this.discordMsgParser.FormatMessage(opts, msg); 70 | const replyId = (msg.reference && msg.reference.messageID) || null; 71 | if (replyId) { 72 | await this.app.puppet.sendReply(params, replyId, { 73 | body: reply.body, 74 | formattedBody: reply.formattedBody, 75 | emote: reply.msgtype === "m.emote", 76 | notice: reply.msgtype === "m.notice", 77 | }); 78 | } else { 79 | await this.app.puppet.sendMessage(params, { 80 | body: reply.body, 81 | formattedBody: reply.formattedBody, 82 | emote: reply.msgtype === "m.emote", 83 | notice: reply.msgtype === "m.notice", 84 | }); 85 | } 86 | } 87 | for (const attachment of msg.attachments.array()) { 88 | params.externalUrl = attachment.url; 89 | await this.app.puppet.sendFileDetect(params, attachment.url, attachment.name || undefined); 90 | } 91 | } 92 | 93 | public async handleDiscordMessageUpdate(puppetId: number, msg1: Discord.Message, msg2: Discord.Message) { 94 | if (msg1.content === msg2.content) { 95 | return; // nothing to do 96 | } 97 | const p = this.app.puppets[puppetId]; 98 | if (!p) { 99 | return; 100 | } 101 | const params = this.app.matrix.getSendParams(puppetId, msg1); 102 | const lockKey = `${puppetId};${msg1.channel.id}`; 103 | if (await this.app.messageDeduplicator.dedupe(lockKey, msg2.author.id, msg2.id, msg2.content)) { 104 | // dedupe message 105 | log.verbose("Deduping message, dropping..."); 106 | return; 107 | } 108 | if (msg2.webhookID && this.app.discord.isTextGuildChannel(msg2.channel)) { 109 | // maybe we are a webhook from our webhook? 110 | const chan = msg2.channel as TextGuildChannel; 111 | const hook = await this.app.discord.getOrCreateWebhook(chan); 112 | if (hook && msg2.webhookID === hook.id) { 113 | log.verbose("Message sent from our webhook, deduping..."); 114 | return; 115 | } 116 | } 117 | // if we are a bot we can safely ignore all our own messages sent 118 | if (p.client.user!.bot && msg2.author.id === p.client.user!.id && !msg2.content && msg2.embeds.length > 0) { 119 | log.verbose("Message sent from our own bot, deduplicating..."); 120 | return; 121 | } 122 | if (!await this.app.bridgeRoom(puppetId, msg1.channel)) { 123 | log.verbose("Unhandled channel, dropping message..."); 124 | return; 125 | } 126 | const opts: IDiscordMessageParserOpts = { 127 | callbacks: this.app.discord.getDiscordMsgParserCallbacks(puppetId), 128 | }; 129 | const reply = await this.discordMsgParser.FormatMessage(opts, msg2); 130 | if (msg1.content) { 131 | // okay we have an actual edit 132 | await this.app.puppet.sendEdit(params, msg1.id, { 133 | body: reply.body, 134 | formattedBody: reply.formattedBody, 135 | emote: reply.msgtype === "m.emote", 136 | notice: reply.msgtype === "m.notice", 137 | }); 138 | } else { 139 | // we actually just want to insert a new message 140 | await this.app.puppet.sendMessage(params, { 141 | body: reply.body, 142 | formattedBody: reply.formattedBody, 143 | emote: reply.msgtype === "m.emote", 144 | notice: reply.msgtype === "m.notice", 145 | }); 146 | } 147 | } 148 | 149 | public async handleDiscordMessageDelete(puppetId: number, msg: Discord.Message) { 150 | const p = this.app.puppets[puppetId]; 151 | if (!p) { 152 | return; 153 | } 154 | const params = this.app.matrix.getSendParams(puppetId, msg); 155 | const lockKey = `${puppetId};${msg.channel.id}`; 156 | if (p.deletedMessages.has(msg.id) || 157 | await this.app.messageDeduplicator.dedupe(lockKey, msg.author.id, msg.id, msg.content)) { 158 | // dedupe message 159 | return; 160 | } 161 | if (!await this.app.bridgeRoom(puppetId, msg.channel)) { 162 | log.info("Unhandled channel, dropping message..."); 163 | return; 164 | } 165 | await this.app.puppet.sendRedact(params, msg.id); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/discord/DiscordUtil.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019, 2020 mx-puppet-discord 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. 12 | */ 13 | import { App, IDiscordSendFile } from "../app"; 14 | import * as Discord from "better-discord.js"; 15 | import { DiscordEventHandler } from "./DiscordEventHandler"; 16 | import { ISendingUser, Log, IRemoteRoom } from "mx-puppet-bridge"; 17 | import { IDiscordMessageParserCallbacks } from "matrix-discord-parser"; 18 | 19 | const log = new Log("DiscordPuppet:DiscordUtil"); 20 | 21 | export type TextGuildChannel = Discord.TextChannel | Discord.NewsChannel; 22 | export type TextChannel = TextGuildChannel | Discord.DMChannel | Discord.GroupDMChannel; 23 | 24 | export type BridgeableGuildChannel = Discord.TextChannel | Discord.NewsChannel; 25 | export type BridgeableChannel = BridgeableGuildChannel | Discord.DMChannel | Discord.GroupDMChannel; 26 | 27 | export class DiscordUtil { 28 | public readonly events: DiscordEventHandler; 29 | private webhookCache: Map = new Map(); 30 | 31 | public constructor(private readonly app: App) { 32 | this.events = new DiscordEventHandler(app); 33 | } 34 | 35 | public async getDiscordChan( 36 | room: IRemoteRoom, 37 | ): Promise { 38 | const p = this.app.puppets[room.puppetId]; 39 | if (!p) { 40 | return null; 41 | } 42 | const id = room.roomId; 43 | const client = p.client; 44 | if (!id.startsWith("dm-")) { 45 | // first fetch from the client channel cache 46 | const chan = client.channels.resolve(id); 47 | if (this.isBridgeableChannel(chan)) { 48 | return chan as BridgeableChannel; 49 | } 50 | // next iterate over all the guild channels 51 | for (const guild of client.guilds.cache.array()) { 52 | const c = guild.channels.resolve(id); 53 | if (this.isBridgeableChannel(c)) { 54 | return c as BridgeableChannel; 55 | } 56 | } 57 | return null; // nothing found 58 | } else { 59 | // we have a DM channel 60 | const parts = id.split("-"); 61 | if (Number(parts[1]) !== room.puppetId) { 62 | return null; 63 | } 64 | const PART_USERID = 2; 65 | const lookupId = parts[PART_USERID]; 66 | const user = await this.getUserById(client, lookupId); 67 | if (!user) { 68 | return null; 69 | } 70 | const chan = await user.createDM(); 71 | return chan; 72 | } 73 | } 74 | 75 | public async getOrCreateWebhook(chan: TextGuildChannel): Promise { 76 | log.debug("Attempting to fetch webhook..."); 77 | const mapKey = `${chan.client.user!.id};${chan.id}`; 78 | if (this.webhookCache.has(mapKey)) { 79 | return this.webhookCache.get(mapKey)!; 80 | } 81 | const permissions = chan.permissionsFor(chan.client.user!); 82 | if (!permissions || !permissions.has(Discord.Permissions.FLAGS.MANAGE_WEBHOOKS)) { 83 | log.warn("Missing webhook permissions"); 84 | return null; 85 | } 86 | try { 87 | const hook = (await chan.fetchWebhooks()).find((h) => h.name === "_matrix") || null; 88 | if (hook) { 89 | this.webhookCache.set(mapKey, hook); 90 | return hook; 91 | } 92 | try { 93 | const newHook = await chan.createWebhook("_matrix", { 94 | reason: "Allow bridging matrix messages to discord nicely", 95 | }); 96 | this.webhookCache.set(mapKey, newHook); 97 | return newHook; 98 | } catch (err) { 99 | log.warn("Unable to create \"_matrix\" webhook", err); 100 | } 101 | } catch (err) { 102 | log.warn("Missing webhook permissions", err); 103 | } 104 | return null; 105 | } 106 | 107 | public async sendToDiscord( 108 | chan: TextChannel, 109 | msg: string | IDiscordSendFile, 110 | asUser: ISendingUser | null, 111 | ): Promise { 112 | log.debug("Sending something to discord..."); 113 | let sendThing: string | Discord.MessageAdditions; 114 | if (typeof msg === "string") { 115 | sendThing = msg; 116 | } else { 117 | sendThing = new Discord.MessageAttachment(msg.buffer, msg.filename); 118 | } 119 | if (!asUser) { 120 | // we don't want to relay, so just send off nicely 121 | log.debug("Not in relay mode, just sending as user"); 122 | return await chan.send(sendThing); 123 | } 124 | // alright, we have to send as if it was another user. First try webhooks. 125 | if (this.isTextGuildChannel(chan)) { 126 | chan = chan as TextGuildChannel; 127 | const hook = await this.getOrCreateWebhook(chan); 128 | if (hook) { 129 | try { 130 | log.debug("Trying to send as webhook..."); 131 | const hookOpts: Discord.WebhookMessageOptions & { split: true } = { 132 | username: asUser.displayname, 133 | avatarURL: asUser.avatarUrl || undefined, 134 | split: true, 135 | }; 136 | if (typeof sendThing === "string") { 137 | return await hook.send(sendThing, hookOpts); 138 | } 139 | if (sendThing instanceof Discord.MessageAttachment) { 140 | hookOpts.files = [sendThing]; 141 | } 142 | return await hook.send(hookOpts); 143 | } catch (err) { 144 | log.debug("Couldn't send as webhook", err); 145 | this.webhookCache.delete(`${chan.client.user!.id};${chan.id}`); 146 | } 147 | } 148 | } 149 | // alright, we either weren't able to send as webhook or we aren't in a webhook-able channel. 150 | // so.....let's try to send as embed next 151 | if (chan.client.user!.bot) { 152 | log.debug("Trying to send as embed..."); 153 | const embed = new Discord.MessageEmbed(); 154 | if (typeof msg === "string") { 155 | embed.setDescription(msg); 156 | } else if (msg.isImage) { 157 | embed.setTitle(msg.filename); 158 | embed.setImage(msg.url); 159 | } else { 160 | const filename = await this.discordEscape(msg.filename); 161 | embed.setDescription(`Uploaded a file \`${filename}\`: ${msg.url}`); 162 | } 163 | embed.setAuthor(asUser.displayname, asUser.avatarUrl || undefined, `https://matrix.to/#/${asUser.mxid}`); 164 | return await chan.send(embed); 165 | } 166 | // alright, nothing is working....let's prefix the displayname and send stuffs 167 | log.debug("Prepending sender information to send the message out..."); 168 | const displayname = await this.discordEscape(asUser.displayname); 169 | let sendMsg = ""; 170 | if (typeof msg === "string") { 171 | sendMsg = `**${displayname}**: ${msg}`; 172 | } else { 173 | const filename = await this.discordEscape(msg.filename); 174 | sendMsg = `**${displayname}** uploaded a file \`${filename}\`: ${msg.url}`; 175 | } 176 | return await chan.send(sendMsg); 177 | } 178 | 179 | public async discordEscape(msg: string): Promise { 180 | return await this.app.matrix.parseMatrixMessage(-1, { 181 | body: msg, 182 | msgtype: "m.text", 183 | }); 184 | } 185 | 186 | public async updatePresence(puppetId: number, presence: Discord.Presence) { 187 | const p = this.app.puppets[puppetId]; 188 | if (!p) { 189 | return; 190 | } 191 | if (!presence || !presence.user) { 192 | return; 193 | } 194 | const matrixPresence = { 195 | online: "online", 196 | idle: "unavailable", 197 | dnd: "unavailable", 198 | offline: "offline", 199 | }[presence.status] as "online" | "offline" | "unavailable"; 200 | let statusMsg = ""; 201 | for (const activity of presence.activities) { 202 | if (statusMsg !== "") { 203 | break; 204 | } 205 | const statusParts: string[] = []; 206 | if (activity.type !== "CUSTOM_STATUS") { 207 | const lower = activity.type.toLowerCase(); 208 | statusParts.push(lower.charAt(0).toUpperCase() + lower.substring(1)); 209 | if (activity.type === "LISTENING") { 210 | statusParts.push(`to ${activity.details} by ${activity.state}`); 211 | } else { 212 | if (activity.name) { 213 | statusParts.push(activity.name); 214 | } 215 | } 216 | } else { 217 | if (activity.emoji) { 218 | statusParts.push(activity.emoji.name); 219 | } 220 | if (activity.state) { 221 | statusParts.push(activity.state); 222 | } 223 | } 224 | statusMsg = statusParts.join(" "); 225 | } 226 | const remoteUser = this.app.matrix.getRemoteUser(puppetId, presence.user!); 227 | await this.app.puppet.setUserPresence(remoteUser, matrixPresence); 228 | await this.app.puppet.setUserStatus(remoteUser, statusMsg); 229 | } 230 | 231 | public getDiscordMsgParserCallbacks(puppetId: number): IDiscordMessageParserCallbacks { 232 | const p = this.app.puppets[puppetId]; 233 | return { 234 | getUser: async (id: string) => { 235 | const mxid = await this.app.puppet.getMxidForUser({ 236 | puppetId, 237 | userId: id, 238 | }); 239 | let name = mxid; 240 | const user = await this.getUserById(p.client, id); 241 | if (user) { 242 | name = user.username; 243 | } 244 | return { 245 | mxid, 246 | name, 247 | }; 248 | }, 249 | getChannel: async (id: string) => { 250 | const room: IRemoteRoom = { 251 | puppetId, 252 | roomId: id, 253 | }; 254 | const mxid = await this.app.puppet.getMxidForRoom(room); 255 | let name = mxid; 256 | const chan = await this.getDiscordChan(room); 257 | if (chan && !(chan instanceof Discord.DMChannel)) { 258 | name = chan.name || ""; 259 | } 260 | return { 261 | mxid, 262 | name, 263 | }; 264 | }, 265 | getEmoji: async (name: string, animated: boolean, id: string) => { 266 | return await this.app.matrix.getEmojiMxc(puppetId, name, animated, id); 267 | }, 268 | }; 269 | } 270 | 271 | public async getDiscordEmoji(puppetId: number, mxc: string): Promise { 272 | const p = this.app.puppets[puppetId]; 273 | if (!p) { 274 | return null; 275 | } 276 | const emote = await this.app.puppet.emoteSync.getByMxc(puppetId, mxc); 277 | if (!emote) { 278 | return null; 279 | } 280 | const emoji = p.client.emojis.resolve(emote.emoteId); 281 | return emoji || null; 282 | } 283 | 284 | public async iterateGuildStructure( 285 | puppetId: number, 286 | guild: Discord.Guild, 287 | catCallback: (cat: Discord.CategoryChannel) => Promise, 288 | chanCallback: (chan: BridgeableGuildChannel) => Promise, 289 | ) { 290 | const bridgedGuilds = await this.app.store.getBridgedGuilds(puppetId); 291 | const bridgedChannels = await this.app.store.getBridgedChannels(puppetId); 292 | const client = guild.client; 293 | const bridgeAll = Boolean(this.app.puppets[puppetId] && this.app.puppets[puppetId].data.bridgeAll); 294 | // first we iterate over the non-sorted channels 295 | for (const chan of guild.channels.cache.array()) { 296 | if (!bridgedGuilds.includes(guild.id) && !bridgedChannels.includes(chan.id) && !bridgeAll) { 297 | continue; 298 | } 299 | if (!chan.parentID && this.isBridgeableGuildChannel(chan) && chan.members.has(client.user!.id)) { 300 | await chanCallback(chan as BridgeableGuildChannel); 301 | } 302 | } 303 | // next we iterate over the categories and all their children 304 | for (const cat of guild.channels.cache.array()) { 305 | if (!(cat instanceof Discord.CategoryChannel)) { 306 | continue; 307 | } 308 | if (cat.members.has(client.user!.id)) { 309 | let doCat = false; 310 | for (const chan of cat.children.array()) { 311 | if (!bridgedGuilds.includes(guild.id) && !bridgedChannels.includes(chan.id) && !bridgeAll) { 312 | continue; 313 | } 314 | if (this.isBridgeableGuildChannel(chan) && chan.members.has(client.user!.id)) { 315 | if (!doCat) { 316 | doCat = true; 317 | await catCallback(cat); 318 | } 319 | await chanCallback(chan as BridgeableGuildChannel); 320 | } 321 | } 322 | } 323 | } 324 | } 325 | 326 | public async getUserById(client: Discord.Client, id: string): Promise { 327 | for (const guild of client.guilds.cache.array()) { 328 | const a = guild.members.cache.find((m) => m.user.id === id); 329 | if (a) { 330 | return a.user; 331 | } 332 | } 333 | if (client.user) { 334 | const user = client.user.relationships.friends.get(id); 335 | if (user) { 336 | return user; 337 | } 338 | } 339 | try { 340 | const user = await client.users.fetch(id); 341 | if (user) { 342 | return user; 343 | } 344 | } catch (err) { 345 | // user not found 346 | } 347 | return null; 348 | } 349 | 350 | public isTextGuildChannel(chan?: Discord.Channel | null): boolean { 351 | return Boolean(chan && ["text", "news"].includes(chan.type)); 352 | } 353 | 354 | public isTextChannel(chan?: Discord.Channel | null): boolean { 355 | return Boolean(chan && ["text", "news", "dm", "group"].includes(chan.type)); 356 | } 357 | 358 | public isBridgeableGuildChannel(chan?: Discord.Channel | null): boolean { 359 | return Boolean(chan && ["text", "news"].includes(chan.type)); 360 | } 361 | 362 | public isBridgeableChannel(chan?: Discord.Channel | null): boolean { 363 | return Boolean(chan && ["text", "news", "dm", "group"].includes(chan.type)); 364 | } 365 | } 366 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable: no-any */ 2 | /* 3 | Copyright 2019, 2020 mx-puppet-discord 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | */ 14 | 15 | import { 16 | PuppetBridge, 17 | IPuppetBridgeRegOpts, 18 | Log, 19 | IRetData, 20 | IProtocolInformation, 21 | } from "mx-puppet-bridge"; 22 | import * as commandLineArgs from "command-line-args"; 23 | import * as commandLineUsage from "command-line-usage"; 24 | import { App } from "./app"; 25 | 26 | const log = new Log("DiscordPuppet:index"); 27 | 28 | const commandOptions = [ 29 | { name: "register", alias: "r", type: Boolean }, 30 | { name: "registration-file", alias: "f", type: String }, 31 | { name: "config", alias: "c", type: String }, 32 | { name: "help", alias: "h", type: Boolean }, 33 | ]; 34 | const options = Object.assign({ 35 | "register": false, 36 | "registration-file": "discord-registration.yaml", 37 | "config": "config.yaml", 38 | "help": false, 39 | }, commandLineArgs(commandOptions)); 40 | 41 | if (options.help) { 42 | // tslint:disable-next-line:no-console 43 | console.log(commandLineUsage([ 44 | { 45 | header: "Matrix Discord Puppet Bridge", 46 | content: "A matrix puppet bridge for discord", 47 | }, 48 | { 49 | header: "Options", 50 | optionList: commandOptions, 51 | }, 52 | ])); 53 | process.exit(0); 54 | } 55 | 56 | const protocol: IProtocolInformation = { 57 | features: { 58 | file: true, 59 | presence: true, 60 | edit: true, 61 | reply: true, 62 | advancedRelay: true, 63 | globalNamespace: true, 64 | typingTimeout: 10 * 1000, // tslint:disable-line no-magic-numbers 65 | }, 66 | id: "discord", 67 | displayname: "Discord", 68 | externalUrl: "https://discordapp.com/", 69 | namePatterns: { 70 | user: ":name", 71 | userOverride: ":displayname", 72 | room: "[:guild?#:name - :guild,:name]", 73 | group: ":name", 74 | }, 75 | }; 76 | 77 | const puppet = new PuppetBridge(options["registration-file"], options.config, protocol); 78 | 79 | if (options.register) { 80 | // okay, all we have to do is generate a registration file 81 | puppet.readConfig(false); 82 | try { 83 | puppet.generateRegistration({ 84 | prefix: "_discordpuppet_", 85 | id: "discord-puppet", 86 | url: `http://${puppet.Config.bridge.bindAddress}:${puppet.Config.bridge.port}`, 87 | } as IPuppetBridgeRegOpts); 88 | } catch (err) { 89 | // tslint:disable-next-line:no-console 90 | console.log("Couldn't generate registration file:", err); 91 | } 92 | process.exit(0); 93 | } 94 | 95 | async function run() { 96 | await puppet.init(); 97 | const app = new App(puppet); 98 | await app.init(); 99 | puppet.on("puppetNew", app.newPuppet.bind(app)); 100 | puppet.on("puppetDelete", app.deletePuppet.bind(app)); 101 | puppet.on("message", app.matrix.events.handleMatrixMessage.bind(app.matrix.events)); 102 | puppet.on("file", app.matrix.events.handleMatrixFile.bind(app.matrix.events)); 103 | puppet.on("redact", app.matrix.events.handleMatrixRedact.bind(app.matrix.events)); 104 | puppet.on("edit", app.matrix.events.handleMatrixEdit.bind(app.matrix.events)); 105 | puppet.on("reply", app.matrix.events.handleMatrixReply.bind(app.matrix.events)); 106 | puppet.on("reaction", app.matrix.events.handleMatrixReaction.bind(app.matrix.events)); 107 | puppet.on("removeReaction", app.matrix.events.handleMatrixRemoveReaction.bind(app.matrix.events)); 108 | puppet.on("typing", app.matrix.events.handleMatrixTyping.bind(app.matrix.events)); 109 | puppet.on("presence", app.matrix.events.handleMatrixPresence.bind(app.matrix.events)); 110 | puppet.on("puppetName", app.handlePuppetName.bind(app)); 111 | puppet.on("puppetAvatar", app.handlePuppetAvatar.bind(app)); 112 | puppet.setGetUserIdsInRoomHook(app.getUserIdsInRoom.bind(app)); 113 | puppet.setCreateRoomHook(app.matrix.createRoom.bind(app.matrix)); 114 | puppet.setCreateUserHook(app.matrix.createUser.bind(app.matrix)); 115 | puppet.setCreateGroupHook(app.matrix.createGroup.bind(app.matrix)); 116 | puppet.setGetDmRoomIdHook(app.matrix.getDmRoom.bind(app.matrix)); 117 | puppet.setListUsersHook(app.listUsers.bind(app)); 118 | puppet.setListRoomsHook(app.matrix.listRooms.bind(app.matrix)); 119 | puppet.setGetDescHook(async (puppetId: number, data: any): Promise => { 120 | let s = "Discord"; 121 | if (data.username) { 122 | s += ` as \`${data.username}\``; 123 | } 124 | if (data.id) { 125 | s += ` (\`${data.id}\`)`; 126 | } 127 | return s; 128 | }); 129 | puppet.setGetDataFromStrHook(async (str: string): Promise => { 130 | const retData = { 131 | success: false, 132 | } as IRetData; 133 | if (!str) { 134 | retData.error = "Please specify a token to link!"; 135 | return retData; 136 | } 137 | const parts = str.split(" "); 138 | const PARTS_LENGTH = 2; 139 | if (parts.length !== PARTS_LENGTH) { 140 | retData.error = "Please specify if your token is a user or a bot token! `link token`"; 141 | return retData; 142 | } 143 | const type = parts[0].toLowerCase(); 144 | if (!["bot", "user"].includes(type)) { 145 | retData.error = "Please specify if your token is a user or a bot token! `link token`"; 146 | return retData; 147 | } 148 | retData.success = true; 149 | retData.data = { 150 | token: parts[1].trim(), 151 | bot: type === "bot", 152 | }; 153 | return retData; 154 | }); 155 | puppet.setBotHeaderMsgHook((): string => { 156 | return "Discord Puppet Bridge"; 157 | }); 158 | puppet.setResolveRoomIdHook(async (ident: string): Promise => { 159 | if (ident.match(/^[0-9]+$/)) { 160 | return ident; 161 | } 162 | const matches = ident.match(/^(?:https?:\/\/)?discordapp\.com\/channels\/[^\/]+\/([0-9]+)/); 163 | if (matches) { 164 | return matches[1]; 165 | } 166 | return null; 167 | }); 168 | puppet.registerCommand("syncprofile", { 169 | fn: app.commands.commandSyncProfile.bind(app.commands), 170 | help: `Enable/disable the syncing of the matrix profile to the discord one (name and avatar) 171 | 172 | Usage: \`syncprofile <1/0>\``, 173 | }); 174 | puppet.registerCommand("joinentireguild", { 175 | fn: app.commands.commandJoinEntireGuild.bind(app.commands), 176 | help: `Join all the channels in a guild, if it is bridged 177 | 178 | Usage: \`joinentireguild \``, 179 | }); 180 | puppet.registerCommand("listguilds", { 181 | fn: app.commands.commandListGuilds.bind(app.commands), 182 | help: `List all guilds that can be bridged 183 | 184 | Usage: \`listguilds \``, 185 | }); 186 | puppet.registerCommand("acceptinvite", { 187 | fn: app.commands.commandAcceptInvite.bind(app.commands), 188 | help: `Accept a discord.gg invite 189 | 190 | Usage: \`acceptinvite \``, 191 | }); 192 | puppet.registerCommand("bridgeguild", { 193 | fn: app.commands.commandBridgeGuild.bind(app.commands), 194 | help: `Bridge a guild 195 | 196 | Usage: \`bridgeguild \``, 197 | }); 198 | puppet.registerCommand("unbridgeguild", { 199 | fn: app.commands.commandUnbridgeGuild.bind(app.commands), 200 | help: `Unbridge a guild 201 | 202 | Usage: \`unbridgeguild \``, 203 | }); 204 | puppet.registerCommand("bridgechannel", { 205 | fn: app.commands.commandBridgeChannel.bind(app.commands), 206 | help: `Bridge a channel 207 | 208 | Usage: \`bridgechannel \``, 209 | }); 210 | puppet.registerCommand("unbridgechannel", { 211 | fn: app.commands.commandUnbridgeChannel.bind(app.commands), 212 | help: `Unbridge a channel 213 | 214 | Usage: \`unbridgechannel \``, 215 | }); 216 | puppet.registerCommand("bridgeall", { 217 | fn: app.commands.commandBridgeAll.bind(app.commands), 218 | help: `Bridge everything 219 | 220 | Usage: \`bridgeall <1/0>\``, 221 | }); 222 | puppet.registerCommand("enablefriendsmanagement", { 223 | fn: app.commands.commandEnableFriendsManagement.bind(app.commands), 224 | help: `Enables friends management on the discord account 225 | 226 | Usage: \`enablefriendsmanagement \``, 227 | }); 228 | puppet.registerCommand("listfriends", { 229 | fn: app.commands.commandListFriends.bind(app.commands), 230 | help: `List all your current friends 231 | 232 | Usage: \`listfriends \``, 233 | }); 234 | puppet.registerCommand("addfriend", { 235 | fn: app.commands.commandAddFriend.bind(app.commands), 236 | help: `Add a new friend 237 | 238 | Usage: \`addfriend \`, friend can be either the full username or the user ID`, 239 | }); 240 | puppet.registerCommand("removefriend", { 241 | fn: app.commands.commandRemoveFriend.bind(app.commands), 242 | help: `Remove a friend 243 | 244 | Usage: \`removefriend \`, friend can be either the full username or the user ID`, 245 | }); 246 | await puppet.start(); 247 | } 248 | 249 | // tslint:disable-next-line:no-floating-promises 250 | run(); // start the thing! 251 | -------------------------------------------------------------------------------- /src/matrix/MatrixEventHandler.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable: no-any */ 2 | /* 3 | Copyright 2019, 2020 mx-puppet-discord 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | */ 14 | import { IRemoteRoom, IMessageEvent, ISendingUser, IFileEvent, Util, Log, IPresenceEvent } from "mx-puppet-bridge"; 15 | import { App, IDiscordSendFile, MAXFILESIZE, AVATAR_SETTINGS } from "../app"; 16 | import * as Discord from "better-discord.js"; 17 | import { TextEncoder, TextDecoder } from "util"; 18 | import { TextGuildChannel } from "../discord/DiscordUtil"; 19 | 20 | const log = new Log("DiscordPuppet:MatrixEventHandler"); 21 | 22 | export class MatrixEventHandler { 23 | public constructor(private readonly app: App) {} 24 | 25 | public async handleMatrixMessage(room: IRemoteRoom, data: IMessageEvent, asUser: ISendingUser | null, event: any) { 26 | const p = this.app.puppets[room.puppetId]; 27 | if (!p) { 28 | return; 29 | } 30 | const chan = await this.app.discord.getDiscordChan(room); 31 | if (!chan) { 32 | log.warn("Channel not found", room); 33 | return; 34 | } 35 | 36 | if (asUser) { 37 | const MAX_NAME_LENGTH = 80; 38 | const displayname = (new TextEncoder().encode(asUser.displayname)); 39 | asUser.displayname = (new TextDecoder().decode(displayname.slice(0, MAX_NAME_LENGTH))); 40 | } 41 | 42 | const sendMsg = await this.app.matrix.parseMatrixMessage(room.puppetId, event.content); 43 | const lockKey = `${room.puppetId};${chan.id}`; 44 | this.app.messageDeduplicator.lock(lockKey, p.client.user!.id, sendMsg); 45 | try { 46 | const reply = await this.app.discord.sendToDiscord(chan, sendMsg, asUser); 47 | await this.app.matrix.insertNewEventId(room, data.eventId!, reply); 48 | } catch (err) { 49 | log.warn("Couldn't send message", err); 50 | this.app.messageDeduplicator.unlock(lockKey); 51 | await this.app.matrix.sendMessageFail(room); 52 | } 53 | } 54 | 55 | public async handleMatrixFile(room: IRemoteRoom, data: IFileEvent, asUser: ISendingUser | null, event: any) { 56 | const p = this.app.puppets[room.puppetId]; 57 | if (!p) { 58 | return; 59 | } 60 | const chan = await this.app.discord.getDiscordChan(room); 61 | if (!chan) { 62 | log.warn("Channel not found", room); 63 | return; 64 | } 65 | 66 | let size = data.info ? data.info.size || 0 : 0; 67 | const mimetype = data.info ? data.info.mimetype || "" : ""; 68 | const lockKey = `${room.puppetId};${chan.id}`; 69 | const isImage = Boolean(mimetype && mimetype.split("/")[0] === "image"); 70 | if (size < MAXFILESIZE) { 71 | const buffer = await Util.DownloadFile(data.url); 72 | size = buffer.byteLength; 73 | if (size < MAXFILESIZE) { 74 | // send as attachment 75 | const filename = this.app.getFilenameForMedia(data.filename, mimetype); 76 | this.app.messageDeduplicator.lock(lockKey, p.client.user!.id, `file:${filename}`); 77 | try { 78 | const sendFile: IDiscordSendFile = { 79 | buffer, 80 | filename, 81 | url: data.url, 82 | isImage, 83 | }; 84 | const reply = await this.app.discord.sendToDiscord(chan, sendFile, asUser); 85 | await this.app.matrix.insertNewEventId(room, data.eventId!, reply); 86 | return; 87 | } catch (err) { 88 | this.app.messageDeduplicator.unlock(lockKey); 89 | log.warn("Couldn't send media message, retrying as embed/url", err); 90 | } 91 | } 92 | } 93 | try { 94 | const filename = await this.app.discord.discordEscape(data.filename); 95 | const msg = `Uploaded a file \`${filename}\`: ${data.url}`; 96 | this.app.messageDeduplicator.lock(lockKey, p.client.user!.id, msg); 97 | const reply = await this.app.discord.sendToDiscord(chan, msg, asUser); 98 | await this.app.matrix.insertNewEventId(room, data.eventId!, reply); 99 | } catch (err) { 100 | log.warn("Couldn't send media message", err); 101 | this.app.messageDeduplicator.unlock(lockKey); 102 | await this.app.matrix.sendMessageFail(room); 103 | } 104 | } 105 | 106 | public async handleMatrixRedact(room: IRemoteRoom, eventId: string, asUser: ISendingUser | null, event: any) { 107 | const p = this.app.puppets[room.puppetId]; 108 | if (!p) { 109 | return; 110 | } 111 | const chan = await this.app.discord.getDiscordChan(room); 112 | if (!chan) { 113 | log.warn("Channel not found", room); 114 | return; 115 | } 116 | log.verbose(`Deleting message with ID ${eventId}...`); 117 | const msg = await chan.messages.fetch(eventId); 118 | if (!msg) { 119 | return; 120 | } 121 | try { 122 | p.deletedMessages.add(msg.id); 123 | const hook = msg.webhookID && this.app.discord.isTextGuildChannel(chan) ? 124 | await this.app.discord.getOrCreateWebhook(chan as TextGuildChannel) : null; 125 | if (hook && msg.webhookID === hook.id) { 126 | await hook.deleteMessage(msg); 127 | } else { 128 | await msg.delete(); 129 | } 130 | await this.app.puppet.eventSync.remove(room, msg.id); 131 | } catch (err) { 132 | log.warn("Couldn't delete message", err); 133 | } 134 | } 135 | 136 | public async handleMatrixEdit( 137 | room: IRemoteRoom, 138 | eventId: string, 139 | data: IMessageEvent, 140 | asUser: ISendingUser | null, 141 | event: any, 142 | ) { 143 | const p = this.app.puppets[room.puppetId]; 144 | if (!p) { 145 | return; 146 | } 147 | const chan = await this.app.discord.getDiscordChan(room); 148 | if (!chan) { 149 | log.warn("Channel not found", room); 150 | return; 151 | } 152 | log.verbose(`Editing message with ID ${eventId}...`); 153 | const msg = await chan.messages.fetch(eventId); 154 | if (!msg) { 155 | return; 156 | } 157 | let sendMsg = await this.app.matrix.parseMatrixMessage(room.puppetId, event.content["m.new_content"]); 158 | // prepend a quote, if needed 159 | if (msg.content.startsWith("> <")) { 160 | const matches = msg.content.match(/^((> [^\n]+\n)+)/); 161 | if (matches) { 162 | sendMsg = `${matches[1]}${sendMsg}`; 163 | } 164 | } 165 | const lockKey = `${room.puppetId};${chan.id}`; 166 | this.app.messageDeduplicator.lock(lockKey, p.client.user!.id, sendMsg); 167 | try { 168 | let reply: Discord.Message | Discord.Message[]; 169 | let matrixEventId = data.eventId!; 170 | if (asUser) { 171 | const hook = this.app.discord.isTextGuildChannel(chan) ? 172 | await this.app.discord.getOrCreateWebhook(chan as TextGuildChannel) : null; 173 | if (hook && msg.webhookID === hook.id) { 174 | // the original message was by our webhook, try to edit it 175 | reply = await hook.editMessage(msg, sendMsg, { 176 | username: asUser.displayname, 177 | avatarURL: asUser.avatarUrl || undefined, 178 | }); 179 | } else if (eventId === this.app.lastEventIds[chan.id]) { 180 | // just re-send as new message 181 | try { 182 | p.deletedMessages.add(msg.id); 183 | const matrixEvents = await this.app.puppet.eventSync.getMatrix(room, msg.id); 184 | if (matrixEvents.length > 0) { 185 | matrixEventId = matrixEvents[0]; 186 | } 187 | await msg.delete(); 188 | await this.app.puppet.eventSync.remove(room, msg.id); 189 | } catch (err) { 190 | log.warn("Couldn't delete old message", err); 191 | } 192 | reply = await this.app.discord.sendToDiscord(chan, sendMsg, asUser); 193 | } else { 194 | sendMsg = `**EDIT:** ${sendMsg}`; 195 | reply = await this.app.discord.sendToDiscord(chan, sendMsg, asUser); 196 | } 197 | } else { 198 | reply = await msg.edit(sendMsg); 199 | } 200 | await this.app.matrix.insertNewEventId(room, matrixEventId, reply); 201 | } catch (err) { 202 | log.warn("Couldn't edit message", err); 203 | this.app.messageDeduplicator.unlock(lockKey); 204 | await this.app.matrix.sendMessageFail(room); 205 | } 206 | } 207 | 208 | public async handleMatrixReply( 209 | room: IRemoteRoom, 210 | eventId: string, 211 | data: IMessageEvent, 212 | asUser: ISendingUser | null, 213 | event: any, 214 | ) { 215 | const p = this.app.puppets[room.puppetId]; 216 | if (!p) { 217 | return; 218 | } 219 | const chan = await this.app.discord.getDiscordChan(room); 220 | if (!chan) { 221 | log.warn("Channel not found", room); 222 | return; 223 | } 224 | log.verbose(`Replying to message with ID ${eventId}...`); 225 | const msg = await chan.messages.fetch(eventId); 226 | if (!msg) { 227 | return; 228 | } 229 | let sendMsg = await this.app.matrix.parseMatrixMessage(room.puppetId, event.content); 230 | let content = msg.content; 231 | if (!content && msg.embeds.length > 0 && msg.embeds[0].description) { 232 | content = msg.embeds[0].description; 233 | } 234 | const quoteParts = content.split("\n"); 235 | quoteParts[0] = `<@${msg.author.id}>: ${quoteParts[0]}`; 236 | const quote = quoteParts.map((s) => `> ${s}`).join("\n"); 237 | sendMsg = `${quote}\n${sendMsg}`; 238 | const lockKey = `${room.puppetId};${chan.id}`; 239 | try { 240 | this.app.messageDeduplicator.lock(lockKey, p.client.user!.id, sendMsg); 241 | const reply = await this.app.discord.sendToDiscord(chan, sendMsg, asUser); 242 | await this.app.matrix.insertNewEventId(room, data.eventId!, reply); 243 | } catch (err) { 244 | log.warn("Couldn't send reply", err); 245 | this.app.messageDeduplicator.unlock(lockKey); 246 | await this.app.matrix.sendMessageFail(room); 247 | } 248 | } 249 | 250 | public async handleMatrixReaction( 251 | room: IRemoteRoom, 252 | eventId: string, 253 | reaction: string, 254 | asUser: ISendingUser | null, 255 | event: any, 256 | ) { 257 | const p = this.app.puppets[room.puppetId]; 258 | if (!p || asUser) { 259 | return; 260 | } 261 | const chan = await this.app.discord.getDiscordChan(room); 262 | if (!chan) { 263 | log.warn("Channel not found", room); 264 | return; 265 | } 266 | log.verbose(`Reacting to ${eventId} with ${reaction}...`); 267 | const msg = await chan.messages.fetch(eventId); 268 | if (!msg) { 269 | return; 270 | } 271 | if (reaction.startsWith("mxc://")) { 272 | const emoji = await this.app.discord.getDiscordEmoji(room.puppetId, reaction); 273 | if (emoji) { 274 | await msg.react(emoji); 275 | } 276 | } else { 277 | await msg.react(reaction); 278 | } 279 | } 280 | 281 | public async handleMatrixRemoveReaction( 282 | room: IRemoteRoom, 283 | eventId: string, 284 | reaction: string, 285 | asUser: ISendingUser | null, 286 | event: any, 287 | ) { 288 | const p = this.app.puppets[room.puppetId]; 289 | if (!p || asUser) { 290 | return; 291 | } 292 | const chan = await this.app.discord.getDiscordChan(room); 293 | if (!chan) { 294 | log.warn("Channel not found", room); 295 | return; 296 | } 297 | log.verbose(`Removing reaction to ${eventId} with ${reaction}...`); 298 | const msg = await chan.messages.fetch(eventId); 299 | if (!msg) { 300 | return; 301 | } 302 | let emoji: Discord.Emoji | null = null; 303 | if (reaction.startsWith("mxc://")) { 304 | emoji = await this.app.discord.getDiscordEmoji(room.puppetId, reaction); 305 | } 306 | for (const r of msg.reactions.cache.array()) { 307 | if (r.emoji.name === reaction) { 308 | await r.remove(); 309 | break; 310 | } 311 | if (emoji && emoji.id === r.emoji.id) { 312 | await r.remove(); 313 | break; 314 | } 315 | } 316 | } 317 | 318 | public async handleMatrixTyping( 319 | room: IRemoteRoom, 320 | typing: boolean, 321 | asUser: ISendingUser | null, 322 | event: any, 323 | ) { 324 | const p = this.app.puppets[room.puppetId]; 325 | if (!p || asUser) { 326 | return; 327 | } 328 | const chan = await this.app.discord.getDiscordChan(room); 329 | if (!chan) { 330 | log.warn("Channel not found", room); 331 | return; 332 | } 333 | if (typing) { 334 | await chan.startTyping(); 335 | } else { 336 | chan.stopTyping(true); 337 | } 338 | } 339 | 340 | public async handleMatrixPresence( 341 | puppetId: number, 342 | presence: IPresenceEvent, 343 | asUser: ISendingUser | null, 344 | event: any, 345 | ) { 346 | const p = this.app.puppets[puppetId]; 347 | if (!p || asUser) { 348 | return; 349 | } 350 | const presenceObj: Discord.PresenceData = { 351 | status: { 352 | online: "online", 353 | offline: "invisible", 354 | unavailable: "idle", 355 | }[presence.presence] as "online" | "invisible" | "idle" | undefined, 356 | }; 357 | if (presence.statusMsg) { 358 | presenceObj.activity = { 359 | name: presence.statusMsg, 360 | type: "PLAYING", 361 | }; 362 | } 363 | await p.client.user!.setPresence(presenceObj); 364 | } 365 | } 366 | -------------------------------------------------------------------------------- /src/matrix/MatrixUtil.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable: no-any */ 2 | /* 3 | Copyright 2019, 2020 mx-puppet-discord 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | */ 14 | import { App, AVATAR_SETTINGS } from "../app"; 15 | import * as Discord from "better-discord.js"; 16 | import { IStringFormatterVars, IRemoteUser, IRemoteRoom, 17 | IRemoteGroup, IRemoteUserRoomOverride, IReceiveParams, IRetList, Log, 18 | } from "mx-puppet-bridge"; 19 | import * as escapeHtml from "escape-html"; 20 | import { IMatrixMessageParserOpts } from "matrix-discord-parser"; 21 | import { MatrixEventHandler } from "./MatrixEventHandler"; 22 | import { TextGuildChannel, TextChannel, BridgeableGuildChannel, BridgeableChannel } from "../discord/DiscordUtil"; 23 | 24 | const log = new Log("DiscordPuppet:MatrixUtil"); 25 | 26 | export class MatrixUtil { 27 | public readonly events: MatrixEventHandler; 28 | 29 | public constructor(private readonly app: App) { 30 | this.events = new MatrixEventHandler(app); 31 | } 32 | 33 | public async getDmRoom(user: IRemoteUser): Promise { 34 | const p = this.app.puppets[user.puppetId]; 35 | if (!p) { 36 | return null; 37 | } 38 | const u = await this.app.discord.getUserById(p.client, user.userId); 39 | if (!u) { 40 | return null; 41 | } 42 | return `dm-${user.puppetId}-${u.id}`; 43 | } 44 | 45 | public async getEmojiMxc(puppetId: number, name: string, animated: boolean, id: string): Promise { 46 | const emoji = await this.app.puppet.emoteSync.get({ 47 | puppetId, 48 | emoteId: id, 49 | }); 50 | if (emoji && emoji.avatarMxc) { 51 | return emoji.avatarMxc; 52 | } 53 | const { emote } = await this.app.puppet.emoteSync.set({ 54 | puppetId, 55 | emoteId: id, 56 | avatarUrl: `https://cdn.discordapp.com/emojis/${id}${animated ? ".gif" : ".png"}`, 57 | name, 58 | data: { 59 | animated, 60 | name, 61 | }, 62 | }); 63 | return emote.avatarMxc || null; 64 | } 65 | 66 | public getSendParams( 67 | puppetId: number, 68 | msgOrChannel: Discord.Message | BridgeableChannel, 69 | user?: Discord.User | Discord.GuildMember, 70 | ): IReceiveParams { 71 | let channel: BridgeableChannel; 72 | let eventId: string | undefined; 73 | let externalUrl: string | undefined; 74 | let isWebhook = false; 75 | let guildChannel: BridgeableGuildChannel | undefined; 76 | if (!user) { 77 | const msg = msgOrChannel as Discord.Message; 78 | channel = msg.channel; 79 | user = msg.member || msg.author; 80 | eventId = msg.id; 81 | isWebhook = msg.webhookID ? true : false; 82 | if (this.app.discord.isBridgeableGuildChannel(channel)) { 83 | guildChannel = channel as BridgeableGuildChannel; 84 | externalUrl = `https://discordapp.com/channels/${guildChannel.guild.id}/${guildChannel.id}/${eventId}`; 85 | } else if (["group", "dm"].includes(channel.type)) { 86 | externalUrl = `https://discordapp.com/channels/@me/${channel.id}/${eventId}`; 87 | } 88 | } else { 89 | channel = msgOrChannel as BridgeableChannel; 90 | } 91 | return { 92 | room: this.getRemoteRoom(puppetId, channel), 93 | user: this.getRemoteUser(puppetId, user, isWebhook, guildChannel), 94 | eventId, 95 | externalUrl, 96 | }; 97 | } 98 | 99 | public getRemoteUserRoomOverride(member: Discord.GuildMember, chan: BridgeableGuildChannel): IRemoteUserRoomOverride { 100 | const nameVars: IStringFormatterVars = { 101 | name: member.user.username, 102 | discriminator: member.user.discriminator, 103 | displayname: member.displayName, 104 | channel: chan.name, 105 | guild: chan.guild.name, 106 | }; 107 | return { 108 | nameVars, 109 | }; 110 | } 111 | 112 | public getRemoteUser( 113 | puppetId: number, 114 | userOrMember: Discord.User | Discord.GuildMember, 115 | isWebhook: boolean = false, 116 | chan?: BridgeableGuildChannel, 117 | ): IRemoteUser { 118 | let user: Discord.User; 119 | let member: Discord.GuildMember | null = null; 120 | if (userOrMember instanceof Discord.GuildMember) { 121 | member = userOrMember; 122 | user = member.user; 123 | } else { 124 | user = userOrMember; 125 | } 126 | const nameVars: IStringFormatterVars = { 127 | name: user.username, 128 | discriminator: user.discriminator, 129 | }; 130 | const response: IRemoteUser = { 131 | userId: isWebhook ? `webhook-${user.id}-${user.username}` : user.id, 132 | puppetId, 133 | avatarUrl: user.avatarURL(AVATAR_SETTINGS), 134 | nameVars, 135 | }; 136 | if (member) { 137 | response.roomOverrides = {}; 138 | if (chan) { 139 | response.roomOverrides[chan.id] = this.getRemoteUserRoomOverride(member, chan); 140 | } else { 141 | for (const gchan of member.guild.channels.cache.array()) { 142 | if (this.app.discord.isBridgeableGuildChannel(gchan)) { 143 | response.roomOverrides[gchan.id] = this.getRemoteUserRoomOverride(member, gchan as BridgeableGuildChannel); 144 | } 145 | } 146 | } 147 | } 148 | return response; 149 | } 150 | 151 | public getRemoteRoom(puppetId: number, channel: BridgeableChannel): IRemoteRoom { 152 | let roomId = channel.id; 153 | if (channel instanceof Discord.DMChannel) { 154 | roomId = `dm-${puppetId}-${channel.recipient.id}`; 155 | } 156 | const ret: IRemoteRoom = { 157 | roomId, 158 | puppetId, 159 | isDirect: channel.type === "dm", 160 | }; 161 | if (channel instanceof Discord.GroupDMChannel) { 162 | ret.nameVars = { 163 | name: channel.name, 164 | }; 165 | ret.avatarUrl = channel.iconURL(AVATAR_SETTINGS); 166 | } 167 | if (this.app.discord.isBridgeableGuildChannel(channel)) { 168 | const gchan = channel as BridgeableGuildChannel; 169 | ret.nameVars = { 170 | name: gchan.name, 171 | guild: gchan.guild.name, 172 | category: gchan.parent?.name, 173 | }; 174 | ret.avatarUrl = gchan.guild.iconURL(AVATAR_SETTINGS); 175 | ret.groupId = gchan.guild.id; 176 | ret.topic = gchan.topic; 177 | ret.emotes = gchan.guild.emojis.cache.map((e) => { 178 | return { 179 | emoteId: e.id, 180 | name: e.name, 181 | avatarUrl: e.url, 182 | data: { 183 | animated: e.animated, 184 | name: e.name, 185 | }, 186 | }; 187 | }); 188 | } 189 | return ret; 190 | } 191 | 192 | public async getRemoteRoomById(room: IRemoteRoom): Promise { 193 | const chan = await this.app.discord.getDiscordChan(room); 194 | if (!chan) { 195 | return null; 196 | } 197 | if (!await this.app.bridgeRoom(room.puppetId, chan)) { 198 | return null; 199 | } 200 | return this.getRemoteRoom(room.puppetId, chan); 201 | } 202 | 203 | public async getRemoteGroup(puppetId: number, guild: Discord.Guild): Promise { 204 | const roomIds: string[] = []; 205 | let description = `

${escapeHtml(guild.name)}

`; 206 | description += `

Channels:

    `; 207 | await this.app.discord.iterateGuildStructure(puppetId, guild, 208 | async (cat: Discord.CategoryChannel) => { 209 | const name = escapeHtml(cat.name); 210 | description += `

${name}

    `; 211 | }, 212 | async (chan: BridgeableGuildChannel) => { 213 | roomIds.push(chan.id); 214 | const mxid = await this.app.puppet.getMxidForRoom({ 215 | puppetId, 216 | roomId: chan.id, 217 | }); 218 | const url = "https://matrix.to/#/" + mxid; 219 | const name = escapeHtml(chan.name); 220 | description += `
  • ${name}: ${name}
  • `; 221 | }, 222 | ); 223 | description += "
"; 224 | return { 225 | puppetId, 226 | groupId: guild.id, 227 | nameVars: { 228 | name: guild.name, 229 | }, 230 | avatarUrl: guild.iconURL(AVATAR_SETTINGS), 231 | roomIds, 232 | longDescription: description, 233 | }; 234 | } 235 | 236 | public async insertNewEventId(room: IRemoteRoom, matrixId: string, msgs: Discord.Message | Discord.Message[]) { 237 | const p = this.app.puppets[room.puppetId]; 238 | if (!Array.isArray(msgs)) { 239 | msgs = [msgs]; 240 | } 241 | for (const m of msgs) { 242 | const lockKey = `${room.puppetId};${m.channel.id}`; 243 | await this.app.puppet.eventSync.insert(room, matrixId, m.id); 244 | this.app.messageDeduplicator.unlock(lockKey, p.client.user!.id, m.id); 245 | this.app.lastEventIds[m.channel.id] = m.id; 246 | } 247 | } 248 | 249 | public async createRoom(chan: IRemoteRoom): Promise { 250 | return await this.getRemoteRoomById(chan); 251 | } 252 | 253 | public async createUser(user: IRemoteUser): Promise { 254 | const p = this.app.puppets[user.puppetId]; 255 | if (!p) { 256 | return null; 257 | } 258 | if (user.userId.startsWith("webhook-")) { 259 | return null; 260 | } 261 | const u = await this.app.discord.getUserById(p.client, user.userId); 262 | if (!u) { 263 | return null; 264 | } 265 | const remoteUser = this.getRemoteUser(user.puppetId, u); 266 | remoteUser.roomOverrides = {}; 267 | for (const guild of p.client.guilds.cache.array()) { 268 | const member = guild.members.resolve(u.id); 269 | if (member) { 270 | for (const chan of guild.channels.cache.array()) { 271 | if (this.app.discord.isBridgeableGuildChannel(chan)) { 272 | remoteUser.roomOverrides[chan.id] = this.getRemoteUserRoomOverride(member, chan as BridgeableGuildChannel); 273 | } 274 | } 275 | } 276 | } 277 | return remoteUser; 278 | } 279 | 280 | public async createGroup(group: IRemoteGroup): Promise { 281 | const p = this.app.puppets[group.puppetId]; 282 | if (!p) { 283 | return null; 284 | } 285 | 286 | const guild = p.client.guilds.resolve(group.groupId); 287 | if (!guild) { 288 | return null; 289 | } 290 | return await this.getRemoteGroup(group.puppetId, guild); 291 | } 292 | 293 | public async listRooms(puppetId: number): Promise { 294 | const retGroups: IRetList[] = []; 295 | const retGuilds: IRetList[] = []; 296 | const p = this.app.puppets[puppetId]; 297 | if (!p) { 298 | return []; 299 | } 300 | for (const guild of p.client.guilds.cache.array()) { 301 | let didGuild = false; 302 | let didCat = false; 303 | await this.app.discord.iterateGuildStructure(puppetId, guild, 304 | async (cat: Discord.CategoryChannel) => { 305 | didCat = true; 306 | retGuilds.push({ 307 | category: true, 308 | name: `${guild.name} - ${cat.name}`, 309 | }); 310 | }, 311 | async (chan: BridgeableGuildChannel) => { 312 | if (!didGuild && !didCat) { 313 | didGuild = true; 314 | retGuilds.push({ 315 | category: true, 316 | name: guild.name, 317 | }); 318 | } 319 | retGuilds.push({ 320 | name: chan.name, 321 | id: chan.id, 322 | }); 323 | }, 324 | ); 325 | } 326 | for (const chan of p.client.channels.cache.array()) { 327 | if (chan instanceof Discord.GroupDMChannel) { 328 | const found = retGuilds.find((element) => element.id === chan.id); 329 | if (!found) { 330 | retGroups.push({ 331 | name: chan.name || "", 332 | id: chan.id, 333 | }); 334 | } 335 | } 336 | } 337 | return retGroups.concat(retGuilds); 338 | } 339 | 340 | public async parseMatrixMessage(puppetId: number, eventContent: any): Promise { 341 | const opts: IMatrixMessageParserOpts = { 342 | displayname: "", // something too short 343 | callbacks: { 344 | canNotifyRoom: async () => true, 345 | getUserId: async (mxid: string) => { 346 | const parts = this.app.puppet.userSync.getPartsFromMxid(mxid); 347 | if (!parts || (parts.puppetId !== puppetId && parts.puppetId !== -1)) { 348 | return null; 349 | } 350 | return parts.userId; 351 | }, 352 | getChannelId: async (mxid: string) => { 353 | const parts = await this.app.puppet.roomSync.getPartsFromMxid(mxid); 354 | if (!parts || (parts.puppetId !== puppetId && parts.puppetId !== -1)) { 355 | return null; 356 | } 357 | return parts.roomId; 358 | }, 359 | getEmoji: async (mxc: string, name: string) => { 360 | const emote = await this.app.puppet.emoteSync.getByMxc(puppetId, mxc); 361 | log.info("Found emoji", emote); 362 | if (!emote) { 363 | return null; 364 | } 365 | return { 366 | animated: Boolean(emote.data && emote.data.animated), 367 | name: ((emote.data && emote.data.name) || emote.name) as string, 368 | id: emote.emoteId, 369 | }; 370 | }, 371 | mxcUrlToHttp: (mxc: string) => this.app.puppet.getUrlFromMxc(mxc), 372 | }, 373 | determineCodeLanguage: true, 374 | }; 375 | const msg = await this.app.matrixMsgParser.FormatMessage(opts, eventContent); 376 | return msg; 377 | } 378 | 379 | public async sendMessageFail(room: IRemoteRoom) { 380 | const chan = await this.app.discord.getDiscordChan(room); 381 | if (!chan) { 382 | return; 383 | } 384 | let msg = ""; 385 | if (chan instanceof Discord.DMChannel) { 386 | msg = `Failed to send message to DM with user ${chan.recipient.username}`; 387 | } else if (chan instanceof Discord.GroupDMChannel) { 388 | let name = chan.name; 389 | if (!name) { 390 | const names: string[] = []; 391 | for (const user of chan.recipients.array()) { 392 | names.push(user.username); 393 | } 394 | name = names.join(", "); 395 | } 396 | msg = `Failed to send message into Group DM ${name}`; 397 | } else if (this.app.discord.isBridgeableGuildChannel(chan)) { 398 | const gchan = chan as BridgeableGuildChannel; 399 | msg = `Failed to send message into channel ${gchan.name} of guild ${gchan.guild.name}`; 400 | } else { 401 | msg = `Failed to send message into channel with id \`${chan.id}\``; 402 | } 403 | await this.app.puppet.sendStatusMessage(room, msg); 404 | } 405 | } 406 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2019, 2020 mx-puppet-discord 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. 12 | */ 13 | 14 | import { Store } from "mx-puppet-bridge"; 15 | 16 | const CURRENT_SCHEMA = 4; 17 | 18 | export class IDbEmoji { 19 | public emojiId: string; 20 | public name: string; 21 | public animated: boolean; 22 | public mxcUrl: string; 23 | } 24 | 25 | export class DiscordStore { 26 | constructor( 27 | private store: Store, 28 | ) { } 29 | 30 | public async init(): Promise { 31 | await this.store.init(CURRENT_SCHEMA, "discord_schema", (version: number) => { 32 | return require(`./db/schema/v${version}.js`).Schema; 33 | }, false); 34 | } 35 | 36 | public async getBridgedGuilds(puppetId: number): Promise { 37 | const rows = await this.store.db.All("SELECT guild_id FROM discord_bridged_guilds WHERE puppet_id=$puppetId", { 38 | puppetId, 39 | }); 40 | const result: string[] = []; 41 | for (const row of rows) { 42 | result.push(row.guild_id as string); 43 | } 44 | return result; 45 | } 46 | 47 | public async isGuildBridged(puppetId: number, guildId: string): Promise { 48 | const exists = await this.store.db.Get("SELECT 1 FROM discord_bridged_guilds WHERE puppet_id=$p AND guild_id=$g", { 49 | p: puppetId, 50 | g: guildId, 51 | }); 52 | return exists ? true : false; 53 | } 54 | 55 | public async setBridgedGuild(puppetId: number, guildId: string): Promise { 56 | if (await this.isGuildBridged(puppetId, guildId)) { 57 | return; 58 | } 59 | await this.store.db.Run("INSERT INTO discord_bridged_guilds (puppet_id, guild_id) VALUES ($p, $g)", { 60 | p: puppetId, 61 | g: guildId, 62 | }); 63 | } 64 | 65 | public async removeBridgedGuild(puppetId: number, guildId: string): Promise { 66 | await this.store.db.Run("DELETE FROM discord_bridged_guilds WHERE puppet_id=$p AND guild_id=$g", { 67 | p: puppetId, 68 | g: guildId, 69 | }); 70 | } 71 | 72 | public async getBridgedChannels(puppetId: number): Promise { 73 | const rows = await this.store.db.All("SELECT channel_id FROM discord_bridged_channels WHERE puppet_id=$puppetId", { 74 | puppetId, 75 | }); 76 | const result: string[] = []; 77 | for (const row of rows) { 78 | result.push(row.channel_id as string); 79 | } 80 | return result; 81 | } 82 | 83 | public async isChannelBridged(puppetId: number, channelId: string): Promise { 84 | const exists = await this.store.db.Get("SELECT 1 FROM discord_bridged_channels" + 85 | " WHERE puppet_id=$p AND channel_id=$c", { 86 | p: puppetId, 87 | c: channelId, 88 | }); 89 | return exists ? true : false; 90 | } 91 | 92 | public async setBridgedChannel(puppetId: number, channelId: string): Promise { 93 | if (await this.isChannelBridged(puppetId, channelId)) { 94 | return; 95 | } 96 | await this.store.db.Run("INSERT INTO discord_bridged_channels (puppet_id, channel_id) VALUES ($p, $c)", { 97 | p: puppetId, 98 | c: channelId, 99 | }); 100 | } 101 | 102 | public async removeBridgedChannel(puppetId: number, channelId: string): Promise { 103 | await this.store.db.Run("DELETE FROM discord_bridged_channels WHERE puppet_id=$p AND channel_id=$c", { 104 | p: puppetId, 105 | c: channelId, 106 | }); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "moduleResolution": "node", 5 | "target": "es2016", 6 | "noImplicitAny": false, 7 | "inlineSourceMap": true, 8 | "outDir": "./build", 9 | "types": ["node"], 10 | "strictNullChecks": true, 11 | "allowSyntheticDefaultImports": true 12 | }, 13 | "compileOnSave": true, 14 | "include": [ 15 | "src/**/*", 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rules": { 4 | "ordered-imports": false, 5 | "no-trailing-whitespace": "error", 6 | "max-classes-per-file": { 7 | "severity": "warning" 8 | }, 9 | "object-literal-sort-keys": "off", 10 | "no-any":{ 11 | "severity": "warning" 12 | }, 13 | "arrow-return-shorthand": true, 14 | "no-magic-numbers": [true, -1, 0, 1, 1000], 15 | "prefer-for-of": true, 16 | "typedef": { 17 | "severity": "warning" 18 | }, 19 | "await-promise": [true], 20 | "curly": true, 21 | "no-empty": false, 22 | "no-invalid-this": true, 23 | "no-string-throw": { 24 | "severity": "warning" 25 | }, 26 | "no-unused-expression": true, 27 | "prefer-const": true, 28 | "object-literal-sort-keys": false, 29 | "indent": [true, "tabs", 1], 30 | "max-file-line-count": { 31 | "severity": "warning", 32 | "options": [500] 33 | }, 34 | "no-duplicate-imports": true, 35 | "array-type": [true, "array"], 36 | "promise-function-async": true, 37 | "no-bitwise": true, 38 | "no-debugger": true, 39 | "no-floating-promises": true, 40 | "prefer-template": [true, "allow-single-concat"] 41 | } 42 | } 43 | --------------------------------------------------------------------------------