Mouth: follow upgrades only a plain basis; @room keeps room #76
3 changed files with 21 additions and 9 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue