From d6f38b17af6537f55034a17ae5efa26cd5b99943 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sun, 4 Oct 2026 13:43:34 -0300 Subject: [PATCH] feat(chat): reply to something in a bot's chat The owner can reply to one of the bot's replies, or to a message it got. messages.send takes replyTo; the daemon checks the item is the bot's and keeps its text on one line (migration 0018), and the bot reads it quoted above the owner's words. The app shows the quote over the composer and over the owner's bubble, which jumps to the quoted item. --- app/src/features/bots/moments.test.tsx | 1 + app/src/features/chat/BotRun.tsx | 10 +- app/src/features/chat/ChatComposer.tsx | 21 ++++ app/src/features/chat/ChatView.tsx | 36 ++++-- app/src/features/chat/InboundRow.tsx | 24 ++-- app/src/features/chat/Replying.tsx | 93 +++++++++++++++ app/src/features/chat/reply.test.tsx | 60 ++++++++++ app/src/features/chat/rows.test.ts | 1 + app/src/i18n/en/chat.ts | 8 ++ app/src/i18n/es/chat.ts | 7 ++ app/src/i18n/pt-BR/chat.ts | 7 ++ app/src/lib/fakeConversation.ts | 24 +++- app/src/lib/fakeQuestions.ts | 1 + app/src/lib/protocol.gen.ts | 19 +++- crates/botloft-core/src/envelope.rs | 25 ++++ crates/botloft-core/src/protocol/export.rs | 1 + crates/botloft-core/src/protocol/messaging.rs | 19 +++- .../migrations/0018_message_reply.sql | 6 + crates/botloft-store/src/lib.rs | 1 + crates/botloft-store/src/messages.rs | 15 ++- crates/botloft-store/src/migrate.rs | 1 + crates/botloftd/src/courier/render.rs | 34 +++++- crates/botloftd/src/routines/fire.rs | 1 + crates/botloftd/src/service/delete.rs | 1 + crates/botloftd/src/service/messages.rs | 42 ++++++- crates/botloftd/src/service/questions.rs | 1 + crates/botloftd/src/service/tasks.rs | 2 + crates/botloftd/src/service/tasks/expire.rs | 1 + crates/botloftd/tests/reply_to.rs | 107 ++++++++++++++++++ crates/botloftd/tests/routine_runs.rs | 1 + docs/spec.md | 13 ++- 31 files changed, 550 insertions(+), 33 deletions(-) create mode 100644 app/src/features/chat/Replying.tsx create mode 100644 app/src/features/chat/reply.test.tsx create mode 100644 crates/botloft-store/migrations/0018_message_reply.sql create mode 100644 crates/botloftd/tests/reply_to.rs diff --git a/app/src/features/bots/moments.test.tsx b/app/src/features/bots/moments.test.tsx index 34182f71..d81a6d75 100644 --- a/app/src/features/bots/moments.test.tsx +++ b/app/src/features/bots/moments.test.tsx @@ -129,6 +129,7 @@ describe("glances", () => { routineId: null, questionId: null, attachments: [], + replyTo: null, createdAt: Date.now(), }; act(() => fake.emit({ name: "message.created", params: message })); diff --git a/app/src/features/chat/BotRun.tsx b/app/src/features/chat/BotRun.tsx index af75be12..a0034f5f 100644 --- a/app/src/features/chat/BotRun.tsx +++ b/app/src/features/chat/BotRun.tsx @@ -15,6 +15,7 @@ import { ApprovalCard } from "./ApprovalCard"; import { Markdown } from "./Markdown"; import { MemoryNote } from "./MemoryNote"; import { memoryOf, memoryPlaces } from "./memory"; +import { Repliable } from "./Replying"; import { runParts, splitDraft } from "./rows"; import { SharedFiles } from "./SharedFiles"; import { isSharedFiles } from "./shared"; @@ -160,7 +161,14 @@ function BotRunView({ } case "reply": // It was already on screen as it was written. - return ; + return ( + + + + ); case "approval": return ( diff --git a/app/src/features/chat/ChatComposer.tsx b/app/src/features/chat/ChatComposer.tsx index 89538a3c..ddd2afcb 100644 --- a/app/src/features/chat/ChatComposer.tsx +++ b/app/src/features/chat/ChatComposer.tsx @@ -28,6 +28,7 @@ import { EffortPicker } from "./EffortPicker"; import { rememberImage } from "./images"; import { ModelPicker } from "./ModelPicker"; import { ModePicker } from "./ModePicker"; +import { ReplyBar, type ReplyTarget } from "./Replying"; import type { Files, PendingFile } from "./useFiles"; const MAX_HEIGHT_PX = 240; @@ -64,12 +65,17 @@ export const ChatComposer = memo(function ChatComposer({ files, stopped, onSent, + replying = null, + onCancelReply, }: { bot: Bot; files: Files; /** The bot is paused or its crew is. */ stopped: boolean; onSent(): void; + /** What the message replies to (spec 9.3). */ + replying?: ReplyTarget | null; + onCancelReply?(): void; }) { const api = useApi(); const t = useT(); @@ -88,6 +94,13 @@ export const ChatComposer = memo(function ChatComposer({ const tooLong = text.length > FIELD_LIMITS.message; const empty = !text.trim() && files.files.length === 0; + // Replying starts in the field. + useEffect(() => { + if (replying) { + field.current?.focus(); + } + }, [replying]); + const working = bot.state === "busy" || bot.state === "needs_approval"; useEffect(() => { if (!working) { @@ -120,6 +133,7 @@ export const ChatComposer = memo(function ChatComposer({ botId: bot.id, body: text, ...(attachments.length > 0 ? { attachments } : {}), + ...(replying ? { replyTo: replying.itemId } : {}), }); // The daemon keeps the order it was given. message.attachments.forEach((attachment, index) => { @@ -130,6 +144,7 @@ export const ChatComposer = memo(function ChatComposer({ }); setText(""); files.clear(); + onCancelReply?.(); playSound("sent"); onSent(); } catch (failure) { @@ -142,6 +157,11 @@ export const ChatComposer = memo(function ChatComposer({ const enterSends = usePref(prefs.enterSends); const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && replying) { + event.preventDefault(); + onCancelReply?.(); + return; + } if (event.key !== "Enter" || event.nativeEvent.isComposing) { return; } @@ -171,6 +191,7 @@ export const ChatComposer = memo(function ChatComposer({

)}
+ {replying && onCancelReply && } {files.files.length > 0 && (
    {files.files.map((file) => ( diff --git a/app/src/features/chat/ChatView.tsx b/app/src/features/chat/ChatView.tsx index 1066c772..afd0abbd 100644 --- a/app/src/features/chat/ChatView.tsx +++ b/app/src/features/chat/ChatView.tsx @@ -24,6 +24,7 @@ import { BotRun, type Live } from "./BotRun"; import { ChatComposer } from "./ChatComposer"; import { InboundRow } from "./InboundRow"; import { NoticeRow } from "./NoticeRow"; +import { type ReplyTarget, StartReply } from "./Replying"; import { chatRows, rowHas } from "./rows"; import { useChat } from "./useChat"; import { useFiles } from "./useFiles"; @@ -51,6 +52,9 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto /** Distance from the bottom to keep while older items load above. */ const anchor = useRef(null); const [dragging, setDragging] = useState(false); + /** What the owner's next message replies to (spec 9.3). */ + const [replying, setReplying] = useState(null); + const cancelReply = useCallback(() => setReplying(null), []); // What was there when the owner opened this chat stays still; what // arrives while they look animates in. // biome-ignore lint/correctness/useExhaustiveDependencies: a new bot is a new chat @@ -104,7 +108,7 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto ); } if (row.kind === "inbound") { - return ; + return ; } if (row.kind === "notice") { return ; @@ -207,20 +211,30 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto

    {t.chat.view.emptyBody}

)} - -
    - {list} -
-
+ + +
    + {list} +
+
+
{/* Keyed by bot: each one has its own draft (spec 15.1). */} - +
{dragging && (
diff --git a/app/src/features/chat/InboundRow.tsx b/app/src/features/chat/InboundRow.tsx index 44a5acad..5f361a7f 100644 --- a/app/src/features/chat/InboundRow.tsx +++ b/app/src/features/chat/InboundRow.tsx @@ -12,6 +12,7 @@ import { BOTLOFT_COLOR, BotAvatar } from "../bots/BotAvatar"; import { DeliveryStatus } from "../messages/DeliveryStatus"; import { AttachmentList } from "./AttachmentList"; import { Mentions } from "./mentions"; +import { Repliable, ReplyQuote } from "./Replying"; function Time({ at }: { at: number }) { return ( @@ -26,6 +27,7 @@ function OwnerMessage({ message, bot }: { message: Message; bot: Bot }) { const arrival = useArrival(message.createdAt); return (
  • + {message.replyTo && } {message.body && (

    (message.fromBotId ? state.bots[message.fromBotId] : undefined)); const delivery = useApp((state) => state.deliveries[message.id]); @@ -118,12 +120,17 @@ function OtherMessage({ message }: { message: Message }) {

  • {(!routine || open) && ( -

    - -

    +

    + +

    + )} {delivery && delivery.state !== "sent" && (
    @@ -138,13 +145,16 @@ function OtherMessage({ message }: { message: Message }) { export const InboundRow = memo(function InboundRow({ message, bot, + itemId, }: { message: Message; bot: Bot; + /** The chat item that shows it, to reply to. */ + itemId: string; }) { return message.fromKind === "owner" ? ( ) : ( - + ); }); diff --git a/app/src/features/chat/Replying.tsx b/app/src/features/chat/Replying.tsx new file mode 100644 index 00000000..84f6a2b7 --- /dev/null +++ b/app/src/features/chat/Replying.tsx @@ -0,0 +1,93 @@ +// Replying to something in the bot's chat (spec 9.3): a button that shows +// on what can be quoted (the bot's replies, the messages it got), the +// quote over the composer, and the quote over the owner's bubble after. + +import { CornerUpLeft, X } from "lucide-react"; +import { createContext, type ReactNode, useContext } from "react"; +import { useT } from "../../i18n"; +import { plainText } from "../../lib/plainText"; +import type { ChatItemId, MessageReply } from "../../lib/protocol.gen"; +import { useApp } from "../../store/context"; + +/** What the owner is replying to, while they write. */ +export interface ReplyTarget { + itemId: ChatItemId; + /** Whose words: the bot's name, another bot's, or Botloft. */ + who: string; + text: string; +} + +/** Starts a reply; absent where the chat cannot be written to. */ +export const StartReply = createContext<((target: ReplyTarget) => void) | null>(null); + +/** Wraps what can be replied to, with the button that shows on hover. */ +export function Repliable({ + target, + className = "", + children, +}: { + target: ReplyTarget; + /** A bubble narrows it to its own width, for the button to sit on its corner. */ + className?: string; + children: ReactNode; +}) { + const t = useT().chat.reply; + const start = useContext(StartReply); + if (!start) { + return children; + } + return ( +
    + {children} + +
    + ); +} + +/** Over the composer: what the next message replies to, with a way out. */ +export function ReplyBar({ target, onCancel }: { target: ReplyTarget; onCancel(): void }) { + const t = useT().chat.reply; + return ( +
    + +
    +

    {t.replyingTo(target.who)}

    +

    {plainText(target.text)}

    +
    + +
    + ); +} + +/** Over the owner's bubble: what it replied to; a click shows it in the chat. */ +export function ReplyQuote({ botId, reply }: { botId: string; reply: MessageReply }) { + const t = useT().chat.reply; + const openAt = useApp((state) => state.openAt); + return ( + + ); +} diff --git a/app/src/features/chat/reply.test.tsx b/app/src/features/chat/reply.test.tsx new file mode 100644 index 00000000..f8cb0c5a --- /dev/null +++ b/app/src/features/chat/reply.test.tsx @@ -0,0 +1,60 @@ +import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { crewOpened, openBot, renderApp } from "../../test/app"; + +afterEach(cleanup); + +async function setup() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + const writer = fake.addBot(ops.id, "Writer", "Writes"); + fake.setBotState(scout.id, "idle"); + const said = fake.chat.reply(scout.id, "Acme signs monthly.\n\nDana approves."); + fake.chat.turn(scout.id); + fake.conversation.say({ from: writer.id, to: scout.id, body: "The draft is ready." }); + renderApp(fake); + await crewOpened("Ops"); + openBot("Scout"); + await screen.findByRole("list", { name: "Messages" }); + return { fake, scout, said }; +} + +const field = () => screen.getByRole("textbox", { name: "Message to Scout" }); +const sent = (fake: FakeBotloft) => + fake.calls.filter((call) => call.method === "messages.send").map((call) => call.params); + +describe("replying to a message", () => { + test("quotes the bot's reply over the composer, sends it, and shows it on the bubble", async () => { + const { fake, scout, said } = await setup(); + fireEvent.click(screen.getByRole("button", { name: "Reply to Scout" })); + expect(screen.getByText("Replying to Scout")).toBeDefined(); + expect(document.activeElement).toBe(field()); + + fireEvent.change(field(), { target: { value: "Make it yearly" } }); + fireEvent.keyDown(field(), { key: "Enter" }); + await waitFor(() => + expect(sent(fake)).toEqual([{ botId: scout.id, body: "Make it yearly", replyTo: said.id }]), + ); + // The bar leaves with the message, and the bubble keeps the quote. + await waitFor(() => expect(screen.queryByText("Replying to Scout")).toBeNull()); + const quote = await screen.findByRole("button", { name: "Acme signs monthly. Dana approves." }); + expect(quote.title).toBe("Show what this replies to"); + }); + + test("replies to another bot's message too, and Esc or X cancels", async () => { + const { fake } = await setup(); + fireEvent.click(screen.getByRole("button", { name: "Reply to Writer" })); + const bar = screen.getByText("Replying to Writer").parentElement as HTMLElement; + expect(within(bar).getByText("The draft is ready.")).toBeDefined(); + fireEvent.keyDown(field(), { key: "Escape" }); + expect(screen.queryByText("Replying to Writer")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Reply to Writer" })); + fireEvent.click(screen.getByRole("button", { name: "Cancel the reply" })); + fireEvent.change(field(), { target: { value: "Plain" } }); + fireEvent.keyDown(field(), { key: "Enter" }); + await waitFor(() => expect(sent(fake).at(-1)).not.toHaveProperty("replyTo")); + }); +}); diff --git a/app/src/features/chat/rows.test.ts b/app/src/features/chat/rows.test.ts index f08716a4..10ce0415 100644 --- a/app/src/features/chat/rows.test.ts +++ b/app/src/features/chat/rows.test.ts @@ -41,6 +41,7 @@ const inbound = (body: string, at?: number) => routineId: null, questionId: null, attachments: [], + replyTo: null, createdAt: at ?? start, }, }, diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts index 8601eda4..d7928649 100644 --- a/app/src/i18n/en/chat.ts +++ b/app/src/i18n/en/chat.ts @@ -95,6 +95,14 @@ export const chat = { changed: "What changed", now: "The memory now says:", }, + /** Replying to something in the chat (spec 9.3). */ + reply: { + action: "Reply", + to: (who: string) => `Reply to ${who}`, + replyingTo: (who: string) => `Replying to ${who}`, + cancel: "Cancel the reply", + jump: "Show what this replies to", + }, approval: { asks: (bot: string, action: string) => `${bot} asks to ${action}`, wants: (bot: string, action: string) => `${bot} wants to ${action}`, diff --git a/app/src/i18n/es/chat.ts b/app/src/i18n/es/chat.ts index b95f63f8..1b832958 100644 --- a/app/src/i18n/es/chat.ts +++ b/app/src/i18n/es/chat.ts @@ -88,6 +88,13 @@ export const chat: Messages["chat"] = { changed: "Lo que cambió", now: "Ahora la memoria dice:", }, + reply: { + action: "Responder", + to: (who) => `Responder a ${who}`, + replyingTo: (who) => `Respondiendo a ${who}`, + cancel: "Cancelar la respuesta", + jump: "Mostrar el mensaje respondido", + }, approval: { asks: (bot: string, action: string) => `${bot} pide ${action}`, wants: (bot: string, action: string) => `${bot} quiere ${action}`, diff --git a/app/src/i18n/pt-BR/chat.ts b/app/src/i18n/pt-BR/chat.ts index 93a106d8..53321e68 100644 --- a/app/src/i18n/pt-BR/chat.ts +++ b/app/src/i18n/pt-BR/chat.ts @@ -87,6 +87,13 @@ export const chat: Messages["chat"] = { changed: "O que mudou", now: "Agora a memória diz:", }, + reply: { + action: "Responder", + to: (who) => `Responder a ${who}`, + replyingTo: (who) => `Respondendo a ${who}`, + cancel: "Cancelar a resposta", + jump: "Mostrar a mensagem respondida", + }, approval: { asks: (bot: string, action: string) => `${bot} pede para ${action}`, wants: (bot: string, action: string) => `${bot} quer ${action}`, diff --git a/app/src/lib/fakeConversation.ts b/app/src/lib/fakeConversation.ts index 0655826c..debc4557 100644 --- a/app/src/lib/fakeConversation.ts +++ b/app/src/lib/fakeConversation.ts @@ -14,6 +14,7 @@ import { FIELD_LIMITS, type Message, type MessageKind, + type MessageReply, type Task, type TaskId, } from "./protocol.gen"; @@ -55,11 +56,31 @@ export class FakeConversation { routineId: null, questionId: null, attachments: [], + replyTo: null, createdAt: this.fake.now, }; return { message, delivery: this.record(message) }; } + /** What a reply quotes, as the daemon checks it (spec 9.3). */ + private quote(botId: BotId, itemId: string): MessageReply { + const item = this.fake.chat.items.find((one) => one.id === itemId && one.botId === botId); + if (!item) { + throw notFound(`chat item ${itemId}`); + } + const text = + item.body.kind === "reply" + ? item.body.text + : item.body.kind === "inbound" + ? item.body.message.body + : null; + if (text === null) { + throw invalid("reply_to: only a reply or a message can be quoted"); + } + const flat = text.split(/\s+/).filter(Boolean).join(" "); + return { itemId, text: flat.length > 300 ? `${flat.slice(0, 299)}…` : flat }; + } + /** The courier moves a delivery along. */ deliver(deliveryId: string, state: DeliveryState, lastError: string | null = null): Delivery { const delivery = this.delivery(deliveryId); @@ -111,7 +132,7 @@ export class FakeConversation { handlers(): Conversation { return { - "messages.send": ({ botId, body, attachments }) => { + "messages.send": ({ botId, body, attachments, replyTo }) => { const bot = this.fake.bot(botId); const files = attachments ?? []; if (!body.trim() && files.length === 0) { @@ -132,6 +153,7 @@ export class FakeConversation { routineId: null, questionId: null, attachments: files.map((file) => this.saved(file)), + replyTo: replyTo ? this.quote(botId, replyTo) : null, createdAt: this.fake.now, }; this.record(message); diff --git a/app/src/lib/fakeQuestions.ts b/app/src/lib/fakeQuestions.ts index d8a9ff9e..72eae181 100644 --- a/app/src/lib/fakeQuestions.ts +++ b/app/src/lib/fakeQuestions.ts @@ -88,6 +88,7 @@ export class FakeQuestions { routineId: null, questionId: question.id, attachments: [], + replyTo: null, createdAt: this.fake.now, }); return question; diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index ed9f4261..bbe172e3 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -620,11 +620,24 @@ questionId: QuestionId | null, * Files the owner attached (spec 9.5); empty for everything else. */ attachments: Array, +/** + * What in the bot's chat the owner replied to (spec 9.3). + */ +replyTo: MessageReply | null, /** * Unix time in milliseconds. */ createdAt: number, }; +/** + * The chat item a message replies to, and its text as quoted. + */ +export type MessageReply = { itemId: ChatItemId, +/** + * The start of the item's text, on one line (at most 300 characters). + */ +text: string, }; + /** * Getting one message into one bot's process. */ @@ -682,7 +695,11 @@ export type MessagesSendParams = { botId: BotId, body: string, /** * Up to 10 files (spec 9.5). */ -attachments?: Array, }; +attachments?: Array, +/** + * A reply of the bot, or a message it got, in its chat (spec 9.3). + */ +replyTo?: ChatItemId, }; /** * Newest first. Page back with `before` set to the oldest id received. diff --git a/crates/botloft-core/src/envelope.rs b/crates/botloft-core/src/envelope.rs index 8336b57d..1ed2c05e 100644 --- a/crates/botloft-core/src/envelope.rs +++ b/crates/botloft-core/src/envelope.rs @@ -141,6 +141,21 @@ pub fn answer(question_id: &str, question: &str, body: &str) -> String { ) } +/// Longest part of a chat item quoted in a message that replies to it, in +/// characters. +pub const QUOTED_REPLY_MAX_CHARS: usize = 300; + +/// The owner's message replying to something in the bot's chat (spec 9.3): +/// their words, after a line that quotes what they reply to. `quote` is +/// already on one line. +pub fn reply(quote: &str, body: &str) -> String { + format!( + "Replying to: \"{quote}\" + +{body}" + ) +} + /// Time left until `deadline_ms`, rounded for a reader who does not know /// the current time. pub fn due(deadline_ms: i64, now_ms: i64) -> String { @@ -220,6 +235,16 @@ mod tests { ); } + #[test] + fn a_reply_quotes_what_it_replies_to_above_the_owner_words() { + assert_eq!( + reply("Acme signs annual.", "Then send the yearly quote."), + "Replying to: \"Acme signs annual.\" + +Then send the yearly quote." + ); + } + #[test] fn a_deleted_sender_cannot_be_answered() { let text = envelope(Sender::DeletedBot, MessageKind::Note, None).render(0); diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index 10074136..25290c11 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -157,6 +157,7 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); diff --git a/crates/botloft-core/src/protocol/messaging.rs b/crates/botloft-core/src/protocol/messaging.rs index f2b0750d..6347b83b 100644 --- a/crates/botloft-core/src/protocol/messaging.rs +++ b/crates/botloft-core/src/protocol/messaging.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use crate::ids::{ - AttachmentId, BotId, CrewId, DeliveryId, MessageId, QuestionId, RoutineId, TaskId, + AttachmentId, BotId, ChatItemId, CrewId, DeliveryId, MessageId, QuestionId, RoutineId, TaskId, }; text_enum!( @@ -77,10 +77,23 @@ pub struct Message { pub question_id: Option, /// Files the owner attached (spec 9.5); empty for everything else. pub attachments: Vec, + /// What in the bot's chat the owner replied to (spec 9.3). + #[serde(default)] + pub reply_to: Option, /// Unix time in milliseconds. pub created_at: i64, } +/// The chat item a message replies to, and its text as quoted. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct MessageReply { + pub item_id: ChatItemId, + /// The start of the item's text, on one line (at most 300 characters). + pub text: String, +} + /// A file attached to a message, saved in the bot's folder. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -208,6 +221,10 @@ pub struct MessagesSendParams { #[serde(default)] #[cfg_attr(test, ts(optional))] pub attachments: Option>, + /// A reply of the bot, or a message it got, in its chat (spec 9.3). + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub reply_to: Option, } /// Newest first. Page back with `before` set to the oldest id received. diff --git a/crates/botloft-store/migrations/0018_message_reply.sql b/crates/botloft-store/migrations/0018_message_reply.sql new file mode 100644 index 00000000..fea65e49 --- /dev/null +++ b/crates/botloft-store/migrations/0018_message_reply.sql @@ -0,0 +1,6 @@ +-- Replying to something in a bot's chat (spec 9.3): the message keeps the +-- chat item it replies to (no foreign key: chat items leave before +-- messages when a bot is deleted, spec 7.6) and its text as quoted then. + +ALTER TABLE messages ADD COLUMN reply_item_id TEXT; +ALTER TABLE messages ADD COLUMN reply_text TEXT; diff --git a/crates/botloft-store/src/lib.rs b/crates/botloft-store/src/lib.rs index 9fd894c3..61360bac 100644 --- a/crates/botloft-store/src/lib.rs +++ b/crates/botloft-store/src/lib.rs @@ -213,6 +213,7 @@ pub(crate) mod tests { task_id: None, routine_id: None, question_id: None, + reply_to: None, attachments: Vec::new(), created_at: 0, }; diff --git a/crates/botloft-store/src/messages.rs b/crates/botloft-store/src/messages.rs index 3cbd4587..f4b52685 100644 --- a/crates/botloft-store/src/messages.rs +++ b/crates/botloft-store/src/messages.rs @@ -6,13 +6,13 @@ use botloft_core::ids::{ AttachmentId, BotId, ChatItemId, CrewId, MessageId, QuestionId, RoutineId, TaskId, }; use botloft_core::protocol::{ - Attachment, ChatBody, ChatItem, Delivery, InboundItem, Message, Task, + Attachment, ChatBody, ChatItem, Delivery, InboundItem, Message, MessageReply, Task, }; use rusqlite::{Connection, OptionalExtension, Row, params}; use crate::{Result, Store, cached_execute, cached_row, parse_column, to_sql_int}; -const COLUMNS: &str = "id, crew_id, from_kind, from_bot_id, to_bot_id, kind, body, task_id, created_at, routine_id, \n question_id"; +const COLUMNS: &str = "id, crew_id, from_kind, from_bot_id, to_bot_id, kind, body, task_id, created_at, routine_id, \n question_id, reply_item_id, reply_text"; fn from_row(row: &Row<'_>) -> rusqlite::Result { Ok(Message { @@ -27,6 +27,10 @@ fn from_row(row: &Row<'_>) -> rusqlite::Result { routine_id: optional_column(row, 9)?, question_id: optional_column(row, 10)?, attachments: Vec::new(), + reply_to: match (optional_column::(row, 11)?, row.get(12)?) { + (Some(item_id), Some(text)) => Some(MessageReply { item_id, text }), + _ => None, + }, created_at: row.get(8)?, }) } @@ -105,7 +109,7 @@ impl Store { cached_execute( conn, &format!( - "INSERT INTO messages ({COLUMNS}) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)" + "INSERT INTO messages ({COLUMNS}) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)" ), params![ message.id.as_str(), @@ -119,6 +123,11 @@ impl Store { message.created_at, message.routine_id.as_ref().map(RoutineId::as_str), message.question_id.as_ref().map(QuestionId::as_str), + message + .reply_to + .as_ref() + .map(|reply| reply.item_id.as_str()), + message.reply_to.as_ref().map(|reply| reply.text.as_str()), ], )?; for attachment in &message.attachments { diff --git a/crates/botloft-store/src/migrate.rs b/crates/botloft-store/src/migrate.rs index 178dace7..c7ff6b83 100644 --- a/crates/botloft-store/src/migrate.rs +++ b/crates/botloft-store/src/migrate.rs @@ -24,6 +24,7 @@ const MIGRATIONS: &[(u32, &str)] = &[ (15, include_str!("../migrations/0015_questions.sql")), (16, include_str!("../migrations/0016_chat_search.sql")), (17, include_str!("../migrations/0017_routine_signals.sql")), + (18, include_str!("../migrations/0018_message_reply.sql")), ]; /// Schema version after every migration has run. diff --git a/crates/botloftd/src/courier/render.rs b/crates/botloftd/src/courier/render.rs index ccacef79..a5ea3004 100644 --- a/crates/botloftd/src/courier/render.rs +++ b/crates/botloftd/src/courier/render.rs @@ -44,6 +44,10 @@ pub(super) fn render(message: &Message, context: &Context<'_>, now: i64) -> Rend // The owner is the session's user: their words go as written. SenderKind::Owner => { let body = with_attachment_list(&message.body, &message.attachments); + let body = match &message.reply_to { + Some(reply) => envelope::reply(&reply.text, &body), + None => body, + }; match context.question { Some((id, question)) => envelope::answer(id, question, &body), None => body, @@ -132,8 +136,8 @@ fn inline_image(attachment: &Attachment, workspace: &Path) -> Option { #[cfg(test)] mod tests { - use botloft_core::ids::{AttachmentId, BotId, CrewId, MessageId}; - use botloft_core::protocol::MessageKind; + use botloft_core::ids::{AttachmentId, BotId, ChatItemId, CrewId, MessageId}; + use botloft_core::protocol::{MessageKind, MessageReply}; use super::*; @@ -150,6 +154,7 @@ mod tests { routine_id: None, question_id: None, attachments: Vec::new(), + reply_to: None, created_at: 0, } } @@ -185,6 +190,31 @@ mod tests { assert!(text.ends_with("hi")); } + #[test] + fn a_reply_quotes_the_chat_item_above_the_owner_words() { + let dir = tempfile::tempdir().expect("tempdir"); + let context = Context { + crew_name: "Ops", + sender_handle: None, + task: None, + workspace: dir.path(), + routine: None, + question: None, + }; + let mut owner = message(SenderKind::Owner, "Make it yearly"); + owner.reply_to = Some(MessageReply { + item_id: ChatItemId::generate(), + text: "Acme signs monthly.".into(), + }); + let line = parse(&render(&owner, &context, 0)); + assert_eq!( + line["message"]["content"][0]["text"], + "Replying to: \"Acme signs monthly.\" + +Make it yearly" + ); + } + #[test] fn attachments_are_listed_and_small_images_go_inline() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/botloftd/src/routines/fire.rs b/crates/botloftd/src/routines/fire.rs index 2c0562fa..59005201 100644 --- a/crates/botloftd/src/routines/fire.rs +++ b/crates/botloftd/src/routines/fire.rs @@ -155,6 +155,7 @@ pub(super) fn attempt( task_id: None, routine_id: Some(routine.id.clone()), question_id: None, + reply_to: None, attachments: Vec::new(), created_at: now, }; diff --git a/crates/botloftd/src/service/delete.rs b/crates/botloftd/src/service/delete.rs index c2f05cf4..80347f41 100644 --- a/crates/botloftd/src/service/delete.rs +++ b/crates/botloftd/src/service/delete.rs @@ -185,6 +185,7 @@ fn tell(daemon: &Daemon, store: &Store, crew: &Crew, to: BotId, body: String) { task_id: None, routine_id: None, question_id: None, + reply_to: None, attachments: Vec::new(), created_at: daemon.clock.now_ms(), }; diff --git a/crates/botloftd/src/service/messages.rs b/crates/botloftd/src/service/messages.rs index 65b1e69a..d89493bd 100644 --- a/crates/botloftd/src/service/messages.rs +++ b/crates/botloftd/src/service/messages.rs @@ -1,12 +1,13 @@ //! `messages.*` operations, and posting a message for the courier to deliver //! (spec 9.1). -use botloft_core::ids::{DeliveryId, MessageId}; +use botloft_core::envelope::QUOTED_REPLY_MAX_CHARS; +use botloft_core::ids::{BotId, ChatItemId, DeliveryId, MessageId}; use botloft_core::protocol::{ - ChatItem, Delivery, DeliveryState, Message, MessageKind, MessagesListParams, - MessagesSendParams, SenderKind, Task, + ChatBody, ChatItem, Delivery, DeliveryState, Message, MessageKind, MessageReply, + MessagesListParams, MessagesSendParams, SenderKind, Task, }; -use botloft_core::validate; +use botloft_core::{chat, validate}; use botloft_store::{MessageFilter, Store}; use super::{ApiError, ApiResult, attachments, bots, crews}; @@ -36,6 +37,10 @@ pub fn send(daemon: &Daemon, params: MessagesSendParams) -> ApiResult { let store = daemon.store(); // Archived or deleted while the files were saved. let (crew, bot) = bots::active(&store, ¶ms.bot_id)?; + let reply_to = match ¶ms.reply_to { + Some(item) => Some(quote(&store, &bot.id, item)?), + None => None, + }; let message = Message { id: MessageId::generate(), crew_id: crew.id, @@ -47,12 +52,41 @@ pub fn send(daemon: &Daemon, params: MessagesSendParams) -> ApiResult { task_id: None, routine_id: None, question_id: None, + reply_to, attachments, created_at: now, }; post(daemon, &store, message, None) } +/// What the owner replies to (spec 9.3): something the bot wrote, or a +/// message it got, in its own chat, quoted on one line. +fn quote(store: &Store, bot: &BotId, item_id: &ChatItemId) -> ApiResult { + let item = store + .chat_item(item_id)? + .filter(|item| &item.bot_id == bot) + .ok_or_else(|| ApiError::NotFound(format!("chat item {item_id} does not exist")))?; + let text = match &item.body { + ChatBody::Reply(reply) => &reply.text, + ChatBody::Inbound(inbound) => &inbound.message.body, + _ => { + return Err(ApiError::validation( + "reply_to: only a reply or a message can be quoted", + )); + } + }; + let text = chat::one_line(text, QUOTED_REPLY_MAX_CHARS); + if text.is_empty() { + return Err(ApiError::validation( + "reply_to: the item has no text to quote", + )); + } + Ok(MessageReply { + item_id: item.id, + text, + }) +} + /// Stores the message with a pending delivery (and the task it creates), /// tells the app and wakes the courier. pub(crate) fn post( diff --git a/crates/botloftd/src/service/questions.rs b/crates/botloftd/src/service/questions.rs index 41f9e749..dd41475b 100644 --- a/crates/botloftd/src/service/questions.rs +++ b/crates/botloftd/src/service/questions.rs @@ -126,6 +126,7 @@ pub fn answer(daemon: &Daemon, params: QuestionsAnswerParams) -> ApiResult ApiResult<() task_id: Some(task.id.clone()), routine_id: None, question_id: None, + reply_to: None, attachments: Vec::new(), created_at: now, }; diff --git a/crates/botloftd/tests/reply_to.rs b/crates/botloftd/tests/reply_to.rs new file mode 100644 index 00000000..9292838b --- /dev/null +++ b/crates/botloftd/tests/reply_to.rs @@ -0,0 +1,107 @@ +//! The owner replying to something in a bot's chat (spec 9.3): the message +//! keeps what it quotes, and the bot reads the quote above the words. + +mod common; + +use botloft_core::protocol::BotState; +use common::TestDaemon; +use common::bots::text_of; +use common::stream; +use serde_json::{Value, json}; + +const NOT_FOUND: i64 = -32002; +const VALIDATION: i64 = -32004; + +async fn history(app: &mut common::Client, bot: &Value) -> Vec { + app.call("chat.history", json!({ "botId": bot["id"] })) + .await + .expect("history") + .as_array() + .expect("items") + .clone() +} + +fn of_kind<'a>(items: &'a [Value], kind: &str) -> &'a Value { + items + .iter() + .find(|item| item["body"]["kind"] == kind) + .expect("item of kind") +} + +#[tokio::test] +async fn a_reply_quotes_the_item_for_the_bot_and_keeps_it() { + let t = TestDaemon::start_supervised().await; + let mut app = t.session().await; + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let new_bot = + |name: &str| json!({ "crewId": crew["id"], "name": name, "role": "", "instructions": "" }); + let scout = app + .call("bots.create", new_bot("Scout")) + .await + .expect("bot"); + let writer = app + .call("bots.create", new_bot("Writer")) + .await + .expect("bot"); + t.until_state(&scout, BotState::Idle).await; + let process = t.process_of(&scout).await; + + // Scout answers a first message. + app.call( + "messages.send", + json!({ "botId": scout["id"], "body": "Who signs?" }), + ) + .await + .expect("send"); + let line = process.wait_lines(1).await.remove(0); + stream::answer(&process, &line, "Acme signs monthly.\nDana approves.").await; + t.until_state(&scout, BotState::Idle).await; + let items = history(&mut app, &scout).await; + let reply = of_kind(&items, "reply"); + + // The owner replies to it. + let params = json!({ "botId": scout["id"], "body": "Make it yearly", "replyTo": reply["id"] }); + let message = app.call("messages.send", params).await.expect("reply"); + assert_eq!(message["replyTo"]["itemId"], reply["id"]); + assert_eq!( + message["replyTo"]["text"], + "Acme signs monthly. Dana approves." + ); + let line = process.wait_lines(2).await.remove(1); + assert_eq!( + text_of(&line), + "Replying to: \"Acme signs monthly. Dana approves.\"\n\nMake it yearly" + ); + + // It stays with the message, as the chat and the lists read it. + let items = history(&mut app, &scout).await; + assert_eq!( + items[0]["body"]["message"]["replyTo"]["itemId"], + reply["id"] + ); + let listed = app + .call("messages.list", json!({ "botId": scout["id"] })) + .await + .expect("list"); + assert_eq!( + listed[0]["replyTo"]["text"], + "Acme signs monthly. Dana approves." + ); + + // Only an item of this bot's chat, with words. + let refused = |item: &Value, to: &Value| json!({ "botId": to["id"], "body": "Hm", "replyTo": item["id"] }); + for (params, expected) in [ + (refused(reply, &writer), NOT_FOUND), + (refused(of_kind(&items, "turn"), &scout), VALIDATION), + ] { + let code = app + .call("messages.send", params) + .await + .expect_err("refused") + .code; + assert_eq!(code, expected); + } +} diff --git a/crates/botloftd/tests/routine_runs.rs b/crates/botloftd/tests/routine_runs.rs index de19eb6e..c1a76c4a 100644 --- a/crates/botloftd/tests/routine_runs.rs +++ b/crates/botloftd/tests/routine_runs.rs @@ -76,6 +76,7 @@ async fn a_message_that_joins_the_runs_turn_leaves_the_run_to_it() { bot_id: s.bot.clone(), body: "one more thing".into(), attachments: None, + reply_to: None, }, ) .expect("send"); diff --git a/docs/spec.md b/docs/spec.md index ba7be71c..c0586a19 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -480,6 +480,14 @@ A resposta sai no stdout como `{"type":"control_response","response":{"subtype": A mensagem do **dono** vai como ele escreveu, sem envelope: é o usuário da sessão falando, com a autoridade de quem digita. A resposta a uma pergunta do bot vem depois de uma linha que cita a pergunta (23.4). +**Responder a algo do chat.** O dono pode responder a uma fala do bot (item `reply`) ou a uma message que ele recebeu (item `inbound`) no chat desse bot: `messages.send` leva `replyTo` com o id do item. O daemon confere que o item é do mesmo bot e tem texto (senão, `not_found` ou `validation`) e guarda na message `replyTo {itemId, text}`, com o texto numa linha só e cortado em 300 caracteres, como estava na hora. O bot recebe a citação antes das palavras do dono: + +``` +Replying to: "" + + +``` + Mensagens de **outros bots** e **avisos do daemon** levam um envelope em inglês, como as regras geradas (5.1): ``` @@ -645,7 +653,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `approvals.answer` | `approvalId, allow, note?, input?, always?` (`input`: a sugestão de bot como o dono a deixou, 10.2; `always`: com `allow`, grava o `always` do pedido como regra do bot, 10.1) | `Approval` | | `rules.list` | `botId` | `AllowRule[]`: o que o bot faz sem perguntar, da regra mais antiga à mais nova (10.1) | | `rules.delete` | `ruleId` | `{botId, rules}`: as regras que ficaram; o bot volta a perguntar pelo que saiu (10.1) | -| `messages.send` | `botId, body, attachments?` (`[{name, mediaType, data}]`, data em base64) | `Message` | +| `messages.send` | `botId, body, attachments?` (`[{name, mediaType, data}]`, data em base64), `replyTo?` (id de um item do chat do bot, 9.3) | `Message` | | `messages.list` | `crewId?, botId?, before?, limit?` | `Message[]` | | `attachments.read` | `attachmentId` | `{mediaType, data}`, data em base64 (9.5) | | `files.list` | `botId` | `BotFile[]`: os arquivos que o bot fez, do mais novo ao mais antigo (8.4) | @@ -688,7 +696,7 @@ Pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_time |---|---| | `crews` | `id, name, slug, paused, work_dir, lead_bot_id, created_at, archived_at` (`work_dir` é a pasta escolhida; `NULL` usa a `shared`. `lead_bot_id` é o chefe, sem chave estrangeira: ele é gravado junto com a crew) | | `bots` | `id, crew_id, name, handle, slug, role, instructions, color, paused, permission_mode, model, model_in_use, effort, effort_default, token_hash, session_id, created_at, archived_at` (`effort_default` é o nível do próprio modelo, como o Claude Code disse: `low` a `max`, `none` ou `NULL`, 7.4) | -| `messages` | `id, crew_id, from_kind (owner/bot/system), from_bot_id, to_bot_id, kind (note/task/result/system), body, task_id, created_at` | +| `messages` | `id, crew_id, from_kind (owner/bot/system), from_bot_id, to_bot_id, kind (note/task/result/system), body, task_id, created_at`; `reply_item_id` e `reply_text` quando o dono respondeu a algo do chat (9.3; sem chave estrangeira, porque os itens saem antes das messages na exclusão, 7.6) | | `attachments` | `id, message_id, name, media_type, size, path, created_at` | | `deliveries` | `id, message_id, bot_id, state, attempts, next_attempt_at, lease_until, last_error, sent_generation, turn_uuid, read_at, updated_at` | | `tasks` | `id, crew_id, requester_bot_id, assignee_bot_id, status, deadline_at, hops, origin_task_id, result, created_at, updated_at` | @@ -881,6 +889,7 @@ No chat: - O chat ocupa toda a largura que sobra, alinhado à esquerda, com o compositor na mesma largura (sem coluna centralizada, que deixava vazio dos dois lados); o dia é uma pílula no meio. - O dono fala em balões à direita; o bot, à esquerda, com markdown. +- Passando o mouse numa fala do bot ou numa message de outro bot, aparece no canto o botão de responder (a seta para a esquerda; pelo teclado, ele aparece com o foco). Ele leva a citação para cima do compositor, "Respondendo a " com o começo do texto e um X; o campo ganha o foco, e Esc cancela. Enviada, a citação vai junto (9.3) e aparece em cima do balão do dono, com uma borda de destaque; um clique leva até o item citado no chat. - Embaixo da mensagem do dono, e em cada linha da timeline da crew, fica onde ela está (9.1), com ícone e texto: esperando, entregando, "Entregue" com um tique e, quando o bot começou a trabalhar nela, "Lida" com dois tiques. Os dois tiques de "Lida" ficam em azul-claro, como nos apps de mensagem, para o dono ver de relance que o bot leu; a palavra continua na cor apagada das outras linhas, e é ela que diz o estado. A cor é o token `--read` (`#1787c9` no tema claro, `#5cc4f2` no escuro): mais clara que o `--work`, que é de bot trabalhando e de mensagem sendo entregue, e com contraste de ao menos 3:1 sobre `canvas`, `panel` e `sunken` nos dois temas, o que um teste confere. O azul dos apps de mensagem (`#53bdeb`) não passa de 2,2:1 no tema claro, por isso o claro usa um tom mais fechado. - Mensagens de outros bots aparecem à esquerda, com o avatar e o nome de quem mandou. - Um bot citado pelo handle ("@writer") nas respostas e nas messages fica em azul-claro (token `--mention`, que ainda lê a 4,5:1) e um pouco mais forte, para não se perder no texto. E-mail, caminho e o que está em código ou link não contam.