| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2023-10-18 16:22:54 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2023-10-18 16:23:06 UTC |
| parent | f0bf34df23e0e3f6c9a6f566df2556a0331d2ffe |
| xmpp/Caps.hx | +1 | -1 |
| xmpp/Chat.hx | +209 | -29 |
| xmpp/ChatMessage.hx | +9 | -9 |
| xmpp/Client.hx | +73 | -22 |
| xmpp/persistence/browser.js | +1 | -1 |
| xmpp/queries/DiscoInfoGet.hx | +1 | -1 |
diff --git a/xmpp/Caps.hx b/xmpp/Caps.hx index 5208627..f1b0b1c 100644 --- a/xmpp/Caps.hx +++ b/xmpp/Caps.hx @@ -8,7 +8,7 @@ using Lambda; @:expose class Caps { private final node: String; - private final identities: Array<Identity>; + public final identities: Array<Identity>; public final features : Array<String>; // TODO: data forms diff --git a/xmpp/Chat.hx b/xmpp/Chat.hx index 922c934..d91bd23 100644 --- a/xmpp/Chat.hx +++ b/xmpp/Chat.hx @@ -9,6 +9,7 @@ import xmpp.ID; import xmpp.MessageSync; import xmpp.jingle.PeerConnection; import xmpp.jingle.Session; +import xmpp.queries.DiscoInfoGet; import xmpp.queries.MAMQuery; using Lambda; @@ -21,22 +22,40 @@ abstract class Chat { private var trusted:Bool = false; public var chatId(default, null):String; public var jingleSessions: Map<String, xmpp.jingle.Session> = []; + private var displayName:String; - private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String) { + public function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String) { this.client = client; this.stream = stream; this.persistence = persistence; this.chatId = chatId; + this.displayName = chatId; } + abstract public function prepareIncomingMessage(message:ChatMessage, stanza:Stanza):ChatMessage; + abstract public function sendMessage(message:ChatMessage):Void; abstract public function getMessages(beforeId:Null<String>, beforeTime:Null<String>, handler:(Array<ChatMessage>)->Void):Void; - abstract public function getDisplayName():String; - abstract public function getParticipants():Array<String>; + abstract public function getParticipantDetails(participantId:String, callback:({photoUri:String, displayName:String})->Void):Void; + + abstract public function bookmark():Void; + + public function getPhoto(callback:(String)->Void) { + callback(Color.defaultPhoto(chatId, getDisplayName().charAt(0))); + } + + public function setDisplayName(fn:String) { + this.displayName = fn; + } + + public function getDisplayName() { + return this.displayName; + } + public function setCaps(resource:String, caps:Caps) { this.caps.set(resource, caps); } @@ -49,6 +68,10 @@ abstract class Chat { return caps[resource]; } + public function setAvatarSha1(sha1: BytesData) { + this.avatarSha1 = sha1; + } + public function setTrusted(trusted:Bool) { this.trusted = trusted; } @@ -134,26 +157,17 @@ abstract class Chat { @:expose class DirectChat extends Chat { - private var displayName:String; - public function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String) { - super(client, stream, persistence, chatId); - this.displayName = chatId; - } - - public function setDisplayName(fn:String) { - this.displayName = fn; - } - - public function getDisplayName() { - return this.displayName; - } - public function getParticipants() { return chatId.split("\n"); } + public function getParticipantDetails(participantId:String, callback:({photoUri:String, displayName:String})->Void) { + final chat = client.getDirectChat(participantId); + chat.getPhoto((photoUri) -> callback({ photoUri: photoUri, displayName: chat.getDisplayName() })); + } + public function getMessages(beforeId:Null<String>, beforeTime:Null<String>, handler:(Array<ChatMessage>)->Void):Void { - persistence.getMessages(client.jid, chatId, beforeId, beforeTime, (messages) -> { + persistence.getMessages(client.accountId(), chatId, beforeId, beforeTime, (messages) -> { if (messages.length > 0) { handler(messages); } else { @@ -171,6 +185,10 @@ class DirectChat extends Chat { }); } + public function prepareIncomingMessage(message:ChatMessage, stanza:Stanza) { + return message; + } + public function sendMessage(message:ChatMessage):Void { client.chatActivity(this); message.recipients = getParticipants().map((p) -> JID.parse(p)); @@ -182,7 +200,7 @@ class DirectChat extends Chat { public function bookmark() { stream.sendIq( - new Stanza("iq", { type: "set", id: ID.short() }) + new Stanza("iq", { type: "set" }) .tag("query", { xmlns: "jabber:iq:roster" }) .tag("item", { jid: chatId }) .up().up(), @@ -194,23 +212,180 @@ class DirectChat extends Chat { ); } - public function setAvatarSha1(sha1: BytesData) { - this.avatarSha1 = sha1; - } - - public function getPhoto(callback:(String)->Void) { + override public function getPhoto(callback:(String)->Void) { if (avatarSha1 != null) { persistence.getMediaUri("sha-1", avatarSha1, (uri) -> { if (uri != null) { callback(uri); } else { - callback(Color.defaultPhoto(chatId, chatId.charAt(0))); + callback(Color.defaultPhoto(chatId, getDisplayName().charAt(0))); + } + }); + } else { + super.getPhoto(callback); + } + } +} + +@:expose +class Channel extends Chat { + private var disco: Caps = new Caps("", [], ["http://jabber.org/protocol/muc"]); // TODO: persist this + + public function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, ?disco: Caps) { + super(client, stream, persistence, chatId); + if (disco != null) this.disco = disco; + selfPing(disco == null); + } + + public function selfPing(shouldRefreshDisco = true) { + stream.sendIq( + new Stanza("iq", { type: "get", to: getFullJid().asString() }) + .tag("ping", { xmlns: "urn:xmpp:ping" }).up(), + (response) -> { + if (response.attr.get("type") == "error") { + final err = response.getChild("error")?.getChild(null, "urn:ietf:params:xml:ns:xmpp-stanzas"); + if (err.name == "service-unavailable" || err.name == "feature-not-implemented") return; // Error, success! + if (err.name == "remote-server-not-found" || err.name == "remote-server-timeout") return; // Timeout, retry later + if (err.name == "item-not-found") return; // Nick was changed? + (shouldRefreshDisco ? refreshDisco : (cb)->cb())(() -> { + client.sendPresence( + getFullJid().asString(), + (stanza) -> { + stanza.tag("x", { xmlns: "http://jabber.org/protocol/muc" }); + if (disco.features.contains("urn:xmpp:mam:2")) stanza.tag("history", { maxchars: "0" }).up(); + // TODO: else since (last message we know about) + stanza.up(); + return stanza; + } + ); + }); } + } + ); + } + + public function refreshDisco(?callback: ()->Void) { + final discoGet = new DiscoInfoGet(chatId); + discoGet.onFinished(() -> { + if (discoGet.getResult() != null) { + disco = discoGet.getResult(); + persistence.storeCaps(discoGet.getResult()); + persistence.storeChat(client.accountId(), this); + } + if (callback != null) callback(); + }); + client.sendQuery(discoGet); + } + + private function getFullJid() { + final jid = JID.parse(chatId); + return new JID(jid.node, jid.domain, client.displayName()); + } + + public function getParticipants() { + final jid = JID.parse(chatId); + return caps.keys().map((resource) -> new JID(jid.node, jid.domain, resource).asString()); + } + + public function getParticipantDetails(participantId:String, callback:({photoUri:String, displayName:String})->Void) { + if (participantId == getFullJid().asString()) { + client.getDirectChat(client.accountId(), false).getPhoto((photoUri) -> { + callback({ photoUri: photoUri, displayName: client.displayName() }); }); } else { - callback(Color.defaultPhoto(chatId, chatId.charAt(0))); + final nick = JID.parse(participantId).resource; + final photoUri = Color.defaultPhoto(participantId, nick.charAt(0)); + callback({ photoUri: photoUri, displayName: nick }); } } + + public function getMessages(beforeId:Null<String>, beforeTime:Null<String>, handler:(Array<ChatMessage>)->Void):Void { + persistence.getMessages(client.accountId(), chatId, beforeId, beforeTime, (messages) -> { + if (messages.length > 0) { + handler(messages); + } else { + var filter:MAMQueryParams = {}; + if (beforeId != null) filter.page = { before: beforeId }; + var sync = new MessageSync(this.client, this.stream, filter, chatId); + sync.onMessages((messages) -> { + for (message in messages.messages) { + message = prepareIncomingMessage(message, new Stanza("message", { from: message.senderId() })); + trace("WUT", message); + persistence.storeMessage(client.jid, message); + } + handler(messages.messages.filter((m) -> m.chatId() == chatId)); + }); + sync.fetchNext(); + } + }); + } + + public function prepareIncomingMessage(message:ChatMessage, stanza:Stanza) { + // TODO: mark type!=groupchat as whisper somehow + message.sender = JID.parse(stanza.attr.get("from")); // MUC always needs full JIDs + if (message.senderId() == getFullJid().asString()) { + message.recipients = message.replyTo; + message.direction = MessageSent; + } + return message; + } + + public function sendMessage(message:ChatMessage):Void { + client.chatActivity(this); + message.to = JID.parse(chatId); + client.sendStanza(message.asStanza("groupchat")); + } + + public function bookmark() { + // TODO: we should have been created from an existing bookmark if there was one, + // and we need to be sure to preserve everything we don't mean to change if this is an update + stream.sendIq( + new Stanza("iq", { type: "set" }) + .tag("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" }) + .tag("publish", { node: "urn:xmpp:bookmarks:1" }) + .tag("item", { id: chatId }) + .tag("conference", { xmlns: "urn:xmpp:bookmarks:1", name: getDisplayName(), autojoin: "true" }) + .textTag("nick", client.displayName()) + .up().up() + .tag("publish-options") + .tag("x", { xmlns: "jabber:x:data", type: "submit" }) + .tag("field", { "var": "FORM_TYPE", type: "hidden" }).textTag("value", "http://jabber.org/protocol/pubsub#publish-options").up() + .tag("field", { "var": "pubsub#persist_items" }).textTag("value", "true").up() + .tag("field", { "var": "pubsub#max_items" }).textTag("value", "max").up() + .tag("field", { "var": "pubsub#send_last_published_item" }).textTag("value", "never").up() + .tag("field", { "var": "pubsub#access_model" }).textTag("value", "whitelist").up() + .tag("field", { "var": "pubsub#notify_delete" }).textTag("value", "true").up() + .tag("field", { "var": "pubsub#notify_retract" }).textTag("value", "true").up() + .up().up().up().up(), + (response) -> { + if (response.attr.get("type") == "error") { + final preconditionError = response.getChild("error")?.getChild("precondition-not-met", "http://jabber.org/protocol/pubsub#errors"); + if (preconditionError != null) { + // publish options failed, so force them to be right, what a silly workflow + stream.sendIq( + new Stanza("iq", { type: "set" }) + .tag("pubsub", { xmlns: "http://jabber.org/protocol/pubsub#owner" }) + .tag("configure", { node: "urn:xmpp:bookmarks:1" }) + .tag("x", { xmlns: "jabber:x:data", type: "submit" }) + .tag("field", { "var": "FORM_TYPE", type: "hidden" }).textTag("value", "http://jabber.org/protocol/pubsub#publish-options").up() + .tag("field", { "var": "pubsub#persist_items" }).textTag("value", "true").up() + .tag("field", { "var": "pubsub#max_items" }).textTag("value", "max").up() + .tag("field", { "var": "pubsub#send_last_published_item" }).textTag("value", "never").up() + .tag("field", { "var": "pubsub#access_model" }).textTag("value", "whitelist").up() + .tag("field", { "var": "pubsub#notify_delete" }).textTag("value", "true").up() + .tag("field", { "var": "pubsub#notify_retract" }).textTag("value", "true").up() + .up().up().up(), + (response) -> { + if (response.attr.get("type") == "result") { + bookmark(); + } + } + ); + } + } + } + ); + } } @:expose @@ -231,9 +406,14 @@ class SerializedChat { this.klass = klass; } - public function toDirectChat(client: Client, stream: GenericStream, persistence: Persistence) { - if (klass != "DirectChat") throw "Not a direct chat: " + klass; - final chat = new DirectChat(client, stream, persistence, chatId); + public function toChat(client: Client, stream: GenericStream, persistence: Persistence) { + final chat = if (klass == "DirectChat") { + new DirectChat(client, stream, persistence, chatId); + } else if (klass == "Channel") { + new Channel(client, stream, persistence, chatId); + } else { + throw "Unknown class: " + klass; + } if (displayName != null) chat.setDisplayName(displayName); if (avatarSha1 != null) chat.setAvatarSha1(avatarSha1); chat.setTrusted(trusted); diff --git a/xmpp/ChatMessage.hx b/xmpp/ChatMessage.hx index 4ddbc9f..2670d25 100644 --- a/xmpp/ChatMessage.hx +++ b/xmpp/ChatMessage.hx @@ -25,9 +25,9 @@ class ChatMessage { public var to: Null<JID> = null; private var from: Null<JID> = null; - private var sender: Null<JID> = null; + public var sender: Null<JID> = null; public var recipients: Array<JID> = []; - private var replyTo: Array<JID> = []; + public var replyTo: Array<JID> = []; var threadId (default, null): Null<String> = null; @@ -36,7 +36,7 @@ class ChatMessage { public var text (default, null): Null<String> = null; public var lang (default, null): Null<String> = null; - private var direction: MessageDirection = MessageReceived; + public var direction: MessageDirection = MessageReceived; public function new() { } @@ -51,7 +51,7 @@ class ChatMessage { msg.to = to == null ? null : JID.parse(to); final from = stanza.attr.get("from"); msg.from = from == null ? null : JID.parse(from); - msg.sender = msg.from; + msg.sender = stanza.attr.get("type") == "groupchat" ? msg.from : msg.from?.asBare(); final localJid = JID.parse(localJidStr); final localJidBare = localJid.asBare(); final domain = localJid.domain; @@ -82,7 +82,7 @@ class ChatMessage { recipients[msg.to.asBare().asString()] = true; } if (msg.direction == MessageReceived && msg.from != null) { - replyTo[msg.from.asString()] = true; + replyTo[stanza.attr.get("type") == "groupchat" ? msg.from.asBare().asString() : msg.from.asString()] = true; } else if(msg.to != null) { replyTo[msg.to.asString()] = true; } @@ -109,7 +109,7 @@ class ChatMessage { } else if (address.attr.get("type") == "ofrom") { if (JID.parse(jid).domain == msg.sender?.domain) { // TODO: check that domain supports extended addressing - msg.sender = JID.parse(jid); + msg.sender = JID.parse(jid).asBare(); } } } @@ -158,7 +158,7 @@ class ChatMessage { } public function senderId():String { - return sender?.asBare().asString() ?? throw "sender is null"; + return sender?.asString() ?? throw "sender is null"; } public function account():String { @@ -169,8 +169,8 @@ class ChatMessage { return direction == MessageReceived; } - public function asStanza():Stanza { - var attrs: haxe.DynamicAccess<String> = { type: "chat" }; + public function asStanza(?type: String):Stanza { + var attrs: haxe.DynamicAccess<String> = { type: type ?? "chat" }; if (from != null) attrs.set("from", from.asString()); if (to != null) attrs.set("to", to.asString()); if (localId != null) attrs.set("id", localId); diff --git a/xmpp/Client.hx b/xmpp/Client.hx index 6cff23e..f9b874f 100644 --- a/xmpp/Client.hx +++ b/xmpp/Client.hx @@ -18,15 +18,14 @@ import xmpp.queries.JabberIqGatewayGet; import xmpp.queries.PubsubGet; import xmpp.queries.Push2Enable; import xmpp.queries.RosterGet; - -typedef ChatList = Array<Chat>; +using Lambda; @:expose class Client extends xmpp.EventEmitter { private var stream:GenericStream; private var chatMessageHandlers: Array<(ChatMessage)->Void> = []; public var jid(default,null):String; - private var chats: ChatList = []; + private var chats: Array<Chat> = []; private var persistence: Persistence; private final caps = new Caps( "https://sdk.snikket.org", @@ -57,10 +56,14 @@ class Client extends xmpp.EventEmitter { return JID.parse(jid).asBare().asString(); } + public function displayName() { + return JID.parse(jid).node; + } + public function start() { persistence.getChats(jid, (protoChats) -> { for (protoChat in protoChats) { - chats.push(protoChat.toDirectChat(this, stream, persistence)); + chats.push(protoChat.toChat(this, stream, persistence)); } this.trigger("chats/update", chats); @@ -132,12 +135,16 @@ class Client extends xmpp.EventEmitter { } } - final chatMessage = ChatMessage.fromStanza(stanza, jid); + var chatMessage = ChatMessage.fromStanza(stanza, jid); if (chatMessage != null) { - var chat = getDirectChat(chatMessage.chatId()); - chatActivity(chat); - for (handler in chatMessageHandlers) { - handler(chatMessage); + var chat = getChat(chatMessage.chatId()); + if (chat == null && stanza.attr.get("type") != "groupchat") chat = getDirectChat(chatMessage.chatId()); + if (chat != null) { + chatActivity(chat); + chatMessage = chat.prepareIncomingMessage(chatMessage, stanza); + for (handler in chatMessageHandlers) { + handler(chatMessage); + } } } @@ -317,30 +324,72 @@ class Client extends xmpp.EventEmitter { } /* Return array of chats, sorted by last activity */ - public function getChats():ChatList { + public function getChats():Array<Chat> { return chats; } + // We can ask for caps here because presumably they looked this up + // via findAvailableChats + public function startChat(chatId:String, fn:Null<String>, caps:Caps):Chat { + final existingChat = getChat(chatId); + if (existingChat != null) return existingChat; + + final chat = if (caps.isChannel(chatId)) { + final channel = new Channel(this, this.stream, this.persistence, chatId, caps); + chats.unshift(channel); + channel; + } else { + getDirectChat(chatId, false); + } + if (fn != null) chat.setDisplayName(fn); + persistence.storeChat(accountId(), chat); + this.trigger("chats/update", [chat]); + return chat; + } + + public function getChat(chatId:String):Null<Chat> { + return chats.find((chat) -> chat.chatId == chatId); + } + public function getDirectChat(chatId:String, triggerIfNew:Bool = true):DirectChat { for (chat in chats) { if (Std.isOfType(chat, DirectChat) && chat.chatId == chatId) { return Std.downcast(chat, DirectChat); } } - var chat = new DirectChat(this, this.stream, this.persistence, chatId); + final chat = new DirectChat(this, this.stream, this.persistence, chatId); persistence.storeChat(jid, chat); chats.unshift(chat); if (triggerIfNew) this.trigger("chats/update", [chat]); return chat; } - public function findAvailableChats(q:String, callback:(q:String, chatIds:Array<String>) -> Void) { + public function findAvailableChats(q:String, callback:(q:String, results:Array<{ chatId: String, fn: String, note: String, caps: Caps }>) -> Void) { var results = []; final query = StringTools.trim(q); final jid = JID.parse(query); + final checkAndAdd = (jid) -> { + final discoGet = new DiscoInfoGet(jid.asString()); + discoGet.onFinished(() -> { + final resultCaps = discoGet.getResult(); + if (resultCaps == null) { + final err = discoGet.responseStanza?.getChild("error")?.getChild(null, "urn:ietf:params:xml:ns:xmpp-stanzas"); + if (err == null || err?.name == "service-unavailable" || err?.name == "feature-not-implemented") { + results.push({ chatId: jid.asString(), fn: query, note: jid.asString(), caps: new Caps("", [], []) }); + } + } else { + persistence.storeCaps(resultCaps); + final identity = resultCaps.identities[0]; + final fn = identity?.name ?? query; + final note = jid.asString() + (identity == null ? "" : " (" + identity.type + ")"); + results.push({ chatId: jid.asString(), fn: fn, note: note, caps: resultCaps }); + } + callback(q, results); + }); + sendQuery(discoGet); + }; if (jid.isValid()) { - results.push(jid.asBare().asString()); - callback(q, results); // send some right away + checkAndAdd(jid); } for (chat in chats) { if (chat.isTrusted()) { @@ -359,18 +408,15 @@ class Client extends xmpp.EventEmitter { if (jigGet.getResult() == null) { final caps = chat.getResourceCaps(resource); if (bareJid.isDomain() && caps.features.contains("jid\\20escaping")) { - results.push(new JID(query, bareJid.domain).asString()); - callback(q, results); + checkAndAdd(new JID(query, bareJid.domain)); } else if (bareJid.isDomain()) { - results.push(new JID(StringTools.replace(query, "@", "%"), bareJid.domain).asString()); - callback(q, results); + checkAndAdd(new JID(StringTools.replace(query, "@", "%"), bareJid.domain)); } } else { switch (jigGet.getResult()) { case Left(error): return; case Right(result): - results.push(result); - callback(q, results); + checkAndAdd(JID.parse(result)); } } }); @@ -398,8 +444,13 @@ class Client extends xmpp.EventEmitter { stream.sendStanza(stanza); } - public function sendPresence(?to: String) { - sendStanza(caps.addC(new Stanza("presence", to == null ? {} : { to: to }))); + public function sendPresence(?to: String, ?augment: (Stanza)->Stanza) { + sendStanza( + (augment ?? (s)->s)( + caps.addC(new Stanza("presence", to == null ? {} : { to: to })) + .textTag("nick", displayName(), { xmlns: "http://jabber.org/protocol/nick" }) + ) + ); } #if js diff --git a/xmpp/persistence/browser.js b/xmpp/persistence/browser.js index bf81fbe..200ca7b 100644 --- a/xmpp/persistence/browser.js +++ b/xmpp/persistence/browser.js @@ -83,7 +83,7 @@ exports.xmpp.persistence = { avatarSha1: chat.avatarSha1, caps: chat.caps, displayName: chat.displayName, - class: chat instanceof xmpp.DirectChat ? "DirectChat" : "Chat" + class: chat instanceof xmpp.DirectChat ? "DirectChat" : (chat instanceof xmpp.Channel ? "Channel" : "Chat") }); }, diff --git a/xmpp/queries/DiscoInfoGet.hx b/xmpp/queries/DiscoInfoGet.hx index 6fdfab1..904aad3 100644 --- a/xmpp/queries/DiscoInfoGet.hx +++ b/xmpp/queries/DiscoInfoGet.hx @@ -14,7 +14,7 @@ class DiscoInfoGet extends GenericQuery { public var xmlns(default, null) = "http://jabber.org/protocol/disco#info"; public var queryId:String = null; public var ver:String = null; - private var responseStanza:Stanza; + public var responseStanza(default, null):Stanza; private var result: Caps; public function new(to: String, ?node: String) {