fix(slack): support exact message reads

This commit is contained in:
Peter Steinberger
2026-05-02 03:24:01 +01:00
parent 9d4a98e599
commit a22f065043
12 changed files with 173 additions and 16 deletions

View File

@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
- macOS/Voice Wake: send wake-word and Push-to-Talk transcripts through the selected macOS session target instead of always falling back to main WebChat. Fixes #51040. Thanks @carl-jeffrolc.
- 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 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.
- Heartbeat: strip legacy `[TOOL_CALL]...[/TOOL_CALL]` and `[TOOL_RESULT]...[/TOOL_RESULT]` pseudo-call blocks from heartbeat replies before channel delivery. Fixes #54138. Thanks @Deniable9570.
- macOS/Voice Wake: send wake-word and Push-to-Talk transcripts through the selected macOS session target instead of always falling back to main WebChat. Fixes #51040. Thanks @carl-jeffrolc.
- 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.

View File

@@ -101,7 +101,8 @@ Name lookup:
- `read`
- Channels: Discord/Slack/Matrix
- Required: `--target`
- Optional: `--limit`, `--before`, `--after`
- Optional: `--limit`, `--message-id`, `--before`, `--after`
- Slack only: `--message-id` reads a specific Slack message timestamp; combine with `--thread-id` to read an exact thread reply.
- Discord only: `--around`
- `edit`

View File

