mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
fix(slack): support exact message reads
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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" } }]);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user