fix(slack): align App Home with active commands

This commit is contained in:
Peter Steinberger
2026-07-11 17:26:38 -07:00
committed by Peter Steinberger
parent 12f7d9c784
commit ecefc6cab6
8 changed files with 95 additions and 25 deletions

View File

@@ -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.

View File

@@ -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.
<AccordionGroup>
<Accordion title="Optional native slash commands">

View File

@@ -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 });
}

View File

@@ -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 () => {

View File

@@ -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)}`));

View File

@@ -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,

View File

@@ -306,7 +306,10 @@ vi.mock("./slash-commands.runtime.js", () => {
};
});
type RegisterFn = (params: { ctx: unknown; account: unknown }) => Promise<void>;
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<typeof vi.fn>) {
});
}
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({

View File

@@ -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<void> {
}): Promise<SlackCommandRegistration> {
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;
}