| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-06-16 17:35:37 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-06-16 17:35:37 UTC |
| parent | af5ace839e555773259c74ee33d89bb88b7d730d |
| 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); + }); + } +}