From 2da964fdfa2ec6d5f121e1b880bd06db0ff4db33 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sun, 25 Oct 2026 10:37:09 +0800 Subject: [PATCH] feat(social): offer to start a room when nobody is waiting (#1053) Rebased onto 0a4445c1 (#1095 also moved client/i18n.js and 0001). patches/ds-bot/0001 and 0008 are that tree plus this change only: four i18n lines and the matching hunk counts. --- changelog.d/1053-face-first-create.changed.md | 1 + core/client.js | 4 ++ core/src/client/i18n.js | 4 ++ docs/subsystems/core-client.md | 2 +- docs/subsystems/social.md | 3 +- patches/ds-bot/0001-core-fixes.patch | 6 ++- patches/ds-bot/0008-client-bundle.patch | 6 ++- plugins/social/client.js | 22 +++++--- plugins/social/test/social.test.js | 54 ++++++++++++++++++- 9 files changed, 87 insertions(+), 15 deletions(-) create mode 100644 changelog.d/1053-face-first-create.changed.md diff --git a/changelog.d/1053-face-first-create.changed.md b/changelog.d/1053-face-first-create.changed.md new file mode 100644 index 000000000..f660ac059 --- /dev/null +++ b/changelog.d/1053-face-first-create.changed.md @@ -0,0 +1 @@ +A fresh 6-digit code no longer answers with an empty-result line. When nobody is waiting, the dialog's primary line is **Start a new room with …** and **Start a new room** is the first button; the note under it still says to ask for an invite link if that room already opened ([#1053](https://github.com/ThinkFlowLab/FlyBot/issues/1053)). diff --git a/core/client.js b/core/client.js index 99f060bb2..c1fae5e98 100644 --- a/core/client.js +++ b/core/client.js @@ -57,6 +57,10 @@ var ZH = { "Meet people nearby with a 6-digit number": "用同一组 6 位数字和身边的人见面", // #892: one entry for both ways in, a number entered together or a group's invite link, and its dialog. "Join or create by number or link": "面对面建群或用链接加入", + // #1053: a fresh 6-digit code offers to start the room. The hint still covers a number whose room already opened (#999). + "Start a new room with {code}": "用 {code} 开一个新房间", + "No one is waiting under this number. If someone already opened the room, the number no longer works — ask them for an invite link.": "这个号码下还没有人在等。如果已经有人开过这个房间,号码就不再有效——向他们要一条邀请链接。", + "Start a new room": "开一个新房间", "Enter a number, or paste a group invite link": "输入数字面对面建群,或粘贴邀请链接加入", "Pick your Bots. Enter the same 6-digit number as everyone nearby to build a room together, or paste a group invite link or scan its QR code to join that group.": "选好你的 Bot。和身边的人输入同一组 6 位数字一起建群,或粘贴群邀请链接、扫描二维码加入那个群。", "Or paste a group invite link (https://…/r/…)": "或粘贴群邀请链接(https://…/r/…)", diff --git a/core/src/client/i18n.js b/core/src/client/i18n.js index 2644f9007..55d5f65a6 100644 --- a/core/src/client/i18n.js +++ b/core/src/client/i18n.js @@ -20,6 +20,10 @@ const ZH = { 'Meet people nearby with a 6-digit number': '用同一组 6 位数字和身边的人见面', // #892: one entry for both ways in, a number entered together or a group's invite link, and its dialog. 'Join or create by number or link': '面对面建群或用链接加入', + // #1053: a fresh 6-digit code offers to start the room. The hint still covers a number whose room already opened (#999). + 'Start a new room with {code}': '用 {code} 开一个新房间', + 'No one is waiting under this number. If someone already opened the room, the number no longer works — ask them for an invite link.': '这个号码下还没有人在等。如果已经有人开过这个房间,号码就不再有效——向他们要一条邀请链接。', + 'Start a new room': '开一个新房间', 'Enter a number, or paste a group invite link': '输入数字面对面建群,或粘贴邀请链接加入', 'Pick your Bots. Enter the same 6-digit number as everyone nearby to build a room together, or paste a group invite link or scan its QR code to join that group.': '选好你的 Bot。和身边的人输入同一组 6 位数字一起建群,或粘贴群邀请链接、扫描二维码加入那个群。', 'Or paste a group invite link (https://…/r/…)': '或粘贴群邀请链接(https://…/r/…)', diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index a53041c7e..cad236644 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -69,7 +69,7 @@ Add a new invariant under the heading it belongs to (or a new heading), not at t ### Held-send approval card -A `flybot.session.dock` component also gets the host translator `t`, so a plugin's card can read in the interface language (the social hold card's *Held for your approval: contains …* line, #371; its words are in `i18n.js`). +A `flybot.session.dock` component also gets the host translator `t`, so a plugin's card can read in the interface language (the social hold card's *Held for your approval: contains …* line, #371; its words are in `i18n.js`). The face-room create prompt (*Start a new room with {code}*, and the hint under it, #1053) is in that same map, so a zh UI reads it in Chinese. The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second copy (#260): an approval whose policy a `flybot.session.dock` entry declares (`policy`, e.g. social) is drawn by that dock in its chat, which reports the ids it shows through `claim(ids)`; the overlay skips claimed ids. A docked approval no open chat shows becomes a small pointer (`Sender → Recipient`, `Waiting for approval`, **Review** opens the chat via `actions.openSession`) after 1.2 s, so moving between chats never flashes it; the chat that is open never gets a pointer for its own approvals (each `FlyBotSessionDock` reports its chat through `chatOpened`, #271). An approval's chat is its room, else the chat whose turn a held reply answers (`meta.replyIn`, #418), else the friend's chat it is addressed to, else the Bot that wrote it; the pointer's Review opens that one (#278). The claims live in `approval-claims.js` so a chat cell can claim without the slot components. Any other policy's approval is a card in the overlay, with the same two lines as the dock's card (`Waiting for approval` / `Not sent`, then `Sender → Recipient`; the policy's long reason is the tooltip) and Approve / Reject (Retry for a failed send `canRetry()` allows, #968). Approve / Reject is drawn only for a status `isApprovalWaiting()` calls waiting (`pending`, `pending_approval`), the same rule the held-reply card asks (#734); a settled status gets no button the host would refuse, and a failed send keeps Retry only while `canRetry()` allows it (#968). The recipient is the person who gets it (#418, #498): the host's `recipientName`, else the owner of the friend's chat (`friendBotOwner`), else as the host named it, so a card reads `Chief → SelfB1233`, never two Bot names. `senderName` / `targetName` are resolved server-side and a delegated marker follows a relayed send (`hop > 0`). The decision itself is owner-bound in the host (#52); see [core-host](core-host.md). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index a6f26765d..097555687 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -47,8 +47,7 @@ Everything in `plugins/social/` (entry `plugins/social/index.js` and any helpers 3. A server policy pause (`conversation_paused`: loop suspected between the Bots, emergency stop, or an older server's turn budget) never surfaces as a bare send error: both send paths (the `bot-social` transport and the remote-chat sender) map it to *"Bot Social paused that chat (reason). Send your message again: it starts a new chat."* (structured: `code: 'conversation_paused'`, `paused`, `resend`), because `send_as_member` opens a fresh conversation for the pair's next message. It never points to the Bot Social website (`/ui`), which a FlyBot owner cannot sign in to, and never names Extend or turns (there is no turn limit, owner decision 2026-10-19, Bot Social #28; see below) and no other member Bot is tried for it. Other failures end in *Try again.*, never in a console pointer. Other `sendChat` failures are one short English line with a next step (reconnect, invite, try again) — never `Bot Social send_as_member: …`. A human's own message in a face-to-face or shared room to a friend's Bot that is already a member of that room is delivered as that human in the room (`onFaceLine` / room log); it is not signed or grant-checked as Chief (#143). Chief-originated `bot.send` / `message_friend_bot` to an ungranted friend Bot stays denied. The refusal says it the right way round and names the one setting that exists (#273): the sending Bot is not shared with that friend (*"Chief is not shared with that friend, so it cannot message their Bots. To allow it, share Chief with them in its settings → Sharing."*), or the target is not a Bot a friend shared with you; `message_friend_bot` tells the model to relay that reason as written. 4. Access is a per-Bot **grant** (#90, #138): default deny, Chief included. `register` / `registerAll` register a Bot when it is invited, when it joins an invite, when it already has a person-grant or a saved identity, or at face-to-face join (`forFace`, `roomOnly`). `pollOnce` reads inboxes of non-room-only members only. Friends' Bots is `list_remote_bots` (Bots granted **to me**), minus reverse-friends of invites I created and minus face-room-only remotes. Accepting **Sharing → Copy invite link** is the grant the other way; **Share with a friend** (`social/share-friends` + `social/share-with-friend`, via `grantBotToFriend` / `grant_bot_to_friend`) is the same grant for someone who is already a friend, with no invite code; both endpoints register the Bot's Bot Social identity on demand (`register(bot, { forInvite: true })`) when it has none yet, so the share list works on a Bot that was never invited or shared. Joining does not grant my talk-through Bot back. Join offers **Talk through** only when I have more than one Bot (default Chief; tooltip: not shared unless I invite them to it, #228). `social/access` + `social/revoke-grant` (via `mcp-client.js` `listGrants` / `revokeGrant`, preferring `list_bot_grants` / `revoke_bot_grant`) list people as `Name (@home)` and revoke one person. Core `reach()` / `flybot.grants` are the unbypassable gate. **Face-to-face join (#83 / #89):** bringing my own Bot by number is an implicit room-scoped grant. If it has no identity, join registers it room-only; friends' `list_remote_bots`, invites, polling and `reach()` outside the room stay denied. When that Bot leaves its last face room or the room closes, the plugin revokes the room-only identity (`remove_home_member`) and drops room-scoped remotes from Friends' Bots. A failed join that created a new room-only identity is revoked too. A later person-grant reuses the handle and drops `roomOnly`, keeping the stored `ownerName`/`home` and filling either from `whoami` when the room-only record never had it (#863). Registration also records the owner-level `ownerName` and `home` on that member identity (#848) from one `whoami`: top-level `display_name` and `home`, else `connector_identity.bot_handles[0]`, else the `flybot-home` entry in `bots[]` (given the `@`), and `registerAll` fills them into an identity saved before this without registering it again; the group room-share envelope reads them to name the inviter. 5. First start retries a taken home handle (`FlyBot`, `FlyBot2`, …) instead of failing for a YAML edit (#136). If every retry is still taken, the social UI never shows the host's `Unknown FlyBot endpoint social/…` message: `apply` registers the social UI endpoints with a friendly `Social is not running: ` refusal and `flybot.warn` records the failure. The client also maps `bot/not-found` on `social/*` to that idle state. Add friend shows `You are @your.home` (#181). -6. Face-to-face join (#83, #92, #158) is a waiting room, not an instant chat: the owner picks local Bots, types the 6-digit number, then waits with the same code shown large until someone **Enter chat**s. Copy never says there is no preview. Each picker tile and each waiting-room member uses the host Bot avatar (`__flybot__.BotAvatar`), not a shared first-letter badge. The client calls `social/face-join` once per Bot with the same code and shows partial failures. If a chosen Bot has no identity, the plugin registers it first (see invariant 4); registration failures (`handle_taken`, rate limit, timeout) map to English copy and leave no leftover member. Failed join responses never leak `members` / `events` / `room_id`. Every FACE_ROOMS.md error code maps to English copy (`rate_limited` / `locked_out` include `retry_after`). The waiting room polls `social/face-rooms` (which refreshes from Bot Social, including `joinCode`) and draws one tile per person (#457: `FacePersonTile`, the person's name, `Me (you)` for the owner, with the Bots they brought underneath; the owner's tile has a tiny accent dot); **Leave** and **Enter chat** share the footer. **Enter chat** is `social/face-start` then `openSession(roomId)`, and the screen follows if someone else starts. Peer `room.message` text is untrusted: strip fence markers and wrap in `` before anything model-visible. Bot replies POST `/v1/face-rooms/{id}/messages`; `pending_approval` shows in the existing queue and in the chat (owner Approve / Reject). Owner-typed `FLYBOT-FACE-HUMAN` lines stay on that card until Approve / Always approve / Reject unless face **Auto-send** is on; the card and chat never show the raw envelope (#159). Face-room **Auto-send** is one switch per Bot of mine in the room (#457, `Auto-send Chief → room`, `social/face-auto { botId, on }`, kept in this home's `settings.faceAutoSend`, off by default): when that Bot's line comes back `pending_approval` and Bot Social says the hold is its own blanket rule (`hold_reason: 'blanket_outbound'`) or nothing but the room's Auto-send switch (`hold_reason: 'face_room_auto_send_off'` — what a clean face-room line carries once that switch is the only thing left), the host approves it at once (`decide_approval`; the switch is the owner's standing consent, so a switch that reads **on** has to send), so the room sees it; any other `hold_reason` (`risk`, `attachment`, `policy`, a value this build does not know) and other Bots' lines still wait for the owner. Only this face-room path accepts `face_room_auto_send_off` (`holdAllowsFaceAutoSend`, on top of `holdIsBlanket`); the two outbound paths keep the blanket rule alone (#865). The older account-wide `auto_approve_face_rooms` (`whoami` / MCP `set_auto_approve`) reads as every switch on; the first per-Bot change keeps the others on here and turns the account-wide one off (a `confirmation_required` answer is retried with `confirm_code` in the same click; the UI never asks for a code). REST `/v1/me` is not used with the connector token. A friend's Bot in the room member list and waiting grid uses the host Bot avatar (handle-seeded when Bot Social sends no image). New members only receive events from their join point (`after` cursor; the server also floors it). Join does not grant the Bot to friends. The UI never says published. A number nobody else is using opens no room (#458): `social/face-join` without `start` answers `{ nobody: true, faceRoomId }` when the server's room holds only this home's Bots, and the host keeps that fresh candidate off the sidebar and Past rooms (an in-memory hold, dropped on the next poll after 2 minutes). The dialog shows **Nobody is waiting under …** with **Cancel** (`social/face-leave { faceRoomId }` leaves it on Bot Social and releases a new room-only identity) and **Start a new room** (`start: true` adopts the held room through `GET`, no second create). That answer is the only thing the host knows: a join that returns no other home is the same answer for an unused number and for a number whose room already opened, so the sentence never claims nobody uses it and points at an invite link instead (#999) — **Enter chat** from a lone waiting room is what burns a number. A room nobody from another home ever joined leaves no Past rooms entry and no kept transcript, whether the owner left it or the joining window ran out (the host then leaves it on Bot Social). The room stays open while two or more homes are in it and closes here only when this home is the last; the closed room's dock reads "… left the room. Room closed." with one **Close**. Leave and Close go back to the last non-face chat this browser showed (else the Main Bot) and close the details panel. Countdowns use the server's `expires_at` corrected by the Bot Social clock offset (from the `Date` header, applied only at 3 s or more) and catch up on `visibilitychange`. Join stays disabled until 6 digits; a number that is not empty and not six digits says **Enter 6 digits** under the field (`data-flybot=face-join-digits`, zh 请输入 6 位数字, #1052) while an empty field stays quiet, an outside click closes the dialog like Escape, and Enter chat steps aside while Leave asks. Core's sidebar leads a face-room row with its number and keeps face-room turns out of a Bot row's group-work line. -7. Every import in shipped sources stays inside the plugin package: an installed plugin cannot reach into other packages' files (a repo-relative `../..` into `core/` breaks the installed bundle). +6. Face-to-face join (#83, #92, #158) is a waiting room, not an instant chat: the owner picks local Bots, types the 6-digit number, then waits with the same code shown large until someone **Enter chat**s. Copy never says there is no preview. Each picker tile and each waiting-room member uses the host Bot avatar (`__flybot__.BotAvatar`), not a shared first-letter badge. The client calls `social/face-join` once per Bot with the same code and shows partial failures. If a chosen Bot has no identity, the plugin registers it first (see invariant 4); registration failures (`handle_taken`, rate limit, timeout) map to English copy and leave no leftover member. Failed join responses never leak `members` / `events` / `room_id`. Every FACE_ROOMS.md error code maps to English copy (`rate_limited` / `locked_out` include `retry_after`). The waiting room polls `social/face-rooms` (which refreshes from Bot Social, including `joinCode`) and draws one tile per person (#457: `FacePersonTile`, the person's name, `Me (you)` for the owner, with the Bots they brought underneath; the owner's tile has a tiny accent dot); **Leave** and **Enter chat** share the footer. **Enter chat** is `social/face-start` then `openSession(roomId)`, and the screen follows if someone else starts. Peer `room.message` text is untrusted: strip fence markers and wrap in `` before anything model-visible. Bot replies POST `/v1/face-rooms/{id}/messages`; `pending_approval` shows in the existing queue and in the chat (owner Approve / Reject). Owner-typed `FLYBOT-FACE-HUMAN` lines stay on that card until Approve / Always approve / Reject unless face **Auto-send** is on; the card and chat never show the raw envelope (#159). Face-room **Auto-send** is one switch per Bot of mine in the room (#457, `Auto-send Chief → room`, `social/face-auto { botId, on }`, kept in this home's `settings.faceAutoSend`, off by default): when that Bot's line comes back `pending_approval` and Bot Social says the hold is its own blanket rule (`hold_reason: 'blanket_outbound'`) or nothing but the room's Auto-send switch (`hold_reason: 'face_room_auto_send_off'` — what a clean face-room line carries once that switch is the only thing left), the host approves it at once (`decide_approval`; the switch is the owner's standing consent, so a switch that reads **on** has to send), so the room sees it; any other `hold_reason` (`risk`, `attachment`, `policy`, a value this build does not know) and other Bots' lines still wait for the owner. Only this face-room path accepts `face_room_auto_send_off` (`holdAllowsFaceAutoSend`, on top of `holdIsBlanket`); the two outbound paths keep the blanket rule alone (#865). The older account-wide `auto_approve_face_rooms` (`whoami` / MCP `set_auto_approve`) reads as every switch on; the first per-Bot change keeps the others on here and turns the account-wide one off (a `confirmation_required` answer is retried with `confirm_code` in the same click; the UI never asks for a code). REST `/v1/me` is not used with the connector token. A friend's Bot in the room member list and waiting grid uses the host Bot avatar (handle-seeded when Bot Social sends no image). New members only receive events from their join point (`after` cursor; the server also floors it). Join does not grant the Bot to friends. The UI never says published. A number nobody else is using opens no room (#458): `social/face-join` without `start` answers `{ nobody: true, faceRoomId }` when the server's room holds only this home's Bots, and the host keeps that fresh candidate off the sidebar and Past rooms (an in-memory hold, dropped on the next poll after 2 minutes). The dialog's primary line is **Start a new room with …** and its primary button, listed first, is **Start a new room** (`start: true` adopts the held room through `GET`, no second create, #1053); **Cancel** (`social/face-leave { faceRoomId }` leaves it on Bot Social and releases a new room-only identity) is the other way out. The line does not read as a failed join. That answer is the only thing the host knows: a join that returns no other home is the same answer for an unused number and for a number whose room already opened, so the copy never claims nobody uses the number and the hint still points at an invite link (#999) — **Enter chat** from a lone waiting room is what burns a number. A room nobody from another home ever joined leaves no Past rooms entry and no kept transcript, whether the owner left it or the joining window ran out (the host then leaves it on Bot Social). The room stays open while two or more homes are in it and closes here only when this home is the last; the closed room's dock reads "… left the room. Room closed." with one **Close**. Leave and Close go back to the last non-face chat this browser showed (else the Main Bot) and close the details panel. Countdowns use the server's `expires_at` corrected by the Bot Social clock offset (from the `Date` header, applied only at 3 s or more) and catch up on `visibilitychange`. Join stays disabled until 6 digits; a number that is not empty and not six digits says **Enter 6 digits** under the field (`data-flybot=face-join-digits`, zh 请输入 6 位数字, #1052) while an empty field stays quiet, an outside click closes the dialog like Escape, and Enter chat steps aside while Leave asks. Core's sidebar leads a face-room row with its number and keeps face-room turns out of a Bot row's group-work line.7. Every import in shipped sources stays inside the plugin package: an installed plugin cannot reach into other packages' files (a repo-relative `../..` into `core/` breaks the installed bundle). 8. A friend's Bot's reply to a line you typed (or to any request) goes back through `bot.send` as an `auto-reply` and, unless Auto-send is on for that pair, is held for the replying Bot's owner (#254). The hold is decided on the reply itself (#418): the host tags the auto-reply with the chat and turn it answers (`meta.replyIn`, `turns.js` → `hooks.js` `replyRemote`), the Bot's row in `state` lists its `heldReplies` (id, status, turn, recipient; owner only, no text) and counts a waiting one in `pendingCount`, and that turn's bubble is drawn held (dashed, `core/src/client/held-reply.js`) with the decision on it (`flybot.approvals` inline entry `social-held-reply`: **Waiting for approval**, `Chief → SelfB1238`, **Approve** / **Reject** and the **Auto-send from now on** link, which approves it and turns on Auto-send for that pair only, the replying Bot → that person, through `social/auto-send { approvalId }` (#417), with the same toast as the dock card; it never touches the home-wide switch). No dock draws it, in either chat; the overlay's pointer leads to that chat. A reply that left without a card because Auto-send is on for that pair (#417) reads **Auto-sent to SelfB1238** there, quietly, so the owner sees it went without approval: the middleware's `allow` says `autoSent` (with the same `recipient`) and the host keeps that mark by turn (`state.flybot.autoSent`, last 200, no text). After the decision the same place reads **Sent to SelfB1238** or **Not sent** (muted; **Retry** on a failed send, and only when the shared `canRetry()` allows it: this plugin's own dock and the core cards read that one predicate, so a terminal failure — the Bot is gone or no longer shared, the send was refused as not permitted — draws no button anywhere, #968), and the list preview shows the friend's line instead of a reply that did not go. The card names the person who gets it (#498): the holding middleware's `recipient` (the friend's owner name, stored as `recipientName`, so the card reads the same live and after a reload), else the owner of the friend's chat; never the friend's Bot (`Chief → Chief (SelfB1238)` named two Bots); a line held for a face-to-face room names that room instead of the bare `the room` (#991), the same way the room bullet below says. A hold made before the tag (no `replyIn`) keeps its dock card as below. A friend's handle is never a room id. The sender sees nothing until it is approved. The reply also goes back to a requesting Bot that was not shared with us (the default one-way per-Bot grant): the replying Bot's grant to it is enough for a `reply`, never for a new request (#272). 9. Two holds, one click (#258): a send also held by Bot Social (`pending_approval`) is finished by the transport when the host says the owner approved *this very send* on a FlyBot card (`msg.approved`, set by `hooks.js` `release` only; a Bot-supplied field is overwritten). The approval id comes from the `send_as_member` answer, is recorded as an own line (so a failed call is retried by the poll and never shows as a card) and is decided with the same `confirm_code` step as a typed line. Room lines (`via: room` / `room-share`) keep Bot Social's hold: the friend's owner decides those. A send that left on Auto-send (#417) clears Bot Social's hold itself, the same way `autoSendFaceLine` does for a room line (#737): the owner's switch already answered that question, and leaving the hold parked the line with no card anywhere, so it never arrived. That clear only happens for the blanket hold and nothing else (#865): `holdIsBlanket()` reads Bot Social's `hold_reason` and clears the line only when it is `blanket_outbound`; every other value — `risk`, `attachment`, `policy`, a value this build does not know — keeps the line on its card, exactly as with Auto-send off. The face-room path is the one exception, and only for its own switch (#457): `autoSendFaceLine` clears `face_room_auto_send_off` too, while both outbound paths here never do. The field wins when it and `reasons` disagree. A server that sends no field keeps the old allow-list: `reasons` must be non-empty and every entry exactly `mvp.all_outbound_requires_approval`, with no `risk_flags`, so a DLP category, `dlp.blocked`, a reason this build does not know (`policy.*`), an empty `reasons` list and any risk flag all keep the line (#766) — an absent field is not read as "not blanket", or every hold on an older server would strand. A hold Bot Social has not listed yet is not "no finding" either: the line waits and the report names the approval id (`warn`), so a lagging list is diagnosable instead of silent. It reads the pending list directly rather than through `pendingApprovals()`, which would also post the waiting notice for a hold it is about to clear, and a failed `decide_approval` only warns: the hold then shows as the owner's card. Holds that were already stuck before this change need one Approve / Reject in Friends' Bots. 10. Joining is not sharing (#336, Bot Social `faa7cb5`): Bot Social refuses a first message from a friend's Bot to one of my Bots that only joined their invite (`403 bot_not_shared`; nothing stored or delivered) and adds a `share.requested` event to my Bot's queue. `pollOnce` keeps it as one card per sender and Bot (`settings.shareRequests`, names cleaned, no peer content) and listed on `social/status` as `shareRequests`; Friends' Bots shows *"Helper (Eve) tried to start a chat with Writer. Writer is not shared with them."* with **Dismiss** (`social/share-request-dismiss`) and **Share Writer…**, which only opens that Bot's settings (Sharing). Nothing is granted from the card, no typed reply acts on it (#349) and the Bot never answers on its own; a card drops by itself once that Bot is shared with the sender (`flybot.grants.has`) or the Bot is gone. On the sender's side both send paths map `bot_not_shared` — and `capability_not_granted` only when the sentence is "peer has not granted chat.public", which is the same fact reported from the other side once a share is taken back (#971); the same code naming another capability, such as `profile.read`, stays a generic error and keeps Retry (#988) — to *"Coder (Bob) is not shared with your Bots, so the message was not delivered. Bot Social told its owner, who can share it with you. Sending again will not help until then."* (the transport prefixes `Not delivered:`), carrying `noRetry` so no card offers a Retry that cannot work, and with no retry through another member. Core `reach()` still refuses such an inbound message locally as the safety net. diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 9a35543dd..5b0423205 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -447,7 +447,7 @@ diff --git a/src/client/feedback.js b/src/client/feedback.js diff --git a/src/client/i18n.js b/src/client/i18n.js --- a/src/client/i18n.js +++ b/src/client/i18n.js -@@ -12,6 +12,50 @@ const ZH = { +@@ -12,6 +12,54 @@ const ZH = { 'Unread activity': '有未读', Search: '搜索', 'New chat': '新对话', @@ -459,6 +459,10 @@ diff --git a/src/client/i18n.js b/src/client/i18n.js + 'Meet people nearby with a 6-digit number': '用同一组 6 位数字和身边的人见面', + // #892: one entry for both ways in, a number entered together or a group's invite link, and its dialog. + 'Join or create by number or link': '面对面建群或用链接加入', ++ // #1053: a fresh 6-digit code offers to start the room. The hint still covers a number whose room already opened (#999). ++ 'Start a new room with {code}': '用 {code} 开一个新房间', ++ 'No one is waiting under this number. If someone already opened the room, the number no longer works — ask them for an invite link.': '这个号码下还没有人在等。如果已经有人开过这个房间,号码就不再有效——向他们要一条邀请链接。', ++ 'Start a new room': '开一个新房间', + 'Enter a number, or paste a group invite link': '输入数字面对面建群,或粘贴邀请链接加入', + 'Pick your Bots. Enter the same 6-digit number as everyone nearby to build a room together, or paste a group invite link or scan its QR code to join that group.': '选好你的 Bot。和身边的人输入同一组 6 位数字一起建群,或粘贴群邀请链接、扫描二维码加入那个群。', + 'Or paste a group invite link (https://…/r/…)': '或粘贴群邀请链接(https://…/r/…)', diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 8af8715c9..1d9a25c8a 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -3,7 +3,7 @@ FlyBot patch 0008-client-bundle on Fei2411/DeepSeek-Bot@2b10c70 (ds-bot/). Gener diff --git a/client.js b/client.js --- a/client.js +++ b/client.js -@@ -49,6 +49,50 @@ var ZH = { +@@ -49,6 +49,54 @@ var ZH = { "Unread activity": "有未读", Search: "搜索", "New chat": "新对话", @@ -15,6 +15,10 @@ diff --git a/client.js b/client.js + "Meet people nearby with a 6-digit number": "用同一组 6 位数字和身边的人见面", + // #892: one entry for both ways in, a number entered together or a group's invite link, and its dialog. + "Join or create by number or link": "面对面建群或用链接加入", ++ // #1053: a fresh 6-digit code offers to start the room. The hint still covers a number whose room already opened (#999). ++ "Start a new room with {code}": "用 {code} 开一个新房间", ++ "No one is waiting under this number. If someone already opened the room, the number no longer works — ask them for an invite link.": "这个号码下还没有人在等。如果已经有人开过这个房间,号码就不再有效——向他们要一条邀请链接。", ++ "Start a new room": "开一个新房间", + "Enter a number, or paste a group invite link": "输入数字面对面建群,或粘贴邀请链接加入", + "Pick your Bots. Enter the same 6-digit number as everyone nearby to build a room together, or paste a group invite link or scan its QR code to join that group.": "选好你的 Bot。和身边的人输入同一组 6 位数字一起建群,或粘贴群邀请链接、扫描二维码加入那个群。", + "Or paste a group invite link (https://…/r/…)": "或粘贴群邀请链接(https://…/r/…)", diff --git a/plugins/social/client.js b/plugins/social/client.js index deb2d3598..5a26804da 100644 --- a/plugins/social/client.js +++ b/plugins/social/client.js @@ -319,6 +319,7 @@ window.__ModuleLoader__.load({ .fs-wait-foot{display:flex;align-items:stretch;gap:8px;flex:none} .fs-wait-foot .fs-btn{height:36px} .fs-wait-foot .fs-btn-primary{flex:1 1 auto;width:auto;min-width:0} +.fs-wait[data-flybot=face-nobody] .fs-name{white-space:normal;overflow:visible;text-overflow:clip} .fs-fail{padding:0} ` function useStyles() { @@ -960,6 +961,11 @@ window.__ModuleLoader__.load({ // so a zh UI reads it in Chinese. An older host has none: the English stands, with {params} filled in. // #892: the entry, the dialog's title and its copy name both ways in (core's dictionary has the zh). const JOIN_OR_CREATE = 'Join or create by number or link' + // #1053: a fresh code's first step is starting the room. #999: the same answer also means a + // room with this number may already have opened, so the hint still points at an invite link. + const NOBODY_TITLE = 'Start a new room with {code}' + const NOBODY_HINT = 'No one is waiting under this number. If someone already opened the room, the number no longer works — ask them for an invite link.' + const START_NEW_ROOM = 'Start a new room' const JOIN_OR_CREATE_HINT = 'Enter a number, or paste a group invite link' const JOIN_OR_CREATE_ABOUT = 'Pick your Bots. Enter the same 6-digit number as everyone nearby to build a room together, or paste a group invite link or scan its QR code to join that group.' const LINK_PLACEHOLDER = 'Or paste a group invite link (https://…/r/…)' @@ -1738,16 +1744,16 @@ window.__ModuleLoader__.load({ h('div', { className: 'fs-dialog-head' }, h('div', { className: 'fs-name', 'data-flybot': 'face-group-title' }, tr(JOIN_OR_CREATE)), h('button', { type: 'button', className: 'fs-btn fs-btn-sm', onClick: dismiss }, tr('Close'))), - step === 'nobody' ? h('div', { className: 'fs-wait', 'data-flybot': 'face-nobody', role: 'status' }, + step === 'nobody' ? h('div', { className: 'fs-wait', 'data-flybot': 'face-nobody' }, // #999: `nobody` means the join answer held no other home, not that the number is unused — - // a number whose room already opened answers the same way, and the owner's Enter chat is - // what burns it. So the title claims only what we know, and the hint names the way in. - h('div', { className: 'fs-name' }, `Nobody is waiting under ${nobody}.`), - h('div', { className: 'fs-hint' }, 'If someone already opened the room with this number, the number stops working — ask them for an invite link. Otherwise check the number, or start a new room with it and wait for them.'), + // a number whose room already opened answers the same way. #1053: the primary line offers + // to start; the hint keeps the invite-link caveat so this does not read as a failed join. + h('div', { className: 'fs-name', 'data-flybot': 'face-nobody-title' }, tr(NOBODY_TITLE, { code: nobody })), + h('div', { className: 'fs-hint', 'data-flybot': 'face-nobody-hint' }, tr(NOBODY_HINT)), status ? h('div', { className: 'fs-status', 'data-tone': status.tone, role: 'status' }, status.text) : null, - h('div', { className: 'fs-wait-foot' }, - h('button', { type: 'button', className: 'fs-btn', 'data-flybot': 'face-nobody-cancel', disabled: busy, onClick: cancelNobody }, 'Cancel'), - h('button', { type: 'button', className: 'fs-btn fs-btn-primary', 'data-flybot': 'face-nobody-start', disabled: busy, onClick: () => join(null, { start: true }) }, 'Start a new room'))) : waiting ? h('div', { className: 'fs-wait', 'data-flybot': 'face-wait' }, + h('div', { className: 'fs-wait-foot', 'data-flybot': 'face-nobody-foot' }, + h('button', { type: 'button', className: 'fs-btn fs-btn-primary', 'data-flybot': 'face-nobody-start', disabled: busy, onClick: () => join(null, { start: true }) }, tr(START_NEW_ROOM)), + h('button', { type: 'button', className: 'fs-btn', 'data-flybot': 'face-nobody-cancel', disabled: busy, onClick: cancelNobody }, tr('Cancel')))) : waiting ? h('div', { className: 'fs-wait', 'data-flybot': 'face-wait' }, h(FaceWaitCode, { code: faceWaitCode(room, code), qrAt: candidate ? (room.expiresAt || 'open') : null, diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 5a0557ae0..79f07e8ad 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -9858,8 +9858,10 @@ test('#999: a code nobody is waiting under says so without claiming nobody uses const box = tree.find(n => n.props['data-flybot'] === 'face-nobody') assert.ok(box) // #999: `nobody` only means the join answer held no other home — never that the number is unused. A code - // whose room already opened answers the same way, so the title must not assert nobody uses it. - assert.ok(tree.some(n => n.children.includes('Nobody is waiting under 111111.')), 'the title says nobody is waiting, not that nobody uses the number') + // whose room already opened answers the same way, so the copy must not assert nobody uses it. + // #1053: the primary line offers to start the room instead of leading with an empty-result failure. + assert.equal(tree.find(n => n.props['data-flybot'] === 'face-nobody-title').children.join(''), 'Start a new room with 111111') + assert.equal(tree.some(n => n.children?.includes?.('Nobody is waiting under 111111.')), false) assert.equal(tree.some(n => /nobody is using/i.test(JSON.stringify(n.children))), false, 'never claims nobody uses the number (#999)') // The late joiner is told the way in once the room has opened: an invite link. const hint = tree.find(n => n.props?.['data-flybot'] === 'face-nobody').children @@ -9880,6 +9882,54 @@ test('#999: a code nobody is waiting under says so without claiming nobody uses assert.ok(calls.some(([endpoint, payload]) => endpoint === 'social/face-leave' && payload.faceRoomId === 'frm_1')) }) +// #1053: the first person on a fresh code is offered a room to start. The same `nobody` answer +// also covers a number whose room already opened (#999), so the screen still names the invite +// link and never claims the number is unused — it just does not lead with an empty-result failure. +test('#1053: when nobody is waiting, the primary line offers to start the room', async () => { + const { DICTIONARY } = await import('../../../core/src/client/i18n.js') + const TITLE = 'Start a new room with {code}' + const HINT = 'No one is waiting under this number. If someone already opened the room, the number no longer works — ask them for an invite link.' + const START = 'Start a new room' + const calls = [] + const { walk, pluginOf, sandbox } = loadSocialClient(async (endpoint, payload) => { calls.push([endpoint, payload]); return {} }) + const bots = [{ id: 'a', name: 'Chief', isMain: true }] + const held = { nobody: true, faceRoomId: 'frm_1', joinCode: '111111' } + const draw = () => walk(pluginOf(['nobody', bots, ['a'], '111111', false, null, [], held, 0]).FaceToFaceGroup({})) + const tree = draw() + const title = tree.find(n => n.props['data-flybot'] === 'face-nobody-title') + assert.equal(title.children.join(''), 'Start a new room with 111111') + assert.equal(tree.some(n => n.children?.includes?.('Nobody is waiting under 111111.')), false, 'the headline is not an empty-result failure') + const hint = tree.find(n => n.props['data-flybot'] === 'face-nobody-hint') + assert.match(hint.children.join(''), /invite link/) + assert.match(hint.children.join(''), /waiting/i) + assert.equal(tree.some(n => /nobody is using/i.test(JSON.stringify(n.children))), false, 'never claims nobody uses the number (#999)') + const box = tree.find(n => n.props['data-flybot'] === 'face-nobody') + assert.equal(box.props.role, undefined, 'the create prompt is not a live status') + assert.equal(box.props['data-tone'], undefined) + const foot = tree.find(n => n.props['data-flybot'] === 'face-nobody-foot') + const buttons = foot.children.filter(n => n?.type === 'button') + assert.equal(buttons[0].props['data-flybot'], 'face-nobody-start', 'Start is the first action') + assert.match(buttons[0].props.className, /fs-btn-primary/) + assert.equal(buttons[0].children.join(''), START) + assert.equal(buttons[1].props['data-flybot'], 'face-nobody-cancel') + assert.equal(buttons[1].children.join(''), 'Cancel') + await buttons[0].props.onClick() + assert.deepEqual(JSON.parse(JSON.stringify(calls.filter(([endpoint]) => endpoint === 'social/face-join').map(([, payload]) => payload))), [{ code: '111111', botId: 'a', start: true }]) + assert.equal(DICTIONARY.zh[TITLE], '用 {code} 开一个新房间') + assert.equal(DICTIONARY.zh[HINT], '这个号码下还没有人在等。如果已经有人开过这个房间,号码就不再有效——向他们要一条邀请链接。') + assert.equal(DICTIONARY.zh[START], '开一个新房间') + + sandbox.__flybot__.t = (text, params) => { + const mapped = DICTIONARY.zh[text] ?? text + return String(mapped).replace(/\{(\w+)\}/g, (_, key) => String(params?.[key] ?? '')) + } + const zh = draw() + assert.equal(zh.find(n => n.props['data-flybot'] === 'face-nobody-title').children.join(''), '用 111111 开一个新房间') + assert.equal(zh.find(n => n.props['data-flybot'] === 'face-nobody-start').children.join(''), '开一个新房间') + assert.equal(zh.find(n => n.props['data-flybot'] === 'face-nobody-cancel').children.join(''), '取消') + assert.match(zh.find(n => n.props['data-flybot'] === 'face-nobody-hint').children.join(''), /邀请链接/) +}) + test('#458: Join answers a nobody result with the prompt and no room opens', async () => { const steps = [] const rooms = []