diff --git a/CHANGELOG.md b/CHANGELOG.md index 5410a1d3a72..6fba66bd6de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/`) 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. diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 2b1c295b676..856520a8822 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -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. + diff --git a/extensions/slack/src/monitor/events.ts b/extensions/slack/src/monitor/events.ts index 7c637588204..940f01914b5 100644 --- a/extensions/slack/src/monitor/events.ts +++ b/extensions/slack/src/monitor/events.ts @@ -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 }); } diff --git a/extensions/slack/src/monitor/events/home.test.ts b/extensions/slack/src/monitor/events/home.test.ts new file mode 100644 index 00000000000..9adc349a491 --- /dev/null +++ b/extensions/slack/src/monitor/events/home.test.ts @@ -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; body: unknown }) => Promise; + +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(); + }); +}); diff --git a/extensions/slack/src/monitor/events/home.ts b/extensions/slack/src/monitor/events/home.ts new file mode 100644 index 00000000000..b4e911b4ab3 --- /dev/null +++ b/extensions/slack/src/monitor/events/home.ts @@ -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)}`)); + } + }, + ); +} diff --git a/extensions/slack/src/monitor/types.ts b/extensions/slack/src/monitor/types.ts index fb98a8aa5df..171fd799d0c 100644 --- a/extensions/slack/src/monitor/types.ts +++ b/extensions/slack/src/monitor/types.ts @@ -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; diff --git a/extensions/slack/src/setup-shared.ts b/extensions/slack/src/setup-shared.ts index aeaf60b4faf..def0387e580 100644 --- a/extensions/slack/src/setup-shared.ts +++ b/extensions/slack/src/setup-shared.ts @@ -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")}`, "",