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/1161.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A group invite link seats the joiner even when the host's FlyBot has been open, and the joiner's sidebar shows the group. The host was re-reading every open link on each inbox poll and, once Bot Social answered "Too many attempts. Try again in 10 minutes.", previewing again before that cool-down elapsed, so a new claim was never seen; a room share whose text was outside 1–4000 characters was refused, and a join that stayed on an approval card was treated as already sent. ([#1161](https://github.com/ThinkFlowLab/FlyBot/issues/1161))
139 changes: 104 additions & 35 deletions core/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
import { randomBytes } from 'node:crypto'
import { cleanPeerName, disambiguatedRemoteTitle, faceAliases, isChiefRecord, remoteBotName, remoteBotTitle, remoteCallName, sharedRoomTitle } from '../shared/remote-label.js'
import { formatFaceRoomTitle, isAutoFaceTitle, faceRoomCreatedAt } from '../shared/face-title.js'
import { HISTORY_MAX, HISTORY_TEXT, encodeRoomShare, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../shared/room-share.js'
import { HISTORY_MAX, HISTORY_TEXT, ROOM_SHARE_TEXT_MAX, encodeRoomShare, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../shared/room-share.js'
import { cleanFaceLead, cleanRoleLabel, faceLeadOf } from './face-lead.js'
import { cleanName, fenceUntrusted, isFencedUntrusted, unwrapUntrusted } from './delivery.js'
import { hasAnyGrant, mayShareRoom } from './reach.js'
Expand Down Expand Up @@ -976,18 +976,26 @@ export function install(rt) {
async function sendShareTo(room, remote, payload, via, sender, scope) {
const from = sender ?? shareSpeaker(room)
if (!from || !rt.flybotSend) return undefined
const text = encodeRoomShare(payload)
// Bot Social answers `payload.text (1-4000 chars) required` and drops the share (#1161).
if (text.length < 1 || text.length > ROOM_SHARE_TEXT_MAX) {
rt.warn?.('ds-bot: room share not sent to %s: payload.text (1-4000 chars) required', remote.handle || remote.id)
return undefined
}
try {
// The answer says whether the send went out, is held for the owner, or was refused (#825 D):
// a held or refused reply has to become one line in the room instead of nothing.
return await rt.flybotSend({
from,
to: remote.handle || remote.id,
text: encodeRoomShare(payload),
text,
role: 'request',
via,
meta: { roomId: room.id, seq: payload.seq, share: payload.op, ...(scope ? { scope } : {}) },
})
} catch (error) {
// Still on the owner's card (#1161). `allow` would record the seat as told and never retry.
if (error?.code === 'pending_approval') return { action: 'hold' }
rt.warn?.('ds-bot: room share not sent to %s: %s', remote.handle || remote.id, error)
return undefined
}
Expand Down Expand Up @@ -1827,49 +1835,110 @@ export function install(rt) {
// and it can be refused, held, or never read (a room-only Bot is not polled). The claim is the
// authority either way: read every open server link and seat the homes it names. A room that
// cannot be shown, or a notice that does not leave, stays on the link as `waiting` for the owner.
rt.seatOpenClaims = async function seatOpenClaims({ roomId, force = false } = {}) {
// #1161: Bot Social counts each preview against the same address budget as a face-code attempt
// (20 in 10 minutes). The inbox poll used to preview every open link on every tick, so a home
// that stayed open could never see the next claim. One open link per quiet read, a minute apart,
// stays under that budget. Seat them (`force` or one room) still reads at once. A `rate_limited`
// or `locked_out` answer pauses the quiet reads for the server's retry, at least a minute and at
// most the budget window. The clock is only moved after a preview that answered, so a network
// error is tried again on the next tick. Prod (u-682b8a) logged the preview as
// `Too many attempts. Try again in 10 minutes.` and then previewed again on every poll, so that
// 10 minutes never elapsed and the claim was never read.
const CLAIM_READ_GAP_MS = 60_000
const CLAIM_READ_PAUSE_MAX_MS = 10 * 60 * 1000
let claimReadAt = null
let claimReadPauseUntil = 0
let claimReadCursor = 0
const claimWaiting = (rooms) => {
const waiting = []
for (const room of rooms) {
if (!room || room.origin === 'inbound-share' || isFaceRoom(room)) continue
for (const invite of invitesOf(room)) {
if (invite.server !== true || !openInvite(invite) || !invite.waiting) continue
waiting.push({ roomId: room.id, ticket: invite.ticket, reason: invite.waiting.reason, bots: invite.waiting.bots ?? [] })
}
}
return waiting
}
// `retryAfter` is seconds when the client kept it. The sentence the host logged
// (`Try again in 10 minutes.`) is the same fact when only the message arrives.
const attemptLimitMs = (error) => {
const code = String(error?.code ?? '')
const message = String(error?.message ?? error ?? '')
if (code !== 'rate_limited' && code !== 'locked_out' && !/too many attempts/i.test(message)) return 0
const seconds = Number(error?.retryAfter ?? error?.retry_after)
if (Number.isFinite(seconds) && seconds > 0) return seconds * 1000
const minutes = message.match(/(\d+)\s+minutes?/i)
if (minutes) return Number(minutes[1]) * 60 * 1000
const secs = message.match(/(\d+)\s+seconds?/i)
if (secs) return Number(secs[1]) * 1000
return CLAIM_READ_PAUSE_MAX_MS
}
rt.seatOpenClaims = async function seatOpenClaims({ roomId, force = false, now = Date.now() } = {}) {
if (typeof rt.roomInviteServer?.verify !== 'function') return { seated: [], waiting: [] }
const wanted = roomId === undefined || roomId === null || roomId === '' ? undefined : String(roomId)
const rooms = wanted ? [rt.roomOf(wanted)].filter(Boolean) : Object.values(state.rooms || {})
const seated = []
const waiting = []
const polling = !force && wanted === undefined
if (polling && (now < claimReadPauseUntil || (claimReadAt !== null && now - claimReadAt < CLAIM_READ_GAP_MS))) {
return { seated: [], waiting: claimWaiting(rooms) }
}
const open = []
for (const room of rooms) {
if (!room || room.origin === 'inbound-share' || isFaceRoom(room)) continue
for (const invite of invitesOf(room)) {
if (invite.server !== true || !openInvite(invite)) continue
let claim
try {
claim = await rt.roomInviteServer.verify(invite.ticket)
} catch (error) {
rt.warn?.('ds-bot: a room link claim was not read: %s', error)
open.push({ room, invite })
}
}
let batch = open
let cursor = claimReadCursor
if (polling) {
if (open.length === 0) return { seated: [], waiting: [] }
cursor = claimReadCursor % open.length
batch = [open[cursor]]
}
const seated = []
const seen = new Set(batch.map(item => item.invite.ticket))
const waiting = claimWaiting(rooms).filter(item => !seen.has(item.ticket))
for (const { room, invite } of batch) {
let claim
try {
claim = await rt.roomInviteServer.verify(invite.ticket)
} catch (error) {
const wait = attemptLimitMs(error)
if (wait > 0) claimReadPauseUntil = now + Math.min(CLAIM_READ_PAUSE_MAX_MS, Math.max(CLAIM_READ_GAP_MS, wait))
rt.warn?.('ds-bot: a room link claim was not read: %s', error)
if (invite.waiting) waiting.push({ roomId: room.id, ticket: invite.ticket, reason: invite.waiting.reason, bots: invite.waiting.bots ?? [] })
if (wait > 0) break
continue
}
claimReadAt = now
if (polling) claimReadCursor = (cursor + 1) % open.length
if (!claim || String(claim.roomId ?? '') !== room.id) continue
const claims = (Array.isArray(claim.claimers) ? claim.claimers : claim.claimer ? [claim.claimer] : []).filter(c => botsOfClaim(c).length > 0)
const mark = () => JSON.stringify({ waiting: invite.waiting ?? null, told: invite.told ?? null, held: invite.held ?? null, tries: invite.tellTries ?? null })
const before = mark()
let blocked = null
if (claims.length === 0) {
if (invite.waiting) delete invite.waiting
} else for (const mine of claims) {
const admitted = await admitClaim(room, invite, mine, botsOfClaim(mine)[0])
if (admitted.refused === 'not-addable') {
blocked = { reason: 'not-addable', bots: admitted.bots ?? [] }
continue
}
if (!claim || String(claim.roomId ?? '') !== room.id) continue
const claims = (Array.isArray(claim.claimers) ? claim.claimers : claim.claimer ? [claim.claimer] : []).filter(c => botsOfClaim(c).length > 0)
const mark = () => JSON.stringify({ waiting: invite.waiting ?? null, told: invite.told ?? null, held: invite.held ?? null, tries: invite.tellTries ?? null })
const before = mark()
let blocked = null
if (claims.length === 0) {
if (invite.waiting) delete invite.waiting
} else for (const mine of claims) {
const admitted = await admitClaim(room, invite, mine, botsOfClaim(mine)[0])
if (admitted.refused === 'not-addable') {
blocked = { reason: 'not-addable', bots: admitted.bots ?? [] }
continue
}
if (admitted.refused) continue
seated.push(...(admitted.fresh ?? []))
const outcome = await tellSeats(room, invite, admitted.seats ?? [], { force })
if (outcome === 'not-delivered') blocked = blocked ?? { reason: 'not-delivered', bots: [] }
else if (outcome === 'held') blocked = blocked ?? { reason: 'held', bots: [] }
}
if (claims.length > 0) {
if (blocked) invite.waiting = { reason: blocked.reason, bots: blocked.bots, at: invite.waiting?.at ?? Date.now() }
else if (invite.waiting) delete invite.waiting
}
if (mark() !== before) await rt.save?.()
if (invite.waiting) waiting.push({ roomId: room.id, ticket: invite.ticket, reason: invite.waiting.reason, bots: invite.waiting.bots ?? [] })
if (admitted.refused) continue
seated.push(...(admitted.fresh ?? []))
const outcome = await tellSeats(room, invite, admitted.seats ?? [], { force })
if (outcome === 'not-delivered') blocked = blocked ?? { reason: 'not-delivered', bots: [] }
else if (outcome === 'held') blocked = blocked ?? { reason: 'held', bots: [] }
}
if (claims.length > 0) {
if (blocked) invite.waiting = { reason: blocked.reason, bots: blocked.bots, at: invite.waiting?.at ?? Date.now() }
else if (invite.waiting) delete invite.waiting
}
if (mark() !== before) await rt.save?.()
if (invite.waiting) waiting.push({ roomId: room.id, ticket: invite.ticket, reason: invite.waiting.reason, bots: invite.waiting.bots ?? [] })
}
return { seated, waiting }
}
Expand Down
78 changes: 77 additions & 1 deletion core/src/shared/room-share.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,82 @@ export const NOTICE_MAX = 2000
// backfill (an older host); an empty list means the room had no chat lines.
export const HISTORY_MAX = 20
export const HISTORY_TEXT = 2000
// Bot Social rejects `send_as_member` text longer than this. A join that carries the room's chat
// used to exceed it, so the seat was made and the joiner never got the sidebar row (#1161).
export const ROOM_SHARE_TEXT_MAX = 4000
// The instruction-override half of Bot Social's injection detector (plugins/social `ROOM_INSTRUCTION_OVERRIDE`).
// A join that copies one of these lines is held and, until #1161, marked as already sent. Leave them out
// of the join; the owner's card still decides a later line that says the same thing.
const JOIN_HELD_TEXT = [
/ignore (all |any )?(previous|prior|above) (instructions|prompts)/i,
/忽略(之前|以上|前面|先前)的?(所有)?(指令|提示|设定)/,
/(system prompt|系统提示|developer message)/i,
/\[(SYSTEM|ADMIN|PLATFORM)\]|<\/?system>/i,
/(send|forward|reveal|leak) (me )?(all|every|your owner'?s?)/i,
]

function joinTextHeld(text) {
return JOIN_HELD_TEXT.some(pattern => pattern.test(String(text ?? '')))
}

// A join has to fit the transport and must not carry a line that would hold the whole envelope.
// History that was emptied to fit is omitted (absent means don't backfill; `[]` means there were no lines).
// A roster emptied the same way is omitted (`[]` would tell the guest this home keeps no Bots).
function fitJoin(body) {
if (Array.isArray(body.history)) {
const had = body.history.length
body.history = body.history.filter(line => !joinTextHeld(line.text))
if (had > 0 && body.history.length === 0) delete body.history
}
if (typeof body.notice === 'string' && joinTextHeld(body.notice)) delete body.notice
const wire = () => `${ROOM_SHARE_MARK}\n${JSON.stringify(body)}`
let trimmedHistory = false
while (Array.isArray(body.history) && body.history.length > 0 && wire().length > ROOM_SHARE_TEXT_MAX) {
body.history.shift()
trimmedHistory = true
}
if (trimmedHistory && body.history.length === 0) delete body.history
if (typeof body.notice === 'string' && wire().length > ROOM_SHARE_TEXT_MAX) {
while (body.notice.length > 0 && wire().length > ROOM_SHARE_TEXT_MAX) {
const extra = wire().length - ROOM_SHARE_TEXT_MAX
body.notice = body.notice.slice(0, Math.max(0, body.notice.length - extra))
}
if (body.notice.length === 0) delete body.notice
}
if (Array.isArray(body.roster) && body.roster.length > 0 && wire().length > ROOM_SHARE_TEXT_MAX) {
while (body.roster.length > 0 && wire().length > ROOM_SHARE_TEXT_MAX) body.roster.pop()
if (body.roster.length === 0) delete body.roster
}
}

// Bot Social rejects send_as_member text outside 1–4000 characters (`payload.text (1-4000 chars) required`).
// A join is fitted above; a line's own text is the other way an envelope crosses that line (#1161).
function fitTransport(body) {
if (body.op === 'join') fitJoin(body)
const wire = () => `${ROOM_SHARE_MARK}\n${JSON.stringify(body)}`
if (typeof body.text === 'string') {
while (body.text.length > 0 && wire().length > ROOM_SHARE_TEXT_MAX) {
const extra = Math.max(1, wire().length - ROOM_SHARE_TEXT_MAX)
body.text = body.text.slice(0, Math.max(0, body.text.length - extra))
}
if (!body.text) delete body.text
}
for (const key of ['history', 'calls', 'bots', 'roster', 'notice']) {
if (wire().length <= ROOM_SHARE_TEXT_MAX) break
delete body[key]
}
let out = wire()
if (out.length > ROOM_SHARE_TEXT_MAX || out.length < 1) {
out = `${ROOM_SHARE_MARK}\n${JSON.stringify({
v: 1,
op: body.op,
...(body.roomId ? { roomId: String(body.roomId).slice(0, 64) } : {}),
...(body.ticket ? { ticket: body.ticket } : {}),
seq: body.seq || 0,
})}`
}
return out
}

// #904: a handle is unique only inside its home, so an entry carries the home it belongs to: two homes
// can keep Bots with the same local name, and a guest has to tell them apart when it attributes a line.
Expand Down Expand Up @@ -104,7 +180,7 @@ export function encodeRoomShare(payload = {}) {
if (body.op === 'join' && Array.isArray(payload.history)) {
body.history = payload.history.map(historyLine).filter(Boolean).slice(-HISTORY_MAX)
}
return `${ROOM_SHARE_MARK}\n${JSON.stringify(body)}`
return fitTransport(body)
}

// #852: a link invitation for a room that lives in FlyBot itself. No server knows this ticket: the
Expand Down
Loading
Loading