diff --git a/CHANGELOG.md b/CHANGELOG.md index b30a4a664be0..2645d972d2ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,7 +47,6 @@ Docs: https://docs.openclaw.ai - **Windows Node resolution:** preserve the current executable when resolving bare case-insensitive `node.exe` entries under hostile `PATH` values. (#103907) Thanks @soldforaloss. - **Codex runtime switching:** accept the bundled Codex runtime for both `codex/*` and `openai/*` model routes while keeping unsupported provider/runtime pairs rejected. (#103762) - **Agent abort cleanup:** serialize prompt lock reacquisition with terminal cleanup so canceled embedded runs do not self-contend on session locks for up to 60 seconds. -- **Slack App Home command hints:** show the configured native slash command instead of always `/openclaw` in the Home tab. (#102340) Thanks @jontsai. - **Chutes OAuth deadlines:** bound token exchange, profile lookup, and refresh requests, and keep issued tokens when optional userinfo enrichment stalls. (#102026) Thanks @Alix-007. - **Control UI workspace avatars:** inline validated agent avatar files in bootstrap and identity responses so Personal card images render without unauthenticated avatar-route requests, while preserving configured emoji precedence. (#102892, #97602) Thanks @LZY3538. - **Exec safe-bin flags:** auto-approve curated read-only boolean flags for default stdin-only filters while keeping unknown flags, tail follow/retry modes, file operands, and custom profiles fail-closed. (#88953) Thanks @yetval. diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 23249b15afc3..f4f08e23c763 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -912,7 +912,7 @@ 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, and the command hint uses the configured `channels.slack.slashCommand.name`. The **Messages** tab remains enabled for Slack DMs. The manifest also enables Slack assistant threads with `features.assistant_view`, `assistant:write`, `assistant_thread_started`, and `assistant_thread_context_changed`; assistant threads route to their own OpenClaw thread sessions and keep Slack-provided thread context available to the agent. +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. When single slash command mode is enabled, the command hint uses `channels.slack.slashCommand.name`; installations using native commands or no slash commands omit that hint. The **Messages** tab remains enabled for Slack DMs. The manifest also enables Slack assistant threads with `features.assistant_view`, `assistant:write`, `assistant_thread_started`, and `assistant_thread_context_changed`; assistant threads route to their own OpenClaw thread sessions and keep Slack-provided thread context available to the agent. diff --git a/extensions/slack/src/monitor/events.ts b/extensions/slack/src/monitor/events.ts index 017b174a9ebd..d88836e814c9 100644 --- a/extensions/slack/src/monitor/events.ts +++ b/extensions/slack/src/monitor/events.ts @@ -15,6 +15,7 @@ export function registerSlackMonitorEvents(params: { ctx: SlackMonitorContext; account: ResolvedSlackAccount; handleSlackMessage: SlackMessageHandler; + appHomeSlashCommandName?: string; /** Called on each inbound event to update liveness tracking. */ trackEvent?: () => void; }) { @@ -29,7 +30,11 @@ 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 }); + registerSlackHomeEvents({ + ctx: params.ctx, + slashCommandName: params.appHomeSlashCommandName, + trackEvent: params.trackEvent, + }); registerSlackInteractionEvents({ ctx: params.ctx, trackEvent: params.trackEvent }); registerSlackAssistantEvents({ 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 index 58df7eaaa461..03c4105f82e9 100644 --- a/extensions/slack/src/monitor/events/home.test.ts +++ b/extensions/slack/src/monitor/events/home.test.ts @@ -17,17 +17,15 @@ function createHomeContext(params?: { if (params?.shouldDropMismatchedSlackEvent) { harness.ctx.shouldDropMismatchedSlackEvent = params.shouldDropMismatchedSlackEvent; } - harness.ctx.slashCommand = { - enabled: true, - name: params?.slashCommandName ?? "openclaw", - sessionPrefix: "slack:slash", - ephemeral: true, - }; 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 }); + registerSlackHomeEvents({ + ctx: harness.ctx, + slashCommandName: params?.slashCommandName, + trackEvent: params?.trackEvent, + }); return { publish, getHomeHandler: () => harness.getHandler("app_home_opened") as HomeHandler | null, @@ -44,7 +42,7 @@ describe("registerSlackHomeEvents", () => { vi.clearAllMocks(); }); - it("publishes the default Home tab view for app_home_opened", async () => { + it("publishes the Home tab without an inactive slash command hint", async () => { const trackEvent = vi.fn(); const { publish, getHomeHandler } = createHomeContext({ trackEvent }); const handler = getHomeHandler(); @@ -70,6 +68,12 @@ describe("registerSlackHomeEvents", () => { user_id: "U123", view: buildSlackHomeView(), }); + expect(buildSlackHomeView().blocks[1]).toMatchObject({ + type: "section", + text: { + text: "Send a DM or mention OpenClaw in a channel to start a session.", + }, + }); }); it("publishes the configured slash command name", async () => { diff --git a/extensions/slack/src/monitor/events/home.ts b/extensions/slack/src/monitor/events/home.ts index 2e3af8672b11..b6635b9a02e3 100644 --- a/extensions/slack/src/monitor/events/home.ts +++ b/extensions/slack/src/monitor/events/home.ts @@ -6,7 +6,10 @@ import { danger } from "openclaw/plugin-sdk/runtime-env"; import type { SlackMonitorContext } from "../context.js"; import type { SlackAppHomeOpenedEvent } from "../types.js"; -export function buildSlackHomeView(slashCommandName = "openclaw"): HomeView { +export function buildSlackHomeView(slashCommandName?: string): HomeView { + const startSessionText = slashCommandName + ? `Send a DM, mention OpenClaw in a channel, or use \`/${slashCommandName}\` to start a session.` + : "Send a DM or mention OpenClaw in a channel to start a session."; return { type: "home", callback_id: "openclaw:home", @@ -22,7 +25,7 @@ export function buildSlackHomeView(slashCommandName = "openclaw"): HomeView { type: "section", text: { type: "mrkdwn", - text: `Send a DM, mention OpenClaw in a channel, or use \`/${slashCommandName}\` to start a session.`, + text: startSessionText, }, }, { @@ -40,9 +43,10 @@ export function buildSlackHomeView(slashCommandName = "openclaw"): HomeView { export function registerSlackHomeEvents(params: { ctx: SlackMonitorContext; + slashCommandName?: string; trackEvent?: () => void; }) { - const { ctx, trackEvent } = params; + const { ctx, slashCommandName, trackEvent } = params; ctx.app.event( "app_home_opened", @@ -61,7 +65,7 @@ export function registerSlackHomeEvents(params: { await ctx.app.client.views.publish({ token: ctx.botToken, user_id: payload.user, - view: buildSlackHomeView(ctx.slashCommand.name), + view: buildSlackHomeView(slashCommandName), }); } catch (err) { ctx.runtime.error?.(danger(`slack app home handler failed: ${formatErrorMessage(err)}`)); diff --git a/extensions/slack/src/monitor/provider.ts b/extensions/slack/src/monitor/provider.ts index 106a6ab46b20..d19ee7328115 100644 --- a/extensions/slack/src/monitor/provider.ts +++ b/extensions/slack/src/monitor/provider.ts @@ -501,10 +501,20 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { }); } - registerSlackMonitorEvents({ ctx, account, handleSlackMessage, trackEvent }); - if (installationIdentity.kind !== "enterprise") { - await registerSlackMonitorSlashCommands({ ctx, account, trackEvent }); - } + // Resolve command registration first so App Home never advertises an inactive single command. + const commandRegistration = + installationIdentity.kind === "enterprise" + ? ({ mode: "disabled" } as const) + : await registerSlackMonitorSlashCommands({ ctx, account, trackEvent }); + const appHomeSlashCommandName = + commandRegistration.mode === "single" ? commandRegistration.name : undefined; + registerSlackMonitorEvents({ + ctx, + account, + handleSlackMessage, + appHomeSlashCommandName, + trackEvent, + }); if (slackMode === "http" && slackHttpHandler) { unregisterHttpHandler = registerSlackHttpHandler({ path: slackWebhookPath, diff --git a/extensions/slack/src/monitor/slash.test.ts b/extensions/slack/src/monitor/slash.test.ts index 00cacad0ac9e..2ce08fd67d4e 100644 --- a/extensions/slack/src/monitor/slash.test.ts +++ b/extensions/slack/src/monitor/slash.test.ts @@ -306,7 +306,10 @@ vi.mock("./slash-commands.runtime.js", () => { }; }); -type RegisterFn = (params: { ctx: unknown; account: unknown }) => Promise; +type RegisterFn = (params: { + ctx: unknown; + account: unknown; +}) => Promise<{ mode: "single"; name: string } | { mode: "native" } | { mode: "disabled" }>; const { registerSlackMonitorSlashCommands } = (await import("./slash.js")) as { registerSlackMonitorSlashCommands: RegisterFn; }; @@ -324,7 +327,7 @@ afterEach(() => { }); async function registerCommands(ctx: unknown, account: unknown, trackEvent?: () => void) { - await registerSlackMonitorSlashCommands({ + return await registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never, trackEvent, @@ -1207,6 +1210,8 @@ function createPolicyHarness(overrides?: { allowFrom?: string[]; useAccessGroups?: boolean; slashEphemeral?: boolean; + slashCommandEnabled?: boolean; + slashCommandName?: string; shouldDropMismatchedSlackEvent?: (body: unknown) => boolean; resolveChannelName?: () => Promise<{ name?: string; type?: string }>; }) { @@ -1238,8 +1243,8 @@ function createPolicyHarness(overrides?: { useAccessGroups: overrides?.useAccessGroups ?? true, channelsConfig: overrides?.channelsConfig, slashCommand: { - enabled: true, - name: "openclaw", + enabled: overrides?.slashCommandEnabled ?? true, + name: overrides?.slashCommandName ?? "openclaw", ephemeral: overrides?.slashEphemeral ?? true, sessionPrefix: "slack:slash", }, @@ -1335,6 +1340,36 @@ function expectUnauthorizedResponse(respond: ReturnType) { }); } +describe("Slack App Home command presentation", () => { + it("returns the configured single command when it is registered", async () => { + const harness = createPolicyHarness({ slashCommandName: "acme" }); + + await expect(registerCommands(harness.ctx, harness.account)).resolves.toEqual({ + mode: "single", + name: "acme", + }); + expect(harness.commands.size).toBe(1); + }); + + it("omits the single command when slash commands are disabled", async () => { + const harness = createPolicyHarness({ slashCommandEnabled: false }); + + await expect(registerCommands(harness.ctx, harness.account)).resolves.toEqual({ + mode: "disabled", + }); + expect(harness.commands.size).toBe(0); + }); + + it("omits the single command when native commands take precedence", async () => { + const harness = createArgMenusHarness(); + + await expect(registerCommands(harness.ctx, harness.account)).resolves.toEqual({ + mode: "native", + }); + expect(harness.commands.size).toBeGreaterThan(0); + }); +}); + describe("slack slash commands channel policy", () => { it("drops mismatched slash payloads before dispatch", async () => { const harness = createPolicyHarness({ diff --git a/extensions/slack/src/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts index 4f0054a7f38e..5c790b6afca7 100644 --- a/extensions/slack/src/monitor/slash.ts +++ b/extensions/slack/src/monitor/slash.ts @@ -367,11 +367,16 @@ function buildSlackCommandArgMenuBlocks(params: { ]; } +type SlackCommandRegistration = + | { mode: "single"; name: string } + | { mode: "native" } + | { mode: "disabled" }; + export async function registerSlackMonitorSlashCommands(params: { ctx: SlackMonitorContext; account: ResolvedSlackAccount; trackEvent?: () => void; -}): Promise { +}): Promise { const { ctx, account, trackEvent } = params; const startupCfg = ctx.cfg; const runtime = ctx.runtime; @@ -906,8 +911,15 @@ export async function registerSlackMonitorSlashCommands(params: { logVerbose("slack: slash commands disabled"); } + const registration: SlackCommandRegistration = + nativeCommands.length > 0 + ? { mode: "native" } + : slashCommand.enabled + ? { mode: "single", name: slashCommand.name } + : { mode: "disabled" }; + if (nativeCommands.length === 0 || !supportsInteractiveArgMenus) { - return; + return registration; } const registerArgOptions = () => { @@ -1082,4 +1094,5 @@ export async function registerSlackMonitorSlashCommands(params: { }); }; registerArgAction(SLACK_COMMAND_ARG_ACTION_LISTENER); + return registration; }