| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-06-23 16:08:20 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-06-23 16:08:20 UTC |
| parent | 588d49f1c1247d6780a79ce746ed37e545bae05f |
| borogove/Chat.hx | +31 | -2 |
| borogove/Client.hx | +10 | -0 |
| borogove/Message.hx | +15 | -0 |
| borogove/MucUser.hx | +5 | -0 |
| borogove/Persistence.hx | +21 | -0 |
| borogove/persistence/Dummy.hx | +10 | -0 |
| borogove/persistence/IDB.js | +28 | -1 |
| borogove/persistence/Sqlite.hx | +20 | -0 |
| test/TestChat.hx | +39 | -0 |
| test/TestClient.hx | +47 | -0 |
| test/TestSqlite.hx | +32 | -0 |
| test/idb.spec.ts | +58 | -0 |
| test/sqlite.spec.ts | +86 | -0 |
diff --git a/borogove/Chat.hx b/borogove/Chat.hx index 459fd8c..4424a51 100644 --- a/borogove/Chat.hx +++ b/borogove/Chat.hx @@ -1753,9 +1753,9 @@ class Channel extends Chat { }); } } - final mucUser = (presence : Stanza).getChild("x", "http://jabber.org/protocol/muc#user"); + final mucUser: MucUser = (presence : Stanza).getChild("x", "http://jabber.org/protocol/muc#user"); if (mucUser != null) { - final mav = mucUser.getChild("mav", "urn:xmpp:muc:affiliations:1"); + final mav = mucUser.mav; if (mav?.attr?.get("since") != null && mav?.attr?.get("since") != mavUntil) { trace("MAV update with unknown previous version", mavUntil, presence); } @@ -1763,6 +1763,10 @@ class Channel extends Chat { mavUntil = mav?.attr?.get("until"); persistence.storeChats(client.accountId(), [this]); } + if (mucUser.role != "visitor" && mucUser.jid != null) { + // If they're not a visitor they can't be requesting voice + persistence.storeVoiceRequest(client.accountId(), this, mucUser.jid.asString(), false); + } } if (member.isSelf) { if (presence.type == "unavailable") { @@ -2028,6 +2032,31 @@ class Channel extends Chat { return persistence.getMemberDetails(client.accountId(), this, memberIds); } + /** + List of current voice requests we are aware of + **/ + public function voiceRequests() { + return persistence.listVoiceRequests(client.accountId(), this); + } + + /** + Respond to a particular voice request + **/ + public function voiceRequestRespond(member: Member, canSend: Bool) { + if (member.chat == null) return; + + // For canSend=true we could use normal set role to participant as well + final outboxItem = outbox.newItem(); + outboxItem.handle(() -> client.sendStanza( + new Stanza("message", { to: chatId }) + .tag("x", { xmlns: "jabber:x:data", type: "submit" }) + .tag("field", { "var": "FORM_TYPE" }).textTag("value", "http://jabber.org/protocol/muc#request").up() + .tag("field", { "var": "muc#role" }).textTag("value", "participant").up() + .tag("field", { "var": "muc#jid" }).textTag("value", member.chat.chatId).up() + .tag("field", { "var": "muc#request_allow" }).textTag("value", canSend ? "1" : "0").up() + )); + } + private function buildMember(resource: String, presence: Presence): Member { final oneTen = presence?.mucUser?.statusCodes?.find((status) -> status == "110"); final jid = JID.parse(chatId).withResource(resource); diff --git a/borogove/Client.hx b/borogove/Client.hx index c0f7ae1..85ae47b 100644 --- a/borogove/Client.hx +++ b/borogove/Client.hx @@ -535,6 +535,16 @@ class Client extends EventEmitter { persistence.storeChats(accountId(), [chat]); this.trigger("chats/update", [chat]); } + case MucVoiceRequest(jid): + final chat = getChat(message.chatId); + final channel = chat == null ? null : Util.downcast(chat, Channel); + if (channel == null) { + trace("Ignoring voice request from unknown channel: " + stanza.toString()); + } else { + persistence.storeVoiceRequest(accountId(), channel, jid.asBare().asString(), true).then(_ -> { + this.trigger("chats/update", [channel]); + }); + } default: // ignore trace("Ignoring non-chat message: " + stanza.toString()); diff --git a/borogove/Message.hx b/borogove/Message.hx index 1ec0a35..229a8a1 100644 --- a/borogove/Message.hx +++ b/borogove/Message.hx @@ -40,6 +40,7 @@ enum MessageStanza { ModerateMessageStanza(action: ModerationAction); ReactionUpdateStanza(update: ReactionUpdate); MucInviteStanza(serverId: Null<String>, serverIdBy: Null<String>, reason: Null<String>, password: Null<String>); + MucVoiceRequest(jid: JID); UnknownMessageStanza(stanza: Stanza); } @@ -94,6 +95,20 @@ class Message { msg.to = to == null ? localJid : JID.parse(to); msg.encryption = encryptionInfo; + // Voice request is just a message form, but we want to handle it special + final form: Null<DataForm> = stanza.getChild("x", "jabber:x:data"); + if ( + form != null && + form.type == "form" && + "http://jabber.org/protocol/muc#request" == form.field("FORM_TYPE")?.value?.join(" ") && + "participant" == form.field("muc#role")?.value?.join(" ") + ) { + final jid = form.field("muc#jid")?.value; + if (jid != null && jid.length == 1) { + return new Message(from, from, null, MucVoiceRequest(JID.parse(jid[0])), encryptionInfo); + } + } + if (msg.from != null && msg.from.equals(localJidBare)) { var carbon = stanza.getChild("received", "urn:xmpp:carbons:2"); if (carbon == null) carbon = stanza.getChild("sent", "urn:xmpp:carbons:2"); diff --git a/borogove/MucUser.hx b/borogove/MucUser.hx index 1c8ced7..9902994 100644 --- a/borogove/MucUser.hx +++ b/borogove/MucUser.hx @@ -9,6 +9,7 @@ abstract MucUser(Stanza) from Stanza to Stanza { public var role(get, never): String; public var affiliation(get, never): String; public var jid(get, never): Null<JID>; + public var mav(get, never): Null<Stanza>; inline private function get_statusCodes() { return this.allTags("status").map(el -> el.attr.get("code")); @@ -32,4 +33,8 @@ abstract MucUser(Stanza) from Stanza to Stanza { inline private function item() { return this.getChild("item"); } + + inline private function get_mav() { + return this.getChild("mav", "urn:xmpp:muc:affiliations:1"); + } } diff --git a/borogove/Persistence.hx b/borogove/Persistence.hx index 6695130..ea455a7 100644 --- a/borogove/Persistence.hx +++ b/borogove/Persistence.hx @@ -102,6 +102,27 @@ interface Persistence { @HaxeCBridge.noemit public function getMemberDetails(accountId: String, chat: Null<Chat>, ids: Array<String>): Promise<Array<Null<Member>>>; + /** + Store a voice request by Jabber ID + + @param accountId the account that owns the member records + @param chat the Channel the request is for + @param jid the Jabber ID that is requesting / no longer requesting voice + @returns Promise resolving to true + **/ + @HaxeCBridge.noemit + public function storeVoiceRequest(accountId: String, chat: Chat, jid: String, requesting: Bool): Promise<Bool>; + + /** + List known voice requests + + @param accountId the account that owns the member records + @param chat the Channels to look in + @returns Promise resolving to the list of Members requesting voice + **/ + @HaxeCBridge.noemit + public function listVoiceRequests(accountId: String, chat: Chat): Promise<Array<Member>>; + /** Load unread counters and most recent unread message per Chat diff --git a/borogove/persistence/Dummy.hx b/borogove/persistence/Dummy.hx index e23f041..9b73588 100644 --- a/borogove/persistence/Dummy.hx +++ b/borogove/persistence/Dummy.hx @@ -67,6 +67,16 @@ class Dummy implements Persistence { return Promise.resolve([]); } + @HaxeCBridge.noemit + public function storeVoiceRequest(accountId: String, chat: Chat, jid: String, requesting: Bool) { + return Promise.resolve(false); + } + + @HaxeCBridge.noemit + public function listVoiceRequests(accountId: String, chat: Chat) { + return Promise.resolve([]); + } + @HaxeCBridge.noemit public function storeMessages(accountId: String, messages: Array<ChatMessage>): Promise<Array<ChatMessage>> { return Promise.resolve(messages); diff --git a/borogove/persistence/IDB.js b/borogove/persistence/IDB.js index 802ff9b..6ef27f7 100644 --- a/borogove/persistence/IDB.js +++ b/borogove/persistence/IDB.js @@ -701,6 +701,29 @@ export default async (dbname, media, tokenize, stemmer) => { })); }, + async storeVoiceRequest(account, chat, jid, requesting) { + await this.set(`voiceRequest:${account}\n${chat.chatId}\n${jid}`, requesting ? true : undefined); + return true; + }, + + async listVoiceRequests(account, chat) { + const tx = db.transaction(["keyvaluepairs", "members"], "readonly"); + const kvStore = tx.objectStore("keyvaluepairs"); + const keys = await promisifyRequest(kvStore.getAllKeys(IDBKeyRange.bound(`voiceRequest:${account}\n${chat.chatId}\n`, `voiceRequest:${account}\n${chat.chatId}\n\uffff`))); + const jids = keys.map(k => k.split(/\n/)[2]); + + const result = []; + const store = tx.objectStore("members"); + for (const jid of jids) { + const raw = await promisifyRequest(store.index("chatsWithTrueJid").get([account, chat.chatId, 0, jid])); + if (!raw?.id || !raw?.displayName || !raw?.jid) continue; + + result.push(hydrateMember(chat, raw)); + } + + return result; + }, + getChatUnreadDetails: async function(account, chat) { const tx = db.transaction(["messages"], "readonly"); const store = tx.objectStore("messages"); @@ -1491,7 +1514,11 @@ tx.onerror = console.error; set(k, v) { const tx = db.transaction(["keyvaluepairs"], "readwrite"); const store = tx.objectStore("keyvaluepairs"); - return promisifyRequest(store.put(v, k)); + if (typeof(v) === "undefined") { + return promisifyRequest(store.delete(k)); + } else { + return promisifyRequest(store.put(v, k)); + } } }; diff --git a/borogove/persistence/Sqlite.hx b/borogove/persistence/Sqlite.hx index 1d35a3d..f87c6e7 100644 --- a/borogove/persistence/Sqlite.hx +++ b/borogove/persistence/Sqlite.hx @@ -629,6 +629,26 @@ class Sqlite implements Persistence implements KeyValueStore { }); } + @HaxeCBridge.noemit + public function storeVoiceRequest(accountId: String, chat: Chat, jid: String, requesting: Bool) { + return set("voiceRequest:" + accountId + "\n" + chat.chatId + "\n" + jid, requesting ? "1" : null).then(_ -> true); + } + + @HaxeCBridge.noemit + public function listVoiceRequests(accountId: String, chat: Chat) { + return db.exec( + "SELECT member_id, display_name, photo_uri, is_self, chat, json(roles) AS roles, json(presence) AS presence, jid FROM members INNER JOIN keyvaluepairs ON keyvaluepairs.k='voiceRequest:' || account_id || char(10) || chat_id || char(10) || jid WHERE account_id=? AND chat_id=?", + [accountId, chat.chatId] + ).then(rows -> { + final result: Array<Member> = []; + for (row in rows) { + final member = hydrateStoredMember(chat, row); + if (member != null) result.push(member); + } + return result; + }); + } + @HaxeCBridge.noemit public function searchMessages(accountId: String, chatId: Null<String>, q: String): Promise<Array<ChatMessage>> { var sql = "SELECT stanza, direction, type, status, status_text, strftime('%FT%H:%M:%fZ', created_at / 1000.0, 'unixepoch') AS timestamp, sender_id, mam_id, mam_by, sort_id, sync_point FROM messages WHERE account_id=? AND stanza LIKE ?"; diff --git a/test/TestChat.hx b/test/TestChat.hx index 590adcf..acbeede 100644 --- a/test/TestChat.hx +++ b/test/TestChat.hx @@ -694,6 +694,45 @@ class TestChat extends utest.Test { chat.outbox.start(); chat.requestToSend(); } + + public function testVoiceRequestRespond(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"); + final member = new Member("some_id", "some_name", null, false, [], JID.parse("someone@example.com"), new Map(), new AvailableChat("someone_chat@example.com", "", "", CapsRepo.empty)); + + var count = 0; + client.stream.on("sendStanza", (stanza: Stanza) -> { + if (stanza.name == "message" && stanza.attr.get("to") == "channel@example.com" && stanza.attr.get("type") == null) { + final x = stanza.getChild("x", "jabber:x:data"); + if (x != null && x.attr.get("type") == "submit") { + final fields = x.allTags("field"); + Assert.equals(4, fields.length); + Assert.equals("FORM_TYPE", fields[0].attr.get("var")); + Assert.equals("http://jabber.org/protocol/muc#request", fields[0].getChild("value").getText()); + Assert.equals("muc#role", fields[1].attr.get("var")); + Assert.equals("participant", fields[1].getChild("value").getText()); + Assert.equals("muc#jid", fields[2].attr.get("var")); + Assert.equals("someone_chat@example.com", fields[2].getChild("value").getText()); + Assert.equals("muc#request_allow", fields[3].attr.get("var")); + + if (count == 0) { + Assert.equals("1", fields[3].getChild("value").getText()); + } else { + Assert.equals("0", fields[3].getChild("value").getText()); + async.done(); + } + count++; + return EventHandled; + } + } + return EventUnhandled; + }); + + chat.outbox.start(); + chat.voiceRequestRespond(member, true); + chat.voiceRequestRespond(member, false); + } } @:access(borogove) diff --git a/test/TestClient.hx b/test/TestClient.hx index 6330ea4..2f006a3 100644 --- a/test/TestClient.hx +++ b/test/TestClient.hx @@ -600,6 +600,43 @@ class TestClient extends utest.Test { client.start(); } + + public function testMucVoiceRequest(async: Async) { + final persistence = new VoiceRequestMockPersistence(); + final client = new Client("test@example.com", persistence); + final chat = new borogove.Chat.Channel(client, client.stream, persistence, "room@example.com"); + client.chats.push(chat); + + var onPersistedEvent = null; + final afterPersistedEvent = new Promise((resolved, rejected) -> { onPersistedEvent = resolved; }); + + client.on("chats/update", (chats: Array<Chat>) -> { + final c = chats.find(x -> x.chatId == "room@example.com"); + if (c != null && persistence.lastVoiceRequests.length > 0) { + onPersistedEvent(null); + } + return EventHandled; + }); + + final stanza = Stanza.parse('<message from="room@example.com" xmlns="jabber:client"> + <x xmlns="jabber:x:data" type="form"> + <field var="FORM_TYPE"><value>http://jabber.org/protocol/muc#request</value></field> + <field var="muc#role"><value>participant</value></field> + <field var="muc#jid"><value>requester@example.com</value></field> + </x> + </message>'); + + client.stream.onStanza(stanza); + + afterPersistedEvent.then(_ -> { + Assert.equals(1, persistence.lastVoiceRequests.length); + Assert.equals("test@example.com", persistence.lastVoiceRequests[0].accountId); + Assert.equals("room@example.com", persistence.lastVoiceRequests[0].chat.chatId); + Assert.equals("requester@example.com", persistence.lastVoiceRequests[0].jid); + Assert.equals(true, persistence.lastVoiceRequests[0].requesting); + async.done(); + }); + } } @:access(borogove) @@ -671,3 +708,13 @@ class MemberUpdateMockPersistence extends Dummy { return Promise.resolve(true); } } + +@:access(borogove) +class VoiceRequestMockPersistence extends Dummy { + public var lastVoiceRequests: Array<{ accountId: String, chat: Chat, jid: String, requesting: Bool }> = []; + + override public function storeVoiceRequest(accountId: String, chat: Chat, jid: String, requesting: Bool): Promise<Bool> { + lastVoiceRequests.push({ accountId: accountId, chat: chat, jid: jid, requesting: requesting }); + return Promise.resolve(true); + } +} diff --git a/test/TestSqlite.hx b/test/TestSqlite.hx index 7432c54..8a5f219 100644 --- a/test/TestSqlite.hx +++ b/test/TestSqlite.hx @@ -1288,4 +1288,36 @@ class TestSqlite extends utest.Test { async.done(); }); } + + @:timeout(3000) + public function testVoiceRequests(async: Async) { + final account = "alice@example.com"; + final chat = new Channel(cast null, cast null, persistence, "room-voice-requests@example.com"); + chat.displayName = "A Chat"; + + persistence.storeMembers(account, chat.chatId, [ + new Member("room-voice-requests@example.com/bob", "Bob", null, false, [new Role("none", "Participant")], JID.parse("bob@example.com"), ["desk" => Stanza.parse("<presence />")], new AvailableChat("bob@example.com", "Bob", "", new borogove.Caps("", [], [], []))), + new Member("room-voice-requests@example.com/charlie", "Charlie", null, false, [new Role("none", "Participant")], JID.parse("charlie@example.com"), ["desk" => Stanza.parse("<presence />")], new AvailableChat("charlie@example.com", "Charlie", "", new borogove.Caps("", [], [], []))) + ]).then(_ -> + persistence.storeVoiceRequest(account, chat, "bob@example.com", true) + ).then(_ -> + persistence.storeVoiceRequest(account, chat, "charlie@example.com", true) + ).then(_ -> + persistence.listVoiceRequests(account, chat) + ).then(reqs1 -> { + final reqsNames = reqs1.map(m -> m.displayName); + reqsNames.sort((a, b) -> Reflect.compare(a, b)); + Assert.same(["Bob", "Charlie"], reqsNames); + return persistence.storeVoiceRequest(account, chat, "bob@example.com", false); + }).then(_ -> + persistence.listVoiceRequests(account, chat) + ).then(reqs2 -> { + final reqsNames2 = reqs2.map(m -> m.displayName); + Assert.same(["Charlie"], reqsNames2); + async.done(); + }).catchError(e -> { + Assert.fail(Std.string(e)); + async.done(); + }); + } } diff --git a/test/idb.spec.ts b/test/idb.spec.ts index 150985a..ed33b7b 100644 --- a/test/idb.spec.ts +++ b/test/idb.spec.ts @@ -1659,3 +1659,61 @@ test("getMemberDetails returns null for incomplete rows", async ({ page }) => { expect(result).toEqual(["Alpha", null]); }); + +test("storeVoiceRequest and listVoiceRequests", async ({ page }) => { + page.route("https://localhost/", (route) => + route.fulfill({ body: "<html></html>" }), + ); + const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8"); + await page.goto("https://localhost/"); + const result = await page.evaluate(async (code) => { + const blob = new Blob([code], { type: "text/javascript" }); + const borogove = await import(URL.createObjectURL(blob)); + + const mediaStore = await borogove.persistence.MediaStoreCache("snikket"); + const persistence = await borogove.persistence.IDB("snikket", mediaStore); + const chat = Object.create(borogove.Channel.prototype); + chat.chatId = "room-voice-requests@example.com"; + chat.getDisplayName = () => "Tea Room"; + + await persistence.storeMembers("alice@example.com", chat.chatId, [ + { + id: "room-voice-requests@example.com/occ-1", + displayName: "Bob", + photoUri: null, + isSelf: false, + roles: [{ id: "none", title: "Participant" }], + jid: borogove.JID.parse("bob@example.com"), + presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]), + chat: { chatId: "bob@example.com" }, + }, + { + id: "room-voice-requests@example.com/occ-2", + displayName: "Charlie", + photoUri: null, + isSelf: false, + roles: [{ id: "none", title: "Participant" }], + jid: borogove.JID.parse("charlie@example.com"), + presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]), + chat: { chatId: "charlie@example.com" }, + } + ]); + + await persistence.storeVoiceRequest("alice@example.com", chat, "bob@example.com", true); + await persistence.storeVoiceRequest("alice@example.com", chat, "charlie@example.com", true); + + const requests1 = await persistence.listVoiceRequests("alice@example.com", chat); + + await persistence.storeVoiceRequest("alice@example.com", chat, "bob@example.com", false); + + const requests2 = await persistence.listVoiceRequests("alice@example.com", chat); + + return { + requests1: requests1.map((m) => m.displayName).sort(), + requests2: requests2.map((m) => m.displayName).sort(), + }; + }, code); + + expect(result.requests1).toEqual(["Bob", "Charlie"]); + expect(result.requests2).toEqual(["Charlie"]); +}); diff --git a/test/sqlite.spec.ts b/test/sqlite.spec.ts index 64d48aa..6c10e0a 100644 --- a/test/sqlite.spec.ts +++ b/test/sqlite.spec.ts @@ -2640,4 +2640,90 @@ test.describe("not webkit", () => { expect(result).toEqual(["Alpha", null]); }); + + test("storeVoiceRequest and listVoiceRequests", async ({ page }) => { + page.route("https://localhost/", (route) => + route.fulfill({ + body: "<html></html>", + headers: { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "same-origin", + "Cross-Origin-Resource-Policy": "same-origin", + }, + }), + ); + const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8"); + const sqlite = fs.readFileSync("playwright/.cache/sqlite-wasm.js", "utf8"); + const worker1 = fs.readFileSync( + "playwright/.cache/sqlite-worker1.js", + "utf8", + ); + await page.goto("https://localhost/"); + const result = await page.evaluate( + async ([code, sqliteCode, worker1Code]) => { + const borogove = await import( + URL.createObjectURL(new Blob([code], { type: "text/javascript" })) + ); + const sqlite = await import( + URL.createObjectURL( + new Blob([sqliteCode], { type: "text/javascript" }), + ) + ); + window.sqliteWorker1Url = new URL( + URL.createObjectURL( + new Blob([worker1Code], { type: "text/javascript" }), + ), + ); + const persistence = new sqlite.borogove_persistence_Sqlite( + "snikket", + await borogove.persistence.MediaStoreCache("snikket"), + ); + + const chat = Object.create(borogove.Channel.prototype); + chat.chatId = "room-voice-requests@example.com"; + chat.getDisplayName = () => "Tea Room"; + + await persistence.storeMembers("alice@example.com", chat.chatId, [ + { + id: "room-voice-requests@example.com/occ-1", + displayName: "Bob", + photoUri: null, + isSelf: false, + roles: [{ id: "none", title: "Participant" }], + jid: borogove.JID.parse("bob@example.com"), + presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]), + chat: { chatId: "bob@example.com" }, + }, + { + id: "room-voice-requests@example.com/occ-2", + displayName: "Charlie", + photoUri: null, + isSelf: false, + roles: [{ id: "none", title: "Participant" }], + jid: borogove.JID.parse("charlie@example.com"), + presence: new Map([["desk", borogove.Stanza.parse("<presence />")]]), + chat: { chatId: "charlie@example.com" }, + } + ]); + + await persistence.storeVoiceRequest("alice@example.com", chat, "bob@example.com", true); + await persistence.storeVoiceRequest("alice@example.com", chat, "charlie@example.com", true); + + const requests1 = await persistence.listVoiceRequests("alice@example.com", chat); + + await persistence.storeVoiceRequest("alice@example.com", chat, "bob@example.com", false); + + const requests2 = await persistence.listVoiceRequests("alice@example.com", chat); + + return { + requests1: requests1.map((m) => m.displayName).sort(), + requests2: requests2.map((m) => m.displayName).sort(), + }; + }, + [code, sqlite, worker1], + ); + + expect(result.requests1).toEqual(["Bob", "Charlie"]); + expect(result.requests2).toEqual(["Charlie"]); + }); });