mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
fix(slack): backfill fresh dm history
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user