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 CHANGED
@@ -1,4 +1,4 @@
1
- # Slack Channel Router
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-slack-channel-bots",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Multi-session Slack-to-Claude bridge \u2014 run multiple Claude Code bots across Slack channels via Socket Mode",
5
5
  "type": "module",
6
6
  "bin": {
@@ -227,45 +227,67 @@ their defaults unless asked.
227
227
 
228
228
  ---
229
229
 
230
- ### Step 5 — Configure custom CLAUDE.md (optional)
230
+ ### Step 5 — Configure custom system prompt for worker sessions
231
231
 
232
- Worker sessions launched by the server can receive a custom system-prompt
233
- append via `append_system_prompt_file` in `routing.json`. This is useful for
234
- giving all workers project-specific instructions: communication rules,
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
- An example template is included with the package. Show the operator where it
238
- lives:
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
- ls "$(npm root -g)/claude-slack-channel-bots/skills/EXAMPLE_CLAUDE.md" 2>/dev/null \
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
- If the file is found, print its path so the operator can inspect it as a
246
- starting point.
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
- Ask the operator: **"Do you want to configure a custom CLAUDE.md file for
249
- worker sessions?"**
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
- **If yes:**
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
- 1. Prompt for the absolute path to their CLAUDE.md file.
254
- 2. Verify the file exists:
273
+ 2. Save the file to `~/.claude/channels/slack/system-prompt.md`:
255
274
  ```bash
256
- test -f "<provided-path>" && echo "ok" || echo "not found"
275
+ STATE_DIR="${SLACK_STATE_DIR:-$HOME/.claude/channels/slack}"
257
276
  ```
258
- Expand `~` before checking. If the file does not exist, warn the operator
259
- and re-prompt until a valid path is given or they choose to skip.
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": "<provided-path>"
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
- **If skipped:**
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`. Move on.
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 (!deps.env['SLACK_BOT_TOKEN']) {
97
- console.error('missing prerequisite: SLACK_BOT_TOKEN environment variable')
98
- deps.exit(1)
99
- }
100
- if (!deps.env['SLACK_APP_TOKEN']) {
101
- console.error('missing prerequisite: SLACK_APP_TOKEN environment variable')
102
- deps.exit(1)
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 immediately
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
- // Resolve bot user ID
951
- try {
952
- const auth = await web.auth.test()
953
- botUserId = (auth.user_id as string) || ''
954
- } catch (err) {
955
- console.error('[slack] Failed to resolve bot user ID:', err)
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
- // Connect Socket Mode
959
- await socket.start()
960
- console.error('[slack] Socket Mode connected')
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
- // Register pending entry with empty waiters array
1126
- pendingPermissions.set(requestId, {
1127
- requestId,
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
- pendingQuestions.set(requestId, {
1289
- requestId,
1290
- channelId: matchedChannelId,
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