From 46cae41095a09318f4de9d97738c92233172dcae Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:29:54 +0300 Subject: [PATCH 1/6] feat(slack): agent-tailored drill-down buttons that run scoped follow-ups --- apps/slack/src/slack/blocks.test.ts | 15 ++++++++ apps/slack/src/slack/blocks.ts | 25 +++++++++++++ apps/slack/src/slack/drilldown.test.ts | 52 ++++++++++++++++++++++++++ apps/slack/src/slack/drilldown.ts | 45 ++++++++++++++++++++++ apps/slack/src/slack/listeners.ts | 33 +++++++++++++++- 5 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 apps/slack/src/slack/drilldown.test.ts create mode 100644 apps/slack/src/slack/drilldown.ts 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..d25d031b9 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; @@ -238,6 +241,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[], @@ -330,6 +354,7 @@ const RENDERERS: Record Block[]> = { "goals-list": renderGoalsList, "annotations-list": renderAnnotationsList, "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..3f5621ab5 --- /dev/null +++ b/apps/slack/src/slack/drilldown.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "bun:test"; +import { 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(); + }); +}); diff --git a/apps/slack/src/slack/drilldown.ts b/apps/slack/src/slack/drilldown.ts new file mode 100644 index 000000000..13b98c322 --- /dev/null +++ b/apps/slack/src/slack/drilldown.ts @@ -0,0 +1,45 @@ +import type { SlackAgentRun } from "@/agent/agent-client"; + +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; +} + +export function parseDrilldownRun( + body: unknown, + action: unknown +): SlackAgentRun | null { + const prompt = isRecord(action) ? getString(action.value) : undefined; + if (!prompt) { + return null; + } + + const payload = isRecord(body) ? body : {}; + const user = isRecord(payload.user) ? payload.user : {}; + const team = isRecord(payload.team) ? payload.team : {}; + const channel = isRecord(payload.channel) ? payload.channel : {}; + const container = isRecord(payload.container) ? payload.container : {}; + const message = isRecord(payload.message) ? payload.message : {}; + + const channelId = getString(channel.id) ?? getString(container.channel_id); + const userId = getString(user.id); + const messageTs = getString(message.ts) ?? getString(container.message_ts); + const threadTs = getString(message.thread_ts) ?? messageTs; + + if (!(channelId && userId && threadTs)) { + return null; + } + + return { + channelId, + messageTs, + teamId: getString(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..5f6832152 100644 --- a/apps/slack/src/slack/listeners.ts +++ b/apps/slack/src/slack/listeners.ts @@ -11,7 +11,8 @@ import type { DatabuddyAgentClient, SlackAgentRun } from "@/agent/agent-client"; 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, @@ -413,6 +414,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, logger }) => { + await ack(); + const run = parseDrilldownRun(body, action); + 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( From effe953a4bb7ef30d4d8b9e4241e4c7b8c5f119f Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:30:07 +0300 Subject: [PATCH 2/6] feat(ai): let the agent emit suggested-actions and native components in Slack --- packages/ai/src/ai/prompts/analytics.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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. From a79ad0588488d32c59d4601ff9dbfd1191d4d777 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:37:24 +0300 Subject: [PATCH 3/6] fix(slack): block external Slack Connect users from drill-down runs and type payloads --- apps/slack/src/slack/drilldown.test.ts | 25 +++++++- apps/slack/src/slack/drilldown.ts | 83 +++++++++++++++++++++----- apps/slack/src/slack/listeners.ts | 4 +- 3 files changed, 95 insertions(+), 17 deletions(-) diff --git a/apps/slack/src/slack/drilldown.test.ts b/apps/slack/src/slack/drilldown.test.ts index 3f5621ab5..897c9eebd 100644 --- a/apps/slack/src/slack/drilldown.test.ts +++ b/apps/slack/src/slack/drilldown.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "bun:test"; -import { parseDrilldownRun } from "@/slack/drilldown"; +import { + isExternalSlackConnectClick, + parseDrilldownRun, +} from "@/slack/drilldown"; function blockActionsBody(overrides: Record = {}) { return { @@ -49,4 +52,24 @@ describe("parseDrilldownRun", () => { ) ).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 index 13b98c322..5ebd887de 100644 --- a/apps/slack/src/slack/drilldown.ts +++ b/apps/slack/src/slack/drilldown.ts @@ -1,5 +1,18 @@ 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)); } @@ -8,35 +21,77 @@ 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 + action: unknown, + installedTeamId?: string ): SlackAgentRun | null { - const prompt = isRecord(action) ? getString(action.value) : undefined; + const prompt = toBlockAction(action).value; if (!prompt) { return null; } - const payload = isRecord(body) ? body : {}; - const user = isRecord(payload.user) ? payload.user : {}; - const team = isRecord(payload.team) ? payload.team : {}; - const channel = isRecord(payload.channel) ? payload.channel : {}; - const container = isRecord(payload.container) ? payload.container : {}; - const message = isRecord(payload.message) ? payload.message : {}; - - const channelId = getString(channel.id) ?? getString(container.channel_id); - const userId = getString(user.id); - const messageTs = getString(message.ts) ?? getString(container.message_ts); - const threadTs = getString(message.thread_ts) ?? messageTs; + 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: getString(team.id), + teamId: installedTeamId ?? payload.team?.id, text: prompt, threadTs, trigger: "thread_follow_up", diff --git a/apps/slack/src/slack/listeners.ts b/apps/slack/src/slack/listeners.ts index 5f6832152..98614a7ce 100644 --- a/apps/slack/src/slack/listeners.ts +++ b/apps/slack/src/slack/listeners.ts @@ -424,9 +424,9 @@ function registerSlackDrilldown( ) { app.action( DRILLDOWN_ACTION_ID, - async ({ ack, action, body, client, logger }) => { + async ({ ack, action, body, client, context, logger }) => { await ack(); - const run = parseDrilldownRun(body, action); + const run = parseDrilldownRun(body, action, context.teamId); if (!run) { return; } From 11f8c73d6988f41741e73f06856b770d7d01680f Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:15:14 +0300 Subject: [PATCH 4/6] feat(slack): publish an App Home tab with quick-start guidance --- apps/slack/slack-app-manifest.json | 1 + apps/slack/src/slack/app-home.test.ts | 22 ++++++++++++++ apps/slack/src/slack/app-home.ts | 43 +++++++++++++++++++++++++++ apps/slack/src/slack/listeners.ts | 17 +++++++++++ apps/slack/src/slack/types.ts | 1 + 5 files changed, 84 insertions(+) create mode 100644 apps/slack/src/slack/app-home.test.ts create mode 100644 apps/slack/src/slack/app-home.ts 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..6e65ab161 --- /dev/null +++ b/apps/slack/src/slack/app-home.test.ts @@ -0,0 +1,22 @@ +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("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..c940176c1 --- /dev/null +++ b/apps/slack/src/slack/app-home.ts @@ -0,0 +1,43 @@ +import { SLACK_SUGGESTED_PROMPTS } from "@/slack/messages"; + +export function buildAppHomeView(): Record { + 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: "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/listeners.ts b/apps/slack/src/slack/listeners.ts index 98614a7ce..391f3af3f 100644 --- a/apps/slack/src/slack/listeners.ts +++ b/apps/slack/src/slack/listeners.ts @@ -8,6 +8,7 @@ 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"; @@ -156,6 +157,22 @@ 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() as unknown as Parameters< + typeof client.views.publish + >[0]["view"], + }); + } catch (error) { + logger.warn("Failed to publish Slack App Home", error); + } + }); + app.event( "app_mention", async ({ body, client, context, event, logger, say }) => { 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 = ( From da6aad20c0f5287918e3d7a107443b2d68c6d5d4 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:05:50 +0300 Subject: [PATCH 5/6] refactor(slack): collapse list renderers into one config and type the home view --- apps/slack/src/slack/app-home.ts | 3 +- apps/slack/src/slack/blocks.ts | 176 +++++++++++++----------------- apps/slack/src/slack/listeners.ts | 4 +- 3 files changed, 79 insertions(+), 104 deletions(-) diff --git a/apps/slack/src/slack/app-home.ts b/apps/slack/src/slack/app-home.ts index c940176c1..4fc26ed1a 100644 --- a/apps/slack/src/slack/app-home.ts +++ b/apps/slack/src/slack/app-home.ts @@ -1,6 +1,7 @@ +import type { HomeView } from "@slack/web-api"; import { SLACK_SUGGESTED_PROMPTS } from "@/slack/messages"; -export function buildAppHomeView(): Record { +export function buildAppHomeView(): HomeView { const prompts = SLACK_SUGGESTED_PROMPTS.map( (prompt) => `• ${prompt.message}` ).join("\n"); diff --git a/apps/slack/src/slack/blocks.ts b/apps/slack/src/slack/blocks.ts index d25d031b9..c69ab7ab6 100644 --- a/apps/slack/src/slack/blocks.ts +++ b/apps/slack/src/slack/blocks.ts @@ -122,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] : []; -} - -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] : []; -} +interface ListTableConfig { + columns: string[]; + items: string; + row: (item: Record) => Row; + title: string; +} + +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] : []; } @@ -347,12 +323,12 @@ 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, diff --git a/apps/slack/src/slack/listeners.ts b/apps/slack/src/slack/listeners.ts index 391f3af3f..e48a9a328 100644 --- a/apps/slack/src/slack/listeners.ts +++ b/apps/slack/src/slack/listeners.ts @@ -164,9 +164,7 @@ export function registerSlackListeners( try { await client.views.publish({ user_id: event.user, - view: buildAppHomeView() as unknown as Parameters< - typeof client.views.publish - >[0]["view"], + view: buildAppHomeView(), }); } catch (error) { logger.warn("Failed to publish Slack App Home", error); From 0840576605482701c77232a627f90d15edaf3da3 Mon Sep 17 00:00:00 2001 From: iza <59828082+izadoesdev@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:38:10 +0300 Subject: [PATCH 6/6] feat(slack): add quick-action buttons to App Home tab --- apps/slack/src/slack/app-home.test.ts | 12 ++++++++++++ apps/slack/src/slack/app-home.ts | 17 +++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/apps/slack/src/slack/app-home.test.ts b/apps/slack/src/slack/app-home.test.ts index 6e65ab161..992abe435 100644 --- a/apps/slack/src/slack/app-home.test.ts +++ b/apps/slack/src/slack/app-home.test.ts @@ -12,6 +12,18 @@ describe("buildAppHomeView", () => { 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); diff --git a/apps/slack/src/slack/app-home.ts b/apps/slack/src/slack/app-home.ts index 4fc26ed1a..1fd6234f9 100644 --- a/apps/slack/src/slack/app-home.ts +++ b/apps/slack/src/slack/app-home.ts @@ -1,6 +1,14 @@ 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}` @@ -20,6 +28,15 @@ export function buildAppHomeView(): HomeView { 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",