diff --git a/project.godot b/project.godot index e4d9048..f0a51a1 100644 --- a/project.godot +++ b/project.godot @@ -8,6 +8,46 @@ config_version=4 +_global_script_classes=[ { +"base": "Node", +"class": "Connections", +"language": "GDScript", +"path": "res://scenes/Connections.gd" +}, { +"base": "Reference", +"class": "MucService", +"language": "GDScript", +"path": "res://scenes/MucService.gd" +}, { +"base": "Node", +"class": "PolyService", +"language": "GDScript", +"path": "res://scenes/PolyService.gd" +}, { +"base": "Reference", +"class": "PolyServiceBuilder", +"language": "GDScript", +"path": "res://scenes/PolyServiceBuilder.gd" +}, { +"base": "Node", +"class": "Stanza", +"language": "GDScript", +"path": "res://scenes/Stanza.gd" +}, { +"base": "Node", +"class": "Xml", +"language": "GDScript", +"path": "res://scenes/Xml.gd" +} ] +_global_script_class_icons={ +"Connections": "", +"MucService": "", +"PolyService": "", +"PolyServiceBuilder": "", +"Stanza": "", +"Xml": "" +} + [application] config/name="tochie" @@ -17,7 +57,7 @@ config/icon="res://icon.png" [autoload] -Xml="*res://scenes/Xml.gd" +Sums="*res://scenes/Sums.gd" [gui] diff --git a/scenes/App.gd b/scenes/App.gd index 5f453d8..2ae4d4b 100644 --- a/scenes/App.gd +++ b/scenes/App.gd @@ -4,35 +4,30 @@ var _connection onready var n_Connection := get_node("Connections") -func _service_discovery(): - var iq := yield() as Xml.XmlElement - - var feature_promises := Array() - for item in iq.children[0].children: - feature_promises.push_back(_connection.promise_iq( - item.attributes["jid"], "get", - n_Connection.disco_info_queury, - n_Connection.yield_as_is())) - - while not n_Connection.are_promises_done(feature_promises): - yield() - - for feature_promise in feature_promises: - if not feature_promise.is_ok: - push_error("Connection failed") - return - - var feature := feature_promise.value as Xml.XmlElement - print(feature.as_string()) - func _ready(): _connection = n_Connection.establish_new_connection("poto.cafe", "veclavtalica", "-") if _connection == null: push_error("Connection failed") return - if _connection.push_iq(_connection.domain, "get", - n_Connection.disco_items_queury, - _service_discovery()) != OK: - push_error("Connection failed") + var service_request = load("res://scenes/PolyServiceBuilder.gd").new().request(_connection) + if not service_request.is_done: + yield(service_request, "done") + + if not service_request.is_ok: + push_error("Service builder errored out: %s" % [service_request.value]) return + + for service in service_request.value.value: + if service.muc == null: continue + + var rooms_request = service.muc.request_rooms() + if not rooms_request.is_done: + yield(rooms_request, "done") + + if not rooms_request.is_ok: + push_error("Room request errored out: %s" % [rooms_request.value]) + return + + for room in rooms_request.value.value: + print(room.as_string()) diff --git a/scenes/Connections.gd b/scenes/Connections.gd index 3f32923..f5b80d3 100644 --- a/scenes/Connections.gd +++ b/scenes/Connections.gd @@ -1,7 +1,5 @@ extends Node - -const disco_info_queury := "" -const disco_items_queury := "" +class_name Connections class Connection extends Reference: var stream: StreamPeer @@ -9,46 +7,26 @@ class Connection extends Reference: var domain: String var bare_jid: String var jid: String + var _id_counter: int = 0 var _pending_iqs: Dictionary # of id to GDScriptFunctionState var _pending_tail_iqs: Array # of GDScriptFunctionState var _xml_parser := Xml.Parser.new() -# warning-ignore:unused_signal + # todo: Route signals to particular receivers, based on 'from' or 'to' + # warning-ignore:unused_signal signal presence_received(presence) -# warning-ignore:unused_signal + # warning-ignore:unused_signal signal message_received(message) -# warning-ignore:unused_signal + # warning-ignore:unused_signal signal iq_received(iq) + # todo: Separate incremental hash to its own class. func generate_id() -> int: self._id_counter += 1 return hash(self._id_counter) - class Promise extends Reference: - var is_done: bool = false - var is_ok: bool - var value = null - - static func from(capture: GDScriptFunctionState) -> Promise: - var result := Promise.new() - if capture.connect("completed", result, "result_arrived") != OK: - assert("Bruh") - return result - - static func make_error(error: int) -> Promise: - var result := Promise.new() - result.is_done = true - result.is_ok = false - result.value = error - return result - - func result_arrived(p_result) -> void: - self.is_done = true - self.is_ok = true - self.value = p_result - - func promise_iq(to: String, action: String, payload: String, capture: GDScriptFunctionState) -> Promise: + func promise_iq(to: String, action: String, payload: String, capture: GDScriptFunctionState): assert(action in ["get", "set"]) var id := self.generate_id() @@ -59,51 +37,23 @@ class Connection extends Reference: "to": to.xml_escape(), "action": action, "payload": payload - }).to_utf8()) != OK: - return Promise.make_error(ERR_CONNECTION_ERROR) + }).to_utf8()) != OK: + return Sums.Promise.make_error(ERR_CONNECTION_ERROR) assert(not id in self._pending_iqs) self._pending_iqs[id] = capture - return Promise.from(capture) - - func push_iq(to: String, action: String, payload: String, capture: GDScriptFunctionState) -> int: # Error - assert(action in ["get", "set"]) - - var id := self.generate_id() - if self.stream.put_data( - """{payload}""".format({ - "from": self.jid.xml_escape(), - "id": id, - "to": to.xml_escape(), - "action": action, - "payload": payload - }).to_utf8()) != OK: - return ERR_CONNECTION_ERROR - - assert(not id in self._pending_iqs) - self._pending_iqs[id] = capture - - return OK + return Sums.Promise.from(capture) ## Registry of connections used for poking of pending iqs. var _connections: Array # of WeakRef to Connection -static func are_promises_done(promises: Array) -> bool: - for promise in promises: - assert(promise is Connection.Promise) - if not promise.is_done: - return false - return true - -static func yield_as_is(): - return yield() - func _ready(): # todo: Some better interval? if get_tree().connect("physics_frame", self, "_process_connections") != OK: assert("Bruh") +# todo: Collapse Result inside Promise. func _process_connections() -> void: var to_remove := PoolIntArray() @@ -117,17 +67,19 @@ func _process_connections() -> void: if response == null: continue + # todo: Move Result error into closure on connection failure? + while connection._xml_parser.parse_a_bit(response): response = null var stanza := connection._xml_parser.take_root() - if stanza.node_name == "iq": + if stanza.name == "iq": if "to" in stanza.attributes and stanza.attributes["to"] != connection.jid: push_error("Stanza is not addressed to assigned jid") if stanza.attributes["type"] in ["result", "error"]: var id := int(stanza.attributes["id"]) # todo: Use strings directly instead? - var result = connection._pending_iqs[id].resume(stanza) + var result = connection._pending_iqs[id].resume(Sums.Result.make_value(stanza)) if result is GDScriptFunctionState and result.is_valid(): connection._pending_tail_iqs.push_back(result) # elif result != null: @@ -138,10 +90,10 @@ func _process_connections() -> void: elif stanza.attributes["type"] in ["set", "get"]: connection.emit_signal("iq_received", stanza) - elif stanza.node_name == "message": + elif stanza.name == "message": connection.emit_signal("message_received", stanza) - elif stanza.node_name == "presence": + elif stanza.name == "presence": connection.emit_signal("presence_received", stanza) var to_remove_tails := PoolIntArray() @@ -160,7 +112,7 @@ func _process_connections() -> void: for idx in range(to_remove.size() - 1, 0, -1): _connections.remove(to_remove[idx]) -func establish_new_connection(domain: String, identity: String, password: String) -> Connection: +func establish_new_connection(domain: String, identity: String, password: String): var stream := StreamPeerTCP.new() if stream.connect_to_host(domain, 5222) != OK: push_error("Cannot establish connection to " + domain) @@ -228,7 +180,7 @@ func _negotiate_tls(connection: Connection) -> int: ## Check that server response corresponds to what we ask for. # todo: For conformity client must send closing stream tag on error. - if parsed_response._root.node_name != "stream:stream": return ERR_CANT_CONNECT + if parsed_response._root.name != "stream:stream": return ERR_CANT_CONNECT if parsed_response._root.attributes["from"] != connection.domain: return ERR_CANT_CONNECT if "to" in parsed_response._root.attributes: if parsed_response._root.attributes["to"] != connection.bare_jid: return ERR_CANT_CONNECT @@ -299,7 +251,7 @@ func _negotiate_sasl(connection: Connection, password: String) -> int: ## Check that server response corresponds to what we ask for. # todo: For conformity client must send closing stream tag on error. - if parsed_response._root.node_name != "stream:stream": return ERR_CANT_CONNECT + if parsed_response._root.name != "stream:stream": return ERR_CANT_CONNECT if parsed_response._root.attributes["from"] != connection.domain: return ERR_CANT_CONNECT if "to" in parsed_response._root.attributes: if parsed_response._root.attributes["to"] != connection.bare_jid: return ERR_CANT_CONNECT @@ -321,7 +273,7 @@ func _negotiate_sasl(connection: Connection, password: String) -> int: ## We only support PLAIN mechanism as it's sufficient over TLS. var plain_found := false for machanism in mechanisms.children: - if machanism.is_element() and machanism.node_name == "mechanism": + if machanism.is_element() and machanism.name == "mechanism": if machanism.children[0].data == "PLAIN": plain_found = true break @@ -367,7 +319,7 @@ func _bind_resource(connection: Connection, resource: String = "tochie-facade") ## Check that server response corresponds to what we ask for. # todo: For conformity client must send closing stream tag on error. - if parsed_response._root.node_name != "stream:stream": return ERR_CANT_CONNECT + if parsed_response._root.name != "stream:stream": return ERR_CANT_CONNECT if parsed_response._root.attributes["from"] != connection.domain: return ERR_CANT_CONNECT if "to" in parsed_response._root.attributes: if parsed_response._root.attributes["to"] != connection.bare_jid: return ERR_CANT_CONNECT diff --git a/scenes/MucService.gd b/scenes/MucService.gd new file mode 100644 index 0000000..b19bb6b --- /dev/null +++ b/scenes/MucService.gd @@ -0,0 +1,61 @@ +extends Reference +class_name MucService + +## https://xmpp.org/extensions/xep-0045.html + +class MucRoom extends Reference: + var jid: String + var name: String + + func as_string() -> String: + return "MucRoom \"%s\", Jid: \"%s\"" % [name, jid] + +var jid: String + +var _connection: Connections.Connection +var _rooms_promise: Sums.Promise = null + +func try_init(connection: Connections.Connection, p_jid: String, disco_info: Xml.XmlElement): + var is_muc := false + for item in disco_info.children: + if item.is_element() and item.name == "feature": + if item.attributes["var"] == "http://jabber.org/protocol/muc": + is_muc = true + break + + if not is_muc: + return null + + self.jid = p_jid + self._connection = connection + return self + +func request_rooms() -> Sums.Promise: + if self._rooms_promise != null: + return self._rooms_promise + + self._rooms_promise = _connection.promise_iq( + jid, "get", + Stanza.disco_items_queury, + _iq_rooms()) + + return self._rooms_promise + +func _iq_rooms(): + var response = yield() + if not response.is_ok: + return response + + var query := Stanza.unwrap_query_result(response.value) + if not query.is_ok: + return query + + var rooms := Array() + for item in query.value.children: + if item.is_element() and item.name == "item": + var muc_room := MucRoom.new() + muc_room.jid = item.attributes["jid"] + muc_room.name = item.attributes["name"] + rooms.push_back(muc_room) + + return Sums.Result.make_value(rooms) diff --git a/scenes/PolyService.gd b/scenes/PolyService.gd new file mode 100644 index 0000000..dc27ddf --- /dev/null +++ b/scenes/PolyService.gd @@ -0,0 +1,13 @@ +extends Node +class_name PolyService + +class Identity extends Reference: + var category: String + var name: String + var type: String + +var jid: String +var identity: Identity + +## Interfaces are populated based on features exposed on any given resource. +var muc = null diff --git a/scenes/PolyServiceBuilder.gd b/scenes/PolyServiceBuilder.gd new file mode 100644 index 0000000..5ecf669 --- /dev/null +++ b/scenes/PolyServiceBuilder.gd @@ -0,0 +1,56 @@ +extends Reference +class_name PolyServiceBuilder + +## https://xmpp.org/extensions/xep-0030.html + +func request(connection: Connections.Connection) -> Sums.Promise: + return connection.promise_iq(connection.domain, "get", + Stanza.disco_items_queury, + _service_discovery(connection)) + +func _service_discovery(connection: Connections.Connection) -> Sums.Result: + var response = yield() + if not response.is_ok: + return response + + var query := Stanza.unwrap_query_result(response.value) + if not query.is_ok: + return query.value + + var feature_promises := Array() + var poly_services := Dictionary() # of Jid to PolyService + for item in query.value.children: + if not item.is_element() or item.name != "item": + continue + + var poly_service = load("res://scenes/PolyService.gd").new() + poly_service.jid = item.attributes["jid"] + if "name" in item.attributes: + poly_service.name = item.attributes["name"] + poly_services[item.attributes["jid"]] = poly_service + + feature_promises.push_back(connection.promise_iq( + item.attributes["jid"], "get", + Stanza.disco_info_queury, + Stanza.yield_as_is())) + + while not Sums.are_promises_done(feature_promises): + yield() + + # todo: Allow partial success. + if not Sums.are_promises_ok(feature_promises): + return Sums.Result.make_error(Sums.collect_promise_errors(feature_promises)) + + for feature_response in Sums.collect_promise_values(feature_promises): + var jid = feature_response.attributes["from"] + + var features := Stanza.unwrap_query_result(feature_response) + if not features.is_ok: + # todo: Signal the error. + continue + + var poly_service = poly_services[jid] + poly_service.muc = load("res://scenes/MucService.gd").new().try_init( + connection, jid, features.value) + + return Sums.Result.make_value(poly_services.values()) diff --git a/scenes/Stanza.gd b/scenes/Stanza.gd new file mode 100644 index 0000000..ee2c3df --- /dev/null +++ b/scenes/Stanza.gd @@ -0,0 +1,22 @@ +extends Node +class_name Stanza + +const disco_info_queury := "" +const disco_items_queury := "" + +static func yield_as_is(): + return yield() + +static func unwrap_query_result(iq: Xml.XmlElement) -> Sums.Result: + if iq == null or iq.name != "iq" or not "type" in iq.attributes: + return Sums.Result.make_error(ERR_INVALID_DATA) + + # todo: Delegate strctured XMPP error. + if iq.attributes["type"] != "result": + return Sums.Result.make_error(ERR_INVALID_DATA) + + for child in iq.children: + if child.is_element() and child.name == "query": + return Sums.Result.make_value(child) + + return Sums.Result.make_error(ERR_INVALID_DATA) diff --git a/scenes/Sums.gd b/scenes/Sums.gd new file mode 100644 index 0000000..bb2f70e --- /dev/null +++ b/scenes/Sums.gd @@ -0,0 +1,91 @@ +extends Node + +class Result extends Reference: + var is_ok: bool + var value = null + + static func make_value(p_value): + var result := Result.new() + result.is_ok = true + result.value = p_value + return result + + static func make_error(error): + var result := Result.new() + result.is_ok = false + # todo: Proper error object / convention. + result.value = [error, get_stack().slice(1, -1)] + return result + +class Promise extends Reference: + var is_done: bool = false + var is_ok: bool + var value = null + + signal done() + + static func from(capture: GDScriptFunctionState): + var result := Promise.new() + if capture.connect("completed", result, "_result_arrived") != OK: + assert("Bruh") + return result + + static func make_value(p_value): + var result := Promise.new() + result.is_done = true + result.is_ok = true + result.value = p_value + return result + + static func make_error(error): + var result := Promise.new() + result.is_done = true + result.is_ok = false + result.value = [error, get_stack().slice(1, -1)] + return result + + # todo: Use Error class value for erroneous state instead. + func _result_arrived(p_result) -> void: + self.is_done = true + self.is_ok = true + self.value = p_result + emit_signal("done") + +static func are_promises_done(promises: Array) -> bool: + for promise in promises: + assert(promise is Promise) + if not promise.is_done: + return false + return true + +static func are_promises_ok(promises: Array) -> bool: + for promise in promises: + assert(promise is Promise) + assert(promise.is_done) + if not promise.is_ok: + return false + return true + +static func collect_promise_values(promises: Array) -> Array: + var result := Array() + for promise in promises: + assert(promise is Promise) + assert(promise.is_done) + # todo: Don't do this. + if promise.value is Result: + assert(promise.value.is_ok) + result.push_back(promise.value.value) + else: result.push_back(promise.value) + return result + +static func collect_promise_errors(promises: Array) -> Array: + var result := Array() + for promise in promises: + assert(promise is Promise) + assert(promise.is_done) + if not promise.is_ok: + result.push_back(promise.value) + # todo: Don't do this. + elif promise.value is Result and not promise.value.is_ok: + result.push_back(promise.value.value) + return result diff --git a/scenes/Xml.gd b/scenes/Xml.gd index 712caea..75f85c2 100644 --- a/scenes/Xml.gd +++ b/scenes/Xml.gd @@ -1,26 +1,27 @@ extends Node +class_name Xml class XmlNode extends Reference: func is_element() -> bool: return false func is_text() -> bool: return false class XmlElement extends XmlNode: - var node_name: String + var name: String var attributes: Dictionary # String to String var children: Array # of XmlNode func is_element() -> bool: return true - func take_named_child_element(p_node_name: String) -> XmlElement: + func take_named_child_element(p_name: String) -> XmlElement: for idx in range(self.children.size()): var child := self.children[idx] as XmlElement - if child != null and child.node_name == p_node_name: + if child != null and child.name == p_name: self.children.remove(idx) return child return null func as_string(level: int = 0) -> String: - var result = '\t'.repeat(level) + "XmlElement \"%s\"" % self.node_name + var result = '\t'.repeat(level) + "XmlElement \"%s\"" % self.name if self.attributes.size() > 0: result += ", Attributes: " + String(self.attributes) for child in self.children: @@ -69,7 +70,7 @@ class Parser extends Reference: while parser.read() == OK: if parser.get_node_type() == XMLParser.NODE_ELEMENT: var element := XmlElement.new() - element.node_name = parser.get_node_name() + element.name = parser.get_node_name() var attribute_count := parser.get_attribute_count() for idx in range(attribute_count): element.attributes[parser.get_attribute_name(idx)] = parser.get_attribute_value(idx) @@ -88,8 +89,8 @@ class Parser extends Reference: return ERR_PARSE_ERROR var popped := self._element_stack.pop_back() as XmlElement - if popped.node_name != parser.get_node_name(): - push_error("Element <%s> closes sooner than <%s>" % [parser.get_node_name(), popped.node_name]) + if popped.name != parser.get_node_name(): + push_error("Element <%s> closes sooner than <%s>" % [parser.get_node_name(), popped.name]) return ERR_PARSE_ERROR if self._element_stack.size() == 0: