| author | Matthew Wild
<mwild1@gmail.com> 2023-06-05 16:57:08 UTC |
| committer | Matthew Wild
<mwild1@gmail.com> 2023-06-05 16:57:08 UTC |
| Main.hx | +30 | -0 |
| Makefile | +12 | -0 |
| xmpp/Chat.hx | +48 | -0 |
| xmpp/ChatMessage.hx | +75 | -0 |
| xmpp/Client.hx | +47 | -0 |
| xmpp/EventEmitter.hx | +43 | -0 |
| xmpp/EventHandler.hx | +41 | -0 |
| xmpp/FSM.hx | +155 | -0 |
| xmpp/GenericStream.hx | +45 | -0 |
| xmpp/ID.hx | +33 | -0 |
| xmpp/JID.hx | +22 | -0 |
| xmpp/MessageSync.hx | +113 | -0 |
| xmpp/ResultSet.hx | +14 | -0 |
| xmpp/Stanza.hx | +250 | -0 |
| xmpp/Stream.js.hx | +5 | -0 |
| xmpp/queries/GenericQuery.hx | +34 | -0 |
| xmpp/queries/MAMQuery.hx | +124 | -0 |
| xmpp/streams/XmppJsStream.hx | +205 | -0 |
diff --git a/Main.hx b/Main.hx new file mode 100644 index 0000000..809e1f8 --- /dev/null +++ b/Main.hx @@ -0,0 +1,30 @@ +import xmpp.Client; +import xmpp.EventHandler; + +class Main { + static public function main():Void { + var client = new Client("user@example.com"); + + client.on("status/online", function (data) { + trace("CONNECTED CLIENT!"); + + var chat = client.getDirectChat("user2@example.com"); + chat.getMessages(function (result) { + trace('${result.messages.length} messages received:'); + for (message in result.messages) { + trace('[${message.isIncoming()?"incoming":"outgoing"}]: ${message.text}'); + } + trace("complete: " + !result.sync.hasMore()); + }); + + return EventHandled; + }); + + client.on("auth/password-needed", function (data) { + client.usePassword("secret-password"); + return EventHandled; + }); + + client.start(); + } +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ab0385e --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +HAXE_PATH=$$HOME/Software/haxe-4.3.1/hxnodejs/12,1,0/src + +.PHONY: all run-js + +all: test.node.js + +test.node.js: xmpp/*.hx xmpp/queries/*.hx xmpp/streams/*.hx + haxe -D nodejs -D no-deprecation-warnings -m Main --js "$@" -cp "$(HAXE_PATH)" + +run-nodejs: test.node.js + nodejs "$<" + diff --git a/xmpp/Chat.hx b/xmpp/Chat.hx new file mode 100644 index 0000000..9d52453 --- /dev/null +++ b/xmpp/Chat.hx @@ -0,0 +1,48 @@ +package xmpp; + +import xmpp.MessageSync; +import xmpp.ChatMessage; +import xmpp.Chat; +import xmpp.GenericStream; + +enum ChatType { + ChatTypeDirect; + ChatTypeGroup; + ChatTypePublic; +} + +abstract class Chat { + private var client:Client; + private var stream:GenericStream; + public var chatId(default, null):String; + public var type(default, null):ChatType; + + private function new(client:Client, stream:GenericStream, chatId:String, type:ChatType) { + this.client = client; + this.stream = stream; + this.chatId = chatId; + } + + abstract public function sendMessage(message:ChatMessage):Void; + + abstract public function getMessages(handler:MessageListHandler):MessageSync; + + public function isDirectChat():Bool { return type.match(ChatTypeDirect); }; + public function isGroupChat():Bool { return type.match(ChatTypeGroup); }; + public function isPublicChat():Bool { return type.match(ChatTypePublic); }; +} + +class DirectChat extends Chat { + public function new(client:Client, stream:GenericStream, chatId:String) { + super(client, stream, chatId, ChatTypeDirect); + } + + public function getMessages(handler:MessageListHandler):MessageSync { + var sync = new MessageSync(this.client, this.stream, this.chatId, {}); + sync.onMessages(handler); + sync.fetchNext(); + return sync; + } + + public function sendMessage(message:ChatMessage):Void {} +} diff --git a/xmpp/ChatMessage.hx b/xmpp/ChatMessage.hx new file mode 100644 index 0000000..742bd41 --- /dev/null +++ b/xmpp/ChatMessage.hx @@ -0,0 +1,75 @@ +package xmpp; + +import haxe.Exception; + +import xmpp.JID; + +enum MessageDirection { + MessageReceived; + MessageSent; +} + +class ChatAttachment { + public var url(default, null):String = null; + public var description(default, null):String = null; +} + +class ChatMessage { + public var localId (default, set) : String = null; + public var serverId (default, set) : String = null; + + public var timestamp (default, set) : String = null; + + public var to (default, null): String = null; + public var from (default, null): String = null; + + var threadId (default, null): String = null; + var replyTo (default, null): String = null; + + var attachments : Array<ChatAttachment> = null; + + public var text (default, null): String = null; + + private var direction:MessageDirection = null; + + public function new() { } + + public static function fromStanza(stanza:Stanza, localJid:String):ChatMessage { + var msg = new ChatMessage(); + msg.text = stanza.getChildText("body"); + msg.to = stanza.attr.get("to"); + msg.from = stanza.attr.get("from"); + var domain = JID.split(localJid).domain; + for (stanzaId in stanza.allTags("stanza-id", "urn:xmpp:sid:0")) { + if (stanzaId.attr.get("by") == domain) { + msg.serverId = stanzaId.attr.get("id"); + break; + } + } + msg.direction = (msg.to == localJid) ? MessageReceived : MessageSent; + return msg; + } + + public function set_localId(localId:String):String { + if(this.localId != null) { + throw new Exception("Message already has a localId set"); + } + return this.localId = localId; + } + + public function set_serverId(serverId:String):String { + if(this.serverId != null) { + throw new Exception("Message already has a serverId set"); + } + return this.serverId = serverId; + } + + public function set_timestamp(timestamp:String):String { + return this.timestamp = timestamp; + } + + public function isIncoming():Bool { + return direction == MessageReceived; + } +} + diff --git a/xmpp/Client.hx b/xmpp/Client.hx new file mode 100644 index 0000000..f857b3a --- /dev/null +++ b/xmpp/Client.hx @@ -0,0 +1,47 @@ +package xmpp; + +import xmpp.Chat; +import xmpp.EventEmitter; +import xmpp.Stream; +import xmpp.queries.GenericQuery; + +typedef ChatList = Array<Chat>; + +class Client extends xmpp.EventEmitter { + private var stream:GenericStream; + public var jid(default,null):String; + + public function new(jid: String) { + super(); + this.jid = jid; + stream = new Stream(); + stream.on("status/online", this.onConnected); + stream.on("auth/password-needed", (data)->this.trigger("auth/password-needed", { jid: this.jid })); + } + + public function start() { + stream.connect(jid); + } + + private function onConnected(data) { + return this.trigger("status/online", {}); + } + + public function usePassword(password: String):Void { + this.stream.trigger("auth/password", { password: password }); + } + + /* Return array of chats, sorted by last activity */ + public function getChats():ChatList { + return []; + } + + public function getDirectChat(chatId:String):DirectChat { + return new DirectChat(this, this.stream, chatId); + } + + /* Internal-ish methods */ + public function sendQuery(query:GenericQuery) { + this.stream.sendIq(query.getQueryStanza(), query.handleResponse); + } +} diff --git a/xmpp/EventEmitter.hx b/xmpp/EventEmitter.hx new file mode 100644 index 0000000..dfeb9c7 --- /dev/null +++ b/xmpp/EventEmitter.hx @@ -0,0 +1,43 @@ +package xmpp; + +import xmpp.EventHandler; + +class EventEmitter { + private var eventHandlers:Map<String,Array<EventHandler>> = []; + + private function new() { } + + public function on(eventName:String, callback:EventCallback):EventHandler { + var handlers = eventHandlers.get(eventName); + if(handlers == null) { + handlers = []; + eventHandlers.set(eventName, handlers); + } + var newHandler = new EventHandler(handlers, callback); + handlers.push(newHandler); + return newHandler; + } + + public function once(eventName:String, callback:EventCallback) { + return this.on(eventName, callback).once(); + } + + public function trigger(eventName:String, eventData:Dynamic):EventResult { + var handlers = eventHandlers.get(eventName); + if(handlers == null || handlers.length == 0) { + trace('no event handlers for $eventName'); + return EventUnhandled; + } + trace("firing event: "+eventName); + var handled = false; + for (handler in handlers) { + var ret = handler.call(eventData); + switch(ret) { + case EventHandled: handled = true; + case EventUnhandled: continue; + case EventStop | EventValue(_): return ret; + } + } + return EventHandled; + } +} diff --git a/xmpp/EventHandler.hx b/xmpp/EventHandler.hx new file mode 100644 index 0000000..ff52e93 --- /dev/null +++ b/xmpp/EventHandler.hx @@ -0,0 +1,41 @@ +package xmpp; + +enum EventResult { + EventHandled; + EventUnhandled; + EventStop; + EventValue(result:Dynamic); +} + +typedef EventCallback = (Dynamic)->EventResult; + +class EventHandler { + private var handlers:Array<EventHandler> = null; + private var callback:EventCallback = null; + private var onlyOnce:Bool = false; + + public function new(handlers:Array<EventHandler>, callback:EventCallback, ?onlyOnce:Bool) { + this.handlers = handlers; + this.callback = callback; + if(onlyOnce != null) { + this.onlyOnce = onlyOnce; + } + } + + public function call(data:Dynamic):EventResult { + if(onlyOnce) { + this.unsubscribe(); + } + return callback(data); + } + + public function once():EventHandler { + onlyOnce = true; + return this; + } + + public function unsubscribe():Void { + this.handlers.remove(this); + } + +} diff --git a/xmpp/FSM.hx b/xmpp/FSM.hx new file mode 100644 index 0000000..f6e1717 --- /dev/null +++ b/xmpp/FSM.hx @@ -0,0 +1,155 @@ +package xmpp; + +import haxe.Exception; + +typedef FSMTransitionName = String; +typedef FSMStateName = String; + +typedef FSMEvent = { + var fsm : FSM; + + var ?name : FSMTransitionName; + + var to : FSMStateName; + var ?toAttr : Dynamic; + var ?from : FSMStateName; + var ?fromAttr : Dynamic; +}; + +typedef FSMTransition = { + var name : FSMTransitionName; + var from : Array<FSMStateName>; + var to : FSMStateName; +}; + +typedef FSMStateHandler = (FSMEvent)->Void; +typedef FSMTransitionHandler = (FSMEvent)->Bool; + +typedef FSMDescription = { + var transitions : Array<FSMTransition>; + + var ?state_handlers : Map<FSMStateName,FSMStateHandler>; + var ?transition_handlers : Map<FSMTransitionName,FSMTransitionHandler>; +}; + +class FSM extends EventEmitter { + private var states : Map<FSMStateName,Map<FSMTransitionName,FSMStateName>> = []; + private var currentState : FSMStateName = null; + private var currentStateAttributes : Dynamic = null; + + public function new(desc:FSMDescription, initialState:FSMStateName, ?initialAttr:Dynamic) { + super(); + for(transition in desc.transitions) { + var from_states = transition.from; + for (from_state in from_states) { + var from_state_def = states.get(from_state); + if (from_state_def == null) { + from_state_def = []; + states.set(from_state, from_state_def); + } + var to_state_def = states.get(transition.to); + if (to_state_def == null) { + to_state_def = []; + states.set(transition.to, to_state_def); + } + if (states.get(from_state).get(transition.name) != null) { + throw new Exception("Duplicate transition in FSM specification: " + transition.name + " from " + from_state); + } + states.get(from_state).set(transition.name, transition.to); + } + } + + if(desc.state_handlers != null) { + for (state => handler in desc.state_handlers) { + this.on('enter/$state', function (data) { + handler(data); + return EventHandled; + }); + } + } + + if(desc.transition_handlers != null) { + for (transition => handler in desc.transition_handlers) { + this.on('transition/$transition', function (data) { + if(handler(data) == false) { + return EventStop; + } + return EventHandled; + }); + } + } + + currentState = initialState; + currentStateAttributes = initialAttr; + var initialEvent:FSMEvent = { + fsm: this, + to: initialState, + toAttr: initialAttr, + }; + this.notifyTransitioned(initialEvent, true); + } + + public function can(name:FSMTransitionName):Bool { + return states.get(currentState).get(name) != null; + } + + public function event(name:FSMTransitionName, ?attr:Dynamic):Bool { + var newState = states.get(currentState).get(name); + if(newState == null) { + throw new Exception("Invalid state transition: " + currentState + " cannot " + name); + } + + var event:FSMEvent = { + fsm: this, + + name: name, + to: newState, + toAttr: attr, + + from: currentState, + fromAttr: currentStateAttributes, + }; + + if(notifyTransition(event) == false) { + return false; + } + + this.currentState = newState; + this.currentStateAttributes = attr; + + notifyTransitioned(event, false); + return true; + } + + private function notifyTransition(event:FSMEvent):Bool { + var ret; + ret = this.trigger("transition", event); + if(ret == EventStop) { + return false; + } + if(event.to != event.from) { + ret = this.trigger("leave/"+event.from, event); + if (ret == EventStop) { + return false; + } + } + ret = this.trigger("transition/"+event.name, event); + if(ret == EventStop) { + return false; + } + return true; + } + + private function notifyTransitioned(event:FSMEvent, isInitial:Bool):Void { + if(event.to != event.from) { + this.trigger("enter/"+event.to, event); + } + if(isInitial == false) { + if(event.name != null) { + trigger("transitioned/"+event.name, event); + } + trigger("transitioned", event); + } + } +} + diff --git a/xmpp/GenericStream.hx b/xmpp/GenericStream.hx new file mode 100644 index 0000000..a824e33 --- /dev/null +++ b/xmpp/GenericStream.hx @@ -0,0 +1,45 @@ +package xmpp; + +import xmpp.Stanza; +import xmpp.EventEmitter; + +abstract class GenericStream extends EventEmitter { + + public function new() { + super(); + } + + /* Connections and streams */ + + abstract public function connect(jid:String):Void; + abstract public function sendStanza(stanza:Stanza):Void; + abstract public function newId():String; + + public function sendIq(stanza:Stanza, callback:(stanza:Stanza)->Void):Void { + var id = newId(); + stanza.attr.set("id", id); + this.once('iq-response/$id', function (event) { + callback(event.stanza); + return EventHandled; + }); + sendStanza(stanza); + } + + private function onStanza(stanza:Stanza):Void { + trace("stanza received!"); + final xmlns = stanza.attr.get("xmlns"); + if(xmlns == "jabber:client") { + final name = stanza.name; + if(stanza.name == "iq") { + var type = stanza.attr.get("type"); + trace('type: $type'); + if(type == "result" || type == "error") { + var id = stanza.attr.get("id"); + trigger('iq-response/$id', { stanza: stanza }); + } + } else if (name == "message" || name == "presence") { + trigger(name, { stanza: stanza }); + } + } + } +} diff --git a/xmpp/ID.hx b/xmpp/ID.hx new file mode 100644 index 0000000..c6ca3be --- /dev/null +++ b/xmpp/ID.hx @@ -0,0 +1,33 @@ +package xmpp; + +import haxe.crypto.Base64; +import haxe.io.Bytes; + +#if nodejs +import js.node.Crypto; +#end + +class ID { + public static function tiny():String { + return Base64.urlEncode(getRandomBytes(3)); + } + + public static function short():String { + return Base64.urlEncode(getRandomBytes(9)); + } + + public static function medium():String { + return Base64.urlEncode(getRandomBytes(18)); + } + + public static function long():String { + return Base64.urlEncode(getRandomBytes(27)); + } + +#if nodejs + private static function getRandomBytes(n:Int):Bytes { + return Crypto.randomBytes(n).hxToBytes(); + } +#end + +} diff --git a/xmpp/JID.hx b/xmpp/JID.hx new file mode 100644 index 0000000..ae2718c --- /dev/null +++ b/xmpp/JID.hx @@ -0,0 +1,22 @@ +package xmpp; + +typedef SplitJID = { + var ?node : String; + var domain : String; + var ?resource : String; +}; + +class JID { + public static function split(jid:String):SplitJID { + var resourceDelimiter = jid.indexOf("/"); + var nodeDelimiter = jid.indexOf("@"); + if(nodeDelimiter >= resourceDelimiter) { + nodeDelimiter = -1; + } + return { + node: (nodeDelimiter>0)?jid.substr(0, nodeDelimiter):null, + domain: jid.substring((nodeDelimiter == -1)?0:nodeDelimiter+1, (resourceDelimiter == -1)?jid.length+1:resourceDelimiter), + resource: (resourceDelimiter == -1)?null:jid.substring(resourceDelimiter+1), + }; + } +} diff --git a/xmpp/MessageSync.hx b/xmpp/MessageSync.hx new file mode 100644 index 0000000..87a0bea --- /dev/null +++ b/xmpp/MessageSync.hx @@ -0,0 +1,113 @@ +package xmpp; + +import haxe.Exception; + +import xmpp.Client; +import xmpp.ChatMessage; +import xmpp.GenericStream; +import xmpp.ResultSet; +import xmpp.queries.MAMQuery; + +typedef MessageList = { + var sync : MessageSync; + var messages : Array<ChatMessage>; +} + +typedef MessageListHandler = (MessageList)->Void; + +typedef MessageFilter = MAMQueryParams; + +class MessageSync { + private var client:Client; + private var stream:GenericStream; + private var chatId:String; + private var filter:MessageFilter; + private var serviceJID:String; + private var handler:MessageListHandler; + private var lastPage:ResultSetPageResult; + private var complete:Bool = false; + private var newestPageFirst:Bool = true; + + public function new(client:Client, stream:GenericStream, chatId:String, filter:MessageFilter, ?serviceJID:String) { + this.client = client; + this.stream = stream; + this.chatId = chatId; + this.filter = Reflect.copy(filter); + this.serviceJID = serviceJID != null ? serviceJID : client.jid; + } + + public function fetchNext():Void { + if(handler == null) { + throw new Exception("Attempt to fetch messages, but no handler has been set"); + } + var messages:Array<ChatMessage> = []; + if(lastPage == null) { + if(newestPageFirst == true) { + filter.page = { + before: "", // Request last page of results + }; + } else { + filter.page = null; + } + } else { + if(newestPageFirst == true) { + filter.page = { + before: lastPage.first, + }; + } else { + filter.page = { + after: lastPage.last, + }; + } + } + var query = new MAMQuery(filter); + var resultHandler = stream.on("message", function (event) { + var message:Stanza = event.stanza; + var from = message.attr.exists("from") ? message.attr.get("from") : client.jid; + if(from != serviceJID) { // Only listen for results from the JID we queried + return EventUnhandled; + } + var result = message.getChild("result", query.xmlns); + if(result == null || result.attr.get("queryid") != query.queryId) { // Not (a|our) MAM result + return EventUnhandled; + } + var originalMessage = result.findChild("{urn:xmpp:forward:0}forwarded/{jabber:client}message"); + if(originalMessage == null) { // No message, nothing for us to do + return EventHandled; + } + var timestamp = result.findText("{urn:xmpp:forward:0}forwarded/{urn:xmpp:delay}delay@stamp"); + + var msg = ChatMessage.fromStanza(originalMessage, client.jid); + msg.set_serverId(result.attr.get("id")); + msg.set_timestamp(timestamp); + + messages.push(msg); + + return EventHandled; + }); + query.onFinished(function () { + resultHandler.unsubscribe(); + var result = query.getResult(); + if(result != null) { + complete = result.complete; + } + handler({ + sync: this, + messages: messages, + }); + }); + client.sendQuery(query); + } + + public function hasMore():Bool { + return !complete; + } + + public function onMessages(handler:MessageListHandler):Void { + this.handler = handler; + } + + public function setNewestPageFirst(newestPageFirst:Bool):Void { + this.newestPageFirst = newestPageFirst; + } +} diff --git a/xmpp/ResultSet.hx b/xmpp/ResultSet.hx new file mode 100644 index 0000000..19b0539 --- /dev/null +++ b/xmpp/ResultSet.hx @@ -0,0 +1,14 @@ +package xmpp; + +typedef ResultSetPageRequest = { + var ?before : String; // Set to request the page before a given id (or empty string to request the very last page) + var ?after : String; // Set to request the page after a given id + var ?limit : String; // Request a limit on the number of items returned in the page +}; + +typedef ResultSetPageResult = { + var first : String; // The RSM id of the first item in this page + var last : String; // The RSM id of the last item in this page + var ?index : Int; // The position (within 'count') of the first item in this page + var ?count : Int; // Count of the *total* items, not the items in the page +}; diff --git a/xmpp/Stanza.hx b/xmpp/Stanza.hx new file mode 100644 index 0000000..6a77f2b --- /dev/null +++ b/xmpp/Stanza.hx @@ -0,0 +1,250 @@ +package xmpp; + +import haxe.DynamicAccess; +import haxe.Exception; +import haxe.ds.StringMap; + +enum Node { + Element(stanza:Stanza); + CData(textNode:TextNode); +} + +typedef NodeList = Array<Node>; + +private interface NodeInterface { + public function serialize():String; + public function clone():NodeInterface; +} + +class TextNode implements NodeInterface { + private var content(default, null):String = ""; + + public function new (content:String) { + this.content = content; + } + + public function serialize():String { + return content; + } + + public function clone():TextNode { + return new TextNode(this.content); + } +} + +class Stanza implements NodeInterface { + public var name(default, null):String = null; + public var attr(default, null):DynamicAccess<String> = null; + public var children(default, null):Array<Node> = []; + private var last_added(null, null):Stanza; + private var last_added_stack(null, null):Array<Stanza> = []; + + public function new(name:String, ?attr:DynamicAccess<String>) { + this.name = name; + if(attr != null) { + this.attr = attr; + } + this.last_added = this; + }; + + public function serialize():String { + var el = Xml.createElement(name); + for (attr_k in this.attr.keys()) { + el.set(attr_k, this.attr.get(attr_k)); + } + + if (this.children.length == 0) { + return el.toString(); + } + var serialized = el.toString(); + var buffer = [serialized.substring(0, serialized.length-2)+">"]; + for (child in children) { + buffer.push(switch (child) { + case Element(c): c.serialize(); + case CData(c): c.serialize(); + }); + } + buffer.push("</"+this.name+">"); + return buffer.join(""); + } + + public function toString():String { + return this.serialize(); + } + + public function tag(name:String, ?attr:DynamicAccess<String>) { + var child = new Stanza(name, attr); + this.last_added.addDirectChild(Element(child)); + this.last_added_stack.push(this.last_added); + this.last_added = child; + return this; + } + + public function text(content:String) { + this.last_added.addDirectChild(CData(new TextNode(content))); + return this; + } + + public function textTag(tagName:String, textContent:String, ?attr:DynamicAccess<String>) { + this.last_added.addDirectChild(Element(new Stanza(tagName, attr).text(textContent))); + return this; + } + + public function up() { + if(this.last_added != this) { + this.last_added = this.last_added_stack.pop(); + } + return this; + } + + public function reset():Stanza { + this.last_added = this; + return this; + } + + public function addChild(stanza:Stanza) { + this.last_added.children.push(Element(stanza)); + return this; + } + + public function addDirectChild(child:Node) { + this.children.push(child); + return this; + } + + public function clone():Stanza { + var clone = new Stanza(this.name, this.attr); + for (child in children) { + clone.addDirectChild(switch(child) { + case Element(c): Element(c.clone()); + case CData(c): CData(c.clone()); + }); + } + return clone; + } + + public function allTags(?name:String, ?xmlns:String):Array<Stanza> { + var tags = this.children + .filter((child) -> child.match(Element(_))) + .map(function (child:Node) { + return switch(child) { + case Element(c): c; + case _: null; + }; + }); + if (name != null || xmlns != null) { + var ourXmlns = this.attr.get("xmlns"); + tags = tags.filter(function (child:Stanza):Bool { + var childXmlns = child.attr.get("xmlns"); + return (name == null || child.name == name + && ((xmlns == null && ourXmlns == childXmlns) + || childXmlns == xmlns)); + }); + } + return tags; + } + + public function allText():Array<String> { + return this.children + .filter((child) -> child.match(CData(_))) + .map(function (child:Node) { + return switch(child) { + case CData(c): c.serialize(); + case _: null; + }; + }); + } + + public function getFirstChild():Stanza { + return allTags()[0]; + } + + public function getChild(?name:Null<String>, ?xmlns:Null<String>):Null<Stanza> { + var ourXmlns = this.attr.get("xmlns"); + /* + for (child in allTags()) { + if (name == null || child.name == name + && ((xmlns == null && ourXmlns == child.attr.get("xmlns")) + || child.attr.get("xmlns") == xmlns)) { + return child; + } + }*/ + var tags = allTags(name, xmlns); + if(tags.length == 0) { + return null; + } + return tags[0]; + } + + public function getChildText(?name:Null<String>, ?xmlns:Null<String>):String { + var child = getChild(name, xmlns); + if(child == null) { + return null; + } + return child.getText(); + } + + public function getText():String { + return allText().join(""); + } + + public function find(path:String):Node { + var pos = 0; + var len = path.length; + var cursor = this; + + do { + var xmlns = null, name = null, text = null; + var char = path.charAt(pos); + if (char == "@") { + return CData(new TextNode(cursor.attr.get(path.substr(pos+1)))); + } else if (char == "{") { + xmlns = path.substring(pos+1, path.indexOf("}", pos+1)); + pos += xmlns.length + 2; + } + var reName = new EReg("([^@/#]*)([/#]?)", ""); + if(!reName.matchSub(path, pos)) { + throw new Exception("Invalid path to Stanza.find(): "+path); + } + var name = reName.matched(1), text = reName.matched(2); + pos = reName.matchedPos().pos + reName.matchedPos().len; + if(name == "") { + name = null; + }; + if(pos == len) { + if(text == "#") { + var text = cursor.getChildText(name, xmlns); + if(text == null) { + return null; + } + return CData(new TextNode(text)); + } + return Element(cursor.getChild(name, xmlns)); + } + cursor = cursor.getChild(name, xmlns); + } while (cursor != null); + return null; + } + + public function findChild(path:String):Stanza { + var result = find(path); + if(result == null) { + return null; + } + return switch(result) { + case Element(stanza): stanza; + case _: null; + }; + } + + public function findText(path:String):String { + var result = find(path); + if(result == null) { + return null; + } + return switch(result) { + case CData(textNode): textNode.serialize(); + case _: null; + }; + } +} diff --git a/xmpp/Stream.js.hx b/xmpp/Stream.js.hx new file mode 100644 index 0000000..5c36c25 --- /dev/null +++ b/xmpp/Stream.js.hx @@ -0,0 +1,5 @@ +package xmpp; + +import xmpp.streams.XmppJsStream; + +typedef Stream = xmpp.streams.XmppJsStream; diff --git a/xmpp/queries/GenericQuery.hx b/xmpp/queries/GenericQuery.hx new file mode 100644 index 0000000..2139685 --- /dev/null +++ b/xmpp/queries/GenericQuery.hx @@ -0,0 +1,34 @@ +package xmpp.queries; + +import haxe.Exception; + +import xmpp.Stanza; + +abstract class GenericQuery { + private var queryStanza:Stanza; + private var handleFinished:()->Void; + private var isFinished:Bool = false; + + public function getQueryStanza():Stanza { + if(queryStanza == null) { + throw new Exception("Query has not been initialized"); + } + return queryStanza; + } + + private function finish() { + isFinished = true; + if(handleFinished != null) { + handleFinished(); + } + } + + abstract public function handleResponse(response:Stanza):Void; + + public function onFinished(handler:()->Void):Void { + handleFinished = handler; + if(isFinished) { + handleFinished(); + } + } +} diff --git a/xmpp/queries/MAMQuery.hx b/xmpp/queries/MAMQuery.hx new file mode 100644 index 0000000..5d83b14 --- /dev/null +++ b/xmpp/queries/MAMQuery.hx @@ -0,0 +1,124 @@ +package xmpp.queries; + +import haxe.Exception; + +import xmpp.ID; +import xmpp.ResultSet; +import xmpp.Stanza; +import xmpp.Stream; +import xmpp.queries.GenericQuery; + +typedef MAMQueryParams = { + var ?startTime : String; + var ?endTime : String; + var ?with : String; + var ?beforeId : String; + var ?afterId : String; + var ?ids : Array<String>; + + var ?page : { + var ?before : String; + var ?after : String; + var ?limit : Int; + }; +}; + +typedef MAMQueryResult = { + var complete : Bool; + var page : { + var firstId : String; + var lastId : String; + }; +}; + +class MAMQuery extends GenericQuery { + public var xmlns(default, null) = "urn:xmpp:mam:2"; + public var queryId:String = null; + private var responseStanza:Stanza; + private var result:MAMQueryResult; + + private function addStringField(name:String, value:String) { + if(value == null) { + return; + } + queryStanza + .tag("field", { "var": name }) + .textTag("value", value) + .up(); + } + + private function addArrayField(name:String, values:Array<String>) { + if(values == null) { + return; + } + queryStanza.tag("field", { "var": name }); + for (value in values) { + queryStanza.textTag("value", value); + } + queryStanza.up(); + } + + public function new(params:MAMQueryParams, ?jid:String) { + /* Build basic query */ + queryId = ID.short(); + queryStanza = new Stanza("iq", { type: "set", to: jid }) + .tag("query", { xmlns: xmlns, queryid: queryId }) + .tag("x", { xmlns: "jabber:x:data", type: "submit" }) + .tag("field", { "var": "FORM_TYPE", type: "hidden" }) + .textTag("value", xmlns) + .up(); + + /* Add filter parameters to query form */ + addStringField("start", params.startTime); + addStringField("end", params.endTime); + addStringField("with", params.with); + addStringField("before-id", params.beforeId); + addStringField("after-id", params.afterId); + addArrayField("ids", params.ids); + + queryStanza.up(); // Out of <x/> form + + if(params.page != null) { + var page = params.page; + queryStanza.tag("set", { xmlns: "http://jabber.org/protocol/rsm" }); + if(page.limit != null) { + queryStanza.textTag("max", Std.string(page.limit)); + } + if(page.before != null && page.after != null) { + throw new Exception("It is not allowed to request a page before AND a page after"); + } + if(page.before != null) { + queryStanza.textTag("before", page.before); + } else if(page.after != null) { + queryStanza.textTag("after", page.after); + } + queryStanza.up(); // out of <set/> + } + } + + public function handleResponse(stanza:Stanza) { + responseStanza = stanza; + finish(); + } + + public function getResult() { + if (responseStanza == null) { + return null; + } + if(result == null) { + var fin = responseStanza.getFirstChild(); + if(fin == null || fin.name != "fin" || fin.attr.get("xmlns") != xmlns) { + return null; + } + var rsmInfo = fin.getChild("set", "http://jabber.org/protocol/rsm"); + result = { + complete: fin.attr.get("complete") == "true" || fin.attr.get("complete") == "1", + page: { + firstId: rsmInfo.getChildText("first"), + lastId: rsmInfo.getChildText("last"), + } + }; + } + return result; + } +} diff --git a/xmpp/streams/XmppJsStream.hx b/xmpp/streams/XmppJsStream.hx new file mode 100644 index 0000000..4f09b76 --- /dev/null +++ b/xmpp/streams/XmppJsStream.hx @@ -0,0 +1,205 @@ +package xmpp.streams; + +import js.lib.Promise; +import haxe.Http; +import haxe.Json; + +import xmpp.FSM; +import xmpp.GenericStream; +import xmpp.Stanza; + +@:jsRequire("@xmpp/client", "client") +extern class XmppJsClient { + function new(options:Dynamic); + function start():Promise<Dynamic>; + function on(eventName:String, callback:(Dynamic)->Void):Void; + function send(stanza:XmppJsXml):Void; +} + +@:jsRequire("@xmpp/jid", "jid") +extern class XmppJsJID { + function new(jid:String); + + var local(default, set):String; + var domain(default, set):String; + var resource(default, set):String; +} + +@:jsRequire("@xmpp/debug") +extern class XmppJsDebug { + @:selfCall + function new(client:XmppJsClient, force:Bool):Void; +} + +@:jsRequire("@xmpp/xml") +extern class XmppJsXml { + @:selfCall + @:overload(function(tagName:String, ?attr:Dynamic):XmppJsXml { }) + function new(); + @:overload(function(textContent:String):Void { }) + function append(el:XmppJsXml):Void; + + var name:String; + var attrs:Dynamic; + var children:Array<Dynamic>; +} + +@:jsRequire("ltx") // The default XML library used by xmpp.js +extern class XmppJsLtx { + static function isNode(el:Dynamic):Bool; + static function isElement(el:Dynamic):Bool; + static function isText(el:Dynamic):Bool; +} + +@:jsRequire("@xmpp/id") +extern class XmppJsId { + @:selfCall + static function id():String; +} + +typedef HostMetaRecord = { + rel : String, + href : String, +}; +typedef HostMetaJson = { + links : Array<HostMetaRecord>, +}; + +class XmppJsStream extends GenericStream { + private var client:XmppJsClient; + private var jid:XmppJsJID; + private var connectionURI:String; + private var debug = true; + private var state:FSM; + + override public function new() { + super(); + state = new FSM({ + transitions: [ + { name: "connect-requested", from: ["offline"], to: "connecting" }, + { name: "connection-success", from: ["connecting"], to: "online" }, + { name: "connection-error", from: ["connecting"], to: "offline" }, + { name: "connection-closed", from: ["connecting", "online"], to: "offline" }, + ], + state_handlers: [ + "online" => this.onOnline, + "offline" => this.onOffline, + + ], + }, "offline"); + } + + static private function resolveConnectionURI(domain:String, callback:(String)->Void):Void { + var request = new Http('https://$domain/.well-known/host-meta.json'); + request.onData = function (data:String) { + try { + var parsed:HostMetaJson = Json.parse(data); + for(entry in parsed.links) { + trace("ENTRY"); + if(entry.href.substr(0, 6) == "wss://") { + callback(entry.href); + return; + } + } + } catch (e) { + } + callback(null); + }; + request.onError = function (msg:String) { + callback(null); + } + request.request(false); + } + + private function connectWithURI(uri:String) { + trace("Got connection URI: "+uri); + if(uri == null) { + this.state.event("connection-error"); + return; + } + connectionURI = uri; + + this.on("auth/password", function (event) { + var xmpp = new XmppJsClient({ + service: connectionURI, + domain: jid.domain, + username: jid.local, + password: event.password, + }); + + if(this.debug) { + new XmppJsDebug(xmpp, true); + } + + this.client = xmpp; + + xmpp.on("online", function (data) { + this.state.event("connection-success"); + }); + + xmpp.on("offline", function (data) { + this.state.event("connection-closed"); + }); + + xmpp.on("stanza", function (stanza) { + this.onStanza(convertToStanza(stanza)); + }); + + xmpp.start().catchError(function (err) { + trace(err); + }); + return EventHandled; + }); + this.trigger("auth/password-needed", {}); + } + + public function connect(jid:String) { + this.state.event("connect-requested"); + this.jid = new XmppJsJID(jid); + + resolveConnectionURI(this.jid.domain, this.connectWithURI); + } + + private function convertFromStanza(el:Stanza):XmppJsXml { + var xml = new XmppJsXml(el.name, el.attr); + if(el.children.length > 0) { + for(child in el.children) { + switch(child) { + case Element(stanza): xml.append(convertFromStanza(stanza)); + case CData(text): xml.append(text.serialize()); + }; + } + } + return xml; + } + + private function convertToStanza(el:XmppJsXml):Stanza { + var stanza = new Stanza(el.name, el.attrs); + for (child in el.children) { + if(XmppJsLtx.isElement(child)) { + stanza.addChild(convertToStanza(child)); + } else if(XmppJsLtx.isText(child)) { + stanza.text(cast(child, String)); + } + } + return stanza; + } + + public function sendStanza(stanza:Stanza) { + client.send(convertFromStanza(stanza)); + } + + public function newId():String { + return XmppJsId.id(); + } + + /* State handlers */ + + private function onOnline(event) { + trigger("status/online", {}); + } + + private function onOffline(event) { + trigger("status/offline", {}); + } +}