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
63 changes: 62 additions & 1 deletion core/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@
// Inbound shared rooms (`origin: 'inbound-share'`) are the remote owner's projection
// of a friend's room that invited their granted Bot.

import { randomBytes } from 'node:crypto'
import { cleanPeerName, disambiguatedRemoteTitle, isChiefRecord, remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js'
import { formatFaceRoomTitle, isAutoFaceTitle, faceRoomCreatedAt } from '../shared/face-title.js'
import { encodeRoomShare, parseRoomShare } from '../shared/room-share.js'
import { encodeRoomShare, LOCAL_INVITE_TTL_MS, parseRoomShare, roomInviteUrl } from '../shared/room-share.js'
import { cleanFaceLead, cleanRoleLabel, faceLeadOf } from './face-lead.js'
import { cleanName, fenceUntrusted, isFencedUntrusted } from './delivery.js'
import { hasAnyGrant, mayShareRoom } from './reach.js'
Expand Down Expand Up @@ -932,6 +933,66 @@ export function install(rt) {
}
}

// #852: the link invitation for a room that lives here. The room record holds the one live ticket;
// the link is not authority on its own — every knock and every accept is read back against this
// record, and the ticket stops answering the moment it is withdrawn, replaced, expired, or the room
// is gone. Ten minutes, like the face-room window.
const ROOM_INVITE_ORIGIN = 'https://social.botawaken.com'

function liveLocalInvite(room, at = Date.now()) {
const invite = room?.localInvite
if (!invite || typeof invite.ticket !== 'string' || !invite.ticket) return undefined
const expiresAt = Number(invite.expiresAt)
if (!Number.isFinite(expiresAt) || expiresAt <= at) return undefined
return {
ticket: invite.ticket,
handle: invite.handle,
expiresAt,
...(typeof invite.consumedBy === 'string' && invite.consumedBy ? { consumedBy: invite.consumedBy } : {}),
}
}

rt.localInviteOf = function localInviteOf(roomId) {
const live = liveLocalInvite(rt.roomOf(roomId))
if (!live) return undefined
const origin = rt.roomInviteOrigin || ROOM_INVITE_ORIGIN
return { ...live, url: roomInviteUrl(live.handle, live.ticket, origin) }
}

rt.roomOfLocalInvite = function roomOfLocalInvite(ticket) {
const wanted = String(ticket ?? '')
if (!wanted) return undefined
for (const room of Object.values(state.rooms || {})) {
if (room?.localInvite?.ticket === wanted) return room
}
return undefined
}

rt.mintRoomInvite = async function mintRoomInvite(roomId) {
const room = rt.roomOf(roomId)
if (room === undefined) throw invalid('Unknown group chat')
// A shared or face room has its own invitation; this one is for a room that lives in FlyBot.
if (room.origin === 'inbound-share' || isFaceRoom(room)) throw invalid('This room has its own invitation')
const handle = shareInviter(room).inviterHandle
if (!handle) throw invalid('Sign in first')
const expiresAt = Date.now() + LOCAL_INVITE_TTL_MS
// 16 random bytes in base64url are 22 characters, the shape the invite field already accepts.
const ticket = randomBytes(16).toString('base64url')
// One live ticket per room: minting again replaces the last one, and the old link stops working.
room.localInvite = { ticket, handle, expiresAt, mintedAt: Date.now() }
await rt.save?.()
return rt.localInviteOf(room.id)
}

rt.revokeRoomInvite = async function revokeRoomInvite(roomId) {
const room = rt.roomOf(roomId)
if (room === undefined) throw invalid('Unknown group chat')
if (!room.localInvite) return { revoked: false }
delete room.localInvite
await rt.save?.()
return { revoked: true }
}

// Room lines go out to the friends' Bots in the room, checked per author and per recipient
// (#51): a line written by a local Bot that may not share the room never leaves, a friend's
// Bot that is no longer granted to us gets nothing, and the sending Bot is one that may
Expand Down
23 changes: 23 additions & 0 deletions core/src/shared/room-share.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,29 @@ export function encodeRoomShare(payload = {}) {
return `${ROOM_SHARE_MARK}\n${JSON.stringify(body)}`
}

// #852: a link invitation for a room that lives in FlyBot itself. No server knows this ticket: the
// inviter's host mints it and the room record keeps it. The link carries the inviter Bot's handle as
// its own path segment, because that handle is the only address the invitee can knock on — a ticket
// inside the 64-character shape cannot hold one. Ten minutes, like the face-room window, and never
// authority on its own: the room record decides.
export const LOCAL_INVITE_TTL_MS = 10 * 60 * 1000
const INVITE_LINK = /^(?:https?:)?\/\/[^\s/]+\/r\/(?:(@[A-Za-z0-9._-]{1,120})\/)?([A-Za-z0-9_-]{16,64})\/?(?:[?#].*)?$/i

// Two shapes, one field: the face-room link the server owns, and the local one that names the Bot to
// knock on. Nothing here decides which is legitimate — the caller does, by kind.
export function parseRoomInviteLink(value) {
const match = String(value ?? '').trim().match(INVITE_LINK)
if (!match) return undefined
return match[1] ? { kind: 'local', handle: match[1], ticket: match[2] } : { kind: 'face', ticket: match[2] }
}

export function roomInviteUrl(handle, ticket, origin = 'https://social.botawaken.com') {
const who = String(handle ?? '').trim()
const clean = String(ticket ?? '').trim()
if (!/^@[A-Za-z0-9._-]{1,120}$/.test(who) || !/^[A-Za-z0-9_-]{16,64}$/.test(clean)) return ''
return `${String(origin || '').replace(/\/+$/, '')}/r/${who}/${clean}`
}

export function parseRoomShare(text) {
const raw = String(text ?? '')
const at = raw.indexOf(ROOM_SHARE_MARK)
Expand Down
59 changes: 58 additions & 1 deletion core/test/remote-room.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
} from '../src/host/room-membership.js'
import { install as installReach, reach } from '../src/host/reach.js'
import { friendBotOwner, friendBotTitle, remoteBotTitle, sharedRoomTitle } from '../src/shared/remote-label.js'
import { encodeRoomShare, parseRoomShare } from '../src/shared/room-share.js'
import { encodeRoomShare, parseRoomInviteLink, parseRoomShare, roomInviteUrl } from '../src/shared/room-share.js'
import { fenceUntrusted } from '../src/host/delivery.js'
import { mentionRows } from '../src/client/at-menu.js'
import { indexRoster } from '../src/client/sources.js'
Expand Down Expand Up @@ -1112,3 +1112,60 @@ test('#825: an answer held for approval shows up as one line in the room, not as
await rt.fanoutRoomLine(room, { seq: 10, who: 'Writer B', botId: 'spec-b', authorId: 'spec-b', authorKind: 'bot', text: 'held answer' })
assert.ok(room.log.some(line => line.kind === 'notice' && /held for approval/.test(line.text || '')), 'the room says so once')
})

test('#852: a room link is one live ticket — ten minutes, one per room, replaced, withdrawn, gone with the room', async (t) => {
const { rt } = host(t, TEAM)
await rt.load()
const { alice } = await signAliceAndBob(rt)
await rt.authenticate(alice.sessionToken)
await grantToFriend(rt, 'chief-a')
rt.state.bots['chief-a'].fields = { social: { member: { home: '@alice.home', handle: '@chief.a.home' } } }
assert.equal(rt.localInviteOf('war'), undefined, 'a room starts without a link')

const first = await rt.mintRoomInvite('war')
assert.match(first.ticket, /^[A-Za-z0-9_-]{22}$/, '22 random characters, the shape the invite field accepts')
// The link answers "who do I knock on" by naming the inviter Bot in its own path segment.
assert.deepEqual(parseRoomInviteLink(first.url), { kind: 'local', handle: '@chief.a.home', ticket: first.ticket })
assert.equal(roomInviteUrl('@chief.a.home', first.ticket, 'https://social.botawaken.com/'), first.url, 'one builder, no trailing slash')
const window = first.expiresAt - Date.now()
assert.ok(window > 9 * 60 * 1000 && window <= 10 * 60 * 1000, `ten minutes, the face-room window (got ${window} ms)`)
assert.equal(rt.roomOfLocalInvite(first.ticket).id, 'war')

// One live ticket per room: minting again replaces the last one, and the old link stops answering.
const second = await rt.mintRoomInvite('war')
assert.notEqual(second.ticket, first.ticket)
assert.equal(rt.roomOfLocalInvite(first.ticket), undefined, 'the replaced link answers nothing')
assert.equal(rt.roomOfLocalInvite(second.ticket).id, 'war')

// Ten minutes is the whole life: past the deadline the room offers no preview, and a fresh mint works.
rt.state.rooms.war.localInvite.expiresAt = Date.now() - 1
assert.equal(rt.localInviteOf('war'), undefined, 'an expired ticket offers nothing')
assert.notEqual(await rt.mintRoomInvite('war'), undefined, 'the room can hand out a fresh one')

// Withdraw clears it for good, and deleting the room takes its link with it.
const third = await rt.mintRoomInvite('war')
assert.deepEqual(await rt.revokeRoomInvite('war'), { revoked: true })
assert.equal(rt.localInviteOf('war'), undefined)
assert.equal(rt.roomOfLocalInvite(third.ticket), undefined, 'a withdrawn link answers nothing')
assert.deepEqual(await rt.revokeRoomInvite('war'), { revoked: false })
const last = await rt.mintRoomInvite('war')
delete rt.state.rooms.war
assert.equal(rt.roomOfLocalInvite(last.ticket), undefined, 'deleting the room takes its link with it')
})

test('#852: a local link is not a face-room link, and a shared or face room has its own invitation', async (t) => {
// One field, two shapes: the face-room link the server owns, and the local one that names the Bot to knock on.
assert.deepEqual(parseRoomInviteLink('https://social.botawaken.com/r/AbCdEfGhIjKlMnOpQrStUv'), { kind: 'face', ticket: 'AbCdEfGhIjKlMnOpQrStUv' })
assert.deepEqual(parseRoomInviteLink('https://social.botawaken.com/r/@chief.a.home/AbCdEfGhIjKlMnOpQrStUv/'), { kind: 'local', handle: '@chief.a.home', ticket: 'AbCdEfGhIjKlMnOpQrStUv' })
assert.equal(parseRoomInviteLink('AbCdEfGhIjKlMnOpQrStUv'), undefined, 'a bare ticket is not a link')
assert.equal(parseRoomInviteLink('https://social.botawaken.com/r/@chief.a.home/short'), undefined, 'the ticket keeps its own length')
assert.equal(roomInviteUrl('@chief.a.home', 'short'), '', 'no link is built from a ticket that cannot work')
const { rt } = host(t, TEAM)
await rt.load()
const room = rt.state.rooms.war
room.origin = 'inbound-share'
await assert.rejects(() => rt.mintRoomInvite('war'), /its own invitation/)
room.origin = 'face-to-face'
room.face = { roomId: 'frm_1', members: [] }
await assert.rejects(() => rt.mintRoomInvite('war'), /its own invitation/)
})
6 changes: 6 additions & 0 deletions docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,12 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi
- 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. A leave envelope, hide, or grant revoke marks it left/hidden. `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)

- A room that lives in FlyBot can hand out one link: `https://social.botawaken.com/r/<inviter Bot handle>/<ticket>`. `core/src/shared/room-share.js` `parseRoomInviteLink` reads both shapes in the one invite field and says which one it saw (`{ kind: 'local', handle, ticket }` / `{ kind: 'face', ticket }`), and `roomInviteUrl` builds the local one. The ticket is `randomBytes(16)` in base64url — 22 characters of `[A-Za-z0-9_-]`, inside the 64-character shape `FACE_INVITE_TICKET` already accepts. The handle is its own path segment because `send_as_member` addresses a Bot by handle, and a ticket cannot carry one inside that limit.
- The room record is the only authority: `room.localInvite { ticket, handle, expiresAt, mintedAt }`, ten minutes (`LOCAL_INVITE_TTL_MS`, the face-room window). `rt.mintRoomInvite(roomId)` replaces the previous ticket, so there is one live ticket per room; `rt.localInviteOf(roomId)` answers only while it is live; `rt.roomOfLocalInvite(ticket)` resolves the room from the ticket; `rt.revokeRoomInvite(roomId)` and deleting the room clear it. A shared (`inbound-share`) or face-to-face room refuses to mint — it has its own invitation.
- The link is not reachable yet: this half adds the record and the URL shape. The `ask` / `invite` / `accept` envelopes and their fail-closed order still have to be wired into `applyInboundRoomShare` and the social plugin, in this same issue after #858. Covered by `core/test/remote-room.test.js` (#852).

### Face-to-face owner send

- Channel `send` on a face-to-face room (`origin: 'face-to-face'`) always `postRoomMessage`s, then shows a quiet local notice (`postToRoom` `quiet`+`notice`). It does not call `sessionController.prompt` unless a local Bot is @mentioned. Unsigned-in cloud UI uses `implicitRoomOwner` (the local human) so the line still fans out over `onFaceLine` / `FLYBOT-FACE-HUMAN` and the other FlyBot instance shows it; that path must not open a bot round (#124). Covered by `core/test/remote-room.test.js` and `core/test/rounds.test.js`.
Expand Down
63 changes: 62 additions & 1 deletion patches/ds-bot/files/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@
// Inbound shared rooms (`origin: 'inbound-share'`) are the remote owner's projection
// of a friend's room that invited their granted Bot.

import { randomBytes } from 'node:crypto'
import { cleanPeerName, disambiguatedRemoteTitle, isChiefRecord, remoteBotTitle, sharedRoomTitle } from '../shared/remote-label.js'
import { formatFaceRoomTitle, isAutoFaceTitle, faceRoomCreatedAt } from '../shared/face-title.js'
import { encodeRoomShare, parseRoomShare } from '../shared/room-share.js'
import { encodeRoomShare, LOCAL_INVITE_TTL_MS, parseRoomShare, roomInviteUrl } from '../shared/room-share.js'
import { cleanFaceLead, cleanRoleLabel, faceLeadOf } from './face-lead.js'
import { cleanName, fenceUntrusted, isFencedUntrusted } from './delivery.js'
import { hasAnyGrant, mayShareRoom } from './reach.js'
Expand Down Expand Up @@ -932,6 +933,66 @@ export function install(rt) {
}
}

// #852: the link invitation for a room that lives here. The room record holds the one live ticket;
// the link is not authority on its own — every knock and every accept is read back against this
// record, and the ticket stops answering the moment it is withdrawn, replaced, expired, or the room
// is gone. Ten minutes, like the face-room window.
const ROOM_INVITE_ORIGIN = 'https://social.botawaken.com'

function liveLocalInvite(room, at = Date.now()) {
const invite = room?.localInvite
if (!invite || typeof invite.ticket !== 'string' || !invite.ticket) return undefined
const expiresAt = Number(invite.expiresAt)
if (!Number.isFinite(expiresAt) || expiresAt <= at) return undefined
return {
ticket: invite.ticket,
handle: invite.handle,
expiresAt,
...(typeof invite.consumedBy === 'string' && invite.consumedBy ? { consumedBy: invite.consumedBy } : {}),
}
}

rt.localInviteOf = function localInviteOf(roomId) {
const live = liveLocalInvite(rt.roomOf(roomId))
if (!live) return undefined
const origin = rt.roomInviteOrigin || ROOM_INVITE_ORIGIN
return { ...live, url: roomInviteUrl(live.handle, live.ticket, origin) }
}

rt.roomOfLocalInvite = function roomOfLocalInvite(ticket) {
const wanted = String(ticket ?? '')
if (!wanted) return undefined
for (const room of Object.values(state.rooms || {})) {
if (room?.localInvite?.ticket === wanted) return room
}
return undefined
}

rt.mintRoomInvite = async function mintRoomInvite(roomId) {
const room = rt.roomOf(roomId)
if (room === undefined) throw invalid('Unknown group chat')
// A shared or face room has its own invitation; this one is for a room that lives in FlyBot.
if (room.origin === 'inbound-share' || isFaceRoom(room)) throw invalid('This room has its own invitation')
const handle = shareInviter(room).inviterHandle
if (!handle) throw invalid('Sign in first')
const expiresAt = Date.now() + LOCAL_INVITE_TTL_MS
// 16 random bytes in base64url are 22 characters, the shape the invite field already accepts.
const ticket = randomBytes(16).toString('base64url')
// One live ticket per room: minting again replaces the last one, and the old link stops working.
room.localInvite = { ticket, handle, expiresAt, mintedAt: Date.now() }
await rt.save?.()
return rt.localInviteOf(room.id)
}

rt.revokeRoomInvite = async function revokeRoomInvite(roomId) {
const room = rt.roomOf(roomId)
if (room === undefined) throw invalid('Unknown group chat')
if (!room.localInvite) return { revoked: false }
delete room.localInvite
await rt.save?.()
return { revoked: true }
}

// Room lines go out to the friends' Bots in the room, checked per author and per recipient
// (#51): a line written by a local Bot that may not share the room never leaves, a friend's
// Bot that is no longer granted to us gets nothing, and the sending Bot is one that may
Expand Down
23 changes: 23 additions & 0 deletions patches/ds-bot/files/src/shared/room-share.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,29 @@ export function encodeRoomShare(payload = {}) {
return `${ROOM_SHARE_MARK}\n${JSON.stringify(body)}`
}

// #852: a link invitation for a room that lives in FlyBot itself. No server knows this ticket: the
// inviter's host mints it and the room record keeps it. The link carries the inviter Bot's handle as
// its own path segment, because that handle is the only address the invitee can knock on — a ticket
// inside the 64-character shape cannot hold one. Ten minutes, like the face-room window, and never
// authority on its own: the room record decides.
export const LOCAL_INVITE_TTL_MS = 10 * 60 * 1000
const INVITE_LINK = /^(?:https?:)?\/\/[^\s/]+\/r\/(?:(@[A-Za-z0-9._-]{1,120})\/)?([A-Za-z0-9_-]{16,64})\/?(?:[?#].*)?$/i

// Two shapes, one field: the face-room link the server owns, and the local one that names the Bot to
// knock on. Nothing here decides which is legitimate — the caller does, by kind.
export function parseRoomInviteLink(value) {
const match = String(value ?? '').trim().match(INVITE_LINK)
if (!match) return undefined
return match[1] ? { kind: 'local', handle: match[1], ticket: match[2] } : { kind: 'face', ticket: match[2] }
}

export function roomInviteUrl(handle, ticket, origin = 'https://social.botawaken.com') {
const who = String(handle ?? '').trim()
const clean = String(ticket ?? '').trim()
if (!/^@[A-Za-z0-9._-]{1,120}$/.test(who) || !/^[A-Za-z0-9_-]{16,64}$/.test(clean)) return ''
return `${String(origin || '').replace(/\/+$/, '')}/r/${who}/${clean}`
}

export function parseRoomShare(text) {
const raw = String(text ?? '')
const at = raw.indexOf(ROOM_SHARE_MARK)
Expand Down
Loading
Loading