xmpp-discord-bridge/xmpp_discord_bridge/main.py

551 lines
22 KiB
Python
Raw Permalink Normal View History

2021-09-15 20:21:56 +00:00
import logging
import os
import sys
import asyncio
import signal
import base64
import hashlib
import hmac
import urllib.parse
from optparse import OptionParser
import toml
import slixmpp
from slixmpp import Message
from slixmpp.componentxmpp import ComponentXMPP
from slixmpp.exceptions import XMPPError, IqError
2021-09-15 20:33:59 +00:00
from slixmpp.xmlstream import register_stanza_plugin
2021-09-15 20:21:56 +00:00
from slixmpp.jid import JID
from nextcord import Status, Embed
2021-09-15 20:21:56 +00:00
import requests
2021-09-15 20:33:59 +00:00
from xmpp_discord_bridge.slixmpp.oob import OOBData
from xmpp_discord_bridge.avatar import AvatarManager
from xmpp_discord_bridge.discord import DiscordClient
from xmpp_discord_bridge.helpers import discord_status_to_xmpp_show
2021-09-15 20:21:56 +00:00
2021-09-19 11:01:14 +00:00
# TODO: Move into separate file
# TODO: Add an option that allows a real user to join the MUC to replace a virtual one
2021-09-15 20:21:56 +00:00
class BridgeComponent(ComponentXMPP):
def __init__(self, jid, secret, server, port, token, config):
ComponentXMPP.__init__(self, jid, secret, server, port)
self._config = config
self._logger = logging.getLogger("xmpp.bridge")
self._domain = jid
self._bot_nick = "Bot"
self._bot_jid_bare = JID("bot@" + jid)
self._bot_jid_full = JID("bot@" + jid + "/" + self._bot_nick)
self._token = token
self._discord = None
self._avatars = AvatarManager(self, config)
# State tracking
self._virtual_muc_users = {} # MUC -> [Resources]
self._virtual_muc_nicks = {} # MUC -> User ID -> Nick
self._real_muc_users = {} # MUC -> [Resources]
self._guild_map = {} # Guild ID -> Channel ID -> MUC
self._muc_map = {} # MUC -> (Guild ID, Channel ID)
self._mucs = [] # List of known MUCs
self._webhooks = {} # MUC -> Webhook URL
2021-09-15 20:33:59 +00:00
# Settings
self._proxy_url_template = self._config["general"].get("proxy_discord_urls_to", "")
self._proxy_hmac_secret = self._config["general"].get("hmac_secret", "")
self._relay_xmpp_avatars = self._config["general"].get("relay_xmpp_avatars", False)
self._dont_ignore_offline = self._config["general"].get("dont_ignore_offline", True)
self._reactions_compat = self._config["general"].get("reactions_compat", True)
self._muc_mention_compat = self._config["general"].get("muc_mention_compat", True)
2021-09-19 11:10:27 +00:00
self._remove_url_on_embed = self._config["general"].get("remove_url_on_embed", False)
2021-09-15 20:33:59 +00:00
2021-09-15 20:21:56 +00:00
register_stanza_plugin(Message, OOBData)
self.add_event_handler("session_start", self.on_session_start)
self.add_event_handler("groupchat_message", self.on_groupchat_message)
self.add_event_handler("groupchat_presence", self.on_groupchat_presence)
async def send_oob_data(self, url, muc, member):
"""
Send a message using XEP-0066 OOB data
"""
2021-09-15 20:33:59 +00:00
if self._proxy_url_template:
hmac_str = urllib.parse.quote(base64.b64encode(hmac.digest(self._proxy_hmac_secret.encode(),
2021-09-15 20:21:56 +00:00
url.encode(),
"sha256")), safe="")
2021-09-15 20:33:59 +00:00
proxy = self._proxy_url_template.replace("<hmac>", hmac_str).replace("<url>", urllib.parse.quote(url, safe=""))
2021-09-15 20:21:56 +00:00
url = proxy
self._logger.debug("OOB URL: %s", url)
msg = self.make_message(muc,
mbody=url,
mtype="groupchat",
mfrom=self.spoof_member_jid(member.id))
msg["oob"]["url"] = url
msg.send()
def spoof_member_jid(self, id_):
"""
Return a full JID that we use for the puppets
"""
return JID(str(id_) + "@" + self._domain + "/discord")
async def on_shutdown_signal(self):
"""
Receiving SIGINT or SIGTERM means we need to pack up and shut down
"""
2021-09-15 20:21:56 +00:00
await self._discord.close()
# Remove all virtual users
# NOTE: We cannot use leave_muc as this would also remove the MUC
# from slixmpp's internal tracking, which would probably break
# later leaves
for muc in self._mucs:
for uid in self._virtual_muc_nicks[muc]:
nick = self._virtual_muc_nicks[muc][uid]
self.send_presence(pshow='unavailable',
pto="%s/%s" % (muc, nick),
pfrom=self.spoof_member_jid(uid))
# Remove the Bot user
self.send_presence(pshow='unavailable',
pto="%s/%s" % (muc, "Bot"),
pfrom=self._bot_jid_full)
# Disconnect and close
await self.disconnect()
async def on_discord_ready(self):
asyncio.get_event_loop().add_signal_handler(signal.SIGINT,
lambda: asyncio.create_task(self.on_shutdown_signal()))
asyncio.get_event_loop().add_signal_handler(signal.SIGTERM,
lambda: asyncio.create_task(self.on_shutdown_signal()))
2021-09-15 20:21:56 +00:00
for ch in self._config["discord"]["channels"]:
muc = ch["muc"]
channel = ch["channel"]
guild = ch["guild"]
dchannel = self._discord.get_channel(channel)
2021-09-15 20:21:56 +00:00
# Initialise state tracking
self._muc_map[muc] = (guild, channel)
self._virtual_muc_users[muc] = []
self._virtual_muc_nicks[muc] = {}
for member in dchannel.members:
2021-09-16 10:57:36 +00:00
if member.status == Status.offline and not self._dont_ignore_offline:
2021-09-15 20:21:56 +00:00
continue
self._virtual_muc_users[muc].append(member.display_name)
self._virtual_muc_nicks[muc][member.id] = member.display_name
self._real_muc_users[muc] = []
self._mucs.append(muc)
if not guild in self._guild_map:
self._guild_map[guild] = {
channel: muc
}
else:
self._guild_map[guild][channel] = muc
self._logger.debug("Joining %s", muc)
self.plugin["xep_0045"].join_muc(muc,
nick=self._bot_nick,
pfrom=self._bot_jid_full)
# Set the subject
room_config = await self.plugin["xep_0045"].get_room_config(muc,
ifrom=self._bot_jid_full)
room_config_fields = room_config.get_values()
should_update = False
description = room_config_fields.get("muc#roomconfig_roomdesc", None)
name = room_config_fields.get("muc#roomconfig_roomname", None)
room_config.reply()
if not description or description != (dchannel.topic or ""):
should_update = True
room_config.set_values({
"muc#roomconfig_roomdesc": dchannel.topic or ""
})
self.plugin["xep_0045"].set_subject(muc,
dchannel.topic or "",
mfrom=self._bot_jid_full)
if not name or name != dchannel.name:
should_update = True
room_config.set_values({
"muc#roomconfig_roomname": dchannel.name
})
# NOTE: To prevent messages in clients that the roomconfig changed at every restart,
# check first if we really have to change stuff
if should_update:
await self.plugin["xep_0045"].set_room_config(muc,
room_config,
ifrom=self._bot_jid_full)
2021-09-15 20:21:56 +00:00
# TODO: Is this working?
# Mirror the guild's icon
icon_url = dchannel.guild.icon.url
if icon_url:
req = requests.get(icon_url)
vcard = self.plugin["xep_0054"].make_vcard()
vcard["PHOTO"]["TYPE"] = "image/png"
vcard["PHOTO"]["BINVAL"] = base64.b64encode(req.content)
# TODO: Replace with provided API
self.send_raw("""
2021-09-15 20:21:56 +00:00
<iq type="set" from="{}" to="{}">
<vCard xmlns="vcard-temp">
{}
</vCard>
</iq>
""".format(self._bot_jid_full,
muc,
str(vcard)))
2021-09-15 20:21:56 +00:00
# Aquire a webhook
2021-09-16 16:34:17 +00:00
wh = None
2021-09-15 20:21:56 +00:00
for webhook in await dchannel.webhooks():
if webhook.name == "discord-xmpp-bridge":
if not webhook.is_authenticated():
_logger.info("Webhook for %s has no token. Deleting and recreating" % muc)
await webhook.delete(reason="Webhook has no token. Will recreate")
else:
wh = webhook
2021-09-15 20:21:56 +00:00
break
2021-09-16 16:29:55 +00:00
if not wh:
wh = await dchannel.create_webhook(name="discord-xmpp-bridge",
reason="Bridging Discord and XMPP")
self._webhooks[muc] = wh
2021-09-15 20:21:56 +00:00
# Make sure our virtual users can join
affiliation_list = await self.plugin["xep_0045"].get_affiliation_list(muc,
"member",
ifrom=self._bot_jid_full)
2021-09-15 20:21:56 +00:00
for member in dchannel.members:
bare_member_jid = self.spoof_member_jid(member.id).bare
if not bare_member_jid in affiliation_list:
await self.plugin["xep_0045"].set_affiliation(muc,
"member",
jid=bare_member_jid,
ifrom=self._bot_jid_full)
2021-09-15 20:21:56 +00:00
self.virtual_user_join_muc(muc, member, update_state_tracking=False)
self._logger.info("%s is ready", muc)
self._logger.info("Bridge is ready")
async def on_groupchat_message(self, message):
muc = message["from"].bare
if not message["body"]:
return
if not message["from"].resource in self._real_muc_users[muc]:
return
# Prevent the message being reflected back into Discord
if not message["to"] == self._bot_jid_full:
return
2021-09-16 16:29:55 +00:00
content = message["body"]
embed = None
2021-09-15 20:21:56 +00:00
# Look for mentions and replace them
guild, channel = self._muc_map[muc]
for member in self._discord.get_guild(guild).get_channel(channel).members:
self._logger.debug("Checking %s", member.display_name)
2021-09-16 16:29:55 +00:00
if "@" + member.display_name in content:
2021-09-15 20:21:56 +00:00
self._logger.debug("Found mention for %s. Replaceing.",
member.display_name)
2021-09-16 16:29:55 +00:00
content = content.replace("@" + member.display_name,
member.mention)
2021-09-15 20:21:56 +00:00
if message["oob"]["url"] and message["body"] == message["oob"]["url"]:
2021-09-19 11:01:14 +00:00
# TODO: Quick and dirty hack. Discord requires a webhook embed to have
# a description
description = message["oob"]["url"].split("/")[-1]
embed = Embed(url=message["oob"]["url"], type="rich", description=description)
embed.set_image(url=message["oob"]["url"])
# I mean, this should always be true, but you never know
2021-09-19 11:10:27 +00:00
if content == message["oob"]["url"] and self._remove_url_on_embed:
2021-09-19 11:01:14 +00:00
# NOTE: To prevent the URL being visible in Discord, remove the content.
# Makes it feel more native.
content = None
2021-09-16 15:01:34 +00:00
2021-09-16 16:29:55 +00:00
try:
await self._webhooks[muc].send(content=content,
username=message["from"].resource,
avatar_url=self._avatars.get_avatar_url(message["from"]),
embed=embed)
except Exception as err:
self._logger.error("Webhook execution failed: %s",
err)
2021-09-15 20:21:56 +00:00
2021-09-16 10:57:36 +00:00
def virtual_user_update_presence(self, muc, uid, pshow, pstatus=None):
2021-09-15 20:21:56 +00:00
"""
Change the status of a virtual MUC member.
NOTE: This assumes that the user is in the MUC
"""
self.send_presence(pshow=pshow,
2021-09-16 10:57:36 +00:00
pstatus=pstatus,
2021-09-15 20:21:56 +00:00
pto="%s/%s" % (muc, self._virtual_muc_nicks[muc][uid]),
pfrom=self.spoof_member_jid(uid))
def virtual_user_join_muc(self, muc, member, update_state_tracking=False):
"""
Makes the a puppet of member (@discord.Member) join the
MUC. Does nothing if the member is offline.
If update_state_tracking is True, then _virtual_muc_... gets updates.
"""
2021-09-16 10:57:36 +00:00
if member.status == Status.offline and not self._dont_ignore_offline:
2021-09-15 20:21:56 +00:00
return
if update_state_tracking:
self._virtual_muc_users[muc].append(member.display_name)
self._virtual_muc_nicks[muc][member.id] = member.display_name
# Prevent offline users from getting an unavailable presence by
# accident
pshow = discord_status_to_xmpp_show(member.status)
2021-09-16 10:57:36 +00:00
pstatus = ""
if member.status == Status.offline:
2021-09-15 20:21:56 +00:00
pshow = "xa"
2021-09-16 10:57:36 +00:00
if self._dont_ignore_offline:
pstatus = "Offline"
2021-09-15 20:21:56 +00:00
self.plugin["xep_0045"].join_muc(muc,
nick=member.display_name,
pfrom=self.spoof_member_jid(member.id),
2021-09-16 10:57:36 +00:00
pshow=pshow,
pstatus=pstatus)
2021-09-15 20:21:56 +00:00
async def on_discord_member_join(self, member):
guild = member.guild.id
if not guild in self._guild_map:
return
self._logger.debug("%s joined a known guild. Updating channels.",
member.display_name)
for channel in self._guild_map[guild]:
muc = self._guild_map[guild][channel]
self.virtual_user_join_muc(muc, member, update_state_tracking=True)
async def on_discord_member_leave(self, member):
guild = member.guild.id
if not guild in self._guild_map:
return
self._logger.debug("%s left a known guild. Updating channels.",
member.display_name)
for channel in self._guild_map[guild]:
muc = self._guild_map[member.guild.id][channel]
self._virtual_muc_users[muc].remove(member.display_name)
del self._virtual_muc_nicks[muc][member.id]
self.virtual_user_update_presence(muc, member.id, "unavailable")
async def on_discord_member_update(self, before, after):
guild = after.guild.id
if not guild in self._guild_map:
return
# TODO: Handle nick changes
if before.status != after.status:
# Handle a status change
for channel in self._guild_map[guild]:
muc = self._guild_map[guild][channel]
if after.id not in self._virtual_muc_nicks[muc]:
self._logger.debug("Got presence update from someone outside of the channel. Ignoring...")
return
2021-09-16 10:57:36 +00:00
if after.status == Status.offline:
2021-09-15 20:33:59 +00:00
if self._dont_ignore_offline:
2021-09-15 20:21:56 +00:00
self.virtual_user_update_presence(muc,
after.id,
2021-09-16 10:57:36 +00:00
"xa",
"Offline")
2021-09-15 20:21:56 +00:00
else:
self._logger.debug("%s went offline. Removing from state tracking.",
after.display_name)
self.virtual_user_update_presence(muc,
after.id,
discord_status_to_xmpp_show(after.status))
# Remove from all state tracking
self._virtual_muc_users[muc].remove(after.display_name)
del self._virtual_muc_nicks[muc][after.id]
2021-09-16 10:57:36 +00:00
elif before.status == Status.offline and after.status != Status.offline and not self._dont_ignore_offline:
2021-09-15 20:21:56 +00:00
self.virtual_user_join_muc(muc, after, update_state_tracking=True)
else:
self.virtual_user_update_presence(muc,
after.id,
discord_status_to_xmpp_show(after.status))
async def on_discord_channel_update(self, before, after):
if after.type != nextcord.ChannelType.text:
2021-09-15 20:21:56 +00:00
return
guild = after.guild.id
channel = after.id
if not guild in self._guild_map:
return
if not channel in self._guild_map[guild]:
return
# NOTE: We can only really handle description changes
muc = self._guild_map[guild][channel]
if before.topic != after.topic:
self._logger.debug("Channel %s changed the topic. Relaying.",
after.name)
self.plugin["xep_0045"].set_subject(muc,
after.topic or "",
mfrom=self._bot_jid_full)
async def on_discord_reaction(self, guild, channel, emoji_str, msg, uid, kind):
"""
Handle a Discord reaction.
reaction: discord.Reaction
user: discord.Member
kind: Either "add" or "remove"
"""
2021-09-15 20:33:59 +00:00
if not self._reactions_compat:
2021-09-15 20:21:56 +00:00
self._logger.debug("Got a reaction but reactions_compat is turned off. Ignoring.")
return
if not guild in self._guild_map:
return
if not channel in self._guild_map[guild]:
return
muc = self._guild_map[guild][channel]
# TODO: Handle attachments
content = "> " + msg.clean_content.replace("\n", "\n> ") + "\n"
content += "+" if kind == "REACTION_ADD" else "-"
content += " " + emoji_str
self.send_message(mto=muc,
mbody=content,
mtype="groupchat",
mfrom=self.spoof_member_jid(uid))
async def on_discord_message(self, msg):
guild, channel = msg.guild.id, msg.channel.id
if not (guild in self._guild_map and channel in self._guild_map[guild]):
return
muc = self._guild_map[guild][channel]
if msg.author.bot and msg.author.display_name in self._real_muc_users[muc]:
return
# TODO: Handle embeds
for attachment in msg.attachments:
await self.send_oob_data(attachment.url, muc, msg.author)
for sticker in msg.stickers:
await self.send_oob_data(sticker.url, muc, msg.author)
2021-09-15 20:21:56 +00:00
if not msg.clean_content:
self._logger.debug("Message empty. Not relaying.")
return
2021-09-16 17:01:40 +00:00
mentions = [mention.display_name for mention in msg.mentions]
if self._muc_mention_compat and mentions:
2021-09-15 20:21:56 +00:00
content = ", ".join(mentions) + ": " + msg.clean_content
else:
content = msg.clean_content
self.send_message(mto=muc,
mbody=content,
mtype="groupchat",
mfrom=self.spoof_member_jid(msg.author.id))
async def on_groupchat_presence(self, presence):
muc = presence["from"].bare
resource = presence["from"].resource
if not muc in self._mucs:
self._logger.warn("Received presence in unknown MUC %s", muc)
return
if resource in self._virtual_muc_users[muc]:
return
if not presence["to"] == self._bot_jid_full:
return
if resource == self._bot_nick:
return
if presence["type"] == "unavailable":
try:
self._real_muc_users.remove(resource)
except:
self._logger.debug("Trying to remove %s from %s, but user is not in list. Skipping...",
muc,
resource)
else:
2021-09-16 15:01:34 +00:00
if self._relay_xmpp_avatars:
await self._avatars.aquire_avatar(presence["from"])
2021-09-15 20:21:56 +00:00
self._real_muc_users[muc].append(resource)
async def on_session_start(self, event):
2021-09-16 10:57:36 +00:00
self._discord = DiscordClient(self, self._config)
2021-09-15 20:21:56 +00:00
asyncio.ensure_future(self._discord.start(self._token))
def main():
parser = OptionParser()
parser.add_option(
"-d", "--debug", dest="debug", help="Enable debug logging", action="store_true"
)
parser.add_option(
"-c", "--config", dest="config", help="Config file path"
)
2021-09-15 20:21:56 +00:00
2021-09-16 10:57:36 +00:00
(options, args) = parser.parse_args()
2021-09-15 20:21:56 +00:00
verbosity = logging.DEBUG if options.debug else logging.INFO
if options.config:
config = toml.load(options.config)
elif os.path.exists("./config.toml"):
config = toml.load("./config.toml")
elif os.path.exists("/etc/discord-xmpp-bridge/config.toml"):
config = toml.load("/etc/discord-xmpp-bridge/config.toml")
else:
raise Exception("config.toml not found")
2021-09-15 20:21:56 +00:00
general = config["general"]
secret = ""
if not "secret" in general:
if "secret_file" in general:
with open(general["secret_file"], "r") as f:
secret = f.read().replace("\n", "")
else:
raise Exception("No component secret specified")
else:
secret = general["secret"]
2021-09-15 20:21:56 +00:00
xmpp = BridgeComponent(general["jid"],
secret,
2021-09-15 20:21:56 +00:00
general["server"],
general["port"],
general["discord_token"],
config)
for xep in [
"0030",
"0199",
"0045",
"0084",
"0153",
"0054",
"0060"
]:
2021-09-15 20:21:56 +00:00
xmpp.register_plugin(f"xep_{xep}")
logging.basicConfig(stream=sys.stdout, level=verbosity)
xmpp.connect()
xmpp.process(forever=False)
if __name__ == "__main__":
main()