From 86a4a1d0a4ffddea258614bb00371a03e5b5b8e1 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sun, 25 Oct 2026 19:23:43 +0800 Subject: [PATCH 1/2] Say which @ was lost when a typed line falls back to the old envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A person's line goes out with typed_by_owner only when the server stamps typed lines and this token may attest. When the token is refused, the plugin falls back to the FLYBOT-FACE-HUMAN envelope — and on a server that does stamp, the other home reads that envelope as a Bot's line, so the Bot named after @ is never called. All the sender got was a warn() in a log (#997). The fallback stays: the words must still go out, and a server that cannot stamp reads the envelope as the person's line, where the labels still call the Bot. In the one combination where the call is definitely lost — legacy envelope, a person's line, an @ handle, and the server known to stamp — the room now gets one quiet notice naming the handles. A probe that did not answer is not "no": it holds nothing and adds no notice. Fixes #997 --- .../997-legacy-mention-notice.fixed.md | 1 + docs/subsystems/social.md | 1 + plugins/social/src/social.js | 17 ++++++- plugins/social/test/social.test.js | 49 +++++++++++++++++++ 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 changelog.d/997-legacy-mention-notice.fixed.md 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..12d91018c --- /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 Bot that was not called (`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..51dc634a6 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. `plugins/social/test/social.test.js` pins both directions 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..f725352ed 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,21 @@ 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) { + const lost = mentions.filter(handle => typeof handle === 'string' && handle.startsWith('@')) + 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..5e9032b39 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -8291,6 +8291,55 @@ 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: "@Bob's Chief please look", authorId: 'owner-1' } + const noticesOf = tweak => { + const notices = [] + return { notices, tweak: (scoped) => { + 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 the @ reaches nobody. + const refusing = attestingFaceServer({ refuse: true, features: ['typed_by_owner', 'mentions'] }) + const refused = noticesOf() + const a = await faceFixture(t, refusing, refused.tweak) + const before = refused.notices.length + await a.fanout(typedLine, { mentions: ['@chief.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 @chief.bob.home: this connector token may not attest typed lines, so the other home reads this line as a Bot's.`], 'exactly one line, not a warn nobody sees, naming the Bot that was not called') + // One line per lost call, not a repeated copy of the first. + await a.fanout({ ...typedLine, text: "@Bob's Chief again" }, { mentions: ['@chief.bob.home'] }) + assert.equal(refused.notices.length - before, 2, 'one line per lost call') + + // The other direction: when the list does travel, the room stays quiet. + const carrying = attestingFaceServer({ features: ['typed_by_owner', 'mentions'] }) + const quiet = noticesOf() + const b = await faceFixture(t, carrying, quiet.tweak) + const quietBefore = quiet.notices.length + await b.fanout(typedLine, { mentions: ['@chief.bob.home'] }) + assert.deepEqual(carrying.calls.find(c => String(c.name).includes('/messages')).args.mentions, ['@chief.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'] }) + 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: ['@chief.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) From 84b892f53fe16b3b5428b035b930b4ab6f855080 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sun, 25 Oct 2026 19:34:55 +0800 Subject: [PATCH 2/2] Name only a friend's seat in the lost-@ notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The notice at the first tip named every handle in the mention list, but a local Bot named after @ is called by this room itself (core openingSpeakers), attested or as the legacy envelope — its call is not lost, and @ing your own Bot is the common case in a face room. The notice now compares each handle with the room's remote participants (same normalisation as handleKey: no leading @, lower case) and names only those; a local handle adds nothing. The new test case pins it: the same refusal with a local handle stays quiet, next to the friend's seat that still gets the line. Fixes #997 --- .../997-legacy-mention-notice.fixed.md | 2 +- docs/subsystems/social.md | 2 +- plugins/social/src/social.js | 5 +++- plugins/social/test/social.test.js | 29 ++++++++++++------- 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/changelog.d/997-legacy-mention-notice.fixed.md b/changelog.d/997-legacy-mention-notice.fixed.md index 12d91018c..1459d6b58 100644 --- a/changelog.d/997-legacy-mention-notice.fixed.md +++ b/changelog.d/997-legacy-mention-notice.fixed.md @@ -1 +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 Bot that was not called (`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). +**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 51dc634a6..320e980b6 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -108,7 +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. `plugins/social/test/social.test.js` pins both directions and the unanswered probe. +- **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 f725352ed..37ba1880d 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -4462,7 +4462,10 @@ export function createSocial(service, config = {}, { client: given, api: givenAp // 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) { - const lost = mentions.filter(handle => typeof handle === 'string' && handle.startsWith('@')) + // 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') { diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 5e9032b39..1624eb57d 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -8292,41 +8292,49 @@ test('mentions (#293): a typed line carries its @ handles when the server lists }) 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: "@Bob's Chief please look", authorId: 'owner-1' } - const noticesOf = tweak => { + const typedLine = { authorKind: 'human', who: 'Danyang', text: "@Coder · Bob please look", authorId: 'owner-1' } + const noticesOf = () => { const notices = [] return { notices, tweak: (scoped) => { - 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 the @ reaches nobody. + // 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: ['@chief.bob.home'] }) + 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 @chief.bob.home: this connector token may not attest typed lines, so the other home reads this line as a Bot's.`], 'exactly one line, not a warn nobody sees, naming the Bot that was not called') + 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: "@Bob's Chief again" }, { mentions: ['@chief.bob.home'] }) + 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: ['@chief.bob.home'] }) - assert.deepEqual(carrying.calls.find(c => String(c.name).includes('/messages')).args.mentions, ['@chief.bob.home']) + 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 }) @@ -8334,12 +8342,11 @@ test('#997: an @ that cannot ride the legacy envelope is not swallowed — one r const unknown = noticesOf() const c = await faceFixture(t, unreachable, unknown.tweak) const unknownBefore = unknown.notices.length - await c.fanout(typedLine, { mentions: ['@chief.bob.home'] }) + 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)