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 from slixmpp.xmlstream import register_stanza_plugin from slixmpp.jid import JID from nextcord import Status, Embed import requests 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 # TODO: Move into separate file # TODO: Add an option that allows a real user to join the MUC to replace a virtual one 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 # 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) 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 """ if self._proxy_url_template: hmac_str = urllib.parse.quote(base64.b64encode(hmac.digest(self._proxy_hmac_secret.encode(), url.encode(), "sha256")), safe="") proxy = self._proxy_url_template.replace("", hmac_str).replace("", urllib.parse.quote(url, safe="")) 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 """ 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())) for ch in self._config["discord"]["channels"]: muc = ch["muc"] channel = ch["channel"] guild = ch["guild"] dchannel = self._discord.get_channel(channel) # Initialise state tracking self._muc_map[muc] = (guild, channel) self._virtual_muc_users[muc] = [] self._virtual_muc_nicks[muc] = {} for member in dchannel.members: if member.status == Status.offline and not self._dont_ignore_offline: 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) # 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(""" {} """.format(self._bot_jid_full, muc, str(vcard))) # Aquire a webhook wh = None for webhook in await dchannel.webhooks(): if webhook.name == "discord-xmpp-bridge": wh = webhook break if not wh: wh = await dchannel.create_webhook(name="discord-xmpp-bridge", reason="Bridging Discord and XMPP") self._webhooks[muc] = wh # Make sure our virtual users can join affiliation_list = await self.plugin["xep_0045"].get_affiliation_list(muc, "member", ifrom=self._bot_jid_full) 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) 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 content = message["body"] embed = None # 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) if "@" + member.display_name in content: self._logger.debug("Found mention for %s. Replaceing.", member.display_name) content = content.replace("@" + member.display_name, member.mention) if message["oob"]["url"] and message["body"] == message["oob"]["url"]: # 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 if content == message["oob"]["url"]: # NOTE: To prevent the URL being visible in Discord, remove the content. # Makes it feel more native. content = None 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) def virtual_user_update_presence(self, muc, uid, pshow, pstatus=None): """ Change the status of a virtual MUC member. NOTE: This assumes that the user is in the MUC """ self.send_presence(pshow=pshow, pstatus=pstatus, 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. """ if member.status == Status.offline and not self._dont_ignore_offline: 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) pstatus = "" if member.status == Status.offline: pshow = "xa" if self._dont_ignore_offline: pstatus = "Offline" self.plugin["xep_0045"].join_muc(muc, nick=member.display_name, pfrom=self.spoof_member_jid(member.id), pshow=pshow, pstatus=pstatus) 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 if after.status == Status.offline: if self._dont_ignore_offline: self.virtual_user_update_presence(muc, after.id, "xa", "Offline") 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] elif before.status == Status.offline and after.status != Status.offline and not self._dont_ignore_offline: 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: 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" """ if not self._reactions_compat: 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) if not msg.clean_content: self._logger.debug("Message empty. Not relaying.") return mentions = [mention.display_name for mention in msg.mentions] if self._muc_mention_compat and mentions: 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: if self._relay_xmpp_avatars: await self._avatars.aquire_avatar(presence["from"]) self._real_muc_users[muc].append(resource) async def on_session_start(self, event): self._discord = DiscordClient(self, self._config) asyncio.ensure_future(self._discord.start(self._token)) def main(): if 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") parser = OptionParser() parser.add_option( "-d", "--debug", dest="debug", help="Enable debug logging", action="store_true" ) (options, args) = parser.parse_args() verbosity = logging.DEBUG if options.debug else logging.INFO general = config["general"] xmpp = BridgeComponent(general["jid"], general["secret"], general["server"], general["port"], general["discord_token"], config) for xep in [ "0030", "0199", "0045", "0084", "0153", "0054", "0060" ]: xmpp.register_plugin(f"xep_{xep}") logging.basicConfig(stream=sys.stdout, level=verbosity) xmpp.connect() xmpp.process(forever=False) if __name__ == "__main__": main()