Skip to content
1 change: 1 addition & 0 deletions apps/slack/slack-app-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"settings": {
"event_subscriptions": {
"bot_events": [
"app_home_opened",
"app_mention",
"assistant_thread_context_changed",
"assistant_thread_started",
Expand Down
34 changes: 34 additions & 0 deletions apps/slack/src/slack/app-home.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>;
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<Record<string, unknown>>;
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);
}
});
});
61 changes: 61 additions & 0 deletions apps/slack/src/slack/app-home.ts
Original file line number Diff line number Diff line change
@@ -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`",
},
],
},
],
};
}
15 changes: 15 additions & 0 deletions apps/slack/src/slack/blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>>;
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",
Expand Down
197 changes: 99 additions & 98 deletions apps/slack/src/slack/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, unknown>;
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<string, unknown>;
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<string, unknown>) => Row;
title: string;
}

function renderLinksList(spec: ComponentSpec): Block[] {
const rows = asArray(spec.links).map((item) => {
const link = item as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, ListTableConfig> = {
"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<string, unknown>;
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<string, unknown>)
);
const block = dataTable(title(spec, config.title), config.columns, rows);
return block ? [block] : [];
}

Expand All @@ -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<string, unknown>;
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[],
Expand Down Expand Up @@ -323,13 +323,14 @@ const RENDERERS: Record<string, (spec: ComponentSpec) => 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,
Expand Down
Loading