fix(slack): backfill fresh dm history

This commit is contained in:
Peter Steinberger
2026-05-02 04:11:45 +01:00
parent f11046e0bf
commit 47f76c563f
7 changed files with 266 additions and 1 deletions

View File

@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
- Providers/xAI: give Grok `web_search` a 60s default timeout, harden malformed xAI Responses parsing, and return structured timeout errors instead of aborting the tool call. Fixes #58063 and #58733. Thanks @dnishimura, @marvcasasola-svg, and @Nanako0129.
- Providers/configure: preserve the existing default model when adding or reauthing a provider whose plugin returns a default-model config patch. Fixes #50268. Thanks @rixcorp-oc.
- Slack/message actions: send media before the follow-up Block Kit message when Slack `send` includes a file plus presentation or interactive controls, so file attachments are no longer rejected. Fixes #51458. Thanks @HirokiKobayashi-R.
- Slack/DMs: honor `dmHistoryLimit` for fresh 1:1 Slack DM sessions by backfilling recent conversation history before the current reply. Fixes #64427. Thanks @brantley-creator.
- Slack/mentions: resolve `<!subteam^...>` user-group mentions through Slack `usergroups.users.list` and treat them as explicit mentions only when the bot user is a member, so mention-gated agent channels wake for real user-group mentions without config-only allowlists. Fixes #73827. Thanks @CG-Intelligence-Agent-Jack.
- Slack/message tool: let `read` fetch an exact Slack message timestamp, including a specific thread reply when paired with `threadId`, instead of returning only the parent thread or recent channel history. Fixes #53943. Thanks @zomars.
- Web search: point missing-key errors to `web_fetch` for known URLs and the browser tool for interactive pages. Thanks @zhaoyang97.

View File

@@ -41,6 +41,7 @@ export type SlackMonitorContext = {
apiAppId: string;
historyLimit: number;
dmHistoryLimit: number;
channelHistories: Map<string, HistoryEntry[]>;
sessionScope: SessionScope;
mainKey: string;
@@ -110,6 +111,7 @@ export function createSlackMonitorContext(params: {
apiAppId: string;
historyLimit: number;
dmHistoryLimit?: number;
sessionScope: SessionScope;
mainKey: string;
@@ -406,6 +408,7 @@ export function createSlackMonitorContext(params: {
teamId: params.teamId,
apiAppId: params.apiAppId,
historyLimit: params.historyLimit,
dmHistoryLimit: Math.max(0, params.dmHistoryLimit ?? 0),
channelHistories,
sessionScope: params.sessionScope,
mainKey: params.mainKey,

View File

@@ -0,0 +1,123 @@
import { formatInboundEnvelope } from "openclaw/plugin-sdk/channel-inbound";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { ResolvedSlackAccount } from "../../accounts.js";
import type { SlackMonitorContext } from "../context.js";
type SlackDmHistoryMessage = {
text?: string;
user?: string;
bot_id?: string;
username?: string;
ts?: string;
};
type SlackDmHistoryEntry = {
sender: string;
body: string;
timestamp?: number;
};
export function resolveSlackDmHistoryLimit(params: {
account: ResolvedSlackAccount;
userId?: string;
defaultLimit: number;
}): number {
const override =
params.userId && params.account.config.dms?.[params.userId]?.historyLimit !== undefined
? params.account.config.dms[params.userId]?.historyLimit
: undefined;
return Math.max(0, override ?? params.defaultLimit);
}
export async function resolveSlackDmHistoryContext(params: {
ctx: SlackMonitorContext;
channelId: string;
currentMessageTs?: string;
limit: number;
envelopeOptions: ReturnType<
typeof import("openclaw/plugin-sdk/channel-inbound").resolveEnvelopeFormatOptions
>;
}): Promise<{ body: string | undefined; inboundHistory: SlackDmHistoryEntry[] | undefined }> {
const maxMessages = Math.max(0, Math.floor(params.limit));
if (maxMessages <= 0) {
return { body: undefined, inboundHistory: undefined };
}
try {
const response = (await params.ctx.app.client.conversations.history({
token: params.ctx.botToken,
channel: params.channelId,
...(params.currentMessageTs ? { latest: params.currentMessageTs, inclusive: true } : {}),
limit: maxMessages + 1,
})) as { messages?: SlackDmHistoryMessage[] };
const messages = (response.messages ?? [])
.filter((message) => {
if (params.currentMessageTs && message.ts === params.currentMessageTs) {
return false;
}
return Boolean(normalizeOptionalString(message.text));
})
.slice(0, maxMessages)
.toReversed();
if (messages.length === 0) {
return { body: undefined, inboundHistory: undefined };
}
const userNames = new Map<string, string>();
const resolveUserLabel = async (userId: string): Promise<string> => {
const cached = userNames.get(userId);
if (cached) {
return cached;
}
const resolved = normalizeOptionalString((await params.ctx.resolveUserName(userId)).name);
const label = resolved ?? userId;
userNames.set(userId, label);
return label;
};
const entries: SlackDmHistoryEntry[] = [];
const formatted: string[] = [];
for (const message of messages) {
const body = normalizeOptionalString(message.text);
if (!body) {
continue;
}
const isCurrentBot =
(params.ctx.botUserId && message.user === params.ctx.botUserId) ||
(params.ctx.botId && message.bot_id === params.ctx.botId);
const role = isCurrentBot || message.bot_id ? "assistant" : "user";
const senderBase = isCurrentBot
? "Assistant"
: message.user
? await resolveUserLabel(message.user)
: (normalizeOptionalString(message.username) ?? (message.bot_id ? "Bot" : "Unknown"));
const sender = `${senderBase} (${role})`;
const timestamp = message.ts ? Math.round(Number(message.ts) * 1000) : undefined;
entries.push({ sender, body, timestamp });
formatted.push(
formatInboundEnvelope({
channel: "Slack",
from: sender,
timestamp,
body: `${body}\n[slack message id: ${message.ts ?? "unknown"} channel: ${params.channelId}]`,
chatType: "direct",
envelope: params.envelopeOptions,
}),
);
}
return {
body: formatted.length > 0 ? formatted.join("\n\n") : undefined,
inboundHistory: entries.length > 0 ? entries : undefined,
};
} catch (err) {
logVerbose(
`slack: failed to fetch DM history for channel ${params.channelId}: ${formatErrorMessage(err)}`,
);
return { body: undefined, inboundHistory: undefined };
}
}

View File

@@ -15,6 +15,7 @@ export function createInboundSlackTestContext(params: {
replyToMode?: "off" | "all" | "first" | "batched";
channelsConfig?: SlackChannelConfigEntries;
threadRequireExplicitMention?: boolean;
dmHistoryLimit?: number;
}) {
return createSlackMonitorContext({
cfg: params.cfg,
@@ -27,6 +28,7 @@ export function createInboundSlackTestContext(params: {
teamId: "T1",
apiAppId: "A1",
historyLimit: 0,
dmHistoryLimit: params.dmHistoryLimit,
sessionScope: "per-sender",
mainKey: "main",
dmEnabled: true,

View File

@@ -776,6 +776,118 @@ describe("slack prepareSlackMessage inbound contract", () => {
expect(replies).toHaveBeenCalledTimes(2);
});
it("injects Slack DM history for new top-level DM sessions", async () => {
const { storePath } = storeFixture.makeTmpStorePath();
const history = vi.fn().mockResolvedValue({
messages: [
{ text: "current answer", user: "U1", ts: "300.000" },
{ text: "please choose A or B", bot_id: "B1", ts: "299.000" },
{ text: "earlier user context", user: "U1", ts: "298.000" },
],
});
const slackCtx = createInboundSlackCtx({
cfg: {
session: { store: storePath },
channels: { slack: { enabled: true, dmHistoryLimit: 2 } },
} as OpenClawConfig,
appClient: { conversations: { history } } as unknown as App["client"],
dmHistoryLimit: 2,
});
slackCtx.resolveUserName = async (id: string) => ({ name: id === "U1" ? "Alice" : id });
const prepared = await prepareMessageWith(
slackCtx,
createSlackAccount({ dmHistoryLimit: 2 }),
createSlackMessage({ text: "current answer", ts: "300.000" }),
);
expect(prepared).toBeTruthy();
expect(history).toHaveBeenCalledWith({
token: "token",
channel: "D123",
latest: "300.000",
inclusive: true,
limit: 3,
});
expect(prepared!.ctxPayload.Body).toContain("earlier user context");
expect(prepared!.ctxPayload.Body).toContain("please choose A or B");
expect(
Array.from(
(prepared!.ctxPayload.Body ?? "").matchAll(/\[slack message id: 300\.000 channel: D123\]/g),
),
).toHaveLength(1);
expect(prepared!.ctxPayload.InboundHistory).toEqual([
{
sender: "Alice (user)",
body: "earlier user context",
timestamp: 298000,
},
{
sender: "Assistant (assistant)",
body: "please choose A or B",
timestamp: 299000,
},
]);
});
it("uses per-DM Slack history limits and skips existing DM sessions", async () => {
const { storePath } = storeFixture.makeTmpStorePath();
const cfg = {
session: { store: storePath },
channels: {
slack: {
enabled: true,
dmHistoryLimit: 4,
dms: { U1: { historyLimit: 1 } },
},
},
} as OpenClawConfig;
const history = vi.fn().mockResolvedValue({
messages: [
{ text: "current", user: "U1", ts: "400.000" },
{ text: "only one previous", user: "U1", ts: "399.000" },
],
});
const slackCtx = createInboundSlackCtx({
cfg,
appClient: { conversations: { history } } as unknown as App["client"],
dmHistoryLimit: 4,
});
slackCtx.resolveUserName = async () => ({ name: "Alice" });
const account = createSlackAccount({
dmHistoryLimit: 4,
dms: { U1: { historyLimit: 1 } },
});
const prepared = await prepareMessageWith(
slackCtx,
account,
createSlackMessage({ text: "current", ts: "400.000" }),
);
expect(prepared).toBeTruthy();
expect(history).toHaveBeenCalledWith(
expect.objectContaining({
limit: 2,
}),
);
history.mockClear();
fs.writeFileSync(
storePath,
JSON.stringify({ [prepared!.ctxPayload.SessionKey!]: { updatedAt: Date.now() } }, null, 2),
);
const existing = await prepareMessageWith(
slackCtx,
account,
createSlackMessage({ text: "next", ts: "401.000" }),
);
expect(existing).toBeTruthy();
expect(history).not.toHaveBeenCalled();
expect(existing!.ctxPayload.InboundHistory).toBeUndefined();
});
it("uses room users allowlist for thread context filtering", async () => {
const { prepared, replies } = await prepareThreadContextAllowlistCase({
channel: "C123",
@@ -1632,6 +1744,7 @@ describe("prepareSlackMessage sender prefix", () => {
teamId: "T1",
apiAppId: "A1",
historyLimit: 0,
dmHistoryLimit: 0,
channelHistories: new Map(),
sessionScope: "per-sender",
mainKey: "agent:main:main",

View File

@@ -60,6 +60,7 @@ import { resolveSlackRoomContextHints } from "../room-context.js";
import { sendMessageSlack } from "../send.runtime.js";
import { resolveSlackThreadStarter } from "../thread.js";
import { resolveSlackMessageContent } from "./prepare-content.js";
import { resolveSlackDmHistoryContext, resolveSlackDmHistoryLimit } from "./prepare-dm-history.js";
import { resolveSlackRoutingContext } from "./prepare-routing.js";
import { resolveSlackThreadContextData } from "./prepare-thread-context.js";
import { isSlackSubteamMentionForBot } from "./subteam-mentions.js";
@@ -640,6 +641,13 @@ export async function prepareSlackMessage(params: {
storePath,
sessionKey,
});
const dmHistoryLimit = isDirectMessage
? resolveSlackDmHistoryLimit({
account,
userId: message.user,
defaultLimit: ctx.dmHistoryLimit,
})
: 0;
const body = formatInboundEnvelope({
channel: "Slack",
from: envelopeFrom,
@@ -652,6 +660,19 @@ export async function prepareSlackMessage(params: {
});
let combinedBody = body;
const dmHistoryContext =
isDirectMessage && !isThreadReply && dmHistoryLimit > 0 && !previousTimestamp
? await resolveSlackDmHistoryContext({
ctx,
channelId: message.channel,
currentMessageTs: message.ts,
limit: dmHistoryLimit,
envelopeOptions,
})
: { body: undefined, inboundHistory: undefined };
if (dmHistoryContext.body) {
combinedBody = `${dmHistoryContext.body}\n\n${combinedBody}`;
}
if (isRoomish && ctx.historyLimit > 0) {
combinedBody = buildPendingHistoryContextFromMap({
historyMap: ctx.channelHistories,
@@ -715,7 +736,7 @@ export async function prepareSlackMessage(params: {
body: entry.body,
timestamp: entry.timestamp,
}))
: undefined;
: dmHistoryContext.inboundHistory;
const commandBody = textForCommandDetection.trim();
const ctxPayload = finalizeInboundContext({

View File

@@ -122,6 +122,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
cfg.messages?.groupChat?.historyLimit ??
DEFAULT_GROUP_HISTORY_LIMIT,
);
const dmHistoryLimit = Math.max(0, account.config.dmHistoryLimit ?? 0);
const sessionCfg = cfg.session;
const sessionScope: SessionScope = sessionCfg?.scope ?? "per-sender";
@@ -266,6 +267,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
teamId,
apiAppId,
historyLimit,
dmHistoryLimit,
sessionScope,
mainKey,
dmEnabled,