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/883-link-live-fixes.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A link withdrawn on Bot Social (a `204` answer) no longer ends the FlyBot process, and a guest reads the inviting owner’s name rather than the host’s own “You”.
1 change: 1 addition & 0 deletions changelog.d/883-stranger-room-share.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
FlyBot now talks to Bot Social's record of a room link ([#883](https://github.com/ThinkFlowLab/FlyBot/issues/883)): making a link for a room that lives in your FlyBot registers it with Bot Social, a guest's Join claims it there, and the room's own messages travel between your Bot and the guest's Bots under that room only. Anything else to a Bot that is not your friend — a plain message, another room, another Bot — is still refused.
4 changes: 3 additions & 1 deletion core/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -903,8 +903,10 @@ export function install(rt) {
const from = shareSpeaker(room)
const member = from ? state.bots?.[from]?.fields?.social?.member : undefined
const ownerId = from ? state.bots?.[from]?.ownerId : undefined
// "You" is this home's own label for its owner, so the far side would read itself in it.
const humanName = human?.displayName && human.displayName !== 'You' ? human.displayName : undefined
return {
inviterName: human?.displayName || member?.ownerName || displayNameFor(state, ownerId, 'a friend'),
inviterName: humanName || member?.ownerName || displayNameFor(state, ownerId, 'a friend'),
inviterHome: member?.home || human?.ownerHome,
inviterHandle: member?.handle,
}
Expand Down
4 changes: 3 additions & 1 deletion core/test/room-ticket-seat.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ const ALICE = {
seq: 0,
seen: {},
participants: [
{ kind: 'human', id: 'owner-1', role: 'admin', displayName: 'Alice', joinedSeq: 0, joinedAt: 1 },
// A group made on a home whose owner has no name yet: the human is the local "You" (live pre-check).
{ kind: 'human', id: 'owner-1', role: 'admin', displayName: 'You', joinedSeq: 0, joinedAt: 1 },
{ kind: 'bot', id: 'chief-a', role: 'admin', displayName: 'Chief', joinedSeq: 0, joinedAt: 1 },
{ kind: 'bot', id: 'writer-a', role: 'member', displayName: 'Writer', joinedSeq: 0, joinedAt: 1 },
],
Expand Down Expand Up @@ -236,6 +237,7 @@ test('#884: a stranger claims a room link with two of its Bots, both are seated,
assert.deepEqual(mirror.participants.filter(p => p.leftSeq === undefined).map(p => p.id).sort(), ['chief-b', 'helper-b'])
assert.equal(mirror.sharedFrom.remoteRoomId, 'war')
assert.equal(mirror.sharedFrom.ticket, invite.ticket)
assert.equal(mirror.sharedFrom.inviterName, 'Alice', 'the guest reads the owner, never the host’s own "You"')
const joins = log.filter(m => parseRoomShare(m.text)?.op === 'join')
assert.ok(joins.every(m => m.from === 'chief-a' && m.meta.scope === 'war' && m.delivered), 'joins go from the linked Bot, under the room scope')

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 @@ -145,7 +145,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi

### Inbound shared rooms

- Bot Social conversations are 1:1; it does not list a FlyBot group. When a room gains a granted remote Bot (create-room, addParticipants, or 新建群聊 picking a friend's chat), the host fans out a `FLYBOT-ROOM-SHARE` envelope (`src/shared/room-share.js`) through `flybotSend` (`via: 'room-share'` for join/leave, `via: 'room'` for lines, `meta.roomId` set) so the Bot's owner's FlyBot can project it immediately — including a room with no chat lines yet. Receive is `rt.applyInboundRoomShare` (`origin: 'inbound-share'`, `recordInboundSharedRoom`), labelled **Shared room from \<inviter\>** with the room name stored. The envelope names the person who invited the Bot (#848): a human participant in the room when there is one, else the owner-level `ownerName` / `home` the social plugin keeps on that Bot's member record (read from `whoami`), so a group made from the sidebar no longer says `a friend`; a room projected in from an inbound share forwards its own `sharedFrom` inviter instead of this home's owner, so re-sharing never renames the person who invited it. A leave envelope, hide, or grant revoke marks it left/hidden. Which identity a projection records is the envelope's, not the transport's (#873): `applyInboundRoomShare` prefers `data.inviterName` / `inviterHome` / `inviterHandle` and keeps `event.from*` only as the fallback, so a room carried here by a Chief's Bot is still named after the person who invited it. The lookup matches the values a room already stores as well, so a projection recorded under the transport's identity before this fix resolves to the same room instead of forking a second `inbound-share` room; a line keeps its own `who`, or the sender when it names nobody, never the inviter. `reach()` allows a local member to message a remote that already sits in that room when `roomId` is on the send. Still not team mode.
- Bot Social conversations are 1:1; it does not list a FlyBot group. When a room gains a granted remote Bot (create-room, addParticipants, or 新建群聊 picking a friend's chat), the host fans out a `FLYBOT-ROOM-SHARE` envelope (`src/shared/room-share.js`) through `flybotSend` (`via: 'room-share'` for join/leave, `via: 'room'` for lines, `meta.roomId` set) so the Bot's owner's FlyBot can project it immediately — including a room with no chat lines yet. Receive is `rt.applyInboundRoomShare` (`origin: 'inbound-share'`, `recordInboundSharedRoom`), labelled **Shared room from \<inviter\>** with the room name stored. The envelope names the person who invited the Bot (#848): a human participant in the room when there is one, else the owner-level `ownerName` / `home` the social plugin keeps on that Bot's member record (read from `whoami`), so a group made from the sidebar no longer says `a friend`; a human participant whose name is this home's own label `You` (an owner with no name yet) is skipped for that owner-level name, so the far side never reads itself as the inviter (#883); a room projected in from an inbound share forwards its own `sharedFrom` inviter instead of this home's owner, so re-sharing never renames the person who invited it. A leave envelope, hide, or grant revoke marks it left/hidden. Which identity a projection records is the envelope's, not the transport's (#873): `applyInboundRoomShare` prefers `data.inviterName` / `inviterHome` / `inviterHandle` and keeps `event.from*` only as the fallback, so a room carried here by a Chief's Bot is still named after the person who invited it. The lookup matches the values a room already stores as well, so a projection recorded under the transport's identity before this fix resolves to the same room instead of forking a second `inbound-share` room; a line keeps its own `who`, or the sender when it names nobody, never the inviter. `reach()` allows a local member to message a remote that already sits in that room when `roomId` is on the send. Still not team mode.
- A Bot named in a shared room answers in that room (#825). `fanoutRoomLine` (`src/host/room-membership.js`) puts the handles a line calls in the envelope's `calls` (resolved where the room's mentions are resolved, so a fenced or quoted `@name` is literal text), plus its `hop`: a person's line is hop 0 and a Bot's answer is one hop on. The receiving `applyInboundRoomShare` keeps only the calls that name this room's own Bot (`localCalls`), opens **one** turn for each through `rt.startInboundTurn` (`groups.js`: `participant.open` + `participant.runTurn` + `appendRoom`), and skips it when the line's hop has reached the room's `maxHops` (kept on the room, `DEFAULT_MAX_HOPS` 3) or when the same remote line was already answered — the last 200 keys `remoteRoomId:seq:botId` live in `room.answered`. That turn's answer is an ordinary room line, so `fanoutRoomLine` sends it back to the inviter as an `op: 'line'` envelope under the *inviter's* `roomId`, one hop on; an answer its owner's approval holds or refuses still shows as one `notice` line in the room instead of silence. Remote text stays inside the `fencedUntrusted` fence.

### A local room's invite link (#852)
Expand Down
5 changes: 5 additions & 0 deletions docs/subsystems/social.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,11 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_

- Bot Social has no FlyBot-group record (`list_conversations` is 1:1; inbox is `message.received` after a send). The plugin therefore carries a `FLYBOT-ROOM-SHARE` envelope on `send_as_member` text. `pollOnce` calls `flybot.rooms.applyInbound` before 1:1 chat delivery, so the Bot's owner sees the group as soon as they are added. `via: 'room'` / `via: 'room-share'` skip the social outbound hold (the add-to-group click is consent), and a send the host sees come back `pending_approval` clears Bot Social's own blanket hold by itself (`autoSendRoomEnvelope`, `holdIsBlanket()` on `hold_reason` of #865, the allow-list of #737): the invite and the room's lines leave with no card, while a hold with a finding of its own keeps its card. That card reads one sentence about the envelope (`Invite <Bot> to the group "<name>"`, `Group message to <Bot>: <line>`), never the JSON or a local `session-…` id (#847). The invited owner's Bot replies still go through `pending_approval`. A roster fetch that no longer lists a remote, or `social/revoke-grant`, leaves those rooms / hides inbound projections.

### A local room's link for a stranger (#883, #884)

- Bot Social #60 keeps a local room's invite tickets (`/v1/local-room-invites`, feature `local_room_invites`). `face-rooms.js` `createFaceRoomApi().localInvites` has `mint` / `preview` (with this home's token the answer names the claimer to the minting or claiming owner only) / `claim` / `revoke`, on the same connector token as the face-room routes; a refusal keeps Bot Social's code (`expired`, `revoked`, `already_claimed`, `own_invite`, `invite_not_found`, …). `social.js` registers them as the host's seam (`flybot.rooms.serveInvites`); `verify` is the preview read, and `mint` answers `undefined` on a server without the feature, so the host keeps its friend-only link.
- The connector exception is one pair at a time. `ticketPair(msg)` passes a send only when it is a room share (`via` `room` / `room-share`, never a plain line, no files), from the exact local Bot, to the exact handle, under the room id in `meta.scope`, as `flybot.rooms.ticketPeers()` lists it (a seat here from the Bot its link named; on the guest side, the seated Bots to the room's inviter, and the claim's accepting knock). Such a send skips the roster and the hold (the link is the owner's consent, like adding a friend's Bot to a group) and goes out as `send_as_member` with `room_id` = that scope, which Bot Social's room reach carries and refuses once the link is withdrawn or given back. Every other send to someone off the roster keeps the deny sentence. Covered by `social.test.js` (#883) and `face-rooms.test.js` (#884).

### Discarded promise `.catch` (#213)

The plugin's discarded `.catch(() => {})` calls carry a one-line reason comment or `void`; `check-empty-catch` enforces it (see `docs/code-standards.md`).
Expand Down
4 changes: 3 additions & 1 deletion patches/ds-bot/files/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -903,8 +903,10 @@ export function install(rt) {
const from = shareSpeaker(room)
const member = from ? state.bots?.[from]?.fields?.social?.member : undefined
const ownerId = from ? state.bots?.[from]?.ownerId : undefined
// "You" is this home's own label for its owner, so the far side would read itself in it.
const humanName = human?.displayName && human.displayName !== 'You' ? human.displayName : undefined
return {
inviterName: human?.displayName || member?.ownerName || displayNameFor(state, ownerId, 'a friend'),
inviterName: humanName || member?.ownerName || displayNameFor(state, ownerId, 'a friend'),
inviterHome: member?.home || human?.ownerHome,
inviterHandle: member?.handle,
}
Expand Down
4 changes: 3 additions & 1 deletion patches/ds-bot/files/test/room-ticket-seat.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ const ALICE = {
seq: 0,
seen: {},
participants: [
{ kind: 'human', id: 'owner-1', role: 'admin', displayName: 'Alice', joinedSeq: 0, joinedAt: 1 },
// A group made on a home whose owner has no name yet: the human is the local "You" (live pre-check).
{ kind: 'human', id: 'owner-1', role: 'admin', displayName: 'You', joinedSeq: 0, joinedAt: 1 },
{ kind: 'bot', id: 'chief-a', role: 'admin', displayName: 'Chief', joinedSeq: 0, joinedAt: 1 },
{ kind: 'bot', id: 'writer-a', role: 'member', displayName: 'Writer', joinedSeq: 0, joinedAt: 1 },
],
Expand Down Expand Up @@ -236,6 +237,7 @@ test('#884: a stranger claims a room link with two of its Bots, both are seated,
assert.deepEqual(mirror.participants.filter(p => p.leftSeq === undefined).map(p => p.id).sort(), ['chief-b', 'helper-b'])
assert.equal(mirror.sharedFrom.remoteRoomId, 'war')
assert.equal(mirror.sharedFrom.ticket, invite.ticket)
assert.equal(mirror.sharedFrom.inviterName, 'Alice', 'the guest reads the owner, never the host’s own "You"')
const joins = log.filter(m => parseRoomShare(m.text)?.op === 'join')
assert.ok(joins.every(m => m.from === 'chief-a' && m.meta.scope === 'war' && m.delivered), 'joins go from the linked Bot, under the room scope')

Expand Down
55 changes: 55 additions & 0 deletions plugins/social/src/face-rooms.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
// system copy that embeds those names.
export function cleanFaceName(value, fallback = 'a remote Bot') {
const text = stripFaceMarkers(String(value ?? ''))
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ')

Check warning on line 78 in plugins/social/src/face-rooms.js

View workflow job for this annotation

GitHub Actions / plugins (social)

eslint(no-control-regex)

Unexpected control characters
.replace(/[[\]"<>`@]/g, '')
.replace(/\s+/g, ' ')
.trim()
Expand Down Expand Up @@ -448,7 +448,7 @@
|| (typeof member.color === 'string' && !HEX_ONLY.test(member.color) ? member.color.trim().slice(0, 32) : '')
if (color && !/[\n<>]/.test(color)) out.color = color
const src = member.avatar && typeof member.avatar === 'object' ? member.avatar : {}
const avatar = { ...(fromServer.avatar ?? {}) }

Check warning on line 451 in plugins/social/src/face-rooms.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
if (typeof src.shape === 'string' && /^[a-z][\w-]{0,31}$/.test(src.shape)) avatar.shape = src.shape
const image = typeof src.image === 'string' ? src.image.trim() : (typeof member.image === 'string' ? member.image.trim() : '')
if (image && image.length <= 65536 && /^(https?:\/\/|data:image\/)/i.test(image) && !/[\s<>]/.test(image)) avatar.image = image
Expand Down Expand Up @@ -478,7 +478,7 @@
export function cleanFaceRole(value) {
if (typeof value !== 'string') return ''
return stripFaceMarkers(value)
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ')

Check warning on line 481 in plugins/social/src/face-rooms.js

View workflow job for this annotation

GitHub Actions / plugins (social)

eslint(no-control-regex)

Unexpected control characters
.replace(/[[\]"<>`@]/g, '')
.replace(/\s+/g, ' ')
.trim()
Expand Down Expand Up @@ -588,7 +588,7 @@
if (type === 'room.started') return platformStartedText(reason)
if (type === 'room.lead_changed') return platformLeadText(member, reason)
const cleaned = String(text ?? '')
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ')

Check warning on line 591 in plugins/social/src/face-rooms.js

View workflow job for this annotation

GitHub Actions / plugins (social)

eslint(no-control-regex)

Unexpected control characters
.replace(/[[\]"<>`@]/g, '')
.replace(/\s+/g, ' ')
.trim()
Expand Down Expand Up @@ -744,9 +744,64 @@
return viewFaceRoom(body)
}

// #884 / Bot Social #60: the ticket record of a room that lives in a FlyBot home. Same token as the
// face-room routes; refusals keep Bot Social's code on the error.
const localBase = `${origin}/v1/local-room-invites`
const localTicket = (value) => {
const clean = String(value ?? '').trim()
return /^[A-Za-z0-9_-]{16,64}$/.test(clean) ? clean : ''
}
const msOf = value => {
const at = typeof value === 'number' ? value : Date.parse(String(value ?? ''))
return Number.isFinite(at) ? at : undefined
}
const localView = body => ({
roomId: String(body?.room_id ?? ''),
name: body?.name ? String(body.name) : '',
lead: body?.lead ? { handle: normHandle(body.lead.handle), name: String(body.lead.display_name ?? ''), ownerName: String(body.lead.owner_name ?? '') } : undefined,
inviterHandle: normHandle(body?.inviter?.handle),
inviterHome: normHandle(body?.inviter?.home) || undefined,
expiresAt: msOf(body?.expires_at),
claimed: body?.claimed === true,
...(body?.claimer !== undefined ? {
claimer: body.claimer ? {
home: normHandle(body.claimer.home) || undefined,
bots: (Array.isArray(body.claimer.bots) ? body.claimer.bots : []).map(normHandle).filter(Boolean),
members: (Array.isArray(body.claimer.members) ? body.claimer.members : []).map(m => ({ handle: normHandle(m?.handle), display_name: String(m?.display_name ?? ''), owner_name: String(m?.owner_name ?? '') })),
} : null,
} : {}),
})
const localInvites = {
async mint({ roomId, name, handle } = {}) {
const body = await callHttp('POST', localBase, { room_id: String(roomId ?? ''), ...(name ? { name: String(name) } : {}), ...(handle ? { bot_handle: normHandle(handle) } : {}) })
const ticket = localTicket(body?.ticket)
if (!ticket) throw new FaceRoomError('Bot Social did not return an invite. Try again.', 'invite_failed')
return { ticket, url: String(body?.url ?? ''), expiresAt: msOf(body?.expires_at), handle: normHandle(body?.inviter?.handle) || undefined }
},
// With this token the answer also names who claimed it, for the two owners the ticket binds.
async preview(ticket) {
const clean = localTicket(ticket)
if (!clean) throw new FaceRoomError('That is not an invite link.', 'invalid_invite')
return localView(await callHttp('GET', `${localBase}/${encodeURIComponent(clean)}`))
},
async claim(ticket, bots = []) {
const clean = localTicket(ticket)
if (!clean) throw new FaceRoomError('That is not an invite link.', 'invalid_invite')
const handles = (Array.isArray(bots) ? bots : [bots]).map(normHandle).filter(Boolean)
const body = await callHttp('POST', `${localBase}/${encodeURIComponent(clean)}/claim`, { bots: handles })
return { ...localView(body), bots: (Array.isArray(body?.bots) ? body.bots : []).map(normHandle).filter(Boolean) }
},
async revoke(ticket) {
const clean = localTicket(ticket)
if (!clean) return
await callHttp('DELETE', `${localBase}/${encodeURIComponent(clean)}`)
},
}

return {
// Bot Social's clock minus ours, as last seen (0 unless the skew is real).
clockOffsetMs: () => clockOffsetMs,
localInvites,
async join({ code, bots } = {}) {
const digits = String(code ?? '').trim()
if (!isJoinCode(digits)) throw mapFaceError({ error: { code: 'invalid_code' } }, 400)
Expand Down
11 changes: 10 additions & 1 deletion plugins/social/src/mcp-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,14 @@
return { ...init, agent: init.agent ?? socialHttpAgent() }
}

const NULL_BODY = new Set([101, 204, 205, 304])

export function socialFetch(url, init = {}) {
const parsed = new URL(String(url))
const lib = parsed.protocol === 'http:' ? http : https
const agent = init.agent ?? agentFor(url)
const method = String(init.method ?? 'GET').toUpperCase()
const headers = { ...(init.headers ?? {}) }

Check warning on line 46 in plugins/social/src/mcp-client.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-useless-fallback-in-spread)

Empty fallbacks in spreads are unnecessary
const signal = init.signal
return new Promise((resolve, reject) => {
const req = lib.request(parsed, { method, headers, agent }, (res) => {
Expand All @@ -54,7 +56,14 @@
if (value === undefined) continue
headerMap.set(key, Array.isArray(value) ? value.join(', ') : String(value))
}
resolve(new Response(buf, { status: res.statusCode ?? 0, statusText: res.statusMessage ?? '', headers: headerMap }))
// A 204/205/304 must have no body, and a Response that throws here would be uncaught and end the
// process (a link withdrawn with DELETE answers 204): no body for those, and a bad status rejects.
const status = res.statusCode ?? 0
try {
resolve(new Response(NULL_BODY.has(status) ? null : buf, { status, statusText: res.statusMessage ?? '', headers: headerMap }))
} catch (error) {
reject(error)
}
})
})
req.on('error', reject)
Expand All @@ -76,7 +85,7 @@
const list = [...items]
if (list.length === 0) return []
const cap = Math.max(1, Math.min(limit || GRANT_CONCURRENCY, list.length))
const out = new Array(list.length)

Check warning on line 88 in plugins/social/src/mcp-client.js

View workflow job for this annotation

GitHub Actions / plugins (social)

unicorn(no-new-array)

Do not use `new Array(singleArgument)`.
let next = 0
async function worker() {
for (;;) {
Expand Down
Loading
Loading