fork of https://f-hub.org/XMPP/xmpp-discord-bridge

Add existing code

+606
+28
setup.py
··· 1 + from setuptools import setup, find_packages 2 + 3 + setup( 4 + name = "xmpp_discord_bridge", 5 + version = "0.1.0", 6 + url = "https://git.polynom.me/PapaTutuWawa/xmpp-discord-bridge", 7 + author = "Alexander \"PapaTutuWawa\"", 8 + author_email = "papatutuwawa <at> polynom.me", 9 + license = "GPLc3", 10 + packages = find_packages(), 11 + install_requires = [ 12 + "requests>=2.26.0", 13 + "slixmpp>=1.7.1", 14 + "discord.py>=1.7.3", 15 + "toml>=0.10.2" 16 + ], 17 + extra_require = { 18 + "dev": [ 19 + "black" 20 + ] 21 + }, 22 + zip_safe = True, 23 + entry_points = { 24 + "console_scripts": [ 25 + "discord-xmpp-bridge = discord_xmpp_bridge.main:main" 26 + ] 27 + } 28 + )
xmpp_discord_bridge/__init__.py

This is a binary file and will not be displayed.

+578
xmpp_discord_bridge/main.py
··· 1 + import logging 2 + import os 3 + import sys 4 + import asyncio 5 + import signal 6 + import base64 7 + import hashlib 8 + import hmac 9 + import urllib.parse 10 + from optparse import OptionParser 11 + 12 + import toml 13 + import slixmpp 14 + from slixmpp import Message 15 + from slixmpp.componentxmpp import ComponentXMPP 16 + from slixmpp.exceptions import XMPPError, IqError 17 + from slixmpp.xmlstream import ElementBase, register_stanza_plugin 18 + from slixmpp.jid import JID 19 + import discord 20 + import requests 21 + 22 + class OOBData(ElementBase): 23 + name = "x" 24 + namespace = "jabber:x:oob" 25 + plugin_attrib = "oob" 26 + interfaces = {"url"} 27 + sub_interfaces = interfaces 28 + 29 + class AvatarManager: 30 + def __init__(self, xmpp, config): 31 + self._xmpp = xmpp 32 + self._path = config["avatars"]["path"] 33 + self._public = config["avatars"]["url"] 34 + 35 + self._avatars = {} 36 + 37 + self._logger = logging.getLogger("xmpp.avatar") 38 + 39 + def save_avatar(self, jid, data, type_): 40 + filename = hashlib.sha1(data).hexdigest() + "." + type_.split("/")[1] 41 + path = os.path.join(self._path, filename) 42 + 43 + if os.path.exists(path): 44 + self._logger.debug("Avatar for %s already exists, not saving it again", 45 + jid) 46 + else: 47 + with open(path, "wb") as f: 48 + f.write(data) 49 + 50 + self._avatars[jid] = filename 51 + 52 + async def try_xep_0153(self, jid): 53 + try: 54 + iq = await self._xmpp.plugin["xep_0054"].get_vcard(jid=jid, 55 + ifrom=self._xmpp._bot_jid_full) 56 + type_ = iq["vcard_temp"]["PHOTO"]["TYPE"] 57 + data = iq["vcard_temp"]["PHOTO"]["BINVAL"] 58 + self.save_avatar(jid, data, type_) 59 + return True 60 + except IqError: 61 + self._logger.debug("Avatar retrieval via XEP-0054/XEP-0153 failed. Probably no vCard for XEP-0054 published") 62 + return False 63 + 64 + async def try_xep_0084(self, jid): 65 + try: 66 + iq = await self._xmpp.plugin["xep_0060"].get_items(jid=jid, 67 + node="urn:xmpp:avatar:data", 68 + max_items=1, 69 + ifrom=self._xmpp._bot_jid_full) 70 + except IqError: 71 + self._logger.debug("Avatar retrieval via XEP-0084 failed. Probably no avatar published or subscription model not fulfilled.") 72 + return False 73 + 74 + async def aquire_avatar(self, jid): 75 + # First try vCard via 0054/0153 76 + for f in [self.try_xep_0153, self.try_xep_0084]: 77 + if await f(jid): 78 + self._logger.debug("Avatar retrieval successful for %s", 79 + jid) 80 + return 81 + 82 + self._logger.debug("Avatar retrieval failed for %s. Giving up.", 83 + jid) 84 + 85 + def get_avatar(self, jid): 86 + return self._avatars.get(jid, None) 87 + 88 + class DiscordClient(discord.Client): 89 + def __init__(self, xmpp, config): 90 + intents = discord.Intents.default() 91 + intents.members = True 92 + intents.presences = True 93 + intents.messages = True 94 + intents.reactions = True 95 + 96 + discord.Client.__init__(self, intents=intents) 97 + 98 + self._xmpp = xmpp 99 + self._config = config 100 + self._logger = logging.getLogger("discord.client") 101 + 102 + async def on_ready(self): 103 + await self._xmpp.on_discord_ready() 104 + 105 + async def on_message(self, message): 106 + await self._xmpp.on_discord_message(message) 107 + 108 + async def on_member_update(self, before, after): 109 + await self._xmpp.on_discord_member_update(before, after) 110 + 111 + async def on_member_join(self, member): 112 + await self._xmpp.on_discord_member_join(member) 113 + 114 + async def on_member_leave(self, member): 115 + await self._xmpp.on_discord_member_leave(member) 116 + 117 + async def on_guild_channel_update(self, before, after): 118 + await self._xmpp.on_discord_channel_update(before, after) 119 + 120 + async def on_reaction(self, payload): 121 + message = await (self.get_guild(payload.guild_id) 122 + .get_channel(payload.channel_id) 123 + .fetch_message(payload.message_id)) 124 + 125 + await self._xmpp.on_discord_reaction(payload.guild_id, 126 + payload.channel_id, 127 + payload.emoji.name, 128 + message, 129 + payload.user_id, 130 + payload.event_type) 131 + 132 + async def on_raw_reaction_add(self, payload): 133 + await self.on_reaction(payload) 134 + 135 + async def on_raw_reaction_remove(self, payload): 136 + await self.on_reaction(payload) 137 + 138 + def discord_status_to_xmpp_show(status): 139 + return { 140 + discord.Status.online: "available", 141 + discord.Status.idle: "away", 142 + discord.Status.dnd: "dnd", 143 + discord.Status.do_not_disturb: "dnd", 144 + discord.Status.invisible: "xa", # TODO: Kinda 145 + discord.Status.offline: "unavailable" 146 + }.get(status, "available") 147 + 148 + class BridgeComponent(ComponentXMPP): 149 + def __init__(self, jid, secret, server, port, token, config): 150 + ComponentXMPP.__init__(self, jid, secret, server, port) 151 + 152 + self._config = config 153 + self._logger = logging.getLogger("xmpp.bridge") 154 + self._domain = jid 155 + self._bot_nick = "Bot" 156 + self._bot_jid_bare = JID("bot@" + jid) 157 + self._bot_jid_full = JID("bot@" + jid + "/" + self._bot_nick) 158 + 159 + self._token = token 160 + self._discord = None 161 + 162 + self._avatars = AvatarManager(self, config) 163 + 164 + # State tracking 165 + self._virtual_muc_users = {} # MUC -> [Resources] 166 + self._virtual_muc_nicks = {} # MUC -> User ID -> Nick 167 + self._real_muc_users = {} # MUC -> [Resources] 168 + self._guild_map = {} # Guild ID -> Channel ID -> MUC 169 + self._muc_map = {} # MUC -> (Guild ID, Channel ID) 170 + self._mucs = [] # List of known MUCs 171 + self._webhooks = {} # MUC -> Webhook URL 172 + 173 + register_stanza_plugin(Message, OOBData) 174 + 175 + self.add_event_handler("session_start", self.on_session_start) 176 + self.add_event_handler("groupchat_message", self.on_groupchat_message) 177 + self.add_event_handler("groupchat_presence", self.on_groupchat_presence) 178 + 179 + async def send_oob_data(self, url, muc, member): 180 + """ 181 + Send a message using XEP-0066 OOB data 182 + """ 183 + proxy = self._config["general"].get("proxy_discord_urls_to", None) 184 + if proxy: 185 + hmac_str = urllib.parse.quote(base64.b64encode(hmac.digest(self._config["general"]["hmac_secret"].encode(), 186 + url.encode(), 187 + "sha256")), safe="") 188 + proxy = proxy.replace("<hmac>", hmac_str).replace("<url>", urllib.parse.quote(url, safe="")) 189 + url = proxy 190 + 191 + self._logger.debug("OOB URL: %s", url) 192 + msg = self.make_message(muc, 193 + mbody=url, 194 + mtype="groupchat", 195 + mfrom=self.spoof_member_jid(member.id)) 196 + msg["oob"]["url"] = url 197 + msg.send() 198 + 199 + def spoof_member_jid(self, id_): 200 + """ 201 + Return a full JID that we use for the puppets 202 + """ 203 + return JID(str(id_) + "@" + self._domain + "/discord") 204 + 205 + async def on_sigint(self): 206 + await self._discord.close() 207 + 208 + # Remove all virtual users 209 + # NOTE: We cannot use leave_muc as this would also remove the MUC 210 + # from slixmpp's internal tracking, which would probably break 211 + # later leaves 212 + for muc in self._mucs: 213 + for uid in self._virtual_muc_nicks[muc]: 214 + nick = self._virtual_muc_nicks[muc][uid] 215 + self.send_presence(pshow='unavailable', 216 + pto="%s/%s" % (muc, nick), 217 + pfrom=self.spoof_member_jid(uid)) 218 + 219 + # Remove the Bot user 220 + self.send_presence(pshow='unavailable', 221 + pto="%s/%s" % (muc, "Bot"), 222 + pfrom=self._bot_jid_full) 223 + 224 + # Disconnect and close 225 + await self.disconnect() 226 + 227 + async def on_discord_ready(self): 228 + asyncio.get_event_loop().add_signal_handler(signal.SIGINT, 229 + lambda: asyncio.create_task(self.on_sigint())) 230 + 231 + for ch in self._config["discord"]["channels"]: 232 + muc = ch["muc"] 233 + channel = ch["channel"] 234 + guild = ch["guild"] 235 + dchannel = self._discord.get_channel(channel) 236 + 237 + # Initialise state tracking 238 + self._muc_map[muc] = (guild, channel) 239 + self._virtual_muc_users[muc] = [] 240 + self._virtual_muc_nicks[muc] = {} 241 + for member in dchannel.members: 242 + if member.status == discord.Status.offline: 243 + continue 244 + 245 + self._virtual_muc_users[muc].append(member.display_name) 246 + self._virtual_muc_nicks[muc][member.id] = member.display_name 247 + self._real_muc_users[muc] = [] 248 + self._mucs.append(muc) 249 + if not guild in self._guild_map: 250 + self._guild_map[guild] = { 251 + channel: muc 252 + } 253 + else: 254 + self._guild_map[guild][channel] = muc 255 + 256 + self._logger.debug("Joining %s", muc) 257 + self.plugin["xep_0045"].join_muc(muc, 258 + nick=self._bot_nick, 259 + pfrom=self._bot_jid_full) 260 + 261 + # Set the subject 262 + subject = dchannel.topic or "" 263 + self.plugin["xep_0045"].set_subject(muc, 264 + subject, 265 + mfrom=self._bot_jid_full) 266 + 267 + # TODO: Is this working? 268 + # Mirror the guild's icon 269 + icon = await dchannel.guild.icon_url_as(static_format="png", 270 + format="png", 271 + size=128).read() 272 + vcard = self.plugin["xep_0054"].make_vcard() 273 + vcard["PHOTO"]["TYPE"] = "image/png" 274 + vcard["PHOTO"]["BINVAL"] = base64.b64encode(icon) 275 + self.send_raw(""" 276 + <iq type="set" from="{}" to="{}"> 277 + <vCard xmlns="vcard-temp"> 278 + {} 279 + </vCard> 280 + </iq> 281 + """.format(self._bot_jid_full, 282 + muc, 283 + str(vcard))) 284 + 285 + # Aquire a webhook 286 + webhook_url = "" 287 + for webhook in await dchannel.webhooks(): 288 + if webhook.name == "discord-xmpp-bridge": 289 + webhook_url = webhook.url 290 + break 291 + if not webhook_url: 292 + webhook = dchannel.create_webhook(name="discord-xmpp-bridge", 293 + reason="Bridging Discord and XMPP") 294 + webhook_url = webhook.url 295 + self._webhooks[muc] = webhook_url 296 + 297 + # Make sure our virtual users can join 298 + affiliation_delta = [ 299 + (self.spoof_member_jid(member.id).bare, "member") for member in dchannel.members 300 + ] 301 + await self.plugin["xep_0045"].send_affiliation_list(muc, 302 + affiliation_delta, 303 + ifrom=self._bot_jid_full) 304 + 305 + for member in dchannel.members: 306 + self.virtual_user_join_muc(muc, member, update_state_tracking=False) 307 + 308 + self._logger.info("%s is ready", muc) 309 + self._logger.info("Bridge is ready") 310 + 311 + async def on_groupchat_message(self, message): 312 + muc = message["from"].bare 313 + if not message["body"]: 314 + return 315 + if not message["from"].resource in self._real_muc_users[muc]: 316 + return 317 + # Prevent the message being reflected back into Discord 318 + if not message["to"] == self._bot_jid_full: 319 + return 320 + 321 + webhook = { 322 + "content": message["body"], 323 + "username": message["from"].resource 324 + } 325 + 326 + if self._config["general"]["relay_xmpp_avatars"] and self._avatars.get_avatar(message["from"]): 327 + webhook["avatar_url"] = self._avatar.get_avatar(message["from"]) 328 + 329 + # Look for mentions and replace them 330 + guild, channel = self._muc_map[muc] 331 + for member in self._discord.get_guild(guild).get_channel(channel).members: 332 + self._logger.debug("Checking %s", member.display_name) 333 + if "@" + member.display_name in webhook["content"]: 334 + self._logger.debug("Found mention for %s. Replaceing.", 335 + member.display_name) 336 + webhook["content"] = webhook["content"].replace("@" + member.display_name, 337 + member.mention) 338 + 339 + if message["oob"]["url"] and message["body"] == message["oob"]["url"]: 340 + webhook["embed"] = [{ 341 + "type": "rich", 342 + "url": message["oob"]["url"] 343 + }] 344 + 345 + requests.post(self._webhooks[muc], 346 + data=webhook) 347 + 348 + def virtual_user_update_presence(self, muc, uid, pshow): 349 + """ 350 + Change the status of a virtual MUC member. 351 + NOTE: This assumes that the user is in the MUC 352 + """ 353 + self.send_presence(pshow=pshow, 354 + pto="%s/%s" % (muc, self._virtual_muc_nicks[muc][uid]), 355 + pfrom=self.spoof_member_jid(uid)) 356 + 357 + def virtual_user_join_muc(self, muc, member, update_state_tracking=False): 358 + """ 359 + Makes the a puppet of member (@discord.Member) join the 360 + MUC. Does nothing if the member is offline. 361 + 362 + If update_state_tracking is True, then _virtual_muc_... gets updates. 363 + """ 364 + if member.status == discord.Status.offline and not self._config["general"]["dont_ignore_offline"]: 365 + return 366 + 367 + if update_state_tracking: 368 + self._virtual_muc_users[muc].append(member.display_name) 369 + self._virtual_muc_nicks[muc][member.id] = member.display_name 370 + 371 + # Prevent offline users from getting an unavailable presence by 372 + # accident 373 + pshow = discord_status_to_xmpp_show(member.status) 374 + if member.status == discord.Status.offline: 375 + pshow = "xa" 376 + 377 + self.plugin["xep_0045"].join_muc(muc, 378 + nick=member.display_name, 379 + pfrom=self.spoof_member_jid(member.id), 380 + pshow=pshow) 381 + 382 + async def on_discord_member_join(self, member): 383 + guild = member.guild.id 384 + if not guild in self._guild_map: 385 + return 386 + 387 + self._logger.debug("%s joined a known guild. Updating channels.", 388 + member.display_name) 389 + for channel in self._guild_map[guild]: 390 + muc = self._guild_map[guild][channel] 391 + self.virtual_user_join_muc(muc, member, update_state_tracking=True) 392 + 393 + async def on_discord_member_leave(self, member): 394 + guild = member.guild.id 395 + if not guild in self._guild_map: 396 + return 397 + 398 + self._logger.debug("%s left a known guild. Updating channels.", 399 + member.display_name) 400 + for channel in self._guild_map[guild]: 401 + muc = self._guild_map[member.guild.id][channel] 402 + self._virtual_muc_users[muc].remove(member.display_name) 403 + del self._virtual_muc_nicks[muc][member.id] 404 + 405 + self.virtual_user_update_presence(muc, member.id, "unavailable") 406 + 407 + 408 + async def on_discord_member_update(self, before, after): 409 + guild = after.guild.id 410 + if not guild in self._guild_map: 411 + return 412 + 413 + # TODO: Handle nick changes 414 + if before.status != after.status: 415 + # Handle a status change 416 + for channel in self._guild_map[guild]: 417 + muc = self._guild_map[guild][channel] 418 + if after.status == discord.Status.offline: 419 + if self._config["general"]["dont_ignore_offline"]: 420 + self.virtual_user_update_presence(muc, 421 + after.id, 422 + "xa") 423 + else: 424 + self._logger.debug("%s went offline. Removing from state tracking.", 425 + after.display_name) 426 + self.virtual_user_update_presence(muc, 427 + after.id, 428 + discord_status_to_xmpp_show(after.status)) 429 + 430 + # Remove from all state tracking 431 + self._virtual_muc_users[muc].remove(after.display_name) 432 + del self._virtual_muc_nicks[muc][after.id] 433 + elif before.status == discord.Status.offline and after.status != discord.Status.offline: 434 + self.virtual_user_join_muc(muc, after, update_state_tracking=True) 435 + else: 436 + self.virtual_user_update_presence(muc, 437 + after.id, 438 + discord_status_to_xmpp_show(after.status)) 439 + 440 + 441 + async def on_discord_channel_update(self, before, after): 442 + if after.type != discord.ChannelType.text: 443 + return 444 + 445 + guild = after.guild.id 446 + channel = after.id 447 + if not guild in self._guild_map: 448 + return 449 + if not channel in self._guild_map[guild]: 450 + return 451 + 452 + # NOTE: We can only really handle description changes 453 + muc = self._guild_map[guild][channel] 454 + if before.topic != after.topic: 455 + self._logger.debug("Channel %s changed the topic. Relaying.", 456 + after.name) 457 + self.plugin["xep_0045"].set_subject(muc, 458 + after.topic or "", 459 + mfrom=self._bot_jid_full) 460 + 461 + async def on_discord_reaction(self, guild, channel, emoji_str, msg, uid, kind): 462 + """ 463 + Handle a Discord reaction. 464 + 465 + reaction: discord.Reaction 466 + user: discord.Member 467 + kind: Either "add" or "remove" 468 + """ 469 + if not self._config["general"]["reactions_compat"]: 470 + self._logger.debug("Got a reaction but reactions_compat is turned off. Ignoring.") 471 + return 472 + if not guild in self._guild_map: 473 + return 474 + if not channel in self._guild_map[guild]: 475 + return 476 + 477 + muc = self._guild_map[guild][channel] 478 + 479 + # TODO: Handle attachments 480 + content = "> " + msg.clean_content.replace("\n", "\n> ") + "\n" 481 + content += "+" if kind == "REACTION_ADD" else "-" 482 + content += " " + emoji_str 483 + 484 + self.send_message(mto=muc, 485 + mbody=content, 486 + mtype="groupchat", 487 + mfrom=self.spoof_member_jid(uid)) 488 + 489 + async def on_discord_message(self, msg): 490 + guild, channel = msg.guild.id, msg.channel.id 491 + if not (guild in self._guild_map and channel in self._guild_map[guild]): 492 + return 493 + 494 + muc = self._guild_map[guild][channel] 495 + if msg.author.bot and msg.author.display_name in self._real_muc_users[muc]: 496 + return 497 + 498 + # TODO: Handle embeds 499 + for attachment in msg.attachments: 500 + await self.send_oob_data(attachment.url, muc, msg.author) 501 + 502 + if not msg.clean_content: 503 + self._logger.debug("Message empty. Not relaying.") 504 + return 505 + 506 + if self._config["general"]["muc_mention_compat"]: 507 + mentions = [mention.display_name for mention in msg.mentions] 508 + content = ", ".join(mentions) + ": " + msg.clean_content 509 + else: 510 + content = msg.clean_content 511 + 512 + self.send_message(mto=muc, 513 + mbody=content, 514 + mtype="groupchat", 515 + mfrom=self.spoof_member_jid(msg.author.id)) 516 + 517 + async def on_groupchat_presence(self, presence): 518 + muc = presence["from"].bare 519 + resource = presence["from"].resource 520 + 521 + if not muc in self._mucs: 522 + self._logger.warn("Received presence in unknown MUC %s", muc) 523 + return 524 + if resource in self._virtual_muc_users[muc]: 525 + return 526 + if not presence["to"] == self._bot_jid_full: 527 + return 528 + if resource == self._bot_nick: 529 + return 530 + 531 + if presence["type"] == "unavailable": 532 + try: 533 + self._real_muc_users.remove(resource) 534 + except: 535 + self._logger.debug("Trying to remove %s from %s, but user is not in list. Skipping...", 536 + muc, 537 + resource) 538 + else: 539 + await self._avatars.aquire_avatar(presence["from"]) 540 + self._real_muc_users[muc].append(resource) 541 + 542 + async def on_session_start(self, event): 543 + self._discord = DiscordClient(self, config) 544 + asyncio.ensure_future(self._discord.start(self._token)) 545 + 546 + def main(): 547 + if os.path.exists("./config.toml"): 548 + config = toml.load("./config.toml") 549 + elif os.path.exists("/etc/discord-xmpp-bridge/config.toml"): 550 + config = toml.load("/etc/discord-xmpp-bridge/config.toml") 551 + else: 552 + raise Exception("config.toml not found") 553 + 554 + parser = OptionParser() 555 + parser.add_option( 556 + "-d", "--debug", dest="debug", help="Enable debug logging", action="store_true" 557 + ) 558 + 559 + (option, args) = parser.parse_args() 560 + verbosity = logging.DEBUG if options.debug else logging.INFO 561 + 562 + general = config["general"] 563 + xmpp = BridgeComponent(general["jid"], 564 + general["secret"], 565 + general["server"], 566 + general["port"], 567 + general["discord_token"], 568 + config) 569 + for xep in [ "0030", "0199", "0045", "0084", "0153", "0054", "0060" ]: 570 + xmpp.register_plugin(f"xep_{xep}") 571 + 572 + logging.basicConfig(stream=sys.stdout, level=verbosity) 573 + 574 + xmpp.connect() 575 + xmpp.process(forever=False) 576 + 577 + if __name__ == "__main__": 578 + main()