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/group-friend-bot-refusal.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
56 changes: 54 additions & 2 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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": "添加好友",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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"];
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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);
Expand All @@ -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("");
Expand Down Expand Up @@ -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" },
Expand Down
2 changes: 1 addition & 1 deletion core/src/client/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions core/src/client/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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': '添加好友',
Expand Down
48 changes: 45 additions & 3 deletions core/src/client/new-chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('')
Expand All @@ -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)
Expand All @@ -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 }))
Comment on lines +68 to +69

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat an unavailable clipboard as a copy failure

In an insecure context or locked-down WebView where navigator.clipboard is undefined, optional chaining makes this await complete successfully, so the UI claims the invite link was copied even though the clipboard was unchanged. Because this card does not display the newly minted link anywhere, the user cannot send that invite; check for clipboard.writeText explicitly and show the failure or link fallback instead of the copied message.

Useful? React with 👍 / 👎.

} catch {
setFixNote(t('Could not create the invite. Use Bot settings → Invite to this Bot.'))
}
}
const pick = (bot) => {
setPicked(current => [...current, bot.id])
Expand Down Expand Up @@ -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)),
Expand Down
3 changes: 3 additions & 0 deletions core/src/client/styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
6 changes: 4 additions & 2 deletions core/src/host/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() ?? ''}`
Expand Down Expand Up @@ -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)
}
}

Expand Down
Loading
Loading