@rokrokss/claude-slack-channel 0.1.0 → 0.2.0
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/CLAUDE.md +1 -0
- package/README.md +10 -3
- package/bun.lock +3 -3
- package/lib/gate.ts +0 -10
- package/lib/index.ts +1 -0
- package/lib/process.ts +63 -0
- package/package.json +1 -1
- package/server.test.ts +28 -32
- package/server.ts +50 -11
package/CLAUDE.md
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
- `event.ts` — DM 판별, 스레드 해석, stale/empty 필터링
|
|
13
13
|
- `resilience.ts` — 이벤트 중복 제거 (EventDeduplicator)
|
|
14
14
|
- `permalink.ts` — Slack 퍼마링크 빌더
|
|
15
|
+
- `process.ts` — 부모 프로세스 channels 플래그 감지 (hasChannelsFlag)
|
|
15
16
|
- `index.ts` — barrel re-export
|
|
16
17
|
- `server.test.ts` — lib/ 테스트 (bun:test)
|
|
17
18
|
|
package/README.md
CHANGED
|
@@ -37,15 +37,21 @@ Claude Code 세션과 Slack을 양방향으로 연결합니다. Slack DM이나
|
|
|
37
37
|
```
|
|
38
38
|
main()
|
|
39
39
|
│
|
|
40
|
-
├──
|
|
40
|
+
├── hasChannelsFlag()
|
|
41
|
+
│ └── 부모 프로세스(Claude Code)의 command line에서
|
|
42
|
+
│ --dangerously-load-development-channels 또는 --channels 확인
|
|
43
|
+
│
|
|
44
|
+
├── [플래그 있음] startSocketMode()
|
|
41
45
|
│ ├── web.auth.test() → botUserId 확인
|
|
42
46
|
│ │ └── 실패 ──► process.exit(1)
|
|
43
47
|
│ └── socket.start() → Slack 이벤트 수신 시작
|
|
44
48
|
│
|
|
49
|
+
├── [플래그 없음] Socket Mode 스킵 (Tools-only mode)
|
|
50
|
+
│
|
|
45
51
|
└── mcp.connect(transport) → MCP stdio 연결
|
|
46
52
|
```
|
|
47
53
|
|
|
48
|
-
Socket Mode는
|
|
54
|
+
Socket Mode는 부모 프로세스(Claude Code)에 channels 플래그가 있을 때만 연결됩니다. 플래그 없이 실행된 세션은 도구만 제공하며 Socket Mode를 건드리지 않으므로, 기존 channels 세션의 연결이 보호됩니다.
|
|
49
55
|
|
|
50
56
|
## 인바운드 메시지 파이프라인
|
|
51
57
|
|
|
@@ -139,7 +145,8 @@ Claude (tool call)
|
|
|
139
145
|
server.ts MCP 서버, Slack 클라이언트, 이벤트 핸들링
|
|
140
146
|
tools.ts MCP 도구 등록 (reply, react, delete_bot_message, fetch_dm_thread)
|
|
141
147
|
lib/
|
|
142
|
-
gate.ts 접근 제어 (Access, GateOptions, gate
|
|
148
|
+
gate.ts 접근 제어 (Access, GateOptions, gate)
|
|
149
|
+
process.ts 부모 프로세스 channels 플래그 감지 (hasChannelsFlag)
|
|
143
150
|
security.ts 아웃바운드 게이트 (assertOutboundAllowed)
|
|
144
151
|
formatting.ts Slack mrkdwn 변환, 메시지 텍스트 추출
|
|
145
152
|
audit.ts 감사 로그 (AuditEntry, auditLog)
|
package/bun.lock
CHANGED
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
|
|
46
46
|
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
|
47
47
|
|
|
48
|
-
"axios": ["axios@1.
|
|
48
|
+
"axios": ["axios@1.14.0", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } }, "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ=="],
|
|
49
49
|
|
|
50
50
|
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
|
51
51
|
|
|
@@ -199,7 +199,7 @@
|
|
|
199
199
|
|
|
200
200
|
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
|
201
201
|
|
|
202
|
-
"proxy-from-env": ["proxy-from-env@
|
|
202
|
+
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
|
|
203
203
|
|
|
204
204
|
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
|
|
205
205
|
|
|
@@ -255,7 +255,7 @@
|
|
|
255
255
|
|
|
256
256
|
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
|
257
257
|
|
|
258
|
-
"zod-to-json-schema": ["zod-to-json-schema@3.25.
|
|
258
|
+
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
|
259
259
|
|
|
260
260
|
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
|
261
261
|
|
package/lib/gate.ts
CHANGED
|
@@ -18,16 +18,6 @@ export function defaultAccess(): Access {
|
|
|
18
18
|
return { allowFrom: [] }
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/**
|
|
22
|
-
* Check if the MCP client supports the channel capability.
|
|
23
|
-
*/
|
|
24
|
-
export function clientSupportsChannels(capabilities: Record<string, unknown> | undefined): boolean {
|
|
25
|
-
if (!capabilities) return false
|
|
26
|
-
const experimental = capabilities['experimental']
|
|
27
|
-
if (!experimental || typeof experimental !== 'object') return false
|
|
28
|
-
return 'claude/channel' in experimental
|
|
29
|
-
}
|
|
30
|
-
|
|
31
21
|
export function gate(event: unknown, opts: GateOptions): GateResult {
|
|
32
22
|
const ev = event as Record<string, unknown>
|
|
33
23
|
|
package/lib/index.ts
CHANGED
package/lib/process.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { execSync } from 'child_process'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Check if a command line string contains channels-related flags.
|
|
5
|
+
*/
|
|
6
|
+
export function hasChannelsFlag(commandLine?: string): boolean {
|
|
7
|
+
// Environment variable override — always wins
|
|
8
|
+
if (process.env['SLACK_FORCE_SOCKET_MODE'] === '1') return true
|
|
9
|
+
|
|
10
|
+
const cmd = commandLine ?? findAncestorClaudeCommand()
|
|
11
|
+
return (
|
|
12
|
+
cmd.includes('dangerously-load-development-channels') ||
|
|
13
|
+
/--channels\b/.test(cmd)
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Walk up the process tree from ppid to find a `claude` ancestor.
|
|
19
|
+
* Returns its command line, or '' if not found.
|
|
20
|
+
* Stops after 5 levels to avoid runaway traversal.
|
|
21
|
+
* Works on macOS/Linux (ps) and Windows (wmic).
|
|
22
|
+
*/
|
|
23
|
+
function findAncestorClaudeCommand(): string {
|
|
24
|
+
const isWindows = process.platform === 'win32'
|
|
25
|
+
try {
|
|
26
|
+
let pid = process.ppid
|
|
27
|
+
for (let i = 0; i < 5 && pid > 1; i++) {
|
|
28
|
+
const { ppid, cmd } = isWindows
|
|
29
|
+
? getProcessInfoWindows(pid)
|
|
30
|
+
: getProcessInfoUnix(pid)
|
|
31
|
+
if (!cmd) break
|
|
32
|
+
if (/\bclaude\b/.test(cmd)) return cmd
|
|
33
|
+
pid = ppid
|
|
34
|
+
}
|
|
35
|
+
} catch { /* process query failure → return empty */ }
|
|
36
|
+
return ''
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getProcessInfoUnix(pid: number): { ppid: number; cmd: string } {
|
|
40
|
+
const line = execSync(`ps -o ppid=,command= -p ${pid}`, {
|
|
41
|
+
encoding: 'utf-8',
|
|
42
|
+
timeout: 3000,
|
|
43
|
+
}).trim()
|
|
44
|
+
const match = line.match(/^\s*(\d+)\s+(.+)$/)
|
|
45
|
+
if (!match) return { ppid: 0, cmd: '' }
|
|
46
|
+
return { ppid: Number(match[1]), cmd: match[2] }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getProcessInfoWindows(pid: number): { ppid: number; cmd: string } {
|
|
50
|
+
const line = execSync(
|
|
51
|
+
`wmic process where ProcessId=${pid} get ParentProcessId,CommandLine /format:csv`,
|
|
52
|
+
{ encoding: 'utf-8', timeout: 3000 },
|
|
53
|
+
).trim()
|
|
54
|
+
// CSV format: Node,CommandLine,ParentProcessId
|
|
55
|
+
// First line is header, second line is data
|
|
56
|
+
const rows = line.split(/\r?\n/).filter(Boolean)
|
|
57
|
+
if (rows.length < 2) return { ppid: 0, cmd: '' }
|
|
58
|
+
const parts = rows[1].split(',')
|
|
59
|
+
if (parts.length < 3) return { ppid: 0, cmd: '' }
|
|
60
|
+
const cmd = parts.slice(1, -1).join(',') // CommandLine may contain commas
|
|
61
|
+
const ppid = Number(parts[parts.length - 1])
|
|
62
|
+
return { ppid, cmd }
|
|
63
|
+
}
|
package/package.json
CHANGED
package/server.test.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
isStaleEvent,
|
|
15
15
|
isEmptyMessage,
|
|
16
16
|
EventDeduplicator,
|
|
17
|
-
|
|
17
|
+
hasChannelsFlag,
|
|
18
18
|
type Access,
|
|
19
19
|
type AuditEntry,
|
|
20
20
|
type GateOptions,
|
|
@@ -668,55 +668,51 @@ describe('EventDeduplicator', () => {
|
|
|
668
668
|
})
|
|
669
669
|
|
|
670
670
|
// ---------------------------------------------------------------------------
|
|
671
|
-
//
|
|
671
|
+
// hasChannelsFlag()
|
|
672
672
|
// ---------------------------------------------------------------------------
|
|
673
673
|
|
|
674
|
-
describe('
|
|
675
|
-
test('returns
|
|
676
|
-
expect(
|
|
674
|
+
describe('hasChannelsFlag', () => {
|
|
675
|
+
test('returns true for --dangerously-load-development-channels', () => {
|
|
676
|
+
expect(hasChannelsFlag('claude --dangerously-load-development-channels server:slack-channel')).toBe(true)
|
|
677
677
|
})
|
|
678
678
|
|
|
679
|
-
test('returns
|
|
680
|
-
expect(
|
|
679
|
+
test('returns true for --channels flag', () => {
|
|
680
|
+
expect(hasChannelsFlag('claude --channels plugin:telegram@anthropic')).toBe(true)
|
|
681
681
|
})
|
|
682
682
|
|
|
683
|
-
test('returns false
|
|
684
|
-
expect(
|
|
683
|
+
test('returns false for plain claude', () => {
|
|
684
|
+
expect(hasChannelsFlag('claude')).toBe(false)
|
|
685
685
|
})
|
|
686
686
|
|
|
687
|
-
test('returns false
|
|
688
|
-
expect(
|
|
687
|
+
test('returns false for other flags', () => {
|
|
688
|
+
expect(hasChannelsFlag('claude --allow-dangerously-skip-permissions')).toBe(false)
|
|
689
689
|
})
|
|
690
690
|
|
|
691
|
-
test('returns false
|
|
692
|
-
expect(
|
|
693
|
-
experimental: { 'some/other': {} },
|
|
694
|
-
})).toBe(false)
|
|
691
|
+
test('returns false for empty string', () => {
|
|
692
|
+
expect(hasChannelsFlag('')).toBe(false)
|
|
695
693
|
})
|
|
696
694
|
|
|
697
|
-
test('returns true when
|
|
698
|
-
expect(
|
|
699
|
-
experimental: { 'claude/channel': {} },
|
|
700
|
-
})).toBe(true)
|
|
695
|
+
test('returns true when flag is among multiple args', () => {
|
|
696
|
+
expect(hasChannelsFlag('claude --verbose --dangerously-load-development-channels server:slack-channel --model opus')).toBe(true)
|
|
701
697
|
})
|
|
702
698
|
|
|
703
|
-
test('
|
|
704
|
-
expect(
|
|
705
|
-
experimental: {
|
|
706
|
-
'claude/channel': {},
|
|
707
|
-
'claude/channel/permission': {},
|
|
708
|
-
},
|
|
709
|
-
})).toBe(true)
|
|
699
|
+
test('does not match --channels as substring of another flag', () => {
|
|
700
|
+
expect(hasChannelsFlag('claude --channelsFoo bar')).toBe(false)
|
|
710
701
|
})
|
|
711
702
|
|
|
712
|
-
test('
|
|
713
|
-
expect(
|
|
714
|
-
experimental: { 'claude/channel': null },
|
|
715
|
-
})).toBe(true)
|
|
703
|
+
test('matches --channels=value syntax', () => {
|
|
704
|
+
expect(hasChannelsFlag('claude --channels=plugin:foo@bar')).toBe(true)
|
|
716
705
|
})
|
|
717
706
|
|
|
718
|
-
test('
|
|
719
|
-
|
|
707
|
+
test('SLACK_FORCE_SOCKET_MODE=1 overrides detection', () => {
|
|
708
|
+
const prev = process.env['SLACK_FORCE_SOCKET_MODE']
|
|
709
|
+
try {
|
|
710
|
+
process.env['SLACK_FORCE_SOCKET_MODE'] = '1'
|
|
711
|
+
expect(hasChannelsFlag('')).toBe(true)
|
|
712
|
+
} finally {
|
|
713
|
+
if (prev === undefined) delete process.env['SLACK_FORCE_SOCKET_MODE']
|
|
714
|
+
else process.env['SLACK_FORCE_SOCKET_MODE'] = prev
|
|
715
|
+
}
|
|
720
716
|
})
|
|
721
717
|
})
|
|
722
718
|
|
package/server.ts
CHANGED
|
@@ -17,6 +17,10 @@ import { join } from 'path'
|
|
|
17
17
|
import {
|
|
18
18
|
mkdirSync,
|
|
19
19
|
appendFileSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
writeFileSync,
|
|
22
|
+
unlinkSync,
|
|
23
|
+
existsSync,
|
|
20
24
|
} from 'fs'
|
|
21
25
|
import { z } from 'zod'
|
|
22
26
|
import {
|
|
@@ -29,6 +33,7 @@ import {
|
|
|
29
33
|
isStaleEvent,
|
|
30
34
|
isEmptyMessage,
|
|
31
35
|
EventDeduplicator,
|
|
36
|
+
hasChannelsFlag,
|
|
32
37
|
type Access,
|
|
33
38
|
type GateResult,
|
|
34
39
|
} from './lib/index.ts'
|
|
@@ -153,7 +158,7 @@ async function resolveUserName(userId: string): Promise<string> {
|
|
|
153
158
|
// ---------------------------------------------------------------------------
|
|
154
159
|
|
|
155
160
|
const mcp = new McpServer(
|
|
156
|
-
{ name: 'slack-channel', version: '0.
|
|
161
|
+
{ name: 'slack-channel', version: '0.2.0' },
|
|
157
162
|
{
|
|
158
163
|
capabilities: {
|
|
159
164
|
experimental: {
|
|
@@ -374,7 +379,43 @@ socket.on('app_mention', async ({ event, ack }) => {
|
|
|
374
379
|
// Startup
|
|
375
380
|
// ---------------------------------------------------------------------------
|
|
376
381
|
|
|
382
|
+
const PID_FILE = join(STATE_DIR, 'socket.pid')
|
|
383
|
+
|
|
384
|
+
function killPreviousInstance(): void {
|
|
385
|
+
if (!existsSync(PID_FILE)) return
|
|
386
|
+
try {
|
|
387
|
+
const oldPid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10)
|
|
388
|
+
if (isNaN(oldPid) || oldPid === process.pid) return
|
|
389
|
+
// Check if process is alive (signal 0 doesn't kill, just checks)
|
|
390
|
+
process.kill(oldPid, 0)
|
|
391
|
+
debugLog(`[slack] Killing previous Socket Mode instance (pid ${oldPid})`)
|
|
392
|
+
process.kill(oldPid, 'SIGTERM')
|
|
393
|
+
} catch {
|
|
394
|
+
// Process doesn't exist or no permission — safe to proceed
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function writePidFile(): void {
|
|
399
|
+
writeFileSync(PID_FILE, String(process.pid), 'utf-8')
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function cleanupPidFile(): void {
|
|
403
|
+
try {
|
|
404
|
+
if (existsSync(PID_FILE) && readFileSync(PID_FILE, 'utf-8').trim() === String(process.pid)) {
|
|
405
|
+
unlinkSync(PID_FILE)
|
|
406
|
+
}
|
|
407
|
+
} catch {}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
process.on('exit', cleanupPidFile)
|
|
411
|
+
process.on('SIGTERM', () => { cleanupPidFile(); process.exit(0) })
|
|
412
|
+
process.on('SIGINT', () => { cleanupPidFile(); process.exit(0) })
|
|
413
|
+
|
|
377
414
|
async function startSocketMode(): Promise<void> {
|
|
415
|
+
// Kill previous instance if still running
|
|
416
|
+
killPreviousInstance()
|
|
417
|
+
writePidFile()
|
|
418
|
+
|
|
378
419
|
// Resolve bot's own user ID (for mention detection + self-filtering)
|
|
379
420
|
try {
|
|
380
421
|
const auth = await web.auth.test()
|
|
@@ -390,17 +431,15 @@ async function startSocketMode(): Promise<void> {
|
|
|
390
431
|
}
|
|
391
432
|
|
|
392
433
|
async function main(): Promise<void> {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
})
|
|
434
|
+
if (hasChannelsFlag()) {
|
|
435
|
+
await startSocketMode().catch((err) => {
|
|
436
|
+
console.error('[slack] Socket Mode init failed:', err)
|
|
437
|
+
process.exit(1)
|
|
438
|
+
})
|
|
439
|
+
} else {
|
|
440
|
+
debugLog('[slack] Socket Mode skipped — parent process has no channels flag. Tools-only mode.')
|
|
441
|
+
}
|
|
402
442
|
|
|
403
|
-
// Connect MCP stdio (server ↔ Claude Code)
|
|
404
443
|
const transport = new StdioServerTransport()
|
|
405
444
|
await mcp.connect(transport)
|
|
406
445
|
debugLog('[slack] MCP server running on stdio')
|