diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c185c0a54d..7c1eed1c751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Codex harness: route native `request_user_input` prompts back to the originating chat, preserve queued follow-up answers, and honor newer app-server command approval amendment decisions. +- Slack/groups: classify MPIM group DMs as group chat context and suppress verbose tool/plan progress on Slack non-DM surfaces, so internal "Working…" traces no longer leak into rooms. Fixes #70912. - Agents/replay: stop OpenAI/Codex transcript replay from synthesizing missing tool results while still preserving synthetic repair on Anthropic, Gemini, and Bedrock transport-owned sessions. (#61556) Thanks @VictorJeon and @vincentkoc. - Telegram/media replies: parse remote markdown image syntax into outbound media payloads on the final reply path, so Telegram group chats stop falling back to plain-text image URLs when the model or a tool emits `![...](...)` instead of a `MEDIA:` token. (#66191) Thanks @apezam and @vincentkoc. - Agents/WebChat: surface non-retryable provider failures such as billing, auth, and rate-limit errors from the embedded runner instead of logging `surface_error` and leaving webchat with no rendered error. Fixes #70124. (#70848) Thanks @truffle-dev. diff --git a/extensions/slack/src/monitor/channel-type.ts b/extensions/slack/src/monitor/channel-type.ts index e16b49ec191..98d10e5894a 100644 --- a/extensions/slack/src/monitor/channel-type.ts +++ b/extensions/slack/src/monitor/channel-type.ts @@ -1,6 +1,8 @@ import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime"; import type { SlackMessageEvent } from "../types.js"; +export type SlackChatType = "direct" | "group" | "channel"; + export function inferSlackChannelType( channelId?: string | null, ): SlackMessageEvent["channel_type"] | undefined { @@ -40,3 +42,15 @@ export function normalizeSlackChannelType( } return inferred ?? "channel"; } + +export function resolveSlackChatType( + channelType: SlackMessageEvent["channel_type"], +): SlackChatType { + if (channelType === "im") { + return "direct"; + } + if (channelType === "mpim") { + return "group"; + } + return "channel"; +} diff --git a/extensions/slack/src/monitor/context.ts b/extensions/slack/src/monitor/context.ts index e1f1c901e30..318f58c2a7a 100644 --- a/extensions/slack/src/monitor/context.ts +++ b/extensions/slack/src/monitor/context.ts @@ -25,7 +25,11 @@ import { normalizeSlackChannelType } from "./channel-type.js"; import { resolveSessionKey } from "./config.runtime.js"; import { isSlackChannelAllowedByPolicy } from "./policy.js"; -export { inferSlackChannelType, normalizeSlackChannelType } from "./channel-type.js"; +export { + inferSlackChannelType, + normalizeSlackChannelType, + resolveSlackChatType, +} from "./channel-type.js"; export type SlackMonitorContext = { cfg: OpenClawConfig; diff --git a/extensions/slack/src/monitor/message-handler/prepare.test.ts b/extensions/slack/src/monitor/message-handler/prepare.test.ts index dc8b249d6f2..775d64724e5 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test.ts @@ -405,6 +405,22 @@ describe("slack prepareSlackMessage inbound contract", () => { expect(prepared!.ctxPayload.MessageThreadId).toBe("1.000"); }); + it("classifies MPIM group DMs as group chat context", async () => { + const prepared = await prepareMessageWith( + createReplyToAllSlackCtx(), + createSlackAccount({ replyToMode: "all" }), + createSlackMessage({ + channel: "G123", + channel_type: "mpim", + }), + ); + + expect(prepared).toBeTruthy(); + expect(prepared!.isRoomish).toBe(true); + expect(prepared!.ctxPayload.ChatType).toBe("group"); + expect(prepared!.ctxPayload.From).toBe("slack:group:G123"); + }); + it("respects replyToModeByChatType.direct override for DMs", async () => { const prepared = await prepareMessageWith( createReplyToAllSlackCtx(), diff --git a/extensions/slack/src/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts index efbded936ce..314b7c4a03c 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.ts @@ -47,7 +47,11 @@ import { resolveChannelContextVisibilityMode, resolveStorePath, } from "../config.runtime.js"; -import { normalizeSlackChannelType, type SlackMonitorContext } from "../context.js"; +import { + normalizeSlackChannelType, + resolveSlackChatType, + type SlackMonitorContext, +} from "../context.js"; import { recordInboundSession, resolveConversationLabel } from "../conversation.runtime.js"; import { authorizeSlackDirectMessage } from "../dm-auth.js"; import { resolveSlackThreadStarter } from "../media.js"; @@ -541,6 +545,7 @@ export async function prepareSlackMessage(params: { const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`; const senderName = await resolveSenderName(); + const chatType = resolveSlackChatType(conversation.resolvedChannelType); const preview = rawBody.replace(/\s+/g, " ").slice(0, 160); const inboundLabel = isDirectMessage ? `Slack DM from ${senderName}` @@ -558,7 +563,7 @@ export async function prepareSlackMessage(params: { const envelopeFrom = resolveConversationLabel({ - ChatType: isDirectMessage ? "direct" : "channel", + ChatType: chatType, SenderName: senderName, GroupSubject: isRoomish ? roomLabel : undefined, From: slackFrom, @@ -581,7 +586,7 @@ export async function prepareSlackMessage(params: { from: envelopeFrom, timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined, body: textWithId, - chatType: isDirectMessage ? "direct" : "channel", + chatType, sender: { name: senderName, id: senderId }, previousTimestamp, envelope: envelopeOptions, @@ -665,7 +670,7 @@ export async function prepareSlackMessage(params: { To: slackTo, SessionKey: sessionKey, AccountId: route.accountId, - ChatType: isDirectMessage ? "direct" : "channel", + ChatType: chatType, ConversationLabel: envelopeFrom, GroupSubject: isRoomish ? roomLabel : undefined, GroupSpace: ctx.teamId || undefined, diff --git a/extensions/slack/src/monitor/slash.test.ts b/extensions/slack/src/monitor/slash.test.ts index f57ff470863..b6dda65d3ae 100644 --- a/extensions/slack/src/monitor/slash.test.ts +++ b/extensions/slack/src/monitor/slash.test.ts @@ -1023,6 +1023,22 @@ describe("slack slash commands access groups", () => { expect(dispatchArg?.ctx?.CommandAuthorized).toBe(false); }); + it("classifies MPIM slash commands as group chat context", async () => { + const harness = createPolicyHarness({ + channelId: "G_MPIM", + channelName: "group-dm", + resolveChannelName: async () => ({ name: "group-dm", type: "mpim" }), + }); + await registerAndRunPolicySlash({ harness }); + + expect(dispatchMock).toHaveBeenCalledTimes(1); + const dispatchArg = dispatchMock.mock.calls[0]?.[0] as { + ctx?: { ChatType?: string; From?: string }; + }; + expect(dispatchArg?.ctx?.ChatType).toBe("group"); + expect(dispatchArg?.ctx?.From).toBe("slack:group:G_MPIM"); + }); + it("enforces access-group gating when lookup fails for private channels", async () => { const harness = createPolicyHarness({ allowFrom: [], diff --git a/extensions/slack/src/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts index 2ac0fbb1086..b25b59c562f 100644 --- a/extensions/slack/src/monitor/slash.ts +++ b/extensions/slack/src/monitor/slash.ts @@ -21,7 +21,7 @@ import { resolveSlackEffectiveAllowFrom } from "./auth.js"; import { resolveSlackChannelConfig, type SlackChannelConfigResolved } from "./channel-config.js"; import { buildSlackSlashCommandMatcher, resolveSlackSlashCommandConfig } from "./commands.js"; import type { SlackMonitorContext } from "./context.js"; -import { normalizeSlackChannelType } from "./context.js"; +import { normalizeSlackChannelType, resolveSlackChatType } from "./context.js"; import { authorizeSlackDirectMessage } from "./dm-auth.js"; import { createSlackExternalArgMenuStore, @@ -340,6 +340,7 @@ export async function registerSlackMonitorSlashCommands(params: { const rawChannelType = channelInfo?.type ?? (command.channel_name === "directmessage" ? "im" : undefined); const channelType = normalizeSlackChannelType(rawChannelType, command.channel_id); + const chatType = resolveSlackChatType(channelType); const isDirectMessage = channelType === "im"; const isGroupDm = channelType === "mpim"; const isRoom = channelType === "channel" || channelType === "group"; @@ -574,10 +575,10 @@ export async function registerSlackMonitorSlashCommands(params: { ? `slack:channel:${command.channel_id}` : `slack:group:${command.channel_id}`, To: `slash:${command.user_id}`, - ChatType: isDirectMessage ? "direct" : "channel", + ChatType: chatType, ConversationLabel: resolveConversationLabel({ - ChatType: isDirectMessage ? "direct" : "channel", + ChatType: chatType, SenderName: senderName, GroupSubject: isRoomish ? roomLabel : undefined, From: isDirectMessage diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 95f880e5ccf..1cfd5dadc22 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -1451,6 +1451,47 @@ describe("dispatchReplyFromConfig", () => { expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" }); }); + it("suppresses Slack non-DM verbose progress even when verbose is enabled", async () => { + setNoAbort(); + const cfg = { + ...emptyConfig, + agents: { + defaults: { + verboseDefault: "on", + }, + }, + } satisfies OpenClawConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "slack", + Surface: "slack", + ChatType: "channel", + }); + + const replyResolver = async ( + _ctx: MsgContext, + opts?: GetReplyOptions, + _cfg?: OpenClawConfig, + ) => { + await opts?.onPlanUpdate?.({ + phase: "update", + explanation: "Inspect code, patch it, run tests.", + steps: ["Inspect code", "Patch code", "Run tests"], + }); + await opts?.onPatchSummary?.({ + phase: "end", + title: "apply patch", + summary: "1 added, 2 modified", + }); + return { text: "done" } satisfies ReplyPayload; + }; + + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(dispatcher.sendToolResult).not.toHaveBeenCalled(); + expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" }); + }); + it("suppresses plan and working-status progress when session verbose is off", async () => { setNoAbort(); sessionStoreMocks.currentEntry = { diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index da8d672c9c8..1e10ff36b0b 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -643,8 +643,12 @@ export async function dispatchReplyFromConfig( return { queuedFinal, counts }; } - const shouldSendToolSummaries = ctx.ChatType !== "group" || ctx.IsForum === true; - const shouldSendToolStartStatuses = ctx.ChatType !== "group" || ctx.IsForum === true; + const isSlackNonDirectSurface = + (ctx.Surface === "slack" || ctx.Provider === "slack") && ctx.ChatType !== "direct"; + const shouldSendVerboseProgressMessages = + !isSlackNonDirectSurface && (ctx.ChatType !== "group" || ctx.IsForum === true); + const shouldSendToolSummaries = shouldSendVerboseProgressMessages; + const shouldSendToolStartStatuses = shouldSendVerboseProgressMessages; const sendFinalPayload = async ( payload: ReplyPayload, ): Promise<{ queuedFinal: boolean; routedFinalCount: number }> => { @@ -806,7 +810,7 @@ export async function dispatchReplyFromConfig( explanation?: string; steps?: string[]; }): Promise => { - if (suppressDelivery || !shouldEmitVerboseProgress()) { + if (suppressDelivery || !shouldEmitVerboseProgress() || !shouldSendVerboseProgressMessages) { return; } const replyPayload: ReplyPayload = {