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. diff --git a/src/app-service.ts b/src/app-service.ts index 9b9c6f6..ec8a2fb 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.13/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..46f3d65 --- /dev/null +++ b/test/test_app-service.ts @@ -0,0 +1,56 @@ +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; + } + + 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 = mockReceipt(); + expect(receive({ ephemeral: [receipt] })).to.deep.equal([receipt]); + }); + + it("still emits ephemeral events under the unstable MSC2409 key", () => { + const receipt = mockReceipt(); + 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]); + }); + }); +});