@@ -689,6 +689,29 @@ describe("handleSlackAction", () => {
);
});
it("passes messageId through to readSlackMessages", async () => {
readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false });
await handleSlackAction(
{
action: "readMessages",
channelId: "C1",
threadId: "1712345678.123456",
messageId: "1712345678.654321",
},
slackConfig(),
);
expect(readSlackMessages).toHaveBeenCalledWith(
"C1",
expect.objectContaining({
cfg: expect.any(Object),
threadId: "1712345678.123456",
messageId: "1712345678.654321",
}),
);
});
it("adds normalized timestamps to pin payloads", async () => {
listSlackPins.mockResolvedValueOnce([{ message: { ts: "1712345678.123456", text: "pin" } }]);

View File

@@ -366,12 +366,14 @@ export async function handleSlackAction(
const before = readStringParam(params, "before");
const after = readStringParam(params, "after");
const threadId = readStringParam(params, "threadId");
const messageId = readStringParam(params, "messageId");
const result = await slackActionRuntime.readSlackMessages(channelId, {
...readOpts,
limit,
before: before ?? undefined,
after: after ?? undefined,
threadId: threadId ?? undefined,
messageId: messageId ?? undefined,
});
const messages = result.messages.map((message) =>
withNormalizedTimestamp(

View File

@@ -41,6 +41,35 @@ describe("readSlackMessages", () => {
expect(result.messages.map((message) => message.ts)).toEqual(["171234.890", "171235.000"]);
});
it("filters a specific thread reply by messageId", async () => {
const client = createClient();
client.conversations.replies.mockResolvedValueOnce({
messages: [{ ts: "171234.567" }, { ts: "171234.890", text: "reply" }],
has_more: true,
});
const result = await readSlackMessages("C1", {
client,
threadId: "171234.567",
messageId: "171234.890",
limit: 20,
token: "xoxb-test",
});
expect(client.conversations.replies).toHaveBeenCalledWith({
channel: "C1",
ts: "171234.567",
limit: 1,
inclusive: true,
latest: "171234.890",
oldest: undefined,
});
expect(result).toEqual({
messages: [{ ts: "171234.890", text: "reply" }],
hasMore: false,
});
});
it("uses conversations.history when threadId is missing", async () => {
const client = createClient();
client.conversations.history.mockResolvedValueOnce({
@@ -63,4 +92,30 @@ describe("readSlackMessages", () => {
expect(client.conversations.replies).not.toHaveBeenCalled();
expect(result.messages.map((message) => message.ts)).toEqual(["1"]);
});
it("filters a specific channel message by messageId", async () => {
const client = createClient();
client.conversations.history.mockResolvedValueOnce({
messages: [{ ts: "171234.890", text: "exact" }, { ts: "171234.891" }],
has_more: true,
});
const result = await readSlackMessages("C1", {
client,
messageId: "171234.890",
token: "xoxb-test",
});
expect(client.conversations.history).toHaveBeenCalledWith({
channel: "C1",
limit: 1,
inclusive: true,
latest: "171234.890",
oldest: undefined,
});
expect(result).toEqual({
messages: [{ ts: "171234.890", text: "exact" }],
hasMore: false,
});
});
});

View File

@@ -257,37 +257,55 @@ export async function readSlackMessages(
before?: string;
after?: string;
threadId?: string;
messageId?: string;
} = {},
): Promise<{ messages: SlackMessageSummary[]; hasMore: boolean }> {
const client = await getClient(opts);
const exactMessageId = opts.messageId?.trim();
const readLimit = exactMessageId ? 1 : opts.limit;
const exactBounds = exactMessageId
? {
inclusive: true,
latest: exactMessageId,
oldest: undefined,
}
: {
latest: opts.before,
oldest: opts.after,
};
// Use conversations.replies for thread messages, conversations.history for channel messages.
if (opts.threadId) {
const result = await client.conversations.replies({
channel: channelId,
ts: opts.threadId,
limit: opts.limit,
latest: opts.before,
oldest: opts.after,
limit: readLimit,
...exactBounds,
});
const messages = ((result.messages ?? []) as SlackMessageSummary[]).filter((message) => {
if (exactMessageId) {
return message.ts === exactMessageId;
}
// conversations.replies includes the parent message; drop it for replies-only reads.
return message.ts !== opts.threadId;
});
return {
// conversations.replies includes the parent message; drop it for replies-only reads.
messages: (result.messages ?? []).filter(
(message) => (message as SlackMessageSummary)?.ts !== opts.threadId,
) as SlackMessageSummary[],
hasMore: Boolean(result.has_more),
messages,
hasMore: exactMessageId ? false : Boolean(result.has_more),
};
}
const result = await client.conversations.history({
channel: channelId,
limit: opts.limit,
latest: opts.before,
oldest: opts.after,
limit: readLimit,
...exactBounds,
});
const messages = ((result.messages ?? []) as SlackMessageSummary[]).filter(
(message) => !exactMessageId || message.ts === exactMessageId,
);
return {
messages: (result.messages ?? []) as SlackMessageSummary[],
hasMore: Boolean(result.has_more),
messages,
hasMore: exactMessageId ? false : Boolean(result.has_more),
};
}

View File

@@ -268,6 +268,7 @@ describe("slackPlugin actions", () => {
params: {
channelId: "C123",
threadId: "1712345678.123456",
messageId: "1712345678.654321",
},
});
@@ -276,6 +277,7 @@ describe("slackPlugin actions", () => {
action: "readMessages",
channelId: "C123",
threadId: "1712345678.123456",
messageId: "1712345678.654321",
}),
{},
undefined,

View File

@@ -194,6 +194,32 @@ describe("handleSlackMessageAction", () => {
);
});
it("forwards messageId for read actions", async () => {
const invoke = createInvokeSpy();
await handleSlackMessageAction({
providerId: "slack",
ctx: {
action: "read",
cfg: {},
params: {
channelId: "C1",
messageId: "1712345678.654321",
},
} as never,
invoke: invoke as never,
});
expect(invoke).toHaveBeenCalledWith(
expect.objectContaining({
action: "readMessages",
channelId: "C1",
messageId: "1712345678.654321",
}),
{},
);
});
it("requires filePath, path, or media for upload-file", async () => {
await expect(
handleSlackMessageAction({

View File

@@ -122,6 +122,7 @@ export async function handleSlackMessageAction(params: {
limit,
before: readStringParam(actionParams, "before"),
after: readStringParam(actionParams, "after"),
messageId: readStringParam(actionParams, "messageId"),
accountId,
};
if (includeReadThreadId) {

View File

@@ -778,6 +778,32 @@ describe("message tool schema scoping", () => {
expect(getActionEnum(properties)).toContain("download-file");
expect(properties.fileId).toMatchObject({ type: "string" });
});
it("advertises messageId for read actions", () => {
const slackReadPlugin = createChannelPlugin({
id: "slack",
label: "Slack",
docsPath: "/channels/slack",
blurb: "Slack test plugin.",
actions: ["read"],
});
setActivePluginRegistry(
createTestRegistry([{ pluginId: "slack", source: "test", plugin: slackReadPlugin }]),
);
const tool = createMessageTool({
config: {} as never,
currentChannelProvider: "slack",
});
const properties = getToolProperties(tool);
expect(getActionEnum(properties)).toContain("read");
expect(properties.messageId).toMatchObject({
type: "string",
description: expect.stringContaining("read"),
});
});
});
describe("message tool description", () => {

View File

@@ -168,14 +168,14 @@ function buildReactionSchema() {
messageId: Type.Optional(
Type.String({
description:
"Target message id for reaction. If omitted, defaults to the current inbound message id when available.",
"Target message id for read, reaction, edit, delete, pin, or unpin. If omitted for reaction-like actions, defaults to the current inbound message id when available.",
}),
),
message_id: Type.Optional(
Type.String({
// Intentional duplicate alias for tool-schema discoverability in LLMs.
description:
"snake_case alias of messageId. If omitted, defaults to the current inbound message id when available.",
"snake_case alias of messageId. If omitted for reaction-like actions, defaults to the current inbound message id when available.",
}),
),
emoji: Type.Optional(Type.String()),

View File

@@ -12,9 +12,11 @@ export function registerMessageReadEditDeleteCommands(
),
)
.option("--limit <n>", "Result limit")
.option("--message-id <id>", "Read a specific message id")
.option("--before <id>", "Read/search before id")
.option("--after <id>", "Read/search after id")
.option("--around <id>", "Read around id")
.option("--thread-id <id>", "Thread id (Slack thread timestamp)")
.option("--include-thread", "Include thread replies (Discord)", false)
.action(async (opts) => {
await helpers.runMessageAction("read", opts);