Files
openclaw/extensions/slack/src/message-tool-api.ts
Gorkem Erdogan e7c0673fc5 fix(slack): react action rejects emoji glyphs; member-info userId affordance unclear (#100375)
* fix(slack): normalize react emoji glyphs and clarify member-info userId param

Slack's reactions.add/remove only accept shortcode names, never a raw
Unicode glyph, but the react action's emoji param had no description
steering models away from passing one, so calls like
emoji="" failed with invalid_name. Consolidates the glyph-to-shortcode
map that already existed privately in the ack-reaction dispatch path into
the shared normalizeSlackEmojiName export in actions.ts, the layer that
owns the actual Slack API calls, so the message-tool react action gets the
same normalization.

Also tightens the generic userId param description so models stop trying
target on member-info, which has no target mode and requires userId
directly.

* fix(slack): preserve emoji reaction semantics

* fix(slack): default reactions to inbound message

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-06 00:15:32 +01:00

130 lines
4.4 KiB
TypeScript

// Slack API module exposes the plugin public contract.
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
ChannelMessageToolDiscovery,
ChannelMessageToolSchemaContribution,
} from "openclaw/plugin-sdk/channel-contract";
import { Type, type TSchema } from "typebox";
import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
import { listSlackMessageActions } from "./message-actions.js";
const SLACK_MESSAGE_ID_ACTIONS = ["react", "reactions", "edit", "delete", "pin", "unpin"] as const;
function createSlackFileActionSchema(): Record<string, TSchema> {
return {
fileId: Type.Optional(
Type.String({
description:
'Slack file id, starting with "F" (for example F0B0LTT8M36). Required for action="download-file". Read it from inbound Slack file metadata at event.files[].id. This is not the Slack message timestamp/messageId.',
}),
),
};
}
function createSlackReactionEmojiSchema(): Record<string, TSchema> {
return {
emoji: Type.Optional(
Type.String({
description:
'Slack emoji shortcode name (for example "white_check_mark" or "+1") or common emoji character (for example "✅"). Colons are optional around shortcodes.',
}),
),
};
}
function createSlackMessageIdActionSchema(): Record<string, TSchema> {
const description =
'Slack message timestamp/message id (for example "1777423717.666499"). Used by react, reactions, edit, delete, pin, and unpin actions. React defaults to the current inbound message when available. Not used by download-file, which requires fileId from event.files[].id.';
return {
messageId: Type.Optional(Type.String({ description })),
message_id: Type.Optional(Type.String({ description: `${description} Alias for messageId.` })),
};
}
function createSlackSendActionSchema(): Record<string, TSchema> {
return {
topLevel: Type.Optional(
Type.Boolean({
description:
'Slack-only opt-out for action="send" from a threaded same-channel context. Set true to post a new parent-channel message instead of inheriting the current Slack thread. `threadId: null` is accepted as the same top-level request.',
}),
),
replyBroadcast: Type.Optional(
Type.Boolean({
description:
'Slack-only opt-in for action="send" thread replies. Set true with threadId or replyTo on text/block sends to also broadcast the reply to the parent channel. Not supported for media or upload-file.',
}),
),
};
}
function createSlackTopLevelActionSchema(): Record<string, TSchema> {
return {
topLevel: Type.Optional(
Type.Boolean({
description:
"Slack-only opt-out from threaded same-channel context. Set true to post at the channel root instead of inheriting the current Slack thread.",
}),
),
};
}
export function describeSlackMessageTool({
cfg,
accountId,
}: Parameters<
NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>
>[0]): ChannelMessageToolDiscovery {
const actions = listSlackMessageActions(cfg, accountId);
const capabilities = new Set<"presentation">();
const schema: ChannelMessageToolSchemaContribution[] = [];
if (actions.includes("send")) {
capabilities.add("presentation");
}
if (isSlackInteractiveRepliesEnabled({ cfg, accountId })) {
capabilities.add("presentation");
}
if (actions.includes("download-file")) {
schema.push({
properties: createSlackFileActionSchema(),
actions: ["download-file"],
});
}
if (actions.includes("send")) {
schema.push({
properties: createSlackSendActionSchema(),
actions: ["send"],
});
}
if (actions.includes("upload-file")) {
schema.push({
properties: createSlackTopLevelActionSchema(),
actions: ["upload-file"],
});
}
if (actions.includes("react")) {
schema.push({
properties: createSlackReactionEmojiSchema(),
actions: ["react", "reactions"],
});
}
const messageIdActions: ChannelMessageActionName[] = [];
for (const action of SLACK_MESSAGE_ID_ACTIONS) {
if (actions.includes(action)) {
messageIdActions.push(action);
}
}
if (messageIdActions.length > 0) {
schema.push({
properties: createSlackMessageIdActionSchema(),
actions: messageIdActions,
});
}
return {
actions,
capabilities: Array.from(capabilities),
schema: schema.length > 0 ? schema : null,
};
}