Initial commit
This commit is contained in:
commit
caf5871ab0
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
__pycache__
|
||||
177
gateway_discord/__init__.py
Normal file
177
gateway_discord/__init__.py
Normal file
@ -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()
|
||||
235
gateway_xmpp/__init__.py
Normal file
235
gateway_xmpp/__init__.py
Normal file
@ -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:
|
||||
# <presence id="75220a58e9d74eb08c6945752bdec1ef" to="-0x6f6c50dd4094cb51@bridge.underware.dev/mirror" from="bridge-testing@muc.underware.dev/mid" type="error">
|
||||
# <error by="bridge-testing@muc.underware.dev" type="cancel">
|
||||
# <conflict xmlns="urn:ietf:params:xml:ns:xmpp-stanzas" />
|
||||
# </error>
|
||||
# </presence>
|
||||
# SUCCESS CASE:
|
||||
# <presence id="d2992e01ce2e46e29a3248aa00356e91" to="bridge.underware.dev" from="bridge-testing@muc.underware.dev/mid (real)" xml:lang="en">
|
||||
# <show>chat</show>
|
||||
# <occupant-id xmlns="urn:xmpp:occupant-id:0" id="CxI07KA47pQXsp5mGNF8jZGc8eBLWX0gov6TmQTqBc4=" />
|
||||
# <x xmlns="http://jabber.org/protocol/muc#user">
|
||||
# <item role="participant" affiliation="none" jid="d02d29cccd9585fc63fd8054@bridge.underware.dev/mirror" />
|
||||
# </x>
|
||||
# </presence>
|
||||
|
||||
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"
|
||||
35
main.py
Normal file
35
main.py
Normal file
@ -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()
|
||||
51
settings.py
Normal file
51
settings.py
Normal file
@ -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,
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user