@@ -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.