fix(slack): print setup manifest as plain JSON

This commit is contained in:
Peter Steinberger
2026-05-01 22:35:55 +01:00
parent ff64b96ff7
commit c2a2cfe314
11 changed files with 124 additions and 15 deletions

View File

@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Slack/setup: print the generated app manifest as plain JSON instead of embedding it inside the framed setup note, so it can be copied into Slack without deleting border characters. Fixes #65751. Thanks @theDanielJLewis.
- Agents/Codex: stop prompting message-tool-only source turns to finish with `NO_REPLY`, so quiet turns are represented by not calling the visible message tool instead of conflicting final-text instructions. Thanks @pashpashpash.
- Gateway/config: report failed backup restores as failed in logs and config observe audit records instead of marking them valid. (#70515) Thanks @davidangularme.
- Compaction: use the active session model fallback chain for implicit summarization failures without persisting fallback model selection, so Azure content-filter 400s can recover. Fixes #64960. (#74470) Thanks @jalehman and @OpenCodeEngineer.

View File

@@ -23,6 +23,7 @@ import {
import { inspectSlackAccount } from "./account-inspect.js";
import { resolveSlackAccount } from "./accounts.js";
import {
buildSlackManifest,
buildSlackSetupLines,
isSlackSetupAccountConfigured,
SLACK_CHANNEL as channel,
@@ -177,6 +178,17 @@ export function createSlackSetupWizardBase(handlers: {
shouldShow: ({ cfg, accountId }) =>
!isSlackSetupAccountConfigured(resolveSlackAccount({ cfg, accountId })),
},
prepare: async ({ cfg, accountId, prompter }) => {
if (isSlackSetupAccountConfigured(resolveSlackAccount({ cfg, accountId }))) {
return;
}
const manifest = buildSlackManifest();
if (prompter.plain) {
await prompter.plain(manifest);
} else {
await prompter.note(manifest, "Slack manifest JSON");
}
},
envShortcut: {
prompt: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?",
preferredEnvVar: "SLACK_BOT_TOKEN",

View File

@@ -7,7 +7,7 @@ import type { OpenClawConfig } from "./channel-api.js";
export const SLACK_CHANNEL = "slack" as const;
function buildSlackManifest(botName: string) {
export function buildSlackManifest(botName = "OpenClaw") {
const safeName = botName.trim() || "OpenClaw";
const manifest = {
display_information: {
@@ -84,18 +84,16 @@ function buildSlackManifest(botName: string) {
return JSON.stringify(manifest, null, 2);
}
export function buildSlackSetupLines(botName = "OpenClaw"): string[] {
export function buildSlackSetupLines(): string[] {
return [
"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 and App Home events",
"5) App Home -> enable the Home tab and Messages tab for DMs",
"Manifest JSON follows as plain text for copy/paste.",
"Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.",
`Docs: ${formatDocsLink("/slack", "slack")}`,
"",
"Manifest (JSON):",
buildSlackManifest(botName),
];
}

View File

@@ -1,11 +1,13 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import {
createTestWizardPrompter,
runSetupWizardPrepare,
runSetupWizardFinalize,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import type { WizardPrompter } from "openclaw/plugin-sdk/plugin-test-runtime";
import { describe, expect, it, vi } from "vitest";
import { createSlackSetupWizardBase } from "./setup-core.js";
import { buildSlackSetupLines } from "./setup-shared.js";
const slackSetupWizard = createSlackSetupWizardBase({
promptAllowFrom: async ({ cfg }) => cfg,
@@ -18,16 +20,16 @@ const slackSetupWizard = createSlackSetupWizardBase({
resolveGroupAllowlist: async ({ entries }) => entries,
});
describe("slackSetupWizard.finalize", () => {
const baseCfg = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
const baseCfg = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
} as OpenClawConfig;
},
} as OpenClawConfig;
describe("slackSetupWizard.finalize", () => {
it("prompts to enable interactive replies for newly configured Slack accounts", async () => {
const confirm = vi.fn(async () => true);
@@ -75,6 +77,53 @@ describe("slackSetupWizard.finalize", () => {
});
});
describe("slackSetupWizard.prepare", () => {
it("keeps the manifest out of framed intro note lines", () => {
const lines = buildSlackSetupLines();
expect(lines.join("\n")).not.toContain("Manifest (JSON):");
expect(lines.join("\n")).not.toContain('"display_information"');
expect(lines).toContain("Manifest JSON follows as plain text for copy/paste.");
});
it("prints the manifest as plain JSON when Slack is not configured", async () => {
const plain = vi.fn<NonNullable<WizardPrompter["plain"]>>(async () => {});
const note = vi.fn(async () => {});
await runSetupWizardPrepare({
prepare: slackSetupWizard.prepare,
cfg: { channels: { slack: {} } } as OpenClawConfig,
prompter: createTestWizardPrompter({
plain,
note: note as WizardPrompter["note"],
}),
});
expect(plain).toHaveBeenCalledTimes(1);
expect(note).not.toHaveBeenCalled();
const manifest = plain.mock.calls[0]?.[0];
expect(typeof manifest).toBe("string");
expect(JSON.parse(manifest)).toMatchObject({
display_information: { name: "OpenClaw" },
settings: { socket_mode_enabled: true },
});
});
it("does not print the manifest after Slack credentials are configured", async () => {
const plain = vi.fn<NonNullable<WizardPrompter["plain"]>>(async () => {});
await runSetupWizardPrepare({
prepare: slackSetupWizard.prepare,
cfg: baseCfg,
prompter: createTestWizardPrompter({
plain,
}),
});
expect(plain).not.toHaveBeenCalled();
});
});
describe("slackSetupWizard.dmPolicy", () => {
it("reads the named-account DM policy instead of the channel root", () => {
expect(

View File

@@ -66,6 +66,7 @@ export const WizardStepSchema = Type.Object(
]),
title: Type.Optional(Type.String()),
message: Type.Optional(Type.String()),
format: Type.Optional(Type.Union([Type.Literal("plain")])),
options: Type.Optional(Type.Array(WizardStepOptionSchema)),
initialValue: Type.Optional(Type.Unknown()),
placeholder: Type.Optional(Type.String()),

View File

@@ -11,6 +11,7 @@ type QueuedWizardPrompter = {
intro: AsyncUnknownMock;
outro: AsyncUnknownMock;
note: AsyncUnknownMock;
plain: AsyncUnknownMock;
select: AsyncUnknownMock;
multiselect: AsyncUnknownMock;
text: AsyncUnknownMock;
@@ -34,6 +35,7 @@ export function createTestWizardPrompter(overrides: Partial<WizardPrompter> = {}
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async () => {}),
plain: vi.fn(async () => {}),
select: selectFirstWizardOption as WizardPrompter["select"],
multiselect: vi.fn(async () => []),
text: vi.fn(async () => "") as WizardPrompter["text"],
@@ -55,6 +57,7 @@ export function createQueuedWizardPrompter(params?: {
const intro = vi.fn(async () => undefined);
const outro = vi.fn(async () => undefined);
const note = vi.fn(async () => undefined);
const plain = vi.fn(async () => undefined);
const select = vi.fn(async () => selectValues.shift() ?? "");
const multiselect = vi.fn(async () => [] as string[]);
const text = vi.fn(async () => textValues.shift() ?? "");
@@ -68,6 +71,7 @@ export function createQueuedWizardPrompter(params?: {
intro,
outro,
note,
plain,
select,
multiselect,
text,
@@ -77,6 +81,7 @@ export function createQueuedWizardPrompter(params?: {
intro,
outro,
note,
plain,
select: select as WizardPrompter["select"],
multiselect: multiselect as WizardPrompter["multiselect"],
text: text as WizardPrompter["text"],

View File

@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { tokenizedOptionFilter } from "./clack-prompter.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createClackPrompter, tokenizedOptionFilter } from "./clack-prompter.js";
afterEach(() => {
vi.restoreAllMocks();
});
describe("tokenizedOptionFilter", () => {
it("matches tokens regardless of order", () => {
@@ -33,3 +37,14 @@ describe("tokenizedOptionFilter", () => {
expect(tokenizedOptionFilter("openai gpt-5.4", option)).toBe(true);
});
});
describe("createClackPrompter", () => {
it("prints plain output without note framing", async () => {
const write = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
const prompter = createClackPrompter();
await prompter.plain?.('{"ok":true}');
expect(write).toHaveBeenCalledWith('{"ok":true}\n');
});
});

View File

@@ -63,6 +63,9 @@ export function createClackPrompter(): WizardPrompter {
note: async (message, title) => {
emitNote(message, title);
},
plain: async (message) => {
process.stdout.write(message.endsWith("\n") ? message : `${message}\n`);
},
select: async (params) => {
const options = params.options.map((opt) => {
const base = { value: opt.value, label: opt.label };

View File

@@ -39,6 +39,7 @@ export type WizardPrompter = {
intro: (title: string) => Promise<void>;
outro: (message: string) => Promise<void>;
note: (message: string, title?: string) => Promise<void>;
plain?: (message: string) => Promise<void>;
select: <T>(params: WizardSelectParams<T>) => Promise<T>;
multiselect: <T>(params: WizardMultiSelectParams<T>) => Promise<T[]>;
text: (params: WizardTextParams) => Promise<string>;

View File

@@ -47,6 +47,25 @@ describe("WizardSession", () => {
expect(done.status).toBe("done");
});
test("plain output is a client note with plain format", async () => {
const session = new WizardSession(async (prompter) => {
await prompter.plain?.('{"ok":true}');
});
const first = await session.next();
expect(first.step).toMatchObject({
type: "note",
message: '{"ok":true}',
format: "plain",
});
if (!first.step) {
throw new Error("expected plain note");
}
await session.answer(first.step.id, null);
const done = await session.next();
expect(done.done).toBe(true);
});
test("invalid answers throw", async () => {
const session = noteRunner();
const first = await session.next();

View File

@@ -12,6 +12,7 @@ export type WizardStep = {
type: "note" | "select" | "text" | "confirm" | "multiselect" | "progress" | "action";
title?: string;
message?: string;
format?: "plain";
options?: WizardStepOption[];
initialValue?: unknown;
placeholder?: string;
@@ -69,6 +70,10 @@ class WizardSessionPrompter implements WizardPrompter {
await this.prompt({ type: "note", title, message, executor: "client" });
}
async plain(message: string): Promise<void> {
await this.prompt({ type: "note", message, format: "plain", executor: "client" });
}
async select<T>(params: {
message: string;
options: Array<{ value: T; label: string; hint?: string }>;