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/310-add-friend-invite-input.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add friend now accepts an invite pasted as a full link, a link without `https://` or without `/i/`, a bare code, or the whole copied invite text,; the copied link always has its `/i/` part, and a missing, expired, used or own invite shows a short plain sentence instead of `Bot Social join_invite: invite_not_found`. ([#310](https://github.com/ThinkFlowLab/FlyBot/issues/310))
4 changes: 4 additions & 0 deletions docs/subsystems/social.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ pins the list. The authority for the list is Bot Social's README and `docs/FACE_
- **#140:** Auto-send defaults off. Accepting a friend request uses that relationship's peer home for Auto-send, not the first roster entry. The owner sees Approve / Reject in the chat, not “Waiting for the owner's approval.” Composer drafts are labelled You.
- **#136 (suffix):** Onboard retries truncate the sanitized handle body so `Name2` / `Name3` still change within the 32-character `homeHandleOf` limit.

### Add friend invite input (#310)

`social/join-invite` runs the box text through `parseInviteInput` (`social.js`) before `join_invite`. It takes `https://host/i/CODE`, the same without the scheme, a link whose `/i/` is missing (host plus one bare token), a bare code, or the whole copied invite text (the first token with `/i/` or a URL wins), and sends only the code: Bot Social's `join_invite` looks up a code (it strips just a leading `…/i/`), so no host is ever built or added. Copy invite link hands out the server's `invite.url` through `inviteLink`, which rebuilds `origin/i/CODE` if a server ever answered a url without `/i/`; Bot Social itself always emits `/i/CODE` (`inviteUrl`) and serves only that route, so a link without `/i/` comes from hand-trimming or a client that dropped it. A pasted host that is not `config.server` is kept as `host`; a not-found then says the link may come from another server. `mapJoinInviteError` turns `invite_not_found`, expired / revoked / used invites, an own invite, `blocked` and `rate_limited` into one short sentence each ("This invite link wasn't found. It may be incomplete, expired, or already used. Ask your friend for a new link."); anything else passes through unchanged.

### Thinking status for friends' Bots (#297)

- **Signal.** Bot Social's `presence` feature (`GET /v1/platform` `features`; the same probe as `typed_by_owner` / `mentions`, `serverPresence()`): MCP `set_presence { bot_id, state: thinking|idle, to | room_id }` and `list_presence`. Ephemeral and content-free: handle, display name and state only, TTL 20 s, 30 writes/min per Bot, never stored. The server allows it only between Bots with an active relationship (direct) or members of an open room, and shows an entry only to the other side. Plugin option `presence: false` turns it off here. Against an older server (feature absent, or an unknown tool) nothing is sent, polled or shown.
Expand Down
74 changes: 70 additions & 4 deletions plugins/social/src/social.js
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,50 @@ export async function onboardHome({ server, displayName, webhookUrl, fetch: fetc
throw last
}

export function mapJoinInviteError(error) {
const INVITE_CODE = /^[A-Za-z0-9_-]+$/

const stripWrapping = token => token.replace(/^[<("'`[]+/, '').replace(/[>)"'`\].,;:!?]+$/, '')
const SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i

// The link we hand out is the server's own `https://host/i/CODE`. If a server ever answers with a
// url that lacks the /i/ segment (the server only serves /i/CODE), rebuild it from that url's host
// and the code, so Copy invite link can never produce a link Add friend would have to guess (#310).
export function inviteLink(invite) {
const url = String(invite?.url ?? '')
const code = String(invite?.code ?? '')
if (!url || !code || url.includes(`/i/${code}`)) return url || undefined
try { return `${new URL(url).origin}/i/${code}` } catch { return url }
}

// What the Add friend box may hold: the server's link (https://host/i/CODE), the same without
// the scheme, a link whose /i/ went missing (host/CODE), the bare code, or the whole "Add my
// bot ... Link: https://host/i/CODE (single use ...)" text. Bot Social's join_invite takes the
// code, so that is what we send; `host` is the pasted host when there was one, so a link from
// another server can say so when it is not found (#310).
export function parseInviteInput(input) {
const raw = String(input ?? '').trim()
const tokens = raw.split(/\s+/).map(stripWrapping).filter(Boolean)
const hasInvitePath = token => /\/i\/[^/]/.test(token)
const hostLike = token => /^[^/\s]+\.[^/\s]+\/[^/\s]/.test(token) || /^localhost(:\d+)?\/[^/\s]/i.test(token)
const token = tokens.length === 1
? tokens[0]
: tokens.find(hasInvitePath) ?? tokens.find(item => SCHEME.test(item)) ?? tokens.find(hostLike) ?? ''
if (!token) return { code: raw }
const bare = token.replace(SCHEME, '').replace(/[?#].*$/, '').replace(/\/+$/, '')
if (!bare.includes('/')) return { code: INVITE_CODE.test(bare) ? bare : raw }
const [first, ...rest] = bare.split('/')
const hasHost = SCHEME.test(token) || /\./.test(first) || /^localhost(:\d+)?$/i.test(first)
const host = hasHost ? first.toLowerCase() : undefined
const path = (hasHost ? rest : [first, ...rest]).filter(Boolean)
let code
const at = path.lastIndexOf('i')
if (at >= 0 && at < path.length - 1) code = path[at + 1]
else if (path.length === 1) code = path[0]
try { if (code) code = decodeURIComponent(code) } catch { /* keep it as pasted */ }
return { code: code && INVITE_CODE.test(code) ? code : raw, ...(host ? { host } : {}) }
}

export function mapJoinInviteError(error, { foreignHost } = {}) {
const msg = String(error?.message ?? error ?? '')
const code = String(error?.code ?? '')
const blob = `${code} ${msg}`
Expand All @@ -249,10 +292,30 @@ export function mapJoinInviteError(error) {
: {}),
})
}
if (/invite_not_found/i.test(blob)) {
return Object.assign(new Error(foreignHost
? "This invite link wasn't found on your Bot Social server. It may come from another server, or be incomplete, expired, or already used. Ask your friend for a new link."
: "This invite link wasn't found. It may be incomplete, expired, or already used. Ask your friend for a new link."), { code: 'invite_not_found' })
}
if (/invite is expired|invite_expired/i.test(blob)) {
return Object.assign(new Error('This invite has expired. Ask your friend for a new one.'), { code: 'invite_unavailable' })
}
if (/invite is revoked|invite_revoked|invite is pending/i.test(blob)) {
return Object.assign(new Error('This invite is no longer valid. Ask your friend for a new one.'), { code: 'invite_unavailable' })
}
if (/invite_unavailable|invite is used|invite_used|already used|max_uses|used_up/i.test(blob)) {
const code = /invite_unavailable/i.test(blob) ? 'invite_unavailable' : 'invite_used'
return Object.assign(new Error('This invite was already used. Ask your friend for a new one.'), { code })
}
if (/cannot join your own invite/i.test(blob)) {
return Object.assign(new Error("That's your own invite. Send it to a friend instead."), { code: 'own_invite' })
}
if (/\bblocked\b/i.test(blob) && !/dlp_blocked/i.test(blob)) {
return Object.assign(new Error("You can't add this friend."), { code: 'blocked' })
}
if (/rate_limited/i.test(blob)) {
return Object.assign(new Error('Too many tries. Wait a moment and try again.'), { code: 'rate_limited' })
}
return error
}

Expand Down Expand Up @@ -1509,7 +1572,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
if (!member) throw new Error(`${bot.name} has no Bot Social identity yet`)
const invite = await api.createInvite({ bot_id: member.bot_id, max_uses: 1, expires_in_hours: 168, auto_accept: true, offered_capabilities: ['chat.public'], requested_capabilities: ['chat.public'], ...(message ? { message: String(message).slice(0, 280) } : {}) })
const handle = norm(member.handle)
return { botId: bot.id, botName: bot.name, handle, home: norm(home.handle), code: invite.code, url: invite.url, link: invite.url, expiresAt: invite.expires_at, status: invite.status, text: inviteText({ handle, name: bot.name, home: norm(home.handle), url: invite.url, expiresAt: invite.expires_at }) }
const url = inviteLink(invite)
return { botId: bot.id, botName: bot.name, handle, home: norm(home.handle), code: invite.code, url, link: url, expiresAt: invite.expires_at, status: invite.status, text: inviteText({ handle, name: bot.name, home: norm(home.handle), url, expiresAt: invite.expires_at }) }
}
disposers.push(flybot.api.handle('social/create-invite', createBotInvite))
disposers.push(flybot.api.handle('social.createInvite', createBotInvite))
Expand Down Expand Up @@ -1541,6 +1605,8 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
disposers.push(flybot.api.handle('social/join-invite', async ({ code, botId } = {}) => {
const value = String(code ?? '').trim()
if (!value) throw new Error('paste an invite link or code')
const parsed = parseInviteInput(value)
const configuredHost = () => { try { return new URL(String(config.server)).host.toLowerCase() } catch { return '' } }
let from = config.botId ? { from_bot_id: config.botId } : {}
let joinedAs
if (home) {
Expand All @@ -1555,9 +1621,9 @@ export function createSocial(service, config = {}, { client: given, api: givenAp
const before = new Set(listedRemoteBots().map(bot => bot.handle))
let joined
try {
joined = await client.callTool('join_invite', { code: value.replace(/^.*\/i\//, '').replace(/[?#].*$/, ''), ...from })
joined = await client.callTool('join_invite', { code: parsed.code, ...from })
} catch (error) {
const mapped = mapJoinInviteError(error)
const mapped = mapJoinInviteError(error, { foreignHost: Boolean(parsed.host && configuredHost() && parsed.host !== configuredHost()) })
if (mapped.code === 'relationship_exists' && home) {
try {
const result = await client.callTool('list_remote_bots', { home: home.id })
Expand Down
59 changes: 58 additions & 1 deletion plugins/social/test/social.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { test } from 'node:test'
import { apply as applyDsBot } from 'ds-bot'
import { fakeContext } from './fake-ctx.js'
import { apply as applySocial, inject, name } from '../index.js'
import { approvalDraftText, approvalView, createSocial, draftText, homeHandleOf, onboard, onboardHome, nextOnboardDisplayName, mapJoinInviteError, registerUnavailable, startSocial, unavailableMessage } from '../src/social.js'
import { approvalDraftText, approvalView, createSocial, draftText, homeHandleOf, onboard, onboardHome, nextOnboardDisplayName, mapJoinInviteError, parseInviteInput, inviteLink, registerUnavailable, startSocial, unavailableMessage } from '../src/social.js'
import { createMcpClient, createSocialApi, untrustedText, mapPool, socialHttpAgent, fetchOptions, grantPersonView, memberHandleOf, personHomeOf, normalizeGrant, isUnknownTool, isGrantNotFound, isMissingEndpoint } from '../src/mcp-client.js'
import { lookFromServer, lookToServer, lookFingerprint, hexOfColor } from '../src/look.js'
import { encodeRoomShare } from '../../../core/src/shared/room-share.js'
Expand All @@ -31,6 +31,7 @@ function mockServer({ token = 'owner-token', friends = true } = {}) {
const tools = {
create_invite: a => ({ code: 'INV123', url: 'https://s/i/INV123', status: 'active', expires_at: '2026-10-21T00:00:00Z', bot: a.bot_id, auto_accept: a.auto_accept }),
join_invite: a => {
if (a.code === 'NOPE') throw Object.assign(new Error('Bot Social join_invite: invite_not_found'), { code: 'invite_not_found' })
if (a.code === 'USED') throw Object.assign(new Error('Bot Social join_invite: invite is used'), { code: 'invite_used' })
if (a.code === 'UNAVAIL') throw Object.assign(new Error('Bot Social join_invite: invite_unavailable'), { code: 'invite_unavailable' })
if (a.code === 'EXISTS') throw Object.assign(new Error('Bot Social join_invite: relationship_exists'), { code: 'relationship_exists', peer: { handle: '@bob.home', display_name: 'Bob' }, bot: { handle: '@coder.bob.home', display_name: 'Coder' } })
Expand Down Expand Up @@ -5462,3 +5463,59 @@ test('#228: an open People with access shows a friend who accepts, without reope
assert.ok(after.some(n => n.props['data-flybot'] === 'access-grant'))
assert.equal(after.some(n => typeof n.children?.[0] === 'string' && /Nobody has this Bot yet/.test(n.children[0])), false)
})

test('#310: Add friend takes a link, a link without scheme or /i/, a bare code, or the copied text', () => {
const code = 'eWDDdBMIxLSZ'
const host = '47-80-69-220.sslip.io'
for (const input of [
`https://${host}/i/${code}`, `http://${host}/i/${code}/`, `${host}/i/${code}`, `${host}/${code}`, `https://${host}/${code}`,
` ${host}/i/${code}?x=1#y `, `<https://${host}/i/${code}>`, code, ` ${code}. `,
`Add my bot @chief.bob.home (Chief) from my FlyBot home @bob.home. Link: https://${host}/i/${code} (single use, expires Oct 21, 2026).`,
]) assert.equal(parseInviteInput(input).code, code, input)
assert.equal(parseInviteInput(`${host}/${code}`).host, host)
assert.equal(parseInviteInput(`https://${host}/i/${code}`).host, host)
assert.equal(parseInviteInput(code).host, undefined)
assert.equal(parseInviteInput('i/ABC').code, 'ABC')
// A host from another server is kept apart from the code; it is never added to a bare code.
assert.deepEqual(parseInviteInput('https://other.example/i/ABC'), { code: 'ABC', host: 'other.example' })
// Not an invite: passed as typed, so the server's answer is what the person sees.
assert.equal(parseInviteInput(host).code, host)
assert.equal(parseInviteInput(`${host}/a/b/c`).code, `${host}/a/b/c`)
assert.equal(parseInviteInput('not an invite').code, 'not an invite')
})

test('#310: join_invite errors read as plain sentences, never as raw codes', async (t) => {
const plain = error => mapJoinInviteError(error).message
assert.equal(plain(Object.assign(new Error('Bot Social join_invite: invite_not_found'), { code: 'invite_not_found' })), "This invite link wasn't found. It may be incomplete, expired, or already used. Ask your friend for a new link.")
assert.match(mapJoinInviteError(new Error('Bot Social join_invite: invite_not_found'), { foreignHost: true }).message, /^This invite link wasn't found on your Bot Social server\. It may come from another server/)
assert.equal(plain(new Error('Bot Social join_invite: invite is expired')), 'This invite has expired. Ask your friend for a new one.')
assert.equal(plain(new Error('Bot Social join_invite: invite is revoked')), 'This invite is no longer valid. Ask your friend for a new one.')
assert.equal(plain(new Error('Bot Social join_invite: invite is used')), 'This invite was already used. Ask your friend for a new one.')
assert.equal(plain(new Error('Bot Social join_invite: cannot join your own invite')), "That's your own invite. Send it to a friend instead.")
assert.equal(plain(new Error('Bot Social join_invite: relationship is blocked')), "You can't add this friend.")
assert.equal(plain(new Error('Bot Social join_invite: rate_limited')), 'Too many tries. Wait a moment and try again.')
const other = new Error('Bot Social is not connected')
assert.equal(mapJoinInviteError(other), other)

const { ctx, call, stop } = await team()
t.after(stop)
const server = mockServer()
const social = createSocial(ctx.flybot, { autoAccept: false, inboxPollMs: 0 }, { client: createMcpClient({ server: 'https://s', token: 'owner-token', fetch: server.fetch }), home: { id: 'h1', handle: '@danyang.home' } })
t.after(() => social.dispose())
const missing = await call('flybot/social/join-invite', { code: '47-80-69-220.sslip.io/NOPE' })
assert.equal(missing.ok, false)
assert.equal(missing.error.message, "This invite link wasn't found. It may be incomplete, expired, or already used. Ask your friend for a new link.")
assert.equal(server.calls.filter(c => c.name === 'join_invite').at(-1).args.code, 'NOPE')
// The pasted form from the bug report reaches the server as the bare code.
const joined = await call('flybot/social/join-invite', { code: '47-80-69-220.sslip.io/eWDDdBMIxLSZ' })
assert.equal(joined.ok, true, JSON.stringify(joined.error))
assert.equal(server.calls.filter(c => c.name === 'join_invite').at(-1).args.code, 'eWDDdBMIxLSZ')
})

test('#310: Copy invite link always carries the /i/ segment', () => {
assert.equal(inviteLink({ code: 'ABC', url: 'https://h.example/i/ABC' }), 'https://h.example/i/ABC')
assert.equal(inviteLink({ code: 'ABC', url: 'https://h.example/ABC' }), 'https://h.example/i/ABC')
assert.equal(inviteLink({ code: 'ABC', url: 'http://127.0.0.1:8787/ABC' }), 'http://127.0.0.1:8787/i/ABC')
assert.equal(inviteLink({ code: 'ABC' }), undefined)
assert.equal(inviteLink({ url: 'not a url' }), 'not a url')
})
Loading