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: