From 47f76c563fdece9e7788b8a3b248e305c1744fc2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 04:11:45 +0100 Subject: [PATCH] fix(slack): backfill fresh dm history --- CHANGELOG.md | 1 + extensions/slack/src/monitor/context.ts | 3 + .../message-handler/prepare-dm-history.ts | 123 ++++++++++++++++++ .../message-handler/prepare.test-helpers.ts | 2 + .../monitor/message-handler/prepare.test.ts | 113 ++++++++++++++++ .../src/monitor/message-handler/prepare.ts | 23 +++- extensions/slack/src/monitor/provider.ts | 2 + 7 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 extensions/slack/src/monitor/message-handler/prepare-dm-history.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3609c1b3288..f9775b7f009 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `` 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. diff --git a/extensions/slack/src/monitor/context.ts b/extensions/slack/src/monitor/context.ts index 3d3e385d5b2..e1961bf8e88 100644 --- a/extensions/slack/src/monitor/context.ts +++ b/extensions/slack/src/monitor/context.ts @@ -41,6 +41,7 @@ export type SlackMonitorContext = { apiAppId: string; historyLimit: number; + dmHistoryLimit: number; channelHistories: Map; 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, diff --git a/extensions/slack/src/monitor/message-handler/prepare-dm-history.ts b/extensions/slack/src/monitor/message-handler/prepare-dm-history.ts new file mode 100644 index 00000000000..7e33e6291ba --- /dev/null +++ b/extensions/slack/src/monitor/message-handler/prepare-dm-history.ts @@ -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(); + const resolveUserLabel = async (userId: string): Promise => { + 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 }; + } +} diff --git a/extensions/slack/src/monitor/message-handler/prepare.test-helpers.ts b/extensions/slack/src/monitor/message-handler/prepare.test-helpers.ts index 816fac3b5e9..67cbad381b7 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test-helpers.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test-helpers.ts @@ -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, diff --git a/extensions/slack/src/monitor/message-handler/prepare.test.ts b/extensions/slack/src/monitor/message-handler/prepare.test.ts index 4e1d0466563..70c507a6b40 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.test.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.test.ts @@ -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", diff --git a/extensions/slack/src/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts index 09cc16ef9b2..4cc23e1af26 100644 --- a/extensions/slack/src/monitor/message-handler/prepare.ts +++ b/extensions/slack/src/monitor/message-handler/prepare.ts @@ -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({ diff --git a/extensions/slack/src/monitor/provider.ts b/extensions/slack/src/monitor/provider.ts index 752862821fe..57ae70cfa44 100644 --- a/extensions/slack/src/monitor/provider.ts +++ b/extensions/slack/src/monitor/provider.ts @@ -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,