xmpp-discord-bridge/xmpp_discord_bridge/main.py

579 lines
22 KiB
Python
Raw 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
from slixmpp.xmlstream import ElementBase, register_stanza_plugin
from slixmpp.jid import JID
import discord
import requests
class OOBData(ElementBase):
name = "x"
namespace = "jabber:x:oob"
plugin_attrib = "oob"
interfaces = {"url"}
sub_interfaces = interfaces
class AvatarManager:
def __init__(self, xmpp, config):
self._xmpp = xmpp
self._path = config["avatars"]["path"]
self._public = config["avatars"]["url"]
self._avatars = {}
self._logger = logging.getLogger("xmpp.avatar")
def save_avatar(self, jid, data, type_):
filename = hashlib.sha1(data).hexdigest() + "." + type_.split("/")[1]
path = os.path.join(self._path, filename)
if os.path.exists(path):
self._logger.debug("Avatar for %s already exists, not saving it again",
jid)
else:
with open(path, "wb") as f:
f.write(data)
self._avatars[jid] = filename
async def try_xep_0153(self, jid):
try:
iq = await self._xmpp.plugin["xep_0054"].get_vcard(jid=jid,
ifrom=self._xmpp._bot_jid_full)
type_ = iq["vcard_temp"]["PHOTO"]["TYPE"]
data = iq["vcard_temp"]["PHOTO"]["BINVAL"]
self.save_avatar(jid, data, type_)
return True
except IqError:
self._logger.debug("Avatar retrieval via XEP-0054/XEP-0153 failed. Probably no vCard for XEP-0054 published")
return False
async def try_xep_0084(self, jid):
try:
iq = await self._xmpp.plugin["xep_0060"].get_items(jid=jid,
node="urn:xmpp:avatar:data",
max_items=1,
ifrom=self._xmpp._bot_jid_full)
except IqError:
self._logger.debug("Avatar retrieval via XEP-0084 failed. Probably no avatar published or subscription model not fulfilled.")
return False
async def aquire_avatar(self, jid):
# First try vCard via 0054/0153
for f in [self.try_xep_0153, self.try_xep_0084]:
if await f(jid):
self._logger.debug("Avatar retrieval successful for %s",
jid)
return
self._logger.debug("Avatar retrieval failed for %s. Giving up.",
jid)
def get_avatar(self, jid):
return self._avatars.get(jid, None)
class DiscordClient(discord.Client):
def __init__(self, xmpp, config):
intents = discord.Intents.default()
intents.members = True
intents.presences = True
intents.messages = True
intents.reactions = True
discord.Client.__init__(self, intents=intents)
self._xmpp = xmpp
self._config = config
self._logger = logging.getLogger("discord.client")
async def on_ready(self):
await self._xmpp.on_discord_ready()
async def on_message(self, message):
await self._xmpp.on_discord_message(message)
async def on_member_update(self, before, after):
await self._xmpp.on_discord_member_update(before, after)
async def on_member_join(self, member):
await self._xmpp.on_discord_member_join(member)
async def on_member_leave(self, member):
await self._xmpp.on_discord_member_leave(member)
async def on_guild_channel_update(self, before, after):
await self._xmpp.on_discord_channel_update(before, after)
async def on_reaction(self, payload):
message = await (self.get_guild(payload.guild_id)
.get_channel(payload.channel_id)
.fetch_message(payload.message_id))
await self._xmpp.on_discord_reaction(payload.guild_id,
payload.channel_id,
payload.emoji.name,
message,
payload.user_id,
payload.event_type)
async def on_raw_reaction_add(self, payload):
await self.on_reaction(payload)
async def on_raw_reaction_remove(self, payload):
await self.on_reaction(payload)
def discord_status_to_xmpp_show(status):
return {
discord.Status.online: "available",
discord.Status.idle: "away",
discord.Status.dnd: "dnd",
discord.Status.do_not_disturb: "dnd",
discord.Status.invisible: "xa", # TODO: Kinda
discord.Status.offline: "unavailable"
}.get(status, "available")
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
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
"""
proxy = self._config["general"].get("proxy_discord_urls_to", None)
if proxy:
hmac_str = urllib.parse.quote(base64.b64encode(hmac.digest(self._config["general"]["hmac_secret"].encode(),
url.encode(),
"sha256")), safe="")
proxy = proxy.replace("<hmac>", hmac_str).replace("<url>", 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 == discord.Status.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("""
<iq type="set" from="{}" to="{}">
<vCard xmlns="vcard-temp">
{}
</vCard>
</iq>
""".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._config["general"]["relay_xmpp_avatars"] and self._avatars.get_avatar(message["from"]):
webhook["avatar_url"] = self._avatar.get_avatar(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):
"""
Change the status of a virtual MUC member.
NOTE: This assumes that the user is in the MUC
"""
self.send_presence(pshow=pshow,
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 == discord.Status.offline and not self._config["general"]["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)
if member.status == discord.Status.offline:
pshow = "xa"
self.plugin["xep_0045"].join_muc(muc,
nick=member.display_name,
pfrom=self.spoof_member_jid(member.id),
pshow=pshow)
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 == discord.Status.offline:
if self._config["general"]["dont_ignore_offline"]:
self.virtual_user_update_presence(muc,
after.id,
"xa")
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 == discord.Status.offline and after.status != discord.Status.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._config["general"]["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._config["general"]["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, 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"
)
(option, 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()