Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/997-legacy-mention-notice.fixed.md
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions docs/subsystems/social.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 19 additions & 1 deletion plugins/social/src/social.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
Expand Down
56 changes: 56 additions & 0 deletions plugins/social/test/social.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading