room joining

This commit is contained in:
veclav talica 2023-09-02 18:51:00 +05:00
parent e3a5224389
commit 2779ee566e
9 changed files with 447 additions and 118 deletions

View File

@ -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]

View File

@ -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:
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:
var me = service.muc.join_room(room, "tochie-facade")

View File

@ -1,7 +1,7 @@
extends Node
class_name Connections
const disco_info_queury := "<query xmlns='http://jabber.org/protocol/disco#info'/>"
const disco_items_queury := "<query xmlns='http://jabber.org/protocol/disco#items'/>"
# todo: Settle on whether connection should send Sums.Result or receiver should check stanza errors by itself.
class Connection extends Reference:
var stream: StreamPeer
@ -9,11 +9,13 @@ 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 _presence_sinks: Dictionary # of Jid to [[WeakRef, String]]
var _xml_parser := Xml.Parser.new()
# todo: Route signals to particular receivers, based on 'from' or 'to'
# warning-ignore:unused_signal
signal presence_received(presence)
# warning-ignore:unused_signal
@ -21,89 +23,55 @@ class Connection extends Reference:
# 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:
assert(action in ["get", "set"])
func promise_iq(to: String, type: String, payload: String, capture: GDScriptFunctionState) -> Sums.Promise:
assert(type in ["get", "set"])
var id := self.generate_id()
if self.stream.put_data(
"""<iq from='{from}' id='{id}' to='{to}' type='{action}'>{payload}</iq>""".format({
"""<iq from='{from}' id='{id}' to='{to}' type='{type}'>{payload}</iq>""".format({
"from": self.jid.xml_escape(),
"id": id,
"id": String(id).xml_escape(),
"to": to.xml_escape(),
"action": action,
"type": type,
"payload": payload
}).to_utf8()) != OK:
return Promise.make_error(ERR_CONNECTION_ERROR)
return Sums.Promise.make_error(ERR_CONNECTION_ERROR)
assert(not id in self._pending_iqs)
self._pending_iqs[id] = capture
return Promise.from(capture)
return Sums.Promise.from(capture)
func push_iq(to: String, action: String, payload: String, capture: GDScriptFunctionState) -> int: # Error
assert(action in ["get", "set"])
func push_presence(to: String, type, payload: String) -> Sums.Result:
assert(type == null or type is String)
var id := self.generate_id()
if self.stream.put_data(
"""<iq from='{from}' id='{id}' to='{to}' type='{action}'>{payload}</iq>""".format({
"from": self.jid.xml_escape(),
"id": id,
"to": to.xml_escape(),
"action": action,
"payload": payload
}).to_utf8()) != OK:
return ERR_CONNECTION_ERROR
var message = Stanza.form_presence(
String(self.generate_id()),
self.jid, to, type, payload)
assert(not id in self._pending_iqs)
self._pending_iqs[id] = capture
if self.stream.put_data(message.to_utf8()) != OK:
return Sums.Result.make_error(ERR_CONNECTION_ERROR)
return OK
return Sums.Result.make_value(null)
func presence_sink(p_base_jid: String, p_sink: Object, p_signal: String) -> void:
self._presence_sinks[p_base_jid] = \
self._presence_sinks.get(p_base_jid, []) + [[weakref(p_sink), p_signal]]
## 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,50 +85,47 @@ 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":
print(stanza.as_string())
if stanza.name == "iq":
if "to" in stanza.attributes and stanza.attributes["to"] != connection.jid:
# todo: Server errors should not be raised in client.
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)
if result is GDScriptFunctionState and result.is_valid():
connection._pending_tail_iqs.push_back(result)
# elif result != null:
# assert(false, "Ignored result of iq subroutine: " + "%s" % result)
connection._pending_iqs[id].resume(Sums.Result.make_value(stanza))
var was_present := connection._pending_iqs.erase(id)
assert(was_present)
elif stanza.attributes["type"] in ["set", "get"]:
# todo: Emit in any way?
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()
for tail_idx in range(connection._pending_tail_iqs.size()):
var result = connection._pending_tail_iqs[tail_idx].resume()
if not result is GDScriptFunctionState or not result.is_valid():
# assert(result == null, "Ignored result of iq subroutine: " + "%s" % result)
to_remove_tails.push_back(tail_idx)
else:
connection._pending_tail_iqs[tail_idx] = result
for base_jid in connection._presence_sinks.keys():
if not stanza.attributes["from"].begins_with(base_jid):
continue
for tail_idx in range(to_remove_tails.size() - 1, 0, -1):
connection._pending_tail_iqs.remove(to_remove_tails[tail_idx])
for to_emit in connection._presence_sinks[base_jid]:
to_emit[0].emit_signal(to_emit[1], stanza)
## Collect dropped connections.
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 +193,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 +264,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 +286,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 +332,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

125
scenes/MucService.gd Normal file
View File

@ -0,0 +1,125 @@
extends Reference
class_name MucService
## https://xmpp.org/extensions/xep-0045.html
class MucRoom extends Reference:
var jid: String
var name: String
var members: Dictionary # nick to MucMember
signal presence_received(presence)
func _init() -> void:
if self.connect("presence_received", self, "_presence_received") != OK:
assert(false)
func _presence_received(presence) -> void:
if presence.children.size() == 0:
return
var x = presence.get_named_child_element("x")
if x.name != "x" and x.attributes["xmlns"] != "http://jabber.org/protocol/muc#user":
## Member information came.
var item = x.get_named_child_element("item")
var nick = presence.attributes["from"].rsplit("/").pop_front()
var member = MucMember.new()
member.jid = presence.attributes["from"]
member.nick = nick
member.role = item.attributes["role"]
member.affiliation = item.attributes["affiliation"]
# member.room = self
members[nick] = member
func as_string() -> String:
return "MucRoom \"%s\", Jid: \"%s\"" % [name, jid]
class MucMember extends Reference:
var jid: String
var nick: String
var role: String
# var room: MucRoom
var affiliation: String
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() -> 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
var rooms := Array()
for item in query.value.children:
if item.is_element() and item.name == "item":
var muc_room := MucRoom.new()
_connection.presence_sink(muc_room.jid, muc_room, "presence_received")
muc_room.jid = item.attributes["jid"]
muc_room.name = item.attributes["name"]
rooms.push_back(muc_room)
return Sums.Result.make_value(rooms)
## Returns MucMemeber that is registered on behalf on user.
func join_room(room: MucRoom, nick: String) -> Sums.Promise:
# todo: https://xmpp.org/extensions/xep-0045.html#reservednick
return Sums.Promise.from(_join_room(room, nick))
func _join_room(room: MucRoom, nick: String) -> Sums.Result:
var member_jid = room.jid + '/' + nick
var result = _connection.push_presence(member_jid, null,
"<x xmlns='http://jabber.org/protocol/muc'/>")
if not result.is_ok:
return result
var response = Stanza.presence_result(yield(room, "presence_received"))
if not response.is_ok:
return response
while true:
response = Stanza.presence_result(yield(room, "presence_received"))
if not response.is_ok:
return response
if response.value.attributes["from"] == member_jid:
if response.value.children.size() == 0:
continue
var x = response.value.children[0]
if x.name != "x" and x.attributes["xmlns"] != "http://jabber.org/protocol/muc#user":
continue
for child in x.children:
if child.is_element() and child.name == "status" and child.attrbiutes["code"] == "110":
return Sums.Result.make_value(room.members[nick])
return Sums.Result.make_error(null)

13
scenes/PolyService.gd Normal file
View File

@ -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

View File

@ -0,0 +1,53 @@
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 poly_services := Array()
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.push_back(poly_service)
var feature_promise = connection.promise_iq(
item.attributes["jid"], "get",
Stanza.disco_info_queury,
Stanza.yield_as_is())
if not feature_promise.is_done:
yield(feature_promise, "done")
# todo: Propagate error if it's unrelated to service discovery itself.
if feature_promise.is_ok:
# If features arrived, - populate services.
var jid = feature_promise.value.attributes["from"]
var features := Stanza.unwrap_query_result(feature_promise.value)
if not features.is_ok:
# todo: Propagate the error.
continue
poly_service.muc = load("res://scenes/MucService.gd").new().try_init(
connection, jid, features.value)
return Sums.Result.make_value(poly_services)

45
scenes/Stanza.gd Normal file
View File

@ -0,0 +1,45 @@
extends Node
class_name Stanza
const disco_info_queury := "<query xmlns='http://jabber.org/protocol/disco#info'/>"
const disco_items_queury := "<query xmlns='http://jabber.org/protocol/disco#items'/>"
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)
static func form_presence(id: String, from: String, to: String, type, payload: String) -> String:
if type != null:
return """<presence from='{from}' id='{id}' to='{to}' type='{type}'>{payload}</presence>""".format({
"from": from.xml_escape(),
"id": id.xml_escape(),
"to": to.xml_escape(),
"type": type,
"payload": payload
})
else:
return """<presence from='{from}' id='{id}' to='{to}'>{payload}</presence>""".format({
"from": from.xml_escape(),
"id": id.xml_escape(),
"to": to.xml_escape(),
"payload": payload
})
static func presence_result(stanza: Xml.XmlElement) -> Sums.Result:
if "type" in stanza.attributes and stanza.attributes["type"] == "error":
return Sums.Result.make_error(stanza)
else:
return Sums.Result.make_value(stanza)

85
scenes/Sums.gd Normal file
View File

@ -0,0 +1,85 @@
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
# todo: Proper error object / convention.
result.value = [error, get_stack().slice(1, -1)]
return result
func _result_arrived(p_result: Result) -> void:
assert(p_result != null)
self.is_done = true
self.is_ok = p_result.is_ok
self.value = p_result.value
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)
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)
return result

View File

@ -1,26 +1,34 @@
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 get_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:
return child
return null
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.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 +77,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 +96,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: