fix(slack): suppress verbose progress in rooms

This commit is contained in:
Peter Steinberger
2026-04-24 04:49:02 +01:00
parent c1dfaef0a0
commit 76a4c167f7
9 changed files with 113 additions and 11 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: [],

View File

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

View File

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

View File

@@ -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<void> => {
if (suppressDelivery || !shouldEmitVerboseProgress()) {
if (suppressDelivery || !shouldEmitVerboseProgress() || !shouldSendVerboseProgressMessages) {
return;
}
const replyPayload: ReplyPayload = {