2026-01-03 21:27:18 -06:00
|
|
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
2026-03-18 02:07:26 +00:00
|
|
|
import { resolveSlackAccount } from "./accounts.js";
|
2026-01-03 21:27:18 -06:00
|
|
|
import {
|
|
|
|
|
deleteSlackMessage,
|
2026-03-01 18:45:05 +01:00
|
|
|
downloadSlackFile,
|
2026-01-03 21:27:18 -06:00
|
|
|
editSlackMessage,
|
|
|
|
|
getSlackMemberInfo,
|
|
|
|
|
listSlackEmojis,
|
|
|
|
|
listSlackPins,
|
|
|
|
|
listSlackReactions,
|
|
|
|
|
pinSlackMessage,
|
|
|
|
|
reactSlackMessage,
|
|
|
|
|
readSlackMessages,
|
2026-01-07 04:10:13 +01:00
|
|
|
removeOwnSlackReactions,
|
|
|
|
|
removeSlackReaction,
|
2026-01-03 21:27:18 -06:00
|
|
|
sendSlackMessage,
|
|
|
|
|
unpinSlackMessage,
|
2026-03-18 02:07:26 +00:00
|
|
|
} from "./actions.js";
|
|
|
|
|
import { parseSlackBlocksInput } from "./blocks-input.js";
|
2026-03-17 21:32:00 -07:00
|
|
|
import {
|
|
|
|
|
createActionGate,
|
|
|
|
|
imageResultFromFile,
|
|
|
|
|
jsonResult,
|
|
|
|
|
readNumberParam,
|
|
|
|
|
readReactionParams,
|
|
|
|
|
readStringParam,
|
|
|
|
|
type OpenClawConfig,
|
|
|
|
|
withNormalizedTimestamp,
|
|
|
|
|
} from "./runtime-api.js";
|
2026-03-18 02:07:26 +00:00
|
|
|
import { recordSlackThreadParticipation } from "./sent-thread-cache.js";
|
|
|
|
|
import { parseSlackTarget, resolveSlackChannelId } from "./targets.js";
|
2026-01-03 21:27:18 -06:00
|
|
|
|
2026-03-01 18:45:05 +01:00
|
|
|
const messagingActions = new Set([
|
|
|
|
|
"sendMessage",
|
|
|
|
|
"editMessage",
|
|
|
|
|
"deleteMessage",
|
|
|
|
|
"readMessages",
|
|
|
|
|
"downloadFile",
|
|
|
|
|
]);
|
2026-01-03 21:27:18 -06:00
|
|
|
|
|
|
|
|
const reactionsActions = new Set(["react", "reactions"]);
|
|
|
|
|
const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]);
|
|
|
|
|
|
2026-03-18 02:07:26 +00:00
|
|
|
export const slackActionRuntime = {
|
|
|
|
|
deleteSlackMessage,
|
|
|
|
|
downloadSlackFile,
|
|
|
|
|
editSlackMessage,
|
|
|
|
|
getSlackMemberInfo,
|
|
|
|
|
listSlackEmojis,
|
|
|
|
|
listSlackPins,
|
|
|
|
|
listSlackReactions,
|
|
|
|
|
parseSlackBlocksInput,
|
|
|
|
|
pinSlackMessage,
|
|
|
|
|
reactSlackMessage,
|
|
|
|
|
readSlackMessages,
|
|
|
|
|
recordSlackThreadParticipation,
|
|
|
|
|
removeOwnSlackReactions,
|
|
|
|
|
removeSlackReaction,
|
|
|
|
|
sendSlackMessage,
|
|
|
|
|
unpinSlackMessage,
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-08 16:04:52 -08:00
|
|
|
export type SlackActionContext = {
|
|
|
|
|
/** Current channel ID for auto-threading. */
|
|
|
|
|
currentChannelId?: string;
|
|
|
|
|
/** Current thread timestamp for auto-threading. */
|
|
|
|
|
currentThreadTs?: string;
|
|
|
|
|
/** Reply-to mode for auto-threading. */
|
|
|
|
|
replyToMode?: "off" | "first" | "all";
|
|
|
|
|
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
|
|
|
|
hasRepliedRef?: { value: boolean };
|
2026-03-06 09:52:49 +11:00
|
|
|
/** Allowed local media directories for file uploads. */
|
|
|
|
|
mediaLocalRoots?: readonly string[];
|
2026-01-08 16:04:52 -08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Resolve threadTs for a Slack message based on context and replyToMode.
|
|
|
|
|
* - "all": always inject threadTs
|
|
|
|
|
* - "first": inject only for first message (updates hasRepliedRef)
|
|
|
|
|
* - "off": never auto-inject
|
|
|
|
|
*/
|
|
|
|
|
function resolveThreadTsFromContext(
|
|
|
|
|
explicitThreadTs: string | undefined,
|
|
|
|
|
targetChannel: string,
|
|
|
|
|
context: SlackActionContext | undefined,
|
|
|
|
|
): string | undefined {
|
|
|
|
|
// Agent explicitly provided threadTs - use it
|
2026-01-31 16:19:20 +09:00
|
|
|
if (explicitThreadTs) {
|
|
|
|
|
return explicitThreadTs;
|
|
|
|
|
}
|
2026-01-08 16:04:52 -08:00
|
|
|
// No context or missing required fields
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!context?.currentThreadTs || !context?.currentChannelId) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
2026-01-08 16:04:52 -08:00
|
|
|
|
2026-03-01 12:42:12 -04:00
|
|
|
const parsedTarget = parseSlackTarget(targetChannel, {
|
|
|
|
|
defaultKind: "channel",
|
|
|
|
|
});
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!parsedTarget || parsedTarget.kind !== "channel") {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
2026-01-18 00:15:02 +00:00
|
|
|
const normalizedTarget = parsedTarget.id;
|
2026-01-08 16:04:52 -08:00
|
|
|
|
|
|
|
|
// Different channel - don't inject
|
2026-01-31 16:19:20 +09:00
|
|
|
if (normalizedTarget !== context.currentChannelId) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
2026-01-08 16:04:52 -08:00
|
|
|
|
|
|
|
|
// Check replyToMode
|
|
|
|
|
if (context.replyToMode === "all") {
|
|
|
|
|
return context.currentThreadTs;
|
|
|
|
|
}
|
2026-01-14 14:31:43 +00:00
|
|
|
if (context.replyToMode === "first" && context.hasRepliedRef && !context.hasRepliedRef.value) {
|
2026-01-08 16:04:52 -08:00
|
|
|
context.hasRepliedRef.value = true;
|
|
|
|
|
return context.currentThreadTs;
|
|
|
|
|
}
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-16 12:04:18 -05:00
|
|
|
function readSlackBlocksParam(params: Record<string, unknown>) {
|
2026-03-18 02:07:26 +00:00
|
|
|
return slackActionRuntime.parseSlackBlocksInput(params.blocks);
|
2026-02-16 12:04:18 -05:00
|
|
|
}
|
|
|
|
|
|
2026-01-03 21:27:18 -06:00
|
|
|
export async function handleSlackAction(
|
|
|
|
|
params: Record<string, unknown>,
|
2026-01-30 03:15:10 +01:00
|
|
|
cfg: OpenClawConfig,
|
2026-01-08 16:04:52 -08:00
|
|
|
context?: SlackActionContext,
|
2026-01-03 21:27:18 -06:00
|
|
|
): Promise<AgentToolResult<unknown>> {
|
2026-01-18 00:15:02 +00:00
|
|
|
const resolveChannelId = () =>
|
|
|
|
|
resolveSlackChannelId(
|
|
|
|
|
readStringParam(params, "channelId", {
|
|
|
|
|
required: true,
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-01-03 21:27:18 -06:00
|
|
|
const action = readStringParam(params, "action", { required: true });
|
2026-01-08 08:49:16 +01:00
|
|
|
const accountId = readStringParam(params, "accountId");
|
|
|
|
|
const account = resolveSlackAccount({ cfg, accountId });
|
2026-01-13 06:16:43 +00:00
|
|
|
const actionConfig = account.actions ?? cfg.channels?.slack?.actions;
|
2026-01-08 08:49:16 +01:00
|
|
|
const isActionEnabled = createActionGate(actionConfig);
|
2026-03-01 13:05:35 -04:00
|
|
|
const userToken = account.userToken;
|
feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes
User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.
Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 16:11:33 -08:00
|
|
|
const botToken = account.botToken?.trim();
|
|
|
|
|
const allowUserWrites = account.config.userTokenReadOnly === false;
|
|
|
|
|
|
|
|
|
|
// Choose the most appropriate token for Slack read/write operations.
|
|
|
|
|
const getTokenForOperation = (operation: "read" | "write") => {
|
2026-01-31 16:19:20 +09:00
|
|
|
if (operation === "read") {
|
|
|
|
|
return userToken ?? botToken;
|
|
|
|
|
}
|
|
|
|
|
if (!allowUserWrites) {
|
|
|
|
|
return botToken;
|
|
|
|
|
}
|
feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes
User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.
Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 16:11:33 -08:00
|
|
|
return botToken ?? userToken;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const buildActionOpts = (operation: "read" | "write") => {
|
|
|
|
|
const token = getTokenForOperation(operation);
|
|
|
|
|
const tokenOverride = token && token !== botToken ? token : undefined;
|
2026-01-31 16:19:20 +09:00
|
|
|
if (!accountId && !tokenOverride) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes
User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.
Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 16:11:33 -08:00
|
|
|
return {
|
|
|
|
|
...(accountId ? { accountId } : {}),
|
|
|
|
|
...(tokenOverride ? { token: tokenOverride } : {}),
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const readOpts = buildActionOpts("read");
|
|
|
|
|
const writeOpts = buildActionOpts("write");
|
2026-01-03 21:27:18 -06:00
|
|
|
|
|
|
|
|
if (reactionsActions.has(action)) {
|
|
|
|
|
if (!isActionEnabled("reactions")) {
|
|
|
|
|
throw new Error("Slack reactions are disabled.");
|
|
|
|
|
}
|
2026-01-18 00:15:02 +00:00
|
|
|
const channelId = resolveChannelId();
|
2026-01-03 21:27:18 -06:00
|
|
|
const messageId = readStringParam(params, "messageId", { required: true });
|
|
|
|
|
if (action === "react") {
|
2026-01-07 04:10:13 +01:00
|
|
|
const { emoji, remove, isEmpty } = readReactionParams(params, {
|
|
|
|
|
removeErrorMessage: "Emoji is required to remove a Slack reaction.",
|
|
|
|
|
});
|
|
|
|
|
if (remove) {
|
feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes
User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.
Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 16:11:33 -08:00
|
|
|
if (writeOpts) {
|
2026-03-18 02:07:26 +00:00
|
|
|
await slackActionRuntime.removeSlackReaction(channelId, messageId, emoji, writeOpts);
|
2026-01-08 08:49:16 +01:00
|
|
|
} else {
|
2026-03-18 02:07:26 +00:00
|
|
|
await slackActionRuntime.removeSlackReaction(channelId, messageId, emoji);
|
2026-01-08 08:49:16 +01:00
|
|
|
}
|
2026-01-07 04:10:13 +01:00
|
|
|
return jsonResult({ ok: true, removed: emoji });
|
|
|
|
|
}
|
|
|
|
|
if (isEmpty) {
|
feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes
User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.
Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 16:11:33 -08:00
|
|
|
const removed = writeOpts
|
2026-03-18 02:07:26 +00:00
|
|
|
? await slackActionRuntime.removeOwnSlackReactions(channelId, messageId, writeOpts)
|
|
|
|
|
: await slackActionRuntime.removeOwnSlackReactions(channelId, messageId);
|
2026-01-07 04:10:13 +01:00
|
|
|
return jsonResult({ ok: true, removed });
|
|
|
|
|
}
|
feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes
User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.
Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 16:11:33 -08:00
|
|
|
if (writeOpts) {
|
2026-03-18 02:07:26 +00:00
|
|
|
await slackActionRuntime.reactSlackMessage(channelId, messageId, emoji, writeOpts);
|
2026-01-08 08:49:16 +01:00
|
|
|
} else {
|
2026-03-18 02:07:26 +00:00
|
|
|
await slackActionRuntime.reactSlackMessage(channelId, messageId, emoji);
|
2026-01-08 08:49:16 +01:00
|
|
|
}
|
2026-01-07 04:10:13 +01:00
|
|
|
return jsonResult({ ok: true, added: emoji });
|
2026-01-03 21:27:18 -06:00
|
|
|
}
|
feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes
User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.
Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 16:11:33 -08:00
|
|
|
const reactions = readOpts
|
2026-03-18 02:07:26 +00:00
|
|
|
? await slackActionRuntime.listSlackReactions(channelId, messageId, readOpts)
|
|
|
|
|
: await slackActionRuntime.listSlackReactions(channelId, messageId);
|
2026-01-03 21:27:18 -06:00
|
|
|
return jsonResult({ ok: true, reactions });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (messagingActions.has(action)) {
|
|
|
|
|
if (!isActionEnabled("messages")) {
|
|
|
|
|
throw new Error("Slack messages are disabled.");
|
|
|
|
|
}
|
|
|
|
|
switch (action) {
|
|
|
|
|
case "sendMessage": {
|
|
|
|
|
const to = readStringParam(params, "to", { required: true });
|
2026-03-01 12:42:12 -04:00
|
|
|
const content = readStringParam(params, "content", {
|
|
|
|
|
allowEmpty: true,
|
|
|
|
|
});
|
2026-01-03 21:27:18 -06:00
|
|
|
const mediaUrl = readStringParam(params, "mediaUrl");
|
2026-02-16 12:04:18 -05:00
|
|
|
const blocks = readSlackBlocksParam(params);
|
|
|
|
|
if (!content && !mediaUrl && !blocks) {
|
|
|
|
|
throw new Error("Slack sendMessage requires content, blocks, or mediaUrl.");
|
|
|
|
|
}
|
2026-02-16 12:33:43 -05:00
|
|
|
if (mediaUrl && blocks) {
|
|
|
|
|
throw new Error("Slack sendMessage does not support blocks with mediaUrl.");
|
|
|
|
|
}
|
2026-01-08 16:04:52 -08:00
|
|
|
const threadTs = resolveThreadTsFromContext(
|
|
|
|
|
readStringParam(params, "threadTs"),
|
|
|
|
|
to,
|
|
|
|
|
context,
|
|
|
|
|
);
|
2026-03-18 02:07:26 +00:00
|
|
|
const result = await slackActionRuntime.sendSlackMessage(to, content ?? "", {
|
feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes
User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.
Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 16:11:33 -08:00
|
|
|
...writeOpts,
|
2026-01-03 21:27:18 -06:00
|
|
|
mediaUrl: mediaUrl ?? undefined,
|
2026-03-06 09:52:49 +11:00
|
|
|
mediaLocalRoots: context?.mediaLocalRoots,
|
2026-01-09 08:27:17 +01:00
|
|
|
threadTs: threadTs ?? undefined,
|
2026-02-16 12:04:18 -05:00
|
|
|
blocks,
|
2026-01-03 21:27:18 -06:00
|
|
|
});
|
2026-01-09 22:09:02 +01:00
|
|
|
|
2026-03-01 12:42:12 -04:00
|
|
|
if (threadTs && result.channelId && account.accountId) {
|
2026-03-18 02:07:26 +00:00
|
|
|
slackActionRuntime.recordSlackThreadParticipation(
|
|
|
|
|
account.accountId,
|
|
|
|
|
result.channelId,
|
|
|
|
|
threadTs,
|
|
|
|
|
);
|
2026-03-01 12:42:12 -04:00
|
|
|
}
|
|
|
|
|
|
2026-01-09 22:09:02 +01:00
|
|
|
// Keep "first" mode consistent even when the agent explicitly provided
|
|
|
|
|
// threadTs: once we send a message to the current channel, consider the
|
|
|
|
|
// first reply "used" so later tool calls don't auto-thread again.
|
|
|
|
|
if (context?.hasRepliedRef && context.currentChannelId) {
|
2026-01-18 00:15:02 +00:00
|
|
|
const parsedTarget = parseSlackTarget(to, { defaultKind: "channel" });
|
|
|
|
|
if (parsedTarget?.kind === "channel" && parsedTarget.id === context.currentChannelId) {
|
2026-01-09 22:09:02 +01:00
|
|
|
context.hasRepliedRef.value = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-03 21:27:18 -06:00
|
|
|
return jsonResult({ ok: true, result });
|
|
|
|
|
}
|
|
|
|
|
case "editMessage": {
|
2026-01-18 00:15:02 +00:00
|
|
|
const channelId = resolveChannelId();
|
2026-01-03 21:27:18 -06:00
|
|
|
const messageId = readStringParam(params, "messageId", {
|
|
|
|
|
required: true,
|
|
|
|
|
});
|
2026-03-01 12:42:12 -04:00
|
|
|
const content = readStringParam(params, "content", {
|
|
|
|
|
allowEmpty: true,
|
|
|
|
|
});
|
2026-02-16 12:08:58 -05:00
|
|
|
const blocks = readSlackBlocksParam(params);
|
|
|
|
|
if (!content && !blocks) {
|
|
|
|
|
throw new Error("Slack editMessage requires content or blocks.");
|
|
|
|
|
}
|
feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes
User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.
Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 16:11:33 -08:00
|
|
|
if (writeOpts) {
|
2026-03-18 02:07:26 +00:00
|
|
|
await slackActionRuntime.editSlackMessage(channelId, messageId, content ?? "", {
|
2026-02-16 12:08:58 -05:00
|
|
|
...writeOpts,
|
|
|
|
|
blocks,
|
|
|
|
|
});
|
2026-01-08 08:49:16 +01:00
|
|
|
} else {
|
2026-03-18 02:07:26 +00:00
|
|
|
await slackActionRuntime.editSlackMessage(channelId, messageId, content ?? "", {
|
2026-03-01 12:42:12 -04:00
|
|
|
blocks,
|
|
|
|
|
});
|
2026-01-08 08:49:16 +01:00
|
|
|
}
|
2026-01-03 21:27:18 -06:00
|
|
|
return jsonResult({ ok: true });
|
|
|
|
|
}
|
|
|
|
|
case "deleteMessage": {
|
2026-01-18 00:15:02 +00:00
|
|
|
const channelId = resolveChannelId();
|
2026-01-03 21:27:18 -06:00
|
|
|
const messageId = readStringParam(params, "messageId", {
|
|
|
|
|
required: true,
|
|
|
|
|
});
|
feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes
User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.
Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 16:11:33 -08:00
|
|
|
if (writeOpts) {
|
2026-03-18 02:07:26 +00:00
|
|
|
await slackActionRuntime.deleteSlackMessage(channelId, messageId, writeOpts);
|
2026-01-08 08:49:16 +01:00
|
|
|
} else {
|
2026-03-18 02:07:26 +00:00
|
|
|
await slackActionRuntime.deleteSlackMessage(channelId, messageId);
|
2026-01-08 08:49:16 +01:00
|
|
|
}
|
2026-01-03 21:27:18 -06:00
|
|
|
return jsonResult({ ok: true });
|
|
|
|
|
}
|
|
|
|
|
case "readMessages": {
|
2026-01-18 00:15:02 +00:00
|
|
|
const channelId = resolveChannelId();
|
2026-01-03 21:27:18 -06:00
|
|
|
const limitRaw = params.limit;
|
|
|
|
|
const limit =
|
2026-01-14 14:31:43 +00:00
|
|
|
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
|
2026-01-03 21:27:18 -06:00
|
|
|
const before = readStringParam(params, "before");
|
|
|
|
|
const after = readStringParam(params, "after");
|
2026-01-23 01:17:45 -03:00
|
|
|
const threadId = readStringParam(params, "threadId");
|
2026-03-18 02:07:26 +00:00
|
|
|
const result = await slackActionRuntime.readSlackMessages(channelId, {
|
feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes
User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.
Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 16:11:33 -08:00
|
|
|
...readOpts,
|
2026-01-03 21:27:18 -06:00
|
|
|
limit,
|
|
|
|
|
before: before ?? undefined,
|
|
|
|
|
after: after ?? undefined,
|
2026-01-23 01:17:45 -03:00
|
|
|
threadId: threadId ?? undefined,
|
2026-01-03 21:27:18 -06:00
|
|
|
});
|
2026-01-15 22:26:31 +00:00
|
|
|
const messages = result.messages.map((message) =>
|
|
|
|
|
withNormalizedTimestamp(
|
|
|
|
|
message as Record<string, unknown>,
|
|
|
|
|
(message as { ts?: unknown }).ts,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
return jsonResult({ ok: true, messages, hasMore: result.hasMore });
|
2026-01-03 21:27:18 -06:00
|
|
|
}
|
2026-03-01 18:45:05 +01:00
|
|
|
case "downloadFile": {
|
|
|
|
|
const fileId = readStringParam(params, "fileId", { required: true });
|
2026-03-02 02:23:12 +00:00
|
|
|
const channelTarget = readStringParam(params, "channelId") ?? readStringParam(params, "to");
|
|
|
|
|
const channelId = channelTarget ? resolveSlackChannelId(channelTarget) : undefined;
|
|
|
|
|
const threadId = readStringParam(params, "threadId") ?? readStringParam(params, "replyTo");
|
2026-03-01 18:45:05 +01:00
|
|
|
const maxBytes = account.config?.mediaMaxMb
|
|
|
|
|
? account.config.mediaMaxMb * 1024 * 1024
|
|
|
|
|
: 20 * 1024 * 1024;
|
2026-03-18 02:07:26 +00:00
|
|
|
const downloaded = await slackActionRuntime.downloadSlackFile(fileId, {
|
2026-03-01 18:45:05 +01:00
|
|
|
...readOpts,
|
|
|
|
|
maxBytes,
|
2026-03-02 02:23:12 +00:00
|
|
|
channelId,
|
|
|
|
|
threadId: threadId ?? undefined,
|
2026-03-01 18:45:05 +01:00
|
|
|
});
|
|
|
|
|
if (!downloaded) {
|
|
|
|
|
return jsonResult({
|
|
|
|
|
ok: false,
|
|
|
|
|
error: "File could not be downloaded (not found, too large, or inaccessible).",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return await imageResultFromFile({
|
|
|
|
|
label: "slack-file",
|
|
|
|
|
path: downloaded.path,
|
|
|
|
|
extraText: downloaded.placeholder,
|
|
|
|
|
details: { fileId, path: downloaded.path },
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-01-03 21:27:18 -06:00
|
|
|
default:
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (pinActions.has(action)) {
|
|
|
|
|
if (!isActionEnabled("pins")) {
|
|
|
|
|
throw new Error("Slack pins are disabled.");
|
|
|
|
|
}
|
2026-01-18 00:15:02 +00:00
|
|
|
const channelId = resolveChannelId();
|
2026-01-03 21:27:18 -06:00
|
|
|
if (action === "pinMessage") {
|
|
|
|
|
const messageId = readStringParam(params, "messageId", {
|
|
|
|
|
required: true,
|
|
|
|
|
});
|
feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes
User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.
Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 16:11:33 -08:00
|
|
|
if (writeOpts) {
|
2026-03-18 02:07:26 +00:00
|
|
|
await slackActionRuntime.pinSlackMessage(channelId, messageId, writeOpts);
|
2026-01-08 08:49:16 +01:00
|
|
|
} else {
|
2026-03-18 02:07:26 +00:00
|
|
|
await slackActionRuntime.pinSlackMessage(channelId, messageId);
|
2026-01-08 08:49:16 +01:00
|
|
|
}
|
2026-01-03 21:27:18 -06:00
|
|
|
return jsonResult({ ok: true });
|
|
|
|
|
}
|
|
|
|
|
if (action === "unpinMessage") {
|
|
|
|
|
const messageId = readStringParam(params, "messageId", {
|
|
|
|
|
required: true,
|
|
|
|
|
});
|
feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes
User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.
Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 16:11:33 -08:00
|
|
|
if (writeOpts) {
|
2026-03-18 02:07:26 +00:00
|
|
|
await slackActionRuntime.unpinSlackMessage(channelId, messageId, writeOpts);
|
2026-01-08 08:49:16 +01:00
|
|
|
} else {
|
2026-03-18 02:07:26 +00:00
|
|
|
await slackActionRuntime.unpinSlackMessage(channelId, messageId);
|
2026-01-08 08:49:16 +01:00
|
|
|
}
|
2026-01-03 21:27:18 -06:00
|
|
|
return jsonResult({ ok: true });
|
|
|
|
|
}
|
feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes
User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.
Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 16:11:33 -08:00
|
|
|
const pins = writeOpts
|
2026-03-18 02:07:26 +00:00
|
|
|
? await slackActionRuntime.listSlackPins(channelId, readOpts)
|
|
|
|
|
: await slackActionRuntime.listSlackPins(channelId);
|
2026-01-15 22:26:31 +00:00
|
|
|
const normalizedPins = pins.map((pin) => {
|
|
|
|
|
const message = pin.message
|
|
|
|
|
? withNormalizedTimestamp(
|
|
|
|
|
pin.message as Record<string, unknown>,
|
|
|
|
|
(pin.message as { ts?: unknown }).ts,
|
|
|
|
|
)
|
|
|
|
|
: pin.message;
|
|
|
|
|
return message ? { ...pin, message } : pin;
|
|
|
|
|
});
|
|
|
|
|
return jsonResult({ ok: true, pins: normalizedPins });
|
2026-01-03 21:27:18 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (action === "memberInfo") {
|
|
|
|
|
if (!isActionEnabled("memberInfo")) {
|
|
|
|
|
throw new Error("Slack member info is disabled.");
|
|
|
|
|
}
|
|
|
|
|
const userId = readStringParam(params, "userId", { required: true });
|
feat(slack): add userToken for read-only access to DMs and private channels (#981)
- Add userToken and userTokenReadOnly (default: true) config fields
- Implement token routing: reads prefer user token, writes use bot token
- Add tests for token routing logic
- Update documentation with required OAuth scopes
User tokens enable reading DMs and private channels without requiring
bot membership. The userTokenReadOnly flag (true by default) ensures
the user token can only be used for reads, preventing accidental
sends as the user.
Required user token scopes:
- channels:history, channels:read
- groups:history, groups:read
- im:history, im:read
- mpim:history, mpim:read
- users:read, reactions:read, pins:read, emoji:read, search:read
2026-01-15 16:11:33 -08:00
|
|
|
const info = writeOpts
|
2026-03-18 02:07:26 +00:00
|
|
|
? await slackActionRuntime.getSlackMemberInfo(userId, readOpts)
|
|
|
|
|
: await slackActionRuntime.getSlackMemberInfo(userId);
|
2026-01-03 21:27:18 -06:00
|
|
|
return jsonResult({ ok: true, info });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (action === "emojiList") {
|
|
|
|
|
if (!isActionEnabled("emojiList")) {
|
|
|
|
|
throw new Error("Slack emoji list is disabled.");
|
|
|
|
|
}
|
2026-03-18 02:07:26 +00:00
|
|
|
const result = readOpts
|
|
|
|
|
? await slackActionRuntime.listSlackEmojis(readOpts)
|
|
|
|
|
: await slackActionRuntime.listSlackEmojis();
|
2026-02-13 14:20:41 -03:00
|
|
|
const limit = readNumberParam(params, "limit", { integer: true });
|
|
|
|
|
if (limit != null && limit > 0 && result.emoji != null) {
|
|
|
|
|
const entries = Object.entries(result.emoji).toSorted(([a], [b]) => a.localeCompare(b));
|
|
|
|
|
if (entries.length > limit) {
|
|
|
|
|
return jsonResult({
|
|
|
|
|
ok: true,
|
2026-03-01 12:42:12 -04:00
|
|
|
emojis: {
|
|
|
|
|
...result,
|
|
|
|
|
emoji: Object.fromEntries(entries.slice(0, limit)),
|
|
|
|
|
},
|
2026-02-13 14:20:41 -03:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return jsonResult({ ok: true, emojis: result });
|
2026-01-03 21:27:18 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw new Error(`Unknown action: ${action}`);
|
|
|
|
|
}
|