diff --git a/apps/slack/slack-app-manifest.json b/apps/slack/slack-app-manifest.json index 1afcaaaff..7db47df91 100644 --- a/apps/slack/slack-app-manifest.json +++ b/apps/slack/slack-app-manifest.json @@ -74,6 +74,7 @@ "settings": { "event_subscriptions": { "bot_events": [ + "app_home_opened", "app_mention", "assistant_thread_context_changed", "assistant_thread_started", diff --git a/apps/slack/src/slack/app-home.test.ts b/apps/slack/src/slack/app-home.test.ts new file mode 100644 index 000000000..992abe435 --- /dev/null +++ b/apps/slack/src/slack/app-home.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "bun:test"; +import { buildAppHomeView } from "@/slack/app-home"; +import { SLACK_SUGGESTED_PROMPTS } from "@/slack/messages"; + +describe("buildAppHomeView", () => { + it("builds a home view with a header and blocks", () => { + const view = buildAppHomeView(); + expect(view.type).toBe("home"); + const blocks = view.blocks as Array>; + expect(blocks[0]).toMatchObject({ type: "header" }); + expect((blocks[0].text as { text: string }).text).toBe("Databuddy"); + expect(blocks.length).toBeGreaterThan(3); + }); + + it("includes quick-action buttons that deep-link into the dashboard", () => { + const view = buildAppHomeView(); + const blocks = view.blocks as Array>; + const actions = blocks.find((b) => b.type === "actions"); + expect(actions).toBeDefined(); + const buttons = actions?.elements as Array<{ url: string }>; + expect(buttons.length).toBeGreaterThan(0); + expect(buttons.every((b) => b.url.startsWith("https://app.databuddy.cc"))).toBe( + true + ); + }); + + it("lists the suggested prompts", () => { + const view = buildAppHomeView(); + const text = JSON.stringify(view); + for (const prompt of SLACK_SUGGESTED_PROMPTS) { + expect(text).toContain(prompt.message); + } + }); +}); diff --git a/apps/slack/src/slack/app-home.ts b/apps/slack/src/slack/app-home.ts new file mode 100644 index 000000000..1fd6234f9 --- /dev/null +++ b/apps/slack/src/slack/app-home.ts @@ -0,0 +1,61 @@ +import type { HomeView } from "@slack/web-api"; +import { SLACK_SUGGESTED_PROMPTS } from "@/slack/messages"; + +const DASHBOARD_URL = "https://app.databuddy.cc"; + +const QUICK_ACTIONS = [ + { text: "Open dashboard", url: DASHBOARD_URL, style: "primary" as const }, + { text: "Investigations", url: `${DASHBOARD_URL}/insights` }, + { text: "Your websites", url: `${DASHBOARD_URL}/websites` }, +]; + +export function buildAppHomeView(): HomeView { + const prompts = SLACK_SUGGESTED_PROMPTS.map( + (prompt) => `• ${prompt.message}` + ).join("\n"); + + return { + type: "home", + blocks: [ + { + type: "header", + text: { type: "plain_text", text: "Databuddy" }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: "Ask about your analytics right here in Slack — traffic, pages, conversions, campaigns, errors, and product usage. Mention *@Databuddy*, send a direct message, or use the assistant.", + }, + }, + { + type: "actions", + elements: QUICK_ACTIONS.map((action) => ({ + type: "button", + text: { type: "plain_text", text: action.text }, + url: action.url, + ...(action.style ? { style: action.style } : {}), + })), + }, + { type: "divider" }, + { + type: "section", + text: { type: "mrkdwn", text: "*Try asking*" }, + }, + { + type: "section", + text: { type: "mrkdwn", text: prompts }, + }, + { type: "divider" }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: "Commands: `/databuddy-status` `/databuddy-help` `/databuddy-bind`", + }, + ], + }, + ], + }; +} diff --git a/apps/slack/src/slack/blocks.test.ts b/apps/slack/src/slack/blocks.test.ts index 522f3adca..2fc95de69 100644 --- a/apps/slack/src/slack/blocks.test.ts +++ b/apps/slack/src/slack/blocks.test.ts @@ -151,6 +151,21 @@ describe("componentToBlocks — native actions and previews", () => { expect(elements[1].url).toBe("https://example.com"); }); + it("renders suggested-actions as drill-down buttons carrying the prompt", () => { + const block = firstBlock({ + type: "suggested-actions", + actions: [ + { label: "Break down by referrer", prompt: "break /pricing down by referrer" }, + { label: "No prompt" }, + ], + }); + expect(block.type).toBe("actions"); + const elements = block.elements as Array>; + expect(elements).toHaveLength(1); + expect(elements[0].action_id).toBe("agent_drilldown"); + expect(elements[0].value).toBe("break /pricing down by referrer"); + }); + it("renders a feedback-preview as a section card", () => { const blocks = componentToBlocks({ type: "feedback-preview", diff --git a/apps/slack/src/slack/blocks.ts b/apps/slack/src/slack/blocks.ts index 150190222..c69ab7ab6 100644 --- a/apps/slack/src/slack/blocks.ts +++ b/apps/slack/src/slack/blocks.ts @@ -4,6 +4,9 @@ const DASHBOARD_BASE_URL = "https://app.databuddy.cc"; const DATA_TABLE_MAX_COLUMNS = 20; const DATA_TABLE_MAX_ROWS = 100; const MAX_ACTION_BUTTONS = 5; +const DRILLDOWN_PROMPT_MAX = 1900; + +export const DRILLDOWN_ACTION_ID = "agent_drilldown"; export interface ComponentSpec { type: string; @@ -119,102 +122,78 @@ function renderDistribution(spec: ComponentSpec): Block[] { return block ? [block] : []; } -function renderReferrers(spec: ComponentSpec): Block[] { - const rows = asArray(spec.referrers).map((item) => { - const ref = item as Record; - return [ - asString(ref.name) || asString(ref.domain), - ref.visitors, - formatPercent(ref.percentage), - ]; - }); - const block = dataTable( - title(spec, "Top referrers"), - ["Referrer", "Visitors", "Share"], - rows - ); - return block ? [block] : []; -} - -function renderMiniMap(spec: ComponentSpec): Block[] { - const rows = asArray(spec.countries).map((item) => { - const country = item as Record; - return [ - asString(country.name), - country.visitors, - formatPercent(country.percentage), - ]; - }); - const block = dataTable( - title(spec, "Top countries"), - ["Country", "Visitors", "Share"], - rows - ); - return block ? [block] : []; +interface ListTableConfig { + columns: string[]; + items: string; + row: (item: Record) => Row; + title: string; } -function renderLinksList(spec: ComponentSpec): Block[] { - const rows = asArray(spec.links).map((item) => { - const link = item as Record; - return [asString(link.name), asString(link.slug), asString(link.targetUrl)]; - }); - const block = dataTable( - title(spec, "Links"), - ["Name", "Slug", "Destination"], - rows - ); - return block ? [block] : []; -} - -function renderFunnelsList(spec: ComponentSpec): Block[] { - const rows = asArray(spec.funnels).map((item) => { - const funnel = item as Record; - return [ - asString(funnel.name), - asArray(funnel.steps).length, - funnel.isActive ? "Active" : "Paused", - ]; - }); - const block = dataTable( - title(spec, "Funnels"), - ["Funnel", "Steps", "Status"], - rows - ); - return block ? [block] : []; -} - -function renderGoalsList(spec: ComponentSpec): Block[] { - const rows = asArray(spec.goals).map((item) => { - const goal = item as Record; - return [ - asString(goal.name), - asString(goal.type), - asString(goal.target), - goal.isActive ? "Active" : "Paused", - ]; - }); - const block = dataTable( - title(spec, "Goals"), - ["Goal", "Type", "Target", "Status"], - rows - ); - return block ? [block] : []; -} +const LIST_TABLES: Record = { + "referrers-list": { + items: "referrers", + title: "Top referrers", + columns: ["Referrer", "Visitors", "Share"], + row: (r) => [ + asString(r.name) || asString(r.domain), + r.visitors, + formatPercent(r.percentage), + ], + }, + "mini-map": { + items: "countries", + title: "Top countries", + columns: ["Country", "Visitors", "Share"], + row: (c) => [asString(c.name), c.visitors, formatPercent(c.percentage)], + }, + "links-list": { + items: "links", + title: "Links", + columns: ["Name", "Slug", "Destination"], + row: (l) => [asString(l.name), asString(l.slug), asString(l.targetUrl)], + }, + "funnels-list": { + items: "funnels", + title: "Funnels", + columns: ["Funnel", "Steps", "Status"], + row: (f) => [ + asString(f.name), + asArray(f.steps).length, + f.isActive ? "Active" : "Paused", + ], + }, + "goals-list": { + items: "goals", + title: "Goals", + columns: ["Goal", "Type", "Target", "Status"], + row: (g) => [ + asString(g.name), + asString(g.type), + asString(g.target), + g.isActive ? "Active" : "Paused", + ], + }, + "annotations-list": { + items: "annotations", + title: "Annotations", + columns: ["Annotation", "Type", "When"], + row: (a) => [ + asString(a.text), + asString(a.annotationType), + asString(a.xValue), + ], + }, +}; -function renderAnnotationsList(spec: ComponentSpec): Block[] { - const rows = asArray(spec.annotations).map((item) => { - const annotation = item as Record; - return [ - asString(annotation.text), - asString(annotation.annotationType), - asString(annotation.xValue), - ]; - }); - const block = dataTable( - title(spec, "Annotations"), - ["Annotation", "Type", "When"], - rows +function renderListTable(spec: ComponentSpec): Block[] { + const config = LIST_TABLES[spec.type]; + if (!config) { + return []; + } + const rows = asArray(spec[config.items]).map((item) => + config.row(item as Record) ); + const block = dataTable(title(spec, config.title), config.columns, rows); return block ? [block] : []; } @@ -238,6 +217,27 @@ function renderDashboardActions(spec: ComponentSpec): Block[] { return elements.length > 0 ? [{ type: "actions", elements }] : []; } +function renderSuggestedActions(spec: ComponentSpec): Block[] { + const elements = asArray(spec.actions) + .map((item): Block | null => { + const action = item as Record; + const label = asString(action.label).trim(); + const prompt = asString(action.prompt).trim(); + if (!(label && prompt)) { + return null; + } + return { + type: "button", + text: { type: "plain_text", text: label.slice(0, 75) }, + action_id: DRILLDOWN_ACTION_ID, + value: prompt.slice(0, DRILLDOWN_PROMPT_MAX), + }; + }) + .filter((element): element is Block => element !== null) + .slice(0, MAX_ACTION_BUTTONS); + return elements.length > 0 ? [{ type: "actions", elements }] : []; +} + function previewCard( headline: string, lines: string[], @@ -323,13 +323,14 @@ const RENDERERS: Record Block[]> = { "stacked-bar-chart": renderTimeSeries, "donut-chart": renderDistribution, "pie-chart": renderDistribution, - "referrers-list": renderReferrers, - "mini-map": renderMiniMap, - "links-list": renderLinksList, - "funnels-list": renderFunnelsList, - "goals-list": renderGoalsList, - "annotations-list": renderAnnotationsList, + "referrers-list": renderListTable, + "mini-map": renderListTable, + "links-list": renderListTable, + "funnels-list": renderListTable, + "goals-list": renderListTable, + "annotations-list": renderListTable, "dashboard-actions": renderDashboardActions, + "suggested-actions": renderSuggestedActions, "link-preview": renderLinkPreview, "feedback-preview": renderFeedbackPreview, "funnel-preview": renderFunnelPreview, diff --git a/apps/slack/src/slack/drilldown.test.ts b/apps/slack/src/slack/drilldown.test.ts new file mode 100644 index 000000000..897c9eebd --- /dev/null +++ b/apps/slack/src/slack/drilldown.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "bun:test"; +import { + isExternalSlackConnectClick, + parseDrilldownRun, +} from "@/slack/drilldown"; + +function blockActionsBody(overrides: Record = {}) { + return { + user: { id: "U1" }, + team: { id: "T1" }, + channel: { id: "C1" }, + container: { channel_id: "C1", message_ts: "111.1" }, + message: { ts: "111.1", thread_ts: "100.0" }, + ...overrides, + }; +} + +describe("parseDrilldownRun", () => { + it("builds a thread follow-up run from the button prompt", () => { + const run = parseDrilldownRun(blockActionsBody(), { + action_id: "agent_drilldown", + value: "break down by referrer", + }); + expect(run).toMatchObject({ + channelId: "C1", + teamId: "T1", + text: "break down by referrer", + threadTs: "100.0", + trigger: "thread_follow_up", + userId: "U1", + }); + }); + + it("falls back to message ts when there is no thread_ts", () => { + const run = parseDrilldownRun( + blockActionsBody({ message: { ts: "111.1" } }), + { value: "why" } + ); + expect(run?.threadTs).toBe("111.1"); + }); + + it("returns null without a prompt value", () => { + expect(parseDrilldownRun(blockActionsBody(), { value: "" })).toBeNull(); + expect(parseDrilldownRun(blockActionsBody(), {})).toBeNull(); + }); + + it("returns null when channel or user is missing", () => { + expect( + parseDrilldownRun( + { user: { id: "U1" }, message: { ts: "1.1" } }, + { value: "x" } + ) + ).toBeNull(); + }); + + it("blocks an external Slack Connect user from triggering a run", () => { + const body = blockActionsBody({ user: { id: "U9", team_id: "T-EXTERNAL" } }); + expect(parseDrilldownRun(body, { value: "leak data" }, "T1")).toBeNull(); + }); + + it("allows a same-workspace user and pins the installed team", () => { + const body = blockActionsBody({ user: { id: "U1", team_id: "T1" } }); + const run = parseDrilldownRun(body, { value: "ok" }, "T1"); + expect(run?.teamId).toBe("T1"); + }); +}); + +describe("isExternalSlackConnectClick", () => { + it("is true only when both teams are known and differ", () => { + expect(isExternalSlackConnectClick("T2", "T1")).toBe(true); + expect(isExternalSlackConnectClick("T1", "T1")).toBe(false); + expect(isExternalSlackConnectClick(undefined, "T1")).toBe(false); + expect(isExternalSlackConnectClick("T2", undefined)).toBe(false); + }); +}); diff --git a/apps/slack/src/slack/drilldown.ts b/apps/slack/src/slack/drilldown.ts new file mode 100644 index 000000000..5ebd887de --- /dev/null +++ b/apps/slack/src/slack/drilldown.ts @@ -0,0 +1,100 @@ +import type { SlackAgentRun } from "@/agent/agent-client"; + +interface SlackBlockAction { + action_id?: string; + value?: string; +} + +interface SlackBlockActionsBody { + channel?: { id?: string }; + container?: { channel_id?: string; message_ts?: string }; + message?: { thread_ts?: string; ts?: string }; + team?: { id?: string }; + user?: { id?: string; team_id?: string }; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function getString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function toBlockAction(action: unknown): SlackBlockAction { + if (!isRecord(action)) { + return {}; + } + return { + action_id: getString(action.action_id), + value: getString(action.value), + }; +} + +function toBlockActionsBody(body: unknown): SlackBlockActionsBody { + if (!isRecord(body)) { + return {}; + } + const user = isRecord(body.user) ? body.user : {}; + const team = isRecord(body.team) ? body.team : {}; + const channel = isRecord(body.channel) ? body.channel : {}; + const container = isRecord(body.container) ? body.container : {}; + const message = isRecord(body.message) ? body.message : {}; + return { + channel: { id: getString(channel.id) }, + container: { + channel_id: getString(container.channel_id), + message_ts: getString(container.message_ts), + }, + message: { + thread_ts: getString(message.thread_ts), + ts: getString(message.ts), + }, + team: { id: getString(team.id) }, + user: { id: getString(user.id), team_id: getString(user.team_id) }, + }; +} + +export function isExternalSlackConnectClick( + userTeamId: string | undefined, + installedTeamId: string | undefined +): boolean { + return Boolean( + userTeamId && installedTeamId && userTeamId !== installedTeamId + ); +} + +export function parseDrilldownRun( + body: unknown, + action: unknown, + installedTeamId?: string +): SlackAgentRun | null { + const prompt = toBlockAction(action).value; + if (!prompt) { + return null; + } + + const payload = toBlockActionsBody(body); + const channelId = payload.channel?.id ?? payload.container?.channel_id; + const userId = payload.user?.id; + const messageTs = payload.message?.ts ?? payload.container?.message_ts; + const threadTs = payload.message?.thread_ts ?? messageTs; + + if (!(channelId && userId && threadTs)) { + return null; + } + + if (isExternalSlackConnectClick(payload.user?.team_id, installedTeamId)) { + return null; + } + + return { + channelId, + messageTs, + teamId: installedTeamId ?? payload.team?.id, + text: prompt, + threadTs, + trigger: "thread_follow_up", + userId, + }; +} diff --git a/apps/slack/src/slack/listeners.ts b/apps/slack/src/slack/listeners.ts index a1b4bd5b1..e48a9a328 100644 --- a/apps/slack/src/slack/listeners.ts +++ b/apps/slack/src/slack/listeners.ts @@ -8,10 +8,12 @@ import { import { createRPCContext } from "@databuddy/rpc"; import { appendInvestigationReply } from "@databuddy/rpc/insights"; import type { DatabuddyAgentClient, SlackAgentRun } from "@/agent/agent-client"; +import { buildAppHomeView } from "@/slack/app-home"; import { createSlackEventLog } from "@/lib/evlog-slack"; import { abortSlackActiveRun } from "@/slack/active-runs"; import { getSlackChannelMentionPolicy } from "@/slack/channel-policy"; -import { FEEDBACK_ACTION_ID } from "@/slack/blocks"; +import { DRILLDOWN_ACTION_ID, FEEDBACK_ACTION_ID } from "@/slack/blocks"; +import { parseDrilldownRun } from "@/slack/drilldown"; import { handleSlackFeedbackAction, logSlackReactionFeedback, @@ -155,6 +157,20 @@ export function registerSlackListeners( app.assistant(createDatabuddyAssistant({ agent, dedupe, threadQueue })); + app.event("app_home_opened", async ({ client, event, logger }) => { + if (event.tab !== "home") { + return; + } + try { + await client.views.publish({ + user_id: event.user, + view: buildAppHomeView(), + }); + } catch (error) { + logger.warn("Failed to publish Slack App Home", error); + } + }); + app.event( "app_mention", async ({ body, client, context, event, logger, say }) => { @@ -413,6 +429,36 @@ export function registerSlackListeners( registerSlackCommands(app, installations); registerSlackReactionFeedback(app, installations); registerSlackFeedbackButtons(app, installations); + registerSlackDrilldown(app, agent, threadQueue); +} + +function registerSlackDrilldown( + app: App, + agent: Pick, + threadQueue: SlackThreadQueueStore +) { + app.action( + DRILLDOWN_ACTION_ID, + async ({ ack, action, body, client, context, logger }) => { + await ack(); + const run = parseDrilldownRun(body, action, context.teamId); + if (!run) { + return; + } + const say: SlackSay = (message) => + client.chat.postMessage({ channel: run.channelId, ...message }); + const slackContext = createSlackConversationContext(client, run); + await threadQueue.markEngaged(run); + await handleAgentRun({ + agent, + client, + logger, + run: { ...run, slackContext }, + say, + threadQueue, + }); + } + ); } function registerSlackFeedbackButtons( diff --git a/apps/slack/src/slack/types.ts b/apps/slack/src/slack/types.ts index 833de5fd7..c25219277 100644 --- a/apps/slack/src/slack/types.ts +++ b/apps/slack/src/slack/types.ts @@ -25,6 +25,7 @@ export interface SlackAgentClient { "history" | "info" | "replies" >; reactions: Pick; + views: Pick; } export type SlackSay = ( diff --git a/packages/ai/src/ai/prompts/analytics.ts b/packages/ai/src/ai/prompts/analytics.ts index db2e3d8a7..fe64666b8 100644 --- a/packages/ai/src/ai/prompts/analytics.ts +++ b/packages/ai/src/ai/prompts/analytics.ts @@ -96,6 +96,7 @@ Other types: - link-preview: {"type":"link-preview","mode":"create","link":{"name":"…","targetUrl":"…","slug":"…","expiresAt":"Never"}} - feedback-preview: {"type":"feedback-preview","mode":"offer","feedback":{"title":"…","category":"bug_report","description":"…"}} — emit with mode "offer" when offering to send feedback (instead of restating the report in prose; the card has a send button), and again with mode "sent" as the receipt after submit_feedback succeeds. category: bug_report | feature_request | ux_improvement | performance | documentation | other. - dashboard-actions: clickable dashboard navigation. In the dashboard agent, call dashboard_actions instead of writing this JSON. Prefer safe relative hrefs. Known semantic targets are only shortcuts: website.dashboard, website.realtime, website.audience, website.events, website.events.stream, website.event (requires eventName), website.funnels, website.goals, website.users, website.errors, website.vitals, website.map, website.flags, website.revenue, website.settings.tracking, website.agent, global.events, global.events.stream, links, insights, websites, home. Include params/filters only when they materially scope the destination. +- suggested-actions: {"type":"suggested-actions","actions":[{"label":"Break down by referrer","prompt":"break /pricing down by referrer"}]} — offer 1-3 tailored follow-up questions as buttons. label is the button text (short); prompt is the exact question run when clicked. Only offer genuinely useful next steps, never generic filler. Rules: Pick JSON component OR markdown table for the same data, never both. Output the raw JSON directly on its own line with no surrounding markup. NEVER wrap in \`\`\`json code fences. @@ -191,7 +192,8 @@ Routing: Output discipline: - Use only values from this turn's tool results. Render a Slack delivery's channelId as \`<#CHANNELID>\`. - Skip preamble. Lead with the receipt itself. NEVER start with "Sure", "Got it", "Done.", "Done!", "Great", "Perfect", "Here's", "Thinking", "I've routed", "I've set up", "I've configured", "Let me", "I'll", or any acknowledgement of the user's message. -- Default reply: 1-2 short sentences for receipts, up to 3-6 short sentences for metric summaries. No headings/report formatting unless asked. No dashboard JSON. No invented numbers. No marketing or re-pitch. +- Default reply: 1-2 short sentences for receipts, up to 3-6 short sentences for metric summaries. No headings/report formatting unless asked. No invented numbers. No marketing or re-pitch. +- Slack renders component JSON natively: prefer a data-table over a long markdown table, and use chart/list components for trends and rankings. After a substantive analytics answer you may append one suggested-actions component with tailored drill-down follow-ups. - Rewrite/exact-copy tasks => output only the final copy. No labels, options, explanation, or preamble. - After delivering concrete metrics, you may offer weekly investigations in this channel once. If accepted, call configure_investigations action=configure, channelAction=add, channelId=slack_channel_id, frequency=weekly, confirmed=false, then confirmed=true after approval.