git » sdk » commit 3986c4b

Support for caps2 and caps data forms

author Stephen Paul Weber
2025-11-05 05:37:28 UTC
committer Stephen Paul Weber
2025-11-05 05:37:44 UTC
parent 95a83ef9c14e72c0df9e8e90fbf3739430ed28b3

Support for caps2 and caps data forms

And tests

borogove/Caps.hx +88 -11
borogove/Chat.hx +3 -3
borogove/Client.hx +5 -3
borogove/Util.hx +11 -0
borogove/persistence/IDB.js +10 -5
borogove/persistence/Sqlite.hx +15 -5
borogove/queries/DiscoInfoGet.hx +10 -3
npm/index.ts +0 -2
test/TestAll.hx +1 -0
test/TestCaps.hx +112 -0

diff --git a/borogove/Caps.hx b/borogove/Caps.hx
index 3df6ee0..b9223f3 100644
--- a/borogove/Caps.hx
+++ b/borogove/Caps.hx
@@ -1,20 +1,22 @@
 package borogove;
 
 import haxe.crypto.Base64;
+import haxe.ds.ReadOnlyArray;
 import haxe.io.Bytes;
 import haxe.io.BytesData;
 using Lambda;
+using borogove.Util;
 
+import borogove.DataForm;
 import borogove.Hash;
 import borogove.Util;
 
-@:expose
 class Caps {
 	public final node: String;
-	public final identities: Array<Identity>;
-	public final features : Array<String>;
+	public final identities: ReadOnlyArray<Identity>;
+	public final features : ReadOnlyArray<String>;
+	public final data: ReadOnlyArray<DataForm>;
 	private var _ver : Null<Hash> = null;
-	// TODO: data forms
 
 	@:allow(borogove)
 	private static function withIdentity(caps:KeyValueIterator<String, Null<Caps>>, category:Null<String>, type:Null<String>):Array<String> {
@@ -46,10 +48,18 @@ class Caps {
 		return result;
 	}
 
-	public function new(node: String, identities: Array<Identity>, features: Array<String>, ?ver: BytesData) {
+	public function new(node: String, identities: Array<Identity>, features: Array<String>, data: Array<DataForm>, ?ver: BytesData) {
+		if (ver == null) {
+			// If we won't need to generate ver we don't actually need to sort
+			features.sort((x, y) -> x == y ? 0 : (x < y ? -1 : 1));
+			identities.sort((x, y) -> x.ver() == y.ver() ? 0 : (x.ver() < y.ver() ? -1 : 1));
+			data.sort((x, y) -> Reflect.compare(x.field("FORM_TYPE")?.value, y.field("FORM_TYPE")?.value));
+		}
+
 		this.node = node;
 		this.identities = identities;
 		this.features = features;
+		this.data = data;
 		if (ver != null) {
 			_ver = new Hash("sha-1", ver);
 		}
@@ -68,6 +78,7 @@ class Caps {
 		for (feature in features) {
 			query.tag("feature", { "var": feature }).up();
 		}
+		query.addChildren(data);
 		return query;
 	}
 
@@ -78,12 +89,48 @@ class Caps {
 			node: node,
 			ver: ver()
 		}).up();
+		stanza.tag("c", {
+			xmlns: "urn:xmpp:caps",
+		}).textTag(
+			"hash",
+			Hash.sha256(hashInput()).toBase64(),
+			{ xmlns: "urn:xmpp:hashes:2", algo: "sha-256" }
+		).up();
 		return stanza;
 	}
 
+	private function hashInput(): Bytes {
+		var s = new haxe.io.BytesOutput();
+		for (feature in features) {
+			s.writeS(feature);
+			s.writeByte(0x1f);
+		}
+		s.writeByte(0x1c);
+		for (identity in identities) {
+			identity.writeTo(s);
+		}
+		s.writeByte(0x1c);
+		for (form in data) {
+			final fields = form.fields();
+			fields.sort((x, y) -> Reflect.compare([x.name].concat(x.value).join("\x1f"), [y.name].concat(y.value).join("\x1f")));
+			for (field in fields) {
+				final values = field.value;
+				values.sort(Reflect.compare);
+				s.writeS(field.name);
+				s.writeByte(0x1f);
+				for (value in values) {
+					s.writeS(value);
+					s.writeByte(0x1f);
+				}
+				s.writeByte(0x1e);
+			}
+			s.writeByte(0x1d);
+		}
+		s.writeByte(0x1c);
+		return s.getBytes();
+	}
+
 	private function computeVer(): Hash {
-		features.sort((x, y) -> x == y ? 0 : (x < y ? -1 : 1));
-		identities.sort((x, y) -> x.ver() == y.ver() ? 0 : (x.ver() < y.ver() ? -1 : 1));
 		var s = "";
 		for (identity in identities) {
 			s += identity.ver() + "<";
@@ -91,6 +138,21 @@ class Caps {
 		for (feature in features) {
 			s += feature + "<";
 		}
+		for (form in data) {
+			s += form.field("FORM_TYPE").value[0] + "<";
+			final fields = form.fields();
+			fields.sort((x, y) -> Reflect.compare(x.name, y.name));
+			for (field in fields) {
+				if (field.name != "FORM_TYPE") {
+					s += field.name + "<";
+					final values = field.value;
+					values.sort(Reflect.compare);
+					for (value in values) {
+						s += value + "<";
+					}
+				}
+			}
+		}
 		return Hash.sha1(bytesOfString(s));
 	}
 
@@ -104,23 +166,38 @@ class Caps {
 	}
 }
 
-@:expose
 class Identity {
 	public final category:String;
 	public final type:String;
 	public final name:String;
+	public final lang:String;
 
-	public function new(category:String, type: String, name: String) {
+	public function new(category:String, type: String, name: String, lang: Null<String> = null) {
 		this.category = category;
 		this.type = type;
 		this.name = name;
+		this.lang = lang ?? "";
 	}
 
 	public function addToDisco(stanza: Stanza) {
-		stanza.tag("identity", { category: category, type: type, name: name }).up();
+		var attrs: haxe.DynamicAccess<String> = { category: category, type: type, name: name };
+		if (lang != null && lang != "") attrs.set("xml:lang", lang);
+		stanza.tag("identity", attrs).up();
 	}
 
 	public function ver(): String {
-		return category + "/" + type + "//" + name;
+		return category + "/" + type + "/" + (lang ?? "") + "/" + name;
+	}
+
+	public function writeTo(out: haxe.io.Output) {
+		out.writeS(category);
+		out.writeByte(0x1f);
+		out.writeS(type);
+		out.writeByte(0x1f);
+		out.writeS(lang ?? "");
+		out.writeByte(0x1f);
+		out.writeS(name);
+		out.writeByte(0x1f);
+		out.writeByte(0x1e);
 	}
 }
diff --git a/borogove/Chat.hx b/borogove/Chat.hx
index 3781554..90067cb 100644
--- a/borogove/Chat.hx
+++ b/borogove/Chat.hx
@@ -547,7 +547,7 @@ abstract class Chat {
 
 	@:allow(borogove)
 	private function getResourceCaps(resource:String):Caps {
-		return presence[resource]?.caps ?? new Caps("", [], []);
+		return presence[resource]?.caps ?? new Caps("", [], [], []);
 	}
 
 	@:allow(borogove)
@@ -1046,7 +1046,7 @@ class DirectChat extends Chat {
 #end
 class Channel extends Chat {
 	@:allow(borogove)
-	private var disco: Caps = new Caps("", [], ["http://jabber.org/protocol/muc"]);
+	private var disco: Caps = new Caps("", [], ["http://jabber.org/protocol/muc"], []);
 	private var inSync = true;
 	private var sync = null;
 	private var forceLive = false;
@@ -1680,7 +1680,7 @@ class SerializedChat {
 			new DirectChat(client, stream, persistence, chatId, uiState, isBlocked, extensionsStanza, readUpToId, readUpToBy, omemoContactDeviceIDs);
 		} else if (klass == "Channel") {
 			final channel = new Channel(client, stream, persistence, chatId, uiState, isBlocked, extensionsStanza, readUpToId, readUpToBy);
-			channel.disco = disco ?? new Caps("", [], ["http://jabber.org/protocol/muc"]);
+			channel.disco = disco ?? new Caps("", [], ["http://jabber.org/protocol/muc"], []);
 			if (notificationsFiltered == null && !channel.isPrivate()) {
 				mention = filterN = true;
 			}
diff --git a/borogove/Client.hx b/borogove/Client.hx
index 777abcf..c750fd3 100644
--- a/borogove/Client.hx
+++ b/borogove/Client.hx
@@ -70,6 +70,7 @@ class Client extends EventEmitter {
 		[
 			"http://jabber.org/protocol/disco#info",
 			"http://jabber.org/protocol/caps",
+			"urn:xmpp:caps",
 			"urn:xmpp:avatar:metadata+notify",
 			"http://jabber.org/protocol/nick+notify",
 			"urn:xmpp:bookmarks:1+notify",
@@ -86,7 +87,8 @@ class Client extends EventEmitter {
 #if !NO_OMEMO
 			"eu.siacs.conversations.axolotl.devicelist+notify"
 #end
-		]
+		],
+		[]
 	);
 	private var _displayName: String;
 	private var fastMechanism: Null<String> = null;
@@ -889,7 +891,7 @@ class Client extends EventEmitter {
 				if (resultCaps == null) {
 					final err = discoGet.responseStanza?.getChild("error")?.getChild(null, "urn:ietf:params:xml:ns:xmpp-stanzas");
 					if (err == null || err?.name == "service-unavailable" || err?.name == "feature-not-implemented") {
-						add(new AvailableChat(jid.asString(), jid.node == null ? query : jid.node, jid.asString(), new Caps("", [], [])));
+						add(new AvailableChat(jid.asString(), jid.node == null ? query : jid.node, jid.asString(), new Caps("", [], [], [])));
 					}
 				} else {
 					persistence.storeCaps(resultCaps);
@@ -915,7 +917,7 @@ class Client extends EventEmitter {
 			if (chat.chatId != jid.asBare().asString()) {
 				if (chat.chatId.contains(query.toLowerCase()) || chat.getDisplayName().toLowerCase().contains(query.toLowerCase())) {
 					final channel = Util.downcast(chat, Channel);
-					results.push(new AvailableChat(chat.chatId, chat.getDisplayName(), chat.chatId, channel == null || channel.disco == null ? new Caps("", [], []) : channel.disco));
+					results.push(new AvailableChat(chat.chatId, chat.getDisplayName(), chat.chatId, channel == null || channel.disco == null ? new Caps("", [], [], []) : channel.disco));
 				}
 			}
 			if (chat.isTrusted()) {
diff --git a/borogove/Util.hx b/borogove/Util.hx
index 4a54a35..f6bdf5a 100644
--- a/borogove/Util.hx
+++ b/borogove/Util.hx
@@ -85,3 +85,14 @@ macro function getGitVersion():haxe.macro.Expr.ExprOf<String> {
 	return macro $v{commitHash};
 	#end
 }
+
+class Util {
+	inline static public function at<T>(arr: Array<T>, i: Int): T {
+		return arr[i];
+	}
+
+	inline static public function writeS(o: haxe.io.Output, s: String) {
+		final b = bytesOfString(s);
+		o.writeBytes(b, 0, b.length);
+	}
+}
diff --git a/borogove/persistence/IDB.js b/borogove/persistence/IDB.js
index 4c7c3ba..0fd229c 100644
--- a/borogove/persistence/IDB.js
+++ b/borogove/persistence/IDB.js
@@ -280,9 +280,9 @@ export default async (dbname, media, tokenize, stemmer) => {
 					readUpToId: chat.readUpToId,
 					readUpToBy: chat.readUpToBy,
 					notificationSettings: chat.notificationsFiltered() ? { mention: chat.notifyMention(), reply: chat.notifyReply() } : null,
-					disco: chat.disco,
+					disco: { ...chat.disco, data: chat.disco?.data?.map(d => d.toString()) },
 					omemoDevices: chat.omemoContactDeviceIDs,
-				class: chat instanceof borogove.DirectChat ? "DirectChat" : (chat instanceof borogove.Channel ? "Channel" : "Chat")
+					class: chat instanceof borogove.DirectChat ? "DirectChat" : (chat instanceof borogove.Channel ? "Channel" : "Chat")
 				});
 			}
 		},
@@ -308,7 +308,7 @@ export default async (dbname, media, tokenize, stemmer) => {
 				r.notificationSettings === undefined ? null : r.notificationSettings != null,
 				r.notificationSettings?.mention,
 				r.notificationSettings?.reply,
-				r.disco ? new borogove.Caps(r.disco.node, r.disco.identities, r.disco.features) : null,
+				r.disco ? new borogove.Caps(r.disco.node, r.disco.identities, r.disco.features, (r.disco.data || []).map(s => borogove.Stanza.parse(s))) : null,
 				r.omemoDevices || [],
 				r.class
 			)));
@@ -624,7 +624,7 @@ export default async (dbname, media, tokenize, stemmer) => {
 		storeCaps: function(caps) {
 			const tx = db.transaction(["keyvaluepairs"], "readwrite");
 			const store = tx.objectStore("keyvaluepairs");
-			store.put(caps, "caps:" + caps.ver()).onerror = console.error;
+			store.put({ ...caps, data: caps.data.map(d => d.toString()) }, "caps:" + caps.ver()).onerror = console.error;
 		},
 
 		getCaps: async function(ver) {
@@ -632,7 +632,12 @@ export default async (dbname, media, tokenize, stemmer) => {
 			const store = tx.objectStore("keyvaluepairs");
 			const raw = await promisifyRequest(store.get("caps:" + ver));
 			if (raw) {
-				return new borogove.Caps(raw.node, raw.identities.map((identity) => new borogove.Identity(identity.category, identity.type, identity.name)), raw.features);
+				return new borogove.Caps(
+					raw.node,
+					raw.identities.map((identity) => new borogove.Identity(identity.category, identity.type, identity.name)),
+					raw.features,
+					(raw.data || []).map(s => borogove.Stanza.parse(s))
+				);
 			}
 
 			return null;
diff --git a/borogove/persistence/Sqlite.hx b/borogove/persistence/Sqlite.hx
index 7b0884a..4d2005d 100644
--- a/borogove/persistence/Sqlite.hx
+++ b/borogove/persistence/Sqlite.hx
@@ -241,7 +241,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 			final chats: Array<Dynamic> = [];
 			for (row in result) {
 				final capsJson = row.caps == null ? null : Json.parse(row.caps);
-				row.capsObj = capsJson == null ? null : new Caps(capsJson.node, capsJson.identities.map(i -> new Identity(i.category, i.type, i.name), row.caps_ver), capsJson.features);
+				row.capsObj = capsJson == null ? null : hydrateCaps(capsJson, row.caps_ver);
 				final presenceJson: DynamicAccess<Dynamic> = Json.parse(row.presence);
 				row.presenceJson = presenceJson;
 				for (resource => presence in presenceJson) {
@@ -258,7 +258,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 			final capsMap: Map<String, Caps> = [];
 			for (row in result.caps) {
 				final json = Json.parse(row.caps);
-				capsMap[Base64.encode(Bytes.ofData(row.sha1))] = new Caps(json.node, json.identities.map(i -> new Identity(i.category, i.type, i.name), row.sha1), json.features);
+				capsMap[Base64.encode(Bytes.ofData(row.sha1))] = hydrateCaps(json, row.sha1);
 			}
 			result.caps = null;
 			final chats = [];
@@ -571,7 +571,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 			if (!first) q.add(",");
 			q.add("(?,jsonb(?))");
 			params.push(ver);
-			params.push(Json.stringify({ node: caps.node, identities: caps.identities, features: caps.features }));
+			params.push(Json.stringify({ node: caps.node, identities: caps.identities, features: caps.features, data: caps.data.map(d -> d.toString()) }));
 			first = false;
 		}
 		if (params.length < 1) return;
@@ -591,7 +591,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 		).then(result -> {
 			for (row in result) {
 				final json = Json.parse(row.caps);
-				return new Caps(json.node, json.identities.map(i -> new Identity(i.category, i.type, i.name)), json.features, verData);
+				return hydrateCaps(json, verData);
 			}
 			return null;
 		});
@@ -723,7 +723,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 						serviceId: row.service_id,
 						name: row.name,
 						node: row.node,
-						caps: new Caps(json.node, (json.identities ?? []).map(i -> new Identity(i.category, i.type, i.name)), features)
+						caps: hydrateCaps(json)
 					});
 				}
 			}
@@ -871,6 +871,16 @@ class Sqlite implements Persistence implements KeyValueStore {
 		}));
 	}
 
+	private function hydrateCaps(o: { node: Null<String>, identities: Array<{category: String, type: String, name: String}>, features: Array<String>, ?data: Array<String> }, ver: Null<BytesData> = null) {
+		return new Caps(
+			o.node,
+			(o.identities ?? []).map(i -> new Identity(i.category, i.type, i.name)),
+			o.features ?? [],
+			(o.data ?? []).map(d -> Stanza.parse(d)),
+			ver
+		);
+	}
+
 #if !NO_OMEMO
 	// OMEMO
 	// TODO
diff --git a/borogove/queries/DiscoInfoGet.hx b/borogove/queries/DiscoInfoGet.hx
index 4eeff2d..366d725 100644
--- a/borogove/queries/DiscoInfoGet.hx
+++ b/borogove/queries/DiscoInfoGet.hx
@@ -6,7 +6,6 @@ import haxe.Exception;
 import borogove.ID;
 import borogove.ResultSet;
 import borogove.Stanza;
-import borogove.Stream;
 import borogove.queries.GenericQuery;
 import borogove.Caps;
 
@@ -46,8 +45,16 @@ class DiscoInfoGet extends GenericQuery {
 			final features = q.allTags("feature");
 			result = new Caps(
 				q.attr.get("node"),
-				identities.map((identity) -> new Identity(identity.attr.get("category"), identity.attr.get("type"), identity.attr.get("name"))),
-				features.map((feature) -> feature.attr.get("var"))
+				identities.map((identity) ->
+					new Identity(
+						identity.attr.get("category"),
+						identity.attr.get("type"),
+						identity.attr.get("name"),
+						identity.attr.get("xml:lang")
+					)
+				),
+				features.map((feature) -> feature.attr.get("var")),
+				q.allTags("x", "jabber:x:data")
 			);
 		}
 		return result;
diff --git a/npm/index.ts b/npm/index.ts
index 9ceb01f..101a04e 100644
--- a/npm/index.ts
+++ b/npm/index.ts
@@ -5,7 +5,6 @@ import { borogove } from "./borogove.js";
 
 // TODO: should we autogenerate this?
 export import AvailableChat = borogove.AvailableChat;
-export import Caps = borogove.Caps;
 export import Channel = borogove.Channel;
 export import Chat = borogove.Chat;
 export import ChatAttachment = borogove.ChatAttachment;
@@ -18,7 +17,6 @@ export import DirectChat = borogove.DirectChat;
 export import EventEmitter = borogove.EventEmitter;
 export import Hash = borogove.Hash;
 export import Identicon = borogove.Identicon;
-export import Identity = borogove.Identity;
 export import Notification = borogove.Notification;
 export import Participant = borogove.Participant;
 export import Persistence = borogove.Persistence;
diff --git a/test/TestAll.hx b/test/TestAll.hx
index b0e51ac..6d7c949 100644
--- a/test/TestAll.hx
+++ b/test/TestAll.hx
@@ -9,6 +9,7 @@ class TestAll {
 			new TestSessionDescription(),
 			new TestChatMessageBuilder(),
 			new TestStanza(),
+			new TestCaps(),
 		]);
 	}
 }
diff --git a/test/TestCaps.hx b/test/TestCaps.hx
new file mode 100644
index 0000000..bcd3901
--- /dev/null
+++ b/test/TestCaps.hx
@@ -0,0 +1,112 @@
+package test;
+
+import utest.Assert;
+import utest.Async;
+import borogove.Caps;
+import borogove.Hash;
+import borogove.Stanza;
+import borogove.queries.DiscoInfoGet;
+
+@:access(borogove.Caps.hashInput)
+@:access(borogove.Hash.sha256)
+class TestCaps extends utest.Test {
+	final example = '
+	<iq><query xmlns="http://jabber.org/protocol/disco#info" node="somenode">
+		<identity category="client" name="Tkabber" type="pc" xml:lang="en"/>
+		<identity category="client" name="Ткаббер" type="pc" xml:lang="ru"/>
+		<feature var="games:board"/>
+		<feature var="http://jabber.org/protocol/activity"/>
+		<feature var="http://jabber.org/protocol/activity+notify"/>
+		<feature var="http://jabber.org/protocol/bytestreams"/>
+		<feature var="http://jabber.org/protocol/chatstates"/>
+		<feature var="http://jabber.org/protocol/commands"/>
+		<feature var="http://jabber.org/protocol/disco#info"/>
+		<feature var="http://jabber.org/protocol/disco#items"/>
+		<feature var="http://jabber.org/protocol/evil"/>
+		<feature var="http://jabber.org/protocol/feature-neg"/>
+		<feature var="http://jabber.org/protocol/geoloc"/>
+		<feature var="http://jabber.org/protocol/geoloc+notify"/>
+		<feature var="http://jabber.org/protocol/ibb"/>
+		<feature var="http://jabber.org/protocol/iqibb"/>
+		<feature var="http://jabber.org/protocol/mood"/>
+		<feature var="http://jabber.org/protocol/mood+notify"/>
+		<feature var="http://jabber.org/protocol/rosterx"/>
+		<feature var="http://jabber.org/protocol/si"/>
+		<feature var="http://jabber.org/protocol/si/profile/file-transfer"/>
+		<feature var="http://jabber.org/protocol/tune"/>
+		<feature var="http://www.facebook.com/xmpp/messages"/>
+		<feature var="http://www.xmpp.org/extensions/xep-0084.html#ns-metadata+notify"/>
+		<feature var="jabber:iq:avatar"/>
+		<feature var="jabber:iq:browse"/>
+		<feature var="jabber:iq:dtcp"/>
+		<feature var="jabber:iq:filexfer"/>
+		<feature var="jabber:iq:ibb"/>
+		<feature var="jabber:iq:inband"/>
+		<feature var="jabber:iq:jidlink"/>
+		<feature var="jabber:iq:last"/>
+		<feature var="jabber:iq:oob"/>
+		<feature var="jabber:iq:privacy"/>
+		<feature var="jabber:iq:roster"/>
+		<feature var="jabber:iq:time"/>
+		<feature var="jabber:iq:version"/>
+		<feature var="jabber:x:data"/>
+		<feature var="jabber:x:event"/>
+		<feature var="jabber:x:oob"/>
+		<feature var="urn:xmpp:avatar:metadata+notify"/>
+		<feature var="urn:xmpp:ping"/>
+		<feature var="urn:xmpp:receipts"/>
+		<feature var="urn:xmpp:time"/>
+		<x xmlns="jabber:x:data" type="result">
+				<field type="hidden" var="FORM_TYPE">
+						<value>urn:xmpp:dataforms:softwareinfo</value>
+				</field>
+				<field var="software">
+						<value>Tkabber</value>
+				</field>
+				<field var="software_version">
+						<value>0.11.1-svn-20111216-mod (Tcl/Tk 8.6b2)</value>
+				</field>
+				<field var="os">
+						<value>Windows</value>
+				</field>
+				<field var="os_version">
+						<value>XP</value>
+				</field>
+		</x>
+	</query></iq>
+	';
+
+	public function caps(): Caps {
+		final dig = new DiscoInfoGet("");
+		dig.handleResponse(Stanza.parse(example));
+		return dig.getResult();
+	}
+
+	public function testCaps2() {
+		final sha256 = Hash.sha256(caps().hashInput()).toBase64();
+		Assert.equals("u79ZroNJbdSWhdSp311mddz44oHHPsEBntQ5b1jqBSY=", sha256);
+	}
+
+	public function testCaps1() {
+		final sha1 = caps().ver();
+		Assert.equals("cePxJUNNZuDoNDbCMqs2VNEcJeY=", sha1);
+	}
+
+	public function testRoundTrip() {
+		final dig = new DiscoInfoGet("");
+		final iq = new Stanza("iq");
+		iq.addChild(caps().discoReply());
+		dig.handleResponse(iq);
+		final sha256 = Hash.sha256(dig.getResult().hashInput()).toBase64();
+		Assert.equals("u79ZroNJbdSWhdSp311mddz44oHHPsEBntQ5b1jqBSY=", sha256);
+	}
+
+	public function testAddC() {
+		final s = new Stanza("presence");
+		caps().addC(s);
+		Assert.equals(
+			'<presence><c xmlns="http://jabber.org/protocol/caps" ver="cePxJUNNZuDoNDbCMqs2VNEcJeY=" node="somenode" hash="sha-1"/><c xmlns="urn:xmpp:caps"><hash xmlns="urn:xmpp:hashes:2" algo="sha-256">u79ZroNJbdSWhdSp311mddz44oHHPsEBntQ5b1jqBSY=</hash></c></presence>',
+			s.toString()
+		);
+	}
+}