claude-slack-channel-bots 0.4.0 → 0.4.1

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
@@ -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.1",
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": {
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/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