263 lines
9.5 KiB
Swift
263 lines
9.5 KiB
Swift
// RFC 6121
|
|
// XEP-0237
|
|
import Foundation
|
|
|
|
final class RosterModule: XmppModule {
|
|
let id = "Roseter module"
|
|
|
|
private weak var storage: (any XMPPStorage)?
|
|
private var isVerSupported = false
|
|
private let registry = StanzaRegistry()
|
|
|
|
init(_ storage: any XMPPStorage) {
|
|
self.storage = storage
|
|
}
|
|
|
|
func reduce(oldState: ClientState, with _: Event) -> ClientState {
|
|
oldState
|
|
}
|
|
|
|
func process(state: ClientState, with event: Event) async -> Event? {
|
|
switch event {
|
|
case .streamReady:
|
|
return .requestRoster
|
|
|
|
case .xmlInbound(let xml):
|
|
if xml.name == "stream:features" {
|
|
if let ver = xml.nodes.first(where: { $0.name == "ver" }), ver.xmlns == "urn:xmpp:features:rosterver" {
|
|
isVerSupported = true
|
|
}
|
|
}
|
|
return nil
|
|
|
|
case .requestRoster:
|
|
var attributes: [String: String] = [:]
|
|
if isVerSupported {
|
|
let ver = await storage?.getRosterVer(jid: state.jid)
|
|
attributes["ver"] = ver ?? ""
|
|
}
|
|
let req = Stanza.iqGet(
|
|
from: state.jid.full,
|
|
payload: XMLElement(
|
|
name: "query",
|
|
xmlns: "jabber:iq:roster",
|
|
attributes: attributes,
|
|
content: nil,
|
|
nodes: []
|
|
)
|
|
)
|
|
if let req {
|
|
await registry.enqueue(req)
|
|
return .stanzaOutbound(req)
|
|
} else {
|
|
return nil
|
|
}
|
|
|
|
case .addRosterItem(let jidStr, let args), .updateRosterItem(let jidStr, let args):
|
|
var attr = ["jid": jidStr]
|
|
if let name = args["name"] {
|
|
attr["name"] = name
|
|
}
|
|
let req = Stanza.iqSet(
|
|
from: state.jid.full,
|
|
payload: XMLElement(
|
|
name: "query",
|
|
xmlns: "jabber:iq:roster",
|
|
attributes: [:],
|
|
content: nil,
|
|
nodes: [
|
|
XMLElement(
|
|
name: "item",
|
|
xmlns: nil,
|
|
attributes: attr,
|
|
content: nil,
|
|
nodes: []
|
|
)
|
|
]
|
|
)
|
|
)
|
|
if let req {
|
|
await registry.enqueue(req)
|
|
return .stanzaOutbound(req)
|
|
} else {
|
|
return nil
|
|
}
|
|
|
|
case .deleteRosterItem(let jidStr):
|
|
let req = Stanza.iqSet(
|
|
from: state.jid.full,
|
|
payload: XMLElement(
|
|
name: "query",
|
|
xmlns: "jabber:iq:roster",
|
|
attributes: [:],
|
|
content: nil,
|
|
nodes: [
|
|
XMLElement(
|
|
name: "item",
|
|
xmlns: nil,
|
|
attributes: ["jid": jidStr, "subscription": "remove"],
|
|
content: nil,
|
|
nodes: []
|
|
)
|
|
]
|
|
)
|
|
)
|
|
if let req {
|
|
await registry.enqueue(req)
|
|
return .stanzaOutbound(req)
|
|
} else {
|
|
return nil
|
|
}
|
|
|
|
case .stanzaInbound(let stanza):
|
|
if let query = stanza.wrapped.nodes.first(where: { $0.name == "query" }), query.xmlns == "jabber:iq:roster" {
|
|
// update version if needed
|
|
if let ver = stanza.wrapped.nodes.first(where: { $0.name == "query" })?.attributes["ver"] {
|
|
await storage?.setRosterVer(jid: state.jid, version: ver)
|
|
}
|
|
|
|
// process stanza
|
|
return await processInbound(state: state, stanza: stanza)
|
|
} else {
|
|
return nil
|
|
}
|
|
|
|
// if we signaling "result" to server it means roster was updated on this resource
|
|
case .stanzaOutbound(let stanza):
|
|
if let query = stanza.wrapped.nodes.first(where: { $0.name == "query" }), query.xmlns == "jabber:iq:roster", stanza.type == .iq(.result) {
|
|
return .rosterUpdated
|
|
} else {
|
|
return nil
|
|
}
|
|
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension RosterModule {
|
|
func processInbound(state: ClientState, stanza: Stanza) async -> Event? {
|
|
switch stanza.type {
|
|
// "set" type stanza from server is just "push", so change roster accordingly
|
|
case .iq(.set):
|
|
// sanity check (according RFC6121 skip this push if its not for current resource)
|
|
if stanza.wrapped.attributes["to"] != state.jid.bare {
|
|
return nil
|
|
}
|
|
|
|
// get exists roster items
|
|
var existItems: [RosterItem] = []
|
|
if let data = await storage?.getRoster(jid: state.jid), let decoded = try? JSONDecoder().decode([XMLElement].self, from: data) {
|
|
existItems = decoded.compactMap { RosterItem(wrap: $0, owner: state.jid) }
|
|
}
|
|
|
|
// get item from stanza
|
|
let xmlItem = stanza.wrapped
|
|
.nodes
|
|
.first(where: { $0.name == "query" })?
|
|
.nodes
|
|
.first(where: { $0.name == "item" })
|
|
guard let xmlItem, let item = RosterItem(wrap: xmlItem, owner: state.jid) else { return nil }
|
|
|
|
// filter out exists items
|
|
existItems = existItems.filter { $0.id != item.id }
|
|
|
|
// append updated/new item (if it not "removed")
|
|
if item.subsription != .remove {
|
|
existItems.append(item)
|
|
}
|
|
|
|
// save roster
|
|
guard let data = try? JSONEncoder().encode(existItems.map { $0.wrapped }) else { return nil }
|
|
await storage?.setRoster(jid: state.jid, roster: data)
|
|
|
|
// according RFC6120 it is not necessary to answer server with "result" on push, but why not?
|
|
let result = Stanza.iqResult(
|
|
from: state.jid.full,
|
|
payload: XMLElement(
|
|
name: "query",
|
|
xmlns: "jabber:iq:roster",
|
|
attributes: [:],
|
|
content: nil,
|
|
nodes: []
|
|
)
|
|
)
|
|
if let result {
|
|
return .stanzaOutbound(result)
|
|
} else {
|
|
return nil
|
|
}
|
|
|
|
// the "result" from server is an answer from one of our "set", or initial roster request
|
|
case .iq(.result):
|
|
// get request stanza from registry
|
|
guard let respId = stanza.id, let req = await registry.deuque(for: respId) else { return nil }
|
|
|
|
// get exists roster items
|
|
var existItems: [RosterItem] = []
|
|
if let data = await storage?.getRoster(jid: state.jid), let decoded = try? JSONDecoder().decode([XMLElement].self, from: data) {
|
|
existItems = decoded.compactMap { RosterItem(wrap: $0, owner: state.jid) }
|
|
}
|
|
|
|
// perform changes based on stanza request type
|
|
switch req.type {
|
|
// for simple roster request
|
|
case .iq(.get):
|
|
// get items from response
|
|
let stanzaItems = stanza.wrapped
|
|
.nodes
|
|
.first(where: { $0.name == "query" })?
|
|
.nodes
|
|
.filter { $0.name == "item" }
|
|
.compactMap { RosterItem(wrap: $0, owner: state.jid) } ?? []
|
|
let stanzaItemsIds = stanzaItems.map { $0.id }
|
|
|
|
// filter out exists items and append updated
|
|
existItems = existItems.filter { !stanzaItemsIds.contains($0.id) }
|
|
existItems.append(contentsOf: stanzaItems)
|
|
|
|
// save roster
|
|
guard let data = try? JSONEncoder().encode(existItems.map { $0.wrapped }) else { return nil }
|
|
await storage?.setRoster(jid: state.jid, roster: data)
|
|
return .rosterUpdated
|
|
|
|
// for result of one of our request
|
|
case .iq(.set):
|
|
// get item from request stanza
|
|
let xmlItem = req.wrapped
|
|
.nodes
|
|
.first(where: { $0.name == "query" })?
|
|
.nodes
|
|
.first(where: { $0.name == "item" })
|
|
guard let xmlItem, let item = RosterItem(wrap: xmlItem, owner: state.jid) else { return nil }
|
|
|
|
// filter out exists items
|
|
existItems = existItems.filter { $0.id != item.id }
|
|
|
|
// append updated/new item (if it not "removed")
|
|
if item.subsription != .remove {
|
|
existItems.append(item)
|
|
}
|
|
|
|
// save roster
|
|
guard let data = try? JSONEncoder().encode(existItems.map { $0.wrapped }) else { return nil }
|
|
await storage?.setRoster(jid: state.jid, roster: data)
|
|
return .rosterUpdated
|
|
|
|
default:
|
|
return nil
|
|
}
|
|
|
|
// TODO: add error handling here
|
|
case .iq(.error):
|
|
guard let respId = stanza.id, let req = await registry.deuque(for: respId) else { return nil }
|
|
print("Error on request: \(req.wrapped)\n with: \(stanza.wrapped)")
|
|
return nil
|
|
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
}
|