git » sdk » commit fe2ea93

Support XEP0045 configuration form for owner as a command

author Stephen Paul Weber
2026-06-16 17:35:37 UTC
committer Stephen Paul Weber
2026-06-16 17:35:37 UTC
parent af5ace839e555773259c74ee33d89bb88b7d730d

Support XEP0045 configuration form for owner as a command

borogove/Chat.hx +15 -0
borogove/MucSettingsCommand.hx +90 -0
borogove/queries/MucSettingsGet.hx +35 -0
borogove/queries/MucSettingsSubmit.hx +31 -0
test/TestAll.hx +1 -0
test/TestChat.hx +18 -0
test/TestMucSettingsCommand.hx +127 -0

diff --git a/borogove/Chat.hx b/borogove/Chat.hx
index a10a1b6..eb77ff2 100644
--- a/borogove/Chat.hx
+++ b/borogove/Chat.hx
@@ -1630,6 +1630,21 @@ class Channel extends Chat {
 		return disco.features.contains("urn:xmpp:message-moderate:1") && it.next().mucUser.role == "moderator";
 	}
 
+	private function isOwner() {
+		if (self == null) return false;
+
+		return self.roles.exists(r -> r.id == "owner");
+	}
+
+	override public function commands(): Promise<Array<Command>> {
+		return super.commands().then((cmds: Array<Command>) -> {
+			if (isOwner()) {
+				cmds.push(new MucSettingsCommand(client, JID.parse(chatId)));
+			}
+			return cmds;
+		});
+	}
+
 	@:allow(borogove)
 	private function getCaps():KeyValueIterator<Null<String>, Caps> {
 		var hasNext = true;
diff --git a/borogove/MucSettingsCommand.hx b/borogove/MucSettingsCommand.hx
new file mode 100644
index 0000000..660bd98
--- /dev/null
+++ b/borogove/MucSettingsCommand.hx
@@ -0,0 +1,90 @@
+package borogove;
+
+import thenshim.Promise;
+import borogove.Command;
+import borogove.DataForm;
+import borogove.Form;
+import borogove.queries.MucSettingsGet;
+import borogove.queries.MucSettingsSubmit;
+
+#if cpp
+import HaxeCBridge;
+#end
+
+class MucSettingsCommand extends Command {
+	@:allow(borogove)
+	public function new(client: Client, jid: JID) {
+		super(client, { jid: jid, name: "Settings", node: "muc-settings" });
+	}
+
+	override public function execute(): Promise<CommandSession> {
+		return new Promise<CommandSession>((resolve, reject) -> {
+			final q = new MucSettingsGet(this.jid.asString());
+			q.onFinished(() -> {
+				if (q.error != null) {
+					final formish = new Stanza("x", { xmlns: "jabber:x:data", type: "result" }).textTag("instructions", q.error, { type: "error" });
+					resolve(new MucSettingsCommandSession("error", null, [], [new Form(formish, null)], this));
+					return;
+				}
+
+				final form = q.getResult();
+				if (form == null) {
+					reject("Failed to get settings form");
+					return;
+				}
+
+				resolve(new MucSettingsCommandSession("executing", null, [new FormOption("Submit", "execute"), new FormOption("Cancel", "cancel")], [new Form(form, null)], this));
+			});
+			this.client.sendQuery(q);
+		});
+	}
+}
+
+class MucSettingsCommandSession extends CommandSession {
+	#if js
+	override public function execute(
+		action: Null<String> = null,
+		data: Null<haxe.extern.EitherType<
+			haxe.extern.EitherType<
+				haxe.DynamicAccess<StringOrArray>,
+				Map<String, StringOrArray>
+			>,
+			js.html.FormData
+		>> = null,
+		formIdx: Int = 0
+	)
+	#else
+	override public function execute(
+		action: Null<String> = null,
+		data: Null<FormSubmitBuilder> = null,
+		formIdx: Int = 0
+	)
+	#end
+	: Promise<CommandSession> {
+		if (action == "cancel" || action == "prev") {
+			return Promise.resolve((new MucSettingsCommandSession("canceled", null, [], [], command) : CommandSession));
+		}
+
+		final toSubmit = forms[formIdx].submit(data);
+		if (toSubmit == null) return Promise.reject("Invalid submission");
+
+		return new Promise<CommandSession>((resolve, reject) -> {
+			final q = new MucSettingsSubmit(this.command.jid.asString(), toSubmit);
+			q.onFinished(() -> {
+				if (q.error != null) {
+					final formish = new Stanza("x", { xmlns: "jabber:x:data", type: "result" }).textTag("instructions", q.error, { type: "error" });
+					resolve(new MucSettingsCommandSession("error", null, [], [new Form(formish, null)], this.command));
+					return;
+				}
+
+				if (!q.getResult()) {
+					reject("Failed to submit settings");
+					return;
+				}
+
+				resolve(new MucSettingsCommandSession("completed", null, [], [], this.command));
+			});
+			this.command.client.sendQuery(q);
+		});
+	}
+}
diff --git a/borogove/queries/MucSettingsGet.hx b/borogove/queries/MucSettingsGet.hx
new file mode 100644
index 0000000..314fafb
--- /dev/null
+++ b/borogove/queries/MucSettingsGet.hx
@@ -0,0 +1,35 @@
+package borogove.queries;
+
+import borogove.ID;
+import borogove.Stanza;
+
+class MucSettingsGet extends GenericQuery {
+	private var responseStanza:Stanza;
+	public var error(default, null):Null<String> = null;
+
+	public function new(to: String) {
+		queryStanza = new Stanza("iq", { type: "get", to: to, id: ID.unique() })
+			.tag("query", { xmlns: "http://jabber.org/protocol/muc#owner" })
+			.up();
+	}
+
+	public function handleResponse(stanza:Stanza) {
+		responseStanza = stanza;
+		if (stanza.attr.get("type") == "error") {
+			error = stanza.getError().text ?? stanza.getError().condition ?? "error";
+		}
+		finish();
+	}
+
+	public function getResult(): Null<DataForm> {
+		if (responseStanza == null || error != null) return null;
+
+		final query = responseStanza.getChild("query", "http://jabber.org/protocol/muc#owner");
+		if (query == null) return null;
+
+		final x = query.getChild("x", "jabber:x:data");
+		if (x == null) return null;
+
+		return x;
+	}
+}
diff --git a/borogove/queries/MucSettingsSubmit.hx b/borogove/queries/MucSettingsSubmit.hx
new file mode 100644
index 0000000..e704529
--- /dev/null
+++ b/borogove/queries/MucSettingsSubmit.hx
@@ -0,0 +1,31 @@
+package borogove.queries;
+
+import borogove.ID;
+import borogove.Stanza;
+import borogove.Form;
+
+class MucSettingsSubmit extends GenericQuery {
+	private var responseStanza:Stanza;
+	public var error(default, null):Null<String> = null;
+
+	public function new(to: String, submitForm: Stanza) {
+		queryStanza = new Stanza("iq", { type: "set", to: to, id: ID.unique() })
+			.tag("query", { xmlns: "http://jabber.org/protocol/muc#owner" })
+			.addChild(submitForm)
+			.up();
+	}
+
+	public function handleResponse(stanza:Stanza) {
+		responseStanza = stanza;
+		if (stanza.attr.get("type") == "error") {
+			error = stanza.getError().text ?? stanza.getError().condition ?? "error";
+		}
+		finish();
+	}
+
+	public function getResult(): Bool {
+		if (responseStanza == null || error != null) return false;
+
+		return true;
+	}
+}
diff --git a/test/TestAll.hx b/test/TestAll.hx
index fa91928..7654c44 100644
--- a/test/TestAll.hx
+++ b/test/TestAll.hx
@@ -25,6 +25,7 @@ class TestAll {
 			new TestJID(),
 			new TestMember(),
 			new TestMemberUpdate(),
+			new TestMucSettingsCommand(),
 			new TestPresence(),
 			new TestReaction(),
 			new TestSessionDescription(),
diff --git a/test/TestChat.hx b/test/TestChat.hx
index cdb4a92..c34165f 100644
--- a/test/TestChat.hx
+++ b/test/TestChat.hx
@@ -250,6 +250,24 @@ class TestChat extends utest.Test {
 		chat.moderate(message, "Spam");
 	}
 
+	public function testChannelCommandsReturnsSettingsForOwner(async: Async) {
+		final persistence = new Dummy();
+		final client = new Client("test@example.com", persistence);
+		final chat = new borogove.Chat.Channel(client, client.stream, persistence, "channel@example.com");
+
+		chat.self = new Member("me", "myself", null, true, [new Role("member", "")], JID.parse("test@example.com"), new Map(), null);
+		chat.commands().then(cmds -> {
+			Assert.equals(0, cmds.length);
+
+			chat.self = new Member("me", "myself", null, true, [new Role("owner", "")], JID.parse("test@example.com"), new Map(), null);
+			chat.commands().then(cmds2 -> {
+				Assert.equals(1, cmds2.length);
+				Assert.equals("Settings", cmds2[0].name);
+				async.done();
+			});
+		});
+	}
+
 	public function testCanModerateDirectChat() {
 		final persistence = new Dummy();
 		final client = new Client("test@example.com", persistence);
diff --git a/test/TestMucSettingsCommand.hx b/test/TestMucSettingsCommand.hx
new file mode 100644
index 0000000..a3d9463
--- /dev/null
+++ b/test/TestMucSettingsCommand.hx
@@ -0,0 +1,127 @@
+package test;
+
+import utest.Assert;
+import utest.Async;
+import borogove.Client;
+import borogove.Stanza;
+import borogove.JID;
+import borogove.persistence.Dummy;
+import borogove.MucSettingsCommand;
+import borogove.Form;
+
+@:access(borogove)
+class TestMucSettingsCommand extends utest.Test {
+	public function testExecuteSuccess(async: Async) {
+		final persistence = new Dummy();
+		final client = new Client("test@example.com", persistence);
+		final command = new MucSettingsCommand(client, JID.parse("channel@example.com"));
+
+		client.stream.on("sendStanza", (stanza: Stanza) -> {
+			if (stanza.name == "iq" && stanza.attr.get("type") == "get") {
+				Assert.equals("channel@example.com", stanza.attr.get("to"));
+				final query = stanza.getChild("query", "http://jabber.org/protocol/muc#owner");
+				if (query != null) {
+					final responseStanza = new Stanza("iq", { xmlns: "jabber:client", type: "result", id: stanza.attr.get("id"), from: "channel@example.com", to: "test@example.com" })
+						.tag("query", { xmlns: "http://jabber.org/protocol/muc#owner" })
+							.tag("x", { xmlns: "jabber:x:data", type: "form" })
+								.tag("field", { "var": "muc#roomconfig_roomname" })
+									.textTag("value", "Test Room")
+								.up()
+							.up()
+						.up();
+
+					client.stream.onStanza(responseStanza);
+					return EventHandled;
+				}
+			}
+			return EventUnhandled;
+		});
+
+		command.execute().then(session -> {
+			Assert.equals("executing", session.status);
+			Assert.equals(1, session.forms.length);
+			Assert.notNull(session.forms[0]);
+
+			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#owner");
+					if (query != null) {
+						final x = query.getChild("x", "jabber:x:data");
+						Assert.notNull(x);
+
+						final responseStanza = new Stanza("iq", { xmlns: "jabber:client", type: "result", id: stanza.attr.get("id"), from: "channel@example.com", to: "test@example.com" });
+						client.stream.onStanza(responseStanza);
+						return EventHandled;
+					}
+				}
+				return EventUnhandled;
+			});
+
+			#if js
+			final data = {};
+			#else
+			final data = new borogove.Form.FormSubmitBuilder();
+			#end
+
+			session.execute(null, data, 0).then(nextSession -> {
+				Assert.equals("completed", nextSession.status);
+				Assert.equals(0, nextSession.forms.length);
+				async.done();
+			}, err -> {
+				Assert.fail(err);
+			});
+		}, err -> {
+			Assert.fail(err);
+		});
+	}
+
+	public function testExecuteGetError(async: Async) {
+		final persistence = new Dummy();
+		final client = new Client("test@example.com", persistence);
+		final command = new MucSettingsCommand(client, JID.parse("channel@example.com"));
+
+		client.stream.on("sendStanza", (stanza: Stanza) -> {
+			if (stanza.name == "iq" && stanza.attr.get("type") == "get") {
+				final query = stanza.getChild("query", "http://jabber.org/protocol/muc#owner");
+				if (query != null) {
+					final responseStanza = new Stanza("iq", { xmlns: "jabber:client", type: "error", id: stanza.attr.get("id"), from: "channel@example.com", to: "test@example.com" })
+						.tag("error", { type: "auth" })
+							.tag("forbidden", { xmlns: "urn:ietf:params:xml:ns:xmpp-stanzas" }).up()
+						.up();
+
+					client.stream.onStanza(responseStanza);
+					return EventHandled;
+				}
+			}
+			return EventUnhandled;
+		});
+
+		command.execute().then(session -> {
+			Assert.equals("error", session.status);
+			Assert.equals(1, session.forms.length);
+			async.done();
+		}, err -> {
+			Assert.fail(err);
+		});
+	}
+
+	public function testSessionCancel(async: Async) {
+		final persistence = new Dummy();
+		final client = new Client("test@example.com", persistence);
+		final command = new MucSettingsCommand(client, JID.parse("channel@example.com"));
+
+		final session = new borogove.MucSettingsCommand.MucSettingsCommandSession("executing", null, [], [], command);
+
+		client.stream.on("sendStanza", (stanza: Stanza) -> {
+			Assert.fail("Should not send IQ on cancel");
+			return EventHandled;
+		});
+
+		session.execute("cancel", null, 0).then(nextSession -> {
+			Assert.equals("canceled", nextSession.status);
+			async.done();
+		}, err -> {
+			Assert.fail(err);
+		});
+	}
+}