diff --git a/CHANGELOG.md b/CHANGELOG.md index 4838bd9bafb..a8805696354 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index 28bb255f1ae..31ee8f533d4 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -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", diff --git a/extensions/slack/src/setup-shared.ts b/extensions/slack/src/setup-shared.ts index def0387e580..f685196ebc6 100644 --- a/extensions/slack/src/setup-shared.ts +++ b/extensions/slack/src/setup-shared.ts @@ -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), ]; } diff --git a/extensions/slack/src/setup-surface.test.ts b/extensions/slack/src/setup-surface.test.ts index 4cb1b3ef72c..8027ed59003 100644 --- a/extensions/slack/src/setup-surface.test.ts +++ b/extensions/slack/src/setup-surface.test.ts @@ -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>(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>(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( diff --git a/src/gateway/protocol/schema/wizard.ts b/src/gateway/protocol/schema/wizard.ts index 7d4d8669840..50b72c51f5b 100644 --- a/src/gateway/protocol/schema/wizard.ts +++ b/src/gateway/protocol/schema/wizard.ts @@ -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()), diff --git a/src/test-utils/plugin-setup-wizard.ts b/src/test-utils/plugin-setup-wizard.ts index 044593b6cd6..b4615264ca1 100644 --- a/src/test-utils/plugin-setup-wizard.ts +++ b/src/test-utils/plugin-setup-wizard.ts @@ -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 = {} 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"], diff --git a/src/wizard/clack-prompter.test.ts b/src/wizard/clack-prompter.test.ts index 3db5af30e82..52e1d2a26f1 100644 --- a/src/wizard/clack-prompter.test.ts +++ b/src/wizard/clack-prompter.test.ts @@ -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'); + }); +}); diff --git a/src/wizard/clack-prompter.ts b/src/wizard/clack-prompter.ts index 7ffcf01bf37..d251bb54930 100644 --- a/src/wizard/clack-prompter.ts +++ b/src/wizard/clack-prompter.ts @@ -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 }; diff --git a/src/wizard/prompts.ts b/src/wizard/prompts.ts index 1b620ea545c..4a469a6672b 100644 --- a/src/wizard/prompts.ts +++ b/src/wizard/prompts.ts @@ -39,6 +39,7 @@ export type WizardPrompter = { intro: (title: string) => Promise; outro: (message: string) => Promise; note: (message: string, title?: string) => Promise; + plain?: (message: string) => Promise; select: (params: WizardSelectParams) => Promise; multiselect: (params: WizardMultiSelectParams) => Promise; text: (params: WizardTextParams) => Promise; diff --git a/src/wizard/session.test.ts b/src/wizard/session.test.ts index add272e2f80..9356a8911bd 100644 --- a/src/wizard/session.test.ts +++ b/src/wizard/session.test.ts @@ -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(); diff --git a/src/wizard/session.ts b/src/wizard/session.ts index 7d6ff2a1293..e356918fd0d 100644 --- a/src/wizard/session.ts +++ b/src/wizard/session.ts @@ -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 { + await this.prompt({ type: "note", message, format: "plain", executor: "client" }); + } + async select(params: { message: string; options: Array<{ value: T; label: string; hint?: string }>;