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/social-switch-ends-old-face-rooms.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Switching the Bot Social server now turns that server's face-to-face rooms into read-only past rooms instead of leaving them open and accepting lines that can never be delivered, and a line sent to an ended room is refused with a clear message ([#729](https://github.com/ThinkFlowLab/FlyBot/issues/729)). Only a change of address counts: renaming this home on the same server leaves that server's rooms open, and a switch that fails ends none of them.
5 changes: 5 additions & 0 deletions core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,8 @@ export function install(rt) {
await rt.load()
const leaked = unshareableReference(actor, body.text)
if (leaked !== undefined) return fail('You do not own that chat', 'bot/forbidden')
const posted = rt.roomOf(String(body.roomId ?? body.id ?? ''))
if (posted && isFaceRoom(posted) && (posted.face?.status === 'left' || posted.face?.status === 'closed')) return fail('This room has ended', 'bot/room-ended')
const line = await rt.postRoomMessage(String(body.roomId ?? body.id ?? ''), {
text: body.text,
authorId: body.authorId,
Expand Down Expand Up @@ -514,6 +516,9 @@ export function install(rt) {
const room = rt.roomOf(sessionId)
if (room && isFaceRoom(room)) {
if (actor && rt.authzEnforced() && !rt.roomMemberOf(actor, room.id)) return fail('You are not a member of that room', 'bot/forbidden')
// A room the server no longer reaches (a switched-away server, or a room that left):
// refuse instead of appending a line every other home will never see.
if (room.face?.status === 'left' || room.face?.status === 'closed') return fail('This room has ended', 'bot/room-ended')
// Face rooms must not go through sessionController.prompt: that starts a local
// bot round (turns/steps) and never fans the line to the other FlyBot instance.
const line = await rt.postRoomMessage(room.id, { text, displayName: actor?.displayName }, actor)
Expand Down
22 changes: 22 additions & 0 deletions core/test/room-membership.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,28 @@ test('channel: delete-bot answers with the actor\'s scoped view, not the whole t
assert.equal(deleted.value.bots.some(bot => bot.id === 'spec-a'), false, "another owner's private Bot stays out of the answer")
})

test('channel: a line to an ended face room is refused, not appended', async (t) => {
const { call, rt } = startChannel(t, TEAM)
await rt.load()
const room = await rt.upsertFaceRoom({ roomId: 'frm_left', localId: 'g-left', status: 'left', name: 'Ended room' })
const before = room.log.length
const sent = await call('send', { sessionId: 'g-left', text: 'hello' })
assert.equal(sent.ok, false)
assert.equal(sent.error.code, 'bot/room-ended')
assert.equal(rt.roomOf('g-left').log.length, before, 'nothing was appended to a room no home can reach')
})

test('channel: room-post into an ended face room is refused too', async (t) => {
const { call, rt } = startChannel(t, TEAM)
await rt.load()
const room = await rt.upsertFaceRoom({ roomId: 'frm_left', localId: 'g-left', status: 'left', name: 'Ended room' })
const before = room.log.length
const posted = await call('room-post', { roomId: 'g-left', text: 'hello' })
assert.equal(posted.ok, false)
assert.equal(posted.error?.code, 'bot/room-ended')
assert.equal(rt.roomOf('g-left').log.length, before, 'nothing was appended')
})

test('channel: a legacy single-owner create-room still works', async (t) => {
const { call } = startChannel(t, TEAM)
const invite = (await call('auth-invite', {})).value
Expand Down
2 changes: 1 addition & 1 deletion docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi
- Another member's own face-room line arrives through `flybot.rooms.postHuman` (#180): a quiet `postToRoom` post carrying `source.human { authorId, name }`. `group_relay` turns it into a round reply `{ kind: 'user', other: true, name, authorId }` (not the grey `notice`), so it draws as a left-hand bubble from that person; it asks no Bot to answer. `postNotice` stays for system events (joined, left, started). Covered by `core/test/rounds.test.js`.
- **Lines from elsewhere mid-turn (#455):** a `postHuman` or `postRemote` post (`source.human` / `source.remote`) that arrives while the group Session is `running` is not `followup`ed, which would park it in DSH's composer queue (raw `[Group post from …]`, steerable). `postToRoom` holds it (`heldPosts`): the round that is running (`rt.roomProgress`) shows it at once, as `personReply` or a `{ kind: 'remote' }` reply (left out of round reports), and a Bot's line is logged then (a person's line is in the log already through `applyFaceLine`); after `agent.whenIdle()` it is `followup`ed with `shown: true`, so its own round routes it by the usual rules but `group_relay` neither shows nor logs it again. With no round running it is only held and goes as before. The same hold covers this home's **own** Bot's line (#595: a late reply or a `post_to_group` that lands mid-turn showed the raw header in the composer queue): an ordinary line is logged and drawn as the ordinary `{ botId, name, color, seq }` bubble its own reply is, and a Bot's round event (`failed`, `stopped`, `resumed`) or other quiet post is held without being drawn, so its header never reaches the composer either and `group_relay` draws it as that event once the turn is done. A room notice (the `face-notice` sender) keeps the queue path: it is not a Bot's line. Covered by `core/test/rounds.test.js`.
- `flybot.chatApprovals.changed()` saves the team (moving the revision) so `bot.pendingCount` reaches the browser at once when a hold arrives or is decided (#226). Covered by `core/test/flybot.test.js`.
- The DSH composer still prompts the group Session (`user/message` → `group_relay`). Face-to-face turn rule (#167, changed in #287 and #295), `openingSpeakers`: a **plain** line (no `@` naming a Bot) starts one round in which **every** local Bot may answer, the admin first then the room's order, capped at `FACE_PLAIN_SPEAKERS` (6), one at a time and each after the earlier replies; that round is `single` (`isPlainFaceLine`): replies' own `@`s start no follow-up turns inside it. **Routed by intent (#299):** a plain line that is not `@all` (`route` on the round) also tells each Bot in its prompt (`routeNote`) to answer only when the line is theirs — it names them or their role, no other member's role fits better, or it follows up on their own last answer — and to reply `[PASS]` otherwise (the default; no "add something new" licence); the first Bot that posts, works on it or waits for approval ends a non-broadcast routed round (`answeredAlready`): the Bots after it are marked passed without a model call or a line, so three Bots never post three similar answers. The owner's own typed line reaches a face round through the Session inbox (the round's opener is the quiet `name: text` notice), so `drainRoomInbox` routes it (`routeFor`, `single`, `lastFirst`; lines queued together by the newest one) when it starts the round; a friend's attested plain line routes at the opener the same way (#462): `postHuman` posts it from the quiet `face-notice` sender with `human`, and `isPlainFaceLine` reads such a line by its `plain` flag (`friendLine`), so it gets `route`, `single` and the last speaker first; a friend's line that asks my Bots (`plain` or `callIds`, `friendAsks`) picks the speakers over a Bot post queued with it, while their `@Bot` line (`callIds`) stays unrouted. A line to everyone (`ROOM_EVERYONE`) is not cut; a pass (`isPass`) posts, logs and shows nothing. A line to everyone (`ROOM_EVERYONE`: 你们/大家/everyone/introduce yourselves…) says each may answer. The Bot that spoke last before the line (`recentSpeaker`, a follow-up) is asked first and named in the note. When every Bot passes on the **owner's own** line (not a friend's attested one, whose home answers it; not a follow-up to a friend's Bot), `fallbackDue` appends one turn so the first Bot that passed answers with no pass option. **Other homes' answers count (#475):** each home runs its own round over its own Bots, so a friend's Bot line logged after the person's line and after the round opened (`friendAnswers`; a Bot post queued before the round is not counted, it may be older than the line) ends a non-broadcast routed round like a local answer and cancels `fallbackDue`; a Bot still asked (a line to everyone) is told in `routeNote` that `<Bot> already answered this line or posted a plan; add only your role's part`. `everyone` in the post's source marks a friend's `@all` (not routed). Covered by `core/test/rounds.test.js`. `@Name`, the label the picker shows (`Friend's Chief`, `Writer · Friend's Bot`) or a handle picks that local Bot (`face-mentions.js`); `@all` is everyone; a line that only @s a friend's Bot starts none here; A friend's line (`flybot.rooms.postHuman`, only when the server attests `typed_by_owner`, passed as `attested`) names my Bot by label or handle (`callIds` in the post's source, read by `openingSpeakers` before the quiet rule), or is plain or `@all` and then carries `plain: true`, so my Bots answer it in the same single pass (#295); a line that only names a friend's Bot, or one from a server that cannot attest, stays quiet; a bare `@Chief` from a friend whose own Chief is in the room calls nobody here. A Bot's post in a face room still starts only the Bots it @s, so Bots in two homes cannot set each other off; another home's Bot's `@all` / `@所有人` wakes none of mine, only the local Bots it also names (#475). The reply is held / sent under the usual approval and Auto-send rules. An `@` that names nobody in the room adds one quiet notice line (`No one in this room is called @X …`). A face-room line the send path already posted (`rt.notePostedLine`) is not appended or sent again when the round reads it from the Session. **One answer per Bot per line (#456):** a person's plain line (the owner's, opened by `drainRoomInbox` or the opener; a friend's attested `plain` line once it is in the log) opens `room.faceLine` (`seq`, `answered`, `asked` exchange ids); a Bot already in `answered` is passed without a model call, a second answer from the same round is dropped, and `lateRoomReply` drops a late answer from a turn asked for that line when the Bot already answered it. A newer person line ends it. **Held Bot lines (#456):** `fanoutRoomLine` records a local Bot's face line in `room.heldLines` (`seq`, `botId`, `text`, `status: sending`, capped at 50) before the transport sends it; a transport answer `{ held: true, approvalId }` makes it `pending`, anything else drops the record. `rt.settleHeldLine(approvalId, decision)` (`flybot.rooms.settleHeld`) removes it on approve and marks it `rejected` on reject; it is saved with the room, and the roster view carries `heldLines` (not `faceLine`). Covered by `core/test/rounds.test.js`, `core/test/face-mentions.test.js` and `plugins/social/test/social.test.js`.
- The DSH composer still prompts the group Session (`user/message` → `group_relay`). Face-to-face turn rule (#167, changed in #287 and #295), `openingSpeakers`: a **plain** line (no `@` naming a Bot) starts one round in which **every** local Bot may answer, the admin first then the room's order, capped at `FACE_PLAIN_SPEAKERS` (6), one at a time and each after the earlier replies; that round is `single` (`isPlainFaceLine`): replies' own `@`s start no follow-up turns inside it. **Routed by intent (#299):** a plain line that is not `@all` (`route` on the round) also tells each Bot in its prompt (`routeNote`) to answer only when the line is theirs — it names them or their role, no other member's role fits better, or it follows up on their own last answer — and to reply `[PASS]` otherwise (the default; no "add something new" licence); the first Bot that posts, works on it or waits for approval ends a non-broadcast routed round (`answeredAlready`): the Bots after it are marked passed without a model call or a line, so three Bots never post three similar answers. The owner's own typed line reaches a face round through the Session inbox (the round's opener is the quiet `name: text` notice), so `drainRoomInbox` routes it (`routeFor`, `single`, `lastFirst`; lines queued together by the newest one) when it starts the round; a friend's attested plain line routes at the opener the same way (#462): `postHuman` posts it from the quiet `face-notice` sender with `human`, and `isPlainFaceLine` reads such a line by its `plain` flag (`friendLine`), so it gets `route`, `single` and the last speaker first; a friend's line that asks my Bots (`plain` or `callIds`, `friendAsks`) picks the speakers over a Bot post queued with it, while their `@Bot` line (`callIds`) stays unrouted. A line to everyone (`ROOM_EVERYONE`) is not cut; a pass (`isPass`) posts, logs and shows nothing. A line to everyone (`ROOM_EVERYONE`: 你们/大家/everyone/introduce yourselves…) says each may answer. The Bot that spoke last before the line (`recentSpeaker`, a follow-up) is asked first and named in the note. When every Bot passes on the **owner's own** line (not a friend's attested one, whose home answers it; not a follow-up to a friend's Bot), `fallbackDue` appends one turn so the first Bot that passed answers with no pass option. **Other homes' answers count (#475):** each home runs its own round over its own Bots, so a friend's Bot line logged after the person's line and after the round opened (`friendAnswers`; a Bot post queued before the round is not counted, it may be older than the line) ends a non-broadcast routed round like a local answer and cancels `fallbackDue`; a Bot still asked (a line to everyone) is told in `routeNote` that `<Bot> already answered this line or posted a plan; add only your role's part`. `everyone` in the post's source marks a friend's `@all` (not routed). Covered by `core/test/rounds.test.js`. `@Name`, the label the picker shows (`Friend's Chief`, `Writer · Friend's Bot`) or a handle picks that local Bot (`face-mentions.js`); `@all` is everyone; a line that only @s a friend's Bot starts none here; A friend's line (`flybot.rooms.postHuman`, only when the server attests `typed_by_owner`, passed as `attested`) names my Bot by label or handle (`callIds` in the post's source, read by `openingSpeakers` before the quiet rule), or is plain or `@all` and then carries `plain: true`, so my Bots answer it in the same single pass (#295); a line that only names a friend's Bot, or one from a server that cannot attest, stays quiet; a bare `@Chief` from a friend whose own Chief is in the room calls nobody here. A Bot's post in a face room still starts only the Bots it @s, so Bots in two homes cannot set each other off; another home's Bot's `@all` / `@所有人` wakes none of mine, only the local Bots it also names (#475). The reply is held / sent under the usual approval and Auto-send rules. An `@` that names nobody in the room adds one quiet notice line (`No one in this room is called @X …`). A face-room line the send path already posted (`rt.notePostedLine`) is not appended or sent again when the round reads it from the Session. A line to a room whose `face.status` is `left` or `closed` is refused by `send` and `room-post` (`bot/room-ended`) and nothing is appended: a room a switched-away server can no longer reach must not look sent (the social plugin marks those rooms `left` when the server changes). **One answer per Bot per line (#456):** a person's plain line (the owner's, opened by `drainRoomInbox` or the opener; a friend's attested `plain` line once it is in the log) opens `room.faceLine` (`seq`, `answered`, `asked` exchange ids); a Bot already in `answered` is passed without a model call, a second answer from the same round is dropped, and `lateRoomReply` drops a late answer from a turn asked for that line when the Bot already answered it. A newer person line ends it. **Held Bot lines (#456):** `fanoutRoomLine` records a local Bot's face line in `room.heldLines` (`seq`, `botId`, `text`, `status: sending`, capped at 50) before the transport sends it; a transport answer `{ held: true, approvalId }` makes it `pending`, anything else drops the record. `rt.settleHeldLine(approvalId, decision)` (`flybot.rooms.settleHeld`) removes it on approve and marks it `rejected` on reject; it is saved with the room, and the roster view carries `heldLines` (not `faceLine`). Covered by `core/test/rounds.test.js`, `core/test/face-mentions.test.js` and `plugins/social/test/social.test.js`.
- New Bot model (#410): `roster.js` `createBot` fixes a Bot's model at creation. A Bot made by another Bot (`create_bot`, a tree child via `flybot.bot.create` with `createdBy`) and given no model starts on its maker's effective model (`desiredModel`) and its thinking level resolved against that model (dropped when that model does not serve it), so its first request never goes to an unconfigured profile default. A model the caller names wins; a Bot the user makes in the UI keeps the client's start model (Main Bot's, #230). Covered by `core/test/roster.test.js`.
- Silent creation (#645): `createBot` and `create-room` send nothing by themselves. A Bot's model is applied without a turn, and a new group posts no opening round, so making a team costs no model turn. A line the user typed in the new-Bot card is their first request: `new-chat.js` sends it to the new Bot as one user turn and the card carries no `brief`; `roster.js` turns a `brief` into `Why you exist: …` instructions only when a Bot created the Bot (`create_bot`, a tree child), where the brief is a parent's context. The `kickoff` role, the `[Bot team setup]` text and the group-created cue are gone (an older transcript's kickoff line is still skipped by `historyEntry`), and `create_bot` answers that the new Bot starts no turn. Covered by `core/test/roster.test.js`, `core/test/rounds.test.js` and `core/test/new-chat.test.js`.
- Every new Bot is announced (`bot.created`) through the `rt.createBot` wrapper, including the first Main Bot `bootstrap` creates on the first `state` request and a `duplicate-bot` copy. Creating those through the module-local `createBot` used to skip the event, so a plugin that adopts new Bots (the tree root) could miss the first Chief or leave a copy outside the tree until a restart. Covered by `core/test/flybot.test.js`.
Expand Down
Loading
Loading