Discord 멘션을 headless Claude 실행으로 연결하고, 실행 중 질문을 Discord 버튼으로 중계하는 connector

Discord 에서 봇을 멘션하면 Claude 가 일하고 결과를 같은 스레드에 답장해요. 실행 중 Claude 가 질문하면 스레드에 버튼(multiSelect 는 선택 메뉴)이 올라오고, 요청자가 누른 답으로 같은 실행이 이어져요.
제품 동작의 기준은 spec/DESIGN.md 예요.
| 파일 | 역할 |
|---|---|
bin/launch.mjs | discord CLI 의 on_message 훅이 실행하는 런처. 스레드 만들기, 스레드별 잠금, claude -p 실행, 결과 게시 |
hooks/register.ts, hooks/logic.ts | 실행 중 AskUserQuestion 을 Discord 질문으로 중계, auto 모드 권한 거부 알림 |
commands/setup.md | ~/.local/bin/discord-connector-launch shim 설치와 CLI 설정 안내 |
discord CLI (릴리스 전 버전은 계약 문서 spec/concerns/discord-cli-contract.md 기준)discord CLI 설정 파일(~/.areum/discord/config.json)에 있어야 해요 (echo "$TOKEN" | discord init). 훅 실행 환경에는 DISCORD_BOT_TOKEN 이 넘어오지 않아요.discord 데몬이 떠 있어야 버튼 질문(ask)이 동작해요./discord-connector:setup 을 실행해 shim 을 설치해요.~/.areum/discord/config.json 에 on_message, default_workdir, workdirs 를 설정하고 데몬을 재시작해요. 예시는 setup 커맨드가 보여 줘요.~/.areum/discord-connector/ 아래에 저장해요.
| 경로 | 내용 |
|---|---|
threads/<스레드 ID>.json | session_id, 작업 공간 경로(cwd), worktree 여부(worktree) |
locks/<스레드 ID>.lock | 실행 중인 런처의 pid. 죽은 pid 의 잠금은 다음 실행이 회수해요 |
logs/launcher.log | 런처 로그 (claude JSON 결과 포함). 훅 stdout 은 버려지고 stderr 는 안 보이니 사후 단서는 이 파일뿐이에요 |
런처는 아래처럼 claude 를 실행해요. 모델은 sonnet 고정이고, --permission-prompt-tool stdio 가 있어야 AskUserQuestion 이 도구 목록에 들어와요.
claude -p <프롬프트> --permission-mode auto --permission-prompt-tool stdio --output-format json --model sonnet [--resume <session_id>] [--worktree discord-<스레드 ID>] --plugin-dir <이 플러그인 루트>
--worktree 는 작업 디렉토리가 git 저장소일 때만 붙어요. 저장소가 아니면 그 디렉토리에서 바로 실행해요. claude 의 stdin 은 /dev/null 로 연결해요.
작업 디렉토리가 git 저장소면 스레드마다 <저장소>/.claude/worktrees/discord-<스레드 ID> 에서 실행해요. 브랜치는 worktree-discord-<스레드 ID> 예요. 런처는 worktree 를 자동으로 지우지 않아요. 아래 절차로 직접 정리해요.
.claude/worktrees/ 를 무시하지 않으면 그 저장소의 .gitignore 에 .claude/worktrees/ 를 추가해요.git -C <저장소> worktree list<저장소> 와 <스레드 ID> 만 바꿔요.git -C <저장소> worktree unlock <저장소>/.claude/worktrees/discord-<스레드 ID>
git -C <저장소> worktree remove --force <저장소>/.claude/worktrees/discord-<스레드 ID>
git -C <저장소> branch -D worktree-discord-<스레드 ID>
remove --force 는 커밋하지 않은 변경도 지워요. 필요한 변경은 먼저 커밋하거나 옮겨 두세요.
--plugin-dir 사본이 설치본을 대신해요 (Claude Code 공식 문서의 이름 충돌 규칙: 활성화된 --plugin-dir 플러그인이 같은 이름의 설치 플러그인보다 우선). hook 이 두 번 로드되지 않아요. 단 조직 managed settings 가 이 플러그인을 enabledPlugins 로 잠가 두면 --plugin-dir 사본은 무시되고 설치본 hook 이 로드돼요.DISCORD_CONNECTOR_THREAD_ID, DISCORD_CONNECTOR_REQUESTER_ID, DISCORD_CONNECTOR_BOT_ID)가 있을 때만 hook 이 개입해요. 터미널 세션에는 영향이 없어요.bin/launch.mjs)와 hook(hooks/logic.ts)에 같은 규칙으로 따로 구현돼 있어요. 언어가 달라 코드를 공유하지 못해요. 규칙을 바꾸면 양쪽 테스트를 함께 고치세요.node --test 'plugins/discord-connector/tests/**/*.test.mjs'
claude plugin test plugins/discord-connector
claude plugin validate plugins/discord-connector
런처 테스트는 가짜 discord·claude 실행 파일(tests/fixtures/fake-bin/)을 PATH 앞에 두고 HOME 을 임시 디렉토리로 바꿔 실행해요. 실제 discord·claude 는 실행하지 않아요. claude plugin test 는 function hook 플래그(CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1)가 필요해요.
hooks/register.ts 189 lines1import type { EngineInterface, On } from 'claude-code'
2
3import {
4 ASK_TIMEOUT_SECONDS,
5 BACKGROUND_BLOCKED,
6 isBackgroundRequest,
7 NO_ANSWER,
8 formatAskCreateFailure,
9 formatPermissionDenied,
10 formatLogLine,
11 interpretAskWait,
12 LOG_RELATIVE_PATH,
13 neutralizeBotMention,
14 parseAskId,
15 planAskCreate,
16 sendArgv,
17 waitArgv,
18 type Question,
19 type RunContext,
20} from './logic'
21
22const CLI_TIMEOUT_MS = 30_000
23const WAIT_TIMEOUT_MS = 600_000
24
25async function writeLog($: EngineInterface, message: string): Promise<void> {
26 try {
27 const home = await $.env.get('HOME')
28
29 if (!home) {
30 return
31 }
32
33 const path = `${home}/${LOG_RELATIVE_PATH}`
34 const prior = (await $.fs.exists(path)) ? String(await $.fs.read(path)) : ''
35
36 await $.fs.write(path, prior + formatLogLine(await $.clock.now(), message))
37 } catch {
38 // 로그 실패가 원래 흐름을 막으면 안 된다
39 }
40}
41
42async function readContext($: EngineInterface): Promise<RunContext | undefined> {
43 const markers = {
44 DISCORD_CONNECTOR_THREAD_ID: await $.env.get('DISCORD_CONNECTOR_THREAD_ID'),
45 DISCORD_CONNECTOR_REQUESTER_ID: await $.env.get('DISCORD_CONNECTOR_REQUESTER_ID'),
46 DISCORD_CONNECTOR_BOT_ID: await $.env.get('DISCORD_CONNECTOR_BOT_ID'),
47 }
48 const missing = Object.entries(markers)
49 .filter(([, value]) => !value)
50 .map(([name]) => name)
51
52 if (missing.length === Object.keys(markers).length) {
53 return undefined
54 }
55
56 if (missing.length > 0) {
57 await writeLog($, `표식 env 계약 위반 — 없는 값: ${missing.join(', ')}. 개입하지 않아요`)
58
59 return undefined
60 }
61
62 return {
63 threadId: markers.DISCORD_CONNECTOR_THREAD_ID as string,
64 requesterId: markers.DISCORD_CONNECTOR_REQUESTER_ID as string,
65 botId: markers.DISCORD_CONNECTOR_BOT_ID as string,
66 }
67}
68
69async function notify($: EngineInterface, ctx: RunContext, text: string): Promise<void> {
70 try {
71 const sent = await $.process.run(sendArgv(ctx.threadId), {
72 stdin: neutralizeBotMention(text, ctx.botId),
73 timeoutMs: CLI_TIMEOUT_MS,
74 })
75
76 if (sent.exitCode !== 0) {
77 await writeLog($, `알림 전송 실패 (exit ${sent.exitCode}): ${(sent.stderr || sent.stdout).slice(-300)}`)
78 }
79 } catch (err) {
80 await writeLog($, `알림 전송 예외: ${String(err)}`)
81 }
82}
83
84async function askOne($: EngineInterface, ctx: RunContext, q: Question): Promise<string> {
85 const deadlineEpochSec = Math.floor((await $.clock.now()) / 1000) + ASK_TIMEOUT_SECONDS
86 const plan = planAskCreate(q, ctx, deadlineEpochSec)
87
88 if ('error' in plan) {
89 await writeLog($, `질문을 만들 수 없어요: ${plan.error}`)
90 await notify($, ctx, formatAskCreateFailure(plan.error))
91
92 return NO_ANSWER
93 }
94
95 let askId: string | undefined
96 let failure = ''
97
98 try {
99 const created = await $.process.run(plan.argv, { timeoutMs: CLI_TIMEOUT_MS })
100
101 askId = created.exitCode === 0 ? parseAskId(created.stdout) : undefined
102
103 if (askId === undefined) {
104 failure = `exit ${created.exitCode}: ${(created.stdout || created.stderr).slice(-300)}`
105 }
106 } catch (err) {
107 failure = String(err)
108 }
109
110 if (askId === undefined) {
111 await writeLog($, `ask create 실패: ${failure}`)
112 await notify($, ctx, formatAskCreateFailure(failure))
113
114 return NO_ANSWER
115 }
116
117 try {
118 const waited = await $.process.run(waitArgv(askId), { timeoutMs: WAIT_TIMEOUT_MS })
119
120 if (waited.exitCode !== 0) {
121 await writeLog($, `ask wait 비정상 종료 (exit ${waited.exitCode}): ${(waited.stderr || waited.stdout).slice(-300)}`)
122
123 return NO_ANSWER
124 }
125
126 const outcome = interpretAskWait(waited.stdout)
127
128 if ('unanswered' in outcome) {
129 await writeLog($, `ask wait 미응답: ${outcome.unanswered}`)
130
131 return NO_ANSWER
132 }
133
134 return outcome.answer
135 } catch (err) {
136 await writeLog($, `ask wait 예외: ${String(err)}`)
137
138 return NO_ANSWER
139 }
140}
141
142export function register(on: On): void {
143 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
144 const ctx = await readContext($)
145
146 if (ctx === undefined) {
147 return next(e)
148 }
149
150 const answers: Record<string, string> = {}
151
152 for (const q of e.questions) {
153 answers[q.question] = await askOne($, ctx, q)
154 }
155
156 return { result: { questions: e.questions, answers } } as never
157 })
158
159 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
160 if (!isBackgroundRequest(e) || (await readContext($)) === undefined) {
161 return next(e)
162 }
163
164 await writeLog($, 'Bash 백그라운드 실행을 막았어요')
165
166 return { deny: BACKGROUND_BLOCKED }
167 })
168
169 on('agent.spawn', async ($, e, next) => {
170 if (!isBackgroundRequest({ run_in_background: e.background }) || (await readContext($)) === undefined) {
171 return next(e)
172 }
173
174 await writeLog($, `서브에이전트 백그라운드 실행을 막았어요 (${e.subagentType})`)
175
176 return { deny: BACKGROUND_BLOCKED }
177 })
178
179 on('classic.PermissionDenied', async ($, e, next) => {
180 const ctx = await readContext($)
181
182 if (ctx !== undefined) {
183 await notify($, ctx, formatPermissionDenied(e.tool_name, e.reason))
184 }
185
186 return next(e)
187 })
188}
189hooks/logic.ts 141 lines1export const NO_ANSWER = 'Discord에서 시간 안에 답이 없었어요'
2
3export const ASK_TIMEOUT_SECONDS = 540
4
5export type RunContext = {
6 threadId: string
7 requesterId: string
8 botId: string
9}
10
11export type Question = {
12 question: string
13 options: readonly { label: string }[]
14 multiSelect: boolean
15}
16
17const LIMITS = {
18 single: { maxOptions: 4, maxLabel: 80 },
19 multi: { maxOptions: 25, maxLabel: 100 },
20} as const
21
22export function neutralizeBotMention(text: string, botId: string): string {
23 return text.split(`<@${botId}>`).join(`@${botId}`).split(`<@!${botId}>`).join(`@${botId}`)
24}
25
26export function sendArgv(threadId: string): string[] {
27 return ['discord', '--json', 'send', threadId, '-', '--split']
28}
29
30export function waitArgv(askId: string): string[] {
31 return ['discord', '--json', 'ask', 'wait', askId, '--timeout', String(ASK_TIMEOUT_SECONDS)]
32}
33
34export type AskCreatePlan = { argv: string[] } | { error: string }
35
36export function planAskCreate(q: Question, ctx: RunContext, deadlineEpochSec: number): AskCreatePlan {
37 const limit = q.multiSelect ? LIMITS.multi : LIMITS.single
38 const kind = q.multiSelect ? '다중 선택' : '단일 선택'
39
40 if (q.options.length < 1 || q.options.length > limit.maxOptions) {
41 return { error: `${kind} 질문의 선택지는 1~${limit.maxOptions}개여야 해요 (받은 개수: ${q.options.length})` }
42 }
43
44 const tooLong = q.options.find(o => o.label.length > limit.maxLabel)
45
46 if (tooLong !== undefined) {
47 return { error: `${kind} 선택지 라벨은 ${limit.maxLabel}자 이하여야 해요: ${tooLong.label.slice(0, 30)}…` }
48 }
49
50 const body = `${neutralizeBotMention(q.question, ctx.botId)}\n마감 <t:${deadlineEpochSec}:t>`
51 const argv = ['discord', '--json', 'ask', 'create', ctx.threadId, body]
52
53 for (const o of q.options) {
54 argv.push('--option', neutralizeBotMention(o.label, ctx.botId))
55 }
56
57 argv.push('--timeout', String(ASK_TIMEOUT_SECONDS), '--allowed-user', ctx.requesterId)
58
59 if (q.multiSelect) {
60 argv.push('--multi-select')
61 }
62
63 return { argv }
64}
65
66function envelopeData(stdout: string): Record<string, unknown> | undefined {
67 let parsed: unknown
68
69 try {
70 parsed = JSON.parse(stdout)
71 } catch {
72 return undefined
73 }
74
75 if (typeof parsed !== 'object' || parsed === null || (parsed as { ok?: unknown }).ok !== true) {
76 return undefined
77 }
78
79 const data = (parsed as { data?: unknown }).data
80
81 return typeof data === 'object' && data !== null ? (data as Record<string, unknown>) : undefined
82}
83
84export function parseAskId(stdout: string): string | undefined {
85 const id = envelopeData(stdout)?.ask_id
86
87 return typeof id === 'string' && id !== '' ? id : undefined
88}
89
90export type AskWaitOutcome = { answer: string } | { unanswered: string }
91
92export function interpretAskWait(stdout: string): AskWaitOutcome {
93 const data = envelopeData(stdout)
94
95 if (data === undefined) {
96 return { unanswered: `응답이 성공 엔벨로프가 아니에요: ${stdout.slice(-300)}` }
97 }
98
99 if (data.status !== 'answered') {
100 return { unanswered: `상태가 answered 가 아니에요: ${String(data.status)}` }
101 }
102
103 const value = data.value
104
105 if (typeof value === 'string') {
106 return { answer: value }
107 }
108
109 if (Array.isArray(value) && value.every(v => typeof v === 'string')) {
110 return { answer: value.join(', ') }
111 }
112
113 return { unanswered: `answered 인데 value 형식이 달라요: ${JSON.stringify(value)}` }
114}
115
116export const LOG_RELATIVE_PATH = '.areum/discord-connector/logs/hook.log'
117
118export function formatLogLine(epochMs: number, message: string): string {
119 return `${new Date(epochMs).toISOString()} ${message}\n`
120}
121
122export function formatAskCreateFailure(reason: string): string {
123 return `질문을 Discord 에 올리지 못했어요: ${reason}\n질문은 답 없이 넘어가요.`
124}
125
126export function formatPermissionDenied(toolName: string, reason: string): string {
127 return [
128 '자동 모드가 도구 실행을 거부했어요.',
129 `- 도구: ${toolName}`,
130 `- 사유: ${reason}`,
131 '다시 진행하려면 이 스레드에서 봇을 멘션해 재지시해 주세요.',
132 ].join('\n')
133}
134
135export const BACKGROUND_BLOCKED =
136 '이 실행은 Discord 에서 시작돼 턴이 끝나면 종료돼요. 백그라운드 결과는 사용자에게 전달되지 않으니 run_in_background 없이 포그라운드로 다시 실행하세요.'
137
138export function isBackgroundRequest(input: { run_in_background?: unknown }): boolean {
139 return input.run_in_background === true
140}
141