claude-slack-channel-bots 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/package.json +1 -1
- package/skills/setup-slack-channel-bots/SKILL.md +44 -22
- package/src/cli.ts +11 -8
- package/src/lib.ts +2 -2
- package/src/registry.ts +22 -0
- package/src/server.ts +46 -26
- package/src/tokens.ts +13 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Slack Channel
|
|
1
|
+
# Claude Slack Channel Bots
|
|
2
2
|
|
|
3
3
|
A single HTTP MCP server that holds one Slack Socket Mode connection and routes messages to multiple independent Claude Code sessions, each scoped to a different repo and reachable via its own Slack channel. Inbound messages are dispatched to whichever session owns the channel they arrived on; outbound tool calls are restricted to channels that session has previously received a message from.
|
|
4
4
|
|
|
@@ -58,6 +58,7 @@ Tokens and runtime options are read from environment variables. There is no `.en
|
|
|
58
58
|
| `SLACK_APP_TOKEN` | Slack app-level token (`xapp-…`). Required. Generated under Basic Information → App-Level Tokens with the `connections:write` scope. |
|
|
59
59
|
| `SLACK_STATE_DIR` | Override the directory where `routing.json`, `access.json`, and runtime state are stored. Defaults to `~/.claude/channels/slack`. |
|
|
60
60
|
| `SLACK_ACCESS_MODE` | Set to `static` to load `access.json` once at startup and cache it for the lifetime of the process rather than re-reading it on every event. Useful in high-throughput environments where disk reads are a concern. |
|
|
61
|
+
| `SLACK_DRY_RUN` | Set to `1` to start the server without Slack credentials. Token validation is skipped, Socket Mode and `web.auth.test()` are not called, and MCP tool calls (`reply`, `react`, etc.) are logged instead of sent. Useful for integration testing. |
|
|
61
62
|
|
|
62
63
|
Shell profile example:
|
|
63
64
|
|
|
@@ -67,6 +68,8 @@ export SLACK_APP_TOKEN=xapp-your-app-token
|
|
|
67
68
|
# Optional overrides:
|
|
68
69
|
export SLACK_STATE_DIR=~/.config/slack-channel-bots
|
|
69
70
|
export SLACK_ACCESS_MODE=static
|
|
71
|
+
# Dry-run mode (no Slack credentials needed):
|
|
72
|
+
export SLACK_DRY_RUN=1
|
|
70
73
|
```
|
|
71
74
|
|
|
72
75
|
---
|
package/package.json
CHANGED
|
@@ -227,45 +227,67 @@ their defaults unless asked.
|
|
|
227
227
|
|
|
228
228
|
---
|
|
229
229
|
|
|
230
|
-
### Step 5 — Configure custom
|
|
230
|
+
### Step 5 — Configure custom system prompt for worker sessions
|
|
231
231
|
|
|
232
|
-
Worker sessions launched by the server can receive a custom system
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
development process, how to spawn sub-workers, and so on.
|
|
232
|
+
Worker sessions launched by the server can receive a custom system prompt
|
|
233
|
+
via `append_system_prompt_file` in `routing.json`. This controls how the
|
|
234
|
+
bots behave — their role, communication style, and capabilities.
|
|
236
235
|
|
|
237
|
-
|
|
238
|
-
|
|
236
|
+
**This is important.** Without a system prompt, bots won't know to communicate
|
|
237
|
+
via Slack and will try to use the TUI (which nobody can see).
|
|
238
|
+
|
|
239
|
+
An example template is included with the package. Read it for reference:
|
|
239
240
|
|
|
240
241
|
```bash
|
|
241
|
-
|
|
242
|
+
cat "$(npm root -g)/claude-slack-channel-bots/skills/EXAMPLE_CLAUDE.md" 2>/dev/null \
|
|
242
243
|
|| echo "NOT_FOUND"
|
|
243
244
|
```
|
|
244
245
|
|
|
245
|
-
|
|
246
|
-
|
|
246
|
+
Show the user the example content so they understand what a system prompt
|
|
247
|
+
looks like.
|
|
248
|
+
|
|
249
|
+
Then ask: **"What should your bots do? Describe the role you want them to
|
|
250
|
+
play, how they should communicate, and any specific behaviors."**
|
|
251
|
+
|
|
252
|
+
Examples to offer if they're unsure:
|
|
253
|
+
- "I want a coding assistant that responds to my messages in Slack"
|
|
254
|
+
- "I want an orchestrator that manages workers and reports status"
|
|
255
|
+
- "I want a simple bot that answers questions about my codebase"
|
|
256
|
+
|
|
257
|
+
**After the user describes what they want:**
|
|
247
258
|
|
|
248
|
-
|
|
249
|
-
|
|
259
|
+
1. Write a system prompt file based on their description. The file MUST
|
|
260
|
+
always include these two essential sections at the top (adapt the wording
|
|
261
|
+
to match their described role):
|
|
250
262
|
|
|
251
|
-
|
|
263
|
+
```markdown
|
|
264
|
+
# Communication with the User
|
|
265
|
+
The User communicates with you via Slack. Always use the
|
|
266
|
+
mcp__slack-channel-router__reply tool to send messages.
|
|
267
|
+
**Important**: Nothing you send to the TUI will be seen by the User.
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Then add sections based on what the user described (role, process,
|
|
271
|
+
capabilities, tone, etc.).
|
|
252
272
|
|
|
253
|
-
|
|
254
|
-
2. Verify the file exists:
|
|
273
|
+
2. Save the file to `~/.claude/channels/slack/system-prompt.md`:
|
|
255
274
|
```bash
|
|
256
|
-
|
|
275
|
+
STATE_DIR="${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}"
|
|
257
276
|
```
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
3. Read `routing.json`, add or update the top-level field:
|
|
277
|
+
|
|
278
|
+
3. Read `routing.json`, add or update:
|
|
261
279
|
```json
|
|
262
|
-
"append_system_prompt_file": "
|
|
280
|
+
"append_system_prompt_file": "~/.claude/channels/slack/system-prompt.md"
|
|
263
281
|
```
|
|
264
282
|
Write the updated file, preserving all other fields.
|
|
265
283
|
|
|
266
|
-
|
|
284
|
+
4. Show the user what was written and ask if they want to make changes.
|
|
285
|
+
Iterate until they're happy.
|
|
286
|
+
|
|
287
|
+
**If the user explicitly skips:**
|
|
267
288
|
|
|
268
|
-
Do not write `append_system_prompt_file` to `routing.json
|
|
289
|
+
Do not write `append_system_prompt_file` to `routing.json`, but warn them
|
|
290
|
+
that bots won't know to communicate via Slack without a system prompt.
|
|
269
291
|
|
|
270
292
|
---
|
|
271
293
|
|
package/src/cli.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { defaultTmuxClient, isClaudeRunning as tmuxIsClaudeRunning, sessionName
|
|
|
18
18
|
import { readSessions, type SessionsMap } from './sessions.ts'
|
|
19
19
|
import { loadConfig as configLoadConfig, type RoutingConfig } from './config.ts'
|
|
20
20
|
import { initLogging } from './logging.ts'
|
|
21
|
+
import { isDryRun } from './tokens.ts'
|
|
21
22
|
|
|
22
23
|
// ---------------------------------------------------------------------------
|
|
23
24
|
// Injectable dependency interface
|
|
@@ -92,14 +93,16 @@ export function createCli(deps: CliDeps): CliHandlers {
|
|
|
92
93
|
deps.exit(1)
|
|
93
94
|
}
|
|
94
95
|
|
|
95
|
-
// Check required Slack tokens
|
|
96
|
-
if (!
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
96
|
+
// Check required Slack tokens (skipped in dry-run mode)
|
|
97
|
+
if (!isDryRun()) {
|
|
98
|
+
if (!deps.env['SLACK_BOT_TOKEN']) {
|
|
99
|
+
console.error('missing prerequisite: SLACK_BOT_TOKEN environment variable')
|
|
100
|
+
deps.exit(1)
|
|
101
|
+
}
|
|
102
|
+
if (!deps.env['SLACK_APP_TOKEN']) {
|
|
103
|
+
console.error('missing prerequisite: SLACK_APP_TOKEN environment variable')
|
|
104
|
+
deps.exit(1)
|
|
105
|
+
}
|
|
103
106
|
}
|
|
104
107
|
|
|
105
108
|
// Check routing.json exists
|
package/src/lib.ts
CHANGED
|
@@ -189,8 +189,8 @@ export interface GateOptions {
|
|
|
189
189
|
export async function gate(event: unknown, opts: GateOptions): Promise<GateResult> {
|
|
190
190
|
const ev = event as Record<string, unknown>
|
|
191
191
|
|
|
192
|
-
// 1. Drop bot messages
|
|
193
|
-
if (ev['bot_id']) return { action: 'drop' }
|
|
192
|
+
// 1. Drop our own bot messages (but allow messages from other bots)
|
|
193
|
+
if (ev['bot_id'] && ev['user'] === opts.botUserId) return { action: 'drop' }
|
|
194
194
|
|
|
195
195
|
// 2. Drop non-message subtypes (message_changed, message_deleted, etc.)
|
|
196
196
|
if (ev['subtype'] && ev['subtype'] !== 'file_share') return { action: 'drop' }
|
package/src/registry.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
ListToolsRequestSchema,
|
|
16
16
|
} from '@modelcontextprotocol/sdk/types.js'
|
|
17
17
|
import { MCP_SERVER_NAME, type RoutingConfig } from './config.ts'
|
|
18
|
+
import { isDryRun } from './tokens.ts'
|
|
18
19
|
import { getPeerPidByPort, getSessionIdForPid } from './peer-pid.ts'
|
|
19
20
|
import { readSessions, writeSessions, type SessionsMap } from './sessions.ts'
|
|
20
21
|
|
|
@@ -516,6 +517,11 @@ export function createSessionServer(
|
|
|
516
517
|
// Per-session outbound gate (t2.c1r.zk.qm)
|
|
517
518
|
assertOutboundAllowed(chatId, entry.deliveredChannels)
|
|
518
519
|
|
|
520
|
+
if (isDryRun()) {
|
|
521
|
+
console.error(`[slack] dry-run: reply to ${chatId} (${text.length} chars)`)
|
|
522
|
+
return { content: [{ type: 'text', text: `[dry-run] Would send message to ${chatId}` }] }
|
|
523
|
+
}
|
|
524
|
+
|
|
519
525
|
const access = getAccess()
|
|
520
526
|
const limit = access.textChunkLimit || DEFAULT_CHUNK_LIMIT
|
|
521
527
|
const mode = access.chunkMode || 'newline'
|
|
@@ -575,6 +581,10 @@ export function createSessionServer(
|
|
|
575
581
|
// ---------------------------------------------------------------------
|
|
576
582
|
case 'react': {
|
|
577
583
|
assertOutboundAllowed(args.chat_id, entry.deliveredChannels)
|
|
584
|
+
if (isDryRun()) {
|
|
585
|
+
console.error(`[slack] dry-run: react :${args.emoji}: on ${args.message_id}`)
|
|
586
|
+
return { content: [{ type: 'text', text: `[dry-run] Would react :${args.emoji}: to ${args.message_id}` }] }
|
|
587
|
+
}
|
|
578
588
|
await web.reactions.add({
|
|
579
589
|
channel: args.chat_id,
|
|
580
590
|
timestamp: args.message_id,
|
|
@@ -590,6 +600,10 @@ export function createSessionServer(
|
|
|
590
600
|
// ---------------------------------------------------------------------
|
|
591
601
|
case 'edit_message': {
|
|
592
602
|
assertOutboundAllowed(args.chat_id, entry.deliveredChannels)
|
|
603
|
+
if (isDryRun()) {
|
|
604
|
+
console.error(`[slack] dry-run: edit_message ${args.message_id} in ${args.chat_id}`)
|
|
605
|
+
return { content: [{ type: 'text', text: `[dry-run] Would edit message ${args.message_id}` }] }
|
|
606
|
+
}
|
|
593
607
|
await web.chat.update({
|
|
594
608
|
channel: args.chat_id,
|
|
595
609
|
ts: args.message_id,
|
|
@@ -605,6 +619,10 @@ export function createSessionServer(
|
|
|
605
619
|
// ---------------------------------------------------------------------
|
|
606
620
|
case 'fetch_messages': {
|
|
607
621
|
assertOutboundAllowed(args.channel, entry.deliveredChannels)
|
|
622
|
+
if (isDryRun()) {
|
|
623
|
+
console.error(`[slack] dry-run: fetch_messages from ${args.channel}`)
|
|
624
|
+
return { content: [{ type: 'text', text: `[dry-run] Would fetch messages from ${args.channel}` }] }
|
|
625
|
+
}
|
|
608
626
|
const channel: string = args.channel
|
|
609
627
|
const limit = Math.min(args.limit || 20, 100)
|
|
610
628
|
const threadTs: string | undefined = args.thread_ts
|
|
@@ -646,6 +664,10 @@ export function createSessionServer(
|
|
|
646
664
|
// ---------------------------------------------------------------------
|
|
647
665
|
case 'download_attachment': {
|
|
648
666
|
assertOutboundAllowed(args.chat_id, entry.deliveredChannels)
|
|
667
|
+
if (isDryRun()) {
|
|
668
|
+
console.error(`[slack] dry-run: download_attachment from ${args.chat_id} msg=${args.message_id}`)
|
|
669
|
+
return { content: [{ type: 'text', text: `[dry-run] Would download attachments from ${args.message_id}` }] }
|
|
670
|
+
}
|
|
649
671
|
const channel: string = args.chat_id
|
|
650
672
|
const messageTs: string = args.message_id
|
|
651
673
|
|
package/src/server.ts
CHANGED
|
@@ -52,7 +52,7 @@ import {
|
|
|
52
52
|
hasReachedMaxFailures,
|
|
53
53
|
} from './restart.ts'
|
|
54
54
|
import { initHealthCheck, startHealthCheck, stopHealthCheck } from './health-check.ts'
|
|
55
|
-
import { loadTokens } from './tokens.ts'
|
|
55
|
+
import { loadTokens, isDryRun } from './tokens.ts'
|
|
56
56
|
import { checkPidConflict, writePidFile, removePidFile } from './pid.ts'
|
|
57
57
|
import { trackAck, consumeAck } from './ack-tracker.ts'
|
|
58
58
|
import {
|
|
@@ -947,17 +947,22 @@ export async function main(): Promise<void> {
|
|
|
947
947
|
}
|
|
948
948
|
}
|
|
949
949
|
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
950
|
+
if (isDryRun()) {
|
|
951
|
+
console.error('[slack] Running in dry-run mode — Slack disabled')
|
|
952
|
+
botUserId = 'U000DRY'
|
|
953
|
+
} else {
|
|
954
|
+
// Resolve bot user ID
|
|
955
|
+
try {
|
|
956
|
+
const auth = await web.auth.test()
|
|
957
|
+
botUserId = (auth.user_id as string) || ''
|
|
958
|
+
} catch (err) {
|
|
959
|
+
console.error('[slack] Failed to resolve bot user ID:', err)
|
|
960
|
+
}
|
|
957
961
|
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
962
|
+
// Connect Socket Mode
|
|
963
|
+
await socket.start()
|
|
964
|
+
console.error('[slack] Socket Mode connected')
|
|
965
|
+
}
|
|
961
966
|
|
|
962
967
|
// Propagate resolved port to tool deps for peer PID discovery
|
|
963
968
|
sessionToolDeps.serverPort = mcpPort
|
|
@@ -1104,6 +1109,17 @@ export async function main(): Promise<void> {
|
|
|
1104
1109
|
// Generate unique request ID
|
|
1105
1110
|
const requestId = crypto.randomUUID()
|
|
1106
1111
|
|
|
1112
|
+
// Register pending entry BEFORE posting to Slack to avoid race condition:
|
|
1113
|
+
// If the user clicks Allow/Deny before postMessage returns, the interactive
|
|
1114
|
+
// handler needs the entry to already exist in pendingPermissions.
|
|
1115
|
+
pendingPermissions.set(requestId, {
|
|
1116
|
+
requestId,
|
|
1117
|
+
channelId: matchedChannelId,
|
|
1118
|
+
messageTs: '',
|
|
1119
|
+
toolName: tool_name,
|
|
1120
|
+
waiters: [],
|
|
1121
|
+
})
|
|
1122
|
+
|
|
1107
1123
|
// Post Block Kit message to channel
|
|
1108
1124
|
let messageTs: string
|
|
1109
1125
|
try {
|
|
@@ -1115,6 +1131,7 @@ export async function main(): Promise<void> {
|
|
|
1115
1131
|
})
|
|
1116
1132
|
messageTs = postResult.ts as string
|
|
1117
1133
|
} catch (err) {
|
|
1134
|
+
pendingPermissions.delete(requestId)
|
|
1118
1135
|
console.error('[slack] /permission: chat.postMessage failed:', err)
|
|
1119
1136
|
return new Response(JSON.stringify({ error: 'Failed to post message' }), {
|
|
1120
1137
|
status: 500,
|
|
@@ -1122,14 +1139,9 @@ export async function main(): Promise<void> {
|
|
|
1122
1139
|
})
|
|
1123
1140
|
}
|
|
1124
1141
|
|
|
1125
|
-
//
|
|
1126
|
-
pendingPermissions.
|
|
1127
|
-
|
|
1128
|
-
channelId: matchedChannelId,
|
|
1129
|
-
messageTs,
|
|
1130
|
-
toolName: tool_name,
|
|
1131
|
-
waiters: [],
|
|
1132
|
-
})
|
|
1142
|
+
// Update with the real message timestamp (needed for chat.update on decision)
|
|
1143
|
+
const pending = pendingPermissions.get(requestId)
|
|
1144
|
+
if (pending) pending.messageTs = messageTs
|
|
1133
1145
|
|
|
1134
1146
|
return new Response(JSON.stringify({ requestId }), {
|
|
1135
1147
|
status: 200,
|
|
@@ -1269,6 +1281,17 @@ export async function main(): Promise<void> {
|
|
|
1269
1281
|
},
|
|
1270
1282
|
]
|
|
1271
1283
|
|
|
1284
|
+
// Register pending entry BEFORE posting to Slack to avoid race condition:
|
|
1285
|
+
// If the user clicks a button before postMessage returns, the interactive
|
|
1286
|
+
// handler needs the entry to already exist in pendingQuestions.
|
|
1287
|
+
pendingQuestions.set(requestId, {
|
|
1288
|
+
requestId,
|
|
1289
|
+
channelId: matchedChannelId,
|
|
1290
|
+
messageTs: '',
|
|
1291
|
+
question: question as string,
|
|
1292
|
+
waiters: [],
|
|
1293
|
+
})
|
|
1294
|
+
|
|
1272
1295
|
let messageTs: string
|
|
1273
1296
|
try {
|
|
1274
1297
|
const postResult = await web.chat.postMessage({
|
|
@@ -1278,6 +1301,7 @@ export async function main(): Promise<void> {
|
|
|
1278
1301
|
})
|
|
1279
1302
|
messageTs = postResult.ts as string
|
|
1280
1303
|
} catch (err) {
|
|
1304
|
+
pendingQuestions.delete(requestId)
|
|
1281
1305
|
console.error('[slack] /ask: chat.postMessage failed:', err)
|
|
1282
1306
|
return new Response(JSON.stringify({ error: 'Failed to post message' }), {
|
|
1283
1307
|
status: 500,
|
|
@@ -1285,13 +1309,9 @@ export async function main(): Promise<void> {
|
|
|
1285
1309
|
})
|
|
1286
1310
|
}
|
|
1287
1311
|
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
messageTs,
|
|
1292
|
-
question: question as string,
|
|
1293
|
-
waiters: [],
|
|
1294
|
-
})
|
|
1312
|
+
// Update with the real message timestamp
|
|
1313
|
+
const pendingQ = pendingQuestions.get(requestId)
|
|
1314
|
+
if (pendingQ) pendingQ.messageTs = messageTs
|
|
1295
1315
|
|
|
1296
1316
|
return new Response(JSON.stringify({ requestId }), {
|
|
1297
1317
|
status: 200,
|
package/src/tokens.ts
CHANGED
|
@@ -7,11 +7,24 @@
|
|
|
7
7
|
* SPDX-License-Identifier: MIT
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Dry-run detection
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
export function isDryRun(): boolean {
|
|
15
|
+
const val = (process.env['SLACK_DRY_RUN'] ?? '').toLowerCase()
|
|
16
|
+
return val === '1' || val === 'true' || val === 'yes'
|
|
17
|
+
}
|
|
18
|
+
|
|
10
19
|
// ---------------------------------------------------------------------------
|
|
11
20
|
// Token loading
|
|
12
21
|
// ---------------------------------------------------------------------------
|
|
13
22
|
|
|
14
23
|
export function loadTokens(): { botToken: string; appToken: string } {
|
|
24
|
+
if (isDryRun()) {
|
|
25
|
+
return { botToken: 'xoxb-dry-run', appToken: 'xapp-dry-run' }
|
|
26
|
+
}
|
|
27
|
+
|
|
15
28
|
const botToken = process.env['SLACK_BOT_TOKEN'] ?? ''
|
|
16
29
|
const appToken = process.env['SLACK_APP_TOKEN'] ?? ''
|
|
17
30
|
|