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 discord import Status 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 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_sigint(self): 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_sigint())) 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 subject = dchannel.topic or "" self.plugin["xep_0045"].set_subject(muc, subject, mfrom=self._bot_jid_full) # TODO: Is this working? # Mirror the guild's icon icon = await dchannel.guild.icon_url_as(static_format="png", format="png", size=128).read() vcard = self.plugin["xep_0054"].make_vcard() vcard["PHOTO"]["TYPE"] = "image/png" vcard["PHOTO"]["BINVAL"] = base64.b64encode(icon) self.send_raw(""" {} """.format(self._bot_jid_full, muc, str(vcard))) # Aquire a webhook webhook_url = "" for webhook in await dchannel.webhooks(): if webhook.name == "discord-xmpp-bridge": webhook_url = webhook.url break if not webhook_url: webhook = dchannel.create_webhook(name="discord-xmpp-bridge", reason="Bridging Discord and XMPP") webhook_url = webhook.url self._webhooks[muc] = webhook_url # Make sure our virtual users can join affiliation_delta = [ (self.spoof_member_jid(member.id).bare, "member") for member in dchannel.members ] await self.plugin["xep_0045"].send_affiliation_list(muc, affiliation_delta, ifrom=self._bot_jid_full) for member in dchannel.members: 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 webhook = { "content": message["body"], "username": message["from"].resource } if self._relay_xmpp_avatars and self._avatars.get_avatar_url(message["from"]): webhook["avatar_url"] = self._avatar.get_avatar_url(message["from"]) # 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 webhook["content"]: self._logger.debug("Found mention for %s. Replaceing.", member.display_name) webhook["content"] = webhook["content"].replace("@" + member.display_name, member.mention) if message["oob"]["url"] and message["body"] == message["oob"]["url"]: webhook["embed"] = [{ "type": "rich", "url": message["oob"]["url"] }] requests.post(self._webhooks[muc], data=webhook) 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.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 != discord.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) if not msg.clean_content: self._logger.debug("Message empty. Not relaying.") return if self._muc_mention_compat: mentions = [mention.display_name for mention in msg.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: 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()