diff --git a/changelog.d/997-legacy-mention-notice.fixed.md b/changelog.d/997-legacy-mention-notice.fixed.md new file mode 100644 index 000000000..1459d6b58 --- /dev/null +++ b/changelog.d/997-legacy-mention-notice.fixed.md @@ -0,0 +1 @@ +**An `@` that could not reach the other home** used to be swallowed: when a person's line had to go out as the old envelope on a server that stamps typed lines, the other home read it as a Bot's line, so the named Bot was never called and only a log line said so. The room now gets one quiet notice naming the friend's Bot whose call was lost (`No call went out for @x: this connector token may not attest typed lines, so the other home reads this line as a Bot's.`); the words still go out, a probe that did not answer adds no notice, and a server that cannot stamp keeps the old behaviour where the labels still call the Bot (#997). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 98a1af6b5..320e980b6 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -108,6 +108,7 @@ Creating a group opens its Session with zero turns, and the host draws no sessio ### Face-room human turns and UI polish (#166, #167) - A plain human line in an open face-to-face room is logged and fanned out once, and starts a single pass of every local Bot (admin first, capped at 6, no @ needed; #287, #295; core `openingSpeakers`, see [core-host](core-host.md)). Since #299 the Bots pick the responder from a plain line (they may `[PASS]`; the first Bot that answers ends the round unless the line is to everyone; the first Bot answers if all pass on the owner's own line), and a line to everyone or `@all` is unchanged. A friend's attested plain line (or `@all`) starts the same pass on this home, routed and single like the owner's own line (#462) (the plugin passes `attested: true` to `postHuman` only for a stamped line); an attested line that names Bots starts only the Bot(s) it names by the label the picker shows (`Friend's Chief`) or by handle; `plugins/social/test` has the two-home case. **Explicit mention list (#293):** the sender's host resolves the picker's labels against the room (`faceMentions().handles`; core `onFaceLine(fn)` passes `{ mentions }` for the owner's typed line) and `faceApi.send` puts the handles in `mentions` next to `typed_by_owner` only when `GET /v1/platform` `features` lists `mentions` (the one feature probe, `serverMentions()`); never on the legacy envelope, Bot lines or an older server. The receiver reads `mentions` (`cleanMentions`, at most 8 handles) only from a line the server attests `typed_by_owner` (`renderFaceEvent`), and `postHuman` resolves the handles against the room first (`mentionedByHandle`): only my own Bots in the list answer; a list naming nobody the room knows, or no list, falls back to the label/name reading above. The composer's `@` picker inserts the label text, so the handles are derived from it at send, not tracked in the composer. +- **A call that cannot travel is said out loud (#997):** a person's `@` rides the attested line only. If the line has to go out as the legacy envelope and the server does stamp typed lines — the one combination where the other home reads that envelope as a Bot's line, so the `@` calls nobody — the room gets one quiet notice naming the handles (`No call went out for @x: this connector token may not attest typed lines, so the other home reads this line as a Bot's.`) right after the line is sent. The words are never withheld, a probe that did not answer is not treated as "no" and adds no notice (the next line asks again), and a server that cannot stamp keeps the old behaviour, where the envelope is the person's line and its labels still call the Bot. Only the room's **remote** seats count: a local Bot named after `@` is called by this room itself (core `openingSpeakers`), attested or as the envelope, so it is never reported as lost. `plugins/social/test/social.test.js` pins both directions, the local seat and the unanswered probe. - A held room line belongs to one room (#188): the send path records `settings.holdRooms[approvalId] = faceRoomId` when Bot Social's card does not name the room, and `pendingApprovals` drops holds with no known room (legacy) or whose room is left / closed / past here, so no other room shows the card and no waiting dot stays on an old row. The card names that room (#991): the number this home joined it by (`settings.faceRooms[roomId].joinCode`) when there is one, else the hosted room's own title, so two open rooms never both read `Chief → the room` — a hold whose room id names no number and no live room keeps the bare `the room`. A number is the six digits only (#1005): a stored `joinCode` that is not exactly six digits is no number at all — never cut to six, which would name a different room — so the card falls back to the title, and two live rooms with no number and the same title take a stable last-four tag from the room id (`the room Loft · a3f1`), each room keeping its own tag on every render. The room scope in the client, the `roomApprovals` provider and `hidden-remotes` match the room id strictly. - Face-room holds (`face_room_message` / `roomId`) attach `pendingCount` to that room only. They do not badge an unrelated friend row via `from`/`to` handle. The social strip no longer draws an **N to approve** tag under the last sidebar row; the gold wait dot on the owning RoomRow / 1:1 remains. - Approval preview strips `To face room:` / `To the room:` and wraps on word boundaries (`overflow-wrap:break-word; word-break:normal`), with **Show more** when long. diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 88dfea962..37ba1880d 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -4438,6 +4438,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // A person's line goes out plain with typed_by_owner when Bot Social attests it; an older // server (or a token that may not attest) gets the legacy envelope, as before. let sent + let legacy = false if (outbound.human && outbound.plain !== undefined && await canAttest()) { try { // The Bots the person's "@" picked go along as an explicit list when the server carries it (#293). @@ -4449,7 +4450,24 @@ export function createSocial(service, config = {}, { client: given, api: givenAp warn('Bot Social will not attest typed lines for this token; sending the legacy form', error) } } - sent ??= await faceApi.send(serverId, { text: outbound.text, botId: outbound.botId }) + if (sent === undefined) { + legacy = true + sent = await faceApi.send(serverId, { text: outbound.text, botId: outbound.botId }) + } + // #997: a person's "@" cannot ride the legacy envelope. On a server that stamps typed lines, the + // other home reads that envelope as a Bot's line, so the named Bot is never called and the sender + // used to see nothing but a `warn()`. The words still go out as the envelope — dropping the line + // would be worse — but the room gets one line saying which call was lost, instead of silence. + // Only a definite "this token may not attest" (the probe answered, the server refused the stamp) + // counts: a probe that did not answer is not a "no" and takes no line, and a server that cannot + // stamp at all reads the envelope as the person's line, so its labels still call the Bot. + if (legacy && outbound.human && typed.supported) { + // Only a friend's seat can lose the call: a local Bot named in the text is called by this room + // itself (core `openingSpeakers`), attested or as the envelope, so its call is not lost. + const seats = new Set((room.participants ?? []).filter(item => item.remote === true).map(item => norm(item.handle))) + const lost = [...new Set(mentions.map(handle => (typeof handle === 'string' ? norm(handle) : '')).filter(handle => handle && seats.has(handle)))] + if (lost.length > 0) flybot.rooms.postNotice?.(room.id, `No call went out for ${lost.join(', ')}: this connector token may not attest typed lines, so the other home reads this line as a Bot's.`) + } if (sent?.status === 'pending_approval') { rememberHoldRoom(sent.approvalId, serverId) // A person's own line is theirs: typing it is the approval, so it goes out at once diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index e1d104425..1624eb57d 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -8291,6 +8291,62 @@ test('mentions (#293): a typed line carries its @ handles when the server lists assert.equal('mentions' in legacy, false) }) +test('#997: an @ that cannot ride the legacy envelope is not swallowed — one room line names the call that was lost', async (t) => { + const typedLine = { authorKind: 'human', who: 'Danyang', text: "@Coder · Bob please look", authorId: 'owner-1' } + const noticesOf = () => { + const notices = [] + return { notices, tweak: (scoped) => { + const orig = scoped.rooms.postNotice?.bind(scoped.rooms) + scoped.rooms.postNotice = (id, text) => { notices.push(String(text)); return orig?.(id, text) } + } } + } + // The defect: a server that stamps typed lines refuses this token, so the line falls back to the legacy + // envelope. The other home reads that envelope as a Bot's line, so a friend's Bot named after @ is never called. + const refusing = attestingFaceServer({ refuse: true, features: ['typed_by_owner', 'mentions'] }) + refusing.face.peer('123456') // Bob is waiting in the room: his seat is a friend's, mine is local + const refused = noticesOf() + const a = await faceFixture(t, refusing, refused.tweak) + const before = refused.notices.length + await a.fanout(typedLine, { mentions: ['@coder.bob.home'] }) + const legacy = refusing.calls.filter(c => String(c.name).includes('/messages')).at(-1).args + assert.match(legacy.text, /FLYBOT-FACE-HUMAN/) + assert.equal('mentions' in legacy, false, 'the legacy envelope still never carries a callable list') + assert.deepEqual(refused.notices.slice(before), [ + "No call went out for @coder.bob.home: this connector token may not attest typed lines, so the other home reads this line as a Bot's.", + ], 'exactly one line, naming the friend seat whose call was lost') + // One line per lost call, not a repeated copy of the first. + await a.fanout({ ...typedLine, text: '@Coder · Bob again' }, { mentions: ['@coder.bob.home'] }) + assert.equal(refused.notices.length - before, 2, 'one line per lost call') + // A local Bot named after @ is not a lost call: this room calls it from the text itself (core openingSpeakers), + // so the same refusal must stay quiet for it — the notice used to name my own Bots too. + await a.fanout({ ...typedLine, text: '@Chief please look' }, { mentions: ['@chief.danyang.home'] }) + assert.equal(refused.notices.length - before, 2, 'my own Bot is called here; nothing is claimed lost') + + // The other direction: when the list does travel, the room stays quiet. + const carrying = attestingFaceServer({ features: ['typed_by_owner', 'mentions'] }) + carrying.face.peer('123456') + const quiet = noticesOf() + const b = await faceFixture(t, carrying, quiet.tweak) + const quietBefore = quiet.notices.length + await b.fanout(typedLine, { mentions: ['@coder.bob.home'] }) + assert.deepEqual(carrying.calls.find(c => String(c.name).includes('/messages')).args.mentions, ['@coder.bob.home']) + assert.equal(quiet.notices.length - quietBefore, 0, 'a call that went out is not announced') + + // "Not known yet" is not "no": a probe that did not answer does not hold the line and does not claim a drop. + const unreachable = attestingFaceServer({ features: ['typed_by_owner', 'mentions'] }) + unreachable.face.peer('123456') + const inner = unreachable.fetch + unreachable.fetch = async (url, init) => String(url).endsWith('/v1/platform') + ? new Response('no answer', { status: 502 }) + : inner(url, init) + const unknown = noticesOf() + const c = await faceFixture(t, unreachable, unknown.tweak) + const unknownBefore = unknown.notices.length + await c.fanout(typedLine, { mentions: ['@coder.bob.home'] }) + assert.match(unreachable.calls.filter(call => String(call.name).includes('/messages')).at(-1).args.text, /FLYBOT-FACE-HUMAN/, 'the line still goes out') + assert.equal(unknown.notices.length - unknownBefore, 0, 'an unanswered probe is not evidence that the call was dropped') +}) + test('mentions (#293): a friend\'s attested line calls my Bots by its handle list first; text is read only without one, and an unattested line never calls', async (t) => { const server = attestingFaceServer({ features: ['typed_by_owner', 'mentions'] }) const { call, sources, room } = await faceFixture(t, server)