This commit is contained in:
bel
2021-09-14 06:30:17 -06:00
commit 7ab1723a5e
327 changed files with 127104 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
{
"keep": {
"days": true,
"amount": 14
},
"auditLog": ".a2a1fb62cb588c7c72ecce0113e43445aa943172-audit.json",
"files": [
{
"date": 1590160738721,
"name": "bridge.log.2020-05-22",
"hash": "ee610a74498fc9ccb40f1495f9f6826b"
}
]
}

203
matrix-discord/config.yaml Executable file
View File

@@ -0,0 +1,203 @@
bridge:
# Port to host the bridge on
# Used for communication between the homeserver and the bridge
port: 39997
# The host connections to the bridge's webserver are allowed from
bindAddress: 0.0.0.0
# Public domain of the homeserver
domain: synapse.home.blapointe.com
# Reachable URL of the Matrix homeserver
homeserverUrl: https://synapse.home.blapointe.com
# Optionally specify a different media URL used for the media store
#
# This is where Discord will download user profile pictures and media
# from
# mediaUrl: https://external-url.org
# Enables automatic double-puppeting when set. Automatic double-puppeting
# allows Discord accounts to control Matrix accounts. So sending a
# a message on Discord would send it on Matrix from your Matrix account
#
# loginSharedSecretMap is simply a map from homeserver URL
# to shared secret. Example:
#
# loginSharedSecretMap:
# matrix.org: "YOUR SHARED SECRET GOES HERE"
#
# See https://github.com/devture/matrix-synapse-shared-secret-auth for
# the necessary server module
# loginSharedSecretMap:
# Display name of the bridge bot
displayname: Discord Puppet Bridge
# Avatar URL of the bridge bot
# avatarUrl: mxc://example.com/abcdef12345
# Whether to create groups for each Discord Server
#
# Note that 'enable_group_creation' must be 'true' in Synapse's config
# for this to work
enableGroupSync: true
presence:
# Bridge Discord online/offline status
enabled: true
# How often to send status to the homeserver in milliseconds
interval: 30000
provisioning:
# Regex of Matrix IDs allowed to use the puppet bridge
whitelist:
# Allow a specific user
# - "@user:server\\.com"
# Allow users on a specific homeserver
- "@.*:synapse.home.blapointe.com"
# Allow anyone
# - ".*"
# Regex of Matrix IDs forbidden from using the puppet bridge
# blacklist:
# Disallow a specific user
# - "@user:server\\.com"
# Disallow users on a specific homeserver
# - "@.*:server\\.com"
relay:
# Regex of Matrix IDs who are allowed to use the bridge in relay mode.
# Relay mode is when a single Discord bot account relays messages of
# multiple Matrix users
#
# Same format as in provisioning
whitelist:
- "@.*:synapse.home.blapointe.com"
# blacklist:
# - "@user:yourserver\\.com"
selfService:
# Regex of Matrix IDs who are allowed to use bridge self-servicing (plumbed rooms)
#
# Same format as in provisioning
whitelist:
- "@.*:synapse.home.blapointe.com"
# blacklist:
# - "@user:server\\.com"
# Map of homeserver URLs to their C-S API endpoint
#
# Useful for double-puppeting if .well-known is unavailable for some reason
# TODO?
# homeserverUrlMap:
# yourserver.com: http://localhost:1234
# Override the default name patterns for users, rooms and groups
#
# Variable names must be prefixed with a ':'
namePatterns:
# The default displayname for a bridged user
#
# Available variables:
#
# name: username of the user
# discriminator: hashtag of the user (ex. #1234)
user: :name:discriminator
# A user's guild-specific displayname - if they've set a custom nick in
# a guild
#
# Available variables:
#
# name: username of the user
# discriminator: hashtag of the user (ex. #1234)
# displayname: the user's custom group-specific nick
# channel: the name of the channel
# guild: the name of the guild
userOverride: :displayname/:name:discriminator
# Room names for bridged Discord channels
#
# Available variables:
#
# name: name of the channel
# guild: name of the guild
room: :name
# Group names for bridged Discord servers
#
# Available variables:
#
# name: name of the guide
group: d.:name
database:
# Use Postgres as a database backend. If set, will be used instead of SQLite3
#
# Connection string to connect to the Postgres instance
# with username "user", password "pass", host "localhost" and database name "dbname".
#
# Modify each value as necessary
# connString: "postgres://user:pass@localhost/dbname?sslmode=disable"
# Use SQLite3 as a database backend
#
# The name of the database file
filename: database.db
limits:
# Up to how many users should be auto-joined on room creation? -1 to disable
# auto-join functionality
#
# Defaults to 200
# maxAutojoinUsers: 200
# How long the delay between two auto-join users should be in milliseconds
#
# Defaults to 5000
# roomUserAutojoinDelay: 5000
logging:
# Log level of console output
#
# Allowed values starting with most verbose:
# silly, verbose, info, warn, error
console: info
# Date and time formatting
lineDateFormat: MMM-D HH:mm:ss.SSS
# Logging files
#
# Log files are rotated daily by default
files:
# Log file path
- file: "bridge.log"
# Log level for this file
#
# Allowed values starting with most verbose:
# silly, debug, verbose, info, warn, error
level: info
# Date and time formatting
datePattern: YYYY-MM-DD
# Maximum number of logs to keep.
#
# This can be a number of files or number of days.
# If using days, add 'd' as a suffix
maxFiles: 14d
# Maximum size of the file after which it will rotate.
# This can be a number of bytes, or units of kb, mb, and gb.
# If using units, add 'k', 'm', or 'g' as the suffix
maxSize: 50m

15
matrix-discord/discord.yaml Executable file
View File

@@ -0,0 +1,15 @@
as_token: 9ea6bc07-9bc0-4af2-a738-1312519e5625
hs_token: 9fce3f39-7fd5-493e-81d1-4373d4efbe9d
id: discord-puppet
namespaces:
users:
- exclusive: true
regex: '@_discordpuppet_.*'
rooms: []
aliases:
- exclusive: true
regex: '#_discordpuppet_.*'
protocols: []
rate_limited: false
sender_localpart: _discordpuppet_bot
url: 'http://matrix-discord.scratch.com'

BIN
matrix-discord/master.zip Executable file

Binary file not shown.

5
matrix-discord/mx-puppet-discord/.gitignore vendored Executable file
View File

@@ -0,0 +1,5 @@
config.yaml
discord-registration.yaml
node_modules
build
*.db

View File

@@ -0,0 +1,39 @@
FROM node:latest AS builder
WORKDIR /opt/mx-puppet-discord
RUN apt update && apt -y install ca-certificates
# run build process as user in case of npm pre hooks
# pre hooks are not executed while running as root
RUN chown node:node /opt/mx-puppet-discord
USER node
COPY package.json package-lock.json ./
RUN npm install
COPY tsconfig.json ./
COPY src/ ./src/
RUN npm run build
#FROM node:alpine
VOLUME /data
ENV CONFIG_PATH=/data/config.yaml \
REGISTRATION_PATH=/data/discord-registration.yaml
# su-exec is used by docker-run.sh to drop privileges
#RUN apk add --no-cache su-exec
WORKDIR /opt/mx-puppet-discord
COPY docker-run.sh ./
#COPY /opt/mx-puppet-discord/node_modules/ ./node_modules/
#COPY /opt/mx-puppet-discord/build/ ./build/
# change workdir to /data so relative paths in the config.yaml
# point to the persisten volume
WORKDIR /mnt
ENTRYPOINT ["/opt/mx-puppet-discord/docker-run.sh"]

View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,127 @@
[![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)
# mx-puppet-discord
This is a discord puppeting bridge for matrix. It handles bridging private and group DMs, as well as Guilds (servers).
It is based on [mx-puppet-bridge](https://github.com/Sorunome/mx-puppet-bridge).
Also see [matrix-appservice-discord](https://github.com/Half-Shot/matrix-appservice-discord) for an alternative guild-only bridge.
## Setup
Clone the repo and install the dependencies:
```
git clone https://github.com/matrix-discord/mx-puppet-discord
cd mx-puppet-discord
npm install
```
Copy and edit the configuration file to your liking:
```
cp sample.config.yaml config.yaml
... edit config.yaml ...
```
Generate an appservice registration file. Optional parameters are shown in
brackets with default values:
```
npm run start -- -r [-c config.yaml] [-f discord-registration.yaml]
```
Then add the path to the registration file to your synapse `homeserver.yaml`
under `app_service_config_files`, and restart synapse.
Finally, run the bridge:
```
npm run start
```
## Usage
Start a chat with `@_discordpuppet_bot:yourserver.com`. When it joins, type
`help` in the chat to see instructions.
### Linking a Discord bot account
This is the recommended method, and allows Discord users to PM you through a
bot.
First visit your [Discord Application
Portal](https://discordapp.com/login?redirect_to=%2Fdevelopers%2Fapplications%2Fme).
1. Click on 'New Application'
![](img/bot-1.jpg)
2. Customize your bot how you like
![](img/bot-2.jpg)
3. Go to **Create Application** and scroll down to the next page. Find **Create a Bot User** and click on it.
![](img/bot-3.jpg)
4. Click '**Yes, do it!**
![](img/bot-4.jpg)
5. Find the bot's token in the '**App Bot User**' section.
![](img/bot-5.jpg)
6. Click '**Click to Reveal**'
![](img/bot-6.jpg)
Finally, send the appservice bot a message with the contents `link bot
your.token-here`.
### Linking your Discord account
**Warning**: Linking your user account's token is against Discord's Terms of Service.
First [retrieve your Discord User Token](https://discordhelp.net/discord-token).
Then send the bot a message with the contents `link user your.token-here`.
### Guild management
As most users are in many guilds none are bridged by default. You can, however, enable bridging a guild. For that use `listguilds <puppetId>`, e.g. `listguilds 1`. (Puppet ID can be found with `list`.)
Then, to bridge a guild, type `bridgeguild <puppetId> <guildId>` and to unbridge it type `unbridgeguild <puppetId> <guildId>`
### Friends management
**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!**
You first need to enable friends management with `enablefriendsmanagement <puppetId>`.
You can view all friends and invitation status with `listfriends <puppetId>`.
You can accept a friends request / send a friends request with `addfriend <puppetId> <user>` where `<user>` is either the user ID (preferred) or the `username#1234`.
You can remove friends with `removefriend <puppetId> <userId>`.
## Docker
Build docker image:
docker build -t mx-puppet-discord .
You may want some changes in your config.yaml:
```yaml
bindAddress: 0.0.0.0
filename: '/data/database.db'
file: '/data/bridge.log'
```
Once the bridge has generated the `discord-registration.yaml` edit it to fix the
address so that your matrix home server can connect to the bridge:
```yaml
url: 'http://discord:8434'
```

View File

@@ -0,0 +1,38 @@
#!/bin/sh -e
if [ ! -f "$CONFIG_PATH" ]; then
echo 'No config found'
exit 1
fi
args="$@"
if [ ! -f "$REGISTRATION_PATH" ]; then
echo 'No registration found, generating now'
args="-r"
fi
# if no --uid is supplied, prepare files to drop privileges
if [ "$(id -u)" = 0 ]; then
chown node:node /data
if find *.db > /dev/null 2>&1; then
# make sure sqlite files are writeable
chown node:node *.db
fi
if find *.log.* > /dev/null 2>&1; then
# make sure log files are writeable
chown node:node *.log.*
fi
su_exec='su-exec node:node'
else
su_exec=''
fi
# $su_exec is used in case we have to drop the privileges
exec $su_exec /usr/local/bin/node '/opt/mx-puppet-discord/build/index.js' \
-c "$CONFIG_PATH" \
-f "$REGISTRATION_PATH" \
$args

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@@ -0,0 +1,31 @@
{
"name": "mx-puppet-discord",
"version": "0.0.0",
"description": "",
"main": "build/index.js",
"scripts": {
"build": "tsc",
"lint": "tslint --project ./tsconfig.json -t stylish",
"start": "npm run-script build && node ./build/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Sorunome",
"dependencies": {
"better-discord.js": "git+https://github.com/Sorunome/better-discord.js.git#bb8be2aad13daf862d971119ab2441ce2219fc94",
"command-line-args": "^5.1.1",
"command-line-usage": "^5.0.5",
"escape-html": "^1.0.3",
"events": "^3.0.0",
"expire-set": "^1.0.0",
"js-yaml": "^3.13.1",
"matrix-discord-parser": "0.0.11",
"mime": "^2.4.4",
"mx-puppet-bridge": "0.0.43-2",
"path": "^0.12.7",
"tslint": "^5.17.0",
"typescript": "^3.7.4"
},
"devDependencies": {
"@types/node": "^12.0.8"
}
}

View File

@@ -0,0 +1,202 @@
bridge:
# Port to host the bridge on
# Used for communication between the homeserver and the bridge
port: 8434
# The host connections to the bridge's webserver are allowed from
bindAddress: localhost
# Public domain of the homeserver
domain: matrix.org
# Reachable URL of the Matrix homeserver
homeserverUrl: https://matrix.org
# Optionally specify a different media URL used for the media store
#
# This is where Discord will download user profile pictures and media
# from
#mediaUrl: https://external-url.org
# Enables automatic double-puppeting when set. Automatic double-puppeting
# allows Discord accounts to control Matrix accounts. So sending a
# a message on Discord would send it on Matrix from your Matrix account
#
# loginSharedSecretMap is simply a map from homeserver URL
# to shared secret. Example:
#
# loginSharedSecretMap:
# matrix.org: "YOUR SHARED SECRET GOES HERE"
#
# See https://github.com/devture/matrix-synapse-shared-secret-auth for
# the necessary server module
#loginSharedSecretMap:
# Display name of the bridge bot
displayname: Discord Puppet Bridge
# Avatar URL of the bridge bot
#avatarUrl: mxc://example.com/abcdef12345
# Whether to create groups for each Discord Server
#
# Note that 'enable_group_creation' must be 'true' in Synapse's config
# for this to work
enableGroupSync: true
presence:
# Bridge Discord online/offline status
enabled: true
# How often to send status to the homeserver in milliseconds
interval: 500
provisioning:
# Regex of Matrix IDs allowed to use the puppet bridge
whitelist:
# Allow a specific user
#- "@user:server\\.com"
# Allow users on a specific homeserver
- "@.*:server\\.com"
# Allow anyone
#- ".*"
# Regex of Matrix IDs forbidden from using the puppet bridge
#blacklist:
# Disallow a specific user
#- "@user:server\\.com"
# Disallow users on a specific homeserver
#- "@.*:server\\.com"
relay:
# Regex of Matrix IDs who are allowed to use the bridge in relay mode.
# Relay mode is when a single Discord bot account relays messages of
# multiple Matrix users
#
# Same format as in provisioning
whitelist:
- "@.*:yourserver\\.com"
#blacklist:
#- "@user:yourserver\\.com"
selfService:
# Regex of Matrix IDs who are allowed to use bridge self-servicing (plumbed rooms)
#
# Same format as in provisioning
whitelist:
- "@.*:server\\.com"
#blacklist:
#- "@user:server\\.com"
# Map of homeserver URLs to their C-S API endpoint
#
# Useful for double-puppeting if .well-known is unavailable for some reason
#homeserverUrlMap:
#yourserver.com: http://localhost:1234
# Override the default name patterns for users, rooms and groups
#
# Variable names must be prefixed with a ':'
namePatterns:
# The default displayname for a bridged user
#
# Available variables:
#
# name: username of the user
# discriminator: hashtag of the user (ex. #1234)
user: :name
# A user's guild-specific displayname - if they've set a custom nick in
# a guild
#
# Available variables:
#
# name: username of the user
# discriminator: hashtag of the user (ex. #1234)
# displayname: the user's custom group-specific nick
# channel: the name of the channel
# guild: the name of the guild
userOverride: :name
# Room names for bridged Discord channels
#
# Available variables:
#
# name: name of the channel
# guild: name of the guild
room: :name
# Group names for bridged Discord servers
#
# Available variables:
#
# name: name of the guide
group: :name
database:
# Use Postgres as a database backend. If set, will be used instead of SQLite3
#
# Connection string to connect to the Postgres instance
# with username "user", password "pass", host "localhost" and database name "dbname".
#
# Modify each value as necessary
#connString: "postgres://user:pass@localhost/dbname?sslmode=disable"
# Use SQLite3 as a database backend
#
# The name of the database file
filename: database.db
limits:
# Up to how many users should be auto-joined on room creation? -1 to disable
# auto-join functionality
#
# Defaults to 200
#maxAutojoinUsers: 200
# How long the delay between two auto-join users should be in milliseconds
#
# Defaults to 5000
#roomUserAutojoinDelay: 5000
logging:
# Log level of console output
#
# Allowed values starting with most verbose:
# silly, verbose, info, warn, error
console: info
# Date and time formatting
lineDateFormat: MMM-D HH:mm:ss.SSS
# Logging files
#
# Log files are rotated daily by default
files:
# Log file path
- file: "bridge.log"
# Log level for this file
#
# Allowed values starting with most verbose:
# silly, debug, verbose, info, warn, error
level: info
# Date and time formatting
datePattern: YYYY-MM-DD
# Maximum number of logs to keep.
#
# This can be a number of files or number of days.
# If using days, add 'd' as a suffix
maxFiles: 14d
# Maximum size of the file after which it will rotate.
# This can be a number of bytes, or units of kb, mb, and gb.
# If using units, add 'k', 'm', or 'g' as the suffix
maxSize: 50m

View File

@@ -0,0 +1,337 @@
/*
Copyright 2019, 2020 mx-puppet-discord
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { App } from "./app";
import { SendMessageFn, Log } from "mx-puppet-bridge";
import * as Discord from "better-discord.js";
import { BridgeableGuildChannel } from "./discord/DiscordUtil";
const log = new Log("DiscordPuppet:Commands");
const MAX_MSG_SIZE = 4000;
export class Commands {
constructor(private readonly app: App) {}
public async commandSyncProfile(puppetId: number, param: string, sendMessage: SendMessageFn) {
const p = this.app.puppets[puppetId];
if (!p) {
await sendMessage("Puppet not found!");
return;
}
const syncProfile = param === "1" || param.toLowerCase() === "true";
p.data.syncProfile = syncProfile;
await this.app.puppet.setPuppetData(puppetId, p.data);
if (syncProfile) {
await sendMessage("Syncing discord profile with matrix profile now");
await this.app.updateUserInfo(puppetId);
} else {
await sendMessage("Stopped syncing discord profile with matrix profile");
}
}
public async commandJoinEntireGuild(puppetId: number, param: string, sendMessage: SendMessageFn) {
const p = this.app.puppets[puppetId];
if (!p) {
await sendMessage("Puppet not found!");
return;
}
const guild = p.client.guilds.cache.get(param);
if (!guild) {
await sendMessage("Guild not found!");
return;
}
if (!(await this.app.store.isGuildBridged(puppetId, guild.id))) {
await sendMessage("Guild not bridged!");
return;
}
for (const chan of guild.channels.cache.array()) {
if (!this.app.discord.isBridgeableGuildChannel(chan)) {
continue;
}
const gchan = chan as BridgeableGuildChannel;
if (gchan.members.has(p.client.user!.id)) {
const remoteChan = this.app.matrix.getRemoteRoom(puppetId, gchan);
await this.app.puppet.bridgeRoom(remoteChan);
}
}
await sendMessage(`Invited to all channels in guild ${guild.name}!`);
}
public async commandListGuilds(puppetId: number, param: string, sendMessage: SendMessageFn) {
const p = this.app.puppets[puppetId];
if (!p) {
await sendMessage("Puppet not found!");
return;
}
const guilds = await this.app.store.getBridgedGuilds(puppetId);
let sendStr = "Guilds:\n";
for (const guild of p.client.guilds.cache.array()) {
let sendStrPart = ` - ${guild.name} (\`${guild.id}\`)`;
if (guilds.includes(guild.id)) {
sendStrPart += " **bridged!**";
}
sendStrPart += "\n";
if (sendStr.length + sendStrPart.length > MAX_MSG_SIZE) {
await sendMessage(sendStr);
sendStr = "";
}
sendStr += sendStrPart;
}
await sendMessage(sendStr);
}
public async commandAcceptInvite(puppetId: number, param: string, sendMessage: SendMessageFn) {
const p = this.app.puppets[puppetId];
if (!p) {
await sendMessage("Puppet not found!");
return;
}
const matches = param.match(/^(?:https?:\/\/)?(?:discord\.gg\/|discordapp\.com\/invite\/)?([^?\/\s]+)/i);
if (!matches) {
await sendMessage("No invite code found!");
return;
}
const inviteCode = matches[1];
try {
const guild = await p.client.acceptInvite(inviteCode);
if (!guild) {
await sendMessage("Something went wrong");
} else {
await sendMessage(`Accepted invite to guild ${guild.name}!`);
}
} catch (err) {
if (err.message) {
await sendMessage(`Invalid invite code \`${inviteCode}\`: ${err.message}`);
} else {
await sendMessage(`Invalid invite code \`${inviteCode}\``);
}
log.warn(`Invalid invite code ${inviteCode}:`, err);
}
}
public async commandBridgeGuild(puppetId: number, param: string, sendMessage: SendMessageFn) {
const p = this.app.puppets[puppetId];
if (!p) {
await sendMessage("Puppet not found!");
return;
}
const guild = p.client.guilds.cache.get(param);
if (!guild) {
await sendMessage("Guild not found!");
return;
}
await this.app.store.setBridgedGuild(puppetId, guild.id);
let msg = `Guild ${guild.name} (\`${guild.id}\`) is now being bridged!
Either type \`joinentireguild ${puppetId} ${guild.id}\` to get invited to all the channels of that guild `;
msg += `or type \`listrooms\` and join that way.
Additionally you will be invited to guild channels as messages are sent in them.`;
await sendMessage(msg);
}
public async commandUnbridgeGuild(puppetId: number, param: string, sendMessage: SendMessageFn) {
const p = this.app.puppets[puppetId];
if (!p) {
await sendMessage("Puppet not found!");
return;
}
const bridged = await this.app.store.isGuildBridged(puppetId, param);
if (!bridged) {
await sendMessage("Guild wasn't bridged!");
return;
}
await this.app.store.removeBridgedGuild(puppetId, param);
await sendMessage("Unbridged guild!");
}
public async commandBridgeChannel(puppetId: number, param: string, sendMessage: SendMessageFn) {
const p = this.app.puppets[puppetId];
if (!p) {
await sendMessage("Puppet not found!");
return;
}
let channel: BridgeableGuildChannel | undefined;
let guild: Discord.Guild | undefined;
for (const g of p.client.guilds.cache.array()) {
channel = g.channels.resolve(param) as BridgeableGuildChannel;
if (this.app.discord.isBridgeableGuildChannel(channel)) {
guild = g;
break;
}
channel = undefined;
}
if (!channel || !guild) {
await sendMessage("Channel not found!");
return;
}
await this.app.store.setBridgedChannel(puppetId, channel.id);
await sendMessage(`Channel ${channel.name} (\`${channel.id}\`) of guild ${guild.name} is now been bridged!`);
}
public async commandUnbridgeChannel(puppetId: number, param: string, sendMessage: SendMessageFn) {
const p = this.app.puppets[puppetId];
if (!p) {
await sendMessage("Puppet not found!");
return;
}
const bridged = await this.app.store.isChannelBridged(puppetId, param);
if (!bridged) {
await sendMessage("Channel wasn't bridged!");
return;
}
await this.app.store.removeBridgedChannel(puppetId, param);
await sendMessage("Unbridged channel!");
}
public async commandBridgeAll(puppetId: number, param: string, sendMessage: SendMessageFn) {
const p = this.app.puppets[puppetId];
if (!p) {
await sendMessage("Puppet not found!");
return;
}
const bridgeAll = param === "1" || param.toLowerCase() === "true";
p.data.bridgeAll = bridgeAll;
await this.app.puppet.setPuppetData(puppetId, p.data);
if (bridgeAll) {
await sendMessage("Bridging everything now");
} else {
await sendMessage("Not bridging everything anymore");
}
}
public async commandEnableFriendsManagement(puppetId: number, param: string, sendMessage: SendMessageFn) {
const p = this.app.puppets[puppetId];
if (!p) {
await sendMessage("Puppet not found!");
return;
}
if (p.data.friendsManagement) {
await sendMessage("Friends management is already enabled.");
return;
}
if (param === "YES I KNOW THE RISKS") {
p.data.friendsManagement = true;
await this.app.puppet.setPuppetData(puppetId, p.data);
await sendMessage("Friends management enabled!");
return;
}
await sendMessage(`Using user accounts is against discords TOS. As this is required for friends management, you ` +
`will be breaking discords TOS if you enable this feature. Development of it has already softlocked accounts. ` +
`USE AT YOUR OWN RISK!\n\nIf you want to enable friends management type \`enablefriendsmanagement ${puppetId} ` +
`YES I KNOW THE RISKS\``);
}
public async commandListFriends(puppetId: number, param: string, sendMessage: SendMessageFn) {
const p = this.app.puppets[puppetId];
if (!p) {
await sendMessage("Puppet not found!");
return;
}
if (!p.data.friendsManagement) {
await sendMessage(`Friends management is disabled. Please type ` +
`\`enablefriendsmanagement ${puppetId}\` to enable it`);
return;
}
let sendStr = "";
const friends = p.client.user!.relationships.friends;
if (friends.size > 0) {
sendStr += "Friends:\n";
for (const user of p.client.user!.relationships.friends.array()) {
const mxid = await this.app.puppet.getMxidForUser({
puppetId,
userId: user.id,
});
const sendStrPart = ` - ${user.username} (\`${user.id}\`): [${user.username}](https://matrix.to/#/${mxid})\n`;
if (sendStr.length + sendStrPart.length > MAX_MSG_SIZE) {
await sendMessage(sendStr);
sendStr = "";
}
sendStr += sendStrPart;
}
}
const incoming = p.client.user!.relationships.incoming;
if (incoming.size > 0) {
sendStr += "\nIncoming friend requests:\n";
for (const user of incoming.array()) {
const sendStrPart = ` - ${user.username} (\`${user.id}\`)\n`;
if (sendStr.length + sendStrPart.length > MAX_MSG_SIZE) {
await sendMessage(sendStr);
sendStr = "";
}
sendStr += sendStrPart;
}
}
const outgoing = p.client.user!.relationships.outgoing;
if (outgoing.size > 0) {
sendStr += "\nOutgoing friend requests:\n";
for (const user of outgoing.array()) {
const sendStrPart = ` - ${user.username} (\`${user.id}\`)\n`;
if (sendStr.length + sendStrPart.length > MAX_MSG_SIZE) {
await sendMessage(sendStr);
sendStr = "";
}
sendStr += sendStrPart;
}
}
await sendMessage(sendStr);
}
public async commandAddFriend(puppetId: number, param: string, sendMessage: SendMessageFn) {
const p = this.app.puppets[puppetId];
if (!p) {
await sendMessage("Puppet not found!");
return;
}
if (!p.data.friendsManagement) {
await sendMessage(`Friends management is disabled. Please type ` +
`\`enablefriendsmanagement ${puppetId}\` to enable it`);
return;
}
try {
const user = await p.client.user!.relationships.request("friend", param);
if (user) {
await sendMessage(`Added/sent friend request to ${typeof user === "string" ? user : user.username}!`);
} else {
await sendMessage("User not found");
}
} catch (err) {
await sendMessage("User not found");
log.warn(`Couldn't find user ${param}:`, err);
}
}
public async commandRemoveFriend(puppetId: number, param: string, sendMessage: SendMessageFn) {
const p = this.app.puppets[puppetId];
if (!p) {
await sendMessage("Puppet not found!");
return;
}
if (!p.data.friendsManagement) {
await sendMessage(`Friends management is disabled. Please type ` +
`\`enablefriendsmanagement ${puppetId}\` to enable it`);
return;
}
try {
const user = await p.client.user!.relationships.remove(param);
if (user) {
await sendMessage(`Removed ${user.username} as friend!`);
} else {
await sendMessage("User not found");
}
} catch (err) {
await sendMessage("User not found");
log.warn(`Couldn't find user ${param}:`, err);
}
}
}

View File

@@ -0,0 +1,477 @@
/* tslint:disable: no-any */
/*
Copyright 2019, 2020 mx-puppet-discord
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {
PuppetBridge,
Log,
Util,
IRetList,
MessageDeduplicator,
IRemoteRoom,
} from "mx-puppet-bridge";
import * as Discord from "better-discord.js";
import {
DiscordMessageParser,
MatrixMessageParser,
} from "matrix-discord-parser";
import * as path from "path";
import * as mime from "mime";
import { DiscordStore } from "./store";
import {
DiscordUtil, TextGuildChannel, TextChannel, BridgeableGuildChannel, BridgeableChannel,
} from "./discord/DiscordUtil";
import { MatrixUtil } from "./matrix/MatrixUtil";
import { Commands } from "./Commands";
import ExpireSet from "expire-set";
const log = new Log("DiscordPuppet:App");
export const AVATAR_SETTINGS: Discord.ImageURLOptions & { dynamic?: boolean | undefined; }
= { format: "png", size: 2048, dynamic: true };
export const MAXFILESIZE = 8000000;
export interface IDiscordPuppet {
client: Discord.Client;
data: any;
deletedMessages: ExpireSet<string>;
}
export interface IDiscordPuppets {
[puppetId: number]: IDiscordPuppet;
}
export interface IDiscordSendFile {
buffer: Buffer;
filename: string;
url: string;
isImage: boolean;
}
export class App {
public puppets: IDiscordPuppets = {};
public discordMsgParser: DiscordMessageParser;
public matrixMsgParser: MatrixMessageParser;
public messageDeduplicator: MessageDeduplicator;
public store: DiscordStore;
public lastEventIds: {[chan: string]: string} = {};
public readonly discord: DiscordUtil;
public readonly matrix: MatrixUtil;
public readonly commands: Commands;
constructor(
public puppet: PuppetBridge,
) {
this.discordMsgParser = new DiscordMessageParser();
this.matrixMsgParser = new MatrixMessageParser();
this.messageDeduplicator = new MessageDeduplicator();
this.store = new DiscordStore(puppet.store);
this.discord = new DiscordUtil(this);
this.matrix = new MatrixUtil(this);
this.commands = new Commands(this);
}
public async init(): Promise<void> {
await this.store.init();
}
public async handlePuppetName(puppetId: number, name: string) {
const p = this.puppets[puppetId];
if (!p || !p.data.syncProfile || !p.client.user!.bot) {
// bots can't change their name
return;
}
try {
await p.client.user!.setUsername(name);
} catch (err) {
log.warn(`Couldn't set name for ${puppetId}`, err);
}
}
public async handlePuppetAvatar(puppetId: number, url: string, mxc: string) {
const p = this.puppets[puppetId];
if (!p || !p.data.syncProfile) {
return;
}
try {
const AVATAR_SIZE = 800;
const realUrl = this.puppet.getUrlFromMxc(mxc, AVATAR_SIZE, AVATAR_SIZE, "scale");
const buffer = await Util.DownloadFile(realUrl);
await p.client.user!.setAvatar(buffer);
} catch (err) {
log.warn(`Couldn't set avatar for ${puppetId}`, err);
}
}
public async newPuppet(puppetId: number, data: any) {
log.info(`Adding new Puppet: puppetId=${puppetId}`);
if (this.puppets[puppetId]) {
await this.deletePuppet(puppetId);
}
const client = new Discord.Client();
client.on("ready", async () => {
const d = this.puppets[puppetId].data;
d.username = client.user!.tag;
d.id = client.user!.id;
d.bot = client.user!.bot;
await this.puppet.setUserId(puppetId, client.user!.id);
await this.puppet.setPuppetData(puppetId, d);
await this.puppet.sendStatusMessage(puppetId, "connected");
await this.updateUserInfo(puppetId);
// set initial presence for everyone
for (const user of client.users.cache.array()) {
await this.discord.updatePresence(puppetId, user.presence);
}
});
client.on("message", async (msg: Discord.Message) => {
try {
await this.discord.events.handleDiscordMessage(puppetId, msg);
} catch (err) {
log.error("Error handling discord message event", err.error || err.body || err);
}
});
client.on("messageUpdate", async (msg1: Discord.Message, msg2: Discord.Message) => {
try {
await this.discord.events.handleDiscordMessageUpdate(puppetId, msg1, msg2);
} catch (err) {
log.error("Error handling discord messageUpdate event", err.error || err.body || err);
}
});
client.on("messageDelete", async (msg: Discord.Message) => {
try {
await this.discord.events.handleDiscordMessageDelete(puppetId, msg);
} catch (err) {
log.error("Error handling discord messageDelete event", err.error || err.body || err);
}
});
client.on("messageDeleteBulk", async (msgs: Discord.Collection<Discord.Snowflake, Discord.Message>) => {
for (const msg of msgs.array()) {
try {
await this.discord.events.handleDiscordMessageDelete(puppetId, msg);
} catch (err) {
log.error("Error handling one discord messageDeleteBulk event", err.error || err.body || err);
}
}
});
client.on("typingStart", async (chan: Discord.Channel, user: Discord.User) => {
try {
if (!this.discord.isBridgeableChannel(chan)) {
return;
}
const params = this.matrix.getSendParams(puppetId, chan as BridgeableChannel, user);
await this.puppet.setUserTyping(params, true);
} catch (err) {
log.error("Error handling discord typingStart event", err.error || err.body || err);
}
});
client.on("presenceUpdate", async (_, presence: Discord.Presence) => {
try {
await this.discord.updatePresence(puppetId, presence);
} catch (err) {
log.error("Error handling discord presenceUpdate event", err.error || err.body || err);
}
});
client.on("messageReactionAdd", async (reaction: Discord.MessageReaction, user: Discord.User) => {
try {
// TODO: filter out echo back?
const chan = reaction.message.channel;
if (!await this.bridgeRoom(puppetId, chan)) {
return;
}
const params = this.matrix.getSendParams(puppetId, chan, user);
if (reaction.emoji.id) {
const mxc = await this.matrix.getEmojiMxc(
puppetId, reaction.emoji.name, reaction.emoji.animated, reaction.emoji.id,
);
await this.puppet.sendReaction(params, reaction.message.id, mxc || reaction.emoji.name);
} else {
await this.puppet.sendReaction(params, reaction.message.id, reaction.emoji.name);
}
} catch (err) {
log.error("Error handling discord messageReactionAdd event", err.error || err.body || err);
}
});
client.on("messageReactionRemove", async (reaction: Discord.MessageReaction, user: Discord.User) => {
try {
// TODO: filter out echo back?
const chan = reaction.message.channel;
if (!await this.bridgeRoom(puppetId, chan)) {
return;
}
const params = this.matrix.getSendParams(puppetId, chan, user);
if (reaction.emoji.id) {
const mxc = await this.matrix.getEmojiMxc(
puppetId, reaction.emoji.name, reaction.emoji.animated, reaction.emoji.id,
);
await this.puppet.removeReaction(params, reaction.message.id, mxc || reaction.emoji.name);
} else {
await this.puppet.removeReaction(params, reaction.message.id, reaction.emoji.name);
}
} catch (err) {
log.error("Error handling discord messageReactionRemove event", err.error || err.body || err);
}
});
client.on("messageReactionRemoveAll", async (message: Discord.Message) => {
try {
const chan = message.channel;
if (!await this.bridgeRoom(puppetId, chan)) {
return;
}
// alright, let's fetch *an* admin user
let user: Discord.User;
if (this.discord.isBridgeableGuildChannel(chan)) {
const gchan = chan as BridgeableGuildChannel;
user = gchan.guild.owner ? gchan.guild.owner.user : client.user!;
} else if (chan instanceof Discord.DMChannel) {
user = chan.recipient;
} else if (chan instanceof Discord.GroupDMChannel) {
user = chan.owner;
} else {
user = client.user!;
}
const params = this.matrix.getSendParams(puppetId, chan, user);
await this.puppet.removeAllReactions(params, message.id);
} catch (err) {
log.error("Error handling discord messageReactionRemoveAll event", err.error || err.body || err);
}
});
client.on("channelUpdate", async (_, chan: Discord.Channel) => {
if (!this.discord.isBridgeableChannel(chan)) {
return;
}
const remoteChan = this.matrix.getRemoteRoom(puppetId, chan as BridgeableChannel);
await this.puppet.updateRoom(remoteChan);
});
client.on("guildMemberUpdate", async (oldMember: Discord.GuildMember, newMember: Discord.GuildMember) => {
const promiseList: Promise<void>[] = [];
if (oldMember.displayName !== newMember.displayName) {
promiseList.push((async () => {
const remoteUser = this.matrix.getRemoteUser(puppetId, newMember);
await this.puppet.updateUser(remoteUser);
})());
}
// aaaand check for role change
const leaveRooms = new Set<BridgeableGuildChannel>();
const joinRooms = new Set<BridgeableGuildChannel>();
for (const chan of newMember.guild.channels.cache.array()) {
if (!this.discord.isBridgeableGuildChannel(chan)) {
continue;
}
const gchan = chan as BridgeableGuildChannel;
if (gchan.members.has(newMember.id)) {
joinRooms.add(gchan);
} else {
leaveRooms.add(gchan);
}
}
for (const chan of leaveRooms) {
promiseList.push((async () => {
const params = this.matrix.getSendParams(puppetId, chan, newMember);
await this.puppet.removeUser(params);
})());
}
for (const chan of joinRooms) {
promiseList.push((async () => {
const params = this.matrix.getSendParams(puppetId, chan, newMember);
await this.puppet.addUser(params);
})());
}
await Promise.all(promiseList);
});
client.on("userUpdate", async (_, user: Discord.User) => {
const remoteUser = this.matrix.getRemoteUser(puppetId, user);
await this.puppet.updateUser(remoteUser);
});
client.on("guildUpdate", async (_, guild: Discord.Guild) => {
try {
const remoteGroup = await this.matrix.getRemoteGroup(puppetId, guild);
await this.puppet.updateGroup(remoteGroup);
for (const chan of guild.channels.cache.array()) {
if (!this.discord.isBridgeableGuildChannel(chan)) {
return;
}
const remoteChan = this.matrix.getRemoteRoom(puppetId, chan as BridgeableGuildChannel);
await this.puppet.updateRoom(remoteChan);
}
} catch (err) {
log.error("Error handling discord guildUpdate event", err.error || err.body || err);
}
});
client.on("relationshipAdd", async (_, relationship: Discord.Relationship) => {
if (relationship.type === "incoming") {
const msg = `New incoming friends request from ${relationship.user.username}!
Type \`addfriend ${puppetId} ${relationship.user.id}\` to accept it.`;
await this.puppet.sendStatusMessage(puppetId, msg);
}
});
client.on("guildMemberAdd", async (member: Discord.GuildMember) => {
const promiseList: Promise<void>[] = [];
for (const chan of member.guild.channels.cache.array()) {
if ((await this.bridgeRoom(puppetId, chan)) && chan.members.has(member.id)) {
promiseList.push((async () => {
const params = this.matrix.getSendParams(puppetId, chan as BridgeableGuildChannel, member);
await this.puppet.addUser(params);
})());
}
}
await Promise.all(promiseList);
});
client.on("guildMemberRemove", async (member: Discord.GuildMember) => {
const promiseList: Promise<void>[] = [];
for (const chan of member.guild.channels.cache.array()) {
if (this.discord.isBridgeableGuildChannel(chan)) {
promiseList.push((async () => {
const params = this.matrix.getSendParams(puppetId, chan as BridgeableGuildChannel, member);
await this.puppet.removeUser(params);
})());
}
}
await Promise.all(promiseList);
});
const TWO_MIN = 120000;
this.puppets[puppetId] = {
client,
data,
deletedMessages: new ExpireSet(TWO_MIN),
};
await client.login(data.token, data.bot || false);
}
public async deletePuppet(puppetId: number) {
log.info(`Got signal to quit Puppet: puppetId=${puppetId}`);
const p = this.puppets[puppetId];
if (!p) {
return; // nothing to do
}
p.client.destroy();
delete this.puppet[puppetId];
}
public async listUsers(puppetId: number): Promise<IRetList[]> {
const retUsers: IRetList[] = [];
const retGuilds: IRetList[] = [];
const p = this.puppets[puppetId];
if (!p) {
return [];
}
const blacklistedIds = [p.client.user!.id, "1"];
for (const guild of p.client.guilds.cache.array()) {
retGuilds.push({
category: true,
name: guild.name,
});
for (const member of guild.members.cache.array()) {
if (!blacklistedIds.includes(member.user.id)) {
retGuilds.push({
name: member.user.username,
id: member.user.id,
});
}
}
}
for (const user of p.client.users.cache.array()) {
const found = retGuilds.find((element) => element.id === user.id);
if (!found && !blacklistedIds.includes(user.id)) {
retUsers.push({
name: user.username,
id: user.id,
});
}
}
return retUsers.concat(retGuilds);
}
public async getUserIdsInRoom(room: IRemoteRoom): Promise<Set<string> | null> {
const chan = await this.discord.getDiscordChan(room);
if (!chan) {
return null;
}
const users = new Set<string>();
if (chan instanceof Discord.DMChannel) {
users.add(chan.recipient.id);
return users;
}
if (chan instanceof Discord.GroupDMChannel) {
for (const recipient of chan.recipients.array()) {
users.add(recipient.id);
}
return users;
}
if (this.discord.isBridgeableGuildChannel(chan)) {
// chan.members already does a permission check, yay!
const gchan = chan as BridgeableGuildChannel;
for (const member of gchan.members.array()) {
users.add(member.id);
}
return users;
}
return null;
}
public async updateUserInfo(puppetId: number) {
const p = this.puppets[puppetId];
if (!p || !p.data.syncProfile) {
return;
}
const userInfo = await this.puppet.getPuppetMxidInfo(puppetId);
if (userInfo) {
if (userInfo.name) {
await this.handlePuppetName(puppetId, userInfo.name);
}
if (userInfo.avatarUrl) {
await this.handlePuppetAvatar(puppetId, userInfo.avatarUrl, userInfo.avatarMxc as string);
}
}
}
public async bridgeRoom(puppetId: number, chan: Discord.Channel): Promise<boolean> {
if (["dm", "group"].includes(chan.type)) {
return true; // we handle all dm and group channels
}
if (!this.discord.isBridgeableChannel(chan)) {
return false; // we only handle text and news things
}
if (this.puppets[puppetId] && this.puppets[puppetId].data.bridgeAll) {
return true; // we want to bridge everything anyways, no need to hit the store
}
if (this.discord.isBridgeableGuildChannel(chan)) {
// we have a guild text channel, maybe we handle it!
const gchan = chan as BridgeableGuildChannel;
if (await this.store.isGuildBridged(puppetId, gchan.guild.id)) {
return true;
}
// maybe it is a single channel override?
return await this.store.isChannelBridged(puppetId, gchan.id);
}
return false;
}
public getFilenameForMedia(filename: string, mimetype: string): string {
let ext = "";
const mimeExt = mime.getExtension(mimetype);
if (mimeExt) {
ext = "." + mimeExt;
}
if (filename) {
if (path.extname(filename) !== "") {
return filename;
}
return path.basename(filename) + ext;
}
return "matrix-media" + ext;
}
}

View File

@@ -0,0 +1,37 @@
/*
Copyright 2019 mx-puppet-discord
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { IDbSchema, Store } from "mx-puppet-bridge";
export class Schema implements IDbSchema {
public description = "Schema, Emotestore";
public async run(store: Store) {
await store.createTable(`
CREATE TABLE discord_schema (
version INTEGER UNIQUE NOT NULL
);`, "discord_schema");
await store.db.Exec("INSERT INTO discord_schema VALUES (0);");
await store.createTable(`
CREATE TABLE discord_emoji (
emoji_id TEXT NOT NULL,
name TEXT NOT NULL,
animated INTEGER NOT NULL,
mxc_url TEXT NOT NULL,
PRIMARY KEY(emoji_id)
);`, "discord_emoji");
}
public async rollBack(store: Store) {
await store.db.Exec("DROP TABLE IF EXISTS discord_schema");
await store.db.Exec("DROP TABLE IF EXISTS discord_emoji");
}
}

View File

@@ -0,0 +1,29 @@
/*
Copyright 2020 mx-puppet-discord
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { IDbSchema, Store } from "mx-puppet-bridge";
export class Schema implements IDbSchema {
public description = "Guilds Bridged";
public async run(store: Store) {
await store.createTable(`
CREATE TABLE discord_bridged_guilds (
id SERIAL PRIMARY KEY,
puppet_id INTEGER NOT NULL,
guild_id TEXT NOT NULL
);`, "discord_bridged_guilds");
}
public async rollBack(store: Store) {
await store.db.Exec("DROP TABLE IF EXISTS discord_bridged_guilds");
}
}

View File

@@ -0,0 +1,29 @@
/*
Copyright 2020 mx-puppet-discord
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { IDbSchema, Store } from "mx-puppet-bridge";
export class Schema implements IDbSchema {
public description = "Channels Bridged";
public async run(store: Store) {
await store.createTable(`
CREATE TABLE discord_bridged_channels (
id SERIAL PRIMARY KEY,
puppet_id INTEGER NOT NULL,
channel_id TEXT NOT NULL
);`, "discord_bridged_channels");
}
public async rollBack(store: Store) {
await store.db.Exec("DROP TABLE IF EXISTS discord_bridged_channels");
}
}

View File

@@ -0,0 +1,59 @@
/*
Copyright 2020 mx-puppet-discord
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Log, IDbSchema, Store } from "mx-puppet-bridge";
export class Schema implements IDbSchema {
public description = "migrate dm room IDs";
public async run(store: Store) {
try {
let rows: any[];
try {
rows = await store.db.All("SELECT * FROM chan_store WHERE room_id LIKE 'dm%'");
} catch (e) {
rows = await store.db.All("SELECT * FROM room_store WHERE room_id LIKE 'dm%'");
}
for (const row of rows) {
const parts = (row.room_id as string).split("-");
row.room_id = `dm-${row.puppet_id}-${parts[1]}`;
try {
await store.db.Run(`UPDATE chan_store SET
room_id = $room_id,
puppet_id = $puppet_id,
name = $name,
avatar_url = $avatar_url,
avatar_mxc = $avatar_mxc,
avatar_hash = $avatar_hash,
topic = $topic,
group_id = $group_id
WHERE mxid = $mxid`, row);
} catch (e) {
await store.db.Run(`UPDATE room_store SET
room_id = $room_id,
puppet_id = $puppet_id,
name = $name,
avatar_url = $avatar_url,
avatar_mxc = $avatar_mxc,
avatar_hash = $avatar_hash,
topic = $topic,
group_id = $group_id
WHERE mxid = $mxid`, row);
}
}
} catch (err) {
const log = new Log("DiscordPuppet::DbUpgrade");
log.error("Failed to migrate room ID data:", err);
}
}
public async rollBack(store: Store) { } // no rollback
}

View File

@@ -0,0 +1,141 @@
/* tslint:disable: no-any */
/*
Copyright 2019, 2020 mx-puppet-discord
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { App } from "../app";
import * as Discord from "better-discord.js";
import { IDiscordMessageParserOpts, DiscordMessageParser } from "matrix-discord-parser";
import { Log } from "mx-puppet-bridge";
import { TextGuildChannel } from "./DiscordUtil";
const log = new Log("DiscordPuppet:DiscordEventHandler");
export class DiscordEventHandler {
private discordMsgParser: DiscordMessageParser;
public constructor(private readonly app: App) {
this.discordMsgParser = this.app.discordMsgParser;
}
public async handleDiscordMessage(puppetId: number, msg: Discord.Message) {
const p = this.app.puppets[puppetId];
if (!p) {
return;
}
if (msg.type !== "DEFAULT") {
return;
}
log.info("Received new message!");
if (!await this.app.bridgeRoom(puppetId, msg.channel)) {
log.info("Unhandled channel, dropping message...");
return;
}
const params = this.app.matrix.getSendParams(puppetId, msg);
const lockKey = `${puppetId};${msg.channel.id}`;
const dedupeMsg = msg.attachments.first() ? `file:${msg.attachments.first()!.name}` : msg.content;
if (await this.app.messageDeduplicator.dedupe(lockKey, msg.author.id, msg.id, dedupeMsg)) {
// dedupe message
log.info("Deduping message, dropping...");
return;
}
if (msg.webhookID && this.app.discord.isTextGuildChannel(msg.channel)) {
// maybe we are a webhook from our webhook?
const chan = msg.channel as TextGuildChannel;
try {
const hook = (await chan.fetchWebhooks()).find((h) => h.name === "_matrix") || null;
if (hook && msg.webhookID === hook.id) {
log.info("Message sent from our webhook, deduping...");
return;
}
} catch (err) { } // no webhook permissions, ignore
}
this.app.lastEventIds[msg.channel.id] = msg.id;
const externalUrl = params.externalUrl;
for (const attachment of msg.attachments.array()) {
params.externalUrl = attachment.url;
await this.app.puppet.sendFileDetect(params, attachment.url, attachment.name);
}
params.externalUrl = externalUrl;
if (msg.content || msg.embeds.length > 0) {
const opts: IDiscordMessageParserOpts = {
callbacks: this.app.discord.getDiscordMsgParserCallbacks(puppetId),
};
const reply = await this.discordMsgParser.FormatMessage(opts, msg);
await this.app.puppet.sendMessage(params, {
body: reply.body,
formattedBody: reply.formattedBody,
emote: reply.msgtype === "m.emote",
notice: reply.msgtype === "m.notice",
});
}
}
public async handleDiscordMessageUpdate(puppetId: number, msg1: Discord.Message, msg2: Discord.Message) {
if (msg1.content === msg2.content) {
return; // nothing to do
}
const p = this.app.puppets[puppetId];
if (!p) {
return;
}
const params = this.app.matrix.getSendParams(puppetId, msg1);
const lockKey = `${puppetId};${msg1.channel.id}`;
if (await this.app.messageDeduplicator.dedupe(lockKey, msg2.author.id, msg2.id, msg2.content)) {
// dedupe message
return;
}
if (!await this.app.bridgeRoom(puppetId, msg1.channel)) {
log.info("Unhandled channel, dropping message...");
return;
}
const opts: IDiscordMessageParserOpts = {
callbacks: this.app.discord.getDiscordMsgParserCallbacks(puppetId),
};
const reply = await this.discordMsgParser.FormatMessage(opts, msg2);
if (msg1.content) {
// okay we have an actual edit
await this.app.puppet.sendEdit(params, msg1.id, {
body: reply.body,
formattedBody: reply.formattedBody,
emote: reply.msgtype === "m.emote",
notice: reply.msgtype === "m.notice",
});
} else {
// we actually just want to insert a new message
await this.app.puppet.sendMessage(params, {
body: reply.body,
formattedBody: reply.formattedBody,
emote: reply.msgtype === "m.emote",
notice: reply.msgtype === "m.notice",
});
}
}
public async handleDiscordMessageDelete(puppetId: number, msg: Discord.Message) {
const p = this.app.puppets[puppetId];
if (!p) {
return;
}
const params = this.app.matrix.getSendParams(puppetId, msg);
const lockKey = `${puppetId};${msg.channel.id}`;
if (p.deletedMessages.has(msg.id) ||
await this.app.messageDeduplicator.dedupe(lockKey, msg.author.id, msg.id, msg.content)) {
// dedupe message
return;
}
if (!await this.app.bridgeRoom(puppetId, msg.channel)) {
log.info("Unhandled channel, dropping message...");
return;
}
await this.app.puppet.sendRedact(params, msg.id);
}
}

View File

@@ -0,0 +1,356 @@
/*
Copyright 2019, 2020 mx-puppet-discord
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { App, IDiscordSendFile } from "../app";
import * as Discord from "better-discord.js";
import { DiscordEventHandler } from "./DiscordEventHandler";
import { ISendingUser, Log, IRemoteRoom } from "mx-puppet-bridge";
import { IDiscordMessageParserCallbacks } from "matrix-discord-parser";
const log = new Log("DiscordPuppet:DiscordUtil");
export type TextGuildChannel = Discord.TextChannel | Discord.NewsChannel;
export type TextChannel = TextGuildChannel | Discord.DMChannel | Discord.GroupDMChannel;
export type BridgeableGuildChannel = Discord.TextChannel | Discord.NewsChannel;
export type BridgeableChannel = BridgeableGuildChannel | Discord.DMChannel | Discord.GroupDMChannel;
export class DiscordUtil {
public readonly events: DiscordEventHandler;
public constructor(private readonly app: App) {
this.events = new DiscordEventHandler(app);
}
public async getDiscordChan(
room: IRemoteRoom,
): Promise<BridgeableChannel | null> {
const p = this.app.puppets[room.puppetId];
if (!p) {
return null;
}
const id = room.roomId;
const client = p.client;
if (!id.startsWith("dm-")) {
// first fetch from the client channel cache
const chan = client.channels.resolve(id);
if (this.isBridgeableChannel(chan)) {
return chan as BridgeableChannel;
}
// next iterate over all the guild channels
for (const guild of client.guilds.cache.array()) {
const c = guild.channels.resolve(id);
if (this.isBridgeableChannel(c)) {
return c as BridgeableChannel;
}
}
return null; // nothing found
} else {
// we have a DM channel
const parts = id.split("-");
if (Number(parts[1]) !== room.puppetId) {
return null;
}
const PART_USERID = 2;
const lookupId = parts[PART_USERID];
const user = await this.getUserById(client, lookupId);
if (!user) {
return null;
}
const chan = await user.createDM();
return chan;
}
}
public async sendToDiscord(
chan: TextChannel,
msg: string | IDiscordSendFile,
asUser: ISendingUser | null,
replyEmbed?: Discord.MessageEmbed,
): Promise<Discord.Message | Discord.Message[]> {
log.debug("Sending something to discord...");
let sendThing: string | Discord.MessageAdditions;
if (typeof msg === "string") {
sendThing = msg;
} else {
sendThing = new Discord.MessageAttachment(msg.buffer, msg.filename);
}
if (!asUser) {
// we don't want to relay, so just send off nicely
log.debug("Not in relay mode, just sending as user");
if (replyEmbed && chan.client.user!.bot) {
return await chan.send(sendThing, replyEmbed);
}
return await chan.send(sendThing);
}
// alright, we have to send as if it was another user. First try webhooks.
if (this.isTextGuildChannel(chan)) {
chan = chan as TextGuildChannel;
log.debug("Trying to send as webhook...");
let hook: Discord.Webhook | null = null;
try {
hook = (await chan.fetchWebhooks()).find((h) => h.name === "_matrix") || null;
if (!hook) {
try {
hook = await chan.createWebhook("_matrix", {
reason: "Allow bridging matrix messages to discord nicely",
});
} catch (err) {
log.warn("Unable to create \"_matrix\" webhook", err);
}
}
} catch (err) {
log.warn("Missing webhook permissions", err);
}
if (hook) {
const hookOpts: Discord.WebhookMessageOptions & { split: true } = {
username: asUser.displayname,
avatarURL: asUser.avatarUrl || undefined,
embeds: replyEmbed ? [replyEmbed] : [],
split: true,
};
if (typeof sendThing === "string") {
return await hook.send(sendThing, hookOpts);
}
if (sendThing instanceof Discord.MessageAttachment) {
hookOpts.files = [sendThing];
}
return await hook.send(hookOpts);
}
log.debug("Couldn't send as webhook");
}
// alright, we either weren't able to send as webhook or we aren't in a webhook-able channel.
// so.....let's try to send as embed next
if (chan.client.user!.bot) {
log.debug("Trying to send as embed...");
const embed = new Discord.MessageEmbed();
if (typeof msg === "string") {
embed.setDescription(msg);
} else if (msg.isImage) {
embed.setTitle(msg.filename);
embed.setImage(msg.url);
} else {
const filename = await this.discordEscape(msg.filename);
embed.setDescription(`Uploaded a file \`${filename}\`: ${msg.url}`);
}
if (replyEmbed && replyEmbed.description) {
embed.addField("Replying to", replyEmbed.author!.name);
embed.addField("Reply text", replyEmbed.description);
}
embed.setAuthor(asUser.displayname, asUser.avatarUrl || undefined, `https://matrix.to/#/${asUser.mxid}`);
return await chan.send(embed);
}
// alright, nothing is working....let's prefix the displayname and send stuffs
log.debug("Prepending sender information to send the message out...");
const displayname = await this.discordEscape(asUser.displayname);
let sendMsg = "";
if (typeof msg === "string") {
sendMsg = `**${displayname}**: ${msg}`;
} else {
const filename = await this.discordEscape(msg.filename);
sendMsg = `**${displayname}** uploaded a file \`${filename}\`: ${msg.url}`;
}
if (replyEmbed && replyEmbed.description) {
sendMsg += `\n>>> ${replyEmbed.description}`;
}
return await chan.send(sendMsg);
}
public async discordEscape(msg: string): Promise<string> {
return await this.app.matrix.parseMatrixMessage(-1, {
body: msg,
msgtype: "m.text",
});
}
public async updatePresence(puppetId: number, presence: Discord.Presence) {
const p = this.app.puppets[puppetId];
if (!p) {
return;
}
if (!presence || !presence.user) {
return;
}
const matrixPresence = {
online: "online",
idle: "unavailable",
dnd: "unavailable",
offline: "offline",
}[presence.status] as "online" | "offline" | "unavailable";
let statusMsg = "";
for (const activity of presence.activities) {
if (statusMsg !== "") {
break;
}
const statusParts: string[] = [];
if (activity.type !== "CUSTOM_STATUS") {
const lower = activity.type.toLowerCase();
statusParts.push(lower.charAt(0).toUpperCase() + lower.substring(1));
if (activity.type === "LISTENING") {
statusParts.push(`to ${activity.details} by ${activity.state}`);
} else {
if (activity.name) {
statusParts.push(activity.name);
}
}
} else {
if (activity.emoji) {
statusParts.push(activity.emoji.name);
}
if (activity.state) {
statusParts.push(activity.state);
}
}
statusMsg = statusParts.join(" ");
}
const remoteUser = this.app.matrix.getRemoteUser(puppetId, presence.user!);
await this.app.puppet.setUserPresence(remoteUser, matrixPresence);
if (statusMsg) {
await this.app.puppet.setUserStatus(remoteUser, statusMsg);
}
}
public getDiscordMsgParserCallbacks(puppetId: number): IDiscordMessageParserCallbacks {
const p = this.app.puppets[puppetId];
return {
getUser: async (id: string) => {
const mxid = await this.app.puppet.getMxidForUser({
puppetId,
userId: id,
});
let name = mxid;
const user = await this.getUserById(p.client, id);
if (user) {
name = user.username;
}
return {
mxid,
name,
};
},
getChannel: async (id: string) => {
const room: IRemoteRoom = {
puppetId,
roomId: id,
};
const mxid = await this.app.puppet.getMxidForRoom(room);
let name = mxid;
const chan = await this.getDiscordChan(room);
if (chan && !(chan instanceof Discord.DMChannel)) {
name = chan.name || "";
}
return {
mxid,
name,
};
},
getEmoji: async (name: string, animated: boolean, id: string) => {
return await this.app.matrix.getEmojiMxc(puppetId, name, animated, id);
},
};
}
public async getDiscordEmoji(puppetId: number, mxc: string): Promise<Discord.GuildEmoji | null> {
const p = this.app.puppets[puppetId];
if (!p) {
return null;
}
const emote = await this.app.puppet.emoteSync.getByMxc(puppetId, mxc);
if (!emote) {
return null;
}
const emoji = p.client.emojis.resolve(emote.emoteId);
return emoji || null;
}
public async iterateGuildStructure(
puppetId: number,
guild: Discord.Guild,
catCallback: (cat: Discord.CategoryChannel) => Promise<void>,
chanCallback: (chan: BridgeableGuildChannel) => Promise<void>,
) {
const bridgedGuilds = await this.app.store.getBridgedGuilds(puppetId);
const bridgedChannels = await this.app.store.getBridgedChannels(puppetId);
const client = guild.client;
const bridgeAll = Boolean(this.app.puppets[puppetId] && this.app.puppets[puppetId].data.bridgeAll);
// first we iterate over the non-sorted channels
for (const chan of guild.channels.cache.array()) {
if (!bridgedGuilds.includes(guild.id) && !bridgedChannels.includes(chan.id) && !bridgeAll) {
continue;
}
if (!chan.parentID && this.isBridgeableGuildChannel(chan) && chan.members.has(client.user!.id)) {
await chanCallback(chan as BridgeableGuildChannel);
}
}
// next we iterate over the categories and all their children
for (const cat of guild.channels.cache.array()) {
if (!(cat instanceof Discord.CategoryChannel)) {
continue;
}
if (cat.members.has(client.user!.id)) {
let doCat = false;
for (const chan of cat.children.array()) {
if (!bridgedGuilds.includes(guild.id) && !bridgedChannels.includes(chan.id) && !bridgeAll) {
continue;
}
if (this.isBridgeableGuildChannel(chan) && chan.members.has(client.user!.id)) {
if (!doCat) {
doCat = true;
await catCallback(cat);
}
await chanCallback(chan as BridgeableGuildChannel);
}
}
}
}
}
public async getUserById(client: Discord.Client, id: string): Promise<Discord.User | null> {
for (const guild of client.guilds.cache.array()) {
const a = guild.members.cache.find((m) => m.user.id === id);
if (a) {
return a.user;
}
}
if (client.user) {
const user = client.user.relationships.friends.get(id);
if (user) {
return user;
}
}
try {
const user = await client.users.fetch(id);
if (user) {
return user;
}
} catch (err) {
// user not found
}
return null;
}
public isTextGuildChannel(chan?: Discord.Channel | null): boolean {
return Boolean(chan && ["text", "news"].includes(chan.type));
}
public isTextChannel(chan?: Discord.Channel | null): boolean {
return Boolean(chan && ["text", "news", "dm", "group"].includes(chan.type));
}
public isBridgeableGuildChannel(chan?: Discord.Channel | null): boolean {
return Boolean(chan && ["text", "news"].includes(chan.type));
}
public isBridgeableChannel(chan?: Discord.Channel | null): boolean {
return Boolean(chan && ["text", "news", "dm", "group"].includes(chan.type));
}
}

View File

@@ -0,0 +1,248 @@
/* tslint:disable: no-any */
/*
Copyright 2019, 2020 mx-puppet-discord
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {
PuppetBridge,
IPuppetBridgeRegOpts,
Log,
IRetData,
IProtocolInformation,
} from "mx-puppet-bridge";
import * as commandLineArgs from "command-line-args";
import * as commandLineUsage from "command-line-usage";
import { App } from "./app";
const log = new Log("DiscordPuppet:index");
const commandOptions = [
{ name: "register", alias: "r", type: Boolean },
{ name: "registration-file", alias: "f", type: String },
{ name: "config", alias: "c", type: String },
{ name: "help", alias: "h", type: Boolean },
];
const options = Object.assign({
"register": false,
"registration-file": "discord-registration.yaml",
"config": "config.yaml",
"help": false,
}, commandLineArgs(commandOptions));
if (options.help) {
// tslint:disable-next-line:no-console
console.log(commandLineUsage([
{
header: "Matrix Discord Puppet Bridge",
content: "A matrix puppet bridge for discord",
},
{
header: "Options",
optionList: commandOptions,
},
]));
process.exit(0);
}
const protocol: IProtocolInformation = {
features: {
file: true,
presence: true,
edit: true,
reply: true,
advancedRelay: true,
globalNamespace: true,
typingTimeout: 10 * 1000, // tslint:disable-line no-magic-numbers
},
id: "discord",
displayname: "Discord",
externalUrl: "https://discordapp.com/",
namePatterns: {
user: ":name",
userOverride: ":displayname",
room: "[:guild?#:name - :guild,:name]",
group: ":name",
},
};
const puppet = new PuppetBridge(options["registration-file"], options.config, protocol);
if (options.register) {
// okay, all we have to do is generate a registration file
puppet.readConfig(false);
try {
puppet.generateRegistration({
prefix: "_discordpuppet_",
id: "discord-puppet",
url: `http://${puppet.Config.bridge.bindAddress}:${puppet.Config.bridge.port}`,
} as IPuppetBridgeRegOpts);
} catch (err) {
// tslint:disable-next-line:no-console
console.log("Couldn't generate registration file:", err);
}
process.exit(0);
}
async function run() {
await puppet.init();
const app = new App(puppet);
await app.init();
puppet.on("puppetNew", app.newPuppet.bind(app));
puppet.on("puppetDelete", app.deletePuppet.bind(app));
puppet.on("message", app.matrix.events.handleMatrixMessage.bind(app.matrix.events));
puppet.on("file", app.matrix.events.handleMatrixFile.bind(app.matrix.events));
puppet.on("redact", app.matrix.events.handleMatrixRedact.bind(app.matrix.events));
puppet.on("edit", app.matrix.events.handleMatrixEdit.bind(app.matrix.events));
puppet.on("reply", app.matrix.events.handleMatrixReply.bind(app.matrix.events));
puppet.on("reaction", app.matrix.events.handleMatrixReaction.bind(app.matrix.events));
puppet.on("removeReaction", app.matrix.events.handleMatrixRemoveReaction.bind(app.matrix.events));
puppet.on("puppetName", app.handlePuppetName.bind(app));
puppet.on("puppetAvatar", app.handlePuppetAvatar.bind(app));
puppet.setGetUserIdsInRoomHook(app.getUserIdsInRoom.bind(app));
puppet.setCreateRoomHook(app.matrix.createRoom.bind(app.matrix));
puppet.setCreateUserHook(app.matrix.createUser.bind(app.matrix));
puppet.setCreateGroupHook(app.matrix.createGroup.bind(app.matrix));
puppet.setGetDmRoomIdHook(app.matrix.getDmRoom.bind(app.matrix));
puppet.setListUsersHook(app.listUsers.bind(app));
puppet.setListRoomsHook(app.matrix.listRooms.bind(app.matrix));
puppet.setGetDescHook(async (puppetId: number, data: any): Promise<string> => {
let s = "Discord";
if (data.username) {
s += ` as \`${data.username}\``;
}
if (data.id) {
s += ` (\`${data.id}\`)`;
}
return s;
});
puppet.setGetDataFromStrHook(async (str: string): Promise<IRetData> => {
const retData = {
success: false,
} as IRetData;
if (!str) {
retData.error = "Please specify a token to link!";
return retData;
}
const parts = str.split(" ");
const PARTS_LENGTH = 2;
if (parts.length !== PARTS_LENGTH) {
retData.error = "Please specify if your token is a user or a bot token! `link <user|bot> token`";
return retData;
}
const type = parts[0].toLowerCase();
if (!["bot", "user"].includes(type)) {
retData.error = "Please specify if your token is a user or a bot token! `link <user|bot> token`";
return retData;
}
retData.success = true;
retData.data = {
token: parts[1].trim(),
bot: type === "bot",
};
return retData;
});
puppet.setBotHeaderMsgHook((): string => {
return "Discord Puppet Bridge";
});
puppet.setResolveRoomIdHook(async (ident: string): Promise<string | null> => {
if (ident.match(/^[0-9]+$/)) {
return ident;
}
const matches = ident.match(/^(?:https?:\/\/)?discordapp\.com\/channels\/[^\/]+\/([0-9]+)/);
if (matches) {
return matches[1];
}
return null;
});
puppet.registerCommand("syncprofile", {
fn: app.commands.commandSyncProfile.bind(app.commands),
help: `Enable/disable the syncing of the matrix profile to the discord one (name and avatar)
Usage: \`syncprofile <puppetId> <1/0>\``,
});
puppet.registerCommand("joinentireguild", {
fn: app.commands.commandJoinEntireGuild.bind(app.commands),
help: `Join all the channels in a guild, if it is bridged
Usage: \`joinentireguild <puppetId> <guildId>\``,
});
puppet.registerCommand("listguilds", {
fn: app.commands.commandListGuilds.bind(app.commands),
help: `List all guilds that can be bridged
Usage: \`listguilds <puppetId>\``,
});
puppet.registerCommand("acceptinvite", {
fn: app.commands.commandAcceptInvite.bind(app.commands),
help: `Accept a discord.gg invite
Usage: \`acceptinvite <puppetId> <inviteLink>\``,
});
puppet.registerCommand("bridgeguild", {
fn: app.commands.commandBridgeGuild.bind(app.commands),
help: `Bridge a guild
Usage: \`bridgeguild <puppetId> <guildId>\``,
});
puppet.registerCommand("unbridgeguild", {
fn: app.commands.commandUnbridgeGuild.bind(app.commands),
help: `Unbridge a guild
Usage: \`unbridgeguild <puppetId> <guildId>\``,
});
puppet.registerCommand("bridgechannel", {
fn: app.commands.commandBridgeChannel.bind(app.commands),
help: `Bridge a channel
Usage: \`bridgechannel <puppetId> <channelId>\``,
});
puppet.registerCommand("unbridgechannel", {
fn: app.commands.commandUnbridgeChannel.bind(app.commands),
help: `Unbridge a channel
Usage: \`unbridgechannel <puppetId> <channelId>\``,
});
puppet.registerCommand("bridgeall", {
fn: app.commands.commandBridgeAll.bind(app.commands),
help: `Bridge everything
Usage: \`bridgeall <puppetId> <1/0>\``,
});
puppet.registerCommand("enablefriendsmanagement", {
fn: app.commands.commandEnableFriendsManagement.bind(app.commands),
help: `Enables friends management on the discord account
Usage: \`enablefriendsmanagement <puppetId>\``,
});
puppet.registerCommand("listfriends", {
fn: app.commands.commandListFriends.bind(app.commands),
help: `List all your current friends
Usage: \`listfriends <puppetId>\``,
});
puppet.registerCommand("addfriend", {
fn: app.commands.commandAddFriend.bind(app.commands),
help: `Add a new friend
Usage: \`addfriend <puppetId> <friend>\`, friend can be either the full username or the user ID`,
});
puppet.registerCommand("removefriend", {
fn: app.commands.commandRemoveFriend.bind(app.commands),
help: `Remove a friend
Usage: \`removefriend <puppetId> <friend>\`, friend can be either the full username or the user ID`,
});
await puppet.start();
}
// tslint:disable-next-line:no-floating-promises
run(); // start the thing!

View File

@@ -0,0 +1,301 @@
/* tslint:disable: no-any */
/*
Copyright 2019, 2020 mx-puppet-discord
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { IRemoteRoom, IMessageEvent, ISendingUser, IFileEvent, Util, Log } from "mx-puppet-bridge";
import { App, IDiscordSendFile, MAXFILESIZE, AVATAR_SETTINGS } from "../app";
import * as Discord from "better-discord.js";
import { TextEncoder, TextDecoder } from "util";
const log = new Log("DiscordPuppet:MatrixEventHandler");
export class MatrixEventHandler {
public constructor(private readonly app: App) {}
public async handleMatrixMessage(room: IRemoteRoom, data: IMessageEvent, asUser: ISendingUser | null, event: any) {
const p = this.app.puppets[room.puppetId];
if (!p) {
return;
}
const chan = await this.app.discord.getDiscordChan(room);
if (!chan) {
log.warn("Channel not found", room);
return;
}
if (asUser) {
const MAX_NAME_LENGTH = 80;
const displayname = (new TextEncoder().encode(asUser.displayname));
asUser.displayname = (new TextDecoder().decode(displayname.slice(0, MAX_NAME_LENGTH)));
}
const sendMsg = await this.app.matrix.parseMatrixMessage(room.puppetId, event.content);
const lockKey = `${room.puppetId};${chan.id}`;
this.app.messageDeduplicator.lock(lockKey, p.client.user!.id, sendMsg);
try {
const reply = await this.app.discord.sendToDiscord(chan, sendMsg, asUser);
await this.app.matrix.insertNewEventId(room, data.eventId!, reply);
} catch (err) {
log.warn("Couldn't send message", err);
this.app.messageDeduplicator.unlock(lockKey);
await this.app.matrix.sendMessageFail(room);
}
}
public async handleMatrixFile(room: IRemoteRoom, data: IFileEvent, asUser: ISendingUser | null, event: any) {
const p = this.app.puppets[room.puppetId];
if (!p) {
return;
}
const chan = await this.app.discord.getDiscordChan(room);
if (!chan) {
log.warn("Channel not found", room);
return;
}
let size = data.info ? data.info.size || 0 : 0;
const mimetype = data.info ? data.info.mimetype || "" : "";
const lockKey = `${room.puppetId};${chan.id}`;
const isImage = Boolean(mimetype && mimetype.split("/")[0] === "image");
if (size < MAXFILESIZE) {
const buffer = await Util.DownloadFile(data.url);
size = buffer.byteLength;
if (size < MAXFILESIZE) {
// send as attachment
const filename = this.app.getFilenameForMedia(data.filename, mimetype);
this.app.messageDeduplicator.lock(lockKey, p.client.user!.id, `file:${filename}`);
try {
const sendFile: IDiscordSendFile = {
buffer,
filename,
url: data.url,
isImage,
};
const reply = await this.app.discord.sendToDiscord(chan, sendFile, asUser);
await this.app.matrix.insertNewEventId(room, data.eventId!, reply);
return;
} catch (err) {
this.app.messageDeduplicator.unlock(lockKey);
log.warn("Couldn't send media message, retrying as embed/url", err);
}
}
}
try {
const filename = await this.app.discord.discordEscape(data.filename);
const msg = `Uploaded a file \`${filename}\`: ${data.url}`;
this.app.messageDeduplicator.lock(lockKey, p.client.user!.id, msg);
const reply = await this.app.discord.sendToDiscord(chan, msg, asUser);
await this.app.matrix.insertNewEventId(room, data.eventId!, reply);
} catch (err) {
log.warn("Couldn't send media message", err);
this.app.messageDeduplicator.unlock(lockKey);
await this.app.matrix.sendMessageFail(room);
}
}
public async handleMatrixRedact(room: IRemoteRoom, eventId: string, asUser: ISendingUser | null, event: any) {
const p = this.app.puppets[room.puppetId];
if (!p) {
return;
}
const chan = await this.app.discord.getDiscordChan(room);
if (!chan) {
log.warn("Channel not found", room);
return;
}
log.verbose(`Deleting message with ID ${eventId}...`);
const msg = await chan.messages.fetch(eventId);
if (!msg) {
return;
}
try {
p.deletedMessages.add(msg.id);
await msg.delete();
await this.app.puppet.eventSync.remove(room, msg.id);
} catch (err) {
log.warn("Couldn't delete message", err);
}
}
public async handleMatrixEdit(
room: IRemoteRoom,
eventId: string,
data: IMessageEvent,
asUser: ISendingUser | null,
event: any,
) {
const p = this.app.puppets[room.puppetId];
if (!p) {
return;
}
const chan = await this.app.discord.getDiscordChan(room);
if (!chan) {
log.warn("Channel not found", room);
return;
}
log.verbose(`Editing message with ID ${eventId}...`);
const msg = await chan.messages.fetch(eventId);
if (!msg) {
return;
}
let sendMsg = await this.app.matrix.parseMatrixMessage(room.puppetId, event.content["m.new_content"]);
// prepend a quote, if needed
if (msg.content.startsWith("> <")) {
const matches = msg.content.match(/^((> [^\n]+\n)+)/);
if (matches) {
sendMsg = `${matches[1]}${sendMsg}`;
}
}
const lockKey = `${room.puppetId};${chan.id}`;
this.app.messageDeduplicator.lock(lockKey, p.client.user!.id, sendMsg);
try {
let reply: Discord.Message | Discord.Message[];
let matrixEventId = data.eventId!;
if (asUser) {
// just re-send as new message
if (eventId === this.app.lastEventIds[chan.id]) {
try {
p.deletedMessages.add(msg.id);
const matrixEvents = await this.app.puppet.eventSync.getMatrix(room, msg.id);
if (matrixEvents.length > 0) {
matrixEventId = matrixEvents[0];
}
await msg.delete();
await this.app.puppet.eventSync.remove(room, msg.id);
} catch (err) {
log.warn("Couldn't delete old message", err);
}
} else {
sendMsg = `**EDIT:** ${sendMsg}`;
}
reply = await this.app.discord.sendToDiscord(chan, sendMsg, asUser);
} else {
reply = await msg.edit(sendMsg);
}
await this.app.matrix.insertNewEventId(room, matrixEventId, reply);
} catch (err) {
log.warn("Couldn't edit message", err);
this.app.messageDeduplicator.unlock(lockKey);
await this.app.matrix.sendMessageFail(room);
}
}
public async handleMatrixReply(
room: IRemoteRoom,
eventId: string,
data: IMessageEvent,
asUser: ISendingUser | null,
event: any,
) {
const p = this.app.puppets[room.puppetId];
if (!p) {
return;
}
const chan = await this.app.discord.getDiscordChan(room);
if (!chan) {
log.warn("Channel not found", room);
return;
}
log.verbose(`Replying to message with ID ${eventId}...`);
const msg = await chan.messages.fetch(eventId);
if (!msg) {
return;
}
let sendMsg = await this.app.matrix.parseMatrixMessage(room.puppetId, event.content);
let content = msg.content;
if (!content && msg.embeds.length > 0 && msg.embeds[0].description) {
content = msg.embeds[0].description;
}
const quoteParts = content.split("\n");
quoteParts[0] = `<@${msg.author.id}>: ${quoteParts[0]}`;
const quote = quoteParts.map((s) => `> ${s}`).join("\n");
sendMsg = `${quote}\n${sendMsg}`;
const lockKey = `${room.puppetId};${chan.id}`;
try {
this.app.messageDeduplicator.lock(lockKey, p.client.user!.id, sendMsg);
const reply = await this.app.discord.sendToDiscord(chan, sendMsg, asUser);
await this.app.matrix.insertNewEventId(room, data.eventId!, reply);
} catch (err) {
log.warn("Couldn't send reply", err);
this.app.messageDeduplicator.unlock(lockKey);
await this.app.matrix.sendMessageFail(room);
}
}
public async handleMatrixReaction(
room: IRemoteRoom,
eventId: string,
reaction: string,
asUser: ISendingUser | null,
event: any,
) {
const p = this.app.puppets[room.puppetId];
if (!p || asUser) {
return;
}
const chan = await this.app.discord.getDiscordChan(room);
if (!chan) {
log.warn("Channel not found", room);
return;
}
log.verbose(`Reacting to ${eventId} with ${reaction}...`);
const msg = await chan.messages.fetch(eventId);
if (!msg) {
return;
}
if (reaction.startsWith("mxc://")) {
const emoji = await this.app.discord.getDiscordEmoji(room.puppetId, reaction);
if (emoji) {
await msg.react(emoji);
}
} else {
await msg.react(reaction);
}
}
public async handleMatrixRemoveReaction(
room: IRemoteRoom,
eventId: string,
reaction: string,
asUser: ISendingUser | null,
event: any,
) {
const p = this.app.puppets[room.puppetId];
if (!p || asUser) {
return;
}
const chan = await this.app.discord.getDiscordChan(room);
if (!chan) {
log.warn("Channel not found", room);
return;
}
log.verbose(`Removing reaction to ${eventId} with ${reaction}...`);
const msg = await chan.messages.fetch(eventId);
if (!msg) {
return;
}
let emoji: Discord.Emoji | null = null;
if (reaction.startsWith("mxc://")) {
emoji = await this.app.discord.getDiscordEmoji(room.puppetId, reaction);
}
for (const r of msg.reactions.cache.array()) {
if (r.emoji.name === reaction) {
await r.remove();
break;
}
if (emoji && emoji.id === r.emoji.id) {
await r.remove();
break;
}
}
}
}

View File

@@ -0,0 +1,404 @@
/* tslint:disable: no-any */
/*
Copyright 2019, 2020 mx-puppet-discord
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { App, AVATAR_SETTINGS } from "../app";
import * as Discord from "better-discord.js";
import { IStringFormatterVars, IRemoteUser, IRemoteRoom,
IRemoteGroup, IRemoteUserRoomOverride, IReceiveParams, IRetList, Log,
} from "mx-puppet-bridge";
import * as escapeHtml from "escape-html";
import { IMatrixMessageParserOpts } from "matrix-discord-parser";
import { MatrixEventHandler } from "./MatrixEventHandler";
import { TextGuildChannel, TextChannel, BridgeableGuildChannel, BridgeableChannel } from "../discord/DiscordUtil";
const log = new Log("DiscordPuppet:MatrixUtil");
export class MatrixUtil {
public readonly events: MatrixEventHandler;
public constructor(private readonly app: App) {
this.events = new MatrixEventHandler(app);
}
public async getDmRoom(user: IRemoteUser): Promise<string | null> {
const p = this.app.puppets[user.puppetId];
if (!p) {
return null;
}
const u = await this.app.discord.getUserById(p.client, user.userId);
if (!u) {
return null;
}
return `dm-${user.puppetId}-${u.id}`;
}
public async getEmojiMxc(puppetId: number, name: string, animated: boolean, id: string): Promise<string | null> {
const emoji = await this.app.puppet.emoteSync.get({
puppetId,
emoteId: id,
});
if (emoji && emoji.avatarMxc) {
return emoji.avatarMxc;
}
const { emote } = await this.app.puppet.emoteSync.set({
puppetId,
emoteId: id,
avatarUrl: `https://cdn.discordapp.com/emojis/${id}${animated ? ".gif" : ".png"}`,
name,
data: {
animated,
name,
},
});
return emote.avatarMxc || null;
}
public getSendParams(
puppetId: number,
msgOrChannel: Discord.Message | BridgeableChannel,
user?: Discord.User | Discord.GuildMember,
): IReceiveParams {
let channel: BridgeableChannel;
let eventId: string | undefined;
let externalUrl: string | undefined;
let isWebhook = false;
let guildChannel: BridgeableGuildChannel | undefined;
if (!user) {
const msg = msgOrChannel as Discord.Message;
channel = msg.channel;
user = msg.member || msg.author;
eventId = msg.id;
isWebhook = msg.webhookID ? true : false;
if (this.app.discord.isBridgeableGuildChannel(channel)) {
guildChannel = channel as BridgeableGuildChannel;
externalUrl = `https://discordapp.com/channels/${guildChannel.guild.id}/${guildChannel.id}/${eventId}`;
} else if (["group", "dm"].includes(channel.type)) {
externalUrl = `https://discordapp.com/channels/@me/${channel.id}/${eventId}`;
}
} else {
channel = msgOrChannel as BridgeableChannel;
}
return {
room: this.getRemoteRoom(puppetId, channel),
user: this.getRemoteUser(puppetId, user, isWebhook, guildChannel),
eventId,
externalUrl,
};
}
public getRemoteUserRoomOverride(member: Discord.GuildMember, chan: BridgeableGuildChannel): IRemoteUserRoomOverride {
const nameVars: IStringFormatterVars = {
name: member.user.username,
discriminator: member.user.discriminator,
displayname: member.displayName,
channel: chan.name,
guild: chan.guild.name,
};
return {
nameVars,
};
}
public getRemoteUser(
puppetId: number,
userOrMember: Discord.User | Discord.GuildMember,
isWebhook: boolean = false,
chan?: BridgeableGuildChannel,
): IRemoteUser {
let user: Discord.User;
let member: Discord.GuildMember | null = null;
if (userOrMember instanceof Discord.GuildMember) {
member = userOrMember;
user = member.user;
} else {
user = userOrMember;
}
const nameVars: IStringFormatterVars = {
name: user.username,
discriminator: user.discriminator,
};
const response: IRemoteUser = {
userId: isWebhook ? `webhook-${user.id}-${user.username}` : user.id,
puppetId,
avatarUrl: user.avatarURL(AVATAR_SETTINGS),
nameVars,
};
if (member) {
response.roomOverrides = {};
if (chan) {
response.roomOverrides[chan.id] = this.getRemoteUserRoomOverride(member, chan);
} else {
for (const gchan of member.guild.channels.cache.array()) {
if (this.app.discord.isBridgeableGuildChannel(gchan)) {
response.roomOverrides[gchan.id] = this.getRemoteUserRoomOverride(member, gchan as BridgeableGuildChannel);
}
}
}
}
return response;
}
public getRemoteRoom(puppetId: number, channel: BridgeableChannel): IRemoteRoom {
let roomId = channel.id;
if (channel instanceof Discord.DMChannel) {
roomId = `dm-${puppetId}-${channel.recipient.id}`;
}
const ret: IRemoteRoom = {
roomId,
puppetId,
isDirect: channel.type === "dm",
};
if (channel instanceof Discord.GroupDMChannel) {
ret.nameVars = {
name: channel.name,
};
ret.avatarUrl = channel.iconURL(AVATAR_SETTINGS);
}
if (this.app.discord.isBridgeableGuildChannel(channel)) {
const gchan = channel as BridgeableGuildChannel;
ret.nameVars = {
name: gchan.name,
guild: gchan.guild.name,
};
ret.avatarUrl = gchan.guild.iconURL(AVATAR_SETTINGS);
ret.groupId = gchan.guild.id;
ret.topic = gchan.topic;
ret.emotes = gchan.guild.emojis.cache.map((e) => {
return {
emoteId: e.id,
name: e.name,
avatarUrl: e.url,
data: {
animated: e.animated,
name: e.name,
},
};
});
}
return ret;
}
public async getRemoteRoomById(room: IRemoteRoom): Promise<IRemoteRoom | null> {
const chan = await this.app.discord.getDiscordChan(room);
if (!chan) {
return null;
}
if (!await this.app.bridgeRoom(room.puppetId, chan)) {
return null;
}
return this.getRemoteRoom(room.puppetId, chan);
}
public async getRemoteGroup(puppetId: number, guild: Discord.Guild): Promise<IRemoteGroup> {
const roomIds: string[] = [];
let description = `<h1>${escapeHtml(guild.name)}</h1>`;
description += `<h2>Channels:</h2><ul>`;
await this.app.discord.iterateGuildStructure(puppetId, guild,
async (cat: Discord.CategoryChannel) => {
const name = escapeHtml(cat.name);
description += `</ul><h3>${name}</h3><ul>`;
},
async (chan: BridgeableGuildChannel) => {
roomIds.push(chan.id);
const mxid = await this.app.puppet.getMxidForRoom({
puppetId,
roomId: chan.id,
});
const url = "https://matrix.to/#/" + mxid;
const name = escapeHtml(chan.name);
description += `<li>${name}: <a href="${url}">${name}</a></li>`;
},
);
description += "</ul>";
return {
puppetId,
groupId: guild.id,
nameVars: {
name: guild.name,
},
avatarUrl: guild.iconURL(AVATAR_SETTINGS),
roomIds,
longDescription: description,
};
}
public async insertNewEventId(room: IRemoteRoom, matrixId: string, msgs: Discord.Message | Discord.Message[]) {
const p = this.app.puppets[room.puppetId];
if (!Array.isArray(msgs)) {
msgs = [msgs];
}
for (const m of msgs) {
const lockKey = `${room.puppetId};${m.channel.id}`;
await this.app.puppet.eventSync.insert(room, matrixId, m.id);
this.app.messageDeduplicator.unlock(lockKey, p.client.user!.id, m.id);
this.app.lastEventIds[m.channel.id] = m.id;
}
}
public async createRoom(chan: IRemoteRoom): Promise<IRemoteRoom | null> {
return await this.getRemoteRoomById(chan);
}
public async createUser(user: IRemoteUser): Promise<IRemoteUser | null> {
const p = this.app.puppets[user.puppetId];
if (!p) {
return null;
}
if (user.userId.startsWith("webhook-")) {
return null;
}
const u = await this.app.discord.getUserById(p.client, user.userId);
if (!u) {
return null;
}
const remoteUser = this.getRemoteUser(user.puppetId, u);
remoteUser.roomOverrides = {};
for (const guild of p.client.guilds.cache.array()) {
const member = guild.members.resolve(u.id);
if (member) {
for (const chan of guild.channels.cache.array()) {
if (this.app.discord.isBridgeableGuildChannel(chan)) {
remoteUser.roomOverrides[chan.id] = this.getRemoteUserRoomOverride(member, chan as BridgeableGuildChannel);
}
}
}
}
return remoteUser;
}
public async createGroup(group: IRemoteGroup): Promise<IRemoteGroup | null> {
const p = this.app.puppets[group.puppetId];
if (!p) {
return null;
}
const guild = p.client.guilds.resolve(group.groupId);
if (!guild) {
return null;
}
return await this.getRemoteGroup(group.puppetId, guild);
}
public async listRooms(puppetId: number): Promise<IRetList[]> {
const retGroups: IRetList[] = [];
const retGuilds: IRetList[] = [];
const p = this.app.puppets[puppetId];
if (!p) {
return [];
}
for (const guild of p.client.guilds.cache.array()) {
let didGuild = false;
let didCat = false;
await this.app.discord.iterateGuildStructure(puppetId, guild,
async (cat: Discord.CategoryChannel) => {
didCat = true;
retGuilds.push({
category: true,
name: `${guild.name} - ${cat.name}`,
});
},
async (chan: BridgeableGuildChannel) => {
if (!didGuild && !didCat) {
didGuild = true;
retGuilds.push({
category: true,
name: guild.name,
});
}
retGuilds.push({
name: chan.name,
id: chan.id,
});
},
);
}
for (const chan of p.client.channels.cache.array()) {
if (chan instanceof Discord.GroupDMChannel) {
const found = retGuilds.find((element) => element.id === chan.id);
if (!found) {
retGroups.push({
name: chan.name || "",
id: chan.id,
});
}
}
}
return retGroups.concat(retGuilds);
}
public async parseMatrixMessage(puppetId: number, eventContent: any): Promise<string> {
const opts: IMatrixMessageParserOpts = {
displayname: "", // something too short
callbacks: {
canNotifyRoom: async () => true,
getUserId: async (mxid: string) => {
const parts = this.app.puppet.userSync.getPartsFromMxid(mxid);
if (!parts || (parts.puppetId !== puppetId && parts.puppetId !== -1)) {
return null;
}
return parts.userId;
},
getChannelId: async (mxid: string) => {
const parts = await this.app.puppet.roomSync.getPartsFromMxid(mxid);
if (!parts || (parts.puppetId !== puppetId && parts.puppetId !== -1)) {
return null;
}
return parts.roomId;
},
getEmoji: async (mxc: string, name: string) => {
const emote = await this.app.puppet.emoteSync.getByMxc(puppetId, mxc);
log.info("Found emoji", emote);
if (!emote) {
return null;
}
return {
animated: Boolean(emote.data && emote.data.animated),
name: ((emote.data && emote.data.name) || emote.name) as string,
id: emote.emoteId,
};
},
mxcUrlToHttp: (mxc: string) => this.app.puppet.getUrlFromMxc(mxc),
},
determineCodeLanguage: true,
};
const msg = await this.app.matrixMsgParser.FormatMessage(opts, eventContent);
return msg;
}
public async sendMessageFail(room: IRemoteRoom) {
const chan = await this.app.discord.getDiscordChan(room);
if (!chan) {
return;
}
let msg = "";
if (chan instanceof Discord.DMChannel) {
msg = `Failed to send message to DM with user ${chan.recipient.username}`;
} else if (chan instanceof Discord.GroupDMChannel) {
let name = chan.name;
if (!name) {
const names: string[] = [];
for (const user of chan.recipients.array()) {
names.push(user.username);
}
name = names.join(", ");
}
msg = `Failed to send message into Group DM ${name}`;
} else if (this.app.discord.isBridgeableGuildChannel(chan)) {
const gchan = chan as BridgeableGuildChannel;
msg = `Failed to send message into channel ${gchan.name} of guild ${gchan.guild.name}`;
} else {
msg = `Failed to send message into channel with id \`${chan.id}\``;
}
await this.app.puppet.sendStatusMessage(room, msg);
}
}

View File

@@ -0,0 +1,108 @@
/*
Copyright 2019, 2020 mx-puppet-discord
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Store } from "mx-puppet-bridge";
const CURRENT_SCHEMA = 4;
export class IDbEmoji {
public emojiId: string;
public name: string;
public animated: boolean;
public mxcUrl: string;
}
export class DiscordStore {
constructor(
private store: Store,
) { }
public async init(): Promise<void> {
await this.store.init(CURRENT_SCHEMA, "discord_schema", (version: number) => {
return require(`./db/schema/v${version}.js`).Schema;
}, false);
}
public async getBridgedGuilds(puppetId: number): Promise<string[]> {
const rows = await this.store.db.All("SELECT guild_id FROM discord_bridged_guilds WHERE puppet_id=$puppetId", {
puppetId,
});
const result: string[] = [];
for (const row of rows) {
result.push(row.guild_id as string);
}
return result;
}
public async isGuildBridged(puppetId: number, guildId: string): Promise<boolean> {
const exists = await this.store.db.Get("SELECT 1 FROM discord_bridged_guilds WHERE puppet_id=$p AND guild_id=$g", {
p: puppetId,
g: guildId,
});
return exists ? true : false;
}
public async setBridgedGuild(puppetId: number, guildId: string): Promise<void> {
if (await this.isGuildBridged(puppetId, guildId)) {
return;
}
await this.store.db.Run("INSERT INTO discord_bridged_guilds (puppet_id, guild_id) VALUES ($p, $g)", {
p: puppetId,
g: guildId,
});
}
public async removeBridgedGuild(puppetId: number, guildId: string): Promise<void> {
await this.store.db.Run("DELETE FROM discord_bridged_guilds WHERE puppet_id=$p AND guild_id=$g", {
p: puppetId,
g: guildId,
});
}
public async getBridgedChannels(puppetId: number): Promise<string[]> {
const rows = await this.store.db.All("SELECT channel_id FROM discord_bridged_channels WHERE puppet_id=$puppetId", {
puppetId,
});
const result: string[] = [];
for (const row of rows) {
result.push(row.channel_id as string);
}
return result;
}
public async isChannelBridged(puppetId: number, channelId: string): Promise<boolean> {
const exists = await this.store.db.Get("SELECT 1 FROM discord_bridged_channels" +
" WHERE puppet_id=$p AND channel_id=$c", {
p: puppetId,
c: channelId,
});
return exists ? true : false;
}
public async setBridgedChannel(puppetId: number, channelId: string): Promise<void> {
if (await this.isChannelBridged(puppetId, channelId)) {
return;
}
await this.store.db.Run("INSERT INTO discord_bridged_channels (puppet_id, channel_id) VALUES ($p, $c)", {
p: puppetId,
c: channelId,
});
}
public async removeBridgedChannel(puppetId: number, channelId: string): Promise<void> {
await this.store.db.Run("DELETE FROM discord_bridged_channels WHERE puppet_id=$p AND channel_id=$c", {
p: puppetId,
c: channelId,
});
}
}

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"target": "es2016",
"noImplicitAny": false,
"inlineSourceMap": true,
"outDir": "./build",
"types": ["node"],
"strictNullChecks": true,
"allowSyntheticDefaultImports": true
},
"compileOnSave": true,
"include": [
"src/**/*",
]
}

View File

@@ -0,0 +1,42 @@
{
"extends": "tslint:recommended",
"rules": {
"ordered-imports": false,
"no-trailing-whitespace": "error",
"max-classes-per-file": {
"severity": "warning"
},
"object-literal-sort-keys": "off",
"no-any":{
"severity": "warning"
},
"arrow-return-shorthand": true,
"no-magic-numbers": [true, -1, 0, 1, 1000],
"prefer-for-of": true,
"typedef": {
"severity": "warning"
},
"await-promise": [true],
"curly": true,
"no-empty": false,
"no-invalid-this": true,
"no-string-throw": {
"severity": "warning"
},
"no-unused-expression": true,
"prefer-const": true,
"object-literal-sort-keys": false,
"indent": [true, "tabs", 1],
"max-file-line-count": {
"severity": "warning",
"options": [500]
},
"no-duplicate-imports": true,
"array-type": [true, "array"],
"promise-function-async": true,
"no-bitwise": true,
"no-debugger": true,
"no-floating-promises": true,
"prefer-template": [true, "allow-single-concat"]
}
}