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-face-roles-retry.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A Bot's role label for a face-to-face room is retried when the room refuses it (a dropped request or a server error) instead of being counted as sent and never synced; a room that is forgotten clears its per-seat role marks.
2 changes: 1 addition & 1 deletion docs/subsystems/social.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers
9. Two holds, one click (#258): a send also held by Bot Social (`pending_approval`) is finished by the transport when the host says the owner approved *this very send* on a FlyBot card (`msg.approved`, set by `hooks.js` `release` only; a Bot-supplied field is overwritten). The approval id comes from the `send_as_member` answer, is recorded as an own line (so a failed call is retried by the poll and never shows as a card) and is decided with the same `confirm_code` step as a typed line. Room lines (`via: room` / `room-share`) and sends that left on Auto-send keep Bot Social's hold as before. Holds that were already stuck before this change need one Approve / Reject in Friends' Bots.
10. Joining is not sharing (#336, Bot Social `faa7cb5`): Bot Social refuses a first message from a friend's Bot to one of my Bots that only joined their invite (`403 bot_not_shared`; nothing stored or delivered) and adds a `share.requested` event to my Bot's queue. `pollOnce` keeps it as one card per sender and Bot (`settings.shareRequests`, names cleaned, no peer content) and listed on `social/status` as `shareRequests`; Friends' Bots shows *"Helper (Eve) tried to start a chat with Writer. Writer is not shared with them."* with **Dismiss** (`social/share-request-dismiss`) and **Share Writer…**, which only opens that Bot's settings (Sharing). Nothing is granted from the card, no typed reply acts on it (#349) and the Bot never answers on its own; a card drops by itself once that Bot is shared with the sender (`flybot.grants.has`) or the Bot is gone. On the sender's side both send paths map `bot_not_shared` to *"Coder (Bob) is not shared with your Bots yet, so the message was not delivered. Bot Social told its owner, who can share it with you. Sending again will not help until then."* (the transport prefixes `Not delivered:`), with no retry through another member. Core `reach()` still refuses such an inbound message locally as the safety net.
11. A message right after an invite join is never silently lost (#442; owner decision 2026-10-19, option b; Bot Social #30). Bot Social writes a join's grants before it confirms the join and queues `relationship.changed` (`status: "active"`, with `grants_ready` and `grants`) before the friend's first message. `pollOnce` runs `syncOutgoingGrants({ only })` for that Bot when it reads that event (or `grant.changed` `granted`), so the first message is judged with the grant. Fallback for a server without #30 or a lost event: an inbound message the owner policy refuses (`by: 'owner-policy'`, no grant for that sender) syncs that Bot's grants once and is judged again; still refused, it is not delivered and the plugin calls `report_not_delivered { message_id, reason: 'not_shared_yet' }` (a server without the tool only logs it). On the sender's home, a `message.not_delivered` event for a Bot the owner's own chat goes through writes *"Not sent: Coder (Bob) was not ready to talk with you yet, so it did not get your message. Send it again."* into that chat, where a failed send shows its Not sent line; for a Bot's own send (`message_friend_bot`) it is only logged. Grants stay per Bot; nothing is published, approved or granted by this path.
12. A face room has one room lead (总负责人, #476; Bot Social #32). `viewFaceLead` reads `effective_lead_bot_handle` / `lead_bot_handle` / `created_by_me` from the room snapshot; a server without them gets the first member (by join order: the creator's first Bot) on every home, with no way to pick. `social/face-lead` (the Room lead button) is refused unless this home made the room on a server that stores a lead, then `POST /v1/face-rooms/{id}/lead` (Bot Social: creator only, owner token only, no MCP tool). `room.lead_changed` renders as a platform notice rebuilt from the lead's fields. On a server listing `member_roles`, `publishFaceRoles` sends my Bots' role labels (`PUT /v1/face-rooms/{id}/roles`) when the room shows another value, once per value; a friend's `members[].role` is cleaned (`cleanFaceRole`, 80 chars, no markup or `@`) and treated as data.
12. A face room has one room lead (总负责人, #476; Bot Social #32). `viewFaceLead` reads `effective_lead_bot_handle` / `lead_bot_handle` / `created_by_me` from the room snapshot; a server without them gets the first member (by join order: the creator's first Bot) on every home, with no way to pick. `social/face-lead` (the Room lead button) is refused unless this home made the room on a server that stores a lead, then `POST /v1/face-rooms/{id}/lead` (Bot Social: creator only, owner token only, no MCP tool). `room.lead_changed` renders as a platform notice rebuilt from the lead's fields. On a server listing `member_roles`, `publishFaceRoles` sends my Bots' role labels (`PUT /v1/face-rooms/{id}/roles`) when the room shows another value, once per value; a value is recorded as sent only after the room takes it, so a failed call is retried on the next snapshot, and a room that is forgotten clears its per-seat marks; a friend's `members[].role` is cleaned (`cleanFaceRole`, 80 chars, no markup or `@`) and treated as data.

### Paused friend chat (#404; no turn limit since 2026-10-19)

Expand Down
15 changes: 13 additions & 2 deletions plugins/social/src/social.js
Original file line number Diff line number Diff line change
Expand Up @@ -3057,6 +3057,11 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
// Each home tells the room its own Bots' role labels (#476), so the lead sees who does what. Sent only
// on a server that stores them, when a label differs from what the room shows, once per value.
const sentRoles = new Map()
// A room that is forgotten leaves no per-seat role marks behind.
const forgetSentRoles = (roomId) => {
const prefix = `${roomId} `
for (const key of [...sentRoles.keys()]) if (key.startsWith(prefix)) sentRoles.delete(key)
}
function publishFaceRoles(snapshot) {
if (!typed.roles || !faceApi?.setRoles || !Array.isArray(snapshot?.members) || flybot.readOnly?.()) return
const wanted = {}
Expand All @@ -3067,10 +3072,14 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
const role = cleanFaceRole(bot.role ?? '')
const key = `${snapshot.roomId} ${handle}`
if ((seat.role ?? '') === role || sentRoles.get(key) === role) continue
sentRoles.set(key, role)
wanted[handle] = role || null
}
if (Object.keys(wanted).length > 0) void faceApi.setRoles(snapshot.roomId, wanted).catch(error => warn('face-room role labels', error))
if (Object.keys(wanted).length === 0) return
// Record a value only once the room took it: a failed call (a dropped request, a 5xx) must be
// retried on the next snapshot instead of counting as sent.
void faceApi.setRoles(snapshot.roomId, wanted)
.then(() => { for (const [handle, role] of Object.entries(wanted)) sentRoles.set(`${snapshot.roomId} ${handle}`, role || '') })
.catch(error => warn('face-room role labels', error))
}
// The room creator's pick of the room lead (#476), from the room's Room lead button only.
async function setFaceLead({ roomId, faceRoomId, bot } = {}) {
Expand Down Expand Up @@ -3131,6 +3140,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
|| { faceRoomId: serverId, roomId: meta?.localRoomId, name: faceRoomTitle({}, { code: meta?.joinCode, room: hosted }), status: 'closed', members: [], joinCode: meta?.joinCode })
delete settings.faceRooms[serverId]
dropRoomRemotes(serverId)
forgetSentRoles(serverId)
for (const handle of asBotsOf(meta)) {
const left = botByHandle(handle)
if (left) await releaseRoomOnlyIdentity(left.id)
Expand Down Expand Up @@ -3243,6 +3253,7 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
}
delete settings.faceRooms[id]
dropRoomRemotes(id)
forgetSentRoles(id)
for (const handle of asBotsOf(meta)) {
const left = botByHandle(handle)
if (left) await releaseRoomOnlyIdentity(left.id)
Expand Down
28 changes: 28 additions & 0 deletions plugins/social/test/social.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8303,6 +8303,34 @@ test('#476: the room creator picks the lead with the button; my Bots\' roles go
assert.equal(server.calls.filter(c => c.name === 'REST POST /lead').length, 1)
})

test('#476: a role the room refused to take is sent again on the next poll', async (t) => {
const server = leadFaceServer(['typed_by_owner', 'room_lead', 'member_roles'])
const inner = server.fetch
let failing = true
server.fetch = async (url, init) => {
const hit = String(url).match(/\/v1\/face-rooms\/([^/?]+)\/roles$/)
if (!hit) return inner(url, init)
const body = JSON.parse(init.body)
server.calls.push({ url, name: 'REST PUT /roles', args: body })
if (failing) return new Response('nope', { status: 500 })
const room = server.face.rooms.get(hit[1])
for (const [handle, role] of Object.entries(body.roles)) Object.assign(room.members.find(member => member.handle === handle) ?? {}, { role })
return new Response(JSON.stringify(room), { status: 200 })
}
const { call } = await faceFixture(t, server)
const body = server.face.rooms.get('frm_123456')
const mine = body.members[0].handle
Object.assign(body, { created_by_me: true, lead_bot_handle: null, effective_lead_bot_handle: mine })
const roleCalls = () => server.calls.filter(c => c.name === 'REST PUT /roles')
await call('flybot/social/face-rooms', { poll: true })
const afterFail = roleCalls().length
assert.ok(afterFail >= 1, 'the role was published')
failing = false
await call('flybot/social/face-rooms', { poll: true })
assert.ok(roleCalls().length > afterFail, 'a role the server refused to take is sent again, not counted as sent')
assert.equal(roleCalls().at(-1).args.roles[mine], 'Boss')
})

test('#476: a server without the room lead: the first member leads on every home, nobody can pick, no roles are sent', async (t) => {
const server = leadFaceServer(['typed_by_owner'])
const { call, ctx, room } = await faceFixture(t, server)
Expand Down
Loading