From e0f074b02a348a7d9b8c3fb8794e224c37bd1325 Mon Sep 17 00:00:00 2001 From: Birfy Date: Sun, 18 Oct 2026 17:16:50 +0800 Subject: [PATCH] fix(rooms): say why a group cannot mix a friend's Bot with an unshared Bot of mine, and offer the way out A group with a friend's Bot is projected to that friend's home (fanoutRoomLine sends its lines and every sharing Bot's replies on; the friend's lines come back into the room my Bots read), so a local Bot with no grant to that friend stays refused. The refusal was a bare rule ('Only a Bot you granted ...') and a dead end. It now names the Bot and the friend, carries details for the client, and the new-chat screen offers the Bot's invite link or leaving it out. Nothing is granted by the click. --- changelog.d/group-friend-bot-refusal.fixed.md | 1 + core/client.js | 56 +++++++++- core/src/client/api.js | 2 +- core/src/client/i18n.js | 7 ++ core/src/client/new-chat.js | 48 +++++++- core/src/client/styles.js | 3 + core/src/host/channel.js | 6 +- core/src/host/room-membership.js | 30 ++++- core/src/shared/remote-label.js | 14 +++ core/test/new-chat.test.js | 29 ++++- core/test/remote-room.test.js | 104 +++++++++++++++++- docs/subsystems/core-client.md | 6 +- docs/subsystems/core-host.md | 4 +- docs/subsystems/team.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 9 +- patches/ds-bot/0003-flybot-api-client.patch | 77 ++++++++++++- patches/ds-bot/0005-social-seams.patch | 14 ++- patches/ds-bot/0008-client-bundle.patch | 97 ++++++++++++++-- patches/ds-bot/0012-room-membership.patch | 9 ++ .../ds-bot/files/src/host/room-membership.js | 30 ++++- .../ds-bot/files/src/shared/remote-label.js | 14 +++ patches/ds-bot/files/test/new-chat.test.js | 29 ++++- patches/ds-bot/files/test/remote-room.test.js | 104 +++++++++++++++++- 23 files changed, 645 insertions(+), 50 deletions(-) create mode 100644 changelog.d/group-friend-bot-refusal.fixed.md diff --git a/changelog.d/group-friend-bot-refusal.fixed.md b/changelog.d/group-friend-bot-refusal.fixed.md new file mode 100644 index 000000000..0db23bab1 --- /dev/null +++ b/changelog.d/group-friend-bot-refusal.fixed.md @@ -0,0 +1 @@ +Creating or editing a group that holds a friend's Bot and one of your own Bots that is not shared with that friend no longer ends in the bare red line "Only a Bot you granted (or one that joined with the room code) may be in a room with friends' Bots". The refusal now says which Bot is not shared with whom and why (a group with a friend's Bot is sent on to the friend's home), and the new-chat screen offers **Copy invite link for {Bot}** (the friend accepts it to share that Bot) or **Leave {Bot} out**. Nothing is shared by the click; the rule itself (grants are per Bot, default deny) is unchanged. diff --git a/core/client.js b/core/client.js index 3e8ebf09c..d930077a7 100644 --- a/core/client.js +++ b/core/client.js @@ -99,6 +99,13 @@ var ZH = { "Remove Main Bot role": "取消主 Bot", "Bot settings": "Bot 设置", "Create group chat": "新建群聊", + "{name} is not shared with {friend}, so it cannot be in a group with their Bot: the group's messages go to their home.": "{name} 还没有共享给 {friend},所以不能和对方的 Bot 在同一个群里:群消息会发到对方的主页。", + "{name} are not shared with {friend}, so they cannot be in a group with their Bot: the group's messages go to their home.": "{name} 还没有共享给 {friend},所以不能和对方的 Bot 在同一个群里:群消息会发到对方的主页。", + "that friend": "这位好友", + "Copy invite link for {name}": "复制 {name} 的邀请链接", + "Leave {name} out": "不拉 {name} 进群", + "Invite link for {name} copied. Send it to your friend; once they accept, create the group again.": "{name} 的邀请链接已复制。发给好友,对方接受后再重新建群。", + "Could not create the invite. Use Bot settings → Invite to this Bot.": "无法创建邀请。请在 Bot 设置里使用 Invite to this Bot。", "Face-to-face group": "面对面建群", "Meet the people next to you. Pick your Bots and type the same 6-digit number.": "和身边的人建群。选你的 Bot,输入同一组 6 位数字。", "Add friend": "添加好友", @@ -1180,7 +1187,7 @@ var call = async (endpoint, payload, signal) => { if (!result.ok) { const code = result.error?.code ?? "bot/error"; const message = hostText(result.error?.message ?? "Request failed"); - const error = Object.assign(new Error(code === "bot/internal" ? `${message} ${t("Settings → Feedback has the details.")}` : message), { code }); + const error = Object.assign(new Error(code === "bot/internal" ? `${message} ${t("Settings → Feedback has the details.")}` : message), { code, details: result.error?.details }); if (!REFUSALS.has(code)) { noteClientError(endpoint, Object.assign(new Error(result.error?.message ?? "Request failed"), { code })); markNoted(error); @@ -1284,6 +1291,13 @@ function disambiguatedRemoteTitle(record, others = []) { const clash = others.some((item) => item !== record && cleanPeerName(item?.displayName || item?.name) === name && ownerNameOf(item) !== ownerNameOf(record)); return clash ? title : title; } +function ungrantedLocalFix(details) { + if (details?.reason !== "ungranted-local" || !Array.isArray(details.bots)) return void 0; + const bots = details.bots.filter((bot) => typeof bot?.id === "string" && bot.id !== "").map((bot) => ({ id: bot.id, name: cleanPeerName(bot.name, bot.id) })); + if (bots.length === 0) return void 0; + const friends = [...new Set((Array.isArray(details.remotes) ? details.remotes : []).map((remote) => cleanPeerName(remote?.owner || remote?.name, "")).filter(Boolean))]; + return { bots, friends }; +} // src/client/flybot-registry.js var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals", "flybot.room.overview", "flybot.session.dock", "flybot.new-chat.action", "flybot.account.header"]; @@ -3069,6 +3083,9 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-send{height:40px;min-width:40px;border-radius:999px;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-size:14px;padding:0 14px;cursor:pointer} .bt-send:disabled{opacity:.35;cursor:default} .bt-error{padding:0 14px 10px;color:#e02135;font-size:12px} +.bt-fix-actions{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px} +.bt-fix-btn{font:inherit;font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid currentColor;background:transparent;color:inherit;cursor:pointer} +.bt-fix .bt-note{margin-top:6px} .bt-pane{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;z-index:45;pointer-events:auto;background:var(--bt-main);color:var(--bt-ink);display:flex;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} .bt-dialog{flex:1;display:flex;flex-direction:column;min-width:0} @@ -9605,6 +9622,8 @@ function NewChat({ mode, roster, actions }) { const [message, setMessage] = (0, import_react21.useState)(""); const [busy, setBusy] = (0, import_react21.useState)(false); const [error, setError] = (0, import_react21.useState)(""); + const [fix, setFix] = (0, import_react21.useState)(null); + const [fixNote, setFixNote] = (0, import_react21.useState)(""); const [cursor, setCursor] = (0, import_react21.useState)(0); const [draft, setDraft] = (0, import_react21.useState)(mode === "create" ? { name: "", fromList: false } : null); const inputRef = (0, import_react21.useRef)(null); @@ -9621,15 +9640,37 @@ function NewChat({ mode, roster, actions }) { const run = async (task) => { if (busy) return; setError(""); + setFix(null); + setFixNote(""); setBusy(true); try { await task(); } catch (failure) { setError(failure?.message ?? String(failure)); + setFix(ungrantedLocalFix(failure?.details) ?? null); } finally { setBusy(false); } }; + const leaveOut = (id) => { + if (id === admin) setAdmin(null); + setPicked((current) => current.filter((other) => other !== id)); + setError(""); + setFix(null); + setFixNote(""); + }; + const copyInvite = async (bot) => { + const social = globalThis.__flybot__?.social; + try { + const invite = await social.createInvite(bot.id); + const link = invite?.link || invite?.url; + if (!link) throw new Error("no link"); + await navigator.clipboard?.writeText(link); + setFixNote(t("Invite link for {name} copied. Send it to your friend; once they accept, create the group again.", { name: bot.name })); + } catch { + setFixNote(t("Could not create the invite. Use Bot settings → Invite to this Bot.")); + } + }; const pick2 = (bot) => { setPicked((current) => [...current, bot.id]); setQuery(""); @@ -9871,7 +9912,18 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); )) )), menuOnly ? null : (0, import_react21.createElement)("div", { className: "bt-nc-spacer" }), - error ? (0, import_react21.createElement)("div", { className: "bt-error" }, error) : null, + error && fix ? (0, import_react21.createElement)( + "div", + { className: "bt-error bt-fix", role: "alert", "data-fix": "ungranted-local" }, + (0, import_react21.createElement)("div", null, fix.bots.length === 1 ? t("{name} is not shared with {friend}, so it cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots[0].name, friend: fix.friends.join(", ") || t("that friend") }) : t("{name} are not shared with {friend}, so they cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots.map((bot) => bot.name).join(", "), friend: fix.friends.join(", ") || t("that friend") })), + (0, import_react21.createElement)("div", { className: "bt-fix-actions" }, fix.bots.flatMap((bot) => [ + typeof globalThis.__flybot__?.social?.createInvite === "function" && globalThis.__flybot__.social.canInvite?.(bot.id) !== false ? (0, import_react21.createElement)("button", { key: `invite-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "invite", onClick: () => { + void copyInvite(bot); + } }, t("Copy invite link for {name}", { name: bot.name })) : null, + (0, import_react21.createElement)("button", { key: `out-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "leave-out", onClick: () => leaveOut(bot.id) }, t("Leave {name} out", { name: bot.name })) + ])), + fixNote ? (0, import_react21.createElement)("div", { className: "bt-note", role: "status" }, fixNote) : null + ) : error ? (0, import_react21.createElement)("div", { className: "bt-error" }, error) : null, menuOnly ? null : (0, import_react21.createElement)( "div", { className: "bt-nc-compose" }, diff --git a/core/src/client/api.js b/core/src/client/api.js index 3ad309e44..8d04e7918 100644 --- a/core/src/client/api.js +++ b/core/src/client/api.js @@ -36,7 +36,7 @@ export const call = async (endpoint, payload, signal) => { if (!result.ok) { const code = result.error?.code ?? 'bot/error' const message = hostText(result.error?.message ?? 'Request failed') - const error = Object.assign(new Error(code === 'bot/internal' ? `${message} ${t('Settings → Feedback has the details.')}` : message), { code }) + const error = Object.assign(new Error(code === 'bot/internal' ? `${message} ${t('Settings → Feedback has the details.')}` : message), { code, details: result.error?.details }) if (!REFUSALS.has(code)) { noteClientError(endpoint, Object.assign(new Error(result.error?.message ?? 'Request failed'), { code })) markNoted(error) diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 6df9166ca..92bb0a8d9 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -62,6 +62,13 @@ const ZH = { 'Remove Main Bot role': '取消主 Bot', 'Bot settings': 'Bot 设置', 'Create group chat': '新建群聊', + '{name} is not shared with {friend}, so it cannot be in a group with their Bot: the group\'s messages go to their home.': '{name} 还没有共享给 {friend},所以不能和对方的 Bot 在同一个群里:群消息会发到对方的主页。', + '{name} are not shared with {friend}, so they cannot be in a group with their Bot: the group\'s messages go to their home.': '{name} 还没有共享给 {friend},所以不能和对方的 Bot 在同一个群里:群消息会发到对方的主页。', + 'that friend': '这位好友', + 'Copy invite link for {name}': '复制 {name} 的邀请链接', + 'Leave {name} out': '不拉 {name} 进群', + 'Invite link for {name} copied. Send it to your friend; once they accept, create the group again.': '{name} 的邀请链接已复制。发给好友,对方接受后再重新建群。', + 'Could not create the invite. Use Bot settings → Invite to this Bot.': '无法创建邀请。请在 Bot 设置里使用 Invite to this Bot。', 'Face-to-face group': '面对面建群', 'Meet the people next to you. Pick your Bots and type the same 6-digit number.': '和身边的人建群。选你的 Bot,输入同一组 6 位数字。', 'Add friend': '添加好友', diff --git a/core/src/client/new-chat.js b/core/src/client/new-chat.js index 1b6d3ed6c..dd3dcad75 100644 --- a/core/src/client/new-chat.js +++ b/core/src/client/new-chat.js @@ -7,7 +7,7 @@ import { CreateBot } from './create-bot.js' import { QUICK_EDGES, useFlip, useGlide, useMorph } from './motion.js' import { t } from './i18n.js' import { newChatActionRows } from './flybot-registry.js' -import { listedChatBots, pickerBotLabel, splitRoomPicks } from '../shared/remote-label.js' +import { listedChatBots, pickerBotLabel, splitRoomPicks, ungrantedLocalFix } from '../shared/remote-label.js' export function NewChat({ mode, roster, actions }) { const [query, setQuery] = useState('') @@ -20,6 +20,10 @@ export function NewChat({ mode, roster, actions }) { const [message, setMessage] = useState('') const [busy, setBusy] = useState(false) const [error, setError] = useState('') + // A group with a friend's Bot refused because one of my Bots is not shared with that friend: + // the Bots and friends named, so the error offers its two ways out (see ungrantedLocalFix). + const [fix, setFix] = useState(null) + const [fixNote, setFixNote] = useState('') const [cursor, setCursor] = useState(0) // The Create Bot form, opened straight from 'create' mode or from the list. const [draft, setDraft] = useState(mode === 'create' ? { name: '', fromList: false } : null) @@ -37,8 +41,35 @@ export function NewChat({ mode, roster, actions }) { const run = async (task) => { if (busy) return setError('') + setFix(null) + setFixNote('') setBusy(true) - try { await task() } catch (failure) { setError(failure?.message ?? String(failure)) } finally { setBusy(false) } + try { await task() } catch (failure) { + setError(failure?.message ?? String(failure)) + setFix(ungrantedLocalFix(failure?.details) ?? null) + } finally { setBusy(false) } + } + // Take a Bot out of the picks (the admin pick included) and clear the refusal. + const leaveOut = (id) => { + if (id === admin) setAdmin(null) + setPicked(current => current.filter(other => other !== id)) + setError('') + setFix(null) + setFixNote('') + } + // Sharing is the friend's click on an invite to that Bot (Bot Social makes the grant when they accept), + // so the fix is the Bot's invite link; the group is made again once they have accepted it. + const copyInvite = async (bot) => { + const social = globalThis.__flybot__?.social + try { + const invite = await social.createInvite(bot.id) + const link = invite?.link || invite?.url + if (!link) throw new Error('no link') + await navigator.clipboard?.writeText(link) + setFixNote(t('Invite link for {name} copied. Send it to your friend; once they accept, create the group again.', { name: bot.name })) + } catch { + setFixNote(t('Could not create the invite. Use Bot settings → Invite to this Bot.')) + } } const pick = (bot) => { setPicked(current => [...current, bot.id]) @@ -215,7 +246,18 @@ export function NewChat({ mode, roster, actions }) { : h('span', { className: 'bt-option-copy' }, h('span', { className: 'bt-option-label' }, option.label), h('span', { className: 'bt-option-sub' }, option.hint)), option.bot && option.hint && index === active ? h('span', { className: 'bt-option-hint' }, option.hint) : null)))), menuOnly ? null : h('div', { className: 'bt-nc-spacer' }), - error ? h('div', { className: 'bt-error' }, error) : null, + error && fix ? h('div', { className: 'bt-error bt-fix', role: 'alert', 'data-fix': 'ungranted-local' }, + h('div', null, fix.bots.length === 1 + ? t('{name} is not shared with {friend}, so it cannot be in a group with their Bot: the group\'s messages go to their home.', { name: fix.bots[0].name, friend: fix.friends.join(', ') || t('that friend') }) + : t('{name} are not shared with {friend}, so they cannot be in a group with their Bot: the group\'s messages go to their home.', { name: fix.bots.map(bot => bot.name).join(', '), friend: fix.friends.join(', ') || t('that friend') })), + h('div', { className: 'bt-fix-actions' }, fix.bots.flatMap(bot => [ + typeof globalThis.__flybot__?.social?.createInvite === 'function' && globalThis.__flybot__.social.canInvite?.(bot.id) !== false + ? h('button', { key: `invite-${bot.id}`, type: 'button', className: 'bt-fix-btn', 'data-fix-action': 'invite', onClick: () => { void copyInvite(bot) } }, t('Copy invite link for {name}', { name: bot.name })) + : null, + h('button', { key: `out-${bot.id}`, type: 'button', className: 'bt-fix-btn', 'data-fix-action': 'leave-out', onClick: () => leaveOut(bot.id) }, t('Leave {name} out', { name: bot.name })), + ])), + fixNote ? h('div', { className: 'bt-note', role: 'status' }, fixNote) : null) + : error ? h('div', { className: 'bt-error' }, error) : null, menuOnly ? null : h('div', { className: 'bt-nc-compose' }, h('div', { className: 'bt-nc-card' }, h('span', { className: 'bt-nc-plus', 'aria-hidden': true }, h(PlusIcon)), diff --git a/core/src/client/styles.js b/core/src/client/styles.js index 69894ead3..46779ed28 100644 --- a/core/src/client/styles.js +++ b/core/src/client/styles.js @@ -332,6 +332,9 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-send{height:40px;min-width:40px;border-radius:999px;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-size:14px;padding:0 14px;cursor:pointer} .bt-send:disabled{opacity:.35;cursor:default} .bt-error{padding:0 14px 10px;color:#e02135;font-size:12px} +.bt-fix-actions{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px} +.bt-fix-btn{font:inherit;font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid currentColor;background:transparent;color:inherit;cursor:pointer} +.bt-fix .bt-note{margin-top:6px} .bt-pane{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;z-index:45;pointer-events:auto;background:var(--bt-main);color:var(--bt-ink);display:flex;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} .bt-dialog{flex:1;display:flex;flex-direction:column;min-width:0} diff --git a/core/src/host/channel.js b/core/src/host/channel.js index 5500c8ed3..2ba21cc8f 100644 --- a/core/src/host/channel.js +++ b/core/src/host/channel.js @@ -27,7 +27,7 @@ export function install(rt) { rt.uiLanguage = () => (uiLocale === undefined ? 'English' : LANGUAGES[uiLocale.split('-')[0].toLowerCase()] ?? uiLocale) const ok = value => ({ ok: true, value }) - const fail = (message, code = 'bot/error') => ({ ok: false, error: { code, message, details: {} } }) + const fail = (message, code = 'bot/error', details = {}) => ({ ok: false, error: { code, message, details } }) // Models come from DSH, not state.json, so a provider added in Harness settings // changes the view without a new revision. const modelsKey = () => `${rt.cachedCatalog().map(entry => entry.reasoning ? `${entry.ref}:${JSON.stringify(entry.reasoning)}` : entry.ref).join(',')}|${rt.cachedDefault() ?? ''}` @@ -498,7 +498,9 @@ export function install(rt) { const code = errorCode(error) const message = error?.message ?? String(error) if (code === 'bot/internal') rt.noteFailure({ source: 'api', text: `${endpoint}: ${message}`, code: error?.code ?? error?.name, stack: error?.stack }) - return fail(message, code) + // A refusal may carry a plain JSON `details` object the client acts on; nothing else rides along. + const details = error?.details !== null && typeof error?.details === 'object' && !Array.isArray(error.details) ? error.details : {} + return fail(message, code, details) } } diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 5ddfdd36d..99bf06cce 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -464,11 +464,29 @@ export function install(rt) { return { ...line, who: name, ...(id ? { authorId: id, authorKind: 'human' } : {}) } } - const REMOTE_ROOM_MESSAGE = 'Only a Bot you granted (or one that joined with the room code) may be in a room with friends\' Bots' const hasRemote = room => currentParticipants(room).some(isRemoteParticipant) // Local members that may not stay in a room with remote participants or other owners. const unsharable = room => currentParticipants(room).filter(p => p.kind === 'bot' && !p.remote && !mayShareRoom(state, room, p.id)) + // Why a friend's Bot and a local Bot with no grant to that friend cannot share a group. + // A group with a friend's Bot is projected to the friend's home: its lines, and the replies of + // every local Bot that may speak there, are sent on (fanoutRoomLine). Only a Bot the owner + // shared with that friend (a per-Bot grant, or a room-code join) may be in it; the grant is made + // by the friend accepting an invite to that Bot, so this refusal names the Bot, the friend and the + // two ways out instead of a bare rule. `details` is for the client (an invite button), not trusted input. + function ungrantedRefusal(room, bots, extraRemote) { + const remotes = [ + ...currentParticipants(room).filter(isRemoteParticipant).map(p => ({ handle: p.handle || p.id, name: cleanPeerName(p.displayName, p.handle || p.id), owner: ownerNameSafe(p) })), + ...(extraRemote ? [{ handle: extraRemote.handle, name: cleanPeerName(extraRemote.name, extraRemote.handle), owner: cleanPeerName(extraRemote.homeName || extraRemote.owner || extraRemote.home, '') }] : []), + ].filter((p, i, all) => all.findIndex(other => other.handle === p.handle) === i) + const localBots = bots.map(id => ({ id, name: state.bots?.[id]?.name ?? id })) + const names = localBots.map(bot => bot.name).join(', ') + const friends = [...new Set(remotes.map(p => p.owner || p.name))].join(', ') || 'a friend' + const many = localBots.length > 1 + const message = `${names} ${many ? 'are' : 'is'} not shared with ${friends}, so ${many ? 'they' : 'it'} cannot be in a group with their Bot: the group's messages go to ${friends}'s home. Share ${many ? 'them' : 'it'} with ${friends} (Bot settings → Invite to this Bot), or leave ${many ? 'them' : 'it'} out.` + return Object.assign(forbidden(message), { details: { reason: 'ungranted-local', bots: localBots, remotes } }) + } + // `viaCode` (a room-code join, #83) is an internal option: it is never read from a request body. function assertAddable(room, draft, actor, { viaCode = false } = {}) { if (!KINDS.has(draft.kind)) throw invalid('Participant kind must be human or bot') @@ -476,7 +494,8 @@ export function install(rt) { const published = publishedRemoteOf(state, draft.handle || draft.id) if (!published) throw forbidden('Only a Bot granted to you may join a shared room') if (participantOf(room, 'bot', published.handle)) throw invalid('That participant is already in the room') - if (unsharable(room).length > 0) throw forbidden(REMOTE_ROOM_MESSAGE) + const blocked = unsharable(room) + if (blocked.length > 0) throw ungrantedRefusal(room, blocked.map(p => p.id), published) return published } if (typeof draft.id !== 'string' || draft.id === '') throw invalid('A participant needs an id') @@ -507,7 +526,7 @@ export function install(rt) { } throw forbidden('Only a granted Bot of a room member may join a shared room') } - if (hasRemote(room) && !viaCode && !mayShareRoom(state, room, draft.id)) throw forbidden(REMOTE_ROOM_MESSAGE) + if (hasRemote(room) && !viaCode && !mayShareRoom(state, room, draft.id)) throw ungrantedRefusal(room, [draft.id]) const nextOwners = new Set(humans) if (nextOwners.size > 1) { for (const p of currentParticipants(room).filter(item => item.kind === 'bot' && !item.remote)) { @@ -775,7 +794,10 @@ export function install(rt) { } } // A room with a friend's Bot takes only granted local Bots (a room code cannot be used here). - if (hasRemote(room) && unsharable(room).length > 0) throw forbidden(REMOTE_ROOM_MESSAGE) + if (hasRemote(room)) { + const blocked = unsharable(room) + if (blocked.length > 0) throw ungrantedRefusal(room, blocked.map(p => p.id)) + } syncMembers(room) return room } diff --git a/core/src/shared/remote-label.js b/core/src/shared/remote-label.js index a302cd980..03a375647 100644 --- a/core/src/shared/remote-label.js +++ b/core/src/shared/remote-label.js @@ -111,3 +111,17 @@ export function disambiguatedRemoteTitle(record, others = []) { const clash = others.some(item => item !== record && cleanPeerName(item?.displayName || item?.name) === name && ownerNameOf(item) !== ownerNameOf(record)) return clash ? title : title } + +// The host refuses a group that mixes a friend's Bot with one of my Bots that is not shared with +// that friend, and says which (`details.reason === 'ungranted-local'`). Read it defensively: it is +// a plain JSON object from the host, and the names inside are shown, never executed. +export function ungrantedLocalFix(details) { + if (details?.reason !== 'ungranted-local' || !Array.isArray(details.bots)) return undefined + const bots = details.bots + .filter(bot => typeof bot?.id === 'string' && bot.id !== '') + .map(bot => ({ id: bot.id, name: cleanPeerName(bot.name, bot.id) })) + if (bots.length === 0) return undefined + const friends = [...new Set((Array.isArray(details.remotes) ? details.remotes : []) + .map(remote => cleanPeerName(remote?.owner || remote?.name, '')).filter(Boolean))] + return { bots, friends } +} diff --git a/core/test/new-chat.test.js b/core/test/new-chat.test.js index 0ae82220d..4ad2723c8 100644 --- a/core/test/new-chat.test.js +++ b/core/test/new-chat.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { test } from 'node:test' -import { listedChatBots, pickerBotLabel, isHomeLevelRemote, isListedRemoteChat, splitRoomPicks } from '../src/shared/remote-label.js' +import { listedChatBots, pickerBotLabel, isHomeLevelRemote, isListedRemoteChat, splitRoomPicks, ungrantedLocalFix } from '../src/shared/remote-label.js' import { DICTIONARY } from '../src/client/i18n.js' test('picker hides gone, hidden, and home-level remotes; labels include the owner', () => { @@ -92,3 +92,30 @@ test('the sidebar + menu is a compact popover next to +, closed by a press outsi assert.match(styles, /\.bt-pane\[data-menu\]\{background:transparent\}/) assert.match(styles, /\.bt-pane\[data-menu\] \.bt-newchat\{flex:none;align-self:flex-start;width:min\(280px,/) }) + +test('ungrantedLocalFix reads the host refusal defensively', () => { + assert.equal(ungrantedLocalFix(undefined), undefined) + assert.equal(ungrantedLocalFix({}), undefined) + assert.equal(ungrantedLocalFix({ reason: 'other', bots: [{ id: 'a' }] }), undefined) + assert.equal(ungrantedLocalFix({ reason: 'ungranted-local', bots: [{ name: 'no id' }, null] }), undefined) + assert.deepEqual(ungrantedLocalFix({ + reason: 'ungranted-local', + bots: [{ id: 'dev', name: '程序员' }, { id: 'x', name: 'Evil`' }], + remotes: [{ handle: '@yi.home', name: "Yi's Bot", owner: 'Yi' }, { owner: 'Yi' }], + }), { bots: [{ id: 'dev', name: '程序员' }, { id: 'x', name: 'bEvil/b' }], friends: ['Yi'] }) +}) + +test('new-chat turns a refused group into a card with the invite and leave-out actions, never a dead end', () => { + const src = readFileSync(new URL('../src/client/new-chat.js', import.meta.url), 'utf8') + assert.match(src, /ungrantedLocalFix\(failure\?\.details\)/) + assert.match(src, /'data-fix-action': 'invite'/) + assert.match(src, /'data-fix-action': 'leave-out'/) + // The invite is the Bot's own invite: nothing is granted by this click (the friend accepts it). + assert.match(src, /social\.createInvite\(bot\.id\)/) + assert.doesNotMatch(src, /setGrants|grants\.set/) + const api = readFileSync(new URL('../src/client/api.js', import.meta.url), 'utf8') + assert.match(api, /details: result\.error\?\.details/) + for (const key of ['Copy invite link for {name}', 'Leave {name} out', 'that friend']) { + assert.ok(DICTIONARY.zh[key], `ZH entry for ${key}`) + } +}) diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index d3f0a9504..c67f6b11c 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -586,7 +586,7 @@ test('#90: an ungranted local Bot cannot be added to a room that has a friend\'s const { alice } = await signAliceAndBob(rt) const actorA = await rt.authenticate(alice.sessionToken) await withRemote(rt, actorA) - await assert.rejects(() => rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, actorA), /granted/) + await assert.rejects(() => rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, actorA), /Writer A is not shared with Bob/) assert.equal(rt.state.rooms.war.members.includes('spec-a'), false) await grantToFriend(rt, 'spec-a') await rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, actorA) @@ -602,7 +602,7 @@ test('#90: a friend\'s Bot cannot be added to a room that holds an ungranted loc publish(rt) await grantToFriend(rt, 'chief-a') await rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, actorA) - await assert.rejects(() => rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA), /granted/) + await assert.rejects(() => rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA), /Writer A is not shared with Bob/) assert.equal(rt.state.rooms.war.participants.some(p => p.remote), false) await grantToFriend(rt, 'spec-a') await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA) @@ -622,7 +622,7 @@ test('#51: creating a room with a friend\'s Bot and an unpublished member is ref { kind: 'bot', id: 'spec-a' }, { kind: 'bot', remote: true, handle: '@coder.bob.home' }, ] - assert.throws(() => rt.applyParticipants(room, drafts, actorA), /granted/) + assert.throws(() => rt.applyParticipants(room, drafts, actorA), /Writer A is not shared with Bob/) rt.state.grants = { ...(rt.state.grants ?? {}), 'spec-a': [{ id: 'spec-a:@bob.home', grantee: '@bob.home' }] } assert.doesNotThrow(() => rt.applyParticipants({ id: 'new', members: [], participants: [], createdAt: 1 }, drafts, actorA)) }) @@ -689,7 +689,7 @@ test('#51: a room code lets an unpublished Bot in, for that room only (internal const { alice } = await signAliceAndBob(rt) const actorA = await rt.authenticate(alice.sessionToken) await withRemote(rt, actorA) - await assert.rejects(() => rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a', viaCode: true }, actorA), /granted/) + await assert.rejects(() => rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a', viaCode: true }, actorA), /not shared with Bob/) await rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, actorA, { viaCode: true }) const entry = rt.state.rooms.war.participants.find(p => p.id === 'spec-a') assert.equal(entry.viaCode, true) @@ -860,3 +860,99 @@ test('#98: an inbound-share human line from the other owner is fenced', async (t assert.match(String(line.text), /^'s Chief` / ` · 's Bot`, ` · `, handle), local and friend's Bots told apart. `faceMentions().handles` is the handle of every Bot a line names (the explicit mention list a sender attaches, #293) and `mentionedByHandle` resolves a received list to my Bots (`known` counts handles the room has). | | `core/src/host/reach.js` | Per-Bot grants and the owner-policy `reach()` check that hooks.js runs outside the plugin chain (#90); `rt.setGrants`, `rt.hasGrant`, `rt.hasAnyGrant`. A local Bot refused toward a friend's Bot is told that it is not shared with that friend and where to share it (Sharing, #273). | | `core/src/shared/remote-label.js` | Owner-badge labels for a friend's Bot and “Shared room from …” titles. `isListedRemoteChat` / `listedChatBots` keep gone, hidden, and home-level (`@something.home`) remotes out of the sidebar, the **发给** picker, mentions, and `list_bots` friends lines; history stays on disk. | diff --git a/docs/subsystems/team.md b/docs/subsystems/team.md index 559ac4450..04b695e50 100644 --- a/docs/subsystems/team.md +++ b/docs/subsystems/team.md @@ -71,7 +71,7 @@ Central authorization in `core/src/host/authz.js`, installed after member-auth: Typed participants on a native room, in `core/src/host/room-membership.js`, installed after groups: - A room may carry `participants`: `{ kind: 'human'|'bot', id, role: 'admin'|'member', displayName, joinedSeq, joinedAt, leftSeq?, leftAt?, invitedBy? }`. A **granted remote Bot** is the same kind with `remote: true`, `handle`, `ownerHome`, `ownerName`, `isChief?`. `room.members` stays the current *local* bot ids so existing single-owner groups keep their shape. Rooms without `participants` stay legacy: the owner is implicit and `who: 'User'` is that owner. -- Across owners, a *local* agent must have a **person-grant** (#90; Invite to this Bot was accepted) or have joined this room by its room code (#83, this room only). An ungranted Bot cannot be added, and cannot be in a room that has another owner or a friend's Bot (enforced on the host, not only hidden in the UI; revoking the grant takes it out at once). A friend's Bot listed by `@flybot/social` `social/remote-bots` (Bots granted to me) may be added; ungranted specialists stay owner-private. +- Across owners, a *local* agent must have a **person-grant** (#90; Invite to this Bot was accepted) or have joined this room by its room code (#83, this room only). An ungranted Bot cannot be added, and cannot be in a room that has another owner or a friend's Bot (enforced on the host, not only hidden in the UI; revoking the grant takes it out at once). A friend's Bot listed by `@flybot/social` `social/remote-bots` (Bots granted to me) may be added; ungranted specialists stay owner-private. **Why the rule stands for a group (not only a 1:1 chat):** a group that holds a friend's Bot is projected to that friend's home. `fanoutRoomLine` sends its lines on, and the replies of every local Bot that may speak there; the friend's Bot's lines come back into the room that the local Bots read. A local Bot that is not shared with that friend would either be silently cut off from the friend (its lines are filtered, `mayShareRoom`) or be exposed to the friend's Bot's text — so the host refuses instead of half-including it, and the Bot Social server would refuse its messages anyway (no grant on its side). The refusal is not a dead end: it names the Bot and the friend (`details.reason: 'ungranted-local'`, `bots`, `remotes`, Bot names from local records, friend names through `cleanPeerName`) and the new-chat card offers **Copy invite link for {Bot}** (the Bot's own invite; the grant exists only after the friend accepts it) or **Leave {Bot} out**. Nothing is granted by the click, and no room-scoped grant is made for a plain group. - Every posted line carries `authorId` + `authorKind` + a display snapshot (`who`). The signed-in actor wins; `body.authorId` grants nothing. Peer names are sanitized (`cleanPeerName` / `cleanRemote`). - **History-visibility rule (membership window):** only a *current* participant — or the owner of an invited Bot — may read a room. They see lines with `seq >=` the `joinedSeq` of their open interval (for an invited owner, their Bot's interval). Leaving closes the interval and withdraws all history access. Rejoining opens a new interval at the then-current seq; prior intervals do not come back. Non-members cannot read or inject. - Endpoints: `room-history`, `room-post`; `create-room` / `update-room` accept `participants` / `addParticipants` / `removeParticipants`. `view()` presents each viewer under the table below. Inbound projections (`origin: 'inbound-share'`, `rt.recordInboundSharedRoom` / `applyInboundRoomShare`) are the remote owner's copy of a friend's room that invited their granted Bot, created from a `FLYBOT-ROOM-SHARE` envelope when the Bot is added (not only after the first chat line). diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 1ad80fa16..70f82c26d 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -115,10 +115,17 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js Add: '添加', Close: '关闭', Copy: '复制', -@@ -47,15 +47,45 @@ const ZH = { +@@ -47,15 +47,52 @@ const ZH = { 'Remove Main Bot role': '取消主 Bot', 'Bot settings': 'Bot 设置', 'Create group chat': '新建群聊', ++ '{name} is not shared with {friend}, so it cannot be in a group with their Bot: the group\'s messages go to their home.': '{name} 还没有共享给 {friend},所以不能和对方的 Bot 在同一个群里:群消息会发到对方的主页。', ++ '{name} are not shared with {friend}, so they cannot be in a group with their Bot: the group\'s messages go to their home.': '{name} 还没有共享给 {friend},所以不能和对方的 Bot 在同一个群里:群消息会发到对方的主页。', ++ 'that friend': '这位好友', ++ 'Copy invite link for {name}': '复制 {name} 的邀请链接', ++ 'Leave {name} out': '不拉 {name} 进群', ++ 'Invite link for {name} copied. Send it to your friend; once they accept, create the group again.': '{name} 的邀请链接已复制。发给好友,对方接受后再重新建群。', ++ 'Could not create the invite. Use Bot settings → Invite to this Bot.': '无法创建邀请。请在 Bot 设置里使用 Invite to this Bot。', + 'Face-to-face group': '面对面建群', + 'Meet the people next to you. Pick your Bots and type the same 6-digit number.': '和身边的人建群。选你的 Bot,输入同一组 6 位数字。', + 'Add friend': '添加好友', diff --git a/patches/ds-bot/0003-flybot-api-client.patch b/patches/ds-bot/0003-flybot-api-client.patch index 4b311d3a7..01d3d288d 100644 --- a/patches/ds-bot/0003-flybot-api-client.patch +++ b/patches/ds-bot/0003-flybot-api-client.patch @@ -766,12 +766,12 @@ diff --git a/src/client/index.js b/src/client/index.js diff --git a/src/client/new-chat.js b/src/client/new-chat.js --- a/src/client/new-chat.js +++ b/src/client/new-chat.js -@@ -6,11 +6,15 @@ import { useEscape, usePaneLeft } from './overlay-hooks.js' +@@ -6,16 +6,24 @@ import { useEscape, usePaneLeft } from './overlay-hooks.js' import { CreateBot } from './create-bot.js' import { QUICK_EDGES, useFlip, useGlide, useMorph } from './motion.js' import { t } from './i18n.js' +import { newChatActionRows } from './flybot-registry.js' -+import { listedChatBots, pickerBotLabel, splitRoomPicks } from '../shared/remote-label.js' ++import { listedChatBots, pickerBotLabel, splitRoomPicks, ungrantedLocalFix } from '../shared/remote-label.js' export function NewChat({ mode, roster, actions }) { const [query, setQuery] = useState('') @@ -782,7 +782,16 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js // A new group chat picks its admin first, then the other members. const [admin, setAdmin] = useState(null) const [message, setMessage] = useState('') -@@ -24,9 +24,11 @@ export function NewChat({ mode, roster, actions }) { + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') ++ // A group with a friend's Bot refused because one of my Bots is not shared with that friend: ++ // the Bots and friends named, so the error offers its two ways out (see ungrantedLocalFix). ++ const [fix, setFix] = useState(null) ++ const [fixNote, setFixNote] = useState('') + const [cursor, setCursor] = useState(0) + // The Create Bot form, opened straight from 'create' mode or from the list. + const [draft, setDraft] = useState(mode === 'create' ? { name: '', fromList: false } : null) +@@ -24,15 +24,44 @@ export function NewChat({ mode, roster, actions }) { useEscape(close) const paneLeft = usePaneLeft() const wanted = query.trim().toLowerCase() @@ -796,6 +805,40 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js const pickedBots = picked.map(id => roster.byId[id]).filter(Boolean) const run = async (task) => { if (busy) return + setError('') ++ setFix(null) ++ setFixNote('') + setBusy(true) +- try { await task() } catch (failure) { setError(failure?.message ?? String(failure)) } finally { setBusy(false) } ++ try { await task() } catch (failure) { ++ setError(failure?.message ?? String(failure)) ++ setFix(ungrantedLocalFix(failure?.details) ?? null) ++ } finally { setBusy(false) } ++ } ++ // Take a Bot out of the picks (the admin pick included) and clear the refusal. ++ const leaveOut = (id) => { ++ if (id === admin) setAdmin(null) ++ setPicked(current => current.filter(other => other !== id)) ++ setError('') ++ setFix(null) ++ setFixNote('') ++ } ++ // Sharing is the friend's click on an invite to that Bot (Bot Social makes the grant when they accept), ++ // so the fix is the Bot's invite link; the group is made again once they have accepted it. ++ const copyInvite = async (bot) => { ++ const social = globalThis.__flybot__?.social ++ try { ++ const invite = await social.createInvite(bot.id) ++ const link = invite?.link || invite?.url ++ if (!link) throw new Error('no link') ++ await navigator.clipboard?.writeText(link) ++ setFixNote(t('Invite link for {name} copied. Send it to your friend; once they accept, create the group again.', { name: bot.name })) ++ } catch { ++ setFixNote(t('Could not create the invite. Use Bot settings → Invite to this Bot.')) ++ } + } + const pick = (bot) => { + setPicked(current => [...current, bot.id]) @@ -71,7 +71,12 @@ export function NewChat({ mode, roster, actions }) { const makeGroup = async () => { if (admin === null || picked.length === 0) return @@ -886,13 +929,15 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js h('span', { className: 'bt-to-label' }, t('To:')), adminBot ? h('span', { key: adminBot.id, className: 'bt-token bt-token-admin', title: t('Admin') }, h(BotMark, { bot: adminBot, size: 16 }), adminBot.name, h(AdminStar), -@@ -162,11 +162,16 @@ export function NewChat({ mode, roster, actions }) { +@@ -162,11 +162,27 @@ export function NewChat({ mode, roster, actions }) { onMouseEnter: () => setCursor(index), onClick: option.run, }, option.bot ? h(BotMark, { bot: option.bot, size: 22 }) : h('span', { className: 'bt-option-icon' }, option.icon), - h('span', { className: 'bt-option-label' }, option.label), - option.hint && index === active ? h('span', { className: 'bt-option-hint' }, option.hint) : null)))), - h('div', { className: 'bt-nc-spacer' }), +- error ? h('div', { className: 'bt-error' }, error) : null, +- h('div', { className: 'bt-nc-compose' }, + // An action row's description sits under its title and is always there (#283): a long line + // beside the title squeezed the title to nothing on hover, and a line that appears on hover moves the rows. + // A Bot row's short hint stays beside the name and shows on the active row only. @@ -901,8 +946,18 @@ diff --git a/src/client/new-chat.js b/src/client/new-chat.js + : h('span', { className: 'bt-option-copy' }, h('span', { className: 'bt-option-label' }, option.label), h('span', { className: 'bt-option-sub' }, option.hint)), + option.bot && option.hint && index === active ? h('span', { className: 'bt-option-hint' }, option.hint) : null)))), + menuOnly ? null : h('div', { className: 'bt-nc-spacer' }), - error ? h('div', { className: 'bt-error' }, error) : null, -- h('div', { className: 'bt-nc-compose' }, ++ error && fix ? h('div', { className: 'bt-error bt-fix', role: 'alert', 'data-fix': 'ungranted-local' }, ++ h('div', null, fix.bots.length === 1 ++ ? t('{name} is not shared with {friend}, so it cannot be in a group with their Bot: the group\'s messages go to their home.', { name: fix.bots[0].name, friend: fix.friends.join(', ') || t('that friend') }) ++ : t('{name} are not shared with {friend}, so they cannot be in a group with their Bot: the group\'s messages go to their home.', { name: fix.bots.map(bot => bot.name).join(', '), friend: fix.friends.join(', ') || t('that friend') })), ++ h('div', { className: 'bt-fix-actions' }, fix.bots.flatMap(bot => [ ++ typeof globalThis.__flybot__?.social?.createInvite === 'function' && globalThis.__flybot__.social.canInvite?.(bot.id) !== false ++ ? h('button', { key: `invite-${bot.id}`, type: 'button', className: 'bt-fix-btn', 'data-fix-action': 'invite', onClick: () => { void copyInvite(bot) } }, t('Copy invite link for {name}', { name: bot.name })) ++ : null, ++ h('button', { key: `out-${bot.id}`, type: 'button', className: 'bt-fix-btn', 'data-fix-action': 'leave-out', onClick: () => leaveOut(bot.id) }, t('Leave {name} out', { name: bot.name })), ++ ])), ++ fixNote ? h('div', { className: 'bt-note', role: 'status' }, fixNote) : null) ++ : error ? h('div', { className: 'bt-error' }, error) : null, + menuOnly ? null : h('div', { className: 'bt-nc-compose' }, h('div', { className: 'bt-nc-card' }, h('span', { className: 'bt-nc-plus', 'aria-hidden': true }, h(PlusIcon)), @@ -1416,6 +1471,16 @@ diff --git a/src/client/styles.js b/src/client/styles.js .bt-option-icon{width:22px;height:22px;border-radius:50%;background:var(--bt-hover);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;flex:none} .bt-hint{padding:14px;text-align:center;font-size:13px;color:var(--bt-ink-2)} .bt-nc-spacer{flex:1} +@@ -309,6 +309,9 @@ button.bt-q-opt:active{background:var(--bt-active)} + .bt-send{height:40px;min-width:40px;border-radius:999px;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-size:14px;padding:0 14px;cursor:pointer} + .bt-send:disabled{opacity:.35;cursor:default} + .bt-error{padding:0 14px 10px;color:#e02135;font-size:12px} ++.bt-fix-actions{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px} ++.bt-fix-btn{font:inherit;font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid currentColor;background:transparent;color:inherit;cursor:pointer} ++.bt-fix .bt-note{margin-top:6px} + + .bt-pane{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;z-index:45;pointer-events:auto;background:var(--bt-main);color:var(--bt-ink);display:flex;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} + .bt-dialog{flex:1;display:flex;flex-direction:column;min-width:0} @@ -345,6 +345,8 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-drawer-id{display:flex;flex-direction:column;align-items:center;padding:14px 16px 0} .bt-drawer-id>.bt-mark,.bt-drawer-id>.bt-cluster{margin-bottom:16px} diff --git a/patches/ds-bot/0005-social-seams.patch b/patches/ds-bot/0005-social-seams.patch index b679ff372..4716b6f3f 100644 --- a/patches/ds-bot/0005-social-seams.patch +++ b/patches/ds-bot/0005-social-seams.patch @@ -163,7 +163,8 @@ diff --git a/src/host/channel.js b/src/host/channel.js + rt.uiLanguage = () => (uiLocale === undefined ? 'English' : LANGUAGES[uiLocale.split('-')[0].toLowerCase()] ?? uiLocale) const ok = value => ({ ok: true, value }) - const fail = (message, code = 'bot/error') => ({ ok: false, error: { code, message, details: {} } }) +- const fail = (message, code = 'bot/error') => ({ ok: false, error: { code, message, details: {} } }) ++ const fail = (message, code = 'bot/error', details = {}) => ({ ok: false, error: { code, message, details } }) // Models come from DSH, not state.json, so a provider added in Harness settings // changes the view without a new revision. const modelsKey = () => `${rt.cachedCatalog().map(entry => entry.reasoning ? `${entry.ref}:${JSON.stringify(entry.reasoning)}` : entry.ref).join(',')}|${rt.cachedDefault() ?? ''}` @@ -442,7 +443,7 @@ diff --git a/src/host/channel.js b/src/host/channel.js await rt.ensureCapabilities(sessionId) await ctx.sessionController.prompt({ requestId: randomUUID(), -@@ -319,7 +319,53 @@ export function install(rt) { +@@ -319,32 +319,112 @@ export function install(rt) { case 'diagnostics-clear': await rt.diagnostics.clear() return ok(true) @@ -496,7 +497,14 @@ diff --git a/src/host/channel.js b/src/host/channel.js return fail(`Unknown endpoint ${endpoint}`, 'bot/not-found') } } catch (error) { -@@ -331,20 +331,52 @@ export function install(rt) { + const code = errorCode(error) + const message = error?.message ?? String(error) + if (code === 'bot/internal') rt.noteFailure({ source: 'api', text: `${endpoint}: ${message}`, code: error?.code ?? error?.name, stack: error?.stack }) +- return fail(message, code) ++ // A refusal may carry a plain JSON `details` object the client acts on; nothing else rides along. ++ const details = error?.details !== null && typeof error?.details === 'object' && !Array.isArray(error.details) ? error.details : {} ++ return fail(message, code, details) + } } // An exact Fetch route on the shared /api channel; the browser authenticates with its session cookie. diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index e0b68fcba..10c1410f8 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -25,10 +25,17 @@ diff --git a/client.js b/client.js Add: "添加", Close: "关闭", Copy: "复制", -@@ -84,14 +84,44 @@ var ZH = { +@@ -84,14 +84,51 @@ var ZH = { "Remove Main Bot role": "取消主 Bot", "Bot settings": "Bot 设置", "Create group chat": "新建群聊", ++ "{name} is not shared with {friend}, so it cannot be in a group with their Bot: the group's messages go to their home.": "{name} 还没有共享给 {friend},所以不能和对方的 Bot 在同一个群里:群消息会发到对方的主页。", ++ "{name} are not shared with {friend}, so they cannot be in a group with their Bot: the group's messages go to their home.": "{name} 还没有共享给 {friend},所以不能和对方的 Bot 在同一个群里:群消息会发到对方的主页。", ++ "that friend": "这位好友", ++ "Copy invite link for {name}": "复制 {name} 的邀请链接", ++ "Leave {name} out": "不拉 {name} 进群", ++ "Invite link for {name} copied. Send it to your friend; once they accept, create the group again.": "{name} 的邀请链接已复制。发给好友,对方接受后再重新建群。", ++ "Could not create the invite. Use Bot settings → Invite to this Bot.": "无法创建邀请。请在 Bot 设置里使用 Invite to this Bot。", + "Face-to-face group": "面对面建群", + "Meet the people next to you. Pick your Bots and type the same 6-digit number.": "和身边的人建群。选你的 Bot,输入同一组 6 位数字。", + "Add friend": "添加好友", @@ -298,7 +305,16 @@ diff --git a/client.js b/client.js body: JSON.stringify({ endpoint, payload: payload ?? null }), signal }); -@@ -1097,6 +1097,288 @@ var call = async (endpoint, payload, signal) => { +@@ -1087,7 +1087,7 @@ var call = async (endpoint, payload, signal) => { + if (!result.ok) { + const code = result.error?.code ?? "bot/error"; + const message = hostText(result.error?.message ?? "Request failed"); +- const error = Object.assign(new Error(code === "bot/internal" ? `${message} ${t("Settings → Feedback has the details.")}` : message), { code }); ++ const error = Object.assign(new Error(code === "bot/internal" ? `${message} ${t("Settings → Feedback has the details.")}` : message), { code, details: result.error?.details }); + if (!REFUSALS.has(code)) { + noteClientError(endpoint, Object.assign(new Error(result.error?.message ?? "Request failed"), { code })); + markNoted(error); +@@ -1097,6 +1097,295 @@ var call = async (endpoint, payload, signal) => { return result.value; }; @@ -396,6 +412,13 @@ diff --git a/client.js b/client.js + const clash = others.some((item) => item !== record && cleanPeerName(item?.displayName || item?.name) === name && ownerNameOf(item) !== ownerNameOf(record)); + return clash ? title : title; +} ++function ungrantedLocalFix(details) { ++ if (details?.reason !== "ungranted-local" || !Array.isArray(details.bots)) return void 0; ++ const bots = details.bots.filter((bot) => typeof bot?.id === "string" && bot.id !== "").map((bot) => ({ id: bot.id, name: cleanPeerName(bot.name, bot.id) })); ++ if (bots.length === 0) return void 0; ++ const friends = [...new Set((Array.isArray(details.remotes) ? details.remotes : []).map((remote) => cleanPeerName(remote?.owner || remote?.name, "")).filter(Boolean))]; ++ return { bots, friends }; ++} + +// src/client/flybot-registry.js +var SLOTS = ["flybot.sidebar.section", "flybot.sidebar.structure", "flybot.composer.mention", "flybot.bot.details", "flybot.approvals", "flybot.room.overview", "flybot.session.dock", "flybot.new-chat.action", "flybot.account.header"]; @@ -1437,6 +1460,16 @@ diff --git a/client.js b/client.js .bt-option-icon{width:22px;height:22px;border-radius:50%;background:var(--bt-hover);color:var(--bt-ink-2);display:inline-flex;align-items:center;justify-content:center;flex:none} .bt-hint{padding:14px;text-align:center;font-size:13px;color:var(--bt-ink-2)} .bt-nc-spacer{flex:1} +@@ -2127,6 +2127,9 @@ button.bt-q-opt:active{background:var(--bt-active)} + .bt-send{height:40px;min-width:40px;border-radius:999px;border:0;background:var(--bt-accent);color:var(--bt-accent-ink,#fff);font-size:14px;padding:0 14px;cursor:pointer} + .bt-send:disabled{opacity:.35;cursor:default} + .bt-error{padding:0 14px 10px;color:#e02135;font-size:12px} ++.bt-fix-actions{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px} ++.bt-fix-btn{font:inherit;font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid currentColor;background:transparent;color:inherit;cursor:pointer} ++.bt-fix .bt-note{margin-top:6px} + + .bt-pane{position:fixed;top:var(--dsh-frame-chrome-top,0px);right:0;bottom:0;z-index:45;pointer-events:auto;background:var(--bt-main);color:var(--bt-ink);display:flex;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif} + .bt-dialog{flex:1;display:flex;flex-direction:column;min-width:0} @@ -2163,6 +2163,8 @@ button.bt-q-opt:active{background:var(--bt-active)} .bt-drawer-id{display:flex;flex-direction:column;align-items:center;padding:14px 16px 0} .bt-drawer-id>.bt-mark,.bt-drawer-id>.bt-cluster{margin-bottom:16px} @@ -3266,8 +3299,7 @@ diff --git a/client.js b/client.js if (reply.kind === "stopped") { - return (0, import_react6.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); + return (0, import_react7.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("You stopped {name}'s work", { name: bot.name })); - } -- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); ++ } + if (reply.kind === "notice" && room2?.origin === "face-to-face" && String(reply.text ?? "").trim() === WAITING_OTHERS_NOTICE) return null; + if (reply.kind === "notice" || reply.kind === "joined" || reply.kind === "left" || reply.kind === "started") { + return (0, import_react7.createElement)("div", { key: `notice-${index}`, className: "bt-event", "data-flybot": "face-notice" }, remoteMessageText(reply.text)); @@ -3284,7 +3316,8 @@ diff --git a/client.js b/client.js + msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, + useUi + }); -+ } + } +- return (0, import_react6.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); + return (0, import_react7.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi }); }), pending.map((id) => { @@ -6769,7 +6802,7 @@ diff --git a/client.js b/client.js className: "bt-input", autoFocus: !inline, value: name, -@@ -8284,62 +8284,66 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr +@@ -8284,75 +8284,103 @@ function CreateBot({ roster, actions, start, brief = "", onBack, canGoBack, onCr } } }), @@ -6848,6 +6881,8 @@ diff --git a/client.js b/client.js + const [message, setMessage] = (0, import_react21.useState)(""); + const [busy, setBusy] = (0, import_react21.useState)(false); + const [error, setError] = (0, import_react21.useState)(""); ++ const [fix, setFix] = (0, import_react21.useState)(null); ++ const [fixNote, setFixNote] = (0, import_react21.useState)(""); + const [cursor, setCursor] = (0, import_react21.useState)(0); + const [draft, setDraft] = (0, import_react21.useState)(mode === "create" ? { name: "", fromList: false } : null); + const inputRef = (0, import_react21.useRef)(null); @@ -6865,6 +6900,41 @@ diff --git a/client.js b/client.js const pickedBots = picked.map((id) => roster.byId[id]).filter(Boolean); const run = async (task) => { if (busy) return; + setError(""); ++ setFix(null); ++ setFixNote(""); + setBusy(true); + try { + await task(); + } catch (failure) { + setError(failure?.message ?? String(failure)); ++ setFix(ungrantedLocalFix(failure?.details) ?? null); + } finally { + setBusy(false); + } + }; ++ const leaveOut = (id) => { ++ if (id === admin) setAdmin(null); ++ setPicked((current) => current.filter((other) => other !== id)); ++ setError(""); ++ setFix(null); ++ setFixNote(""); ++ }; ++ const copyInvite = async (bot) => { ++ const social = globalThis.__flybot__?.social; ++ try { ++ const invite = await social.createInvite(bot.id); ++ const link = invite?.link || invite?.url; ++ if (!link) throw new Error("no link"); ++ await navigator.clipboard?.writeText(link); ++ setFixNote(t("Invite link for {name} copied. Send it to your friend; once they accept, create the group again.", { name: bot.name })); ++ } catch { ++ setFixNote(t("Could not create the invite. Use Bot settings → Invite to this Bot.")); ++ } ++ }; + const pick2 = (bot) => { + setPicked((current) => [...current, bot.id]); + setQuery(""); @@ -8392,7 +8392,12 @@ function NewChat({ mode, roster, actions }) { const makeGroup = async () => { if (admin === null || picked.length === 0) return; @@ -7045,7 +7115,7 @@ diff --git a/client.js b/client.js "button", { key: option.key, -@@ -8542,21 +8542,24 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); +@@ -8542,21 +8542,35 @@ ${listed}`, { axis: "y", ...QUICK_EDGES }); onMouseEnter: () => setCursor(index), onClick: option.run }, @@ -7064,7 +7134,18 @@ diff --git a/client.js b/client.js - error ? (0, import_react20.createElement)("div", { className: "bt-error" }, error) : null, - (0, import_react20.createElement)( + menuOnly ? null : (0, import_react21.createElement)("div", { className: "bt-nc-spacer" }), -+ error ? (0, import_react21.createElement)("div", { className: "bt-error" }, error) : null, ++ error && fix ? (0, import_react21.createElement)( ++ "div", ++ { className: "bt-error bt-fix", role: "alert", "data-fix": "ungranted-local" }, ++ (0, import_react21.createElement)("div", null, fix.bots.length === 1 ? t("{name} is not shared with {friend}, so it cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots[0].name, friend: fix.friends.join(", ") || t("that friend") }) : t("{name} are not shared with {friend}, so they cannot be in a group with their Bot: the group's messages go to their home.", { name: fix.bots.map((bot) => bot.name).join(", "), friend: fix.friends.join(", ") || t("that friend") })), ++ (0, import_react21.createElement)("div", { className: "bt-fix-actions" }, fix.bots.flatMap((bot) => [ ++ typeof globalThis.__flybot__?.social?.createInvite === "function" && globalThis.__flybot__.social.canInvite?.(bot.id) !== false ? (0, import_react21.createElement)("button", { key: `invite-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "invite", onClick: () => { ++ void copyInvite(bot); ++ } }, t("Copy invite link for {name}", { name: bot.name })) : null, ++ (0, import_react21.createElement)("button", { key: `out-${bot.id}`, type: "button", className: "bt-fix-btn", "data-fix-action": "leave-out", onClick: () => leaveOut(bot.id) }, t("Leave {name} out", { name: bot.name })) ++ ])), ++ fixNote ? (0, import_react21.createElement)("div", { className: "bt-note", role: "status" }, fixNote) : null ++ ) : error ? (0, import_react21.createElement)("div", { className: "bt-error" }, error) : null, + menuOnly ? null : (0, import_react21.createElement)( "div", { className: "bt-nc-compose" }, diff --git a/patches/ds-bot/0012-room-membership.patch b/patches/ds-bot/0012-room-membership.patch index 330797548..d664ff312 100644 --- a/patches/ds-bot/0012-room-membership.patch +++ b/patches/ds-bot/0012-room-membership.patch @@ -20,6 +20,15 @@ diff --git a/src/client/api.js b/src/client/api.js body: JSON.stringify({ endpoint, payload: payload ?? null }), signal, }) +@@ -31,7 +31,7 @@ export const call = async (endpoint, payload, signal) => { + if (!result.ok) { + const code = result.error?.code ?? 'bot/error' + const message = hostText(result.error?.message ?? 'Request failed') +- const error = Object.assign(new Error(code === 'bot/internal' ? `${message} ${t('Settings → Feedback has the details.')}` : message), { code }) ++ const error = Object.assign(new Error(code === 'bot/internal' ? `${message} ${t('Settings → Feedback has the details.')}` : message), { code, details: result.error?.details }) + if (!REFUSALS.has(code)) { + noteClientError(endpoint, Object.assign(new Error(result.error?.message ?? 'Request failed'), { code })) + markNoted(error) diff --git a/src/client/composer.js b/src/client/composer.js --- a/src/client/composer.js +++ b/src/client/composer.js diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index 5ddfdd36d..99bf06cce 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -464,11 +464,29 @@ export function install(rt) { return { ...line, who: name, ...(id ? { authorId: id, authorKind: 'human' } : {}) } } - const REMOTE_ROOM_MESSAGE = 'Only a Bot you granted (or one that joined with the room code) may be in a room with friends\' Bots' const hasRemote = room => currentParticipants(room).some(isRemoteParticipant) // Local members that may not stay in a room with remote participants or other owners. const unsharable = room => currentParticipants(room).filter(p => p.kind === 'bot' && !p.remote && !mayShareRoom(state, room, p.id)) + // Why a friend's Bot and a local Bot with no grant to that friend cannot share a group. + // A group with a friend's Bot is projected to the friend's home: its lines, and the replies of + // every local Bot that may speak there, are sent on (fanoutRoomLine). Only a Bot the owner + // shared with that friend (a per-Bot grant, or a room-code join) may be in it; the grant is made + // by the friend accepting an invite to that Bot, so this refusal names the Bot, the friend and the + // two ways out instead of a bare rule. `details` is for the client (an invite button), not trusted input. + function ungrantedRefusal(room, bots, extraRemote) { + const remotes = [ + ...currentParticipants(room).filter(isRemoteParticipant).map(p => ({ handle: p.handle || p.id, name: cleanPeerName(p.displayName, p.handle || p.id), owner: ownerNameSafe(p) })), + ...(extraRemote ? [{ handle: extraRemote.handle, name: cleanPeerName(extraRemote.name, extraRemote.handle), owner: cleanPeerName(extraRemote.homeName || extraRemote.owner || extraRemote.home, '') }] : []), + ].filter((p, i, all) => all.findIndex(other => other.handle === p.handle) === i) + const localBots = bots.map(id => ({ id, name: state.bots?.[id]?.name ?? id })) + const names = localBots.map(bot => bot.name).join(', ') + const friends = [...new Set(remotes.map(p => p.owner || p.name))].join(', ') || 'a friend' + const many = localBots.length > 1 + const message = `${names} ${many ? 'are' : 'is'} not shared with ${friends}, so ${many ? 'they' : 'it'} cannot be in a group with their Bot: the group's messages go to ${friends}'s home. Share ${many ? 'them' : 'it'} with ${friends} (Bot settings → Invite to this Bot), or leave ${many ? 'them' : 'it'} out.` + return Object.assign(forbidden(message), { details: { reason: 'ungranted-local', bots: localBots, remotes } }) + } + // `viaCode` (a room-code join, #83) is an internal option: it is never read from a request body. function assertAddable(room, draft, actor, { viaCode = false } = {}) { if (!KINDS.has(draft.kind)) throw invalid('Participant kind must be human or bot') @@ -476,7 +494,8 @@ export function install(rt) { const published = publishedRemoteOf(state, draft.handle || draft.id) if (!published) throw forbidden('Only a Bot granted to you may join a shared room') if (participantOf(room, 'bot', published.handle)) throw invalid('That participant is already in the room') - if (unsharable(room).length > 0) throw forbidden(REMOTE_ROOM_MESSAGE) + const blocked = unsharable(room) + if (blocked.length > 0) throw ungrantedRefusal(room, blocked.map(p => p.id), published) return published } if (typeof draft.id !== 'string' || draft.id === '') throw invalid('A participant needs an id') @@ -507,7 +526,7 @@ export function install(rt) { } throw forbidden('Only a granted Bot of a room member may join a shared room') } - if (hasRemote(room) && !viaCode && !mayShareRoom(state, room, draft.id)) throw forbidden(REMOTE_ROOM_MESSAGE) + if (hasRemote(room) && !viaCode && !mayShareRoom(state, room, draft.id)) throw ungrantedRefusal(room, [draft.id]) const nextOwners = new Set(humans) if (nextOwners.size > 1) { for (const p of currentParticipants(room).filter(item => item.kind === 'bot' && !item.remote)) { @@ -775,7 +794,10 @@ export function install(rt) { } } // A room with a friend's Bot takes only granted local Bots (a room code cannot be used here). - if (hasRemote(room) && unsharable(room).length > 0) throw forbidden(REMOTE_ROOM_MESSAGE) + if (hasRemote(room)) { + const blocked = unsharable(room) + if (blocked.length > 0) throw ungrantedRefusal(room, blocked.map(p => p.id)) + } syncMembers(room) return room } diff --git a/patches/ds-bot/files/src/shared/remote-label.js b/patches/ds-bot/files/src/shared/remote-label.js index a302cd980..03a375647 100644 --- a/patches/ds-bot/files/src/shared/remote-label.js +++ b/patches/ds-bot/files/src/shared/remote-label.js @@ -111,3 +111,17 @@ export function disambiguatedRemoteTitle(record, others = []) { const clash = others.some(item => item !== record && cleanPeerName(item?.displayName || item?.name) === name && ownerNameOf(item) !== ownerNameOf(record)) return clash ? title : title } + +// The host refuses a group that mixes a friend's Bot with one of my Bots that is not shared with +// that friend, and says which (`details.reason === 'ungranted-local'`). Read it defensively: it is +// a plain JSON object from the host, and the names inside are shown, never executed. +export function ungrantedLocalFix(details) { + if (details?.reason !== 'ungranted-local' || !Array.isArray(details.bots)) return undefined + const bots = details.bots + .filter(bot => typeof bot?.id === 'string' && bot.id !== '') + .map(bot => ({ id: bot.id, name: cleanPeerName(bot.name, bot.id) })) + if (bots.length === 0) return undefined + const friends = [...new Set((Array.isArray(details.remotes) ? details.remotes : []) + .map(remote => cleanPeerName(remote?.owner || remote?.name, '')).filter(Boolean))] + return { bots, friends } +} diff --git a/patches/ds-bot/files/test/new-chat.test.js b/patches/ds-bot/files/test/new-chat.test.js index 0ae82220d..4ad2723c8 100644 --- a/patches/ds-bot/files/test/new-chat.test.js +++ b/patches/ds-bot/files/test/new-chat.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { test } from 'node:test' -import { listedChatBots, pickerBotLabel, isHomeLevelRemote, isListedRemoteChat, splitRoomPicks } from '../src/shared/remote-label.js' +import { listedChatBots, pickerBotLabel, isHomeLevelRemote, isListedRemoteChat, splitRoomPicks, ungrantedLocalFix } from '../src/shared/remote-label.js' import { DICTIONARY } from '../src/client/i18n.js' test('picker hides gone, hidden, and home-level remotes; labels include the owner', () => { @@ -92,3 +92,30 @@ test('the sidebar + menu is a compact popover next to +, closed by a press outsi assert.match(styles, /\.bt-pane\[data-menu\]\{background:transparent\}/) assert.match(styles, /\.bt-pane\[data-menu\] \.bt-newchat\{flex:none;align-self:flex-start;width:min\(280px,/) }) + +test('ungrantedLocalFix reads the host refusal defensively', () => { + assert.equal(ungrantedLocalFix(undefined), undefined) + assert.equal(ungrantedLocalFix({}), undefined) + assert.equal(ungrantedLocalFix({ reason: 'other', bots: [{ id: 'a' }] }), undefined) + assert.equal(ungrantedLocalFix({ reason: 'ungranted-local', bots: [{ name: 'no id' }, null] }), undefined) + assert.deepEqual(ungrantedLocalFix({ + reason: 'ungranted-local', + bots: [{ id: 'dev', name: '程序员' }, { id: 'x', name: 'Evil`' }], + remotes: [{ handle: '@yi.home', name: "Yi's Bot", owner: 'Yi' }, { owner: 'Yi' }], + }), { bots: [{ id: 'dev', name: '程序员' }, { id: 'x', name: 'bEvil/b' }], friends: ['Yi'] }) +}) + +test('new-chat turns a refused group into a card with the invite and leave-out actions, never a dead end', () => { + const src = readFileSync(new URL('../src/client/new-chat.js', import.meta.url), 'utf8') + assert.match(src, /ungrantedLocalFix\(failure\?\.details\)/) + assert.match(src, /'data-fix-action': 'invite'/) + assert.match(src, /'data-fix-action': 'leave-out'/) + // The invite is the Bot's own invite: nothing is granted by this click (the friend accepts it). + assert.match(src, /social\.createInvite\(bot\.id\)/) + assert.doesNotMatch(src, /setGrants|grants\.set/) + const api = readFileSync(new URL('../src/client/api.js', import.meta.url), 'utf8') + assert.match(api, /details: result\.error\?\.details/) + for (const key of ['Copy invite link for {name}', 'Leave {name} out', 'that friend']) { + assert.ok(DICTIONARY.zh[key], `ZH entry for ${key}`) + } +}) diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index d3f0a9504..c67f6b11c 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -586,7 +586,7 @@ test('#90: an ungranted local Bot cannot be added to a room that has a friend\'s const { alice } = await signAliceAndBob(rt) const actorA = await rt.authenticate(alice.sessionToken) await withRemote(rt, actorA) - await assert.rejects(() => rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, actorA), /granted/) + await assert.rejects(() => rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, actorA), /Writer A is not shared with Bob/) assert.equal(rt.state.rooms.war.members.includes('spec-a'), false) await grantToFriend(rt, 'spec-a') await rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, actorA) @@ -602,7 +602,7 @@ test('#90: a friend\'s Bot cannot be added to a room that holds an ungranted loc publish(rt) await grantToFriend(rt, 'chief-a') await rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, actorA) - await assert.rejects(() => rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA), /granted/) + await assert.rejects(() => rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA), /Writer A is not shared with Bob/) assert.equal(rt.state.rooms.war.participants.some(p => p.remote), false) await grantToFriend(rt, 'spec-a') await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA) @@ -622,7 +622,7 @@ test('#51: creating a room with a friend\'s Bot and an unpublished member is ref { kind: 'bot', id: 'spec-a' }, { kind: 'bot', remote: true, handle: '@coder.bob.home' }, ] - assert.throws(() => rt.applyParticipants(room, drafts, actorA), /granted/) + assert.throws(() => rt.applyParticipants(room, drafts, actorA), /Writer A is not shared with Bob/) rt.state.grants = { ...(rt.state.grants ?? {}), 'spec-a': [{ id: 'spec-a:@bob.home', grantee: '@bob.home' }] } assert.doesNotThrow(() => rt.applyParticipants({ id: 'new', members: [], participants: [], createdAt: 1 }, drafts, actorA)) }) @@ -689,7 +689,7 @@ test('#51: a room code lets an unpublished Bot in, for that room only (internal const { alice } = await signAliceAndBob(rt) const actorA = await rt.authenticate(alice.sessionToken) await withRemote(rt, actorA) - await assert.rejects(() => rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a', viaCode: true }, actorA), /granted/) + await assert.rejects(() => rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a', viaCode: true }, actorA), /not shared with Bob/) await rt.addRoomParticipant('war', { kind: 'bot', id: 'spec-a' }, actorA, { viaCode: true }) const entry = rt.state.rooms.war.participants.find(p => p.id === 'spec-a') assert.equal(entry.viaCode, true) @@ -860,3 +860,99 @@ test('#98: an inbound-share human line from the other owner is fenced', async (t assert.match(String(line.text), /^