Slack: refine scopes and onboarding

This commit is contained in:
Shadow
2026-01-03 23:12:11 -06:00
committed by Peter Steinberger
parent bf3d120f8c
commit 0085b2e0a9
17 changed files with 2484 additions and 1 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -292,6 +292,64 @@ Reaction notification modes:
- `all`: all reactions on all messages.
- `allowlist`: reactions from `guilds.<id>.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:<id>` (DM) or `channel:<id>` 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.

166
docs/slack.md Normal file
View File

@@ -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:<channelId>` sessions.
- Slash commands use `slack:slash:<userId>` 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:<id>` for DMs
- `channel:<id>` 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.

144
skills/slack/SKILL.md Normal file
View File

@@ -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:<id>` or `user:<id>`) 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.

View File

@@ -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" }
}
}
}
}

View File

@@ -62,6 +62,90 @@ async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise<void> {
);
}
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<void> {
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;
}

View File

@@ -40,6 +40,7 @@ const GROUP_LABELS: Record<string, string> = {
talk: "Talk",
telegram: "Telegram",
discord: "Discord",
slack: "Slack",
signal: "Signal",
imessage: "iMessage",
whatsapp: "WhatsApp",
@@ -65,6 +66,7 @@ const GROUP_ORDER: Record<string, number> = {
talk: 130,
telegram: 140,
discord: 150,
slack: 155,
signal: 160,
imessage: 170,
whatsapp: 180,
@@ -92,6 +94,8 @@ const FIELD_LABELS: Record<string, string> = {
"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",
};

View File

@@ -144,6 +144,7 @@ export type HookMappingConfig = {
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage";
to?: string;
@@ -290,6 +291,64 @@ export type DiscordConfig = {
guilds?: Record<string, DiscordGuildEntry>;
};
export type SlackDmConfig = {
/** If false, ignore all incoming Slack DMs. Default: true. */
enabled?: boolean;
/** Allowlist for DM senders (ids). */
allowFrom?: Array<string | number>;
/** If true, allow group DMs (default: false). */
groupEnabled?: boolean;
/** Optional allowlist for group DM channels (ids or slugs). */
groupChannels?: Array<string | number>;
};
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<string | number>;
actions?: SlackActionConfig;
slashCommand?: SlackSlashCommandConfig;
dm?: SlackDmConfig;
channels?: Record<string, SlackChannelConfig>;
};
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;

View File

@@ -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(),

184
src/slack/actions.ts Normal file
View File

@@ -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<SlackMessageSummary["reactions"]> {
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<SlackPin[]> {
const client = await getClient(opts);
const result = await client.pins.list({ channel: channelId });
return (result.items ?? []) as SlackPin[];
}

15
src/slack/index.ts Normal file
View File

@@ -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";

1317
src/slack/monitor.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -235,6 +235,92 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
typeof slash.ephemeral === "boolean" ? slash.ephemeral : true,
};
const slackDm = (slack.dm ?? {}) as Record<string, unknown>;
const slackChannels = slack.channels;
const slackSlash = (slack.slashCommand ?? {}) as Record<string, unknown>;
const slackActions =
(slack.actions ?? {}) as Partial<Record<keyof typeof defaultSlackActions, unknown>>;
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<string, unknown>).map(
([key, value]): SlackChannelForm => {
const entry =
value && typeof value === "object"
? (value as Record<string, unknown>)
: {};
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<string, unknown>)[lastKey];
}
}

View File

@@ -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<string, unknown>;
const slack = { ...(config.slack ?? {}) } as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<SlackActionForm> = {};
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<string, unknown>] | null => {
const key = entry.key.trim();
if (!key) return null;
const record: Record<string, unknown> = {
allow: entry.allow,
requireMention: entry.requireMention,
};
return [key, record];
})
.filter((value): value is [string, Record<string, unknown>] => 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;
}
}

View File

@@ -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;
};

View File

@@ -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;
}
}