diff --git a/docs/bridge.md b/docs/bridge.md index d3a07c9..4468988 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -198,6 +198,30 @@ Bifrost's native Discord gateway is the conversation observer. A direct mention Persona speech goes out through Matrix, not Discord. `tools/persona-matrix-mouth.mjs` is the one process for every Persona (`PERSONAS`, a list of slugs): it holds each Persona's Matrix access token as its own systemd credential (`matrix-token-`) and nobody else holds any. Per Persona it keeps one `/sync` loop over the configured rooms and the account's direct rooms (a DM invitation from an account on `MATRIX_SERVER_NAME` is accepted while that Persona's brake is released, and one from any other server is declined), one cursor and one delivery sequence. A delivery carries the sender's Matrix id, the event's `m.mentions` user ids and whether the room is a DM. A media event (`m.image`, `m.file`, `m.audio`, `m.video`) is delivered as a typed text line, `[image filename="cat.png" mime=image/png size=2048 url=mxc://server/id]`, followed by its caption when it has one, in `text` and in context. Only `m.image` is also fetched, and only when `IMAGE_STORE_ROOT` is set (unset, the line above is all there is and nothing is downloaded). The fetch is made as the receiving Persona, with its own token, from `/_matrix/client/v1/media/download//` on its homeserver, for a message the Persona is delivered (not its own words, a room it is not joined to, or history before the ear), and only from the event's own `content.url`; a URL in the message text is never followed. An encrypted attachment (`m.room.encrypted`, or `content.file`) is never fetched. The type is decided by the magic bytes alone (png, jpeg, gif, webp; SVG and anything else is refused, the declared mime is ignored), the body is capped at 5 MiB by `Content-Length` and by the bytes actually read, and the pixel count read from the image header (never decoded) is capped at 40 MP. A refused or failed fetch leaves the typed line with a fixed-word reason (`refused=encrypted|no-url|fetch-failed|too-big|wrong-type|bad-header|too-many-pixels|store-failed`) and no image. A stored image is written to `IMAGE_STORE_ROOT///[.png|.jpg|.gif|.webp]`, the name generated by the mouth, never the sender's: files `0640`, room directories `2750` (the Persona directory is setgid to the Persona's group, so the Persona's reader opens them by group; the mouth is the only writer), each written under a temporary name and renamed. It is deleted once it is at least 24 hours old and its delivery has been acked, whichever is later; a room keeps at most 50 MiB, the oldest evicted first. The sweep runs when an image is stored and when an ack arrives. A delivery is the 12-field tuple of `gamecult.persona.chat_pending.v2`: field 11, after `is_direct`, is nil or `[path, mime, size, sha256]`, a reference and never bytes; `path` is the store-relative `/` above, `mime` the sniffed type, `size` the file length (at most 5 MiB) and `sha256` its lowercase hex digest. The reader re-checks all four against the file. A Persona turn that has no reader for it still sees the typed line. Reading wakes nothing and acks nothing: the heartbeat polls with the non-consuming `gamecult.persona.chat_pending_request.v1`, and only `chat_ack` advances a position. An ack posts nothing to Matrix. That Persona's public `m.read` receipt follows `gamecult.persona.chat_read.v1` (`[persona, room_alias, event_id, at_unix_ms]`), which Huginn sends when a delivery has gone into the model's input: the mouth posts `m.read` at that event id only, for an event it delivered to that Persona in that room (to the delivered room id, whatever alias the report named), never behind the last receipt that landed there, one report per room at a time, and only while her brake is released (re-read after the presence write; fire and forget on the endpoint: a failure is logged once and not retried; pending polls and acks post none). Her Matrix presence follows a turn and her brake: `online` while a read report, a send or an ack came within `presenceGraceMs` (5 minutes), `unavailable` after that, `offline` while the brake is held. Each `/sync` carries the state as `set_presence`, and a change is also `PUT /presence//status` at once (a brake placed mid long-poll shows within `syncTimeoutMs`, 30 s, plus one PUT; a stopped mouth reads offline after Synapse's sync timeout). The mouth speaks on a `gamecult.persona.chat_send_request.v1` and joins rooms only while that Persona's brake (`/etc/gamecult/minds//control.cc`) is released. One loopback CultNet RUDP endpoint serves all of them; a request naming an unlisted Persona gets a typed `chat_refusal` (`UnknownPersona`). Document shapes: `tools/persona-chat-documents.mjs`; golden bytes: `tests/fixtures/persona-chat-v1/`, `-v2/` and `-v3/` (the pending answer; Huginn pins the same bytes). The Discord posting actuator, its permit and its RUDP listener are removed. +### Addressing facts (`chat_pending.v3`) + +The mouth wakes no one; the heartbeat and each Persona session decide who is told a delivery (Huginn `address.rs`, ruling `eureka-body:ruling:addressed-delivery-only`). Telling every Persona every message made 71.5% of 4,351 Persona turns on 2026-11-10 do nothing. The rule stays in one place, Huginn's, because two of its inputs are not Matrix facts: the Persona's stewardship records (her projects' chat names) and the sender's role (operator or Mind). The mouth owns the other half: what Matrix itself says, per Persona, for the delivery. Matrix verifies the sender's identity and nothing else: `tag` and `room` are the sender's claim in `m.mentions` (the homeserver does not check the text against it, nor that the sender may use `@room`), and `reply` is checked against the event it targets, which the mouth reads as her account. + +A client asks for the facts by the kind it polls with: `gamecult.persona.chat_pending_request.v2` (the v1 tuple, `[persona, after_seq]`) is answered with `gamecult.persona.chat_pending.v3`; a v1 request still gets the 12-field `chat_pending.v2` answer unchanged. A v3 delivery is the 12 fields of the v2 delivery, then: + +| Field | Type | Meaning | +|---|---|---| +| 12 `basis` | `"direct"`, `"tag"`, `"reply"`, `"room"` or nil | Why the message is for this Persona, by what Matrix says, first that holds: `direct` the room is one of her DMs; `tag` `m.mentions.user_ids` lists her id, or the client sent no `m.mentions` at all and an `` in the HTML body is exactly her `matrix.to` link; `reply` it replies to, or is in a thread under, an event her account wrote (her Self run speaks as her account, so its words count; any member's message in a thread under one of her events is a `reply` too, and the target is read once per room and event, a failure for a minute); `room` an `@room` mention. A `notice` or an `edit` costs no reply lookup. Known limit: lookups run one at a time inside the `/sync` round (10 s each, at most two per reply), so a slow homeserver slows the round; they are not parallelised or capped per round. | +| 13 `form` | `"message"`, `"notice"` or `"edit"` | `m.notice` is an automated line; `m.replace` is an edit of an earlier message. Only a `message` can address anyone. | + +What the mouth cannot know, and so what a nil `basis` means: **unproven, not "not for her"**. Her handle and public name in the text (any casing, with or without `@`, `'s`), the names her stewardships list, a project's repo name, and whether the sender is the operator or a Mind are all matched by Huginn, not here. A reply whose target the homeserver will not give is nil and logged. The mouth also cannot see whether a message was meant for her without naming her. Every delivery is still delivered, claimed tags included: nothing is dropped in the mouth, so the Huginn rule cannot lose a message the mouth had no way to judge. + +Proposed runtime half (follow-up `persona-wakes-on-address-only`): + +1. Poll with `chat_pending_request.v2` and parse `chat_pending.v3`. +2. A delivery is *addressed* to a Persona when `form == "message"` and either `basis` is `direct`, `tag` or `reply`, or `address::addresses` matches her names or her projects' names on the text. The off-beat wake keeps the sender gate of `heartbeat.rs` `wakes` (the operator or another Mind of the roster): a stranger's tag, `@room` or DM is delivered but wakes no one off the beat. `basis == "room"` is the runtime's policy to weigh; the mouth only reports it. +3. A real-time wake, a live session's tell and the stagger apply to addressed deliveries only. A `notice` or an `edit` never wakes, whatever its basis. +4. A delivery that is not addressed is acked in sequence with the rest, so the position moves (the mouth stops reading at 500 unacked deliveries); it is not lost: Matrix is the record, and the next addressed delivery's `context` (up to 20 lines) and a beat's own room read show it. Reading the tail on a scheduled beat is a runtime read of Matrix, a follow-up of the ruling `wake-reads-room-tail`; the mouth adds no endpoint for it. +5. `basis` and `form` are evidence, not authority: a sender's claim is weighed by the runtime's own rule (`address.rs` for names, the sender gate for who may wake her); the mouth reports what the event says and what only Matrix could look up (the author of a reply target). + +Golden bytes: `tests/fixtures/persona-chat-v5/`; Huginn pins the same bytes. + + Bifrost admits an observation only when guild, channel, target runtime, canonical repository, Persona, producer, and producer runtime match a preconfigured `bifrost.persona_feedback.target_binding.v0` document in Bifrost's private provider store. That binding also owns source visibility, data classification, and the delivery-store target. Bifrost never infers public visibility from Discord identifiers. It classifies the author using its own `bifrost.discord.actor_link.v0` state: a match yields `linked_governance_feedback`; absence yields `unlinked_social_feedback`. Neither class grants adoption, work, release, or deployment authority. `tools/persona-feedback.mjs enroll-identity` creates the Bifrost service's Ed25519 seed with exclusive creation and mode `0600`, then exports its public MessagePack trust anchor. The private seed belongs under Bifrost's service-private state (default `/var/lib/gamecult/bifrost/persona-feedback/persona-feedback-ed25519.seed`) and is checked for regular-file type, exact Unix mode, and service-user ownership every time it signs. VoidBot publishes pending observations only; it neither pumps admission nor receives the signing path. diff --git a/tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.tag.msgpack b/tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.tag.msgpack new file mode 100644 index 0000000..006f6cf Binary files /dev/null and b/tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.tag.msgpack differ diff --git a/tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.unproven-notice.msgpack b/tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.unproven-notice.msgpack new file mode 100644 index 0000000..069d3f9 Binary files /dev/null and b/tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending.v3.unproven-notice.msgpack differ diff --git a/tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending_request.v2.msgpack b/tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending_request.v2.msgpack new file mode 100644 index 0000000..0c326f5 --- /dev/null +++ b/tests/fixtures/persona-chat-v5/gamecult.persona.chat_pending_request.v2.msgpack @@ -0,0 +1 @@ +’¦eureka \ No newline at end of file diff --git a/tests/persona-matrix-mouth-runtime.test.mjs b/tests/persona-matrix-mouth-runtime.test.mjs index 4d2b0c6..1f9c157 100644 --- a/tests/persona-matrix-mouth-runtime.test.mjs +++ b/tests/persona-matrix-mouth-runtime.test.mjs @@ -173,6 +173,12 @@ test("the RUDP endpoint serves pending, ack and send over CultNet, and answers a assert.deepEqual(pending.deliveries.map(d => d.eventId), ["$e1"]); assert.deepEqual(pending.deliveries[0], delivery, "sender, mentions and the DM flag cross the wire exactly"); + reply = nextReply(); + put(docs.kinds.pendingRequestV3, "eureka", docs.pendingRequestTuple({ persona: "eureka", afterSeq: 0 })); + document = await reply; + assert.equal(document.schemaId, docs.kinds.pendingV3, "the kind asked by names the answer"); + assert.deepEqual(docs.parsePendingV3(msgpack.decode(document.payload)).deliveries[0], { ...delivery, basis: null, form: "message" }); + put(docs.kinds.ack, "eureka:7", docs.ackTuple({ persona: "eureka", throughSeq: 7 })); reply = nextReply(); put(docs.kinds.sendRequest, "req-2", docs.sendRequestTuple({ requestId: "req-2", persona: "eureka", roomAlias: "#operator:gamecult.org", text: "muted" })); @@ -311,6 +317,24 @@ for (const [name, { tuple, parse }] of Object.entries(v3Samples)) { }); } +// The pending answer v3 (ChatDelivery v4, basis and form): the bytes Huginn pins in its crates/mind-body/tests/fixtures/persona-chat-v5/. +const v5Dir = resolve(root, "tests", "fixtures", "persona-chat-v5"); +const v5Samples = { + "gamecult.persona.chat_pending_request.v2": { tuple: docs.pendingRequestTuple({ persona: "eureka", afterSeq: 3 }), parse: docs.parsePendingRequest }, + "gamecult.persona.chat_pending.v3.tag": { tuple: docs.pendingV3Tuple({ persona: "eureka", deliveries: [{ ...delivery, isDirect: false, basis: "tag", form: "message" }] }), parse: docs.parsePendingV3 }, + "gamecult.persona.chat_pending.v3.unproven-notice": { tuple: docs.pendingV3Tuple({ persona: "eureka", deliveries: [{ ...delivery, isDirect: false, mentions: [], basis: null, form: "notice" }] }), parse: docs.parsePendingV3 }, +}; +for (const [name, { tuple, parse }] of Object.entries(v5Samples)) { + test(`golden fixture: ${name} is byte-stable and parses back`, async () => { + const bytes = Buffer.from(msgpack.encode(tuple)); + const file = resolve(v5Dir, `${name}.msgpack`); + if (process.env.UPDATE_FIXTURES === "1") { await mkdir(v5Dir, { recursive: true }); await writeFile(file, bytes); } + assert.deepEqual(bytes, await readFile(file), "wire bytes changed: Huginn decodes these fixtures"); + assert.deepEqual(parse(msgpack.decode(bytes)), parse(tuple)); + if (name.includes("pending.v3")) assert.equal(msgpack.decode(bytes)[1][0].length, 14); + }); +} + // The send pair v2: the bytes the Huginn organ pins from its side. const v2Dir = resolve(root, "tests", "fixtures", "persona-chat-v2"); const v2Request = basis => docs.sendRequestV2Tuple({ requestId: "req-1", persona: "eureka", roomAlias: "#swarm:gamecult.org", text: "hi", basis }); diff --git a/tests/persona-matrix-mouth.test.mjs b/tests/persona-matrix-mouth.test.mjs index 1e33f64..9458370 100644 --- a/tests/persona-matrix-mouth.test.mjs +++ b/tests/persona-matrix-mouth.test.mjs @@ -10,7 +10,7 @@ import { createImageStore, RETAIN_MS } from "../tools/persona-chat-images.mjs"; import { personaStoreRoot } from "./image-bytes.mjs"; import * as docs from "../tools/persona-chat-documents.mjs"; import { gif, png, svg } from "./image-bytes.mjs"; -import { createMouth, bodyText, createBrakeReader, guarded, redact, roomsFor, projectRoomsVariable } from "../tools/persona-matrix-mouth.mjs"; +import { createMouth, pageOf, bodyText, createBrakeReader, guarded, redact, roomsFor, projectRoomsVariable } from "../tools/persona-matrix-mouth.mjs"; const TOKEN = "syt_SECRET_TOKEN_9f3"; const HS_NAME = "gamecult.test"; @@ -1303,3 +1303,175 @@ test("a read report that throws does not wedge the room: the next report still p assert.deepEqual(unhandled, [], "no rejection escapes the tail's cleanup"); assert.equal(hs.receipts().length, 1); }); + +// Addressing facts (docs/bridge.md, "Addressing facts"): why a delivery is for this Persona, as the heartbeat reads it. +// Each case is one message as a room member sends it, with the basis and form the mouth hands the consumer. +const META = `@meta:${HS_NAME}`; +const withContent = (id, sender, content, ts = 3000) => ({ type: "m.room.message", event_id: id, sender, origin_server_ts: ts, content: { msgtype: "m.text", ...content } }); +async function deliver(events, { persona = {}, preload } = {}) { + const hs = await fakeHomeserver({ + preload, + sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", ...joined({ [ROOM]: { timeline: { events } } }) }, + }); + try { + const mouth = mouthFor(hs, persona); + await mouth.syncOnce(); + return { made: await mouth.syncOnce(), hs, mouth }; + } finally { await hs.close(); } +} +const facts = made => made.map(d => [d.eventId, d.basis, d.form]); + +test("addressing facts: the operator speaking untagged proves nothing, tagged proves a tag, and the tag outranks @room", async () => { + const { made } = await deliver([ + withContent("$plain", META, { body: "everyone, status?" }), + withContent("$tag", META, { body: "eureka, status?", "m.mentions": { user_ids: [SELF] } }), + withContent("$both", META, { body: "all hands", "m.mentions": { user_ids: [SELF], room: true } }), + withContent("$room", META, { body: "@room standup", "m.mentions": { room: true } }), + ]); + assert.deepEqual(facts(made), [["$plain", null, "message"], ["$tag", "tag", "message"], ["$both", "tag", "message"], ["$room", "room", "message"]]); +}); + +test("addressing facts: a tag of another Mind is no basis for this one, and is one for that one", async () => { + const hs = await fakeHomeserver({ + users: { [TOKEN]: SELF, [TOKEN_NOVA]: NOVA }, + sync: since => since === null ? { next_batch: "b0", ...joined({ [ROOM]: { timeline: { events: [] } } }) } : { next_batch: "b1", ...joined({ [ROOM]: { timeline: { events: [withContent("$n", META, { body: "nova?", "m.mentions": { user_ids: [NOVA] } })] } } }) }, + }); + try { + const eureka = mouthFor(hs); + const nova = mouthFor(hs, { persona: "nova", token: TOKEN_NOVA }); + for (const m of [eureka, nova]) await m.syncOnce(); + assert.deepEqual([facts(await eureka.syncOnce()), facts(await nova.syncOnce())], [[["$n", null, "message"]], [["$n", "tag", "message"]]]); + } finally { await hs.close(); } +}); + +test("addressing facts: a name in the text is not a basis; the mouth does not know names, so every casing and alias reaches the heartbeat with null", async () => { + const bodies = ["Eureka, look", "EUREKA", "eureka's PR", "@eureka:gamecult.test please", "Bifrost needs a look"]; + const { made } = await deliver(bodies.map((body, i) => withContent(`$n${i}`, META, { body }))); + assert.deepEqual(made.map(d => d.basis), [null, null, null, null, null]); + assert.deepEqual(made.map(d => d.text), bodies, "the text is delivered whole for the heartbeat's own name match"); +}); + +test("addressing facts: a DM is direct whatever it says or carries, a notice in it included", async () => { + const DM = "!dm:gamecult.test"; + const hs = await fakeHomeserver({ + direct: { [META]: [DM] }, + sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", ...joined({ [DM]: { timeline: { events: [withContent("$dm", META, { body: "hi" }), withContent("$dmn", META, { msgtype: "m.notice", body: "automated" })] } } }) }, + }); + try { + const mouth = mouthFor(hs); + await mouth.syncOnce(); + assert.deepEqual(facts(await mouth.syncOnce()), [["$dm", "direct", "message"], ["$dmn", "direct", "notice"]]); + } finally { await hs.close(); } +}); + +test("addressing facts: a notice that tags her carries the tag and the notice form, so the consumer can refuse to wake on it", async () => { + const { made } = await deliver([withContent("$w", `@root-watch:${HS_NAME}`, { msgtype: "m.notice", body: "disk 91%", "m.mentions": { user_ids: [SELF] } })]); + assert.deepEqual(facts(made), [["$w", "tag", "notice"]]); +}); + +test("addressing facts: an edit is its own form and is never read as a new message", async () => { + const { made } = await deliver([withContent("$e", META, { body: "* fixed", "m.new_content": { msgtype: "m.text", body: "fixed" }, "m.relates_to": { rel_type: "m.replace", event_id: "$orig" } })]); + assert.deepEqual(facts(made), [["$e", null, "edit"]]); +}); + +test("addressing facts: a reply to her own words is a reply, in a room and in a thread, and her Self run's words are hers", async () => { + const mine = withContent("$mine", SELF, { body: "my earlier say" }, 1000); + const theirs = withContent("$theirs", `@nibu:${HS_NAME}`, { body: "someone else's" }, 1001); + const reply = to => ({ "m.relates_to": { "m.in_reply_to": { event_id: to } } }); + const thread = (root, fallback) => ({ "m.relates_to": { rel_type: "m.thread", event_id: root, "m.in_reply_to": { event_id: fallback }, is_falling_back: true } }); + const { made } = await deliver([ + withContent("$r1", META, { body: "yes", ...reply("$mine") }), + withContent("$r2", META, { body: "no", ...reply("$theirs") }), + withContent("$r3", META, { body: "in thread under mine", ...thread("$mine", "$theirs") }), + withContent("$r4", META, { body: "in thread under theirs", ...thread("$theirs", "$theirs") }), + withContent("$r5", META, { body: "reply, and tagged", ...reply("$mine"), "m.mentions": { user_ids: [SELF] } }), + ], { preload: { [ROOM]: [mine, theirs] } }); + assert.deepEqual(facts(made), [["$r1", "reply", "message"], ["$r2", null, "message"], ["$r3", "reply", "message"], ["$r4", null, "message"], ["$r5", "tag", "message"]]); +}); + +test("addressing facts: a reply target the homeserver will not give is null and logged without the token, never a guess", async () => { + const logs = []; + const { made } = await deliver([withContent("$r", META, { body: "re", "m.relates_to": { "m.in_reply_to": { event_id: "$gone" } } })], { persona: { log: l => logs.push(l) } }); + assert.deepEqual(facts(made), [["$r", null, "message"]]); + assert.ok(logs.some(l => l.includes("reply target $gone not read")) && logs.every(l => !l.includes(TOKEN))); +}); + +test("addressing facts: a client that sends no m.mentions is a tag when its body links her matrix.to pill; one that sends m.mentions without her is not", async () => { + const pill = id => `Eureka: look`; + const { made } = await deliver([ + withContent("$p1", META, { body: "Eureka: look", format: "org.matrix.custom.html", formatted_body: pill(SELF) }), + withContent("$p2", META, { body: "Eureka: look", format: "org.matrix.custom.html", formatted_body: pill(encodeURIComponent(SELF)) }), + withContent("$p3", META, { body: "Nova: look", format: "org.matrix.custom.html", formatted_body: pill(NOVA) }), + withContent("$p4", META, { body: "Eureka: look", format: "org.matrix.custom.html", formatted_body: pill(SELF), "m.mentions": {} }), + ]); + assert.deepEqual(made.map(d => d.basis), ["tag", "tag", null, null]); +}); + +test("addressing facts: her own messages are no delivery at all, so no basis can wake her on her own words", async () => { + const { made } = await deliver([withContent("$self", SELF, { body: "my own", "m.mentions": { user_ids: [SELF] } })]); + assert.deepEqual(made, []); +}); + +test("addressing facts: the v3 answer carries basis and form; the v2 answer a v1 request gets is the unchanged 12 fields", async () => { + const { made } = await deliver([withContent("$t", META, { body: "x", "m.mentions": { user_ids: [SELF] } })]); + const pack = { encode: v => Buffer.from(JSON.stringify(v)) }; + assert.equal(docs.parsePendingV3(pageOf("eureka", made, pack, () => {}, true)).deliveries[0].basis, "tag"); + const v2 = pageOf("eureka", made, pack); + assert.equal(v2[1][0].length, 12); + assert.equal(docs.parsePending(v2).deliveries[0].basis, undefined); + assert.throws(() => docs.parseDeliveryV3([...docs.deliveryTuple(made[0]), "tag", "poem"]), /unknown delivery form/); + assert.throws(() => docs.parseDeliveryV3([...docs.deliveryTuple(made[0]), "everyone", "message"]), /unknown delivery basis/); + assert.throws(() => docs.parseDeliveryV3(docs.deliveryTuple(made[0])), /14-field/); +}); + +test("addressing facts: a hit read for a reply in one room is not lent to a reply in another room, and a target the server places elsewhere is null", async () => { + const mine = { ...withContent("$mine", SELF, { body: "mine" }, 1000), room_id: ROOM }; + const reply = id => withContent(id, META, { body: "re", "m.relates_to": { "m.in_reply_to": { event_id: "$mine" } } }); + const hs = await fakeHomeserver({ + preload: { [ROOM]: [mine], "!operator:gamecult.test": [{ ...mine, room_id: "!elsewhere:gamecult.test" }] }, + sync: since => since === null ? { next_batch: "b0" } : { next_batch: "b1", ...joined({ [ROOM]: { timeline: { events: [reply("$prime")] } }, "!operator:gamecult.test": { timeline: { events: [reply("$spoof")] } } }) }, + }); + try { + const mouth = mouthFor(hs); + await mouth.syncOnce(); + assert.deepEqual(facts(await mouth.syncOnce()), [["$prime", "reply", "message"], ["$spoof", null, "message"]]); + } finally { await hs.close(); } +}); + +test("addressing facts: a pill is her exact matrix.to anchor: a longer id, a non-link attribute and a quoted reply are no tag", async () => { + const html = h => ({ body: "x", format: "org.matrix.custom.html", formatted_body: h }); + const { made } = await deliver([ + withContent("$a", META, html(`x`)), + withContent("$b", META, html(`x`)), + withContent("$c", META, html(`Eureka`)), + withContent("$d", META, html(`
her said
ok`)), + ]); + assert.deepEqual(made.map(d => d.basis), [null, null, "tag", null]); +}); + +test("addressing facts: an edit or a notice replying to her costs no target fetch", async () => { + const to = id => ({ "m.relates_to": { "m.in_reply_to": { event_id: id } } }); + const { made, hs } = await deliver([ + withContent("$n", META, { msgtype: "m.notice", body: "n", ...to("$noticed") }), + withContent("$e", META, { body: "* e", "m.relates_to": { rel_type: "m.replace", event_id: "$orig", "m.in_reply_to": { event_id: "$edited" } } }), + ]); + assert.deepEqual(facts(made), [["$n", null, "notice"], ["$e", null, "edit"]]); + assert.equal(hs.seen.filter(r => r.path.includes("/event/")).length, 0); +}); + +test("addressing facts: a target that failed is not refetched within a minute, and is after it", async () => { + let t = 1_000_000; + const rel = { "m.relates_to": { "m.in_reply_to": { event_id: "$gone" } } }; + const round = id => joined({ [ROOM]: { timeline: { events: [withContent(id, META, { body: id, ...rel })] } } }); + const script = { null: { next_batch: "b0" }, b0: { next_batch: "b1", ...round("$r1") }, b1: { next_batch: "b2", ...round("$r2") }, b2: { next_batch: "b3", ...round("$r3") } }; + const hs = await fakeHomeserver({ sync: since => script[since] ?? { next_batch: since } }); + const fetched = () => hs.seen.filter(r => r.path.includes("/event/")).length; + try { + const mouth = mouthFor(hs, { now: () => t }); + await mouth.syncOnce(); await mouth.syncOnce(); + t += 59_000; await mouth.syncOnce(); + assert.equal(fetched(), 1, "inside the minute: not refetched"); + t += 2_000; await mouth.syncOnce(); + assert.equal(fetched(), 2, "past the minute: read again"); + } finally { await hs.close(); } +}); diff --git a/tools/persona-chat-documents.mjs b/tools/persona-chat-documents.mjs index f5c6e67..4867901 100644 --- a/tools/persona-chat-documents.mjs +++ b/tools/persona-chat-documents.mjs @@ -9,6 +9,8 @@ import { IMAGE_PATH, MAX_BYTES, MIMES, extensionOf } from "./persona-chat-images export const kinds = Object.freeze({ pendingRequest: "gamecult.persona.chat_pending_request.v1", pending: "gamecult.persona.chat_pending.v2", + pendingRequestV3: "gamecult.persona.chat_pending_request.v2", // the same tuple as v1; asking by this kind is asking for the v3 answer + pendingV3: "gamecult.persona.chat_pending.v3", ack: "gamecult.persona.chat_ack.v1", read: "gamecult.persona.chat_read.v1", sendRequest: "gamecult.persona.chat_send_request.v1", @@ -53,6 +55,12 @@ export function parsePendingRequest(t) { return { persona: text(persona, "persona"), afterSeq: seq(afterSeq, "after_seq") }; } +// What the mouth can establish about a delivery from Matrix alone, for the Persona it is delivered to (see docs/bridge.md, "Addressing facts"). +// `basis` is why the message is for her, or null when the mouth has no proof (not that it is not for her: her names, her projects' names and +// the sender's role are not the mouth's to know). `form` is the event's kind: only a "message" can address anyone. +export const bases = Object.freeze(["direct", "tag", "reply", "room"]); +export const forms = Object.freeze(["message", "notice", "edit"]); + export function ackTuple({ persona, throughSeq }) { return [text(persona, "persona"), seq(throughSeq, "through_seq")]; } @@ -190,6 +198,23 @@ function parseImage(t) { imageTuple({ path, mime, size, sha256 }); return { path, mime, size, sha256 }; } +// The v3 delivery: the 12 fields above, then basis and form. +export function deliveryV3Tuple(d) { + return [...deliveryTuple(d), basis(d.basis ?? null), form(d.form ?? "message")]; +} +export function parseDeliveryV3(t) { + const fields = tuple(t, 14, "delivery_v3"); + return { ...parseDelivery(fields.slice(0, 12)), basis: basis(fields[12]), form: form(fields[13]) }; +} +function basis(v) { + if (v !== null && !bases.includes(v)) throw new Error(`unknown delivery basis ${String(v)}`); + return v; +} +function form(v) { + if (!forms.includes(v)) throw new Error(`unknown delivery form ${String(v)}`); + return v; +} + export function parseDelivery(t) { const [s, roomAlias, roomId, eventId, sender, senderDisplay, body, atUnixMs, context, mentions, isDirect, image] = tuple(t, 12, "delivery"); if (!Array.isArray(context)) throw new Error("delivery context must be a list"); @@ -212,6 +237,15 @@ export function parsePending(t) { return { persona: text(persona, "persona"), deliveries: deliveries.map(parseDelivery) }; } +export function pendingV3Tuple({ persona, deliveries }) { + return [text(persona, "persona"), deliveries.map(deliveryV3Tuple)]; +} +export function parsePendingV3(t) { + const [persona, deliveries] = tuple(t, 2, "chat_pending_v3"); + if (!Array.isArray(deliveries)) throw new Error("chat_pending deliveries must be a list"); + return { persona: text(persona, "persona"), deliveries: deliveries.map(parseDeliveryV3) }; +} + // The CultNet document_put_raw envelope every chat_* document travels in. export function envelope({ kind, recordKey, payload, sourceRuntimeId, sourceAgentId, sourceRole }) { return { diff --git a/tools/persona-matrix-mouth.mjs b/tools/persona-matrix-mouth.mjs index 1fca153..439816e 100644 --- a/tools/persona-matrix-mouth.mjs +++ b/tools/persona-matrix-mouth.mjs @@ -5,7 +5,9 @@ // from another sender becomes a delivery kept in memory, carrying its sender id, // its m.mentions user ids and whether the room is a DM. The /sync position // (next_batch) is persisted only when an ack covers it, so a restart before the -// ack re-delivers instead of losing. Reading wakes nothing: the heartbeat polls +// ack re-delivers instead of losing. A v3 delivery also carries what Matrix proves about why it is for her (basis: direct, tag, +// reply, room; form: message, notice, edit); names and roles are the heartbeat's to match, not the mouth's (docs/bridge.md, +// "Addressing facts"). Reading wakes nothing: the heartbeat polls // with a non-consuming chat_pending_request and decides who runs. An ack posts nothing to Matrix. Her public // m.read receipt follows a chat_read document from Huginn, sent when a delivery has gone into the model's // input: the mouth posts m.read at that event id only, for an event it delivered to her, never behind one @@ -63,6 +65,9 @@ const NOT_E2EE = "This room is not end-to-end encrypted: the homeserver can read const dayOf = ms => new Date(ms).toISOString().slice(0, 10); const HEAD_PAGE = 50; // the room head a gated send reads; also the most ids a RoomMoved receipt names +// The kind of an event for a delivery: an edit (m.replace) and an automated notice (m.notice) are not messages anyone is addressed by. +const formOf = content => content?.["m.relates_to"]?.rel_type === "m.replace" ? "edit" : content?.msgtype === "m.notice" ? "notice" : "message"; + // A message line: a text event with a sender, not an edit. const isLine = e => e?.type === "m.room.message" && typeof e.content?.body === "string" && typeof e.sender === "string" && e.sender !== "" && e.content?.["m.relates_to"]?.rel_type !== "m.replace"; // Whether `event` moves the room under a Persona whose account is `selfId`: someone else's line that is @@ -284,6 +289,43 @@ export function createMouth({ return lines; } + // Why a delivered event is for this Persona, from what Matrix says and nothing else: "direct" (a DM), "tag" (m.mentions lists her + // id, or, from a client that sends no m.mentions at all, the body links her matrix.to pill), "reply" (it replies to, or sits in a + // thread under, an event her account wrote, so her Self run's words count as hers), "room" (an @room mention). In that order; null + // when none holds. Her names, her projects' names and the sender's role are the heartbeat's to match (Huginn address.rs), never the + // mouth's. A reply target that cannot be read is null and logged: unknown, not "not hers". + async function basisOf(roomId, event) { + if (directRooms.has(roomId)) return "direct"; + const mentions = event.content["m.mentions"]; + if (Array.isArray(mentions?.user_ids) ? mentions.user_ids.includes(selfId) : mentions === undefined && pillFor(event.content.formatted_body)) return "tag"; + if (formOf(event.content) === "message" && await repliesToMe(roomId, event.content["m.relates_to"])) return "reply"; // an edit or a notice wakes no one: no fetch for it + return mentions?.room === true ? "room" : null; + } + // A pill is an anchor whose href is exactly her matrix.to link; a longer id, an attribute that merely contains one and a quoted reply do not count. + const pillFor = html => typeof html === "string" && [...html.replace(/[\s\S]*?<\/mx-reply>/gi, "").matchAll(/]*?href\s*=\s*(["'])(.*?)\1/gi)].some(([, , href]) => [selfId, encodeURIComponent(selfId)].some(id => href === `https://matrix.to/#/${id}`)); + const authors = new Map(); // `${room id} ${event id}` -> { sender, at }: read results, a failure kept for NEGATIVE_MS, bounded; the key holds the room so another room's reply cannot borrow a hit + const NEGATIVE_MS = 60 * 1000; + async function authorOf(roomId, eventId) { + const key = `${roomId} ${eventId}`; + const known = authors.get(key); + if (known && (known.sender !== null || now() - known.at < NEGATIVE_MS)) return known.sender; + let sender = null; + try { + const target = await matrix("GET", `/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`, undefined, API, AbortSignal.timeout(sendTimeoutMs)); + if (target?.room_id === undefined || target.room_id === roomId) sender = typeof target?.sender === "string" ? target.sender : null; // an id from another room is not an answer about this one + } catch (error) { + say(`reply target ${eventId} not read: ${error.message}`); + } + authors.set(key, { sender, at: now() }); + if (authors.size > DELIVERED_INDEX) authors.delete(authors.keys().next().value); + return sender; + } + async function repliesToMe(roomId, relates) { + const targets = new Set([relates?.["m.in_reply_to"]?.event_id, relates?.rel_type === "m.thread" ? relates.event_id : undefined].filter(id => typeof id === "string" && id !== "")); + for (const id of targets) if (await authorOf(roomId, id) === selfId) return true; + return false; + } + // The image of one delivered m.image event: { image } (a stored reference) or { refused } (a reason word). With no // store nothing is attempted and nothing is said. The url is the event's own content.url; encrypted events and // anything the checks refuse leave only the text line. @@ -351,12 +393,13 @@ export function createMouth({ const { image = null, refused = null } = await imageOf(roomId, event.content); if (refused !== null) say(`image refused: ${refused}`); const mentioned = event.content["m.mentions"]?.user_ids; + const basis = await basisOf(roomId, event); made.push({ seq: ++seqCounter, roomAlias: alias, roomId, eventId: event.event_id, sender: event.sender, senderDisplay: displayNames.get(event.sender) ?? localpart(event.sender), text: bodyText(event.content, docs.limits.textBytes, refused), atUnixMs, context, image, mentions: Array.isArray(mentioned) ? mentioned.filter(id => typeof id === "string" && id !== "" && id.length <= 255).slice(0, docs.limits.mentions) : [], - isDirect: directRooms.has(roomId), + isDirect: directRooms.has(roomId), basis, form: formOf(event.content), }); if (image) images.hold(image.path, seqCounter); } @@ -628,12 +671,12 @@ export function connectionIdFor(connectionKey) { // The reply to a chat_pending_request: deliveries oldest first, each encoded on its own so one that // will not encode is skipped and logged without silencing the rest, up to the reply byte budget. // The caller acks what it has read and asks again. -export function pageOf(persona, deliveries, msgpack, log = () => {}) { +export function pageOf(persona, deliveries, msgpack, log = () => {}, v3 = false) { const page = []; let bytes = 0; for (const delivery of deliveries) { let tuple, size; - try { tuple = docs.deliveryTuple(delivery); size = msgpack.encode(tuple).length; } catch (error) { + try { tuple = (v3 ? docs.deliveryV3Tuple : docs.deliveryTuple)(delivery); size = msgpack.encode(tuple).length; } catch (error) { log(`delivery ${delivery?.seq} skipped: ${error?.message ?? error}`); continue; } @@ -661,7 +704,7 @@ export async function startChatEndpoint({ mouths, runtime, port, connectionKey, const respond = (peer, persona, kind, recordKey, tuple) => server.send(peer, "schema", msgpack.encode(cultnet.encodeCultNetMessageForWire( docs.envelope({ kind, recordKey, payload: msgpack.encode(tuple), sourceRuntimeId: runtimeId, sourceAgentId: persona, sourceRole: "bifrost-persona-matrix-mouth" }), "cultnet.schema.v0"))); const refuseUnknown = (peer, persona) => respond(peer, persona, docs.kinds.refusal, persona, docs.refusalTuple({ persona, refusal: "UnknownPersona" })); - const served = [docs.kinds.pendingRequest, docs.kinds.ack, docs.kinds.read, docs.kinds.sendRequest, docs.kinds.sendRequestV2, docs.kinds.openDirectRequest]; + const served = [docs.kinds.pendingRequest, docs.kinds.pendingRequestV3, docs.kinds.ack, docs.kinds.read, docs.kinds.sendRequest, docs.kinds.sendRequestV2, docs.kinds.openDirectRequest]; const handle = async ({ peer, channelId, payload }) => { try { if (channelId !== "schema") throw new Error("chat request arrived on a foreign channel"); @@ -678,11 +721,12 @@ export async function startChatEndpoint({ mouths, runtime, port, connectionKey, const mouth = mouths.get(request.persona); const receipt = mouth ? await mouth.openDirect(request) : { requestId: request.requestId, outcome: "UnknownPersona", roomId: null, eventId: null }; respond(peer, request.persona, docs.kinds.openDirectReceipt, receipt.requestId, docs.openDirectReceiptTuple(receipt)); - } else if (document.schemaId === docs.kinds.pendingRequest) { + } else if (document.schemaId === docs.kinds.pendingRequest || document.schemaId === docs.kinds.pendingRequestV3) { + const v3 = document.schemaId === docs.kinds.pendingRequestV3; // the kind asked by names the answer: v1 gets the 12-field v2 answer, as before const request = docs.parsePendingRequest(tuple); const mouth = mouths.get(request.persona); if (!mouth) return refuseUnknown(peer, request.persona); - respond(peer, request.persona, docs.kinds.pending, request.persona, pageOf(request.persona, mouth.pendingAfter(request.afterSeq), msgpack, log)); + respond(peer, request.persona, v3 ? docs.kinds.pendingV3 : docs.kinds.pending, request.persona, pageOf(request.persona, mouth.pendingAfter(request.afterSeq), msgpack, log, v3)); } else if (document.schemaId === docs.kinds.ack) { const request = docs.parseAck(tuple); const mouth = mouths.get(request.persona);