From a4a243cd4167d2ee698481971cbed3fcd2ae054b Mon Sep 17 00:00:00 2001 From: Stephen Paul Weber Date: Thu, 10 Oct 2024 22:03:05 -0500 Subject: [PATCH] Iniital bob-custom-emoji support Store and use XHTML-IM, replacing any BoB-URIs with ni:/// URIs (or optionally /.well-known/ni/ paths). Fetch content for bob items when message first comes in. Open questions: how to trigger retry for failed fetch? TODO: don't fetch from untrusted JID and thus reveal presence. Question: how to trigger retry for fetch after permissions of JID change? Question: how to let UI know we have the image now and it can try to fetch the data again/redraw? --- snikket/Chat.hx | 3 +++ snikket/ChatMessage.hx | 43 ++++++++++++++++++++++++++++++++++++++++++ snikket/Client.hx | 35 ++++++++++++++++++++++++++++++++++ snikket/Message.hx | 5 +++++ snikket/Stanza.hx | 21 +++++++++++++++++++++ 5 files changed, 107 insertions(+) diff --git a/snikket/Chat.hx b/snikket/Chat.hx index 17cf8ac..9aeb41d 100644 --- a/snikket/Chat.hx +++ b/snikket/Chat.hx @@ -891,6 +891,9 @@ class Channel extends Chat { for (m in messageList.messages) { switch (m) { case ChatMessageStanza(message): + for (hash in message.inlineHashReferences()) { + client.fetchMediaByHash([hash], [message.from]); + } promises.push(new thenshim.Promise((resolve, reject) -> { persistence.storeMessage(client.accountId(), message, resolve); })); diff --git a/snikket/ChatMessage.hx b/snikket/ChatMessage.hx index be2691d..e4ec44c 100644 --- a/snikket/ChatMessage.hx +++ b/snikket/ChatMessage.hx @@ -205,10 +205,53 @@ class ChatMessage { Reflect.setField(this, "localId", null); } + @:allow(snikket) + private function inlineHashReferences(): Array { + final result = []; + final htmlBody = payloads.find((p) -> p.attr.get("xmlns") == "http://jabber.org/protocol/xhtml-im" && p.name == "html")?.getChild("body", "http://www.w3.org/1999/xhtml"); + if (htmlBody != null) { + htmlBody.traverse(child -> { + if (child.name == "img") { + final src = child.attr.get("src"); + if (src != null) { + final hash = Hash.fromUri(src); + if (hash != null) { + final x:Hash = hash; + result.push(x); + } + } + return true; + } + return false; + }); + } + + return result; + } + /** Get HTML version of the message body + + WARNING: this is possibly untrusted HTML. You must parse or sanitize appropriately! **/ public function html():String { + final htmlBody = payloads.find((p) -> p.attr.get("xmlns") == "http://jabber.org/protocol/xhtml-im" && p.name == "html")?.getChild("body", "http://www.w3.org/1999/xhtml"); + if (htmlBody != null) { + return htmlBody.getChildren().map(el -> el.traverse(child -> { + if (child.name == "img") { + final src = child.attr.get("src"); + if (src != null) { + final hash = Hash.fromUri(src); + if (hash != null) { + child.attr.set("src", hash.toUri()); + } + } + return true; + } + return false; + }).serialize()).join(""); + } + final codepoints = StringUtil.codepointArray(text ?? ""); // TODO: not every app will implement every feature. How should the app tell us what fallbacks to handle? final fallbacks: Array<{start: Int, end: Int}> = cast payloads.filter( diff --git a/snikket/Client.hx b/snikket/Client.hx index bc1362c..4102aa9 100644 --- a/snikket/Client.hx +++ b/snikket/Client.hx @@ -16,6 +16,7 @@ import snikket.EventHandler; import snikket.PubsubEvent; import snikket.Stream; import snikket.jingle.Session; +import snikket.queries.BoB; import snikket.queries.DiscoInfoGet; import snikket.queries.DiscoItemsGet; import snikket.queries.ExtDiscoGet; @@ -200,6 +201,9 @@ class Client extends EventEmitter { final message = Message.fromStanza(stanza, this.jid); switch (message.parsed) { case ChatMessageStanza(chatMessage): + for (hash in chatMessage.inlineHashReferences()) { + fetchMediaByHash([hash], [chatMessage.from]); + } var chat = getChat(chatMessage.chatId()); if (chat == null && stanza.attr.get("type") != "groupchat") chat = getDirectChat(chatMessage.chatId()); if (chat != null) { @@ -1011,6 +1015,37 @@ class Client extends EventEmitter { stream.sendStanza(new Stanza("inactive", { xmlns: "urn:xmpp:csi:0" })); } + @:allow(snikket) + private function fetchMediaByHash(hashes: Array, counterparts: Array) { + // TODO: only for counterparts who can infer our presence + // So MUCs, roster entires, anyone we've sent a message to in the past (from this client?) + if (hashes.length < 1 || counterparts.length < 1) return thenshim.Promise.reject("no counterparts left"); + return fetchMediaByHashOneCounterpart(hashes, counterparts[0]).then(x -> x, (_) -> fetchMediaByHash(hashes, counterparts.slice(1))); + } + + private function fetchMediaByHashOneCounterpart(hashes: Array, counterpart: JID) { + if (hashes.length < 1) return thenshim.Promise.reject("no hashes left"); + + return new thenshim.Promise((resolve, reject) -> + persistence.hasMedia(hashes[0].algorithm, hashes[0].hash, resolve) + ).then (has -> { + if (has) return thenshim.Promise.resolve(null); + + return new thenshim.Promise((resolve, reject) -> { + final q = BoB.forHash(counterpart.asString(), hashes[0]); + q.onFinished(() -> { + final r = q.getResult(); + if (r == null) { + reject("bad or no result from BoB query"); + } else { + persistence.storeMedia(r.type, r.bytes.getData(), () -> resolve(null)); + } + }); + sendQuery(q); + }).then(x -> x, (_) -> fetchMediaByHashOneCounterpart(hashes.slice(1), counterpart)); + }); + } + @:allow(snikket) private function chatActivity(chat: Chat, trigger = true) { if (chat.uiState == Closed) { diff --git a/snikket/Message.hx b/snikket/Message.hx index 1c7a079..d9dfb51 100644 --- a/snikket/Message.hx +++ b/snikket/Message.hx @@ -180,6 +180,11 @@ class Message { msg.payloads.push(unstyled); } + final html = stanza.getChild("html", "http://jabber.org/protocol/xhtml-im"); + if (html != null) { + msg.payloads.push(html); + } + final reply = stanza.getChild("reply", "urn:xmpp:reply:0"); if (reply != null) { final replyToJid = reply.attr.get("to"); diff --git a/snikket/Stanza.hx b/snikket/Stanza.hx index d29c324..ba95bf6 100644 --- a/snikket/Stanza.hx +++ b/snikket/Stanza.hx @@ -15,6 +15,7 @@ typedef NodeList = Array; private interface NodeInterface { public function serialize():String; public function clone():NodeInterface; + public function traverse(f: (Stanza)->Bool):NodeInterface; } class TextNode implements NodeInterface { @@ -32,6 +33,10 @@ class TextNode implements NodeInterface { public function clone():TextNode { return new TextNode(this.content); } + + public function traverse(f: (Stanza)->Bool) { + return this; + } } @:expose @@ -206,6 +211,13 @@ class Stanza implements NodeInterface { return allTags()[0]; } + public function getChildren():Array { + return children.map(child -> switch(child) { + case Element(el): el; + case CData(text): text; + }); + } + public function getChild(?name:Null, ?xmlns:Null):Null { var ourXmlns = this.attr.get("xmlns"); /* @@ -294,6 +306,15 @@ class Stanza implements NodeInterface { case _: null; }; } + + public function traverse(f: (Stanza)->Bool) { + if (!f(this)) { + for (child in allTags()) { + child.traverse(f); + } + } + return this; + } } enum IqRequestType {