From caf5871ab0520644600698cca4c723cc66aca5d5 Mon Sep 17 00:00:00 2001 From: mid <> Date: Thu, 25 Dec 2025 14:39:18 +0200 Subject: [PATCH] Initial commit --- .gitignore | 1 + gateway_discord/__init__.py | 177 +++++++++++++++++++++++++++ gateway_xmpp/__init__.py | 235 ++++++++++++++++++++++++++++++++++++ main.py | 35 ++++++ settings.py | 51 ++++++++ 5 files changed, 499 insertions(+) create mode 100644 .gitignore create mode 100644 gateway_discord/__init__.py create mode 100644 gateway_xmpp/__init__.py create mode 100644 main.py create mode 100644 settings.py 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, + } + ] +}