git » sdk » commit 510b2a1

Initial role system

author Stephen Paul Weber
2026-04-22 17:48:08 UTC
committer Stephen Paul Weber
2026-04-22 18:03:20 UTC
parent 3e8dacb8934079dd52e791d7f2308015336e175f

Initial role system

borogove/Chat.hx +73 -3
borogove/Participant.hx +13 -1
borogove/Presence.hx +7 -1
borogove/Role.hx +49 -0
test/TestChat.hx +101 -0
test/TestHtml.hx +2 -2
test/TestPresence.hx +18 -0

diff --git a/borogove/Chat.hx b/borogove/Chat.hx
index dd126c7..e4780fb 100644
--- a/borogove/Chat.hx
+++ b/borogove/Chat.hx
@@ -225,6 +225,23 @@ abstract class Chat {
 	**/
 	abstract public function getParticipants():Array<String>;
 
+	/**
+		Roles the current user can assign to the target participant
+	**/
+	public function availableRoles(participantId: String):Array<Role> {
+		return [];
+	}
+
+	/**
+		Add a role to a participant
+	**/
+	public function addRole(participantId: String, role: Role) { }
+
+	/**
+		Remove a role from a participant
+	**/
+	public function removeRole(participantId: String, role: Role) { }
+
 	/**
 		Get the details for one participant in this Chat
 
@@ -999,6 +1016,7 @@ class DirectChat extends Chat {
 			chat.getPhoto(),
 			chat.getPlaceholder(),
 			chat.chatId == client.accountId(),
+			[], // No roles in direct chat
 			JID.parse(participantId),
 			new AvailableChat(participantId, chat.getDisplayName(), "", new Caps("", [], [], []))
 		);
@@ -1698,6 +1716,14 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H
 
 	@HaxeCBridge.noemit // on superclass as abstract
 	public function getParticipantDetails(participantId:String): Participant {
+		final jid = JID.parse(participantId);
+		final nick = jid.resource;
+		final ppresence = presence[nick];
+		final roles = ppresence?.hats ?? [];
+		if (ppresence?.mucUser != null) {
+			final affRole = Role.forAffiliation(ppresence.mucUser.affiliation);
+			if (affRole != null) roles.unshift(affRole);
+		}
 		if (participantId == getFullJid().asString()) {
 			final chat = client.getDirectChat(client.accountId(), false);
 			return new Participant(
@@ -1705,25 +1731,69 @@ trace("XYZZY no MUC avatar locally matching so fetch vcard", chatId, avatarSha1H
 				chat.getPhoto(),
 				chat.getPlaceholder(),
 				true,
+				roles,
 				JID.parse(chat.chatId),
 				new AvailableChat(chat.chatId, chat.getDisplayName(), "", new Caps("", [], [], []))
 			);
 		} else {
-			final jid = JID.parse(participantId);
-			final nick = jid.resource;
 			final placeholderUri = Color.defaultPhoto(participantId, nick == null ? " " : nick.charAt(0));
-			final ppresence = presence[nick];
 			return new Participant(
 				nick ?? "",
 				ppresence?.avatarHash?.toUri(),
 				placeholderUri,
 				false,
+				roles,
 				jid,
 				ppresence?.mucUser?.jid == null ? null : new AvailableChat(ppresence.mucUser.jid.asBare().asString(), nick ?? "", "", new Caps("", [], [], []))
 			);
 		}
 	}
 
+	override public function availableRoles(participantId: String):Array<Role> {
+		if (_nickInUse == null) return [];
+
+		final p = presence[_nickInUse];
+		if (p?.mucUser == null) return [];
+
+		// TODO: this should get their affiliation from the list not using presence
+		// once we are fetching the affiliation list
+		// That's probably true everywhere we use affiliation
+		final pjid = JID.parse(participantId);
+		final pnick = pjid.resource;
+		final ppresence = presence[pnick];
+		if (ppresence?.mucUser == null) return [];
+		if (ppresence?.mucUser?.jid == null) return [];
+
+		if (p.mucUser.affiliation == "owner") {
+			return ["owner", "admin", "member", "outcast"].filter(aff -> aff != ppresence.mucUser.affiliation).map(aff -> Role.forAffiliation(aff));
+		}
+
+		if (p.mucUser.affiliation == "admin") {
+			if (ppresence.mucUser.affiliation == "owner") return [];
+
+			return ["member", "outcast"].filter(aff -> aff != ppresence.mucUser.affiliation).map(aff -> Role.forAffiliation(aff));
+		}
+
+		return [];
+	}
+
+	override public function addRole(participantId: String, role: Role) {
+		final pjid = JID.parse(participantId);
+		final pnick = pjid.resource;
+		final ppresence = presence[pnick];
+		if (ppresence?.mucUser?.jid == null) return;
+
+		final iq = new Stanza("iq", { type: "set", to: chatId })
+			.tag("query", { xmlns: "http://jabber.org/protocol/muc#admin" })
+			.textTag("item", "", { affiliation: role.id, jid: ppresence.mucUser.jid.asBare().asString() });
+		stream.sendIq(iq, (response) -> {});
+	}
+
+	override public function removeRole(participantId: String, role: Role) {
+		// For affiliation-backed roles we remove them by adding affiliation of none
+		addRole(participantId, new Role("none", ""));
+	}
+
 	@HaxeCBridge.noemit // on superclass as abstract
 	public function getMessagesBefore(before: Null<ChatMessage>):Promise<Array<ChatMessage>> {
 		if (before != null && before.chatId() != chatId) throw "Cannot look before from a different chat";
diff --git a/borogove/Participant.hx b/borogove/Participant.hx
index a339123..28ee677 100644
--- a/borogove/Participant.hx
+++ b/borogove/Participant.hx
@@ -1,6 +1,7 @@
 package borogove;
 
 import thenshim.Promise;
+import haxe.ds.ReadOnlyArray;
 
 import borogove.Chat;
 import borogove.queries.PubsubGet;
@@ -20,30 +21,41 @@ class Participant {
 		Display name to show for this participant
 	**/
 	public final displayName: String;
+
 	/**
 		Avatar URI for this participant, or null when none is known
 	**/
 	public final photoUri: Null<String>;
+
 	/**
 		Fallback avatar URI to use when no photo is available
 	**/
 	public final placeholderUri: String;
+
 	/**
 		True when this participant is the connected account
 	**/
 	public final isSelf: Bool;
+
 	/**
 		Chat metadata for this participant when it is available as a direct Chat
 	**/
 	public final chat: Null<AvailableChat>;
+
+	/**
+		Roles this participant has in the Chat
+	**/
+	public final roles: ReadOnlyArray<Role>;
+
 	private final jid: JID;
 
 	@:allow(borogove)
-	private function new(displayName: String, photoUri: Null<String>, placeholderUri: String, isSelf: Bool, jid: JID, chat: Null<AvailableChat>) {
+	private function new(displayName: String, photoUri: Null<String>, placeholderUri: String, isSelf: Bool, roles: Array<Role>, jid: JID, chat: Null<AvailableChat>) {
 		this.displayName = displayName;
 		this.photoUri = photoUri;
 		this.placeholderUri = placeholderUri;
 		this.isSelf = isSelf;
+		this.roles = roles;
 		this.chat = chat;
 		this.jid = jid;
 	}
diff --git a/borogove/Presence.hx b/borogove/Presence.hx
index 05dde65..a537e20 100644
--- a/borogove/Presence.hx
+++ b/borogove/Presence.hx
@@ -1,7 +1,8 @@
 package borogove;
 
-import borogove.MucUser;
 import borogove.Hash;
+import borogove.MucUser;
+import borogove.Role;
 
 @:nullSafety(StrictThreaded)
 @:forward(toString)
@@ -10,6 +11,7 @@ abstract Presence(Stanza) from Stanza to Stanza {
 	public var capsNode(get, never): Null<String>;
 	public var ver(get, never): Null<String>;
 	public var mucUser(get, never): Null<MucUser>;
+	public var hats(get, never): Null<Array<Role>>;
 	public var avatarHash(get, never): Null<Hash>;
 
 	public function new(caps: Null<Caps>, mucUser: Null<MucUser>, avatarHash: Null<Hash>): Presence {
@@ -37,6 +39,10 @@ abstract Presence(Stanza) from Stanza to Stanza {
 		return this.getChild("x", "http://jabber.org/protocol/muc#user");
 	}
 
+	private inline function get_hats() {
+		return (this.getChild("hats", "urn:xmpp:hats:0")?.allTags("hat") ?? []).map(hat -> new Role(hat.attr.get("uri") ?? "", hat.attr.get("title") ?? ""));
+	}
+
 	private inline function get_avatarHash() {
 		final avatarSha1Hex = this.findText("{vcard-temp:x:update}x/photo#");
 		return avatarSha1Hex == null || avatarSha1Hex == "" ? null : Hash.fromHex("sha-1", avatarSha1Hex);
diff --git a/borogove/Role.hx b/borogove/Role.hx
new file mode 100644
index 0000000..a0b0af4
--- /dev/null
+++ b/borogove/Role.hx
@@ -0,0 +1,49 @@
+package borogove;
+
+import borogove.Color;
+
+@:expose
+@:nullSafety(Strict)
+#if cpp
+@:build(HaxeCBridge.expose())
+@:build(HaxeSwiftBridge.expose())
+#end
+class Role {
+	// A role is the unification of XMPP affiliations and hats
+	// importantly, it is *not* and XMPP MUC role
+
+	/**
+		Unique id for the role
+	**/
+	public final id: String;
+
+	/**
+		Human readable name for the role
+	**/
+	public final title: String;
+
+	@:allow(borogove)
+	private function new(id: String, title: String) {
+		this.id = id;
+		this.title = title;
+	}
+
+	@:allow(borogove)
+	private static function forAffiliation(aff: String) {
+		final title = switch (aff) {
+			case "outcast": "Banned";
+			case "member": "Member";
+			case "admin": "Admin";
+			case "owner": "Owner";
+			default: return null;
+		}
+		return new Role(aff, title);
+	}
+
+	/**
+		Suggested color to use when displaying this Role
+	**/
+	public function color() {
+		return Color.forString(id);
+	}
+}
diff --git a/test/TestChat.hx b/test/TestChat.hx
index 6ae661d..c88e3c3 100644
--- a/test/TestChat.hx
+++ b/test/TestChat.hx
@@ -351,4 +351,105 @@ class TestChat extends utest.Test {
 		channel.prepareIncomingMessage(builder, stanza);
 		Assert.isTrue(builder.syncPoint, "Message SHOULD have syncPoint if inSync");
 	}
+
+	public function testGetParticipantDetailsWithRoles() {
+		final persistence = new Dummy();
+		final client = new Client("test@example.com", persistence);
+		final chat = new Channel(client, client.stream, persistence, "channel@example.com");
+
+		final stanza = Stanza.parse('<presence from="channel@example.com/other">
+			<x xmlns="http://jabber.org/protocol/muc#user"><item affiliation="admin" role="participant"/></x>
+			<hats xmlns="urn:xmpp:hats:0">
+				<hat uri="http://example.com/custom" title="Custom Role"/>
+			</hats>
+		</presence>');
+		chat.presence.set("other", stanza);
+
+		final details = chat.getParticipantDetails("channel@example.com/other");
+		Assert.equals(2, details.roles.length);
+		Assert.equals("admin", details.roles[0].id);
+		Assert.equals("Admin", details.roles[0].title);
+		Assert.equals("http://example.com/custom", details.roles[1].id);
+		Assert.equals("Custom Role", details.roles[1].title);
+	}
+
+	public function testAvailableRoles() {
+		final persistence = new Dummy();
+		final client = new Client("test@example.com", persistence);
+		final chat = new Channel(client, client.stream, persistence, "channel@example.com");
+		chat._nickInUse = "me";
+
+		// I am owner
+		final myPresence = Stanza.parse('<presence from="channel@example.com/me">
+			<x xmlns="http://jabber.org/protocol/muc#user"><item affiliation="owner" role="moderator"/></x>
+		</presence>');
+		chat.presence.set("me", myPresence);
+
+		// Other is member
+		final otherPresence = Stanza.parse('<presence from="channel@example.com/other">
+			<x xmlns="http://jabber.org/protocol/muc#user"><item affiliation="member" role="participant" jid="other@example.com"/></x>
+		</presence>');
+		chat.presence.set("other", otherPresence);
+
+		final roles = chat.availableRoles("channel@example.com/other");
+		final ids = roles.map(r -> r.id);
+		Assert.contains("owner", ids);
+		Assert.contains("admin", ids);
+		Assert.contains("outcast", ids);
+		Assert.isFalse(ids.contains("member"), "Should not include current role");
+	}
+
+	public function testAddRole(async: Async) {
+		final persistence = new Dummy();
+		final client = new Client("test@example.com", persistence);
+		final chat = new Channel(client, client.stream, persistence, "channel@example.com");
+
+		final otherPresence = Stanza.parse('<presence from="channel@example.com/other">
+			<x xmlns="http://jabber.org/protocol/muc#user"><item affiliation="member" role="participant" jid="other@example.com"/></x>
+		</presence>');
+		chat.presence.set("other", otherPresence);
+
+		client.stream.on("sendStanza", (stanza: Stanza) -> {
+			if (stanza.name == "iq" && stanza.attr.get("type") == "set") {
+				final query = stanza.getChild("query", "http://jabber.org/protocol/muc#admin");
+				if (query != null) {
+					final item = query.getChild("item");
+					Assert.equals("admin", item.attr.get("affiliation"));
+					Assert.equals("other@example.com", item.attr.get("jid"));
+					async.done();
+					return EventHandled;
+				}
+			}
+			return EventUnhandled;
+		});
+
+		chat.addRole("channel@example.com/other", borogove.Role.forAffiliation("admin"));
+	}
+
+	public function testRemoveRole(async: Async) {
+		final persistence = new Dummy();
+		final client = new Client("test@example.com", persistence);
+		final chat = new Channel(client, client.stream, persistence, "channel@example.com");
+
+		final otherPresence = Stanza.parse('<presence from="channel@example.com/other">
+			<x xmlns="http://jabber.org/protocol/muc#user"><item affiliation="member" role="participant" jid="other@example.com"/></x>
+		</presence>');
+		chat.presence.set("other", otherPresence);
+
+		client.stream.on("sendStanza", (stanza: Stanza) -> {
+			if (stanza.name == "iq" && stanza.attr.get("type") == "set") {
+				final query = stanza.getChild("query", "http://jabber.org/protocol/muc#admin");
+				if (query != null) {
+					final item = query.getChild("item");
+					Assert.equals("none", item.attr.get("affiliation"));
+					Assert.equals("other@example.com", item.attr.get("jid"));
+					async.done();
+					return EventHandled;
+				}
+			}
+			return EventUnhandled;
+		});
+
+		chat.removeRole("channel@example.com/other", borogove.Role.forAffiliation("member"));
+	}
 }
diff --git a/test/TestHtml.hx b/test/TestHtml.hx
index c2c495d..9047cfa 100644
--- a/test/TestHtml.hx
+++ b/test/TestHtml.hx
@@ -44,7 +44,7 @@ class TestHtml extends utest.Test {
 		msg.sender = msg.from;
 		msg.text = "/me says hello";
 
-		final participant = new Participant("hatter", null, "", false, msg.from, null);
+		final participant = new Participant("hatter", null, "", false, [], msg.from, null);
 
 		Assert.equals(
 			"<div class=\"action\"><div>hatter says hello</div></div>",
@@ -60,7 +60,7 @@ class TestHtml extends utest.Test {
 		msg.sender = msg.from;
 		msg.setBody(Html.fromString("/me says <div class='sup&amp;2'><img src='hai'><br><p></p>"));
 
-		final participant = new Participant("hatter", null, "", false, msg.from, null);
+		final participant = new Participant("hatter", null, "", false, [], msg.from, null);
 
 		Assert.equals(
 			"<div class=\"action\">hatter says <div class=\"sup&amp;2\"><img src=\"hai\" /><br /><p></p></div></div>",
diff --git a/test/TestPresence.hx b/test/TestPresence.hx
index b1c8b1e..8d1e3d0 100644
--- a/test/TestPresence.hx
+++ b/test/TestPresence.hx
@@ -23,6 +23,24 @@ class TestPresence extends utest.Test {
 		Assert.equals("deadbeef", presence.avatarHash.toHex());
 	}
 
+	public function testHats() {
+		final stanza = Stanza.parse('<presence from="user@example.com/res">
+			<hats xmlns="urn:xmpp:hats:0">
+				<hat uri="http://example.com/hats/1" title="Hat 1"/>
+				<hat uri="http://example.com/hats/2" title="Hat 2"/>
+			</hats>
+		</presence>');
+		final presence: Presence = stanza;
+
+		Assert.notNull(presence.hats);
+		Assert.equals(2, presence.hats.length);
+		Assert.equals("http://example.com/hats/1", presence.hats[0].id);
+		Assert.equals("Hat 1", presence.hats[0].title);
+		Assert.equals("#8b7600", presence.hats[0].color());
+		Assert.equals("http://example.com/hats/2", presence.hats[1].id);
+		Assert.equals("Hat 2", presence.hats[1].title);
+	}
+
 	public function testNew() {
 		final caps = new Caps("http://example.com", [], [], []);
 		final presence = new Presence(caps, null, Hash.fromHex("sha-1", "deadbeef"));