From 0085b2e0a92ef05bd7f20d342fa80c715707fd5a Mon Sep 17 00:00:00 2001 From: Shadow Date: Sat, 3 Jan 2026 23:12:11 -0600 Subject: [PATCH] Slack: refine scopes and onboarding --- CHANGELOG.md | 1 + CONTRIBUTING.md | 2 +- docs/configuration.md | 58 ++ docs/slack.md | 166 ++++ skills/slack/SKILL.md | 144 +++ src/agents/tool-display.json | 18 + src/commands/onboard-providers.ts | 175 ++++ src/config/schema.ts | 4 + src/config/types.ts | 62 ++ src/config/zod-schema.ts | 56 ++ src/slack/actions.ts | 184 ++++ src/slack/index.ts | 15 + src/slack/monitor.ts | 1317 ++++++++++++++++++++++++++ ui/src/ui/controllers/config.ts | 88 ++ ui/src/ui/controllers/connections.ts | 142 +++ ui/src/ui/ui-types.ts | 44 + ui/src/ui/views/connections.ts | 9 + 17 files changed, 2484 insertions(+), 1 deletion(-) create mode 100644 docs/slack.md create mode 100644 skills/slack/SKILL.md create mode 100644 src/slack/actions.ts create mode 100644 src/slack/index.ts create mode 100644 src/slack/monitor.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 18483be3a..390e7b4b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Skills: add blogwatcher skill for RSS/Atom monitoring โ€” thanks @Hyaxia. - Skills: add Notion API skill โ€” thanks @scald. - Discord: emit system events for reaction add/remove with per-guild reaction notifications (off|own|all|allowlist) (#140) โ€” thanks @thewilloftheshadow. +- Slack: add socket-mode connector, tools, and UI/docs updates (#170) โ€” thanks @thewilloftheshadow. - Agent: add optional per-session Docker sandbox for tool execution (`agent.sandbox`) with allow/deny policy and auto-pruning. - Agent: add sandboxed Chromium browser (CDP + optional noVNC observer) for sandboxed sessions. - Nodes: add `location.get` with Always/Precise settings on macOS/iOS/Android plus CLI/tool support. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6febeebbe..ce22ee8b3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ Welcome to the lobster tank! ๐Ÿฆž - **Peter Steinberger** - Benevolent Dictator - GitHub: [@steipete](https://github.com/steipete) ยท X: [@steipete](https://x.com/steipete) -- **Shadow** - Discord subsystem +- **Shadow** - Discord + Slack subsystem - GitHub: [@4shadowed](https://github.com/4shadowed) ยท X: [@4shad0wed](https://x.com/4shad0wed) - **Jos** - Telegram, API, Nix mode diff --git a/docs/configuration.md b/docs/configuration.md index 63bdba416..b0e806a6e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -292,6 +292,64 @@ Reaction notification modes: - `all`: all reactions on all messages. - `allowlist`: reactions from `guilds..users` on all messages (empty list disables). +### `slack` (socket mode) + +Slack runs in Socket Mode and requires both a bot token and app token: + +```json5 +{ + slack: { + enabled: true, + botToken: "xoxb-...", + appToken: "xapp-...", + dm: { + enabled: true, + allowFrom: ["U123", "U456", "*"], + groupEnabled: false, + groupChannels: ["G123"] + }, + channels: { + C123: { allow: true, requireMention: true }, + "#general": { allow: true, requireMention: false } + }, + reactionNotifications: "own", // off | own | all | allowlist + reactionAllowlist: ["U123"], + actions: { + reactions: true, + messages: true, + pins: true, + memberInfo: true, + emojiList: true + }, + slashCommand: { + enabled: true, + name: "clawd", + sessionPrefix: "slack:slash", + ephemeral: true + }, + replyToMode: "off", // off | first | all + textChunkLimit: 4000, + mediaMaxMb: 20 + } +} +``` + +Clawdis starts Slack only when a `slack` config section exists and both tokens are set (unless `slack.enabled` is `false`). Provide `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` env vars if you prefer. Use `user:` (DM) or `channel:` when specifying delivery targets for cron/CLI commands. + +Reaction notification modes: +- `off`: no reaction events. +- `own`: reactions on the bot's own messages (default). +- `all`: all reactions on all messages. +- `allowlist`: reactions from `slack.reactionAllowlist` on all messages (empty list disables). + +Slack action groups (gate `slack` tool actions): +| Action group | Default | Notes | +| --- | --- | --- | +| reactions | enabled | React + list reactions | +| messages | enabled | Read/send/edit/delete | +| pins | enabled | Pin/unpin/list | +| memberInfo | enabled | Member info | +| emojiList | enabled | Custom emoji list | ### `imessage` (imsg CLI) Clawdis spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required. diff --git a/docs/slack.md b/docs/slack.md new file mode 100644 index 000000000..abf7d93cb --- /dev/null +++ b/docs/slack.md @@ -0,0 +1,166 @@ +# Slack (socket mode) + +## Setup +1) Create a Slack app (From scratch) in https://api.slack.com/apps. +2) **Socket Mode** โ†’ toggle on. Then go to **Basic Information** โ†’ **App-Level Tokens** โ†’ **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`). +3) **OAuth & Permissions** โ†’ add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`). +4) **Event Subscriptions** โ†’ enable events and subscribe to: + - `message.*` (includes edits/deletes/thread broadcasts) + - `app_mention` + - `reaction_added`, `reaction_removed` + - `member_joined_channel`, `member_left_channel` + - `channel_rename` + - `pin_added`, `pin_removed` +5) Invite the bot to channels you want it to read. +6) Slash Commands โ†’ create the `/clawd` command (or your preferred name). +7) App Home โ†’ enable the **Messages Tab** so users can DM the bot. + +Use the manifest below so scopes and events stay in sync. + +## Manifest (optional) +Use this Slack app manifest to create the app quickly (adjust the name/command if you want). + +```json +{ + "display_information": { + "name": "Clawdbot", + "description": "Slack connector for Clawdbot" + }, + "features": { + "bot_user": { + "display_name": "Clawdbot", + "always_online": false + }, + "app_home": { + "messages_tab_enabled": true, + "messages_tab_read_only_enabled": false + }, + "slash_commands": [ + { + "command": "/clawd", + "description": "Send a message to Clawdbot", + "should_escape": false + } + ] + }, + "oauth_config": { + "scopes": { + "bot": [ + "chat:write", + "channels:history", + "channels:read", + "groups:history", + "im:history", + "mpim:history", + "users:read", + "app_mentions:read", + "reactions:read", + "reactions:write", + "pins:read", + "pins:write", + "emoji:read", + "commands", + "files:read", + "files:write" + ] + } + }, + "settings": { + "socket_mode_enabled": true, + "event_subscriptions": { + "bot_events": [ + "app_mention", + "message.channels", + "message.groups", + "message.im", + "message.mpim", + "reaction_added", + "reaction_removed", + "member_joined_channel", + "member_left_channel", + "channel_rename", + "pin_added", + "pin_removed" + ] + } + } +} +``` + +## Config +Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens: + +```json +{ + "slack": { + "enabled": true, + "botToken": "xoxb-...", + "appToken": "xapp-...", + "dm": { + "enabled": true, + "allowFrom": ["U123", "U456", "*"], + "groupEnabled": false, + "groupChannels": ["G123"] + }, + "channels": { + "C123": { "allow": true, "requireMention": true }, + "#general": { "allow": true, "requireMention": false } + }, + "reactionNotifications": "own", + "reactionAllowlist": ["U123"], + "actions": { + "reactions": true, + "messages": true, + "pins": true, + "memberInfo": true, + "emojiList": true + }, + "slashCommand": { + "enabled": true, + "name": "clawd", + "sessionPrefix": "slack:slash", + "ephemeral": true + }, + "replyToMode": "off", + "textChunkLimit": 4000, + "mediaMaxMb": 20 + } +} +``` + +Tokens can also be supplied via env vars: +- `SLACK_BOT_TOKEN` +- `SLACK_APP_TOKEN` + +## Sessions + routing +- DMs share the `main` session (like WhatsApp/Telegram). +- Channels map to `slack:channel:` sessions. +- Slash commands use `slack:slash:` sessions. + +## Reply threading +Slack replies can be threaded when reply tags are present and `slack.replyToMode` is enabled. + +```json +{ "slack": { "replyToMode": "first" } } +``` + +## Delivery targets +Use these with cron/CLI sends: +- `user:` for DMs +- `channel:` for channels + +## Tool actions +Slack tool actions can be gated with `slack.actions.*`: + +| Action group | Default | Notes | +| --- | --- | --- | +| reactions | enabled | React + list reactions | +| messages | enabled | Read/send/edit/delete | +| pins | enabled | Pin/unpin/list | +| memberInfo | enabled | Member info | +| emojiList | enabled | Custom emoji list | + +## Notes +- Mention gating is controlled via `slack.channels` (set `requireMention` to `true`). +- Reaction notifications follow `slack.reactionNotifications` (use `reactionAllowlist` with mode `allowlist`). +- Attachments are downloaded to the media store when permitted and under the size limit. diff --git a/skills/slack/SKILL.md b/skills/slack/SKILL.md new file mode 100644 index 000000000..8f27ed886 --- /dev/null +++ b/skills/slack/SKILL.md @@ -0,0 +1,144 @@ +--- +name: slack +description: Use when you need to control Slack from Clawdis via the slack tool, including reacting to messages or pinning/unpinning items in Slack channels or DMs. +--- + +# Slack Actions + +## Overview + +Use `slack` to react, manage pins, send/edit/delete messages, and fetch member info. The tool uses the bot token configured for Clawdis. + +## Inputs to collect + +- `channelId` and `messageId` (Slack message timestamp, e.g. `1712023032.1234`). +- For reactions, an `emoji` (Unicode or `:name:`). +- For message sends, a `to` target (`channel:` or `user:`) and `content`. +- For searches, a `query` (optionally `channelIds` or `channelNames`). + +Message context lines include `slack message id` and `channel` fields you can reuse directly. + +## Actions + +### Action groups + +| Action group | Default | Notes | +| --- | --- | --- | +| reactions | enabled | React + list reactions | +| messages | enabled | Read/send/edit/delete | +| pins | enabled | Pin/unpin/list | +| memberInfo | enabled | Member info | +| emojiList | enabled | Custom emoji list | + +### React to a message + +```json +{ + "action": "react", + "channelId": "C123", + "messageId": "1712023032.1234", + "emoji": "โœ…" +} +``` + +### List reactions + +```json +{ + "action": "reactions", + "channelId": "C123", + "messageId": "1712023032.1234" +} +``` + +### Send a message + +```json +{ + "action": "sendMessage", + "to": "channel:C123", + "content": "Hello from Clawdis" +} +``` + +### Edit a message + +```json +{ + "action": "editMessage", + "channelId": "C123", + "messageId": "1712023032.1234", + "content": "Updated text" +} +``` + +### Delete a message + +```json +{ + "action": "deleteMessage", + "channelId": "C123", + "messageId": "1712023032.1234" +} +``` + +### Read recent messages + +```json +{ + "action": "readMessages", + "channelId": "C123", + "limit": 20 +} +``` + +### Pin a message + +```json +{ + "action": "pinMessage", + "channelId": "C123", + "messageId": "1712023032.1234" +} +``` + +### Unpin a message + +```json +{ + "action": "unpinMessage", + "channelId": "C123", + "messageId": "1712023032.1234" +} +``` + +### List pinned items + +```json +{ + "action": "listPins", + "channelId": "C123" +} +``` + +### Member info + +```json +{ + "action": "memberInfo", + "userId": "U123" +} +``` + +### Emoji list + +```json +{ + "action": "emojiList" +} +``` + +## Ideas to try + +- React with โœ… to mark completed tasks. +- Pin key decisions or weekly status updates. diff --git a/src/agents/tool-display.json b/src/agents/tool-display.json index ec941b431..403de1f2d 100644 --- a/src/agents/tool-display.json +++ b/src/agents/tool-display.json @@ -209,6 +209,24 @@ "kick": { "label": "kick", "detailKeys": ["guildId", "userId"] }, "ban": { "label": "ban", "detailKeys": ["guildId", "userId"] } } + }, + "slack": { + "emoji": "๐Ÿ’ฌ", + "title": "Slack", + "actions": { + "react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] }, + "reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] }, + "sendMessage": { "label": "send", "detailKeys": ["to", "content"] }, + "editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] }, + "deleteMessage": { "label": "delete", "detailKeys": ["channelId", "messageId"] }, + "readMessages": { "label": "read messages", "detailKeys": ["channelId", "limit"] }, + "pinMessage": { "label": "pin", "detailKeys": ["channelId", "messageId"] }, + "unpinMessage": { "label": "unpin", "detailKeys": ["channelId", "messageId"] }, + "listPins": { "label": "list pins", "detailKeys": ["channelId"] }, + "memberInfo": { "label": "member", "detailKeys": ["userId"] }, + "emojiList": { "label": "emoji list" } + } } } } + diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index 2eb3f6de0..9af615beb 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -62,6 +62,90 @@ async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise { ); } +function buildSlackManifest(botName: string) { + const safeName = botName.trim() || "Clawdis"; + const manifest = { + display_information: { + name: safeName, + description: `${safeName} connector for Clawdis`, + }, + features: { + bot_user: { + display_name: safeName, + always_online: false, + }, + slash_commands: [ + { + command: "/clawd", + description: "Send a message to Clawdis", + should_escape: false, + }, + ], + }, + oauth_config: { + scopes: { + bot: [ + "chat:write", + "channels:history", + "channels:read", + "groups:history", + "im:history", + "mpim:history", + "users:read", + "app_mentions:read", + "reactions:read", + "pins:read", + "pins:write", + "emoji:read", + "commands", + "files:read", + "files:write", + ], + }, + }, + settings: { + socket_mode_enabled: true, + event_subscriptions: { + bot_events: [ + "app_mention", + "message.channels", + "message.groups", + "message.im", + "message.mpim", + "reaction_added", + "reaction_removed", + "member_joined_channel", + "member_left_channel", + "channel_rename", + "pin_added", + "pin_removed", + ], + }, + }, + }; + return JSON.stringify(manifest, null, 2); +} + +async function noteSlackTokenHelp( + prompter: WizardPrompter, + botName: string, +): Promise { + const manifest = buildSlackManifest(botName); + await prompter.note( + [ + "1) Slack API โ†’ Create App โ†’ From scratch", + "2) Add Socket Mode + enable it to get the app-level token (xapp-...)", + "3) OAuth & Permissions โ†’ install app to workspace (xoxb- bot token)", + "4) Enable Event Subscriptions (socket) for message events", + "Tip: set SLACK_BOT_TOKEN + SLACK_APP_TOKEN in your env.", + "", + "Manifest (JSON):", + manifest, + ].join("\n"), + "Slack socket mode tokens", + ); +} + function setWhatsAppAllowFrom(cfg: ClawdisConfig, allowFrom?: string[]) { return { ...cfg, @@ -374,6 +458,96 @@ export async function setupProviders( } } + if (selection.includes("slack")) { + let botToken: string | null = null; + let appToken: string | null = null; + const slackBotName = String( + await prompter.text({ + message: "Slack bot display name (used for manifest)", + initialValue: "Clawdis", + }), + ).trim(); + if (!slackConfigured) { + await noteSlackTokenHelp(prompter, slackBotName); + } + if ( + slackBotEnv && + slackAppEnv && + (!cfg.slack?.botToken || !cfg.slack?.appToken) + ) { + const keepEnv = await prompter.confirm({ + message: "SLACK_BOT_TOKEN + SLACK_APP_TOKEN detected. Use env vars?", + initialValue: true, + }); + if (keepEnv) { + next = { + ...next, + slack: { + ...next.slack, + enabled: true, + }, + }; + } else { + botToken = String( + await prompter.text({ + message: "Enter Slack bot token (xoxb-...)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + appToken = String( + await prompter.text({ + message: "Enter Slack app token (xapp-...)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } else if (cfg.slack?.botToken && cfg.slack?.appToken) { + const keep = await prompter.confirm({ + message: "Slack tokens already configured. Keep them?", + initialValue: true, + }); + if (!keep) { + botToken = String( + await prompter.text({ + message: "Enter Slack bot token (xoxb-...)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + appToken = String( + await prompter.text({ + message: "Enter Slack app token (xapp-...)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } else { + botToken = String( + await prompter.text({ + message: "Enter Slack bot token (xoxb-...)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + appToken = String( + await prompter.text({ + message: "Enter Slack app token (xapp-...)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + + if (botToken && appToken) { + next = { + ...next, + slack: { + ...next.slack, + enabled: true, + botToken, + appToken, + }, + }; + } + } + if (selection.includes("signal")) { let resolvedCliPath = signalCliPath; let cliDetected = signalCliDetected; @@ -550,3 +724,4 @@ export async function setupProviders( return next; } + diff --git a/src/config/schema.ts b/src/config/schema.ts index e89803841..4f46ac7a0 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -40,6 +40,7 @@ const GROUP_LABELS: Record = { talk: "Talk", telegram: "Telegram", discord: "Discord", + slack: "Slack", signal: "Signal", imessage: "iMessage", whatsapp: "WhatsApp", @@ -65,6 +66,7 @@ const GROUP_ORDER: Record = { talk: 130, telegram: 140, discord: 150, + slack: 155, signal: 160, imessage: 170, whatsapp: 180, @@ -92,6 +94,8 @@ const FIELD_LABELS: Record = { "talk.apiKey": "Talk API Key", "telegram.botToken": "Telegram Bot Token", "discord.token": "Discord Bot Token", + "slack.botToken": "Slack Bot Token", + "slack.appToken": "Slack App Token", "signal.account": "Signal Account", "imessage.cliPath": "iMessage CLI Path", }; diff --git a/src/config/types.ts b/src/config/types.ts index 37dfc9853..f2f3d6d2d 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -144,6 +144,7 @@ export type HookMappingConfig = { | "whatsapp" | "telegram" | "discord" + | "slack" | "signal" | "imessage"; to?: string; @@ -290,6 +291,64 @@ export type DiscordConfig = { guilds?: Record; }; +export type SlackDmConfig = { + /** If false, ignore all incoming Slack DMs. Default: true. */ + enabled?: boolean; + /** Allowlist for DM senders (ids). */ + allowFrom?: Array; + /** If true, allow group DMs (default: false). */ + groupEnabled?: boolean; + /** Optional allowlist for group DM channels (ids or slugs). */ + groupChannels?: Array; +}; + +export type SlackChannelConfig = { + allow?: boolean; + requireMention?: boolean; +}; + +export type SlackReactionNotificationMode = "off" | "own" | "all" | "allowlist"; + +export type SlackActionConfig = { + reactions?: boolean; + messages?: boolean; + pins?: boolean; + search?: boolean; + permissions?: boolean; + memberInfo?: boolean; + channelInfo?: boolean; + emojiList?: boolean; +}; + +export type SlackSlashCommandConfig = { + /** Enable handling for the configured slash command (default: false). */ + enabled?: boolean; + /** Slash command name (default: "clawd"). */ + name?: string; + /** Session key prefix for slash commands (default: "slack:slash"). */ + sessionPrefix?: string; + /** Reply ephemerally (default: true). */ + ephemeral?: boolean; +}; + +export type SlackConfig = { + /** If false, do not start the Slack provider. Default: true. */ + enabled?: boolean; + botToken?: string; + appToken?: string; + textChunkLimit?: number; + replyToMode?: ReplyToMode; + mediaMaxMb?: number; + /** Reaction notification mode (off|own|all|allowlist). Default: own. */ + reactionNotifications?: SlackReactionNotificationMode; + /** Allowlist for reaction notifications when mode is allowlist. */ + reactionAllowlist?: Array; + actions?: SlackActionConfig; + slashCommand?: SlackSlashCommandConfig; + dm?: SlackDmConfig; + channels?: Record; +}; + export type SignalConfig = { /** If false, do not start the Signal provider. Default: true. */ enabled?: boolean; @@ -356,6 +415,7 @@ export type QueueModeBySurface = { whatsapp?: QueueMode; telegram?: QueueMode; discord?: QueueMode; + slack?: QueueMode; signal?: QueueMode; imessage?: QueueMode; webchat?: QueueMode; @@ -642,6 +702,7 @@ export type ClawdisConfig = { | "whatsapp" | "telegram" | "discord" + | "slack" | "signal" | "imessage" | "none"; @@ -731,6 +792,7 @@ export type ClawdisConfig = { whatsapp?: WhatsAppConfig; telegram?: TelegramConfig; discord?: DiscordConfig; + slack?: SlackConfig; signal?: SignalConfig; imessage?: IMessageConfig; cron?: CronConfig; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index f0f7e6c90..0aa3ddc07 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -86,6 +86,7 @@ const QueueModeBySurfaceSchema = z whatsapp: QueueModeSchema.optional(), telegram: QueueModeSchema.optional(), discord: QueueModeSchema.optional(), + slack: QueueModeSchema.optional(), signal: QueueModeSchema.optional(), imessage: QueueModeSchema.optional(), webchat: QueueModeSchema.optional(), @@ -163,6 +164,7 @@ const HeartbeatSchema = z z.literal("whatsapp"), z.literal("telegram"), z.literal("discord"), + z.literal("slack"), z.literal("signal"), z.literal("imessage"), z.literal("none"), @@ -225,6 +227,7 @@ const HookMappingSchema = z z.literal("whatsapp"), z.literal("telegram"), z.literal("discord"), + z.literal("slack"), z.literal("signal"), z.literal("imessage"), ]) @@ -619,6 +622,59 @@ export const ClawdisSchema = z.object({ .optional(), }) .optional(), + slack: z + .object({ + enabled: z.boolean().optional(), + botToken: z.string().optional(), + appToken: z.string().optional(), + textChunkLimit: z.number().int().positive().optional(), + replyToMode: ReplyToModeSchema.optional(), + mediaMaxMb: z.number().positive().optional(), + reactionNotifications: z + .enum(["off", "own", "all", "allowlist"]) + .optional(), + reactionAllowlist: z.array(z.union([z.string(), z.number()])).optional(), + actions: z + .object({ + reactions: z.boolean().optional(), + messages: z.boolean().optional(), + pins: z.boolean().optional(), + search: z.boolean().optional(), + permissions: z.boolean().optional(), + memberInfo: z.boolean().optional(), + channelInfo: z.boolean().optional(), + emojiList: z.boolean().optional(), + }) + .optional(), + slashCommand: z + .object({ + enabled: z.boolean().optional(), + name: z.string().optional(), + sessionPrefix: z.string().optional(), + ephemeral: z.boolean().optional(), + }) + .optional(), + dm: z + .object({ + enabled: z.boolean().optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupEnabled: z.boolean().optional(), + groupChannels: z.array(z.union([z.string(), z.number()])).optional(), + }) + .optional(), + channels: z + .record( + z.string(), + z + .object({ + allow: z.boolean().optional(), + requireMention: z.boolean().optional(), + }) + .optional(), + ) + .optional(), + }) + .optional(), signal: z .object({ enabled: z.boolean().optional(), diff --git a/src/slack/actions.ts b/src/slack/actions.ts new file mode 100644 index 000000000..8763288b4 --- /dev/null +++ b/src/slack/actions.ts @@ -0,0 +1,184 @@ +import { WebClient } from "@slack/web-api"; + +import { loadConfig } from "../config/config.js"; +import { sendMessageSlack } from "./send.js"; +import { resolveSlackBotToken } from "./token.js"; + +export type SlackActionClientOpts = { + token?: string; + client?: WebClient; +}; + +export type SlackMessageSummary = { + ts?: string; + text?: string; + user?: string; + thread_ts?: string; + reply_count?: number; + reactions?: Array<{ + name?: string; + count?: number; + users?: string[]; + }>; +}; + +export type SlackPin = { + type?: string; + message?: { ts?: string; text?: string }; + file?: { id?: string; name?: string }; +}; + +function resolveToken(explicit?: string) { + const cfgToken = loadConfig().slack?.botToken; + const token = resolveSlackBotToken( + explicit ?? process.env.SLACK_BOT_TOKEN ?? cfgToken ?? undefined, + ); + if (!token) { + throw new Error( + "SLACK_BOT_TOKEN or slack.botToken is required for Slack actions", + ); + } + return token; +} + +function normalizeEmoji(raw: string) { + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error("Emoji is required for Slack reactions"); + } + return trimmed.replace(/^:+|:+$/g, ""); +} + +async function getClient(opts: SlackActionClientOpts = {}) { + const token = resolveToken(opts.token); + return opts.client ?? new WebClient(token); +} + +export async function reactSlackMessage( + channelId: string, + messageId: string, + emoji: string, + opts: SlackActionClientOpts = {}, +) { + const client = await getClient(opts); + await client.reactions.add({ + channel: channelId, + timestamp: messageId, + name: normalizeEmoji(emoji), + }); +} + +export async function listSlackReactions( + channelId: string, + messageId: string, + opts: SlackActionClientOpts = {}, +): Promise { + const client = await getClient(opts); + const result = await client.reactions.get({ + channel: channelId, + timestamp: messageId, + full: true, + }); + const message = result.message as SlackMessageSummary | undefined; + return message?.reactions ?? []; +} + +export async function sendSlackMessage( + to: string, + content: string, + opts: SlackActionClientOpts & { mediaUrl?: string; replyTo?: string } = {}, +) { + return await sendMessageSlack(to, content, { + token: opts.token, + mediaUrl: opts.mediaUrl, + threadTs: opts.replyTo, + client: opts.client, + }); +} + +export async function editSlackMessage( + channelId: string, + messageId: string, + content: string, + opts: SlackActionClientOpts = {}, +) { + const client = await getClient(opts); + await client.chat.update({ + channel: channelId, + ts: messageId, + text: content, + }); +} + +export async function deleteSlackMessage( + channelId: string, + messageId: string, + opts: SlackActionClientOpts = {}, +) { + const client = await getClient(opts); + await client.chat.delete({ + channel: channelId, + ts: messageId, + }); +} + +export async function readSlackMessages( + channelId: string, + opts: SlackActionClientOpts & { + limit?: number; + before?: string; + after?: string; + } = {}, +): Promise<{ messages: SlackMessageSummary[]; hasMore: boolean }> { + const client = await getClient(opts); + const result = await client.conversations.history({ + channel: channelId, + limit: opts.limit, + latest: opts.before, + oldest: opts.after, + }); + return { + messages: (result.messages ?? []) as SlackMessageSummary[], + hasMore: Boolean(result.has_more), + }; +} + +export async function getSlackMemberInfo( + userId: string, + opts: SlackActionClientOpts = {}, +) { + const client = await getClient(opts); + return await client.users.info({ user: userId }); +} + +export async function listSlackEmojis(opts: SlackActionClientOpts = {}) { + const client = await getClient(opts); + return await client.emoji.list(); +} + +export async function pinSlackMessage( + channelId: string, + messageId: string, + opts: SlackActionClientOpts = {}, +) { + const client = await getClient(opts); + await client.pins.add({ channel: channelId, timestamp: messageId }); +} + +export async function unpinSlackMessage( + channelId: string, + messageId: string, + opts: SlackActionClientOpts = {}, +) { + const client = await getClient(opts); + await client.pins.remove({ channel: channelId, timestamp: messageId }); +} + +export async function listSlackPins( + channelId: string, + opts: SlackActionClientOpts = {}, +): Promise { + const client = await getClient(opts); + const result = await client.pins.list({ channel: channelId }); + return (result.items ?? []) as SlackPin[]; +} diff --git a/src/slack/index.ts b/src/slack/index.ts new file mode 100644 index 000000000..af0c35741 --- /dev/null +++ b/src/slack/index.ts @@ -0,0 +1,15 @@ +export { + deleteSlackMessage, + editSlackMessage, + getSlackMemberInfo, + listSlackEmojis, + listSlackPins, + listSlackReactions, + pinSlackMessage, + reactSlackMessage, + readSlackMessages, + sendSlackMessage, + unpinSlackMessage, +} from "./actions.js"; +export { monitorSlackProvider } from "./monitor.js"; +export { sendMessageSlack } from "./send.js"; diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts new file mode 100644 index 000000000..dea86a8b1 --- /dev/null +++ b/src/slack/monitor.ts @@ -0,0 +1,1317 @@ +import bolt from "@slack/bolt"; + +import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js"; +import { formatAgentEnvelope } from "../auto-reply/envelope.js"; +import { getReplyFromConfig } from "../auto-reply/reply.js"; +import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; +import type { ReplyPayload } from "../auto-reply/types.js"; +import type { + ReplyToMode, + SlackReactionNotificationMode, + SlackSlashCommandConfig, +} from "../config/config.js"; +import { loadConfig } from "../config/config.js"; +import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; +import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; +import { enqueueSystemEvent } from "../infra/system-events.js"; +import { getChildLogger } from "../logging.js"; +import { detectMime } from "../media/mime.js"; +import { saveMediaBuffer } from "../media/store.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { sendMessageSlack } from "./send.js"; +import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js"; + +export type MonitorSlackOpts = { + botToken?: string; + appToken?: string; + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; + replyToMode?: ReplyToMode; + mediaMaxMb?: number; + slashCommand?: SlackSlashCommandConfig; +}; + +type SlackFile = { + id?: string; + name?: string; + mimetype?: string; + size?: number; + url_private?: string; + url_private_download?: string; +}; + +type SlackMessageEvent = { + type: "message"; + user?: string; + bot_id?: string; + subtype?: string; + text?: string; + ts?: string; + thread_ts?: string; + channel: string; + channel_type?: "im" | "mpim" | "channel" | "group"; + files?: SlackFile[]; +}; + +type SlackAppMentionEvent = { + type: "app_mention"; + user?: string; + bot_id?: string; + text?: string; + ts?: string; + thread_ts?: string; + channel: string; + channel_type?: "im" | "mpim" | "channel" | "group"; +}; + +type SlackReactionEvent = { + type: "reaction_added" | "reaction_removed"; + user?: string; + reaction?: string; + item?: { + type?: string; + channel?: string; + ts?: string; + }; + item_user?: string; + event_ts?: string; +}; + +type SlackMemberChannelEvent = { + type: "member_joined_channel" | "member_left_channel"; + user?: string; + channel?: string; + channel_type?: SlackMessageEvent["channel_type"]; + event_ts?: string; +}; + +type SlackChannelCreatedEvent = { + type: "channel_created"; + channel?: { id?: string; name?: string }; + event_ts?: string; +}; + +type SlackChannelRenamedEvent = { + type: "channel_rename"; + channel?: { id?: string; name?: string; name_normalized?: string }; + event_ts?: string; +}; + +type SlackPinEvent = { + type: "pin_added" | "pin_removed"; + channel_id?: string; + user?: string; + item?: { type?: string; message?: { ts?: string } }; + event_ts?: string; +}; + +type SlackMessageChangedEvent = { + type: "message"; + subtype: "message_changed"; + channel?: string; + message?: { ts?: string }; + previous_message?: { ts?: string }; + event_ts?: string; +}; + +type SlackMessageDeletedEvent = { + type: "message"; + subtype: "message_deleted"; + channel?: string; + deleted_ts?: string; + event_ts?: string; +}; + +type SlackThreadBroadcastEvent = { + type: "message"; + subtype: "thread_broadcast"; + channel?: string; + message?: { ts?: string }; + event_ts?: string; +}; + +type SlackChannelConfigResolved = { + allowed: boolean; + requireMention: boolean; +}; + +export function resolveSlackReplyTarget(opts: { + replyToMode: ReplyToMode; + replyToId?: string; + hasReplied: boolean; +}): string | undefined { + if (opts.replyToMode === "off") return undefined; + const replyToId = opts.replyToId?.trim(); + if (!replyToId) return undefined; + if (opts.replyToMode === "all") return replyToId; + return opts.hasReplied ? undefined : replyToId; +} + +function normalizeSlackSlug(raw?: string) { + const trimmed = raw?.trim().toLowerCase() ?? ""; + if (!trimmed) return ""; + const dashed = trimmed.replace(/\s+/g, "-"); + const cleaned = dashed.replace(/[^a-z0-9#@._+-]+/g, "-"); + return cleaned.replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, ""); +} + +function normalizeAllowList(list?: Array) { + return (list ?? []).map((entry) => String(entry).trim()).filter(Boolean); +} + +function normalizeAllowListLower(list?: Array) { + return normalizeAllowList(list).map((entry) => entry.toLowerCase()); +} + +function allowListMatches(params: { + allowList: string[]; + id?: string; + name?: string; +}) { + const allowList = params.allowList; + if (allowList.length === 0) return false; + if (allowList.includes("*")) return true; + const id = params.id?.toLowerCase(); + const name = params.name?.toLowerCase(); + const slug = normalizeSlackSlug(name); + const candidates = [ + id, + id ? `slack:${id}` : undefined, + id ? `user:${id}` : undefined, + name, + name ? `slack:${name}` : undefined, + slug, + ].filter(Boolean) as string[]; + return candidates.some((value) => allowList.includes(value)); +} + +function resolveSlackSlashCommandConfig( + raw?: SlackSlashCommandConfig, +): Required { + return { + enabled: raw?.enabled === true, + name: raw?.name?.trim() || "clawd", + sessionPrefix: raw?.sessionPrefix?.trim() || "slack:slash", + ephemeral: raw?.ephemeral !== false, + }; +} + +function shouldEmitSlackReactionNotification(params: { + mode: SlackReactionNotificationMode | undefined; + botId?: string | null; + messageAuthorId?: string | null; + userId: string; + userName?: string | null; + allowlist?: Array | null; +}) { + const { mode, botId, messageAuthorId, userId, userName, allowlist } = params; + const effectiveMode = mode ?? "own"; + if (effectiveMode === "off") return false; + if (effectiveMode === "own") { + if (!botId || !messageAuthorId) return false; + return messageAuthorId === botId; + } + if (effectiveMode === "allowlist") { + if (!Array.isArray(allowlist) || allowlist.length === 0) return false; + const users = normalizeAllowListLower(allowlist); + return allowListMatches({ + allowList: users, + id: userId, + name: userName ?? undefined, + }); + } + return true; +} + +function resolveSlackChannelLabel(params: { + channelId?: string; + channelName?: string; +}) { + const channelName = params.channelName?.trim(); + if (channelName) { + const slug = normalizeSlackSlug(channelName); + return `#${slug || channelName}`; + } + const channelId = params.channelId?.trim(); + return channelId ? `#${channelId}` : "unknown channel"; +} + +function resolveSlackChannelConfig(params: { + channelId: string; + channelName?: string; + channels?: Record; +}): SlackChannelConfigResolved | null { + const { channelId, channelName, channels } = params; + const entries = channels ?? {}; + const keys = Object.keys(entries); + const normalizedName = channelName ? normalizeSlackSlug(channelName) : ""; + const directName = channelName ? channelName.trim() : ""; + const candidates = [ + channelId, + channelName ? `#${directName}` : "", + directName, + normalizedName, + ].filter(Boolean); + + let matched: { allow?: boolean; requireMention?: boolean } | undefined; + for (const candidate of candidates) { + if (candidate && entries[candidate]) { + matched = entries[candidate]; + break; + } + } + const fallback = entries["*"]; + + if (keys.length === 0) { + return { allowed: true, requireMention: true }; + } + if (!matched && !fallback) { + return { allowed: false, requireMention: true }; + } + + const resolved = matched ?? fallback ?? {}; + const allowed = resolved.allow ?? true; + const requireMention = + resolved.requireMention ?? fallback?.requireMention ?? true; + return { allowed, requireMention }; +} + +async function resolveSlackMedia(params: { + files?: SlackFile[]; + token: string; + maxBytes: number; +}): Promise<{ + path: string; + contentType?: string; + placeholder: string; +} | null> { + const files = params.files ?? []; + for (const file of files) { + const url = file.url_private_download ?? file.url_private; + if (!url) continue; + try { + const res = await fetch(url, { + headers: { Authorization: `Bearer ${params.token}` }, + }); + if (!res.ok) continue; + const buffer = Buffer.from(await res.arrayBuffer()); + if (buffer.byteLength > params.maxBytes) continue; + const contentType = await detectMime({ + buffer, + headerMime: res.headers.get("content-type"), + filePath: file.name, + }); + const saved = await saveMediaBuffer( + buffer, + contentType ?? file.mimetype, + "inbound", + params.maxBytes, + ); + return { + path: saved.path, + contentType: saved.contentType, + placeholder: file.name ? `[Slack file: ${file.name}]` : "[Slack file]", + }; + } catch { + // Ignore download failures and fall through to the next file. + } + } + return null; +} + +export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { + const cfg = loadConfig(); + const botToken = resolveSlackBotToken( + opts.botToken ?? + process.env.SLACK_BOT_TOKEN ?? + cfg.slack?.botToken ?? + undefined, + ); + const appToken = resolveSlackAppToken( + opts.appToken ?? + process.env.SLACK_APP_TOKEN ?? + cfg.slack?.appToken ?? + undefined, + ); + if (!botToken || !appToken) { + throw new Error( + "SLACK_BOT_TOKEN and SLACK_APP_TOKEN (or slack.botToken/slack.appToken) are required for Slack socket mode", + ); + } + + const runtime: RuntimeEnv = opts.runtime ?? { + log: console.log, + error: console.error, + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }; + + const dmConfig = cfg.slack?.dm; + const allowFrom = normalizeAllowList(dmConfig?.allowFrom); + const groupDmEnabled = dmConfig?.groupEnabled ?? false; + const groupDmChannels = normalizeAllowList(dmConfig?.groupChannels); + const channelsConfig = cfg.slack?.channels; + const dmEnabled = dmConfig?.enabled ?? true; + const replyToMode = opts.replyToMode ?? cfg.slack?.replyToMode ?? "off"; + const reactionMode = cfg.slack?.reactionNotifications ?? "own"; + const reactionAllowlist = cfg.slack?.reactionAllowlist ?? []; + const slashCommand = resolveSlackSlashCommandConfig( + opts.slashCommand ?? cfg.slack?.slashCommand, + ); + const textLimit = resolveTextChunkLimit(cfg, "slack"); + const mediaMaxBytes = + (opts.mediaMaxMb ?? cfg.slack?.mediaMaxMb ?? 20) * 1024 * 1024; + + const logger = getChildLogger({ module: "slack-auto-reply" }); + const channelCache = new Map< + string, + { name?: string; type?: SlackMessageEvent["channel_type"] } + >(); + const userCache = new Map(); + const seenMessages = new Map(); + + const markMessageSeen = (channelId: string | undefined, ts?: string) => { + if (!channelId || !ts) return false; + const key = `${channelId}:${ts}`; + if (seenMessages.has(key)) return true; + seenMessages.set(key, Date.now()); + if (seenMessages.size > 500) { + const cutoff = Date.now() - 60_000; + for (const [entry, seenAt] of seenMessages) { + if (seenAt < cutoff || seenMessages.size > 450) { + seenMessages.delete(entry); + } else { + break; + } + } + } + return false; + }; + + const { App } = bolt; + const app = new App({ + token: botToken, + appToken, + socketMode: true, + }); + + let botUserId = ""; + try { + const auth = await app.client.auth.test({ token: botToken }); + botUserId = auth.user_id ?? ""; + } catch (err) { + runtime.error?.(danger(`slack auth failed: ${String(err)}`)); + } + + const resolveChannelName = async (channelId: string) => { + const cached = channelCache.get(channelId); + if (cached) return cached; + try { + const info = await app.client.conversations.info({ + token: botToken, + channel: channelId, + }); + const name = + info.channel && "name" in info.channel ? info.channel.name : undefined; + const channel = info.channel ?? undefined; + const type: SlackMessageEvent["channel_type"] | undefined = channel?.is_im + ? "im" + : channel?.is_mpim + ? "mpim" + : channel?.is_channel + ? "channel" + : channel?.is_group + ? "group" + : undefined; + const entry = { name, type }; + channelCache.set(channelId, entry); + return entry; + } catch { + return {}; + } + }; + + const resolveUserName = async (userId: string) => { + const cached = userCache.get(userId); + if (cached) return cached; + try { + const info = await app.client.users.info({ + token: botToken, + user: userId, + }); + const profile = info.user?.profile; + const name = + profile?.display_name || + profile?.real_name || + info.user?.name || + undefined; + const entry = { name }; + userCache.set(userId, entry); + return entry; + } catch { + return {}; + } + }; + + const isChannelAllowed = (params: { + channelId?: string; + channelName?: string; + channelType?: SlackMessageEvent["channel_type"]; + }) => { + const channelType = params.channelType; + const isDirectMessage = channelType === "im"; + const isGroupDm = channelType === "mpim"; + const isRoom = channelType === "channel" || channelType === "group"; + + if (isDirectMessage && !dmEnabled) return false; + if (isGroupDm && !groupDmEnabled) return false; + + if (isGroupDm && groupDmChannels.length > 0) { + const allowList = normalizeAllowListLower(groupDmChannels); + const candidates = [ + params.channelId, + params.channelName ? `#${params.channelName}` : undefined, + params.channelName, + params.channelName ? normalizeSlackSlug(params.channelName) : undefined, + ] + .filter((value): value is string => Boolean(value)) + .map((value) => value.toLowerCase()); + const permitted = + allowList.includes("*") || + candidates.some((candidate) => allowList.includes(candidate)); + if (!permitted) return false; + } + + if (isRoom && params.channelId) { + const channelConfig = resolveSlackChannelConfig({ + channelId: params.channelId, + channelName: params.channelName, + channels: channelsConfig, + }); + if (channelConfig?.allowed === false) return false; + } + + return true; + }; + + const handleSlackMessage = async ( + message: SlackMessageEvent, + opts: { source: "message" | "app_mention"; wasMentioned?: boolean }, + ) => { + if (opts.source === "message" && message.type !== "message") return; + if (message.bot_id) return; + if ( + opts.source === "message" && + message.subtype && + message.subtype !== "file_share" + ) { + return; + } + if (!message.user) return; + if (markMessageSeen(message.channel, message.ts)) return; + + let channelInfo: { + name?: string; + type?: SlackMessageEvent["channel_type"]; + } = {}; + let channelType = message.channel_type; + if (!channelType || channelType !== "im") { + channelInfo = await resolveChannelName(message.channel); + channelType = channelType ?? channelInfo.type; + } + const channelName = channelInfo?.name; + const resolvedChannelType = channelType; + const isDirectMessage = resolvedChannelType === "im"; + const isGroupDm = resolvedChannelType === "mpim"; + const isRoom = + resolvedChannelType === "channel" || resolvedChannelType === "group"; + + if ( + !isChannelAllowed({ + channelId: message.channel, + channelName, + channelType: resolvedChannelType, + }) + ) { + logVerbose("slack: drop message (channel not allowed)"); + return; + } + + if (isDirectMessage && allowFrom.length > 0) { + const permitted = allowListMatches({ + allowList: normalizeAllowListLower(allowFrom), + id: message.user, + }); + if (!permitted) { + logVerbose( + `Blocked unauthorized slack sender ${message.user} (not in allowFrom)`, + ); + return; + } + } + + const channelConfig = isRoom + ? resolveSlackChannelConfig({ + channelId: message.channel, + channelName, + channels: channelsConfig, + }) + : null; + + const wasMentioned = + opts.wasMentioned ?? + (!isDirectMessage && + Boolean(botUserId && message.text?.includes(`<@${botUserId}>`))); + if (isRoom && channelConfig?.requireMention && !wasMentioned) { + logger.info( + { channel: message.channel, reason: "no-mention" }, + "skipping room message", + ); + return; + } + + const media = await resolveSlackMedia({ + files: message.files, + token: botToken, + maxBytes: mediaMaxBytes, + }); + const rawBody = (message.text ?? "").trim() || media?.placeholder || ""; + if (!rawBody) return; + + const sender = await resolveUserName(message.user); + const senderName = sender?.name ?? message.user; + const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`; + + const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}]`; + const body = formatAgentEnvelope({ + surface: "Slack", + from: senderName, + timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined, + body: textWithId, + }); + + const isRoomish = isRoom || isGroupDm; + const ctxPayload = { + Body: body, + From: isDirectMessage + ? `slack:${message.user}` + : isRoom + ? `slack:channel:${message.channel}` + : `slack:group:${message.channel}`, + To: isDirectMessage + ? `user:${message.user}` + : `channel:${message.channel}`, + ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group", + GroupSubject: isRoomish ? roomLabel : undefined, + SenderName: senderName, + Surface: "slack" as const, + MessageSid: message.ts, + ReplyToId: message.thread_ts ?? message.ts, + Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined, + WasMentioned: isRoomish ? wasMentioned : undefined, + MediaPath: media?.path, + MediaType: media?.contentType, + MediaUrl: media?.path, + }; + + const replyTarget = ctxPayload.To ?? undefined; + if (!replyTarget) { + runtime.error?.(danger("slack: missing reply target")); + return; + } + + if (isDirectMessage) { + const sessionCfg = cfg.session; + const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; + const storePath = resolveStorePath(sessionCfg?.store); + await updateLastRoute({ + storePath, + sessionKey: mainKey, + channel: "slack", + to: `user:${message.user}`, + }); + } + + if (shouldLogVerbose()) { + const preview = rawBody.replace(/\s+/g, " ").slice(0, 160); + logVerbose( + `slack inbound: channel=${message.channel} from=${ctxPayload.From} preview="${preview}"`, + ); + } + + let blockSendChain: Promise = Promise.resolve(); + const sendBlockReply = (payload: ReplyPayload) => { + if ( + !payload?.text && + !payload?.mediaUrl && + !(payload?.mediaUrls?.length ?? 0) + ) { + return; + } + blockSendChain = blockSendChain + .then(async () => { + await deliverReplies({ + replies: [payload], + target: replyTarget, + token: botToken, + runtime, + replyToMode, + textLimit, + }); + }) + .catch((err) => { + runtime.error?.(danger(`slack block reply failed: ${String(err)}`)); + }); + }; + + const replyResult = await getReplyFromConfig( + ctxPayload, + { + onBlockReply: sendBlockReply, + }, + cfg, + ); + const replies = replyResult + ? Array.isArray(replyResult) + ? replyResult + : [replyResult] + : []; + await blockSendChain; + if (replies.length === 0) return; + + await deliverReplies({ + replies, + target: replyTarget, + token: botToken, + runtime, + replyToMode, + textLimit, + }); + if (shouldLogVerbose()) { + logVerbose( + `slack: delivered ${replies.length} reply${replies.length === 1 ? "" : "ies"} to ${replyTarget}`, + ); + } + }; + + app.event("message", async ({ event }) => { + try { + const message = event as SlackMessageEvent; + if (message.subtype === "message_changed") { + const changed = event as SlackMessageChangedEvent; + const channelId = changed.channel; + const channelInfo = channelId + ? await resolveChannelName(channelId) + : {}; + const channelType = channelInfo?.type; + if ( + !isChannelAllowed({ + channelId, + channelName: channelInfo?.name, + channelType, + }) + ) { + return; + } + const messageId = changed.message?.ts ?? changed.previous_message?.ts; + const label = resolveSlackChannelLabel({ + channelId, + channelName: channelInfo?.name, + }); + enqueueSystemEvent(`Slack message edited in ${label}.`, { + contextKey: `slack:message:changed:${channelId ?? "unknown"}:${messageId ?? changed.event_ts ?? "unknown"}`, + }); + return; + } + if (message.subtype === "message_deleted") { + const deleted = event as SlackMessageDeletedEvent; + const channelId = deleted.channel; + const channelInfo = channelId + ? await resolveChannelName(channelId) + : {}; + const channelType = channelInfo?.type; + if ( + !isChannelAllowed({ + channelId, + channelName: channelInfo?.name, + channelType, + }) + ) { + return; + } + const label = resolveSlackChannelLabel({ + channelId, + channelName: channelInfo?.name, + }); + enqueueSystemEvent(`Slack message deleted in ${label}.`, { + contextKey: `slack:message:deleted:${channelId ?? "unknown"}:${deleted.deleted_ts ?? deleted.event_ts ?? "unknown"}`, + }); + return; + } + if (message.subtype === "thread_broadcast") { + const thread = event as SlackThreadBroadcastEvent; + const channelId = thread.channel; + const channelInfo = channelId + ? await resolveChannelName(channelId) + : {}; + const channelType = channelInfo?.type; + if ( + !isChannelAllowed({ + channelId, + channelName: channelInfo?.name, + channelType, + }) + ) { + return; + } + const label = resolveSlackChannelLabel({ + channelId, + channelName: channelInfo?.name, + }); + const messageId = thread.message?.ts ?? thread.event_ts; + enqueueSystemEvent(`Slack thread reply broadcast in ${label}.`, { + contextKey: `slack:thread:broadcast:${channelId ?? "unknown"}:${messageId ?? "unknown"}`, + }); + return; + } + await handleSlackMessage(message, { source: "message" }); + } catch (err) { + runtime.error?.(danger(`slack handler failed: ${String(err)}`)); + } + }); + + app.event("app_mention", async ({ event }) => { + try { + const mention = event as SlackAppMentionEvent; + await handleSlackMessage(mention as unknown as SlackMessageEvent, { + source: "app_mention", + wasMentioned: true, + }); + } catch (err) { + runtime.error?.(danger(`slack mention handler failed: ${String(err)}`)); + } + }); + + const handleReactionEvent = async ( + event: SlackReactionEvent, + action: "added" | "removed", + ) => { + try { + const item = event.item; + if (!event.user) return; + if (!item?.channel || !item?.ts) return; + if (item.type && item.type !== "message") return; + if (botUserId && event.user === botUserId) return; + + const channelInfo = await resolveChannelName(item.channel); + const channelType = channelInfo?.type; + const isDirectMessage = channelType === "im"; + const isGroupDm = channelType === "mpim"; + const isRoom = channelType === "channel" || channelType === "group"; + const channelName = channelInfo?.name; + + if (isDirectMessage && !dmEnabled) return; + if (isGroupDm && !groupDmEnabled) return; + if (isGroupDm && groupDmChannels.length > 0) { + const allowList = normalizeAllowListLower(groupDmChannels); + const candidates = [ + item.channel, + channelName ? `#${channelName}` : undefined, + channelName, + channelName ? normalizeSlackSlug(channelName) : undefined, + ] + .filter((value): value is string => Boolean(value)) + .map((value) => value.toLowerCase()); + const permitted = + allowList.includes("*") || + candidates.some((candidate) => allowList.includes(candidate)); + if (!permitted) return; + } + + if (isRoom) { + const channelConfig = resolveSlackChannelConfig({ + channelId: item.channel, + channelName, + channels: channelsConfig, + }); + if (channelConfig?.allowed === false) return; + } + + const actor = await resolveUserName(event.user); + const shouldNotify = shouldEmitSlackReactionNotification({ + mode: reactionMode, + botId: botUserId, + messageAuthorId: event.item_user ?? undefined, + userId: event.user, + userName: actor?.name ?? undefined, + allowlist: reactionAllowlist, + }); + if (!shouldNotify) return; + + const emojiLabel = event.reaction ?? "emoji"; + const actorLabel = actor?.name ?? event.user; + const channelLabel = channelName + ? `#${normalizeSlackSlug(channelName) || channelName}` + : `#${item.channel}`; + const authorInfo = event.item_user + ? await resolveUserName(event.item_user) + : undefined; + const authorLabel = authorInfo?.name ?? event.item_user; + const baseText = `Slack reaction ${action}: :${emojiLabel}: by ${actorLabel} in ${channelLabel} msg ${item.ts}`; + const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText; + enqueueSystemEvent(text, { + contextKey: `slack:reaction:${action}:${item.channel}:${item.ts}:${event.user}:${emojiLabel}`, + }); + } catch (err) { + runtime.error?.(danger(`slack reaction handler failed: ${String(err)}`)); + } + }; + + app.event("reaction_added", async ({ event }) => { + await handleReactionEvent(event as SlackReactionEvent, "added"); + }); + + app.event("reaction_removed", async ({ event }) => { + await handleReactionEvent(event as SlackReactionEvent, "removed"); + }); + + app.event("member_joined_channel", async ({ event }) => { + try { + const payload = event as SlackMemberChannelEvent; + const channelId = payload.channel; + const channelInfo = channelId ? await resolveChannelName(channelId) : {}; + const channelType = payload.channel_type ?? channelInfo?.type; + if ( + !isChannelAllowed({ + channelId, + channelName: channelInfo?.name, + channelType, + }) + ) { + return; + } + const userInfo = payload.user ? await resolveUserName(payload.user) : {}; + const userLabel = userInfo?.name ?? payload.user ?? "someone"; + const label = resolveSlackChannelLabel({ + channelId, + channelName: channelInfo?.name, + }); + enqueueSystemEvent(`Slack: ${userLabel} joined ${label}.`, { + contextKey: `slack:member:joined:${channelId ?? "unknown"}:${payload.user ?? "unknown"}`, + }); + } catch (err) { + runtime.error?.(danger(`slack join handler failed: ${String(err)}`)); + } + }); + + app.event("member_left_channel", async ({ event }) => { + try { + const payload = event as SlackMemberChannelEvent; + const channelId = payload.channel; + const channelInfo = channelId ? await resolveChannelName(channelId) : {}; + const channelType = payload.channel_type ?? channelInfo?.type; + if ( + !isChannelAllowed({ + channelId, + channelName: channelInfo?.name, + channelType, + }) + ) { + return; + } + const userInfo = payload.user ? await resolveUserName(payload.user) : {}; + const userLabel = userInfo?.name ?? payload.user ?? "someone"; + const label = resolveSlackChannelLabel({ + channelId, + channelName: channelInfo?.name, + }); + enqueueSystemEvent(`Slack: ${userLabel} left ${label}.`, { + contextKey: `slack:member:left:${channelId ?? "unknown"}:${payload.user ?? "unknown"}`, + }); + } catch (err) { + runtime.error?.(danger(`slack leave handler failed: ${String(err)}`)); + } + }); + + app.event("channel_created", async ({ event }) => { + try { + const payload = event as SlackChannelCreatedEvent; + const channelId = payload.channel?.id; + const channelName = payload.channel?.name; + if ( + !isChannelAllowed({ + channelId, + channelName, + channelType: "channel", + }) + ) { + return; + } + const label = resolveSlackChannelLabel({ channelId, channelName }); + enqueueSystemEvent(`Slack channel created: ${label}.`, { + contextKey: `slack:channel:created:${channelId ?? channelName ?? "unknown"}`, + }); + } catch (err) { + runtime.error?.( + danger(`slack channel created handler failed: ${String(err)}`), + ); + } + }); + + app.event("channel_rename", async ({ event }) => { + try { + const payload = event as SlackChannelRenamedEvent; + const channelId = payload.channel?.id; + const channelName = + payload.channel?.name_normalized ?? payload.channel?.name; + if ( + !isChannelAllowed({ + channelId, + channelName, + channelType: "channel", + }) + ) { + return; + } + const label = resolveSlackChannelLabel({ channelId, channelName }); + enqueueSystemEvent(`Slack channel renamed: ${label}.`, { + contextKey: `slack:channel:renamed:${channelId ?? channelName ?? "unknown"}`, + }); + } catch (err) { + runtime.error?.( + danger(`slack channel rename handler failed: ${String(err)}`), + ); + } + }); + + app.event("pin_added", async ({ event }) => { + try { + const payload = event as SlackPinEvent; + const channelId = payload.channel_id; + const channelInfo = channelId ? await resolveChannelName(channelId) : {}; + if ( + !isChannelAllowed({ + channelId, + channelName: channelInfo?.name, + channelType: channelInfo?.type, + }) + ) { + return; + } + const label = resolveSlackChannelLabel({ + channelId, + channelName: channelInfo?.name, + }); + const userInfo = payload.user ? await resolveUserName(payload.user) : {}; + const userLabel = userInfo?.name ?? payload.user ?? "someone"; + const itemType = payload.item?.type ?? "item"; + const messageId = payload.item?.message?.ts ?? payload.event_ts; + enqueueSystemEvent( + `Slack: ${userLabel} pinned a ${itemType} in ${label}.`, + { + contextKey: `slack:pin:added:${channelId ?? "unknown"}:${messageId ?? "unknown"}`, + }, + ); + } catch (err) { + runtime.error?.(danger(`slack pin added handler failed: ${String(err)}`)); + } + }); + + app.event("pin_removed", async ({ event }) => { + try { + const payload = event as SlackPinEvent; + const channelId = payload.channel_id; + const channelInfo = channelId ? await resolveChannelName(channelId) : {}; + if ( + !isChannelAllowed({ + channelId, + channelName: channelInfo?.name, + channelType: channelInfo?.type, + }) + ) { + return; + } + const label = resolveSlackChannelLabel({ + channelId, + channelName: channelInfo?.name, + }); + const userInfo = payload.user ? await resolveUserName(payload.user) : {}; + const userLabel = userInfo?.name ?? payload.user ?? "someone"; + const itemType = payload.item?.type ?? "item"; + const messageId = payload.item?.message?.ts ?? payload.event_ts; + enqueueSystemEvent( + `Slack: ${userLabel} unpinned a ${itemType} in ${label}.`, + { + contextKey: `slack:pin:removed:${channelId ?? "unknown"}:${messageId ?? "unknown"}`, + }, + ); + } catch (err) { + runtime.error?.( + danger(`slack pin removed handler failed: ${String(err)}`), + ); + } + }); + + if (slashCommand.enabled) { + app.command(slashCommand.name, async ({ command, ack, respond }) => { + try { + const prompt = command.text?.trim(); + if (!prompt) { + await ack({ + text: "Message required.", + response_type: "ephemeral", + }); + return; + } + await ack(); + + if (botUserId && command.user_id === botUserId) return; + + const channelInfo = await resolveChannelName(command.channel_id); + const channelType = + channelInfo?.type ?? + (command.channel_name === "directmessage" ? "im" : undefined); + const isDirectMessage = channelType === "im"; + const isGroupDm = channelType === "mpim"; + const isRoom = channelType === "channel" || channelType === "group"; + + if (isDirectMessage && !dmEnabled) { + await respond({ + text: "Slack DMs are disabled.", + response_type: "ephemeral", + }); + return; + } + if (isGroupDm && !groupDmEnabled) { + await respond({ + text: "Slack group DMs are disabled.", + response_type: "ephemeral", + }); + return; + } + if (isGroupDm && groupDmChannels.length > 0) { + const allowList = normalizeAllowListLower(groupDmChannels); + const channelName = channelInfo?.name; + const candidates = [ + command.channel_id, + channelName ? `#${channelName}` : undefined, + channelName, + channelName ? normalizeSlackSlug(channelName) : undefined, + ] + .filter((value): value is string => Boolean(value)) + .map((value) => value.toLowerCase()); + const permitted = + allowList.includes("*") || + candidates.some((candidate) => allowList.includes(candidate)); + if (!permitted) { + await respond({ + text: "This group DM is not allowed.", + response_type: "ephemeral", + }); + return; + } + } + + if (isDirectMessage && allowFrom.length > 0) { + const sender = await resolveUserName(command.user_id); + const permitted = allowListMatches({ + allowList: normalizeAllowListLower(allowFrom), + id: command.user_id, + name: sender?.name ?? undefined, + }); + if (!permitted) { + await respond({ + text: "You are not authorized to use this command.", + response_type: "ephemeral", + }); + return; + } + } + + if (isRoom) { + const channelConfig = resolveSlackChannelConfig({ + channelId: command.channel_id, + channelName: channelInfo?.name, + channels: channelsConfig, + }); + if (channelConfig?.allowed === false) { + await respond({ + text: "This channel is not allowed.", + response_type: "ephemeral", + }); + return; + } + } + + const sender = await resolveUserName(command.user_id); + const senderName = sender?.name ?? command.user_name ?? command.user_id; + const channelName = channelInfo?.name; + const roomLabel = channelName + ? `#${channelName}` + : `#${command.channel_id}`; + const isRoomish = isRoom || isGroupDm; + + const ctxPayload = { + Body: prompt, + From: isDirectMessage + ? `slack:${command.user_id}` + : isRoom + ? `slack:channel:${command.channel_id}` + : `slack:group:${command.channel_id}`, + To: `slash:${command.user_id}`, + ChatType: isDirectMessage ? "direct" : isRoom ? "room" : "group", + GroupSubject: isRoomish ? roomLabel : undefined, + SenderName: senderName, + Surface: "slack" as const, + WasMentioned: true, + MessageSid: command.trigger_id, + Timestamp: Date.now(), + SessionKey: `${slashCommand.sessionPrefix}:${command.user_id}`, + }; + + const replyResult = await getReplyFromConfig( + ctxPayload, + undefined, + cfg, + ); + const replies = replyResult + ? Array.isArray(replyResult) + ? replyResult + : [replyResult] + : []; + + await deliverSlackSlashReplies({ + replies, + respond, + ephemeral: slashCommand.ephemeral, + textLimit, + }); + } catch (err) { + runtime.error?.(danger(`slack slash handler failed: ${String(err)}`)); + await respond({ + text: "Sorry, something went wrong handling that command.", + response_type: "ephemeral", + }); + } + }); + } + + const stopOnAbort = () => { + if (opts.abortSignal?.aborted) void app.stop(); + }; + opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true }); + + try { + await app.start(); + runtime.log?.("slack socket mode connected"); + if (opts.abortSignal?.aborted) return; + await new Promise((resolve) => { + opts.abortSignal?.addEventListener("abort", () => resolve(), { + once: true, + }); + }); + } finally { + opts.abortSignal?.removeEventListener("abort", stopOnAbort); + await app.stop().catch(() => undefined); + } +} + +async function deliverReplies(params: { + replies: ReplyPayload[]; + target: string; + token: string; + runtime: RuntimeEnv; + replyToMode: ReplyToMode; + textLimit: number; +}) { + const chunkLimit = Math.min(params.textLimit, 4000); + let hasReplied = false; + for (const payload of params.replies) { + const mediaList = + payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const text = payload.text ?? ""; + const replyToId = payload.replyToId; + if (!text && mediaList.length === 0) continue; + + if (mediaList.length === 0) { + for (const chunk of chunkText(text, chunkLimit)) { + const threadTs = resolveSlackReplyTarget({ + replyToMode: params.replyToMode, + replyToId, + hasReplied, + }); + const trimmed = chunk.trim(); + if (!trimmed || trimmed === SILENT_REPLY_TOKEN) continue; + await sendMessageSlack(params.target, trimmed, { + token: params.token, + threadTs, + }); + if (threadTs && !hasReplied) hasReplied = true; + } + } else { + let first = true; + for (const mediaUrl of mediaList) { + const caption = first ? text : ""; + first = false; + const threadTs = resolveSlackReplyTarget({ + replyToMode: params.replyToMode, + replyToId, + hasReplied, + }); + await sendMessageSlack(params.target, caption, { + token: params.token, + mediaUrl, + threadTs, + }); + if (threadTs && !hasReplied) hasReplied = true; + } + } + params.runtime.log?.(`delivered reply to ${params.target}`); + } +} + +type SlackRespondFn = (payload: { + text: string; + response_type?: "ephemeral" | "in_channel"; +}) => Promise; + +async function deliverSlackSlashReplies(params: { + replies: ReplyPayload[]; + respond: SlackRespondFn; + ephemeral: boolean; + textLimit: number; +}) { + const messages: string[] = []; + const chunkLimit = Math.min(params.textLimit, 4000); + for (const payload of params.replies) { + const textRaw = payload.text?.trim() ?? ""; + const text = + textRaw && textRaw !== SILENT_REPLY_TOKEN ? textRaw : undefined; + const mediaList = + payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const combined = [ + text ?? "", + ...mediaList.map((url) => url.trim()).filter(Boolean), + ] + .filter(Boolean) + .join("\n"); + if (!combined) continue; + for (const chunk of chunkText(combined, chunkLimit)) { + messages.push(chunk); + } + } + + if (messages.length === 0) { + await params.respond({ + text: "No response was generated for that command.", + response_type: "ephemeral", + }); + return; + } + + const responseType = params.ephemeral ? "ephemeral" : "in_channel"; + for (const message of messages) { + await params.respond({ text: message, response_type: responseType }); + } +} diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index 0581eb155..184c5c709 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -235,6 +235,92 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot typeof slash.ephemeral === "boolean" ? slash.ephemeral : true, }; + const slackDm = (slack.dm ?? {}) as Record; + const slackChannels = slack.channels; + const slackSlash = (slack.slashCommand ?? {}) as Record; + const slackActions = + (slack.actions ?? {}) as Partial>; + state.slackForm = { + enabled: typeof slack.enabled === "boolean" ? slack.enabled : true, + botToken: typeof slack.botToken === "string" ? slack.botToken : "", + appToken: typeof slack.appToken === "string" ? slack.appToken : "", + dmEnabled: typeof slackDm.enabled === "boolean" ? slackDm.enabled : true, + allowFrom: toList(slackDm.allowFrom), + groupEnabled: + typeof slackDm.groupEnabled === "boolean" ? slackDm.groupEnabled : false, + groupChannels: toList(slackDm.groupChannels), + mediaMaxMb: + typeof slack.mediaMaxMb === "number" ? String(slack.mediaMaxMb) : "", + textChunkLimit: + typeof slack.textChunkLimit === "number" + ? String(slack.textChunkLimit) + : "", + replyToMode: + slack.replyToMode === "first" || slack.replyToMode === "all" + ? slack.replyToMode + : "off", + reactionNotifications: + slack.reactionNotifications === "off" || + slack.reactionNotifications === "all" || + slack.reactionNotifications === "allowlist" + ? slack.reactionNotifications + : "own", + reactionAllowlist: toList(slack.reactionAllowlist), + slashEnabled: + typeof slackSlash.enabled === "boolean" ? slackSlash.enabled : false, + slashName: typeof slackSlash.name === "string" ? slackSlash.name : "", + slashSessionPrefix: + typeof slackSlash.sessionPrefix === "string" + ? slackSlash.sessionPrefix + : "", + slashEphemeral: + typeof slackSlash.ephemeral === "boolean" ? slackSlash.ephemeral : true, + actions: { + ...defaultSlackActions, + reactions: + typeof slackActions.reactions === "boolean" + ? slackActions.reactions + : defaultSlackActions.reactions, + messages: + typeof slackActions.messages === "boolean" + ? slackActions.messages + : defaultSlackActions.messages, + pins: + typeof slackActions.pins === "boolean" + ? slackActions.pins + : defaultSlackActions.pins, + memberInfo: + typeof slackActions.memberInfo === "boolean" + ? slackActions.memberInfo + : defaultSlackActions.memberInfo, + emojiList: + typeof slackActions.emojiList === "boolean" + ? slackActions.emojiList + : defaultSlackActions.emojiList, + }, + channels: Array.isArray(slackChannels) + ? [] + : typeof slackChannels === "object" && slackChannels + ? Object.entries(slackChannels as Record).map( + ([key, value]): SlackChannelForm => { + const entry = + value && typeof value === "object" + ? (value as Record) + : {}; + return { + key, + allow: + typeof entry.allow === "boolean" ? entry.allow : true, + requireMention: + typeof entry.requireMention === "boolean" + ? entry.requireMention + : false, + }; + }, + ) + : [], + }; + state.signalForm = { enabled: typeof signal.enabled === "boolean" ? signal.enabled : true, account: typeof signal.account === "string" ? signal.account : "", @@ -281,6 +367,7 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot const configInvalid = snapshot.valid === false ? "Config invalid." : null; state.telegramConfigStatus = configInvalid; state.discordConfigStatus = configInvalid; + state.slackConfigStatus = configInvalid; state.signalConfigStatus = configInvalid; state.imessageConfigStatus = configInvalid; @@ -405,3 +492,4 @@ function removePathValue( delete (current as Record)[lastKey]; } } + diff --git a/ui/src/ui/controllers/connections.ts b/ui/src/ui/controllers/connections.ts index 68bdc7409..4f45dc192 100644 --- a/ui/src/ui/controllers/connections.ts +++ b/ui/src/ui/controllers/connections.ts @@ -379,6 +379,147 @@ export async function saveDiscordConfig(state: ConnectionsState) { } } +export async function saveSlackConfig(state: ConnectionsState) { + if (!state.client || !state.connected) return; + if (state.slackSaving) return; + state.slackSaving = true; + state.slackConfigStatus = null; + try { + const base = state.configSnapshot?.config ?? {}; + const config = { ...base } as Record; + const slack = { ...(config.slack ?? {}) } as Record; + const form = state.slackForm; + + if (form.enabled) { + delete slack.enabled; + } else { + slack.enabled = false; + } + + if (!state.slackTokenLocked) { + const token = form.botToken.trim(); + if (token) slack.botToken = token; + else delete slack.botToken; + } + if (!state.slackAppTokenLocked) { + const token = form.appToken.trim(); + if (token) slack.appToken = token; + else delete slack.appToken; + } + + const dm = { ...(slack.dm ?? {}) } as Record; + dm.enabled = form.dmEnabled; + const allowFrom = parseList(form.allowFrom); + if (allowFrom.length > 0) dm.allowFrom = allowFrom; + else delete dm.allowFrom; + if (form.groupEnabled) { + dm.groupEnabled = true; + } else { + delete dm.groupEnabled; + } + const groupChannels = parseList(form.groupChannels); + if (groupChannels.length > 0) dm.groupChannels = groupChannels; + else delete dm.groupChannels; + if (Object.keys(dm).length > 0) slack.dm = dm; + else delete slack.dm; + + const mediaMaxMb = Number.parseFloat(form.mediaMaxMb); + if (Number.isFinite(mediaMaxMb) && mediaMaxMb > 0) { + slack.mediaMaxMb = mediaMaxMb; + } else { + delete slack.mediaMaxMb; + } + + const textChunkLimit = Number.parseInt(form.textChunkLimit, 10); + if (Number.isFinite(textChunkLimit) && textChunkLimit > 0) { + slack.textChunkLimit = textChunkLimit; + } else { + delete slack.textChunkLimit; + } + + if (form.replyToMode === "off") delete slack.replyToMode; + else slack.replyToMode = form.replyToMode; + + if (form.reactionNotifications === "own") { + delete slack.reactionNotifications; + } else { + slack.reactionNotifications = form.reactionNotifications; + } + const reactionAllowlist = parseList(form.reactionAllowlist); + if (reactionAllowlist.length > 0) { + slack.reactionAllowlist = reactionAllowlist; + } else { + delete slack.reactionAllowlist; + } + + const slash = { ...(slack.slashCommand ?? {}) } as Record; + if (form.slashEnabled) { + slash.enabled = true; + } else { + delete slash.enabled; + } + if (form.slashName.trim()) slash.name = form.slashName.trim(); + else delete slash.name; + if (form.slashSessionPrefix.trim()) + slash.sessionPrefix = form.slashSessionPrefix.trim(); + else delete slash.sessionPrefix; + if (form.slashEphemeral) { + delete slash.ephemeral; + } else { + slash.ephemeral = false; + } + if (Object.keys(slash).length > 0) slack.slashCommand = slash; + else delete slack.slashCommand; + + const actions: Partial = {}; + const applyAction = (key: keyof SlackActionForm) => { + const value = form.actions[key]; + if (value !== defaultSlackActions[key]) actions[key] = value; + }; + applyAction("reactions"); + applyAction("messages"); + applyAction("pins"); + applyAction("memberInfo"); + applyAction("emojiList"); + if (Object.keys(actions).length > 0) { + slack.actions = actions; + } else { + delete slack.actions; + } + + const channels = form.channels + .map((entry): [string, Record] | null => { + const key = entry.key.trim(); + if (!key) return null; + const record: Record = { + allow: entry.allow, + requireMention: entry.requireMention, + }; + return [key, record]; + }) + .filter((value): value is [string, Record] => Boolean(value)); + if (channels.length > 0) { + slack.channels = Object.fromEntries(channels); + } else { + delete slack.channels; + } + + if (Object.keys(slack).length > 0) { + config.slack = slack; + } else { + delete config.slack; + } + + const raw = `${JSON.stringify(config, null, 2).trimEnd()}\n`; + await state.client.request("config.set", { raw }); + state.slackConfigStatus = "Saved. Restart gateway if needed."; + } catch (err) { + state.slackConfigStatus = String(err); + } finally { + state.slackSaving = false; + } +} + export async function saveSignalConfig(state: ConnectionsState) { if (!state.client || !state.connected) return; if (state.signalSaving) return; @@ -529,3 +670,4 @@ export async function saveIMessageConfig(state: ConnectionsState) { state.imessageSaving = false; } } + diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index 9eea25bf3..b70019aa1 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -60,6 +60,41 @@ export type DiscordActionForm = { moderation: boolean; }; +export type SlackChannelForm = { + key: string; + allow: boolean; + requireMention: boolean; +}; + +export type SlackActionForm = { + reactions: boolean; + messages: boolean; + pins: boolean; + memberInfo: boolean; + emojiList: boolean; +}; + +export type SlackForm = { + enabled: boolean; + botToken: string; + appToken: string; + dmEnabled: boolean; + allowFrom: string; + groupEnabled: boolean; + groupChannels: string; + mediaMaxMb: string; + textChunkLimit: string; + replyToMode: "off" | "first" | "all"; + reactionNotifications: "off" | "own" | "all" | "allowlist"; + reactionAllowlist: string; + slashEnabled: boolean; + slashName: string; + slashSessionPrefix: string; + slashEphemeral: boolean; + actions: SlackActionForm; + channels: SlackChannelForm[]; +}; + export const defaultDiscordActions: DiscordActionForm = { reactions: true, stickers: true, @@ -78,6 +113,14 @@ export const defaultDiscordActions: DiscordActionForm = { moderation: false, }; +export const defaultSlackActions: SlackActionForm = { + reactions: true, + messages: true, + pins: true, + memberInfo: true, + emojiList: true, +}; + export type SignalForm = { enabled: boolean; account: string; @@ -125,3 +168,4 @@ export type CronFormState = { timeoutSeconds: string; postToMainPrefix: string; }; + diff --git a/ui/src/ui/views/connections.ts b/ui/src/ui/views/connections.ts index ab9eca9d9..22f3f9cd1 100644 --- a/ui/src/ui/views/connections.ts +++ b/ui/src/ui/views/connections.ts @@ -28,6 +28,14 @@ const discordActionOptions = [ { key: "moderation", label: "Moderation" }, ] satisfies Array<{ key: keyof DiscordActionForm; label: string }>; +const slackActionOptions = [ + { key: "reactions", label: "Reactions" }, + { key: "messages", label: "Messages" }, + { key: "pins", label: "Pins" }, + { key: "memberInfo", label: "Member info" }, + { key: "emojiList", label: "Emoji list" }, +] satisfies Array<{ key: keyof SlackActionForm; label: string }>; + export type ConnectionsProps = { connected: boolean; loading: boolean; @@ -1347,3 +1355,4 @@ function renderProvider( return nothing; } } +