SLOPSHOPPER

discord-connector

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

newguardprocessagents
A shopper browsing a rack in a slop shop
README

discord-connector

Discord 에서 봇을 멘션하면 Claude 가 일하고 결과를 같은 스레드에 답장해요. 실행 중 Claude 가 질문하면 스레드에 버튼(multiSelect 는 선택 메뉴)이 올라오고, 요청자가 누른 답으로 같은 실행이 이어져요.

제품 동작의 기준은 spec/DESIGN.md 예요.

구성

파일역할
bin/launch.mjsdiscord CLI 의 on_message 훅이 실행하는 런처. 스레드 만들기, 스레드별 잠금, claude -p 실행, 결과 게시
hooks/register.ts, hooks/logic.ts실행 중 AskUserQuestion 을 Discord 질문으로 중계, auto 모드 권한 거부 알림
commands/setup.md~/.local/bin/discord-connector-launch shim 설치와 CLI 설정 안내

사전 조건

  • Node 22 이상, discord CLI (릴리스 전 버전은 계약 문서 spec/concerns/discord-cli-contract.md 기준)
  • 봇 토큰은 discord CLI 설정 파일(~/.areum/discord/config.json)에 있어야 해요 (echo "$TOKEN" | discord init). 훅 실행 환경에는 DISCORD_BOT_TOKEN 이 넘어오지 않아요.
  • discord 데몬이 떠 있어야 버튼 질문(ask)이 동작해요.

설치

  1. 마켓플레이스로 플러그인을 설치해요.
  2. /discord-connector:setup 을 실행해 shim 을 설치해요.
  3. ~/.areum/discord/config.json 에 on_message, default_workdir, workdirs 를 설정하고 데몬을 재시작해요. 예시는 setup 커맨드가 보여 줘요.

상태와 로그

~/.areum/discord-connector/ 아래에 저장해요.

경로내용
threads/<스레드 ID>.jsonsession_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 로 연결해요.

스레드별 worktree

작업 디렉토리가 git 저장소면 스레드마다 <저장소>/.claude/worktrees/discord-<스레드 ID> 에서 실행해요. 브랜치는 worktree-discord-<스레드 ID> 예요. 런처는 worktree 를 자동으로 지우지 않아요. 아래 절차로 직접 정리해요.

  • 대상 저장소가 .claude/worktrees/ 를 무시하지 않으면 그 저장소의 .gitignore 에 .claude/worktrees/ 를 추가해요.
  • 남은 worktree 확인: 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 이 로드돼요.
  • 런처와 hook 은 같은 표식 env(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)가 필요해요.

Source 2 files
hooks/register.ts 189 lines
1import 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}
189
hooks/logic.ts 141 lines
1export 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