commit caf5871ab0520644600698cca4c723cc66aca5d5
Author: mid <>
Date: Thu Dec 25 14:39:18 2025 +0200
Initial commit
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..bee8a64
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+__pycache__
diff --git a/gateway_discord/__init__.py b/gateway_discord/__init__.py
new file mode 100644
index 0000000..10bb1d3
--- /dev/null
+++ b/gateway_discord/__init__.py
@@ -0,0 +1,177 @@
+import os, re, hashlib, sys, traceback
+import asyncio
+import nextcord
+
+class GatewayDiscord(nextcord.Client):
+ def __init__(self, plexus, settings, gateway_name, gateway_data):
+ nextcord.Client.__init__(self, loop = asyncio.get_event_loop(), intents = nextcord.Intents.all())
+
+ global PLEXUS
+ PLEXUS = plexus
+
+ self.settings = settings
+
+ self.gateway_name = gateway_name
+
+ self.discord_ready = False
+
+ self.room_name_to_pair = {}
+ self.pair_to_room_name = {}
+
+ self.discord_id_to_xdm_id = {}
+ self.xdm_id_to_discord_id = {}
+
+ self.my_puppets = {}
+
+ self.webhooks = {}
+
+ self.active_users = {}
+
+ self.new_user_queue = []
+
+ def new_user(identification, nick_name):
+ if identification[0] == "discord" and identification[1] == self.gateway_name:
+ return
+
+ self.new_user_queue.append(identification)
+
+ if self.discord_ready:
+ self.handle_new_user_queue()
+ PLEXUS.sub("new_user", new_user)
+
+ def message(msg_unique_id, room_name, nick_name, gateway_type, gateway_name, unique_id, body, attachments, was_edit):
+ if gateway_type == "discord" and gateway_name == self.gateway_name:
+ # Ignore ours
+ return
+
+ if len(attachments):
+ body = str(body) + "\n\n" + "\n".join([str(a) for a in attachments])
+
+ guild, channel = self.room_name_to_pair[room_name]
+ wh = self.webhooks[channel]
+
+ if was_edit:
+ if msg_unique_id not in self.xdm_id_to_discord_id:
+ # Too late
+ return
+
+ asyncio.ensure_future(wh.edit_message(self.xdm_id_to_discord_id[msg_unique_id], content = str(body)))
+ else:
+ def on_done(fut):
+ wh_msg = fut.result()
+ self.xdm_id_to_discord_id[msg_unique_id] = wh_msg.id
+ self.discord_id_to_xdm_id[wh_msg.id] = msg_unique_id
+
+ asyncio.ensure_future(wh.send(content = str(body), username = nick_name, wait = True, allowed_mentions = nextcord.AllowedMentions(everyone = False))).add_done_callback(on_done)
+ PLEXUS.sub("message", message)
+
+ PLEXUS.sub("ready", lambda: asyncio.ensure_future(self.start(gateway_data["token"])))
+
+ def handle_new_user_queue(self):
+ for identification in self.new_user_queue:
+ self.my_puppets[identification] = {}
+ self.new_user_queue.clear()
+
+ def ensure_user_is_active(self, member):
+ if member.id not in self.active_users:
+ self.active_users[member.id] = {}
+ PLEXUS.pub("new_user", ("discord", self.gateway_name, member.id), member.display_name)
+
+ async def on_message(self, message):
+ if message.author.bot:
+ # Ignore ours
+ return
+
+ if not isinstance(message.channel, nextcord.TextChannel):
+ return
+
+ guild_id = message.channel.guild.id
+ channel_id = message.channel.id
+
+ if (guild_id, channel_id) not in self.pair_to_room_name:
+ return
+
+ room_name = self.pair_to_room_name[(guild_id, channel_id)]
+
+ self.ensure_user_is_active(message.author)
+
+ msg_unique_id = os.urandom(16)
+ self.xdm_id_to_discord_id[msg_unique_id] = message.id
+ self.discord_id_to_xdm_id[message.id] = msg_unique_id
+
+ PLEXUS.pub("message", msg_unique_id, room_name, message.author.display_name, "discord", self.gateway_name, message.author.id, message.clean_content, [a.url for a in message.attachments], False)
+
+ async def on_message_edit(self, before, after):
+ if before.author.bot:
+ # Ignore ours
+ return
+
+ if not isinstance(before.channel, nextcord.TextChannel):
+ return
+
+ guild_id = after.channel.guild.id
+ channel_id = after.channel.id
+
+ if (guild_id, channel_id) not in self.pair_to_room_name:
+ return
+
+ if before.clean_content == after.clean_content:
+ return
+
+ # Message content was edited
+
+ if before.id not in self.discord_id_to_xdm_id:
+ return
+
+ msg_unique_id = self.discord_id_to_xdm_id[after.id]
+ room_name = self.pair_to_room_name[(guild_id, channel_id)]
+ PLEXUS.pub("message", msg_unique_id, room_name, after.author.display_name, "discord", self.gateway_name, after.author.id, after.clean_content, [a.url for a in after.attachments], True)
+
+ async def on_message_delete(self, message):
+ if message.author.bot:
+ # Ignore ours
+ return
+
+ if not isinstance(message.channel, nextcord.TextChannel):
+ return
+
+ guild_id = message.channel.guild.id
+ channel_id = message.channel.id
+
+ if (guild_id, channel_id) not in self.pair_to_room_name:
+ return
+
+ room_name = self.pair_to_room_name[(guild_id, channel_id)]
+
+ if message.author.id not in self.active_users:
+ return
+
+ if message.id not in self.discord_id_to_xdm_id:
+ return
+
+ msg_unique_id = self.discord_id_to_xdm_id[message.id]
+ PLEXUS.pub("message_delete", msg_unique_id, room_name, "discord", self.gateway_name, message.author.id)
+
+ async def on_ready(self):
+ for room_name, room_gateways in self.settings.ROOMS.items():
+ for gateway in room_gateways:
+ if "discord" in gateway and gateway["discord"] == self.gateway_name:
+ discord_channel = self.get_channel(gateway["channel"])
+
+ wh = None
+ for webhook in await discord_channel.webhooks():
+ if webhook.name == "xdm":
+ if not webhook.is_authenticated():
+ await webhook.delete(reason = "Must recreate")
+ else:
+ wh = webhook
+ if not wh:
+ wh = await discord_channel.create_webhook(name = "xdm", reason = "XDM bridge")
+
+ self.webhooks[gateway["channel"]] = wh
+
+ self.room_name_to_pair[room_name] = (gateway["guild"], gateway["channel"])
+ self.pair_to_room_name[(gateway["guild"], gateway["channel"])] = room_name
+
+ self.discord_ready = True
+ self.handle_new_user_queue()
diff --git a/gateway_xmpp/__init__.py b/gateway_xmpp/__init__.py
new file mode 100644
index 0000000..34e101a
--- /dev/null
+++ b/gateway_xmpp/__init__.py
@@ -0,0 +1,235 @@
+import os, re, hashlib, sys, traceback
+import asyncio
+import slixmpp
+from slixmpp.componentxmpp import ComponentXMPP
+from slixmpp.types import PresenceArgs
+
+class GatewayXMPP(ComponentXMPP):
+ def __init__(self, plexus, settings, gateway_name, gateway_data):
+ ComponentXMPP.__init__(self, gateway_data["jid"], gateway_data["secret"], gateway_data["server"], gateway_data["port"])
+
+ global PLEXUS
+ PLEXUS = plexus
+
+ self.settings = settings
+
+ self.pfrom = gateway_data["jid"]
+ self.gateway_name = gateway_name
+
+ self.relevant_mucs = {}
+ self.room_name_to_muc = {}
+ self.muc_to_room_name = {}
+ self.my_puppets = {}
+
+ self.xdm_id_to_base_id = {}
+ self.base_id_to_xdm_id = {}
+
+ self.xdm_id_to_stanza_id = {}
+ self.stanza_id_to_xdm_id = {}
+
+ self.xdm_id_to_origin_id = {}
+ self.origin_id_to_xdm_id = {}
+
+ self.xmpp_ready = False
+
+ self.add_event_handler("session_start", self.session_start)
+ self.add_event_handler("groupchat_message", self.message)
+ self.add_event_handler("groupchat_presence", self.groupchat_presence)
+ self.register_handler(slixmpp.xmlstream.handler.Callback("nickname_conflict", slixmpp.xmlstream.matcher.StanzaPath("presence"), self.nickname_conflict))
+
+ self.register_plugin('xep_0030') # Service Discovery
+ self.register_plugin('xep_0004') # Data Forms
+ self.register_plugin('xep_0060') # PubSub
+ self.register_plugin('xep_0199') # XMPP Ping
+ self.register_plugin('xep_0045') # MUC
+ self.register_plugin('xep_0359') # Stanza IDs
+ self.register_plugin('xep_0308') # Message correction
+ self.register_plugin('xep_0424') # Message retraction
+
+ def new_user(identification, nick_name):
+ if identification[0] == "xmpp" and identification[1] == self.gateway_name:
+ # Ignore ours
+ return
+
+ puppet_data = {
+ "identification": identification,
+ "jid": f"{hashlib.sha256(str(identification).encode()).hexdigest()[:24]}@{self.pfrom}/mirror",
+
+ # A puppet may have a different nick per MUC because of conflicts
+ "nicknames": {muc_jid: {"state": "joining", "nick": nick_name} for muc_jid in self.relevant_mucs},
+
+ # Messages are added and executed as separate steps
+ "queue": []
+ }
+ self.my_puppets[identification] = puppet_data
+ PLEXUS.sub("new_user", new_user)
+
+ def message(msg_unique_id, room_name, nick_name, gateway_type, gateway_name, unique_id, body, attachments, was_edit):
+ if gateway_type == "xmpp" and gateway_name == self.gateway_name:
+ # Ignore ours
+ return
+
+ muc_jid = self.room_name_to_muc[room_name]
+ puppet_data = self.my_puppets[(gateway_type, gateway_name, unique_id)]
+
+ if len(attachments):
+ body = str(body) + "\n\n" + "\n".join([str(a) for a in attachments])
+
+ def queue_callback():
+ kwargs = {
+ "mto": muc_jid, "mbody": body, "mtype": "groupchat", "mfrom": puppet_data["jid"]
+ }
+ xmpp_msg = self.make_message(**kwargs)
+
+ if msg_unique_id in self.xdm_id_to_base_id:
+ # This message is an edit of an older message
+ xmpp_msg["replace"]["id"] = self.xdm_id_to_base_id[msg_unique_id]
+ else:
+ # New message
+ self.xdm_id_to_base_id[msg_unique_id] = xmpp_msg["id"]
+ self.base_id_to_xdm_id[xmpp_msg["id"]] = msg_unique_id
+
+ self.xdm_id_to_origin_id[msg_unique_id] = msg_unique_id.hex()
+ self.origin_id_to_xdm_id[msg_unique_id.hex()] = msg_unique_id
+ xmpp_msg["origin_id"]["id"] = msg_unique_id.hex()
+
+ print(xmpp_msg)
+ xmpp_msg.send()
+ puppet_data["queue"].append(queue_callback)
+ PLEXUS.sub("message", message)
+
+ def message_delete(msg_unique_id, room_name, gateway_type, gateway_name, unique_id):
+ if gateway_type == "xmpp" and gateway_name == self.gateway_name:
+ # Ignore ours
+ return
+
+ muc_jid = self.room_name_to_muc[room_name]
+ puppet_data = self.my_puppets[(gateway_type, gateway_name, unique_id)]
+
+ def queue_callback():
+ if msg_unique_id in self.xdm_id_to_origin_id:
+ retractee_id = self.xdm_id_to_origin_id[msg_unique_id]
+ else:
+ retractee_id = self.xdm_id_to_stanza_id[msg_unique_id]
+ self.plugin["xep_0424"].send_retraction(mfrom = puppet_data["jid"], mto = muc_jid, mtype = "groupchat", id = retractee_id)
+ puppet_data["queue"].append(queue_callback)
+ PLEXUS.sub("message_delete", message_delete)
+
+ PLEXUS.sub("ready", lambda: self.connect())
+
+ asyncio.get_event_loop().create_task(self.step_puppets())
+
+ # Puppets work as asynchronous state machines in the XMPP gateway,
+ # because setting them up requires a few "handshakes" such as making
+ # sure they've successfully reserved a nickname in each room.
+ # If a puppet has not successfully joined all rooms, messages are
+ # stored in a queue before being sent.
+ async def step_puppets(self):
+ while True:
+ for puppet_data in self.my_puppets.values():
+ puppet_jid = puppet_data["jid"]
+
+ if all([nn["state"] == "joined" for nn in puppet_data["nicknames"].values()]):
+ # We are good for doing stuff
+ if len(puppet_data["queue"]):
+ cb = puppet_data["queue"].pop(0)
+ cb()
+ else:
+ # We are still looking for nicknames
+ for muc_jid, nn in puppet_data["nicknames"].items():
+ if nn["state"] == "failed":
+ nn["nick"] = nn["nick"] + " (real)"
+ nn["state"] = "joining"
+
+ if nn["state"] == "joining":
+ stanz = self.plugin["xep_0045"].make_join_stanza(muc_jid, nick = nn["nick"], presence_options = PresenceArgs(pstatus = "", pshow = "chat", pfrom = puppet_jid))
+ stanz.send()
+ nn["state"] = "waiting"
+
+ await asyncio.sleep(0.2)
+
+ def session_start(self, ev):
+ for room_name, room_gateways in self.settings.ROOMS.items():
+ for gateway in room_gateways:
+ if "xmpp" in gateway and gateway["xmpp"] == self.gateway_name:
+ self.relevant_mucs[gateway["jid"]] = gateway
+ self.room_name_to_muc[room_name] = gateway["jid"]
+ self.muc_to_room_name[gateway["jid"]] = room_name
+ self.plugin["xep_0045"].join_muc(gateway["jid"], gateway["nick"], pfrom = self.pfrom)
+ self.xmpp_ready = True
+
+ def message(self, msg):
+ if msg["to"].full != self.pfrom:
+ return
+ if msg["from"].bare not in self.relevant_mucs:
+ return
+ if msg["from"].resource in [p["nicknames"][msg["from"].bare]["nick"] for p in self.my_puppets.values()]:
+ return
+
+ if "DIE DIE DIE" in msg["body"]:
+ sys.exit(0)
+
+ if msg["replace"]["id"]:
+ if msg["replace"]["id"] not in self.base_id_to_xdm_id:
+ # Too late
+ return
+
+ msg_unique_id = self.base_id_to_xdm_id[msg["replace"]["id"]]
+ PLEXUS.pub("message", msg_unique_id, self.muc_to_room_name[msg["from"].bare], f"{msg['from'].resource}", "xmpp", self.gateway_name, (msg["from"].bare, msg["from"].resource), msg["body"], [], True)
+
+ return
+
+ msg_unique_id = os.urandom(16)
+
+ self.xdm_id_to_base_id[msg_unique_id] = msg["id"]
+ self.base_id_to_xdm_id[msg["id"]] = msg_unique_id
+
+ self.xdm_id_to_stanza_id[msg_unique_id] = msg["stanza_id"]["id"]
+ self.stanza_id_to_xdm_id[msg["stanza_id"]["id"]] = msg_unique_id
+
+ PLEXUS.pub("message", msg_unique_id, self.muc_to_room_name[msg["from"].bare], f"{msg['from'].resource}", "xmpp", self.gateway_name, (msg["from"].bare, msg["from"].resource), msg["body"], [], False)
+
+ def groupchat_presence(self, presence):
+ if presence["from"].bare not in self.relevant_mucs:
+ return
+ # Access to the true JID is why the xdm bridge must be a moderator in the room
+ item_stanza = presence.xml.find(".//{http://jabber.org/protocol/muc#user}item")
+ if item_stanza is not None:
+ jid = item_stanza.attrib.get("jid")
+ if jid and re.match(re.compile(f".*?@{re.escape(self.pfrom)}/mirror"), jid):
+ return
+ if presence["to"] != self.pfrom:
+ return
+ if presence["from"].resource == self.relevant_mucs[presence["from"].bare]["nick"]:
+ return
+
+ PLEXUS.pub("new_user", ("xmpp", self.gateway_name, (presence["from"].bare, presence["from"].resource)), presence["from"].resource)
+
+ def nickname_conflict(self, presence):
+ # FAILURE CASE:
+ #
+ #
+ #
+ #
+ #
+ # SUCCESS CASE:
+ #
+ # chat
+ #
+ #
+ #
+ #
+ #
+
+ err = presence.xml.find(".//{urn:ietf:params:xml:ns:xmpp-stanzas}conflict/..")
+ if err is not None:
+ for puppet_data in self.my_puppets.values():
+ if puppet_data["jid"] == presence["to"].full:
+ for muc_jid, nn in puppet_data["nicknames"].items():
+ if muc_jid == presence["from"].bare:
+ nn["state"] = "failed"
+ elif presence["to"].full == self.pfrom and presence.xml.find(".//{http://jabber.org/protocol/muc#user}x/{http://jabber.org/protocol/muc#user}item") is not None:
+ for puppet_data in self.my_puppets.values():
+ for muc_jid, nn in puppet_data["nicknames"].items():
+ if presence["from"].bare == muc_jid and presence["from"].resource == nn["nick"] and nn["state"] == "waiting":
+ nn["state"] = "joined"
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..ce17d94
--- /dev/null
+++ b/main.py
@@ -0,0 +1,35 @@
+import os, re, hashlib, sys, traceback
+import asyncio
+
+import gateway_xmpp
+import gateway_discord
+
+import settings
+
+class Plexus:
+ def __init__(self):
+ self.pubsub_callbacks = {}
+
+ def sub(self, event_name, callback):
+ if event_name not in self.pubsub_callbacks:
+ self.pubsub_callbacks[event_name] = []
+ self.pubsub_callbacks[event_name].append(callback)
+
+ def pub(self, event_name, *args):
+ print(event_name, args)
+ if event_name in self.pubsub_callbacks:
+ for callback in self.pubsub_callbacks[event_name]:
+ try:
+ callback(*args)
+ except e:
+ print(traceback.format_exc())
+PLEXUS = Plexus()
+
+for name, data in settings.XMPPs.items():
+ data["handler"] = gateway_xmpp.GatewayXMPP(PLEXUS, settings, name, data)
+for name, data in settings.DISCORDs.items():
+ data["handler"] = gateway_discord.GatewayDiscord(PLEXUS, settings, name, data)
+
+PLEXUS.pub("ready")
+
+asyncio.get_event_loop().run_forever()
diff --git a/settings.py b/settings.py
new file mode 100644
index 0000000..05ccea2
--- /dev/null
+++ b/settings.py
@@ -0,0 +1,51 @@
+# This configuration file is itself a Python module so beware
+
+# The bridge theoretically supports multiple gateways per protocol,
+# but this is untested and all of them are named "main"
+
+# For XMPP, the bridge needs to be a separate component (XEP-0114)
+# The bridge will connect to each MUC and it *MUST* BE A MODERATOR!!
+XMPPs = {
+ "main": {
+ # Bridge component JID
+ "jid": "bridge.underware.dev",
+ # Component secret (can be weak if the server-component link is local)
+ "secret": "myprivates",
+
+ # XMPP server address
+ "server": "127.0.0.1",
+ "port": 5347
+ }
+}
+
+DISCORDs = {
+ "main": {
+ # Bot token
+ "token": "myprivates"
+ }
+}
+
+# A Discord channel, XMPP MUC, Matrix room, etc. are all considered one "room" in xdm.
+# Each room must be uniquely named
+ROOMS = {
+ "nectar": [
+ # List of gateways this room bridges to
+ {
+ # Use the XMPP gateway "main"
+ "xmpp": "main",
+
+ # JID of the MUC
+ "jid": "bridge-testing@muc.underware.dev",
+ # Nickname for the bridge
+ "nick": "Bridge",
+ },
+ {
+ # Use the Discord gateway "main"
+ "discord": "main",
+
+ # You want a fucking textbook? What do you think this is
+ "guild": 123123696942042069,
+ "channel": 800813580081351010,
+ }
+ ]
+}