From 277dcab40d906ef25379b3289dfdb42266c22538 Mon Sep 17 00:00:00 2001 From: mid <> Date: Sun, 22 Feb 2026 23:44:32 +0200 Subject: [PATCH] Attempt to bridge avatars to XMPP (still not ideal) --- gateway_xmpp/__init__.py | 106 ++++++++++++++++++++++++++++++++------- 1 file changed, 87 insertions(+), 19 deletions(-) diff --git a/gateway_xmpp/__init__.py b/gateway_xmpp/__init__.py index 1f520f1..689433b 100644 --- a/gateway_xmpp/__init__.py +++ b/gateway_xmpp/__init__.py @@ -5,7 +5,20 @@ from slixmpp.componentxmpp import ComponentXMPP from slixmpp.types import PresenceArgs import slixmpp.stanza import slixmpp.plugins.xep_0084.stanza +import slixmpp.plugins.xep_0060.stanza +import slixmpp.plugins.xep_0292.stanza +import slixmpp.plugins.xep_0153.stanza +import xml.etree.ElementTree as ET import base64, time +import magic + +# Avatars were one of the hardest things to get right in this gateway. +# At least Gajim works with the following protocol: +# 1. If a puppet joins or changes their avatar, we send a presence stanza with the new avatar hash (XEP-0153) +# 2. Then the client notices the update and requests the new avatar +# 3. If a native user (non-puppet) joins a room, they *WONT* query the XEP-0153 or XEP-0054 avatars, but they'll query the vCard4 avatars! +# Therefore, this gateway has to support both XEP-0153, XEP-0054 and vCard4 requests + NON_BMP_RE = re.compile(u"[^\U00000000-\U0000d7ff\U0000e000-\U0000ffff]", flags=re.UNICODE) def non_bmp(s): return NON_BMP_RE.sub(u'', s) @@ -56,7 +69,10 @@ class GatewayXMPP(ComponentXMPP): self.register_plugin('xep_0359') # Stanza IDs self.register_plugin('xep_0308') # Message correction self.register_plugin('xep_0424') # Message retraction - self.register_plugin('xep_0084') # user avatar + + #self.register_plugin('xep_0084') # user avatar + self.register_plugin('xep_0054') # vcard-temp + self.register_plugin('xep_0153') # vcard-temp avatars update def new_user(identification, nick_name): if identification[0] == "xmpp" and identification[1] == self.gateway_name: @@ -75,7 +91,7 @@ class GatewayXMPP(ComponentXMPP): # Messages are added and executed as separate steps "queue": [], - "avatar": {"state": "ready", "desired": None, "timeout": 0} + "avatar": None } self.my_puppets[identification] = puppet_data PLEXUS.sub("new_user", new_user) @@ -91,17 +107,33 @@ class GatewayXMPP(ComponentXMPP): if len(attachments): body = str(body) + "\n\n" + "\n".join([str(a) for a in attachments]) - if avatar and (puppet_data["avatar"]["desired"] is None): - puppet_data["avatar"]["desired"] = avatar - puppet_data["avatar"]["state"] = "waiting" - puppet_data["avatar"]["timeout"] = time.time_ns() - def queue_callback(): kwargs = { "mto": muc_jid, "mbody": body, "mtype": "groupchat", "mfrom": puppet_data["jid"] } xmpp_msg = self.make_message(**kwargs) + if puppet_data["avatar"] != avatar: + puppet_data["avatar"] = avatar + + # Advertise new puppet avatar + + def cb(f): + if not f.exception(): + stanz = self.plugin["xep_0045"].make_join_stanza(muc_jid, nick = puppet_data["nicknames"][muc_jid]["nick"], presence_options = PresenceArgs(pstatus = "", pshow = "chat", pfrom = puppet_data["jid"])) + + x = ET.SubElement(stanz.xml, "{vcard-temp:x:update}x") + photo = ET.SubElement(x, "{vcard-temp:x:update}photo") + photo.text = str(hashlib.sha1(avatar).hexdigest()) + + print("NEW PUPPET AVATAR", ET.tostring(stanz.xml, encoding = "unicode")) + + stanz.send() + + # Although xep_0153 plugin does send_last_presence, it is not aware of the MUCs we are connected to, so we do that ourselves in cb + # This calls set_avatar per each room, when it should be done only once, but whatever + self.plugin["xep_0153"].set_avatar(jid = puppet_data["jid"], avatar = avatar).add_done_callback(cb) + 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] @@ -155,7 +187,7 @@ class GatewayXMPP(ComponentXMPP): # stored in a queue before being sent. def is_puppet_ready(self, puppet_data): - return puppet_data["avatar"]["state"] == "ready" and all([nn["state"] == "joined" for nn in puppet_data["nicknames"].values()]) + return all([nn["state"] == "joined" for nn in puppet_data["nicknames"].values()]) async def step_puppets(self): while True: @@ -167,13 +199,7 @@ class GatewayXMPP(ComponentXMPP): cb = puppet_data["queue"].pop(0) cb() else: - # We are still looking for nicknames OR trying to setup an avatar - - if puppet_data["avatar"]["state"] == "waiting" and time.time_ns() >= puppet_data["avatar"]["timeout"]: - pass - - puppet_data["avatar"]["state"] = "ready" - + # We are still looking for nicknames for muc_jid, nn in puppet_data["nicknames"].items(): if nn["state"] == "failed": nn["nick"] = nn["nick"] + " (real)" @@ -226,9 +252,6 @@ class GatewayXMPP(ComponentXMPP): 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}", self.avatar_cache[msg["from"].full], "xmpp", self.gateway_name, (msg["from"].bare, msg["from"].resource), msg["body"], [], False) - - if msg["from"].full not in self.avatar_cache: - # First try loading avatar before sending message def groupchat_presence(self, presence): if presence["from"].bare not in self.relevant_mucs: @@ -251,12 +274,57 @@ class GatewayXMPP(ComponentXMPP): data = stanza.xml.find(".//{urn:xmpp:avatar:data}data") if data is not None: self.avatar_cache[presence["from"].full] = base64.standard_b64decode("".join(data.itertext())) - self.plugin["xep_0060"].get_items(jid = presence["from"].full, node = "urn:xmpp:avatar:data", ifrom = self.pfrom, callback = got_avatar, max_items = 1) + # We need this callback to ensure exceptions are handled + def cb(fut): + fut.exception() + self.plugin["xep_0060"].get_items(jid = presence["from"].full, node = "urn:xmpp:avatar:data", ifrom = self.pfrom, callback = got_avatar, max_items = 1).add_done_callback(cb) PLEXUS.pub("new_user", ("xmpp", self.gateway_name, (presence["from"].bare, presence["from"].resource)), presence["from"].resource) def iq_funker(self, iq): print("GOT IQ", iq) + if iq["type"] == "get" and iq.xml.find(".//{vcard-temp}vCard") is not None: + for puppet_identification, puppet_data in self.my_puppets.items(): + if puppet_data["jid"] == iq["to"].full + "/mirror": + ret = self.Iq() + ret["id"] = iq["id"] + ret["to"] = iq["from"] + ret["from"] = iq["to"] + ret["type"] = "result" + ret["vcard_temp"]["PHOTO"]["BINVAL"] = puppet_data['avatar']#base64.standard_b64encode(puppet_data['avatar']).decode() + + print(ET.tostring(ret.xml, encoding = "unicode")) + + ret.send() + + break + + if iq["type"] == "get" and (iq.xml.find(".//{http://jabber.org/protocol/pubsub}pubsub/{http://jabber.org/protocol/pubsub}items[@node='urn:xmpp:vcard4']") is not None): + for puppet_identification, puppet_data in self.my_puppets.items(): + if puppet_data["jid"] == iq["to"].full + "/mirror": + ret = self.Iq() + ret["id"] = iq["id"] + ret["to"] = iq["from"] + ret["from"] = iq["to"] + ret["type"] = "result" + ret["pubsub"]["items"]["node"] = "urn:xmpp:vcard4" + + vcard = slixmpp.plugins.xep_0292.stanza.VCard4() + vcard.set_full_name(str(puppet_identification[-1])) + if puppet_data["avatar"]: + photo = ET.SubElement(vcard.xml, "{urn:ietf:params:xml:ns:vcard-4.0}photo") + uri = ET.SubElement(photo, "{urn:ietf:params:xml:ns:vcard-4.0}uri") + uri.text = f"data:{magic.from_buffer(puppet_data['avatar'], mime = True)};base64,{base64.standard_b64encode(puppet_data['avatar']).decode()}" + print(ET.tostring(vcard.xml, encoding = "unicode")) + + item = slixmpp.plugins.xep_0060.stanza.Item() + item["id"] = hashlib.sha256((puppet_data["jid"] + " avatar lol").encode()).hexdigest() + item["payload"] = vcard + ret["pubsub"]["items"].append(item) + + ret.send() + + break def nickname_conflict(self, presence): # FAILURE CASE: