From 287fc30afd00d9bbb629aeb2ef76534cb60826dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=AA=20Qu=E1=BB=91c=20B=C3=ACnh?= Date: Thu, 2 Jul 2026 20:18:21 +0700 Subject: [PATCH 1/3] Read ephemeral events from the stable transaction key Homeservers deliver ephemeral events (typing, receipts, presence) to appservices under the stable `ephemeral` key in the transaction body per the Matrix spec, but onTransaction only read the unstable `de.sorunome.msc2409.ephemeral` key. Modern homeservers (e.g. Synapse, and Beeper's hungryserv) send the stable key, so ephemeral events were silently dropped. Read the stable key with a fallback to the unstable one, and add tests. --- src/app-service.ts | 5 +++- test/test_app-service.ts | 52 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 test/test_app-service.ts diff --git a/src/app-service.ts b/src/app-service.ts index 9b9c6f6..82a2add 100644 --- a/src/app-service.ts +++ b/src/app-service.ts @@ -285,7 +285,10 @@ export class AppService extends EventEmitter { } const events = req.body.events || []; - const ephemeral = req.body["de.sorunome.msc2409.ephemeral"] || []; + // Ephemeral events are delivered under the stable `ephemeral` key by + // spec-compliant homeservers; fall back to the unstable MSC2409 key for + // older ones. See https://spec.matrix.org/v1.11/application-service-api/#put_matrixappv1transactionstxnid + const ephemeral = req.body.ephemeral || req.body["de.sorunome.msc2409.ephemeral"] || []; if (this.lastProcessedTxnId === txnId) { res.send({}); // duplicate diff --git a/test/test_app-service.ts b/test/test_app-service.ts new file mode 100644 index 0000000..ee39f77 --- /dev/null +++ b/test/test_app-service.ts @@ -0,0 +1,52 @@ +import { AppService } from "../src/app-service"; +import { expect } from "chai"; +import { Request, Response } from "express"; + +const HS_TOKEN = "hstoken"; + +function mockReq(txnId: string, body: unknown): Request { + return { + params: { txnId }, + query: { access_token: HS_TOKEN }, + headers: {}, + body, + } as unknown as Request; +} + +function mockRes(): Response { + return { + status() { return this; }, + send() { return this; }, + } as unknown as Response; +} + +describe("AppService", () => { + describe("onTransaction ephemeral events", () => { + function receive(body: unknown): Record[] { + const appservice = new AppService({ homeserverToken: HS_TOKEN }); + const received: Record[] = []; + appservice.on("ephemeral", (ev) => received.push(ev)); + // onTransaction is private; exercise it directly with a valid token + (appservice as unknown as { onTransaction: (req: Request, res: Response) => void }) + .onTransaction(mockReq("1", body), mockRes()); + return received; + } + + it("emits ephemeral events sent under the stable `ephemeral` key", () => { + const receipt = { type: "m.receipt", content: {} }; + expect(receive({ ephemeral: [receipt] })).to.deep.equal([receipt]); + }); + + it("still emits ephemeral events under the unstable MSC2409 key", () => { + const receipt = { type: "m.receipt", content: {} }; + expect(receive({ "de.sorunome.msc2409.ephemeral": [receipt] })).to.deep.equal([receipt]); + }); + + it("prefers the stable key when both are present", () => { + const stable = { type: "m.receipt", content: { stable: true } }; + const unstable = { type: "m.receipt", content: { stable: false } }; + expect(receive({ ephemeral: [stable], "de.sorunome.msc2409.ephemeral": [unstable] })) + .to.deep.equal([stable]); + }); + }); +}); From 08ade3a24a2b1546a34a4ae4a9221de4b50ca503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=AA=20Qu=E1=BB=91c=20B=C3=ACnh?= Date: Thu, 2 Jul 2026 20:19:14 +0700 Subject: [PATCH 2/3] Add changelog entry --- changelog.d/84.bugfix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/84.bugfix diff --git a/changelog.d/84.bugfix b/changelog.d/84.bugfix new file mode 100644 index 0000000..eb86563 --- /dev/null +++ b/changelog.d/84.bugfix @@ -0,0 +1 @@ +Read ephemeral events from the stable `ephemeral` transaction key, falling back to the unstable MSC2409 key. Fixes ephemeral events (typing, receipts, presence) being dropped from spec-compliant homeservers. From 38e30144857bcd551b814747927043763ad425bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=AA=20Qu=E1=BB=91c=20B=C3=ACnh?= Date: Fri, 3 Jul 2026 00:41:03 +0700 Subject: [PATCH 3/3] Address review: spec link v1.13 and richer receipt test fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Lê Quốc Bình --- src/app-service.ts | 2 +- test/test_app-service.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/app-service.ts b/src/app-service.ts index 82a2add..ec8a2fb 100644 --- a/src/app-service.ts +++ b/src/app-service.ts @@ -287,7 +287,7 @@ export class AppService extends EventEmitter { const events = req.body.events || []; // Ephemeral events are delivered under the stable `ephemeral` key by // spec-compliant homeservers; fall back to the unstable MSC2409 key for - // older ones. See https://spec.matrix.org/v1.11/application-service-api/#put_matrixappv1transactionstxnid + // older ones. See https://spec.matrix.org/v1.13/application-service-api/#put_matrixappv1transactionstxnid const ephemeral = req.body.ephemeral || req.body["de.sorunome.msc2409.ephemeral"] || []; if (this.lastProcessedTxnId === txnId) { diff --git a/test/test_app-service.ts b/test/test_app-service.ts index ee39f77..46f3d65 100644 --- a/test/test_app-service.ts +++ b/test/test_app-service.ts @@ -32,13 +32,17 @@ describe("AppService", () => { return received; } + function mockReceipt() { + return { type: "m.receipt", content: { "!e:example.org": { "m.read": { "@u:example.org": { ts: Date.now() } } } } }; + } + it("emits ephemeral events sent under the stable `ephemeral` key", () => { - const receipt = { type: "m.receipt", content: {} }; + const receipt = mockReceipt(); expect(receive({ ephemeral: [receipt] })).to.deep.equal([receipt]); }); it("still emits ephemeral events under the unstable MSC2409 key", () => { - const receipt = { type: "m.receipt", content: {} }; + const receipt = mockReceipt(); expect(receive({ "de.sorunome.msc2409.ephemeral": [receipt] })).to.deep.equal([receipt]); });