@rokrokss/claude-slack-channel 0.1.0 → 0.3.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 +208 -124
- 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 +61 -13
- package/tools.ts +8 -1
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
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
# Claude Slack Channel
|
|
2
2
|
|
|
3
|
-
Claude Code 세션과 Slack을 양방향으로
|
|
3
|
+
Claude Code 세션과 Slack을 양방향으로 연결하는 MCP 채널 서버입니다. Slack DM이나 채널 멘션으로 Claude Code와 대화할 수 있습니다.
|
|
4
|
+
|
|
5
|
+
## 설치
|
|
6
|
+
|
|
7
|
+
프로젝트 `.mcp.json` 또는 `~/.claude.json`에 추가합니다:
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"mcpServers": {
|
|
12
|
+
"slack-channel": {
|
|
13
|
+
"command": "bunx",
|
|
14
|
+
"args": ["@rokrokss/claude-slack-channel"],
|
|
15
|
+
"env": {
|
|
16
|
+
"SLACK_BOT_TOKEN": "xoxb-...",
|
|
17
|
+
"SLACK_APP_TOKEN": "xapp-...",
|
|
18
|
+
"SLACK_ALLOW_FROM": "U123,U456",
|
|
19
|
+
"SLACK_WORKSPACE": "your-workspace",
|
|
20
|
+
"SLACK_ACK_REACTION": "eyes"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
| 환경변수 | 필수 | 설명 |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `SLACK_BOT_TOKEN` | O | Bot User OAuth Token (`xoxb-...`) |
|
|
30
|
+
| `SLACK_APP_TOKEN` | O | App-Level Token (`xapp-...`) |
|
|
31
|
+
| `SLACK_ALLOW_FROM` | O | 허용할 Slack 사용자 ID (쉼표 구분) |
|
|
32
|
+
| `SLACK_WORKSPACE` | O | Slack 워크스페이스 서브도메인 (예: `msuniverse`). permalink 생성에 사용 |
|
|
33
|
+
| `SLACK_BOT_OWNER` | - | 봇 소유자 사용자 ID. allowlist 없이도 항상 허용 |
|
|
34
|
+
| `SLACK_ACK_REACTION` | - | 수신 확인 이모지 (예: `eyes`). 답장 후 자동 제거 |
|
|
35
|
+
| `SLACK_DEFAULT_COLOR` | - | 메시지 사이드바 색상 hex (기본: `#e5da9a`) |
|
|
36
|
+
| `SLACK_SHOW_FOOTER` | - | 메시지 하단 footer 표시 여부 (기본: `true`). `false`로 설정 시 숨김 |
|
|
37
|
+
| `SLACK_FORCE_SOCKET_MODE` | - | `1`로 설정 시 플래그 감지를 건너뛰고 Socket Mode 강제 연결 |
|
|
38
|
+
|
|
39
|
+
## 실행
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
claude --dangerously-load-development-channels server:slack-channel
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
허용된 사용자는 DM이든 채널이든 어디서든 봇과 대화할 수 있습니다.
|
|
4
46
|
|
|
5
47
|
## 아키텍처
|
|
6
48
|
|
|
@@ -8,22 +50,22 @@ Claude Code 세션과 Slack을 양방향으로 연결합니다. Slack DM이나
|
|
|
8
50
|
┌─────────────────────────────────────────────────────────────────┐
|
|
9
51
|
│ Claude Code (Host) │
|
|
10
52
|
│ │
|
|
11
|
-
│ Claude LLM
|
|
12
|
-
│ │
|
|
53
|
+
│ Claude LLM <──── <channel> notification <──── stdout │
|
|
54
|
+
│ │ ^ │
|
|
13
55
|
│ │ tool call (reply/react/...) │ │
|
|
14
|
-
│
|
|
15
|
-
│ stdin
|
|
16
|
-
│ │
|
|
56
|
+
│ v │ │
|
|
57
|
+
│ stdin ────> server.ts (MCP Server) ────> mcp.notification() │
|
|
58
|
+
│ │ ^ │
|
|
17
59
|
│ │ │ │
|
|
18
|
-
│
|
|
60
|
+
│ v │ │
|
|
19
61
|
│ Slack Web API Socket Mode (WebSocket) │
|
|
20
62
|
│ │ │ │
|
|
21
63
|
└────────────────────┼───────────┼────────────────────────────────┘
|
|
22
64
|
│ │
|
|
23
|
-
|
|
65
|
+
v │
|
|
24
66
|
┌──────────────────┴──────┐
|
|
25
|
-
│ Slack Platform
|
|
26
|
-
│
|
|
67
|
+
│ Slack Platform │
|
|
68
|
+
│ (채널, DM, 스레드) │
|
|
27
69
|
└─────────────────────────┘
|
|
28
70
|
```
|
|
29
71
|
|
|
@@ -37,49 +79,141 @@ Claude Code 세션과 Slack을 양방향으로 연결합니다. Slack DM이나
|
|
|
37
79
|
```
|
|
38
80
|
main()
|
|
39
81
|
│
|
|
40
|
-
├──
|
|
82
|
+
├── hasChannelsFlag()
|
|
83
|
+
│ └── 부모 프로세스(Claude Code)의 command line에서
|
|
84
|
+
│ --dangerously-load-development-channels 또는 --channels 확인
|
|
85
|
+
│
|
|
86
|
+
├── [플래그 있음] startSocketMode()
|
|
87
|
+
│ ├── killPreviousInstance() → 기존 인스턴스 종료 (PID 파일)
|
|
88
|
+
│ ├── writePidFile() → 자신의 PID 기록
|
|
41
89
|
│ ├── web.auth.test() → botUserId 확인
|
|
42
|
-
│ │ └── 실패
|
|
90
|
+
│ │ └── 실패 → process.exit(1)
|
|
43
91
|
│ └── socket.start() → Slack 이벤트 수신 시작
|
|
44
92
|
│
|
|
93
|
+
├── [플래그 없음] Socket Mode 스킵 (Tools-only mode)
|
|
94
|
+
│
|
|
45
95
|
└── mcp.connect(transport) → MCP stdio 연결
|
|
46
96
|
```
|
|
47
97
|
|
|
48
|
-
Socket Mode는
|
|
98
|
+
Socket Mode는 부모 프로세스(Claude Code)에 channels 플래그가 있을 때만 연결됩니다. 플래그 없이 실행된 세션은 도구만 제공하며 Socket Mode를 건드리지 않으므로, 기존 channels 세션의 연결이 보호됩니다.
|
|
99
|
+
|
|
100
|
+
### Channels 플래그 감지 (`hasChannelsFlag`)
|
|
101
|
+
|
|
102
|
+
Claude Code는 `--dangerously-load-development-channels` 또는 `--channels` 플래그와 함께 실행될 때만 채널 기능을 사용합니다. 이 MCP 서버는 부모 프로세스의 커맨드라인을 검사하여 플래그 존재 여부를 판단합니다.
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
hasChannelsFlag()
|
|
106
|
+
│
|
|
107
|
+
├── SLACK_FORCE_SOCKET_MODE=1 ? ──── true (환경변수 오버라이드)
|
|
108
|
+
│
|
|
109
|
+
└── findAncestorClaudeCommand()
|
|
110
|
+
│
|
|
111
|
+
│ process.ppid부터 시작, 최대 5단계 탐색
|
|
112
|
+
│
|
|
113
|
+
v
|
|
114
|
+
┌──────────────────────────────────────────────────────┐
|
|
115
|
+
│ PID 1234 (bun server.ts) │
|
|
116
|
+
│ └── ppid -> PID 1230 │
|
|
117
|
+
│ │
|
|
118
|
+
│ PID 1230 (node ...) │
|
|
119
|
+
│ └── ppid -> PID 1225 │
|
|
120
|
+
│ │
|
|
121
|
+
│ PID 1225 (claude --channels server:slack) <── 발견! │
|
|
122
|
+
│ └── 커맨드라인에 "channels" 포함 -> true │
|
|
123
|
+
└──────────────────────────────────────────────────────┘
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**프로세스 정보 조회 방법:**
|
|
127
|
+
|
|
128
|
+
| 플랫폼 | 명령 |
|
|
129
|
+
|---|---|
|
|
130
|
+
| macOS / Linux | `ps -o ppid=,command= -p <pid>` |
|
|
131
|
+
| Windows | `wmic process where ProcessId=<pid> get ParentProcessId,CommandLine /format:csv` |
|
|
132
|
+
|
|
133
|
+
각 단계에서 부모 PID와 커맨드라인을 함께 가져와, `claude`라는 이름의 프로세스를 찾으면 그 커맨드라인에서 플래그를 확인합니다. 5단계 안에 찾지 못하면 `false`를 반환하며, Socket Mode를 시작하지 않습니다.
|
|
134
|
+
|
|
135
|
+
**왜 이것이 중요한가:**
|
|
136
|
+
|
|
137
|
+
Claude Code는 하나의 앱에 대해 여러 세션을 열 수 있습니다. 플래그 없이 실행된 세션이 Socket Mode에 연결하면, 이미 연결된 channels 세션의 WebSocket을 끊어버립니다. 플래그 검사를 통해 일반 세션은 tools-only 모드로 동작하여 기존 channels 세션을 보호합니다.
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
세션 A: claude --channels server:slack <- Socket Mode 연결 O
|
|
141
|
+
세션 B: claude <- Socket Mode 스킵 (tools-only)
|
|
142
|
+
세션 C: claude <- Socket Mode 스킵 (tools-only)
|
|
143
|
+
세션 A의 연결은 안전하게 유지됨
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Socket Preemption (PID 파일 기반 단일 인스턴스 보장)
|
|
147
|
+
|
|
148
|
+
Slack Socket Mode는 **앱당 하나의 WebSocket 연결만** 허용합니다. 두 번째 연결이 생기면 첫 번째가 끊기며 이벤트가 유실될 수 있습니다. PID 파일(`~/.claude/channels/slack/socket.pid`)을 사용하여 항상 최신 인스턴스만 Socket Mode를 유지합니다.
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
인스턴스 A 시작 인스턴스 B 시작
|
|
152
|
+
│ │
|
|
153
|
+
v │
|
|
154
|
+
PID 파일 없음 │
|
|
155
|
+
│ │
|
|
156
|
+
v │
|
|
157
|
+
socket.pid <- A.pid 기록 │
|
|
158
|
+
│ │
|
|
159
|
+
v │
|
|
160
|
+
Socket Mode 연결 O │
|
|
161
|
+
이벤트 수신 중... │
|
|
162
|
+
│ v
|
|
163
|
+
│ socket.pid 읽기 -> A.pid
|
|
164
|
+
│ │
|
|
165
|
+
│ v
|
|
166
|
+
│ kill(A.pid, 0) -> 생존 확인
|
|
167
|
+
│ │
|
|
168
|
+
│<──── SIGTERM ─────────────── kill(A.pid, SIGTERM)
|
|
169
|
+
│ │
|
|
170
|
+
v v
|
|
171
|
+
cleanupPidFile() socket.pid <- B.pid 기록
|
|
172
|
+
PID 파일 정리 (자기 PID일 때만) │
|
|
173
|
+
│ v
|
|
174
|
+
v Socket Mode 연결 O
|
|
175
|
+
프로세스 종료 이벤트 수신 중...
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**PID 파일 안전 장치:**
|
|
179
|
+
|
|
180
|
+
- **생존 확인**: `process.kill(oldPid, 0)` — signal 0은 프로세스를 죽이지 않고 존재 여부만 확인합니다. 이미 종료된 프로세스에 SIGTERM을 보내는 것을 방지합니다.
|
|
181
|
+
- **소유권 확인**: `cleanupPidFile()`은 PID 파일의 내용이 자신의 PID일 때만 삭제합니다. 새 인스턴스가 이미 PID를 덮어썼다면 건드리지 않습니다.
|
|
182
|
+
- **종료 핸들링**: `exit`, `SIGTERM`, `SIGINT` 세 가지 시그널 모두에서 PID 파일을 정리합니다.
|
|
49
183
|
|
|
50
184
|
## 인바운드 메시지 파이프라인
|
|
51
185
|
|
|
52
|
-
Slack에서 메시지가 들어오면
|
|
186
|
+
Slack에서 메시지가 들어오면 5단계 파이프라인을 거칩니다. 각 단계에서 조건을 만족하지 않으면 메시지를 drop합니다.
|
|
53
187
|
|
|
54
188
|
```
|
|
55
189
|
Slack Event (message / app_mention)
|
|
56
190
|
│
|
|
57
|
-
|
|
191
|
+
v
|
|
58
192
|
┌─────────────┐
|
|
59
193
|
│ 1. Dedup │──── 이미 처리한 이벤트? ──── drop
|
|
60
194
|
└──────┬──────┘
|
|
61
195
|
│ 새 이벤트
|
|
62
|
-
|
|
196
|
+
v
|
|
63
197
|
┌─────────────┐
|
|
64
198
|
│ 2. Stale │──── 10분 이상 된 이벤트? ──── drop
|
|
65
199
|
└──────┬──────┘
|
|
66
200
|
│ 최근 이벤트
|
|
67
|
-
|
|
201
|
+
v
|
|
68
202
|
┌─────────────┐
|
|
69
203
|
│ 3. Empty │──── 텍스트/파일/블록 없음? ──── drop
|
|
70
204
|
└──────┬──────┘
|
|
71
205
|
│ 내용 있음
|
|
72
|
-
|
|
206
|
+
v
|
|
73
207
|
┌─────────────┐
|
|
74
208
|
│ 4. Gate │──── 봇 자신? subtype 불허? ──── drop
|
|
75
|
-
│ (접근제어)
|
|
209
|
+
│ (접근제어) │──── allowlist에 없음? ──────── drop
|
|
76
210
|
└──────┬──────┘
|
|
77
211
|
│ 허용된 사용자
|
|
78
|
-
|
|
212
|
+
v
|
|
79
213
|
┌─────────────┐
|
|
80
214
|
│ 5. Deliver │──── permalink 생성
|
|
81
215
|
│ │──── ack reaction 추가
|
|
82
|
-
│ │──── mcp.notification()
|
|
216
|
+
│ │──── mcp.notification() -> Claude
|
|
83
217
|
└─────────────┘
|
|
84
218
|
```
|
|
85
219
|
|
|
@@ -87,106 +221,78 @@ Slack Event (message / app_mention)
|
|
|
87
221
|
|
|
88
222
|
Claude가 사용할 수 있는 MCP 도구:
|
|
89
223
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
│
|
|
97
|
-
├── react ─────────── 이모지 리액션 추가
|
|
98
|
-
│ reactions.add
|
|
99
|
-
│
|
|
100
|
-
├── delete_bot_message ── 봇 메시지 삭제
|
|
101
|
-
│ chat.delete (자기 메시지만)
|
|
102
|
-
│
|
|
103
|
-
└── fetch_dm_thread ── DM 스레드 읽기
|
|
104
|
-
conversations.replies
|
|
105
|
-
(is_dm=true일 때만 사용)
|
|
106
|
-
```
|
|
224
|
+
| 도구 | 기능 | Slack API |
|
|
225
|
+
|---|---|---|
|
|
226
|
+
| `reply` | 메시지 전송 (mrkdwn 포맷 변환, ack reaction 자동 제거) | `chat.postMessage` |
|
|
227
|
+
| `react` | 이모지 리액션 추가 | `reactions.add` |
|
|
228
|
+
| `delete_bot_message` | 봇 자신의 메시지 삭제 | `chat.delete` |
|
|
229
|
+
| `fetch_dm_thread` | DM 스레드 내용 읽기 (`is_dm=true`일 때만 사용) | `conversations.replies` |
|
|
107
230
|
|
|
108
231
|
모든 아웃바운드 호출은 **audit log**에 기록되며, **outbound gate**를 통과해야 합니다 (인바운드를 받은 채널에만 응답 가능).
|
|
109
232
|
|
|
110
|
-
## 보안
|
|
233
|
+
## 보안
|
|
111
234
|
|
|
112
235
|
```
|
|
113
|
-
|
|
114
|
-
│ Inbound Security
|
|
115
|
-
│
|
|
116
|
-
│ Allowlist Gate ── SLACK_ALLOW_FROM에 등록된
|
|
117
|
-
│ 사용자만 Claude에 메시지 전달
|
|
118
|
-
│
|
|
119
|
-
│ Bot Owner ────── SLACK_BOT_OWNER는 항상 허용
|
|
120
|
-
│
|
|
121
|
-
│ Self-loop ────── 봇 자신의 메시지 자동 drop
|
|
122
|
-
│
|
|
123
|
-
│ Dedup ────────── 중복 이벤트 필터링 (TTL 10분)
|
|
124
|
-
│
|
|
125
|
-
|
|
126
|
-
│
|
|
127
|
-
│
|
|
128
|
-
│ Outbound Gate ── 인바운드 수신 이력이 있는
|
|
129
|
-
│ 채널에만 응답 허용
|
|
130
|
-
│
|
|
131
|
-
│ Prompt Hardening ── 시스템 프롬프트에서
|
|
132
|
-
│ 설정 변경 요청 거부
|
|
133
|
-
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
## 프로젝트 구조
|
|
137
|
-
|
|
138
|
-
```
|
|
139
|
-
server.ts MCP 서버, Slack 클라이언트, 이벤트 핸들링
|
|
140
|
-
tools.ts MCP 도구 등록 (reply, react, delete_bot_message, fetch_dm_thread)
|
|
141
|
-
lib/
|
|
142
|
-
gate.ts 접근 제어 (Access, GateOptions, gate, clientSupportsChannels)
|
|
143
|
-
security.ts 아웃바운드 게이트 (assertOutboundAllowed)
|
|
144
|
-
formatting.ts Slack mrkdwn 변환, 메시지 텍스트 추출
|
|
145
|
-
audit.ts 감사 로그 (AuditEntry, auditLog)
|
|
146
|
-
event.ts DM 판별, 스레드 해석, stale/empty 필터링
|
|
147
|
-
resilience.ts 이벤트 중복 제거 (EventDeduplicator)
|
|
148
|
-
permalink.ts Slack 퍼마링크 빌더
|
|
149
|
-
index.ts barrel re-export
|
|
150
|
-
server.test.ts lib/ 테스트 (bun:test, 85개)
|
|
236
|
+
┌───────────────────────────────────────────────────────┐
|
|
237
|
+
│ Inbound Security │
|
|
238
|
+
│ │
|
|
239
|
+
│ Allowlist Gate ── SLACK_ALLOW_FROM에 등록된 │
|
|
240
|
+
│ 사용자만 Claude에 메시지 전달 │
|
|
241
|
+
│ │
|
|
242
|
+
│ Bot Owner ────── SLACK_BOT_OWNER는 항상 허용 │
|
|
243
|
+
│ │
|
|
244
|
+
│ Self-loop ────── 봇 자신의 메시지 자동 drop │
|
|
245
|
+
│ │
|
|
246
|
+
│ Dedup ────────── 중복 이벤트 필터링 (TTL 10분) │
|
|
247
|
+
│ │
|
|
248
|
+
├───────────────────────────────────────────────────────┤
|
|
249
|
+
│ Outbound Security │
|
|
250
|
+
│ │
|
|
251
|
+
│ Outbound Gate ── 인바운드 수신 이력이 있는 │
|
|
252
|
+
│ 채널에만 응답 허용 │
|
|
253
|
+
│ │
|
|
254
|
+
│ Prompt Hardening ── 시스템 프롬프트에서 │
|
|
255
|
+
│ 설정 변경 요청 거부 │
|
|
256
|
+
└───────────────────────────────────────────────────────┘
|
|
151
257
|
```
|
|
152
258
|
|
|
153
259
|
## 데이터 흐름 상세
|
|
154
260
|
|
|
155
|
-
### Permission Request
|
|
261
|
+
### Permission Request
|
|
156
262
|
|
|
157
263
|
도구 실행에 권한 확인이 필요할 때 Slack으로 알림을 보냅니다:
|
|
158
264
|
|
|
159
265
|
```
|
|
160
|
-
Claude Code
|
|
266
|
+
Claude Code ──> permission_request notification
|
|
161
267
|
│
|
|
162
|
-
|
|
268
|
+
v
|
|
163
269
|
server.ts 수신
|
|
164
270
|
│
|
|
165
|
-
|
|
271
|
+
v
|
|
166
272
|
마지막 inbound 채널/스레드로
|
|
167
273
|
Slack 알림 전송 (경고색 #f0ad4e)
|
|
168
274
|
│
|
|
169
|
-
|
|
275
|
+
v
|
|
170
276
|
사용자가 Slack에서 확인
|
|
171
277
|
(승인/거부는 터미널에서)
|
|
172
278
|
```
|
|
173
279
|
|
|
174
|
-
### Ack Reaction
|
|
280
|
+
### Ack Reaction
|
|
175
281
|
|
|
176
282
|
메시지 수신 확인과 답장 완료를 이모지로 표시합니다:
|
|
177
283
|
|
|
178
284
|
```
|
|
179
|
-
메시지 수신
|
|
285
|
+
메시지 수신 ──> ack reaction 추가 (예: eyes)
|
|
180
286
|
│
|
|
181
|
-
|
|
287
|
+
v
|
|
182
288
|
Claude가 처리 중...
|
|
183
289
|
│
|
|
184
|
-
|
|
290
|
+
v
|
|
185
291
|
reply 도구 호출 시
|
|
186
292
|
ack reaction 자동 제거
|
|
187
293
|
```
|
|
188
294
|
|
|
189
|
-
##
|
|
295
|
+
## Slack App 설정
|
|
190
296
|
|
|
191
297
|
이미 Slack App이 있다면 (예: slack MCP 서버용), 같은 앱에 아래 설정을 추가합니다.
|
|
192
298
|
|
|
@@ -218,50 +324,28 @@ Claude Code ──► permission_request notification
|
|
|
218
324
|
|
|
219
325
|
**OAuth & Permissions** → **Reinstall to Workspace** → Bot Token (`xoxb-...`) 복사
|
|
220
326
|
|
|
221
|
-
##
|
|
222
|
-
|
|
223
|
-
프로젝트 `.mcp.json` 또는 `~/.claude.json`에 추가합니다:
|
|
327
|
+
## 프로젝트 구조
|
|
224
328
|
|
|
225
|
-
```json
|
|
226
|
-
{
|
|
227
|
-
"mcpServers": {
|
|
228
|
-
"slack-channel": {
|
|
229
|
-
"command": "bunx",
|
|
230
|
-
"args": ["@rokrokss/claude-slack-channel"],
|
|
231
|
-
"env": {
|
|
232
|
-
"SLACK_BOT_TOKEN": "xoxb-...",
|
|
233
|
-
"SLACK_APP_TOKEN": "xapp-...",
|
|
234
|
-
"SLACK_ALLOW_FROM": "U123,U456",
|
|
235
|
-
"SLACK_WORKSPACE": "your-workspace",
|
|
236
|
-
"SLACK_ACK_REACTION": "eyes"
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
329
|
```
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
## 실행
|
|
256
|
-
|
|
257
|
-
```bash
|
|
258
|
-
claude --dangerously-load-development-channels server:slack-channel
|
|
330
|
+
server.ts MCP 서버, Slack 클라이언트, 이벤트 핸들링
|
|
331
|
+
tools.ts MCP 도구 등록 (reply, react, delete_bot_message, fetch_dm_thread)
|
|
332
|
+
lib/
|
|
333
|
+
gate.ts 접근 제어 (Access, GateOptions, gate)
|
|
334
|
+
process.ts 부모 프로세스 channels 플래그 감지 (hasChannelsFlag)
|
|
335
|
+
security.ts 아웃바운드 게이트 (assertOutboundAllowed)
|
|
336
|
+
formatting.ts Slack mrkdwn 변환, 메시지 텍스트 추출
|
|
337
|
+
audit.ts 감사 로그 (AuditEntry, auditLog)
|
|
338
|
+
event.ts DM 판별, 스레드 해석, stale/empty 필터링
|
|
339
|
+
resilience.ts 이벤트 중복 제거 (EventDeduplicator)
|
|
340
|
+
permalink.ts Slack 퍼마링크 빌더
|
|
341
|
+
index.ts barrel re-export
|
|
342
|
+
server.test.ts lib/ 테스트 (bun:test)
|
|
259
343
|
```
|
|
260
344
|
|
|
261
345
|
## 개발
|
|
262
346
|
|
|
263
347
|
```bash
|
|
264
|
-
bun test # 테스트 실행
|
|
348
|
+
bun test # 테스트 실행
|
|
265
349
|
bun run typecheck # 타입 체크
|
|
266
350
|
```
|
|
267
351
|
|
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'
|
|
@@ -73,7 +78,9 @@ const allowFromList = (process.env['SLACK_ALLOW_FROM'] || '')
|
|
|
73
78
|
const ackReaction = (process.env['SLACK_ACK_REACTION'] || '').trim().replace(/^:|:$/g, '') || undefined
|
|
74
79
|
console.error(`[slack] ackReaction: ${ackReaction ?? '(disabled)'}`)
|
|
75
80
|
const botOwner = (process.env['SLACK_BOT_OWNER'] || '').trim() || undefined
|
|
81
|
+
const showFooter = (process.env['SLACK_SHOW_FOOTER'] ?? 'true').trim().toLowerCase() !== 'false'
|
|
76
82
|
console.error(`[slack] botOwner: ${botOwner ?? '(not set)'}`)
|
|
83
|
+
console.error(`[slack] showFooter: ${showFooter}`)
|
|
77
84
|
const workspace = process.env['SLACK_WORKSPACE'] || ''
|
|
78
85
|
if (!workspace) {
|
|
79
86
|
console.error('[slack] SLACK_WORKSPACE is required for permalink generation. Set it in .mcp.json env field.')
|
|
@@ -153,7 +160,7 @@ async function resolveUserName(userId: string): Promise<string> {
|
|
|
153
160
|
// ---------------------------------------------------------------------------
|
|
154
161
|
|
|
155
162
|
const mcp = new McpServer(
|
|
156
|
-
{ name: 'slack-channel', version: '0.
|
|
163
|
+
{ name: 'slack-channel', version: '0.3.0' },
|
|
157
164
|
{
|
|
158
165
|
capabilities: {
|
|
159
166
|
experimental: {
|
|
@@ -190,10 +197,16 @@ registerTools({
|
|
|
190
197
|
web,
|
|
191
198
|
stateDir: STATE_DIR,
|
|
192
199
|
defaultColor: DEFAULT_COLOR,
|
|
200
|
+
botOwner,
|
|
201
|
+
showFooter,
|
|
193
202
|
assertOutboundAllowed,
|
|
194
203
|
lastInboundMessageId,
|
|
195
204
|
pendingAckReactions,
|
|
196
205
|
resolveUserName,
|
|
206
|
+
clearInboundContext: () => {
|
|
207
|
+
lastInboundContext = null
|
|
208
|
+
console.error('[slack] inbound context cleared (reply sent)')
|
|
209
|
+
},
|
|
197
210
|
})
|
|
198
211
|
|
|
199
212
|
// ---------------------------------------------------------------------------
|
|
@@ -201,6 +214,7 @@ registerTools({
|
|
|
201
214
|
// ---------------------------------------------------------------------------
|
|
202
215
|
|
|
203
216
|
// 마지막 inbound 메시지의 채널+스레드를 추적하여 permission 알림 전송 대상으로 사용
|
|
217
|
+
// reply 도구 호출 시 초기화 — Slack 대화 처리 완료로 간주
|
|
204
218
|
let lastInboundContext: { channelId: string; threadTs: string } | null = null
|
|
205
219
|
|
|
206
220
|
mcp.server.setNotificationHandler(
|
|
@@ -224,13 +238,13 @@ mcp.server.setNotificationHandler(
|
|
|
224
238
|
|
|
225
239
|
const { channelId, threadTs } = lastInboundContext
|
|
226
240
|
try {
|
|
227
|
-
const ownerTag = botOwner ? ` <@${botOwner}>` : ''
|
|
228
241
|
await web.chat.postMessage({
|
|
229
242
|
channel: channelId,
|
|
230
243
|
thread_ts: threadTs,
|
|
231
244
|
attachments: [{
|
|
232
245
|
color: '#f0ad4e',
|
|
233
|
-
text: `터미널에서 도구 실행 권한 확인이
|
|
246
|
+
text: `터미널에서 도구 실행 권한 확인이 필요합니다.\n\`${tool_name}\`: ${description}`,
|
|
247
|
+
...(showFooter && botOwner ? { footer: `다음 사용자가 만듬 <@${botOwner}>` } : {}),
|
|
234
248
|
mrkdwn_in: ['text'],
|
|
235
249
|
}],
|
|
236
250
|
unfurl_links: false,
|
|
@@ -374,7 +388,43 @@ socket.on('app_mention', async ({ event, ack }) => {
|
|
|
374
388
|
// Startup
|
|
375
389
|
// ---------------------------------------------------------------------------
|
|
376
390
|
|
|
391
|
+
const PID_FILE = join(STATE_DIR, 'socket.pid')
|
|
392
|
+
|
|
393
|
+
function killPreviousInstance(): void {
|
|
394
|
+
if (!existsSync(PID_FILE)) return
|
|
395
|
+
try {
|
|
396
|
+
const oldPid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10)
|
|
397
|
+
if (isNaN(oldPid) || oldPid === process.pid) return
|
|
398
|
+
// Check if process is alive (signal 0 doesn't kill, just checks)
|
|
399
|
+
process.kill(oldPid, 0)
|
|
400
|
+
debugLog(`[slack] Killing previous Socket Mode instance (pid ${oldPid})`)
|
|
401
|
+
process.kill(oldPid, 'SIGTERM')
|
|
402
|
+
} catch {
|
|
403
|
+
// Process doesn't exist or no permission — safe to proceed
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function writePidFile(): void {
|
|
408
|
+
writeFileSync(PID_FILE, String(process.pid), 'utf-8')
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function cleanupPidFile(): void {
|
|
412
|
+
try {
|
|
413
|
+
if (existsSync(PID_FILE) && readFileSync(PID_FILE, 'utf-8').trim() === String(process.pid)) {
|
|
414
|
+
unlinkSync(PID_FILE)
|
|
415
|
+
}
|
|
416
|
+
} catch {}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
process.on('exit', cleanupPidFile)
|
|
420
|
+
process.on('SIGTERM', () => { cleanupPidFile(); process.exit(0) })
|
|
421
|
+
process.on('SIGINT', () => { cleanupPidFile(); process.exit(0) })
|
|
422
|
+
|
|
377
423
|
async function startSocketMode(): Promise<void> {
|
|
424
|
+
// Kill previous instance if still running
|
|
425
|
+
killPreviousInstance()
|
|
426
|
+
writePidFile()
|
|
427
|
+
|
|
378
428
|
// Resolve bot's own user ID (for mention detection + self-filtering)
|
|
379
429
|
try {
|
|
380
430
|
const auth = await web.auth.test()
|
|
@@ -390,17 +440,15 @@ async function startSocketMode(): Promise<void> {
|
|
|
390
440
|
}
|
|
391
441
|
|
|
392
442
|
async function main(): Promise<void> {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
})
|
|
443
|
+
if (hasChannelsFlag()) {
|
|
444
|
+
await startSocketMode().catch((err) => {
|
|
445
|
+
console.error('[slack] Socket Mode init failed:', err)
|
|
446
|
+
process.exit(1)
|
|
447
|
+
})
|
|
448
|
+
} else {
|
|
449
|
+
debugLog('[slack] Socket Mode skipped — parent process has no channels flag. Tools-only mode.')
|
|
450
|
+
}
|
|
402
451
|
|
|
403
|
-
// Connect MCP stdio (server ↔ Claude Code)
|
|
404
452
|
const transport = new StdioServerTransport()
|
|
405
453
|
await mcp.connect(transport)
|
|
406
454
|
debugLog('[slack] MCP server running on stdio')
|
package/tools.ts
CHANGED
|
@@ -8,14 +8,17 @@ export interface ToolDependencies {
|
|
|
8
8
|
web: WebClient
|
|
9
9
|
stateDir: string
|
|
10
10
|
defaultColor: string
|
|
11
|
+
botOwner?: string
|
|
12
|
+
showFooter: boolean
|
|
11
13
|
assertOutboundAllowed: (chatId: string) => void
|
|
12
14
|
lastInboundMessageId: Map<string, string>
|
|
13
15
|
pendingAckReactions: Map<string, { channel: string; ts: string; emoji: string }>
|
|
14
16
|
resolveUserName: (userId: string) => Promise<string>
|
|
17
|
+
clearInboundContext: () => void
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
export function registerTools(deps: ToolDependencies): void {
|
|
18
|
-
const { mcp, web, stateDir, defaultColor, assertOutboundAllowed, lastInboundMessageId, pendingAckReactions, resolveUserName } = deps
|
|
21
|
+
const { mcp, web, stateDir, defaultColor, botOwner, showFooter, assertOutboundAllowed, lastInboundMessageId, pendingAckReactions, resolveUserName, clearInboundContext } = deps
|
|
19
22
|
|
|
20
23
|
mcp.registerTool('reply', {
|
|
21
24
|
description: 'Send a message to a Slack channel or DM.',
|
|
@@ -48,6 +51,7 @@ export function registerTools(deps: ToolDependencies): void {
|
|
|
48
51
|
color,
|
|
49
52
|
text: fixSlackMrkdwn(args.text),
|
|
50
53
|
mrkdwn_in: ['text'],
|
|
54
|
+
...(showFooter && botOwner ? { footer: `다음 사용자가 만듬 <@${botOwner}>` } : {}),
|
|
51
55
|
}],
|
|
52
56
|
unfurl_links: false,
|
|
53
57
|
unfurl_media: false,
|
|
@@ -70,6 +74,9 @@ export function registerTools(deps: ToolDependencies): void {
|
|
|
70
74
|
} catch (err) { console.error('[slack] ack reaction auto-remove failed:', err) }
|
|
71
75
|
}
|
|
72
76
|
|
|
77
|
+
// Slack 대화 처리 완료로 간주 — permission_request 알림 대상 초기화
|
|
78
|
+
clearInboundContext()
|
|
79
|
+
|
|
73
80
|
return {
|
|
74
81
|
content: [{
|
|
75
82
|
type: 'text' as const,
|