diff --git a/docs/bridge.md b/docs/bridge.md index 5872f22..b4cf73c 100644 --- a/docs/bridge.md +++ b/docs/bridge.md @@ -255,13 +255,13 @@ Golden bytes: `tests/fixtures/persona-chat-v5/` (`chat_pending.v3.tail` carries ### Follow (`basis` `"follow"`, `CHAT_FOLLOW=1`) -A message from someone she is in conversation with is for her even when it names no one. **It is off unless the mouth runs with `CHAT_FOLLOW=1`** (exactly `1`; default off), because Huginn's parser refuses an unknown basis until its `Basis::Follow` (and the one `is_for` line) lands. With the flag off the mouth emits no `follow`, makes no extra read, and keeps no follow state. +A message from someone she is in conversation with is for her even when it names no one. **It is off unless the mouth runs with `CHAT_FOLLOW=1`** (exactly `1`; default off), because the basis is Huginn's `Basis::FollowUp`, wire string `follow` (`FOLLOW_UP_BASIS`, f09bc56e), and a Huginn that predates it refuses the string. With the flag off the mouth emits no `follow`, makes no extra read, and keeps no follow state. With it on: - **Anchor.** Her own post in a public room anchors that room for W = 600 s from the post. The mouth anchors at send time (the send's own time, in `send`, in the background: the send receipt never waits on the room-state read) and also from the /sync echo of her own events, so a post from another client of her account anchors too; one event anchors once. Only her posting again re-anchors the room and re-snapshots who is counted; a follow never extends the window. - **Counted senders** (snapshotted at the post): those who spoke in that room in the 10 minutes before the post (a per-room ring of recent senders with event times, 200 entries), plus anyone the post tagged (`m.mentions.user_ids`) or replied to (the author of the quoted `m.in_reply_to`, not a thread fallback pointer) when the event carries it. Someone who first speaks after her post is not counted. A member who spoke in the 10 minutes before her post is counted, bounded by 3 follows per hour per (peer, room). Nothing reads message text; no ids are scraped from bodies. She herself, and bridged senders (the `platformEvent` identifier rule), never count. -- **Follow.** A `message` (not an edit, not a notice) from a counted sender gets `follow` when it would otherwise have no basis or `room`; `tag`, `reply` and `direct` are never replaced. Its timestamp must lie inside the window (a homeserver clock up to 60 s behind still follows) and this process's clock must not be past the window's end by more than 60 s, so an old or forged `origin_server_ts` reuses nothing. At most K = 3 per (Mind, peer, room) in the hour before the message; past it the message keeps the basis it had. +- **Follow.** A `message` (not an edit, not a notice) from a counted sender gets `follow` only when it would otherwise have no basis (a plain message); `room` (@room), `tag`, `reply` and `direct` keep their own basis, and an @room neither wakes via follow nor spends the peer's K. Its timestamp must lie inside the window (a homeserver clock up to 60 s behind still follows) and this process's clock must not be past the window's end by more than 60 s, so an old or forged `origin_server_ts` reuses nothing. At most K = 3 per (Mind, peer, room) in the hour before the message; past it the message keeps the basis it had. - **"Public" means non-DM, non-encrypted, non-bridged.** The room is not one of her DMs and its state has no `m.room.encryption`, `m.bridge` or `uk.half-shot.bridge` (read once per room, kept 5 minutes; unreadable or malformed state is not public). An invite-only plaintext room counts as public. Nothing is anchored in, and no follow is delivered in, any other room. State is in memory only (per-room anchor, sender ring, follow times), purged on every sync pass; nothing is stored, and a restart only shortens windows. diff --git a/tests/persona-matrix-mouth-follow.test.mjs b/tests/persona-matrix-mouth-follow.test.mjs index c5bd5f2..c1a705d 100644 --- a/tests/persona-matrix-mouth-follow.test.mjs +++ b/tests/persona-matrix-mouth-follow.test.mjs @@ -176,7 +176,7 @@ test("follow: at most three an hour per room and peer, counted independently; a assert.deepEqual(got, { $k0: null, $l0: null, $ko0: null, $k1: "follow", $ko1: "follow", $k2: "follow", $k3: "follow", $k4: null, $l4: "follow", $k30: null, $k31: null, $k60: null, $k62: "follow" }); }); -test("follow: an edit and a notice are never follow; a room-basis message from a counted sender is; a tag keeps its own basis", async () => { +test("follow: an edit and a notice are never follow; a room-basis message from a counted sender stays room; a tag keeps its own basis", async () => { const w = world({ rounds: [ { [ROOM]: [msg("$k0", KIKO, T0 - MIN)] }, { [ROOM]: [mine("$mine", T0)] }, @@ -187,7 +187,7 @@ test("follow: an edit and a notice are never follow; a room-basis message from a msg("$tag", KIKO, T0 + SEC, { "m.mentions": { user_ids: [ME] } }), ] }, ] }); - assert.deepEqual((await run(w, 3))[2].sort(), [["$edit", null], ["$notice", null], ["$room", "follow"], ["$tag", "tag"]]); + assert.deepEqual((await run(w, 3))[2].sort(), [["$edit", null], ["$notice", null], ["$room", "room"], ["$tag", "tag"]]); }); test("follow: flag off emits nothing new and reads nothing extra; a fresh mouth has no engagement", async () => { @@ -376,3 +376,15 @@ test("follow: an echo that arrives while the send's anchor is still reading wait assert.equal(w.seen.filter(l => l.endsWith("/state")).length, 1); assert.equal(w.mouth.followState().engagements[0].from, T0, "anchored at the send's time, not the echo's"); }); + +test("follow: an @room from a followed peer stays room and spends none of the cap; plain messages still upgrade to follow", async () => { + const w = world({ rounds: [ + { [ROOM]: [msg("$k0", KIKO, T0 - MIN)] }, + { [ROOM]: [mine("$mine", T0)] }, + { [ROOM]: [1, 2, 3, 4].map(n => msg(`$r${n}`, KIKO, T0 + n * SEC, { "m.mentions": { room: true } })) }, + { [ROOM]: [1, 2, 3].map(n => msg(`$p${n}`, KIKO, T0 + 10 * SEC + n)) }, + ] }); + const out = await run(w, 4); + assert.deepEqual(out[2], [["$r1", "room"], ["$r2", "room"], ["$r3", "room"], ["$r4", "room"]]); + assert.deepEqual(out[3], [["$p1", "follow"], ["$p2", "follow"], ["$p3", "follow"]]); // all three of K still unspent +}); diff --git a/tools/persona-matrix-mouth.mjs b/tools/persona-matrix-mouth.mjs index 77b45e2..0964830 100644 --- a/tools/persona-matrix-mouth.mjs +++ b/tools/persona-matrix-mouth.mjs @@ -25,8 +25,8 @@ // // Follow (CHAT_FOLLOW=1, default off; docs/bridge.md "Follow"): her own send in a public room (not a DM, not encrypted, not bridged) anchors the room // for 10 minutes; those who spoke there in the 10 minutes before it, and whoever it tagged or replied to, get basis "follow" on their later messages -// inside the window, at most 3 an hour per peer and room. Memory only. Off, no basis "follow" is ever emitted: Huginn's parser refuses it until its -// Basis::Follow lands. +// inside the window, at most 3 an hour per peer and room; only a plain message (no basis) is upgraded, an @room keeps `room`. Memory only. Off, no +// basis "follow" is ever emitted. Huginn's variant is Basis::FollowUp, wire string "follow" (FOLLOW_UP_BASIS, f09bc56e). // // Media: an m.image/m.file/m.audio/m.video event is delivered as one typed line (see bodyText), not its bare filename. // An m.image event in a delivered message is also fetched, as this Persona and only when an image store is configured @@ -137,7 +137,7 @@ export function createMouth({ persona, homeserver, serverName, rooms, token, fetch: httpFetch = globalThis.fetch, state, brake, now = Date.now, log = () => {}, syncTimeoutMs = 30000, maxPending = MAX_PENDING, gate = createRoomGate(), sendTimeoutMs = SEND_TIMEOUT_MS, presenceGraceMs = PRESENCE_GRACE_MS, fetchDeadlineMs = FETCH_DEADLINE_MS, - follow = false, // the follow basis (CHAT_FOLLOW): an untagged message from someone she tagged or replied to, within FOLLOW_WINDOW_MS of her own post, is for her + follow = false, // the follow basis (CHAT_FOLLOW): a plain (no-basis) message from someone she tagged or replied to, within FOLLOW_WINDOW_MS of her own post, is for her replyLink = false, // resolve each reply's quoted event into delivery.replyTo (the v4 answer; one more event read per reply to a non-Mind) images = null, // this Persona's image store (createImageStore), or null: images stay the typed text line isServed = async () => false, // (async) whether a Matrix user id is an account this process speaks for: a Mind is not a person to open a DM with @@ -580,7 +580,7 @@ export function createMouth({ if (!baseline && !freshRooms.has(roomId) && event.sender === selfId) await noteOwnPost(roomId, event); noteSpeaker(roomId, event); let basis = deliverable ? await basisOf(roomId, event) : null; - if (deliverable && (basis === null || basis === "room") && await followed(roomId, event)) basis = "follow"; + if (deliverable && basis === null && await followed(roomId, event)) basis = "follow"; const replyTo = deliverable ? await replyToOf(roomId, event) : null; processed.add(event.event_id); if (deliverable) { @@ -1185,7 +1185,7 @@ export function loadRuntime(cultRoot) { export const replyLinkFromEnv = env => env.CHAT_PENDING_V4 === "1"; // The fetch flag, the same way: on only for exactly "1". Off, the fetch request kind is an unknown kind. export const fetchFromEnv = env => env.CHAT_FETCH === "1"; -// The follow flag, the same way. Off, the mouth never emits basis "follow" (Huginn's parser refuses an unknown basis until its Basis::Follow lands). +// The follow flag, the same way. Off, the mouth never emits basis "follow" (Huginn's variant is Basis::FollowUp, wire string "follow", FOLLOW_UP_BASIS f09bc56e). export const followFromEnv = env => env.CHAT_FOLLOW === "1"; function required(env, name) {