fix: align slack thread footer metadata with reply semantics (#14625) (thanks @bennewton999)

This commit is contained in:
Peter Steinberger
2026-02-13 05:16:24 +01:00
parent 2b9d5e6e30
commit a43136c85e
3 changed files with 78 additions and 4 deletions

View File

@@ -67,6 +67,7 @@ Docs: https://docs.openclaw.ai
- BlueBubbles: fix webhook auth bypass via loopback proxy trust. (#13787) Thanks @coygeek.
- Slack: change default replyToMode from "off" to "all". (#14364) Thanks @nm-de.
- Slack: detect control commands when channel messages start with bot mention prefixes (for example, `@Bot /new`). (#14142) Thanks @beefiker.
- Slack: include thread reply metadata in inbound message footer context (`thread_ts`, `parent_user_id`) while keeping top-level `thread_ts == ts` events unthreaded. (#14625) Thanks @bennewton999.
- Signal: enforce E.164 validation for the Signal bot account prompt so mistyped numbers are caught early. (#15063) Thanks @Duartemartins.
- Discord: process DM reactions instead of silently dropping them. (#10418) Thanks @mcaxtr.
- Discord: treat Administrator as full permissions in channel permission checks. Thanks @thewilloftheshadow.

View File

@@ -306,7 +306,7 @@ describe("slack prepareSlackMessage inbound contract", () => {
expect(prepared).toBeTruthy();
// Verify thread metadata is in the message footer
expect(prepared!.ctxPayload.Body).toMatch(
/\[slack message id: 1\.002 channel: D123 thread_ts: 1\.000 parent_user: U2\]/,
/\[slack message id: 1\.002 channel: D123 thread_ts: 1\.000 parent_user_id: U2\]/,
);
});
@@ -380,4 +380,76 @@ describe("slack prepareSlackMessage inbound contract", () => {
expect(prepared!.ctxPayload.Body).toMatch(/\[slack message id: 1\.000 channel: D123\]$/);
expect(prepared!.ctxPayload.Body).not.toContain("thread_ts");
});
it("excludes thread metadata when thread_ts equals ts without parent_user_id", async () => {
const slackCtx = createSlackMonitorContext({
cfg: {
channels: { slack: { enabled: true } },
} as OpenClawConfig,
accountId: "default",
botToken: "token",
app: { client: {} } as App,
runtime: {} as RuntimeEnv,
botUserId: "B1",
teamId: "T1",
apiAppId: "A1",
historyLimit: 0,
sessionScope: "per-sender",
mainKey: "main",
dmEnabled: true,
dmPolicy: "open",
allowFrom: [],
groupDmEnabled: true,
groupDmChannels: [],
defaultRequireMention: true,
groupPolicy: "open",
useAccessGroups: false,
reactionMode: "off",
reactionAllowlist: [],
replyToMode: "off",
threadHistoryScope: "thread",
threadInheritParent: false,
slashCommand: {
enabled: false,
name: "openclaw",
sessionPrefix: "slack:slash",
ephemeral: true,
},
textLimit: 4000,
ackReactionScope: "group-mentions",
mediaMaxBytes: 1024,
removeAckAfterReply: false,
});
// oxlint-disable-next-line typescript/no-explicit-any
slackCtx.resolveUserName = async () => ({ name: "Alice" }) as any;
const account: ResolvedSlackAccount = {
accountId: "default",
enabled: true,
botTokenSource: "config",
appTokenSource: "config",
config: {},
};
const message: SlackMessageEvent = {
channel: "D123",
channel_type: "im",
user: "U1",
text: "top level",
ts: "1.000",
thread_ts: "1.000",
} as SlackMessageEvent;
const prepared = await prepareSlackMessage({
ctx: slackCtx,
account,
message,
opts: { source: "message" },
});
expect(prepared).toBeTruthy();
expect(prepared!.ctxPayload.Body).toMatch(/\[slack message id: 1\.000 channel: D123\]$/);
expect(prepared!.ctxPayload.Body).not.toContain("thread_ts");
expect(prepared!.ctxPayload.Body).not.toContain("parent_user_id");
});
});

View File

@@ -399,9 +399,10 @@ export async function prepareSlackMessage(params: {
GroupSubject: isRoomish ? roomLabel : undefined,
From: slackFrom,
}) ?? (isDirectMessage ? senderName : roomLabel);
const threadInfo = message.thread_ts
? ` thread_ts: ${message.thread_ts}${message.parent_user_id ? ` parent_user: ${message.parent_user_id}` : ""}`
: "";
const threadInfo =
isThreadReply && threadTs
? ` thread_ts: ${threadTs}${message.parent_user_id ? ` parent_user_id: ${message.parent_user_id}` : ""}`
: "";
const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}${threadInfo}]`;
const storePath = resolveStorePath(ctx.cfg.session?.store, {
agentId: route.agentId,