diff --git a/__tests__/unit/services/cat-post-mentions.test.ts b/__tests__/unit/services/cat-post-mentions.test.ts new file mode 100644 index 000000000..29a619a97 --- /dev/null +++ b/__tests__/unit/services/cat-post-mentions.test.ts @@ -0,0 +1,104 @@ +/** + * Wall posts reach the queue through a database trigger, because posts are + * written straight from the browser to a Postgres function and there is no + * server seam to hook. That trigger is a PREFILTER: it queues anything + * containing the substring "@cat", so `@catalogue` arrives here too. + * + * The point of these tests is that the prefilter is not the rule. Detection has + * ONE implementation — the resolver — and the worker is where its verdict is + * applied. A second copy of "what counts as a mention", written in SQL, is + * exactly the kind of duplication that drifts silently. + */ + +const replyToPostMention = jest.fn().mockResolvedValue(true); +const resolveMentions = jest.fn(); +const claimCatMentions = jest.fn(); +const completeCatMention = jest.fn(); +const failCatMention = jest.fn(); + +jest.mock('@/services/mentions/cat-account', () => ({ + ensureCatAccount: jest.fn().mockResolvedValue({ id: 'cat-1', username: 'cat' }), +})); +jest.mock('@/services/mentions/cat-post-reply', () => ({ + replyToPostMention: (...a: unknown[]) => replyToPostMention(...a), +})); +jest.mock('@/services/mentions/cat-reply', () => ({ + replyToConversationMention: jest.fn().mockResolvedValue(true), +})); +jest.mock('@/services/mentions/resolve', () => ({ + resolveMentions: (...a: unknown[]) => resolveMentions(...a), +})); +jest.mock('@/services/mentions/queue', () => ({ + claimCatMentions: (...a: unknown[]) => claimCatMentions(...a), + completeCatMention: (...a: unknown[]) => completeCatMention(...a), + failCatMention: (...a: unknown[]) => failCatMention(...a), + MAX_ATTEMPTS: 3, +})); + +import { runCatMentions } from '@/services/mentions/worker'; + +const postMention = { + id: 'q1', + source_type: 'timeline_event', + source_id: 'e1', + requester_id: 'u1', + conversation_id: null, + parent_event_id: 'e1', + attempts: 1, +}; + +/** Admin stub returning one post's text. */ +const admin = (description: string) => + ({ + from: () => ({ + select: () => ({ eq: () => ({ maybeSingle: () => Promise.resolve({ data: { title: null, description }, error: null }) }) }), + }), + }) as never; + +beforeEach(() => { + replyToPostMention.mockClear().mockResolvedValue(true); + resolveMentions.mockReset(); + claimCatMentions.mockReset().mockResolvedValue([postMention]); + completeCatMention.mockReset(); + failCatMention.mockReset(); +}); + +describe('wall-post mentions', () => { + it('answers a post that really tags the Cat', async () => { + resolveMentions.mockResolvedValue({ mentions: [], mentionsCat: true }); + const result = await runCatMentions(admin('@cat is this goal realistic?')); + expect(replyToPostMention).toHaveBeenCalledWith(expect.anything(), { + eventId: 'e1', + catId: 'cat-1', + }); + expect(result.answered).toBe(1); + }); + + it('discards the prefilter’s over-selection without replying', async () => { + // The trigger queued this because it contains "@cat" as a substring. The + // resolver says otherwise, and the resolver is the authority. + resolveMentions.mockResolvedValue({ mentions: [], mentionsCat: false }); + const result = await runCatMentions(admin('browsing the @catalogue today')); + expect(replyToPostMention).not.toHaveBeenCalled(); + expect(result.answered).toBe(1); + expect(result.failed).toBe(0); + }); + + it('treats a discarded mention as resolved, not failed', async () => { + resolveMentions.mockResolvedValue({ mentions: [], mentionsCat: false }); + await runCatMentions(admin('the @catalogue')); + // Marking it failed would retry it three times and then log an error about + // a post that never asked the Cat anything. + expect(completeCatMention).toHaveBeenCalled(); + expect(failCatMention).not.toHaveBeenCalled(); + }); + + it('does not consult the resolver for a private-message mention', async () => { + claimCatMentions.mockResolvedValue([ + { ...postMention, conversation_id: 'c1', parent_event_id: null }, + ]); + await runCatMentions(admin('irrelevant')); + // Those arrive through an API route that already resolved them. + expect(resolveMentions).not.toHaveBeenCalled(); + }); +}); diff --git a/src/services/mentions/cat-post-reply.ts b/src/services/mentions/cat-post-reply.ts new file mode 100644 index 000000000..3dfa76314 --- /dev/null +++ b/src/services/mentions/cat-post-reply.ts @@ -0,0 +1,129 @@ +/** + * Answering a mention on the wall. + * + * Same promise as in a private message — the Cat answers where it was asked — + * but the context is different in a way that matters. On X, tagging Grok under + * a reply gets you an answer about that reply. Here the Cat reads the THREAD: + * the post that started it and the replies leading to the one that tagged it. + * "@cat is this realistic?" three replies deep is a question about the + * conversation, and answering it from the last sentence alone is the difference + * between an assistant and an autocomplete. + */ + +import { CAT_CONTEXT_MESSAGE_WINDOW, CAT_DISPLAY_NAME, CAT_MENTION } from '@/config/cat-identity'; +import { DATABASE_TABLES } from '@/config/database-tables'; +import { callPlatformJson, parseJsonLoose } from '@/services/cat/platform-llm'; +import { writeTimelineReply } from '@/services/timeline/write-timeline-reply'; +import { logger } from '@/utils/logger'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +export const CAT_POST_FALLBACK = + "I couldn't work that one out just now — tag me again and I'll try afresh."; + +const SYSTEM_PROMPT = [ + `You are ${CAT_DISPLAY_NAME}, the OrangeCat agent, replying publicly under someone's post.`, + `You were tagged with ${CAT_MENTION}. Answer the question using the thread for context.`, + 'This is a public reply: be brief, specific and useful to anyone reading, not just the asker.', + 'Two or three sentences. No preamble, no restating the question, no sign-off.', + 'If you do not know something, say so in one clause and say what would settle it.', + 'Respond as JSON: {"reply": ""}', +].join('\n'); + +interface ThreadEvent { + id: string; + actor_id: string; + title: string | null; + description: string | null; + parent_event_id: string | null; + thread_id: string | null; + created_at: string; +} + +/** + * The post that was tagged, plus the thread it belongs to, oldest first. + * + * Falls back to the tagged post alone when it starts a thread of its own — a + * top-level post has no ancestors, and that is not a failure. + */ +export async function loadThreadContext( + admin: SupabaseClient, + eventId: string +): Promise { + const { data: tagged } = await admin + .from(DATABASE_TABLES.TIMELINE_EVENTS) + .select('id, actor_id, title, description, parent_event_id, thread_id, created_at') + .eq('id', eventId) + .maybeSingle(); + + if (!tagged) { + return []; + } + const event = tagged as ThreadEvent; + const threadId = event.thread_id ?? event.parent_event_id ?? event.id; + + const { data: thread } = await admin + .from(DATABASE_TABLES.TIMELINE_EVENTS) + .select('id, actor_id, title, description, parent_event_id, thread_id, created_at') + .or(`id.eq.${threadId},thread_id.eq.${threadId}`) + .eq('is_deleted', false) + .order('created_at', { ascending: true }) + .limit(CAT_CONTEXT_MESSAGE_WINDOW); + + const rows = (thread ?? []) as ThreadEvent[]; + // The tagged post itself must be present even if the thread query missed it + // (a brand-new row, or one whose thread_id was never set). + return rows.some(r => r.id === event.id) ? rows : [...rows, event]; +} + +export function buildThreadPrompt(events: ThreadEvent[], taggedId: string): string { + const lines = events.map(e => { + const body = (e.description ?? e.title ?? '').trim(); + const marker = e.id === taggedId ? ' <- tagged you here' : ''; + return `- ${body}${marker}`; + }); + return ['Thread (oldest first):', lines.join('\n'), '', 'Reply to the post that tagged you.'].join( + '\n' + ); +} + +/** + * @returns true when a reply was posted. As in a private message, a failure to + * think still produces an answer rather than silence — under a public post + * that matters more, not less, because everyone can see nothing happened. + */ +export async function replyToPostMention( + admin: SupabaseClient, + params: { eventId: string; catId: string } +): Promise { + const { eventId, catId } = params; + + const thread = await loadThreadContext(admin, eventId); + if (thread.length === 0) { + logger.warn('Cat tagged on a post it cannot read', { eventId }, 'CatPostReply'); + return false; + } + + let reply = ''; + try { + const raw = await callPlatformJson(SYSTEM_PROMPT, buildThreadPrompt(thread, eventId), { + timeoutMs: 30_000, + }); + reply = (parseJsonLoose<{ reply?: string }>(raw)?.reply ?? '').trim(); + } catch (error) { + logger.error( + 'Cat post reply generation failed', + { eventId, error: error instanceof Error ? error.message : String(error) }, + 'CatPostReply' + ); + } + + await writeTimelineReply(admin, { + parentEventId: eventId, + actorId: catId, + description: reply || CAT_POST_FALLBACK, + // Marked so the UI can render a Cat reply distinctly rather than leaving a + // reader to work out from the avatar that this one was written by an agent. + metadata: { is_cat_reply: true, answered_event_id: eventId }, + }); + return true; +} diff --git a/src/services/mentions/worker.ts b/src/services/mentions/worker.ts index 1c6e02b86..ffaf74aee 100644 --- a/src/services/mentions/worker.ts +++ b/src/services/mentions/worker.ts @@ -9,6 +9,9 @@ import { ensureCatAccount } from '@/services/mentions/cat-account'; import { replyToConversationMention } from '@/services/mentions/cat-reply'; +import { replyToPostMention } from '@/services/mentions/cat-post-reply'; +import { resolveMentions } from '@/services/mentions/resolve'; +import { DATABASE_TABLES } from '@/config/database-tables'; import { claimCatMentions, completeCatMention, @@ -94,7 +97,37 @@ async function answer( catId, }); } - // Wall posts are queued by the same table but answered by a later change; - // until then such a row would be retried forever, so it fails fast instead. + + if (mention.parent_event_id) { + // The database trigger is a PREFILTER: it queues anything containing the + // substring "@cat", so `@catalogue` and `bob@catering.com` arrive here too. + // The resolver is the authority, and this is where its verdict is applied — + // detection has one implementation, not one per surface. + if (!(await postActuallyTagsTheCat(admin, mention.parent_event_id))) { + return true; // Nothing owed. Resolved, not failed. + } + return replyToPostMention(admin, { eventId: mention.parent_event_id, catId }); + } + return false; } + +/** Ask the real resolver whether the post's text mentions the Cat. */ +async function postActuallyTagsTheCat( + admin: SupabaseClient, + eventId: string +): Promise { + const { data } = await admin + .from(DATABASE_TABLES.TIMELINE_EVENTS) + .select('title, description') + .eq('id', eventId) + .maybeSingle(); + + if (!data) { + return false; + } + const row = data as { title: string | null; description: string | null }; + const text = `${row.description ?? ''}\n${row.title ?? ''}`; + const { mentionsCat } = await resolveMentions(admin, text); + return mentionsCat; +} diff --git a/src/services/timeline/write-timeline-reply.ts b/src/services/timeline/write-timeline-reply.ts new file mode 100644 index 000000000..1ac9a492b --- /dev/null +++ b/src/services/timeline/write-timeline-reply.ts @@ -0,0 +1,97 @@ +/** + * Writing a timeline reply as a trusted server caller. + * + * The same split as features/messaging/server/write-message.ts, for the same + * reason. `create_timeline_event` re-imposes the RLS actor check by hand — + * `v_actor_id IS DISTINCT FROM auth.uid()` raises "Actor mismatch" — which is + * exactly right when a browser calls it, and impossible for the Cat: a worker + * has no `auth.uid()` at all, so every Cat reply would raise. + * + * Relaxing that check is not an option; it is the only thing standing between + * one authenticated user and posting as another. So authorization stays there + * for callers that have a session, and this exists for callers that have + * already established authority some other way — which is why it takes an admin + * client explicitly instead of creating one. + */ + +import { DATABASE_TABLES } from '@/config/database-tables'; +import { logger } from '@/utils/logger'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +export interface WriteTimelineReplyInput { + parentEventId: string; + actorId: string; + description: string; + metadata?: Record; +} + +/** @returns the new event id, or null if it could not be written. */ +export async function writeTimelineReply( + admin: SupabaseClient, + input: WriteTimelineReplyInput +): Promise { + const { data: parent } = await admin + .from(DATABASE_TABLES.TIMELINE_EVENTS) + .select('id, thread_id, thread_depth, visibility') + .eq('id', input.parentEventId) + .maybeSingle(); + + if (!parent) { + logger.warn('Cannot reply to a post that is not there', { id: input.parentEventId }, 'TimelineWrite'); + return null; + } + + const parentRow = parent as { + id: string; + thread_id: string | null; + thread_depth: number | null; + visibility: string | null; + }; + + const title = input.description.slice(0, 140) || 'Update'; + const now = new Date().toISOString(); + + const { data, error } = await admin + .from(DATABASE_TABLES.TIMELINE_EVENTS) + .insert({ + event_type: 'status_update', + actor_id: input.actorId, + actor_type: 'user', + subject_type: 'profile', + subject_id: input.actorId, + title, + description: input.description, + content: { text: input.description }, + // A reply inherits its parent's audience. Answering a followers-only post + // in public would republish the question to people who could not see it. + visibility: parentRow.visibility ?? 'public', + metadata: input.metadata ?? {}, + parent_event_id: parentRow.id, + thread_id: parentRow.thread_id ?? parentRow.id, + thread_depth: (parentRow.thread_depth ?? 0) + 1, + event_timestamp: now, + created_at: now, + updated_at: now, + }) + .select('id') + .single(); + + if (error || !data) { + logger.error('Failed to write a timeline reply', { error: error?.message }, 'TimelineWrite'); + return null; + } + + const eventId = (data as { id: string }).id; + + // Without a visibility row the reply exists and appears on nobody's timeline — + // the same trap create_post_with_visibility exists to avoid. + const { error: visError } = await admin + .from('timeline_event_visibility') + .insert({ event_id: eventId, timeline_type: 'profile', timeline_owner_id: input.actorId }); + + if (visError) { + logger.warn('Cat reply written but not routed to a timeline', { eventId, error: visError.message }, 'TimelineWrite'); + } + + return eventId; +} diff --git a/supabase/migrations/20260826170000_cat_mention_trigger_timeline.sql b/supabase/migrations/20260826170000_cat_mention_trigger_timeline.sql new file mode 100644 index 000000000..8e1ff5bd1 --- /dev/null +++ b/supabase/migrations/20260826170000_cat_mention_trigger_timeline.sql @@ -0,0 +1,73 @@ +-- Notice when a post or reply tags the Cat. +-- +-- Private messages are written through an API route, so there is a server seam +-- to notice a mention in. Wall posts are not: post-composer.ts calls a Postgres +-- function straight from the BROWSER. There is nowhere in the app to hook, so +-- the notice has to happen in the database. +-- +-- A trigger is also the stronger choice on its own merits: it cannot be skipped +-- by a client that forgets to call an endpoint, or by a second client written +-- later, and it costs one small insert on a table that is already being written. +-- +-- THIS TRIGGER IS A PREFILTER, NOT THE RULE. +-- Mention detection has ONE implementation — domain/mentions/parse.ts plus +-- services/mentions/resolve.ts — and it stays there, because getting `@cat.` and +-- `@catalog` and `bob@example.com` right is not something to write twice in two +-- languages and hope they agree. So this asks only the cheap, permissive +-- question "does this text contain @cat at all?" and the worker decides for +-- real. It may over-select (a post about `@catalogue` is queued and then +-- discarded); it must never under-select, which is why the pattern is a plain +-- case-insensitive substring rather than a clever one. + +CREATE OR REPLACE FUNCTION public.note_cat_mention_on_timeline_event() +RETURNS trigger + LANGUAGE plpgsql SECURITY DEFINER + SET search_path TO 'public' + AS $$ +DECLARE + v_cat_id uuid; +BEGIN + -- Nothing to do for the overwhelming majority of posts. + IF COALESCE(NEW.description, '') !~* '@cat' + AND COALESCE(NEW.title, '') !~* '@cat' THEN + RETURN NEW; + END IF; + + SELECT id INTO v_cat_id FROM profiles WHERE username = 'cat'; + + -- No Cat account yet: nothing can be owed to a sender that does not exist. + IF v_cat_id IS NULL THEN + RETURN NEW; + END IF; + + -- The Cat never answers itself. Without this, a reply of its own that quoted + -- the handle would queue another reply, forever. + IF NEW.actor_id = v_cat_id THEN + RETURN NEW; + END IF; + + -- ON CONFLICT DO NOTHING is what makes the trigger safe to fire more than + -- once: the unique key on (source_type, source_id) means one post owes one + -- reply however many times this runs. + INSERT INTO cat_mention_queue (source_type, source_id, requester_id, parent_event_id) + VALUES ('timeline_event', NEW.id, NEW.actor_id, NEW.id) + ON CONFLICT (source_type, source_id) DO NOTHING; + + RETURN NEW; +END; +$$; + +COMMENT ON FUNCTION public.note_cat_mention_on_timeline_event IS + 'Prefilter only: queues a post that MIGHT tag the Cat. services/mentions/resolve.ts is the authority on whether it actually does.'; + +DROP TRIGGER IF EXISTS trg_note_cat_mention ON public.timeline_events; + +-- AFTER INSERT: the post is already stored, so a fault here can never cost +-- somebody their words. The trigger is not deferrable for the same reason — +-- queueing late is fine, losing the post is not. +CREATE TRIGGER trg_note_cat_mention + AFTER INSERT ON public.timeline_events + FOR EACH ROW + EXECUTE FUNCTION public.note_cat_mention_on_timeline_event(); + +NOTIFY pgrst, 'reload schema';