feat(slack): publish App Home tab views

This commit is contained in:
Vincent Koc
2026-05-01 06:20:18 -07:00
committed by GitHub
parent 472de0e1d5
commit cef2542cec
7 changed files with 191 additions and 2 deletions

View File

@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
- Gateway/SDK: add SDK-facing tools.invoke RPC with shared HTTP policy, typed approval/refusal results, and SDK helper support. Refs #74705. Thanks @BunsDev and @ai-hpc.
- Discord: keep active buttons, selects, and forms working across Gateway restarts until they expire, so multi-step Discord interactions are less likely to break during upgrades or restarts. Thanks @amknight.
- Messages/docs: clarify that `BodyForAgent` is the primary inbound model text while `Body` is the legacy envelope fallback, and add Signal coverage so channel hardening patches target the real prompt path. Refs #66198. Thanks @defonota3box.
- Slack: publish a safe default App Home tab view on `app_home_opened` and include the Home tab event in setup manifests. Fixes #11655; refs #52020. Thanks @TinyTb.
- Slack: keep track of bot-participated threads across restarts, so ongoing threaded conversations can continue auto-replying after the Gateway is restarted. Thanks @amknight.
- Control UI/Usage: add UTC quarter-hour token buckets for the Usage Mosaic and reuse them for hour filtering, keeping the legacy session-span fallback for older summaries. (#74337) Thanks @konanok.
- BlueBubbles: add opt-in `channels.bluebubbles.replyContextApiFallback` that fetches the original message from the BlueBubbles HTTP API when the in-memory reply-context cache misses (multi-instance deployments sharing one BB account, post-restart, after long-lived TTL/LRU eviction). Off by default; channel-level setting propagates to accounts that omit the flag through `mergeAccountConfig`; routed through the typed `BlueBubblesClient` so every fetch is SSRF-guarded by the same three-mode policy as every other BB client request; reply-id shape is validated and part-index prefixes (`p:0/<guid>`) are stripped before the request; concurrent webhooks for the same `replyToId` coalesce into one fetch and successful responses populate the reply cache for subsequent hits. Also promotes BlueBubbles attachment download failures from verbose to runtime error so silently-dropped inbound images are visible at default log level, and extends `sanitizeForLog` to redact `?password=…`/`?token=…` query params and `Authorization:` headers before they reach the log sink (CWE-532). (#71820) Thanks @coletebou and @zqchris.

View File

@@ -169,6 +169,7 @@ Base manifest (Socket Mode default):
"features": {
"bot_user": { "display_name": "OpenClaw", "always_online": true },
"app_home": {
"home_tab_enabled": true,
"messages_tab_enabled": true,
"messages_tab_read_only_enabled": false
},
@@ -212,6 +213,7 @@ Base manifest (Socket Mode default):
"socket_mode_enabled": true,
"event_subscriptions": {
"bot_events": [
"app_home_opened",
"app_mention",
"channel_rename",
"member_joined_channel",
@@ -264,6 +266,8 @@ For **HTTP Request URLs mode**, replace `settings` with the HTTP variant and add
Surface different features that extend the above defaults.
The default manifest enables the Slack App Home **Home** tab and subscribes to `app_home_opened`. When a workspace member opens the Home tab, OpenClaw publishes a safe default Home view with `views.publish`; no conversation payload or private configuration is included. The **Messages** tab remains enabled for Slack DMs.
<AccordionGroup>
<Accordion title="Optional native slash commands">

View File

@@ -1,6 +1,7 @@
import type { ResolvedSlackAccount } from "../accounts.js";
import type { SlackMonitorContext } from "./context.js";
import { registerSlackChannelEvents } from "./events/channels.js";
import { registerSlackHomeEvents } from "./events/home.js";
import { registerSlackInteractionEvents } from "./events/interactions.js";
import { registerSlackMemberEvents } from "./events/members.js";
import { registerSlackMessageEvents } from "./events/messages.js";
@@ -23,5 +24,6 @@ export function registerSlackMonitorEvents(params: {
registerSlackMemberEvents({ ctx: params.ctx, trackEvent: params.trackEvent });
registerSlackChannelEvents({ ctx: params.ctx, trackEvent: params.trackEvent });
registerSlackPinEvents({ ctx: params.ctx, trackEvent: params.trackEvent });
registerSlackHomeEvents({ ctx: params.ctx, trackEvent: params.trackEvent });
registerSlackInteractionEvents({ ctx: params.ctx, trackEvent: params.trackEvent });
}

View File

@@ -0,0 +1,102 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
let buildSlackHomeView: typeof import("./home.js").buildSlackHomeView;
let registerSlackHomeEvents: typeof import("./home.js").registerSlackHomeEvents;
let createSlackSystemEventTestHarness: typeof import("./system-event-test-harness.js").createSlackSystemEventTestHarness;
type HomeHandler = (args: { event: Record<string, unknown>; body: unknown }) => Promise<void>;
function createHomeContext(params?: {
trackEvent?: () => void;
shouldDropMismatchedSlackEvent?: (body: unknown) => boolean;
}) {
const harness = createSlackSystemEventTestHarness();
const publish = vi.fn().mockResolvedValue({ ok: true });
if (params?.shouldDropMismatchedSlackEvent) {
harness.ctx.shouldDropMismatchedSlackEvent = params.shouldDropMismatchedSlackEvent;
}
harness.ctx.botToken = "xoxb-test";
(harness.ctx.app as unknown as { client: { views: { publish: typeof publish } } }).client = {
views: { publish },
};
registerSlackHomeEvents({ ctx: harness.ctx, trackEvent: params?.trackEvent });
return {
publish,
getHomeHandler: () => harness.getHandler("app_home_opened") as HomeHandler | null,
};
}
describe("registerSlackHomeEvents", () => {
beforeAll(async () => {
({ buildSlackHomeView, registerSlackHomeEvents } = await import("./home.js"));
({ createSlackSystemEventTestHarness } = await import("./system-event-test-harness.js"));
});
beforeEach(() => {
vi.clearAllMocks();
});
it("publishes the default Home tab view for app_home_opened", async () => {
const trackEvent = vi.fn();
const { publish, getHomeHandler } = createHomeContext({ trackEvent });
const handler = getHomeHandler();
expect(handler).toBeTruthy();
await handler!({
event: {
type: "app_home_opened",
user: "U123",
channel: "D123",
tab: "home",
event_ts: "123.456",
},
body: { api_app_id: "A1" },
});
expect(trackEvent).toHaveBeenCalledTimes(1);
expect(publish).toHaveBeenCalledTimes(1);
expect(publish).toHaveBeenCalledWith({
token: "xoxb-test",
user_id: "U123",
view: buildSlackHomeView(),
});
});
it("does not publish when Slack reports the Messages tab", async () => {
const trackEvent = vi.fn();
const { publish, getHomeHandler } = createHomeContext({ trackEvent });
await getHomeHandler()!({
event: {
type: "app_home_opened",
user: "U123",
channel: "D123",
tab: "messages",
},
body: {},
});
expect(trackEvent).toHaveBeenCalledTimes(1);
expect(publish).not.toHaveBeenCalled();
});
it("does not track or publish mismatched events", async () => {
const trackEvent = vi.fn();
const { publish, getHomeHandler } = createHomeContext({
trackEvent,
shouldDropMismatchedSlackEvent: () => true,
});
await getHomeHandler()!({
event: {
type: "app_home_opened",
user: "U123",
tab: "home",
},
body: { api_app_id: "A_OTHER" },
});
expect(trackEvent).not.toHaveBeenCalled();
expect(publish).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,70 @@
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
import type { HomeView } from "@slack/types";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import type { SlackMonitorContext } from "../context.js";
import type { SlackAppHomeOpenedEvent } from "../types.js";
export function buildSlackHomeView(): HomeView {
return {
type: "home",
callback_id: "openclaw:home",
blocks: [
{
type: "header",
text: {
type: "plain_text",
text: "OpenClaw",
},
},
{
type: "section",
text: {
type: "mrkdwn",
text: "Send a DM, mention OpenClaw in a channel, or use `/openclaw` to start a session.",
},
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: "This Home tab is safe to show to any workspace member who opens the app.",
},
],
},
],
};
}
export function registerSlackHomeEvents(params: {
ctx: SlackMonitorContext;
trackEvent?: () => void;
}) {
const { ctx, trackEvent } = params;
ctx.app.event(
"app_home_opened",
async ({ event, body }: SlackEventMiddlewareArgs<"app_home_opened">) => {
try {
if (ctx.shouldDropMismatchedSlackEvent(body)) {
return;
}
trackEvent?.();
const payload = event as SlackAppHomeOpenedEvent;
if (!payload.user || payload.tab === "messages") {
return;
}
await ctx.app.client.views.publish({
token: ctx.botToken,
user_id: payload.user,
view: buildSlackHomeView(),
});
} catch (err) {
ctx.runtime.error?.(danger(`slack app home handler failed: ${formatErrorMessage(err)}`));
}
},
);
}

View File

@@ -60,6 +60,14 @@ export type SlackChannelIdChangedEvent = {
event_ts?: string;
};
export type SlackAppHomeOpenedEvent = {
type: "app_home_opened";
user?: string;
channel?: string;
tab?: "home" | "messages";
event_ts?: string;
};
export type SlackPinEvent = {
type: "pin_added" | "pin_removed";
channel_id?: string;

View File

@@ -20,6 +20,7 @@ function buildSlackManifest(botName: string) {
always_online: true,
},
app_home: {
home_tab_enabled: true,
messages_tab_enabled: true,
messages_tab_read_only_enabled: false,
},
@@ -63,6 +64,7 @@ function buildSlackManifest(botName: string) {
socket_mode_enabled: true,
event_subscriptions: {
bot_events: [
"app_home_opened",
"app_mention",
"channel_rename",
"member_joined_channel",
@@ -87,8 +89,8 @@ export function buildSlackSetupLines(botName = "OpenClaw"): string[] {
"1) Slack API -> Create App -> From scratch or From manifest (with the JSON below)",
"2) Add Socket Mode + enable it to get the app-level token (xapp-...)",
"3) Install App to workspace to get the xoxb- bot token",
"4) Enable Event Subscriptions (socket) for message events",
"5) App Home -> enable the Messages tab for DMs",
"4) Enable Event Subscriptions (socket) for message and App Home events",
"5) App Home -> enable the Home tab and Messages tab for DMs",
"Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.",
`Docs: ${formatDocsLink("/slack", "slack")}`,
"",