diff --git a/app/src/features/chat/BotRun.tsx b/app/src/features/chat/BotRun.tsx index a0034f5f..ff86f15c 100644 --- a/app/src/features/chat/BotRun.tsx +++ b/app/src/features/chat/BotRun.tsx @@ -165,6 +165,7 @@ function BotRunView({ diff --git a/app/src/features/chat/ChatView.tsx b/app/src/features/chat/ChatView.tsx index 166af57e..66c30df3 100644 --- a/app/src/features/chat/ChatView.tsx +++ b/app/src/features/chat/ChatView.tsx @@ -25,6 +25,7 @@ import { BotRun, type Live } from "./BotRun"; import { ChatComposer } from "./ChatComposer"; import { InboundRow } from "./InboundRow"; import { NoticeRow } from "./NoticeRow"; +import { ReactionsOf, useReactions } from "./Reactions"; import { type ReplyTarget, StartReply } from "./Replying"; import { chatRows, rowHas } from "./rows"; import { useChat } from "./useChat"; @@ -58,6 +59,7 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto /** What the owner's next message replies to (spec 9.3). */ const [replying, setReplying] = useState(null); const cancelReply = useCallback(() => setReplying(null), []); + const reactions = useReactions(bot.id); // 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 @@ -215,15 +217,17 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto )} - -
    - {list} -
-
+ + +
    + {list} +
+
+
diff --git a/app/src/features/chat/Reactions.tsx b/app/src/features/chat/Reactions.tsx new file mode 100644 index 00000000..8931a937 --- /dev/null +++ b/app/src/features/chat/Reactions.tsx @@ -0,0 +1,139 @@ +// The owner's reactions to what the bot wrote (spec 8.9): a button beside +// the reply button opens the emoji, and the one picked sits under the reply, +// saying whether the bot will see it with the next message or already did. + +import { SmilePlus } from "lucide-react"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useT } from "../../i18n"; +import { REACTIONS, type Reaction } from "../../lib/protocol.gen"; +import { useApi, useApp } from "../../store/context"; +import { attempt } from "../../ui/toast"; +import { useDismiss } from "../../ui/useDismiss"; + +export interface Reactions { + byItem: Record; + set(itemId: string, emoji: string | null): void; +} + +/** The open chat's reactions; absent where none can be put. */ +export const ReactionsOf = createContext(null); + +/** The bot's reactions, loaded on each connection and kept current. */ +export function useReactions(botId: string): Reactions { + const api = useApi(); + const t = useT().chat.react; + const open = useApp((state) => state.connection.kind === "open"); + const [byItem, setByItem] = useState>({}); + useEffect(() => { + if (!open) return; + let live = true; + void api.call("reactions.list", { botId }).then( + (list) => live && setByItem(Object.fromEntries(list.map((one) => [one.itemId, one]))), + () => undefined, + ); + const stop = api.subscribe((event) => { + if (event.name !== "reaction.changed" || event.params.botId !== botId) return; + const { itemId, reaction } = event.params; + setByItem((before) => { + const { [itemId]: _, ...rest } = before; + return reaction ? { ...rest, [itemId]: reaction } : rest; + }); + }); + return () => { + live = false; + stop(); + }; + }, [api, botId, open]); + const set = useCallback( + (itemId: string, emoji: string | null) => + attempt(t.failed, () => api.call("reactions.set", { botId, itemId, emoji })), + [api, botId, t.failed], + ); + return useMemo(() => ({ byItem, set }), [byItem, set]); +} + +/** Opens the emoji to react with. */ +export function ReactButton({ itemId, bot }: { itemId: string; bot: string }) { + const t = useT().chat.react; + const reactions = useContext(ReactionsOf); + const [open, setOpen] = useState(false); + const root = useRef(null); + const close = useCallback(() => setOpen(false), []); + useDismiss(open, root, close); + if (!reactions) { + return null; + } + const current = reactions.byItem[itemId]?.emoji ?? null; + return ( +
+ + {open && ( +
+ {REACTIONS.map((emoji) => ( + + ))} +
+ )} +
+ ); +} + +/** Under the reply: the owner's reaction; a click takes it off. */ +export function ReactionMark({ itemId, bot }: { itemId: string; bot: string }) { + const t = useT().chat.react; + const reactions = useContext(ReactionsOf); + const reaction = reactions?.byItem[itemId]; + if (!reactions || !reaction) { + return null; + } + const seen = reaction.sentIn !== null; + return ( +
+ + {seen ? t.seen(bot) : t.waiting(bot)} +
+ ); +} diff --git a/app/src/features/chat/Replying.tsx b/app/src/features/chat/Replying.tsx index 84f6a2b7..48e42e1e 100644 --- a/app/src/features/chat/Replying.tsx +++ b/app/src/features/chat/Replying.tsx @@ -8,6 +8,7 @@ import { useT } from "../../i18n"; import { plainText } from "../../lib/plainText"; import type { ChatItemId, MessageReply } from "../../lib/protocol.gen"; import { useApp } from "../../store/context"; +import { ReactButton, ReactionMark } from "./Reactions"; /** What the owner is replying to, while they write. */ export interface ReplyTarget { @@ -20,14 +21,20 @@ export interface ReplyTarget { /** 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. */ +/** + * Wraps what can be replied to, with the buttons that show on hover: reply, + * and for the bot's own replies, react (spec 8.9). + */ export function Repliable({ target, + react = false, className = "", children, }: { target: ReplyTarget; - /** A bubble narrows it to its own width, for the button to sit on its corner. */ + /** The bot's own reply: the owner can react to it too. */ + react?: boolean; + /** A bubble narrows it to its own width, for the buttons to sit on its corner. */ className?: string; children: ReactNode; }) { @@ -39,15 +46,19 @@ export function Repliable({ return (
{children} - + {react && } +
+ {react && } + +
); } diff --git a/app/src/features/chat/reactions.test.tsx b/app/src/features/chat/reactions.test.tsx new file mode 100644 index 00000000..2dbdc8c6 --- /dev/null +++ b/app/src/features/chat/reactions.test.tsx @@ -0,0 +1,67 @@ +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 annual."); + 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 }; +} + +function react(emoji: string) { + fireEvent.click(screen.getByRole("button", { name: "React to what Scout wrote" })); + const menu = screen.getByRole("menu", { name: "React to what Scout wrote" }); + fireEvent.click(within(menu).getByRole("menuitemradio", { name: emoji })); +} + +describe("reacting to a reply", () => { + test("puts the emoji under the reply, waiting for the next message, then seen", async () => { + const { fake, scout, said } = await setup(); + // Only the bot's own replies take one. + expect(screen.getAllByRole("button", { name: /^React to/ })).toHaveLength(1); + + react("👍"); + expect(await screen.findByText("Scout sees it with your next message")).toBeDefined(); + expect(fake.calls.at(-1)).toMatchObject({ + method: "reactions.set", + params: { botId: scout.id, itemId: said.id, emoji: "👍" }, + }); + // The bot was not sent anything. + expect(fake.calls.some((call) => call.method === "messages.send")).toBe(false); + + const field = screen.getByRole("textbox", { name: "Message to Scout" }); + fireEvent.change(field, { target: { value: "Next?" } }); + fireEvent.keyDown(field, { key: "Enter" }); + expect(await screen.findByText("Scout saw it")).toBeDefined(); + }); + + test("another emoji replaces it, the same one or a click on it takes it off", async () => { + await setup(); + react("👍"); + await screen.findByRole("button", { name: "👍 · Take the reaction off" }); + react("🎉"); + await screen.findByRole("button", { name: "🎉 · Take the reaction off" }); + react("🎉"); + await waitFor(() => + expect(screen.queryByRole("button", { name: /Take the reaction off/ })).toBeNull(), + ); + react("❤️"); + fireEvent.click(await screen.findByRole("button", { name: "❤️ · Take the reaction off" })); + await waitFor(() => + expect(screen.queryByRole("button", { name: /Take the reaction off/ })).toBeNull(), + ); + }); +}); diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts index d7928649..b2d066eb 100644 --- a/app/src/i18n/en/chat.ts +++ b/app/src/i18n/en/chat.ts @@ -103,6 +103,15 @@ export const chat = { cancel: "Cancel the reply", jump: "Show what this replies to", }, + /** The owner's reactions to the bot's replies (spec 8.9). */ + react: { + action: "React", + to: (bot: string) => `React to what ${bot} wrote`, + remove: "Take the reaction off", + waiting: (bot: string) => `${bot} sees it with your next message`, + seen: (bot: string) => `${bot} saw it`, + failed: "Could not save the reaction", + }, 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 1b832958..d32c08f7 100644 --- a/app/src/i18n/es/chat.ts +++ b/app/src/i18n/es/chat.ts @@ -95,6 +95,14 @@ export const chat: Messages["chat"] = { cancel: "Cancelar la respuesta", jump: "Mostrar el mensaje respondido", }, + react: { + action: "Reaccionar", + to: (bot) => `Reaccionar a lo que escribió ${bot}`, + remove: "Quitar la reacción", + waiting: (bot) => `${bot} lo verá con tu próximo mensaje`, + seen: (bot) => `${bot} lo vio`, + failed: "No se pudo guardar la reacción", + }, 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 53321e68..30b0efd2 100644 --- a/app/src/i18n/pt-BR/chat.ts +++ b/app/src/i18n/pt-BR/chat.ts @@ -94,6 +94,14 @@ export const chat: Messages["chat"] = { cancel: "Cancelar a resposta", jump: "Mostrar a mensagem respondida", }, + react: { + action: "Reagir", + to: (bot) => `Reagir ao que ${bot} escreveu`, + remove: "Tirar a reação", + waiting: (bot) => `${bot} vê junto com sua próxima mensagem`, + seen: (bot) => `${bot} viu`, + failed: "Não foi possível salvar a reação", + }, approval: { asks: (bot: string, action: string) => `${bot} pede para ${action}`, wants: (bot: string, action: string) => `${bot} quer ${action}`, diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index a6c08a15..13b75c38 100644 --- a/app/src/lib/fake.ts +++ b/app/src/lib/fake.ts @@ -11,6 +11,7 @@ import { FakeConversation } from "./fakeConversation"; import { crewHandlers } from "./fakeCrews"; import { FakeFiles } from "./fakeFiles"; import { FakeQuestions } from "./fakeQuestions"; +import { FakeReactions } from "./fakeReactions"; import { FakeRoutines } from "./fakeRoutines"; import { conflict, invalid, notFound, slugify } from "./fakeRules"; import { FakeScreens } from "./fakeScreens"; @@ -77,6 +78,7 @@ export class FakeBotloft implements BotloftApi { readonly browser = new FakeBrowser(this); readonly screens = new FakeScreens(this); readonly questions = new FakeQuestions(this); + readonly reactions = new FakeReactions(this); /** Clock for created and updated times. */ now = Date.now(); closed = false; @@ -261,6 +263,7 @@ export class FakeBotloft implements BotloftApi { ...this.screens.handlers(), ...this.routines.handlers(), ...this.questions.handlers(), + ...this.reactions.handlers(), ...usageHandlers(this), ...searchHandlers(this), }; diff --git a/app/src/lib/fakeConversation.ts b/app/src/lib/fakeConversation.ts index debc4557..42abff50 100644 --- a/app/src/lib/fakeConversation.ts +++ b/app/src/lib/fakeConversation.ts @@ -221,6 +221,10 @@ export class FakeConversation { this.fake.emit({ name: "message.created", params: message }); this.fake.emit({ name: "delivery.changed", params: delivery }); this.fake.chat.add(message.toBotId, { kind: "inbound", message }); + // Every message of the owner takes the reactions waiting (spec 8.9). + if (message.fromKind === "owner") { + this.fake.reactions.sendWith(message.toBotId, message.id); + } return delivery; } diff --git a/app/src/lib/fakeReactions.ts b/app/src/lib/fakeReactions.ts new file mode 100644 index 00000000..ef089213 --- /dev/null +++ b/app/src/lib/fakeReactions.ts @@ -0,0 +1,69 @@ +// The fake daemon's reactions (spec 8.9): the owner's emoji on a bot's +// reply, waiting for the owner's next message, which takes them along. + +import type { FakeBotloft, Handlers } from "./fake"; +import { invalid, notFound } from "./fakeRules"; +import type { BotId, MessageId, Reaction } from "./protocol.gen"; +import { REACTIONS } from "./protocol.gen"; + +type ReactionMethods = Extract; + +export class FakeReactions { + readonly reactions = new Map(); + + constructor(private readonly fake: FakeBotloft) {} + + private changed(botId: BotId, itemId: string, reaction: Reaction | null) { + this.fake.emit({ name: "reaction.changed", params: { botId, itemId, reaction } }); + } + + /** The owner's message `messageId` to `botId` takes what waits. */ + sendWith(botId: BotId, messageId: MessageId): void { + for (const reaction of this.reactions.values()) { + if (reaction.botId === botId && reaction.sentIn === null) { + reaction.sentIn = messageId; + this.changed(botId, reaction.itemId, { ...reaction }); + } + } + } + + handlers(): Pick { + return { + "reactions.list": ({ botId }) => { + this.fake.bot(botId); + return [...this.reactions.values()] + .filter((reaction) => reaction.botId === botId) + .map((reaction) => ({ ...reaction })); + }, + "reactions.set": ({ botId, itemId, emoji }) => { + if (emoji !== null && !(REACTIONS as readonly string[]).includes(emoji)) { + throw invalid(`emoji must be one of ${REACTIONS.join(" ")}`); + } + this.fake.bot(botId); + const item = this.fake.chat.items.find((one) => one.id === itemId && one.botId === botId); + if (!item) { + throw notFound(`chat item ${itemId}`); + } + if (item.body.kind !== "reply") { + throw invalid("only a reply of the bot takes a reaction"); + } + if (emoji === null) { + if (this.reactions.delete(itemId)) { + this.changed(botId, itemId, null); + } + return null; + } + const reaction: Reaction = { + botId, + itemId, + emoji, + createdAt: this.fake.now, + sentIn: null, + }; + this.reactions.set(itemId, reaction); + this.changed(botId, itemId, { ...reaction }); + return { ...reaction }; + }, + }; + } +} diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index bbe172e3..c9034d31 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -918,6 +918,40 @@ export type QuestionsAnswerParams = { questionId: QuestionId, answer: string, }; export type QuestionIdParams = { questionId: QuestionId, }; +/** The emoji the owner can react with (spec 8.9). */ +export const REACTIONS = ["👍", "❤️", "😂", "🎉", "🙏", "👀"] as const; + +export type Reaction = { botId: BotId, +/** + * The bot's reply it is on. + */ +itemId: ChatItemId, +/** + * One of [`REACTIONS`]. + */ +emoji: string, +/** + * Unix time in milliseconds. + */ +createdAt: number, +/** + * The owner's message that took it to the bot; `null` while it waits + * for one. + */ +sentIn: MessageId | null, }; + +export type ReactionsListParams = { botId: BotId, }; + +/** + * Puts the owner's reaction on a reply, or takes it off with `null`. + */ +export type ReactionsSetParams = { botId: BotId, itemId: ChatItemId, emoji: string | null, }; + +/** + * A reaction was put, changed, sent or taken off (`reaction` is `null`). + */ +export type ReactionChanged = { botId: BotId, itemId: ChatItemId, reaction: Reaction | null, }; + export type TurnItem = { durationMs: number, /** * Tokens the turn used (spec 8.7); `null` when Claude Code did not say @@ -1433,6 +1467,8 @@ export interface RpcMethods { "questions.list": { params: QuestionsListParams; result: Array }; "questions.answer": { params: QuestionsAnswerParams; result: Question }; "questions.dismiss": { params: QuestionIdParams; result: Question }; + "reactions.list": { params: ReactionsListParams; result: Array }; + "reactions.set": { params: ReactionsSetParams; result: Reaction | null }; "chat.search": { params: ChatSearchParams; result: Array }; "browser.list": { params: undefined; result: Array }; "browser.watch": { params: BrowserWatchParams; result: BrowserView }; @@ -1466,6 +1502,7 @@ export interface RpcNotifications { "routine.changed": Routine; "routine.run": RoutineRun; "question.changed": Question; + "reaction.changed": ReactionChanged; "bot.rules": BotRules; "browser.changed": BrowserState; "browser.action": BrowserAction; diff --git a/crates/botloft-core/src/envelope.rs b/crates/botloft-core/src/envelope.rs index 1ed2c05e..2570ba07 100644 --- a/crates/botloft-core/src/envelope.rs +++ b/crates/botloft-core/src/envelope.rs @@ -156,6 +156,31 @@ pub fn reply(quote: &str, body: &str) -> String { ) } +/// Longest part of a reply quoted with a reaction to it, in characters. +pub const QUOTED_REACTION_MAX_CHARS: usize = 120; + +/// The owner's reactions since their last message (spec 8.9), one line +/// each, above what that message says. Each is an emoji and the reply it +/// is on, already quoted on one line. +pub fn with_reactions(reactions: &[(String, String)], text: &str) -> String { + if reactions.is_empty() { + return text.to_owned(); + } + let lines: Vec = reactions + .iter() + .map(|(emoji, quote)| format!("Reacted {emoji} to: \"{quote}\"")) + .collect(); + format!( + "{} + +{text}", + lines.join( + " +" + ) + ) +} + /// 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 { @@ -245,6 +270,22 @@ Then send the yearly quote." ); } + #[test] + fn reactions_go_above_the_owner_words_one_line_each() { + let reactions = [ + ("👍".to_owned(), "Acme signs annual.".to_owned()), + ("🎉".to_owned(), "All tests pass.".to_owned()), + ]; + assert_eq!( + with_reactions(&reactions, "Next?"), + "Reacted 👍 to: \"Acme signs annual.\" +Reacted 🎉 to: \"All tests pass.\" + +Next?" + ); + assert_eq!(with_reactions(&[], "Next?"), "Next?"); + } + #[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 25290c11..70129ad3 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -18,6 +18,8 @@ use crate::ids::{ mod browser; #[path = "export_questions.rs"] mod questions; +#[path = "export_reactions.rs"] +mod reactions; #[path = "export_routines.rs"] mod routines; #[path = "export_rules.rs"] @@ -176,6 +178,7 @@ fn export_bindings() { out.decl::(); usage::decls(&mut out); questions::decls(&mut out); + reactions::decls(&mut out); out.decl::(); out.decl::(); out.decl::(); @@ -337,6 +340,7 @@ fn export_bindings() { rules::methods(&mut out); routines::methods(&mut out); questions::methods(&mut out); + reactions::methods(&mut out); search::methods(&mut out); browser::methods(&mut out); out.text.push_str("}\n\n"); @@ -383,6 +387,7 @@ fn export_bindings() { session::notifications(&mut out); routines::notifications(&mut out); questions::notifications(&mut out); + reactions::notifications(&mut out); rules::notifications(&mut out); browser::notifications(&mut out); out.text.push_str("}\n\n"); diff --git a/crates/botloft-core/src/protocol/export_reactions.rs b/crates/botloft-core/src/protocol/export_reactions.rs new file mode 100644 index 00000000..b3e301a7 --- /dev/null +++ b/crates/botloft-core/src/protocol/export_reactions.rs @@ -0,0 +1,46 @@ +//! The reactions part of the TypeScript bindings (spec 8.9). + +use std::fmt::Write as _; + +use super::super::*; +use super::Out; + +pub(super) fn decls(out: &mut Out) { + let emoji: Vec = REACTIONS + .iter() + .map(|emoji| format!("\"{emoji}\"")) + .collect(); + let _ = writeln!( + out.text, + "/** The emoji the owner can react with (spec 8.9). */ +export const REACTIONS = [{}] as const; +", + emoji.join(", ") + ); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); +} + +pub(super) fn methods(out: &mut Out) { + out.method( + method::REACTIONS_LIST, + &out.name::(), + &out.name::>(), + ); + out.method( + method::REACTIONS_SET, + &out.name::(), + &out.name::>(), + ); +} + +pub(super) fn notifications(out: &mut Out) { + let changed = out.name::(); + let _ = writeln!( + out.text, + " \"{}\": {changed};", + notification::REACTION_CHANGED + ); +} diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index f07a28bc..f8b9a963 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -59,6 +59,7 @@ mod messaging; mod methods; mod model; mod questions; +mod reactions; mod routines; mod rules; mod screens; @@ -75,6 +76,7 @@ pub use messaging::*; pub use methods::*; pub use model::*; pub use questions::*; +pub use reactions::*; pub use routines::*; pub use rules::*; pub use screens::*; @@ -148,6 +150,8 @@ pub mod method { pub const QUESTIONS_LIST: &str = "questions.list"; pub const QUESTIONS_ANSWER: &str = "questions.answer"; pub const QUESTIONS_DISMISS: &str = "questions.dismiss"; + pub const REACTIONS_LIST: &str = "reactions.list"; + pub const REACTIONS_SET: &str = "reactions.set"; pub const SETTINGS_GET: &str = "settings.get"; pub const SETTINGS_UPDATE: &str = "settings.update"; pub const USAGE_TOKENS: &str = "usage.tokens"; @@ -176,6 +180,7 @@ pub mod notification { pub const BROWSER_FRAME: &str = "browser.frame"; pub const SCREEN_DRAFT: &str = "screen.draft"; pub const QUESTION_CHANGED: &str = "question.changed"; + pub const REACTION_CHANGED: &str = "reaction.changed"; } /// Error codes: the standard JSON-RPC ones plus the daemon's (spec 11.4). diff --git a/crates/botloft-core/src/protocol/reactions.rs b/crates/botloft-core/src/protocol/reactions.rs new file mode 100644 index 00000000..e0acceb3 --- /dev/null +++ b/crates/botloft-core/src/protocol/reactions.rs @@ -0,0 +1,53 @@ +//! The owner's reactions to what a bot wrote (spec 8.9): one emoji per +//! reply, which the bot reads with the owner's next message instead of +//! being woken for it. With the params of the `reactions.*` methods. + +use serde::{Deserialize, Serialize}; + +use crate::ids::{BotId, ChatItemId, MessageId}; + +/// The emoji the owner can react with. +pub const REACTIONS: &[&str] = &["👍", "❤️", "😂", "🎉", "🙏", "👀"]; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct Reaction { + pub bot_id: BotId, + /// The bot's reply it is on. + pub item_id: ChatItemId, + /// One of [`REACTIONS`]. + pub emoji: String, + /// Unix time in milliseconds. + pub created_at: i64, + /// The owner's message that took it to the bot; `null` while it waits + /// for one. + pub sent_in: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct ReactionsListParams { + pub bot_id: BotId, +} + +/// Puts the owner's reaction on a reply, or takes it off with `null`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct ReactionsSetParams { + pub bot_id: BotId, + pub item_id: ChatItemId, + pub emoji: Option, +} + +/// A reaction was put, changed, sent or taken off (`reaction` is `null`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct ReactionChanged { + pub bot_id: BotId, + pub item_id: ChatItemId, + pub reaction: Option, +} diff --git a/crates/botloft-store/migrations/0019_reactions.sql b/crates/botloft-store/migrations/0019_reactions.sql new file mode 100644 index 00000000..5d6e8107 --- /dev/null +++ b/crates/botloft-store/migrations/0019_reactions.sql @@ -0,0 +1,15 @@ +-- The owner's reactions to what a bot wrote (spec 8.9): one per reply, +-- with the reply's text as quoted to the bot, and the owner's message that +-- took it there (null while it waits for the next one). + +CREATE TABLE reactions ( + item_id TEXT PRIMARY KEY REFERENCES chat_items (id), + bot_id TEXT NOT NULL REFERENCES bots (id), + emoji TEXT NOT NULL, + quote TEXT NOT NULL, + created_at INTEGER NOT NULL, + sent_in TEXT REFERENCES messages (id) +); + +CREATE INDEX reactions_bot ON reactions (bot_id, sent_in); +CREATE INDEX reactions_sent_in ON reactions (sent_in) WHERE sent_in IS NOT NULL; diff --git a/crates/botloft-store/src/delete.rs b/crates/botloft-store/src/delete.rs index fe436fbc..c453884a 100644 --- a/crates/botloft-store/src/delete.rs +++ b/crates/botloft-store/src/delete.rs @@ -14,6 +14,8 @@ const DELETE_BOT: &[&str] = &[ "UPDATE messages SET question_id = NULL \ WHERE question_id IN (SELECT id FROM questions WHERE bot_id = ?1)", "DELETE FROM questions WHERE bot_id = ?1", + // Reactions point at chat items and at the messages that took them. + "DELETE FROM reactions WHERE bot_id = ?1", "DELETE FROM approvals WHERE bot_id = ?1", "DELETE FROM chat_items WHERE bot_id = ?1", "DELETE FROM browser_sites WHERE bot_id = ?1", diff --git a/crates/botloft-store/src/lib.rs b/crates/botloft-store/src/lib.rs index 61360bac..e120ef35 100644 --- a/crates/botloft-store/src/lib.rs +++ b/crates/botloft-store/src/lib.rs @@ -17,6 +17,7 @@ mod migrate; #[cfg(test)] mod plans; mod questions; +mod reactions; mod routine_runs; mod routines; mod search; diff --git a/crates/botloft-store/src/messages.rs b/crates/botloft-store/src/messages.rs index f4b52685..3ef10d5d 100644 --- a/crates/botloft-store/src/messages.rs +++ b/crates/botloft-store/src/messages.rs @@ -6,7 +6,7 @@ use botloft_core::ids::{ AttachmentId, BotId, ChatItemId, CrewId, MessageId, QuestionId, RoutineId, TaskId, }; use botloft_core::protocol::{ - Attachment, ChatBody, ChatItem, Delivery, InboundItem, Message, MessageReply, Task, + Attachment, ChatBody, ChatItem, Delivery, InboundItem, Message, MessageReply, SenderKind, Task, }; use rusqlite::{Connection, OptionalExtension, Row, params}; @@ -147,6 +147,10 @@ impl Store { )?; } Self::insert_delivery_in(conn, delivery)?; + // Every message of the owner takes the reactions waiting for one. + if message.from_kind == SenderKind::Owner { + Self::send_reactions_in(conn, &message.to_bot_id, &message.id)?; + } let item = ChatItem { id: ChatItemId::generate(), bot_id: message.to_bot_id.clone(), diff --git a/crates/botloft-store/src/migrate.rs b/crates/botloft-store/src/migrate.rs index c7ff6b83..33de452f 100644 --- a/crates/botloft-store/src/migrate.rs +++ b/crates/botloft-store/src/migrate.rs @@ -25,6 +25,7 @@ const MIGRATIONS: &[(u32, &str)] = &[ (16, include_str!("../migrations/0016_chat_search.sql")), (17, include_str!("../migrations/0017_routine_signals.sql")), (18, include_str!("../migrations/0018_message_reply.sql")), + (19, include_str!("../migrations/0019_reactions.sql")), ]; /// Schema version after every migration has run. diff --git a/crates/botloft-store/src/reactions.rs b/crates/botloft-store/src/reactions.rs new file mode 100644 index 00000000..e386410b --- /dev/null +++ b/crates/botloft-store/src/reactions.rs @@ -0,0 +1,88 @@ +//! `reactions` table (spec 8.9): the owner's emoji on a bot's reply, which +//! the owner's next message takes to the bot. + +use botloft_core::ids::{BotId, ChatItemId, MessageId}; +use botloft_core::protocol::Reaction; +use rusqlite::{Connection, Row, params}; + +use crate::messages::optional_column; +use crate::{Result, Store, cached_execute, parse_column}; + +const COLUMNS: &str = "bot_id, item_id, emoji, created_at, sent_in"; + +fn from_row(row: &Row<'_>) -> rusqlite::Result { + Ok(Reaction { + bot_id: parse_column(row, 0)?, + item_id: parse_column(row, 1)?, + emoji: row.get(2)?, + created_at: row.get(3)?, + sent_in: optional_column(row, 4)?, + }) +} + +impl Store { + /// Puts or changes the owner's reaction on a reply. A changed one waits + /// for the next message again. + pub fn set_reaction(&self, reaction: &Reaction, quote: &str) -> Result<()> { + cached_execute( + &self.conn, + "INSERT INTO reactions (bot_id, item_id, emoji, quote, created_at, sent_in) \ + VALUES (?1, ?2, ?3, ?4, ?5, NULL) \ + ON CONFLICT (item_id) DO UPDATE SET \ + emoji = excluded.emoji, quote = excluded.quote, \ + created_at = excluded.created_at, sent_in = NULL", + params![ + reaction.bot_id.as_str(), + reaction.item_id.as_str(), + reaction.emoji, + quote, + reaction.created_at, + ], + )?; + Ok(()) + } + + /// Takes the reaction off; whether there was one. + pub fn remove_reaction(&self, item: &ChatItemId) -> Result { + let removed = cached_execute( + &self.conn, + "DELETE FROM reactions WHERE item_id = ?1", + [item.as_str()], + )?; + Ok(removed > 0) + } + + /// The bot's reactions, oldest first. + pub fn reactions(&self, bot: &BotId) -> Result> { + let mut stmt = self.conn.prepare_cached(&format!( + "SELECT {COLUMNS} FROM reactions WHERE bot_id = ?1 ORDER BY created_at, item_id" + ))?; + let rows = stmt.query_map([bot.as_str()], from_row)?; + Ok(rows.collect::>()?) + } + + /// The reactions `message` took to its bot, with what each quotes, + /// oldest first. + pub fn reactions_sent_in(&self, message: &MessageId) -> Result> { + let mut stmt = self.conn.prepare_cached(&format!( + "SELECT {COLUMNS}, quote FROM reactions WHERE sent_in = ?1 ORDER BY created_at, item_id" + ))?; + let rows = stmt.query_map([message.as_str()], |row| Ok((from_row(row)?, row.get(5)?)))?; + Ok(rows.collect::>()?) + } + + /// Gives the bot's waiting reactions to the owner's `message`, inside + /// the caller's transaction (spec 8.9). + pub(crate) fn send_reactions_in( + conn: &Connection, + bot: &BotId, + message: &MessageId, + ) -> Result<()> { + cached_execute( + conn, + "UPDATE reactions SET sent_in = ?2 WHERE bot_id = ?1 AND sent_in IS NULL", + params![bot.as_str(), message.as_str()], + )?; + Ok(()) + } +} diff --git a/crates/botloftd/src/courier/mod.rs b/crates/botloftd/src/courier/mod.rs index 209d6550..7c0bc6c1 100644 --- a/crates/botloftd/src/courier/mod.rs +++ b/crates/botloftd/src/courier/mod.rs @@ -214,6 +214,11 @@ fn prepare(daemon: &Daemon, store: &Store, delivery: &Delivery) -> Result Result, /// The question an answer is for: its id and text. question: Option<(String, String)>, + /// The owner's reactions it takes to the bot: emoji and quote. + reactions: Vec<(String, String)>, } impl Draft { @@ -250,6 +258,7 @@ impl Draft { .question .as_ref() .map(|(id, text)| (id.as_str(), text.as_str())), + reactions: &self.reactions, }; render(&self.message, &context, now) } diff --git a/crates/botloftd/src/courier/render.rs b/crates/botloftd/src/courier/render.rs index a5ea3004..f08fd44a 100644 --- a/crates/botloftd/src/courier/render.rs +++ b/crates/botloftd/src/courier/render.rs @@ -30,6 +30,9 @@ pub(super) struct Context<'a> { pub routine: Option<&'a RoutineContext>, /// For the owner's answer to a question: its id and text (spec 23.4). pub question: Option<(&'a str, &'a str)>, + /// For the owner's message: the reactions it takes along, emoji and + /// quote (spec 8.9). + pub reactions: &'a [(String, String)], } pub(super) struct Rendered { @@ -48,10 +51,11 @@ pub(super) fn render(message: &Message, context: &Context<'_>, now: i64) -> Rend Some(reply) => envelope::reply(&reply.text, &body), None => body, }; - match context.question { + let text = match context.question { Some((id, question)) => envelope::answer(id, question, &body), None => body, - } + }; + envelope::with_reactions(context.reactions, &text) } SenderKind::Bot => { let from = match context.sender_handle { @@ -174,6 +178,7 @@ mod tests { workspace: dir.path(), routine: None, question: None, + reactions: &[], }; let owner = render(&message(SenderKind::Owner, "Ship it"), &context, 0); let line = parse(&owner); @@ -200,6 +205,7 @@ mod tests { workspace: dir.path(), routine: None, question: None, + reactions: &[], }; let mut owner = message(SenderKind::Owner, "Make it yearly"); owner.reply_to = Some(MessageReply { @@ -244,6 +250,7 @@ Make it yearly" workspace: dir.path(), routine: None, question: None, + reactions: &[], }; let line = parse(&render(&owner, &context, 0)); let content = line["message"]["content"].as_array().expect("blocks"); diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index 297bb110..1014d011 100644 --- a/crates/botloftd/src/rpc/dispatch.rs +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -9,7 +9,8 @@ use super::jsonrpc::{RpcError, empty_params}; use crate::approvals; use crate::service::{ self, ApiResult, archive, attachments, bots, chat, crews, delete, deliveries, files, lead, - messages, models, modes, questions, routines, rules, screens, settings, tasks, usage, + messages, models, modes, questions, reactions, routines, rules, screens, settings, tasks, + usage, }; use crate::state::Daemon; @@ -67,6 +68,8 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(questions::list(daemon, parse(params)?)), method::QUESTIONS_ANSWER => reply(questions::answer(daemon, parse(params)?)), method::QUESTIONS_DISMISS => reply(questions::dismiss(daemon, parse(params)?)), + method::REACTIONS_LIST => reply(reactions::list(daemon, parse(params)?)), + method::REACTIONS_SET => reply(reactions::set(daemon, parse(params)?)), method::SCREENS_LIST => reply(screens::list(daemon, parse(params)?)), method::SETTINGS_GET => reply(settings::get(daemon)), method::SETTINGS_UPDATE => reply(settings::update(daemon, parse(params)?)), diff --git a/crates/botloftd/src/rpc/mod.rs b/crates/botloftd/src/rpc/mod.rs index 97e67fe1..c5e949c9 100644 --- a/crates/botloftd/src/rpc/mod.rs +++ b/crates/botloftd/src/rpc/mod.rs @@ -290,5 +290,6 @@ fn to_notification(event: &Event) -> String { Event::BrowserAction(action) => note(notification::BROWSER_ACTION, action), Event::ScreenDraft(draft) => note(notification::SCREEN_DRAFT, draft), Event::QuestionChanged(question) => note(notification::QUESTION_CHANGED, question), + Event::ReactionChanged(change) => note(notification::REACTION_CHANGED, change), } } diff --git a/crates/botloftd/src/service/messages.rs b/crates/botloftd/src/service/messages.rs index d89493bd..eb3a3f47 100644 --- a/crates/botloftd/src/service/messages.rs +++ b/crates/botloftd/src/service/messages.rs @@ -10,7 +10,7 @@ use botloft_core::protocol::{ use botloft_core::{chat, validate}; use botloft_store::{MessageFilter, Store}; -use super::{ApiError, ApiResult, attachments, bots, crews}; +use super::{ApiError, ApiResult, attachments, bots, crews, reactions}; use crate::chat::items; use crate::state::{Daemon, Event}; @@ -97,7 +97,9 @@ pub(crate) fn post( ) -> ApiResult { let delivery = pending_delivery(&message); let item = store.insert_message(&message, &delivery, task.as_ref())?; + let reactions = reactions::sent_with(store, &message)?; announce(daemon, task, &message, delivery, item); + reactions::announce_sent(daemon, reactions); Ok(message) } diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs index d9f2650c..d2ec2a1b 100644 --- a/crates/botloftd/src/service/mod.rs +++ b/crates/botloftd/src/service/mod.rs @@ -14,6 +14,7 @@ pub mod messages; pub mod models; pub mod modes; pub mod questions; +pub mod reactions; pub mod routines; pub mod rules; pub mod screens; diff --git a/crates/botloftd/src/service/questions.rs b/crates/botloftd/src/service/questions.rs index dd41475b..a48c6751 100644 --- a/crates/botloftd/src/service/questions.rs +++ b/crates/botloftd/src/service/questions.rs @@ -11,7 +11,7 @@ use botloft_core::protocol::{ use botloft_core::validate; use botloft_store::Store; -use super::{ApiError, ApiResult, bots, messages}; +use super::{ApiError, ApiResult, bots, messages, reactions}; use crate::chat::items; use crate::state::{Daemon, Event}; @@ -134,9 +134,11 @@ pub fn answer(daemon: &Daemon, params: QuestionsAnswerParams) -> ApiResult ApiResult> { + let store = daemon.store(); + bots::active(&store, ¶ms.bot_id)?; + Ok(store.reactions(¶ms.bot_id)?) +} + +/// Puts, changes or takes off the owner's reaction on one of the bot's +/// replies; the reaction as it is now. +pub fn set(daemon: &Daemon, params: ReactionsSetParams) -> ApiResult> { + if let Some(emoji) = ¶ms.emoji + && !REACTIONS.contains(&emoji.as_str()) + { + return Err(ApiError::validation(format!( + "emoji must be one of {}", + REACTIONS.join(" ") + ))); + } + let store = daemon.store(); + let (_, bot) = bots::active(&store, ¶ms.bot_id)?; + let item = store + .chat_item(¶ms.item_id)? + .filter(|item| item.bot_id == bot.id) + .ok_or_else(|| { + ApiError::NotFound(format!("chat item {} does not exist", params.item_id)) + })?; + let ChatBody::Reply(reply) = &item.body else { + return Err(ApiError::validation( + "only a reply of the bot takes a reaction", + )); + }; + let reaction = match params.emoji { + Some(emoji) => { + let reaction = Reaction { + bot_id: bot.id.clone(), + item_id: item.id.clone(), + emoji, + created_at: daemon.clock.now_ms(), + sent_in: None, + }; + store.set_reaction( + &reaction, + &chat::one_line(&reply.text, QUOTED_REACTION_MAX_CHARS), + )?; + Some(reaction) + } + None if store.remove_reaction(&item.id)? => None, + // There was none: nothing changed. + None => return Ok(None), + }; + drop(store); + daemon.emit(Event::ReactionChanged(ReactionChanged { + bot_id: bot.id, + item_id: item.id, + reaction: reaction.clone(), + })); + Ok(reaction) +} + +/// The reactions an owner's message just took to its bot, to tell the app. +pub(crate) fn sent_with(store: &Store, message: &Message) -> ApiResult> { + if message.from_kind != SenderKind::Owner { + return Ok(Vec::new()); + } + Ok(store + .reactions_sent_in(&message.id)? + .into_iter() + .map(|(reaction, _)| reaction) + .collect()) +} + +pub(crate) fn announce_sent(daemon: &Daemon, sent: Vec) { + for reaction in sent { + daemon.emit(Event::ReactionChanged(ReactionChanged { + bot_id: reaction.bot_id.clone(), + item_id: reaction.item_id.clone(), + reaction: Some(reaction), + })); + } +} diff --git a/crates/botloftd/src/state.rs b/crates/botloftd/src/state.rs index 38be4459..56b10471 100644 --- a/crates/botloftd/src/state.rs +++ b/crates/botloftd/src/state.rs @@ -7,7 +7,7 @@ use std::time::Instant; use botloft_core::protocol::{ AccountUsage, Bot, BotContextChanged, BotDeleted, BotRules, BotStateChanged, BrowserAction, BrowserState, ChatDelta, ChatItemChanged, Crew, CrewDeleted, Delivery, FolderRecycled, Message, - Question, Routine, RoutineRun, ScreenDraft, Task, + Question, ReactionChanged, Routine, RoutineRun, ScreenDraft, Task, }; use botloft_store::Store; use tokio::runtime::{Handle, RuntimeFlavor}; @@ -52,6 +52,7 @@ pub enum Event { BrowserAction(BrowserAction), ScreenDraft(ScreenDraft), QuestionChanged(Question), + ReactionChanged(ReactionChanged), } /// Events buffered per connection before a slow client is dropped. diff --git a/crates/botloftd/tests/reactions.rs b/crates/botloftd/tests/reactions.rs new file mode 100644 index 00000000..451569e3 --- /dev/null +++ b/crates/botloftd/tests/reactions.rs @@ -0,0 +1,129 @@ +//! The owner's reactions to a bot's replies (spec 8.9): kept on the reply, +//! and read by the bot with the owner's next message, never on their own. + +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 reactions(app: &mut common::Client, bot: &Value) -> Vec { + app.call("reactions.list", json!({ "botId": bot["id"] })) + .await + .expect("reactions") + .as_array() + .expect("list") + .clone() +} + +#[tokio::test] +async fn reactions_wait_for_the_owner_next_message() { + 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; + + 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 **annual**.").await; + t.until_state(&scout, BotState::Idle).await; + let history = app + .call("chat.history", json!({ "botId": scout["id"] })) + .await + .expect("history"); + let items = history.as_array().expect("items"); + let find = |kind: &str| { + items + .iter() + .find(|item| item["body"]["kind"] == kind) + .expect("item") + .clone() + }; + let (reply, turn) = (find("reply"), find("turn")); + let react = |bot: &Value, item: &Value, emoji: Value| json!({ "botId": bot["id"], "itemId": item["id"], "emoji": emoji }); + + // Only one of the set, on a reply of this bot. + for (params, code) in [ + (react(&scout, &reply, json!("🦄")), VALIDATION), + (react(&scout, &turn, json!("👍")), VALIDATION), + (react(&writer, &reply, json!("👍")), NOT_FOUND), + ] { + let refused = app + .call("reactions.set", params) + .await + .expect_err("refused"); + assert_eq!(refused.code, code); + } + let put = app + .call("reactions.set", react(&scout, &reply, json!("👍"))) + .await + .expect("react"); + assert_eq!(put["emoji"], "👍"); + assert_eq!(put["sentIn"], Value::Null); + // The bot is not woken for it. + assert_eq!(process.input_lines().len(), 1); + + // The owner's next message takes it along, once. + let next = app + .call( + "messages.send", + json!({ "botId": scout["id"], "body": "Next?" }), + ) + .await + .expect("send"); + let line = process.wait_lines(2).await.remove(1); + assert_eq!( + text_of(&line), + "Reacted 👍 to: \"Acme signs **annual**.\"\n\nNext?" + ); + assert_eq!(reactions(&mut app, &scout).await[0]["sentIn"], next["id"]); + stream::answer(&process, &line, "Noted.").await; + t.until_state(&scout, BotState::Idle).await; + app.call( + "messages.send", + json!({ "botId": scout["id"], "body": "And?" }), + ) + .await + .expect("send"); + assert_eq!(text_of(&process.wait_lines(3).await.remove(2)), "And?"); + + // Changed, it waits again; taken off, it is gone. + app.call("reactions.set", react(&scout, &reply, json!("🎉"))) + .await + .expect("change"); + let listed = reactions(&mut app, &scout).await; + assert_eq!( + (listed[0]["emoji"].clone(), listed[0]["sentIn"].clone()), + (json!("🎉"), Value::Null) + ); + let off = app + .call("reactions.set", react(&scout, &reply, Value::Null)) + .await + .expect("off"); + assert_eq!(off, Value::Null); + assert!(reactions(&mut app, &scout).await.is_empty()); +} diff --git a/docs/spec.md b/docs/spec.md index 99ab4d29..09854bda 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -435,6 +435,16 @@ O dono procura palavras em todas as conversas: o que ele mandou, o que outros bo **No app.** "Buscar", no topo da barra lateral (ou Ctrl+K), abre a busca no meio da janela: um campo, a escolha de uma equipe ou de todas, e os resultados conforme o dono escreve, cada um com o rosto e o nome do bot, a equipe, a hora, quem escreveu (o dono, outro bot, o Botloft ou o próprio bot) e o trecho com as palavras em destaque. "Mostrar mais" traz os anteriores. Um clique abre o chat do bot nesse ponto, com o item em destaque por um instante. A busca guarda o que foi digitado enquanto o app está aberto, e o texto volta selecionado, pronto para uma busca nova. Sem nada achado: "Nada encontrado". +### 8.9 Reações + +O dono reage a uma fala do bot (item `reply`) com um emoji, sem gastar um turno: a reação não acorda o bot. Ela espera e vai junto com a próxima message do dono para esse bot. + +- Uma reação por fala, de uma lista fixa (`REACTIONS`, exportada para o app): 👍 ❤️ 😂 🎉 🙏 👀. Outro emoji troca a reação; `null` a tira. +- `reactions.set {botId, itemId, emoji}` confere que o item é uma fala do bot (`not_found` se não é do chat dele, `validation` se não é `reply` ou se o emoji não está na lista) e guarda a reação com a fala citada numa linha só, cortada em 120 caracteres. Devolve a reação, ou `null` quando tirou. `reactions.list {botId}` devolve as reações do bot. +- Toda message do dono para o bot (`messages.send` e a resposta a uma pergunta, 23.4) leva as reações que esperam, na mesma transação em que é gravada: cada uma ganha `sentIn` com o id da message. O bot lê uma linha por reação antes do resto (9.3): `Reacted 👍 to: ""`. Trocar uma reação que já foi faz ela esperar de novo; tirar uma que já foi não avisa o bot. +- `reaction.changed {botId, itemId, reaction}` avisa o app de toda reação posta, trocada, enviada ou tirada (`reaction: null`). +- **No app** (15.3): na fala do bot, ao lado do botão de responder, o botão de reagir abre os seis emojis numa pílula. A reação fica embaixo da fala, com " vê junto com sua próxima mensagem" enquanto espera e " viu" depois que foi. Um clique nela, ou no mesmo emoji de novo, tira. + ## 9. Mensagens e entrega ### 9.1 Fluxo @@ -478,7 +488,7 @@ A resposta sai no stdout como `{"type":"control_response","response":{"subtype": ### 9.3 Texto que o bot recebe -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). +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). As reações que esperavam (8.9) vêm antes de tudo, uma por linha. **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: @@ -666,6 +676,8 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `questions.list` | `status?` (`open` se ausente) | `Question[]` de bots e crews ativos, da mais nova à mais velha (23.5) | | `questions.answer` | `questionId, answer` | `Question`; a resposta vai ao bot como message do dono (23.3) | | `questions.dismiss` | `questionId` | `Question`; fecha sem avisar o bot (23.3) | +| `reactions.list` | `botId` | `Reaction[]` (8.9) | +| `reactions.set` | `botId, itemId, emoji` (`null` tira) | `Reaction` ou `null` (8.9) | | `settings.get` | | `Settings`: `startWithWindows`, `keepAwake` e `approvalWaitMinutes`, como estão no `config.toml` (6) | | `usage.tokens` | `since` (ms Unix, não negativo) | `BotTokens[]`: `botId`, `name`, `color`, `crew` (nome da equipe), `archived`, `turns` e `tokens` somados (8.7) | | `settings.update` | `startWithWindows?, keepAwake?, approvalWaitMinutes?` (1 a 1440) | `Settings`; grava o que veio e aplica na hora (6, 14). Se a tarefa não pode mudar, nada é gravado e volta um erro | @@ -674,7 +686,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co ### 11.3 Notificações do servidor -`bot.state`, `bot.changed`, `bot.context` (8.6), `bot.rules` (10.1), `bot.deleted`, `crew.changed`, `crew.deleted`, `folder.recycled` (7.6), `chat.item`, `chat.delta`, `message.created`, `delivery.changed`, `task.changed`, das rotinas `routine.changed` e `routine.run` (20.8), e do navegador `browser.changed`, `browser.action` e, só para quem assiste, `browser.frame` (21.7), e das telas `screen.draft` (22.3), e das perguntas `question.changed` (23.5). +`bot.state`, `bot.changed`, `bot.context` (8.6), `bot.rules` (10.1), `bot.deleted`, `crew.changed`, `crew.deleted`, `folder.recycled` (7.6), `chat.item`, `chat.delta`, `message.created`, `delivery.changed`, `task.changed`, das rotinas `routine.changed` e `routine.run` (20.8), e do navegador `browser.changed`, `browser.action` e, só para quem assiste, `browser.frame` (21.7), e das telas `screen.draft` (22.3), e das perguntas `question.changed` (23.5), e das reações `reaction.changed` (8.9). ### 11.4 Erros @@ -690,7 +702,7 @@ Além desses, os códigos padrão do JSON-RPC: `-32700` (JSON inválido), `-3260 ## 12. Dados (SQLite) -Pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=5000`, `temp_store=MEMORY`, cache de 16 MB e `PRAGMA optimize` ao abrir. Com WAL, `NORMAL` não corrompe o banco; uma queda de energia pode perder os últimos commits, mas nenhum commit espera o disco (com `FULL`, cada item de chat pagava um flush no Windows). As consultas quentes passam pelo cache de statements do rusqlite, e toda coluna que aponta para outra tabela tem índice: as tabelas nunca encolhem, e sem índice a exclusão varre a tabela filha uma vez por linha apagada. Migrations numeradas em `botloft-store/migrations/NNNN_nome.sql`, versão em `PRAGMA user_version`. Tempo em milissegundos Unix (`INTEGER`). Arquivamento é lógico (`archived_at`). Exclusão (7.6) apaga as linhas: as chaves estrangeiras não têm `ON DELETE`, então o daemon apaga ou solta, na ordem e numa transação só, tudo que aponta para o bot (`questions`, `approvals`, `chat_items`, `browser_sites`, `allow_rules`, `routine_runs`, `attachments`, `deliveries`, as `messages` recebidas, `routines`, `tasks`), e zera `messages.from_bot_id`, `messages.task_id`, `tasks.origin_task_id` e `crews.lead_bot_id` onde apontavam para o que saiu. Uma crew sai depois dos bots dela. +Pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=5000`, `temp_store=MEMORY`, cache de 16 MB e `PRAGMA optimize` ao abrir. Com WAL, `NORMAL` não corrompe o banco; uma queda de energia pode perder os últimos commits, mas nenhum commit espera o disco (com `FULL`, cada item de chat pagava um flush no Windows). As consultas quentes passam pelo cache de statements do rusqlite, e toda coluna que aponta para outra tabela tem índice: as tabelas nunca encolhem, e sem índice a exclusão varre a tabela filha uma vez por linha apagada. Migrations numeradas em `botloft-store/migrations/NNNN_nome.sql`, versão em `PRAGMA user_version`. Tempo em milissegundos Unix (`INTEGER`). Arquivamento é lógico (`archived_at`). Exclusão (7.6) apaga as linhas: as chaves estrangeiras não têm `ON DELETE`, então o daemon apaga ou solta, na ordem e numa transação só, tudo que aponta para o bot (`questions`, `reactions`, `approvals`, `chat_items`, `browser_sites`, `allow_rules`, `routine_runs`, `attachments`, `deliveries`, as `messages` recebidas, `routines`, `tasks`), e zera `messages.from_bot_id`, `messages.task_id`, `tasks.origin_task_id` e `crews.lead_bot_id` onde apontavam para o que saiu. Uma crew sai depois dos bots dela. | Tabela | Colunas principais | |---|---| @@ -707,6 +719,7 @@ Pragmas: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_time | `browser_sites` | `bot_id, host, allowed_at`: sites que o dono deixou o bot usar no navegador (21.5) | | `allow_rules` | `id (rul_), bot_id, tool_name, kind, value, created_at`, única por `(bot_id, tool_name, kind, value)`: o que o dono permitiu de vez para o bot (10.1) | | `questions` | seção 23.7; `messages` ganha `question_id` | +| `reactions` | `item_id` (chave, item `reply`), `bot_id`, `emoji`, `quote`, `created_at`, `sent_in` (a message que a levou ao bot; nula enquanto espera). Sai com o bot, antes dos itens e das messages (7.6) | | `chat_search` | índice FTS5 do texto de `chat_items`, pela view `chat_text`, mantido por triggers (8.8) | Índices mínimos: `deliveries(state, next_attempt_at)`, `messages(crew_id, created_at)`, `tasks(assignee_bot_id, status)`, `bots(crew_id)`, `chat_items(bot_id, id)`, `chat_items(created_at) WHERE kind = 'turn'` (8.7), `attachments(message_id)`.