Claude Code mod that opens the Slack DMs and group DMs sent to you as conversations in a side pane (/desk-slack): recent messages, replying, closing, and a…

English | 한국어
desk-slack is a Claude Code plugin (mod) that opens the Slack DMs sent to you in a side pane next to the conversation. It comes from the same marketplace as desk-look, which redraws the Claude Code terminal to look like the Claude desktop app. It works on its own; with desk-look, the unread count shows as the ✉ N chip in desk-look's band, /desk-fold folds its pane too and it follows desk-look's language.
At the prompt of a Claude Code terminal session:
/plugin install desk-slack --marketplace KyongSik-Yoon/cc-desktop-mod
It follows the latest Claude Code release, like desk-look. /desk-update from desk-look updates it too; without desk-look, run claude plugin update desk-slack.
/desk-slack: Slack DMsThe DMs and group DMs other people sent you, kept as conversations like the Slack client, read through the Slack MCP server Claude Code already has connected (the claude.ai Slack connector; no token or setting of its own). Messages, @mentions and thread replies in channels are not included (thread replies in a DM are). With no Slack server, the pane says to connect Slack in /mcp.
● when it has messages you have not opened in desk-slack, the people in it, the last message and how long ago. The first time, it fills in the last 3 days as already read↻ (r in every view) reloads, ← Conversations (b, also under the reply box) goes backN replies opens that thread (the message and its latest 30 replies), and a reply written there goes to the thread. ← name (b) goes back to the conversation. When a conversation's last message is a thread reply, clicking it in the list opens that thread directly✕ closes a conversation, and Close all at the top closes every conversation in the list (both need a second press within 5 seconds). A closed conversation comes back when a new message arrives in itHand to Claude fills the prompt with the conversation's recent messages; Open in Slack opens it in SlackslackNotify turns that off). With desk-look, the ✉ N chip in its band shows the unread count and opens the pane. With several sessions open, one of them checks and the others share what it findspermissions.allow in ~/.claude/settings.json; until then it calls nothing (no checking, no notifications) and the pane lists the missing rules. The check runs in the background, where nobody could answer a permission prompt: "mcp__claude_ai_Slack__slack_search_public_and_private", "mcp__claude_ai_Slack__slack_read_channel",
"mcp__claude_ai_Slack__slack_send_message", "mcp__claude_ai_Slack__slack_read_user_profile"
To open threads, add "mcp__claude_ai_Slack__slack_read_thread" too. Without just that rule, checking, notifications and conversations keep working; threads do not open, and the conversation shows the rule to add
The rule allows the same tools for Claude too: Claude can then send Slack messages without asking
desk-slack collects no data and runs no programs. Everything it reads stays on your machine, except what is listed under "Sends".
Reads: Claude Code's language setting, and desk-look's language and /desk-fold state when desk-look is installed.
Sends: only to the Slack MCP server Claude Code already has connected (with Claude Code's own connection and login), but only while all four of its Slack tools (slack_search_public_and_private, slack_read_channel, slack_send_message, slack_read_user_profile) have an allow rule (slack_read_thread, which opens threads, is called only when it has one too): once a minute, from one running session, it searches for DMs and group DMs sent to you since the last check; when you open a conversation or one of its threads it reads its latest 30 messages; when you submit a reply it sends it there; and it reads your Slack user ID once. It keeps the conversation list, your user ID and which session checks in its own plugin store ($.store), and raises a notification for new messages through your notification channel unless slackNotify is off. Without the rules it calls nothing and the pane lists the missing ones (found with $.tool.check). It publishes the unread count for desk-look's ✉ chip.
Every hook below passes the event on with next(e) and only watches the result, except where it says otherwise.
ui.render: draws the /desk-slack panesession.start, session.end: register /desk-slack, start the once-a-minute check, and tell desk-look the plugin is loaded (again after /resume or /clear in the same window)state.set on desk-look's lang and folded: after desk-look's own write goes through, follow its language and fold or unfold this plugin's pane with /desk-foldcommand.run: handles /desk-slack onlySearch for desk-slack in /config to find these options.
language: auto (default), en, ko. Language of the plugin's own labels. auto follows desk-look's language, or without desk-look Claude Code's language in /config: Korean when it names Korean, English otherwiseslackNotify: on (default), off. Notify when a new Slack DM arrives (see /desk-slack). off keeps checking for the pane and desk-look's ✉ chip, without notificationsMIT (LICENSE)
hooks/register.tsx 467 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderSurface, Timer } from 'claude-code'
3
4import type { FoldedPane, SlackInbox, SlackPoller, SlackRoom, SlackServer, SlackState, SlackView } from '../types'
5import {
6 FIRST_FILL_S,
7 closeRoom,
8 emptyInbox,
9 findSlackServer,
10 historyCall,
11 holdsLease,
12 inboxCall,
13 mergeInbox,
14 notices,
15 parseHistory,
16 parseThread,
17 parseMe,
18 parseSearch,
19 profileCall,
20 readRoom,
21 resultText,
22 roomList,
23 sendCall,
24 sendProblem,
25 slackPrompt,
26 threadCall,
27 slackText,
28 slackThreadTool,
29 slackTools,
30 tsCompare,
31 unreadCount,
32} from './slack'
33import type { SlackCall } from './slack'
34import { T, langFromConfig, langSetting, setLang } from './i18n'
35import { COLORS, ago, cellWidth, clip, fillPrompt, rowButton } from './util'
36
37const SLACK_PANE = 'desk-slack'
38// 답장 입력칸이 자랄 수 있는 줄 수(desk-look 의 diff 줄 댓글 칸과 같다).
39const COMMENT_ROWS = 3
40
41// 받은 편지(store 를 비춘 것)와 펼친 대화방. 답장 글은 Input 이 갖고, 보내거나 지우면 키를 바꿔 비운다.
42const slack = atom({ plugin: 'desk-slack', key: 'slack' } as const, null as SlackState | null)
43const slackView = atom({ plugin: 'desk-slack', key: 'slackView' } as const, null as SlackView | null)
44// 방 닫기 확인처럼 잠깐 켜 두는 것.
45const open = atom({ plugin: 'desk-slack', key: 'open' } as const, {} as Record<string, boolean>)
46// desk-look 이 읽는 값: 아래턱 ✉ 칩의 안 읽은 수, 이 세션에 떠 있음(옮김 안내), /desk-fold 로 접어 둔 패널.
47const unread = atom({ plugin: 'desk-slack', key: 'unread' } as const, 0)
48const ready = atom({ plugin: 'desk-slack', key: 'ready' } as const, false)
49const folded = atom({ plugin: 'desk-slack', key: 'folded' } as const, null as FoldedPane[] | null)
50// 이 플러그인의 패널이 지금 열려 있는지(ui.open·ui.close 훅이 맞춘다). desk-look 의 /desk-fold 가 접을지 펼지 고른다.
51const shown = atom({ plugin: 'desk-slack', key: 'shown' } as const, false)
52let slackDraft = ''
53let slackSent = 0
54let slackWatch: Timer | null = null
55let slackNotify = true
56
57// 받은 편지 상태를 바꾸고, desk-look 의 ✉ 칩이 읽는 안 읽은 수를 같이 맞춘다.
58async function setSlack($: EngineInterface, change: (state: SlackState | null) => SlackState) {
59 await update($, slack, change)
60 const state = await read($, slack)
61 await update($, unread, () => (state ? unreadCount(state.inbox) : 0))
62}
63
64// /desk-slack 의 Slack 호출(slack.ts 참고). 엔진이 거절하면(서버가 끊김, 권한) 던지는 대신 오류 글로 돌려준다.
65const SLACK_POLL_MS = 60_000
66const CLOSE_ARM_MS = 5000
67
68async function callSlack($: EngineInterface, server: SlackServer, call: SlackCall, key: string) {
69 try {
70 return resultText(await $.mcp.call(server.name, call.tool, call.args), key)
71 } catch (error) {
72 return { ok: false as const, error: (error instanceof Error ? error.message : String(error)).split('\n')[0] ?? '' }
73 }
74}
75
76async function findSlack($: EngineInterface): Promise<SlackServer | null> {
77 const tools = await $.tool.list().catch(() => [])
78 return findSlackServer(tools.filter(tool => tool.mcp).map(tool => tool.name))
79}
80
81// allow 규칙이 없는 Slack 도구. unruled 에 하나라도 있으면 Slack 을 부르지 않는다(받은 편지 확인은 타이머라 물어볼 사람이 없다).
82// 스레드 도구는 따로: 없으면 threadUnruled 에 이름이 들고 스레드만 열지 않는다.
83async function slackUnruled($: EngineInterface, server: SlackServer): Promise<{ unruled: string[]; threadUnruled?: string }> {
84 const tools = [...slackTools(server), slackThreadTool(server)]
85 const verdicts = await Promise.all(tools.map(tool => $.tool.check({ tool, input: {} }).catch(() => null)))
86 const missing = tools.filter((_, index) => verdicts[index]?.decision !== 'allow')
87 const thread = slackThreadTool(server)
88 return { unruled: missing.filter(tool => tool !== thread), ...(missing.includes(thread) ? { threadUnruled: thread } : {}) }
89}
90
91// $.store 는 떠 있는 세션끼리 같이 본다(2.1.302 실측). 모양이 다르면 빈 받은 편지.
92async function readInbox($: EngineInterface): Promise<SlackInbox> {
93 const kept = (await $.store.get('slackInbox').catch(() => undefined)) as SlackInbox | undefined
94 return kept && typeof kept === 'object' && kept.rooms && typeof kept.rooms === 'object' ? kept : emptyInbox()
95}
96
97async function saveInbox($: EngineInterface, inbox: SlackInbox) {
98 await $.store.set('slackInbox', inbox)
99 await setSlack($, (state): SlackState => ({ status: state?.status ?? 'ok', server: state?.server ?? null, unruled: state?.unruled ?? [], threadUnruled: state?.threadUnruled, inbox, error: state?.error }))
100}
101
102// 내 Slack UID. 서버마다 한 번 읽어 둔다.
103async function slackMe($: EngineInterface, server: SlackServer): Promise<string | null> {
104 const kept = (await $.store.get('slackMe').catch(() => undefined)) as { server?: string; id?: string } | undefined
105 if (kept?.server === server.name && typeof kept.id === 'string') return kept.id
106 const got = await callSlack($, server, profileCall(), 'result')
107 const me = got.ok ? parseMe(got.text) : null
108 if (me) await $.store.set('slackMe', { server: server.name, id: me })
109 return me
110}
111
112let slackPoll: Promise<void> | null = null
113
114// 받은 편지 확인. 타이머(force false)는 맡은 세션 하나만 Slack 을 읽고 나머지는 store 만 비춘다.
115// 패널을 열거나 ↻(force)는 맡은 세션이 아니어도 읽는다. 알림은 store 의 notified 뒤의 것만(세션끼리 겹치지 않게).
116async function pollSlack($: EngineInterface, force: boolean) {
117 if (slackPoll) return slackPoll
118 slackPoll = (async () => {
119 const shown = await read($, slack)
120 const server = shown?.server ?? (await findSlack($))
121 const kept = await readInbox($)
122 const put = (state: Omit<SlackState, 'inbox'>, inbox = kept) => setSlack($, (): SlackState => ({ ...state, inbox }))
123 if (server === null) return void (await put({ status: 'none', server: null, unruled: [] }))
124 const rules = await slackUnruled($, server)
125 if (rules.unruled.length > 0) return void (await put({ status: 'ok', server, ...rules }))
126 const [session, now, surfaces] = await Promise.all([$.session.id().catch(() => ''), $.clock.now(), $.session.surfaces().catch((): readonly RenderSurface[] => [])])
127 const poller = (await $.store.get('slackPoller').catch(() => undefined)) as SlackPoller | undefined
128 // 터미널이 없는 세션(SDK 등)은 알림을 띄울 곳이 없어 맡지 않는다: 맡으면 아무도 알리지 않게 된다
129 const canWatch = surfaces.includes('terminal')
130 if (!force && (!canWatch || !holdsLease(poller ?? null, session, now))) return void (await put({ status: shown?.status === 'loading' ? 'ok' : (shown?.status ?? 'ok'), server, ...rules }))
131 await $.store.set('slackPoller', { session, at: now })
132 if (force) await put({ status: 'loading', server, ...rules })
133 const me = await slackMe($, server)
134 if (me === null) return void (await put({ status: 'failed', server, ...rules, error: T.slackNoUser }))
135 const nowS = Math.floor(now / 1000)
136 const afterS = kept.watermark === null ? nowS - FIRST_FILL_S : Number(kept.watermark.split('.')[0])
137 const got = await callSlack($, server, inboxCall(me, afterS), 'results')
138 if (!got.ok) return void (await put({ status: 'failed', server, ...rules, error: got.error }))
139 // 그사이 다른 세션·패널이 방을 닫았을 수 있어 넣기 바로 앞에서 다시 읽는다
140 const latest = await readInbox($)
141 const merged = mergeInbox(latest, parseSearch(got.text), me, nowS)
142 const fresh = merged.fresh.filter(message => latest.notified === null || tsCompare(message.ts, latest.notified) > 0)
143 const inbox: SlackInbox = { ...merged.inbox, notified: fresh.length > 0 ? fresh[fresh.length - 1]!.ts : (latest.notified ?? merged.inbox.watermark) }
144 await $.store.set('slackInbox', inbox)
145 await put({ status: 'ok', server, ...rules }, inbox)
146 if (!slackNotify) return
147 for (const notice of notices(fresh, inbox, T.slackMore)) {
148 void $.ui.notify(notice.text, { title: notice.title }).catch(() => undefined)
149 }
150 })().finally(() => {
151 slackPoll = null
152 })
153 return slackPoll
154}
155
156async function openSlack($: EngineInterface) {
157 const shown = pollSlack($, true).catch(() => undefined)
158 await $.ui.open({ id: SLACK_PANE, title: 'Slack', focus: true })
159 await shown
160}
161
162// 방을 열면(다시 읽어도) 기록을 읽고 그 방 안 읽음을 지운다. thread 가 있으면 채널 기록 대신 그 스레드(부모와 답글).
163async function openSlackRoom($: EngineInterface, server: SlackServer, channel: string, thread: string | null = null) {
164 const isSame = (view: SlackView | null) => view?.channel === channel && view.thread === thread
165 // 방과 스레드를 오가면 쓰던 답장이 다른 곳으로 가지 않게 비운다
166 if (!isSame(await read($, slackView))) slackDraft = ''
167 await update($, slackView, (view): SlackView => ({ channel, thread, status: 'loading', posts: isSame(view) ? view!.posts : [] }))
168 const got = await callSlack($, server, thread === null ? historyCall(channel) : threadCall(channel, thread), 'messages')
169 const posts = got.ok ? (thread === null ? parseHistory(got.text) : parseThread(got.text)) : []
170 await update($, slackView, (view): SlackView | null =>
171 !isSame(view) ? view : got.ok ? { channel, thread, status: 'ok', posts } : { channel, thread, status: 'failed', posts: view!.posts, error: got.error },
172 )
173 if (!got.ok) return
174 const me = (await $.store.get('slackMe').catch(() => undefined)) as { id?: string } | undefined
175 await saveInbox($, readRoom(await readInbox($), channel, posts, me?.id ?? '', thread))
176}
177
178async function closeSlackView($: EngineInterface) {
179 slackDraft = ''
180 await update($, slackView, () => null)
181}
182
183async function clearSlackDraft($: EngineInterface, channel: string, thread: string | null) {
184 slackDraft = ''
185 slackSent++
186 await update($, slackView, view => (view ? { ...view } : view))
187 await $.ui.open({ id: SLACK_PANE, title: 'Slack', focus: true })
188 await $.ui.focus({ requestId: SLACK_PANE, key: `slack-write-${channel}-${thread ?? 'room'}-${slackSent}` }).catch(() => undefined)
189}
190
191async function sendSlack($: EngineInterface, server: SlackServer, channel: string, thread: string | null, value: string) {
192 const text = value.trim()
193 if (text === '') return
194 const call = sendCall(channel, text, thread)
195 const problem = await $.mcp.call(server.name, call.tool, call.args).then(sendProblem, (error: unknown) => (error instanceof Error ? error.message : String(error)).split('\n')[0] || 'error')
196 if (problem !== null) {
197 $.ui.toast(T.slackSendFailed(problem))
198 return
199 }
200 slackDraft = ''
201 slackSent++
202 $.ui.toast(T.slackSent)
203 await openSlackRoom($, server, channel, thread)
204}
205
206// ✕ 는 두 번 눌러 닫는다(Asana 담당자 넘기기와 같은 무장). channel 이 null 이면 모든 방(모두 닫기).
207async function armSlackClose($: EngineInterface, channel: string | null, isArmed: boolean) {
208 const key = `slack:close:${channel ?? '*'}`
209 if (!isArmed) {
210 await update($, open, state => ({ ...state, [key]: true }))
211 $.clock.after(CLOSE_ARM_MS, () => void update($, open, state => ({ ...state, [key]: false })))
212 return
213 }
214 await update($, open, state => ({ ...state, [key]: false }))
215 const inbox = await readInbox($)
216 await saveInbox($, channel === null ? { ...inbox, rooms: {} } : closeRoom(inbox, channel))
217 const view = await read($, slackView)
218 if (view !== null && (channel === null || view.channel === channel)) await closeSlackView($)
219}
220
221// 세션마다 비워지는 상태를 다시 채운다: 세션이 시작될 때와 창 안에서 다른 세션으로 넘어간 뒤(/resume·/clear).
222function refreshSessionState($: EngineInterface) {
223 void update($, ready, () => true).catch(() => undefined)
224 // 받은 편지 칩은 store 에서 바로.
225 void readInbox($)
226 .then(inbox => setSlack($, (state): SlackState => state ?? { status: 'loading', server: null, unruled: [], inbox }))
227 .catch(() => undefined)
228}
229
230// desk-look 의 /desk-fold 를 따라 접고 편다: desk-look 이 folded 에 목록을 쓰면(접을 desk-look 패널이 없으면 빈 목록)
231// 이 플러그인의 패널도 접고, null 을 쓰면 접어 둔 것을 다시 연다. 엔진은 다른 플러그인의 패널을 닫게 하지 않는다.
232async function followFold($: EngineInterface, isFolding: boolean) {
233 if (isFolding) {
234 const panes = (await $.ui.panes()).filter(pane => pane.isPlaced)
235 if (panes.length === 0) return
236 // 이미 접어 둔 것이 있으면 새로 접는 것을 더한다(desk-look 의 toggleFold 와 같다).
237 const saved = await read($, folded)
238 const fresh = panes.map(pane => ({ id: pane.id, title: pane.title, isShown: pane.isShown, offset: 0 }))
239 await update($, folded, () => [...(saved ?? []).filter(pane => !fresh.some(item => item.id === pane.id)), ...fresh])
240 for (const pane of panes) await $.ui.close({ id: pane.id }).catch(() => undefined)
241 return
242 }
243 const saved = await read($, folded)
244 await update($, folded, () => null)
245 for (const pane of saved ?? []) await $.ui.open({ id: pane.id, title: pane.title, ...(pane.isShown ? { focus: true as const } : {}) })
246}
247
248// 설정 language 가 auto 일 때: desk-look 이 있으면 desk-look 이 고른 언어, 없으면 Claude Code /config 의 language.
249async function pickLang($: EngineInterface) {
250 const { value } = await $.state.get({ plugin: 'desk-look', key: 'lang' })
251 if (value === 'en' || value === 'ko') return setLang(value)
252 const row = (await $.config.list().catch(() => [])).find(item => item.key === 'language')
253 return setLang(langFromConfig(row?.value))
254}
255
256export const register: Register = (on, options) => {
257 const language = langSetting(options)
258 setLang(language === 'auto' ? 'en' : language)
259 slackNotify = options.slackNotify !== 'off'
260
261 on('session.start', async ($, e, next) => {
262 if (language === 'auto' && (await pickLang($).catch(() => false))) $.ui.invalidate('ui.render')
263 // 패널만 여는 명령은 턴 중에도 바로 연다(immediate).
264 await $.command.register({ name: 'desk-slack', description: T.cmdSlack, immediate: true })
265 refreshSessionState($)
266 // Slack 확인은 1분마다(MCP 서버는 시작 뒤에 붙는다).
267 slackWatch?.cancel()
268 slackWatch = $.clock.every(SLACK_POLL_MS, () => void pollSlack($, false).catch(() => undefined))
269 return next(e)
270 })
271
272 // 창 안의 /resume·/clear 는 상태(atom)를 비우고 session.start 없이 이어 간다. 넘어간 뒤 다시 채운다.
273 on('session.end', async ($, e, next) => {
274 const done = await next(e)
275 if (e.reason === 'resume' || e.reason === 'clear') $.clock.after(300, () => refreshSessionState($))
276 return done
277 })
278
279 on('state.set', { plugin: 'desk-look', key: 'lang' }, async ($, e, next) => {
280 const done = await next(e)
281 if (language === 'auto' && (e.value === 'en' || e.value === 'ko') && setLang(e.value)) $.ui.invalidate('ui.render')
282 return done
283 }).catch(($, e, next) => next(e))
284
285 on('state.set', { plugin: 'desk-look', key: 'folded' }, async ($, e, next) => {
286 const done = await next(e)
287 await followFold($, e.value !== null).catch(() => undefined)
288 return done
289 }).catch(($, e, next) => next(e))
290
291 // 패널이 열려 있는지 desk-look 에 알린다. 사람이 ✕ 로 닫아도, 플러그인을 내려도 ui.close 가 온다.
292 on('ui.open', { id: SLACK_PANE }, async ($, e, next) => {
293 const opened = await next(e)
294 if (opened.value?.isPlaced) await update($, shown, () => true).catch(() => undefined)
295 return opened
296 })
297
298 on('ui.close', { id: SLACK_PANE }, async ($, e, next) => {
299 const done = await next(e)
300 await update($, shown, () => false).catch(() => undefined)
301 return done
302 })
303
304 on('command.run', { command: 'desk-slack' }, async $ => {
305 await openSlack($)
306 const state = await read($, slack)
307 return { text: state?.status === 'none' ? T.slackNoServer : T.slackOpened }
308 })
309
310 // Slack 패널: 나에게 온 DM·그룹 DM 을 대화방으로(최근 방부터), 하나를 누르면 그 방의 최근 대화와 답장.
311 // 마지막 글이 스레드 답글인 방은 그 스레드로 열린다. 방 안의 "답글 N개" 를 누르면 그 스레드, ← 는 방으로.
312 on('ui.render', { component: 'Pane', requestId: SLACK_PANE }, async ($, e, next) => {
313 if (e.surface !== 'terminal') return next(e)
314 const els = $.ui.resolve(e)
315 const { Box, Text, Button, Input, Link } = els
316 const [state, view, armed, now, me] = await Promise.all([read($, slack), read($, slackView), read($, open), $.clock.now(), $.store.get('slackMe').catch(() => undefined)])
317 const columns = Math.max(24, e.props.bodyColumns)
318 const server = state?.server ?? null
319 const myId = (me as { id?: string } | undefined)?.id ?? ''
320 const when = (ts: string) => ago(Number(ts.split('.')[0]) * 1000, now)
321 // 스레드 도구에 규칙이 없으면 "답글 N개" 는 글자만, 방 위에 넣을 규칙
322 const canThread = state?.threadUnruled === undefined
323 const closer = (room: SlackRoom) => {
324 const isArmed = armed[`slack:close:${room.id}`] ?? false
325 return <Button key={`slack-close-${room.id}`} plain dimColor={!isArmed} label={isArmed ? T.slackCloseConfirm : '✕'} onPress={() => void armSlackClose($, room.id, isArmed)} />
326 }
327
328 const room = view === null ? undefined : state?.inbox.rooms[view.channel]
329 if (view !== null && server !== null) {
330 const title = room?.title ?? view.channel
331 // 글이 길게 쌓이면 위로 되돌아가지 않도록 답장 칸 아래에도 같은 버튼을 둔다.
332 const back = (key: string) => (
333 <Button
334 key={key}
335 plain
336 dimColor
337 hotkey={key === 'slack-back' ? 'b' : undefined}
338 label={view.thread === null ? T.slackBack : `← ${title}`}
339 onPress={() => void (view.thread === null ? closeSlackView($) : openSlackRoom($, server, view.channel))}
340 />
341 )
342 return (
343 <Box flexDirection="column" rowGap={1}>
344 <Box columnGap={1}>
345 {back('slack-back')}
346 <Button key="slack-reload" plain dimColor hotkey="r" label={view.status === 'loading' ? '…' : '↻'} onPress={() => void openSlackRoom($, server, view.channel, view.thread)} />
347 </Box>
348 <Box flexDirection="column">
349 <Text color={COLORS.text} weight="semibold">
350 {title}
351 </Text>
352 <Box columnGap={2}>
353 {room && <Button key="slack-claude" plain label={T.handToClaude} onPress={() => void fillPrompt($, slackPrompt(room, view.posts, T.slackPromptHead(title)))} />}
354 {room?.link && <Link href={room.link}>{T.slackOpenOn}</Link>}
355 {room && closer(room)}
356 </Box>
357 </Box>
358 {view.status === 'failed' && <Text color={COLORS.danger}>{T.slackFailed(view.error ?? '')}</Text>}
359 {!canThread && view.posts.some(post => post.replies > 0) && (
360 <Box flexDirection="column">
361 <Text color={COLORS.warning}>{T.slackThreadUnruled}</Text>
362 <Text color={COLORS.muted}>{`"${state?.threadUnruled}",`}</Text>
363 </Box>
364 )}
365 {view.posts.length === 0 && view.status === 'loading' && <Text color={COLORS.muted}>{T.slackLoading}</Text>}
366 {view.posts.map(post => (
367 <Box key={`slack-post-${post.ts}`} flexDirection="column">
368 <Box columnGap={1}>
369 <Text color={post.from.id === myId ? COLORS.muted : COLORS.accent}>{post.from.id === myId ? T.slackMe : post.from.name}</Text>
370 <Text color={COLORS.muted}>{when(post.ts)}</Text>
371 </Box>
372 <Text color={COLORS.text}>{slackText(post.text)}</Text>
373 {view.thread === null && post.replies > 0 && (
374 <Box>
375 {canThread ? (
376 <Button key={`slack-thread-${post.ts}`} plain label={T.slackReplies(post.replies)} onPress={() => void openSlackRoom($, server, view.channel, post.ts)} />
377 ) : (
378 <Text color={COLORS.muted}>{T.slackReplies(post.replies)}</Text>
379 )}
380 </Box>
381 )}
382 </Box>
383 ))}
384 <Input
385 key={`slack-write-${view.channel}-${view.thread ?? 'room'}-${slackSent}`}
386 label={T.slackReplyLabel}
387 placeholder={view.thread === null ? T.slackReplyPlaceholder(title) : T.slackThreadPlaceholder}
388 rows={COMMENT_ROWS}
389 value={slackDraft}
390 submitLabel={T.slackSend}
391 onInput={value => (slackDraft = value)}
392 onSubmit={value => void sendSlack($, server, view.channel, view.thread, value)}
393 />
394 <Box columnGap={2}>
395 {back('slack-back-end')}
396 <Button key="slack-clear" plain dimColor label={T.inputClear} onPress={() => void clearSlackDraft($, view.channel, view.thread)} />
397 </Box>
398 </Box>
399 )
400 }
401
402 const rooms = state ? roomList(state.inbox) : []
403 const unread = state ? unreadCount(state.inbox) : 0
404 const isLoading = state === null || state.status === 'loading'
405 const head = (
406 <Box columnGap={1}>
407 <Text color={COLORS.text} weight="semibold">
408 {T.slackInbox(rooms.length, unread)}
409 </Text>
410 <Button key="slack-refresh" plain dimColor hotkey="r" label={isLoading ? '…' : '↻'} onPress={() => void pollSlack($, true).catch(() => undefined)} />
411 {rooms.length > 0 && (
412 <Button
413 key="slack-close-all"
414 plain
415 dimColor={!(armed['slack:close:*'] ?? false)}
416 label={(armed['slack:close:*'] ?? false) ? T.slackCloseAllConfirm : T.slackCloseAll}
417 onPress={() => void armSlackClose($, null, armed['slack:close:*'] ?? false)}
418 />
419 )}
420 </Box>
421 )
422 const unruled = (state?.unruled.length ?? 0) > 0 && (
423 <Box flexDirection="column">
424 <Text color={COLORS.warning}>{T.slackUnruled}</Text>
425 <Text color={COLORS.muted}>{state?.unruled.map(tool => `"${tool}",`).join('\n')}</Text>
426 </Box>
427 )
428 const message =
429 state?.status === 'none'
430 ? T.slackNoServer
431 : state?.status === 'failed'
432 ? T.slackFailed(state.error ?? '')
433 : rooms.length === 0
434 ? isLoading
435 ? T.slackLoading
436 : T.slackEmpty
437 : null
438 // 줄: "● 이름 미리보기 3분 전" 한 줄 전체가 버튼, 끝에 ✕(닫기). 이름은 열의 1/4, 나머지를 미리보기에.
439 const nameRoom = Math.max(8, Math.floor(columns / 4))
440 const rows = rooms.map(item => {
441 const age = when(item.lastTs)
442 const lead = item.lastFrom === null ? `${T.slackMe}: ` : item.kind === 'group' ? `${item.lastFrom}: ` : ''
443 const room = Math.max(8, columns - nameRoom - cellWidth(age) - 10)
444 return (
445 <Box key={`slack-row-${item.id}`} columnGap={1}>
446 {rowButton(
447 els,
448 { key: `slack-room-${item.id}`, dimColor: item.unread === 0, onPress: () => void (server && openSlackRoom($, server, item.id, canThread ? (item.lastThread ?? null) : null)) },
449 `${item.unread > 0 ? '●' : ' '} ${clip(item.title, nameRoom)}`,
450 <Text color={COLORS.muted}>{` ${clip(`${lead}${item.preview}`, room)}`}</Text>,
451 <Text color={COLORS.muted}>{` ${age}`}</Text>,
452 )}
453 {closer(item)}
454 </Box>
455 )
456 })
457 return (
458 <Box flexDirection="column" rowGap={1}>
459 {head}
460 {unruled}
461 {message !== null && <Text color={state?.status === 'failed' ? COLORS.danger : COLORS.muted}>{message}</Text>}
462 {rows.length > 0 && <Box flexDirection="column">{rows}</Box>}
463 </Box>
464 )
465 })
466}
467hooks/slack.ts 277 lines1import type { SlackInbox, SlackMessage, SlackPoller, SlackPost, SlackRoom, SlackServer } from '../types'
2
3// /desk-slack: 나에게 온 DM·그룹 DM 을 대화방으로. 엔진에 붙은 Slack MCP 서버(claude.ai 커넥터)를 그대로 쓴다.
4// 이 서버는 JSON 이 아니라 마크다운 글을 JSON 한 겹({"results": …}, {"messages": …})에 싸서 준다. 그래서 줄 정규식으로 읽는다.
5// 받은 편지: 검색 to:<@나> 는 DM·그룹 DM 에서 남이 보낸 것만 준다(내 답장은 빠진다, 스레드 답글은 들어온다).
6// 방 안은 채널 기록(read_channel)으로 양쪽 다. 채널 기록에는 최상위 글만 있어 스레드 답글은 스레드(read_thread)로 따로 읽는다.
7// 읽음 표시를 Slack 에 되돌리는 도구가 없어 안 읽음은 desk-slack 안에서만 센다.
8
9export const SLACK_TOOLS = {
10 search: 'slack_search_public_and_private',
11 history: 'slack_read_channel',
12 thread: 'slack_read_thread',
13 send: 'slack_send_message',
14 me: 'slack_read_user_profile',
15} as const
16
17// 처음 열 때 채우는 기간. 그 뒤로는 마지막으로 본 메시지 다음부터.
18export const FIRST_FILL_S = 3 * 24 * 60 * 60
19// 방 안에서 보여 줄 메시지 수
20export const HISTORY_LIMIT = 30
21// 다른 세션이 이만큼 받은 편지를 읽지 않았으면 이 세션이 넘겨받는다
22export const LEASE_MS = 3 * 60 * 1000
23
24// 검색 도구가 있는 서버. 나머지 도구가 목록에 없으면(deny 규칙은 도구를 목록에서 뺀다) 서버 없음이 아니라 규칙 안내로 간다(slackTools).
25export function findSlackServer(names: readonly string[]): SlackServer | null {
26 const suffix = `__${SLACK_TOOLS.search}`
27 const found = names.find(name => name.startsWith('mcp__') && name.endsWith(suffix) && name.length > 'mcp__'.length + suffix.length)
28 return found ? { name: found.slice('mcp__'.length, -suffix.length) } : null
29}
30
31// 패널과 받은 편지 확인이 부르는 도구의 전체 이름. allow 규칙이 다 있어야 부른다(Asana paneTools 와 같은 까닭).
32// 스레드 도구는 뒤에 더한 것이라 빼고 따로 본다(slackThreadTool): 규칙이 없어도 스레드만 못 열고 나머지는 돈다.
33export function slackTools(server: SlackServer): string[] {
34 return Object.values(SLACK_TOOLS)
35 .filter(tool => tool !== SLACK_TOOLS.thread)
36 .map(tool => `mcp__${server.name}__${tool}`)
37}
38
39export function slackThreadTool(server: SlackServer): string {
40 return `mcp__${server.name}__${SLACK_TOOLS.thread}`
41}
42
43export type SlackCall = { tool: string; args: Record<string, unknown> }
44
45export function profileCall(): SlackCall {
46 return { tool: SLACK_TOOLS.me, args: {} }
47}
48
49// 나에게 온 DM·그룹 DM, 최근 것부터. after 는 Unix 초(포함). 필터의 after: 는 날짜 단위라 쓰지 않는다.
50export function inboxCall(me: string, afterS: number): SlackCall {
51 return {
52 tool: SLACK_TOOLS.search,
53 args: {
54 filters: `to:<@${me}>`,
55 natural_language_query: '',
56 channel_types: 'im,mpim',
57 after: String(afterS),
58 sort: 'timestamp',
59 sort_dir: 'desc',
60 limit: 20,
61 include_context: false,
62 },
63 }
64}
65
66// 방의 최근 메시지. concise 에는 ts 가 없어 detailed 로.
67export function historyCall(channel: string): SlackCall {
68 return { tool: SLACK_TOOLS.history, args: { channel_id: channel, limit: HISTORY_LIMIT, response_format: 'detailed' } }
69}
70
71// 스레드의 부모 글과 최근 답글(오래된 것부터). limit 은 최신 쪽에서 자른다.
72export function threadCall(channel: string, thread: string): SlackCall {
73 return { tool: SLACK_TOOLS.thread, args: { channel_id: channel, message_ts: thread, limit: HISTORY_LIMIT, response_format: 'detailed' } }
74}
75
76// thread 가 있으면 그 스레드에 답글로
77export function sendCall(channel: string, text: string, thread: string | null = null): SlackCall {
78 return { tool: SLACK_TOOLS.send, args: thread === null ? { channel_id: channel, message: text } : { channel_id: channel, message: text, thread_ts: thread } }
79}
80
81type Block = { type: string; text?: string }
82
83// MCP 결과의 글(key 는 results·messages·result). 실패(isError)나 모양이 다르면 오류 글.
84export function resultText(result: { content: readonly Block[]; isError: boolean }, key: string): { ok: true; text: string } | { ok: false; error: string } {
85 const text = result.content
86 .filter(block => block.type === 'text' && typeof block.text === 'string')
87 .map(block => block.text as string)
88 .join('\n')
89 .trim()
90 if (result.isError) return { ok: false, error: firstLine(text) || 'error' }
91 try {
92 const value = (JSON.parse(text) as Record<string, unknown> | null)?.[key]
93 if (typeof value === 'string') return { ok: true, text: value }
94 } catch {
95 // 아래에서 오류로
96 }
97 return { ok: false, error: firstLine(text) || 'empty result' }
98}
99
100// 보내기 결과: 실패면 그 글의 첫 줄, 아니면 null(성공 글의 모양은 보지 않는다).
101export function sendProblem(result: { content: readonly Block[]; isError: boolean }): string | null {
102 if (!result.isError) return null
103 const text = result.content.map(block => (block.type === 'text' ? (block.text ?? '') : '')).join('\n')
104 return firstLine(text) || 'error'
105}
106
107const firstLine = (text: string) => (text.split('\n').find(line => line.trim() !== '') ?? '').trim().slice(0, 200)
108
109// 프로필에서 내 UID
110export function parseMe(text: string): string | null {
111 return /^User ID: (\w+)$/m.exec(text)?.[1] ?? null
112}
113
114const people = (text: string) => [...text.matchAll(/([^,]+?) \(ID: (\w+)\)/g)].map(match => ({ name: match[1]!.trim(), id: match[2]! }))
115// "kevin <kevin@example.com>" → "kevin"
116const bare = (name: string) => name.replace(/\s*<[^>]*>\s*$/, '').trim()
117
118// 검색 결과: "### Result N of M" 마다 Channel·Participants·From·Time·Message_ts·Permalink·Text 줄. DM·그룹 DM 만.
119// 스레드 답글은 Permalink 에 ?thread_ts=부모 가 붙는다(부모 글 자신이면 같은 ts 라 스레드로 치지 않는다).
120export function parseSearch(text: string): SlackMessage[] {
121 const out: SlackMessage[] = []
122 for (const block of text.split(/^### Result \d+ of \d+[^\n]*$/m).slice(1)) {
123 const channel = /^Channel: (DM|Group DM) \(ID: (\w+)\)$/m.exec(block)
124 const from = /^From: (.+?) \(ID: (\w+)\)/m.exec(block)
125 const ts = /^Message_ts: (\d+\.\d+)/m.exec(block)?.[1]
126 if (!channel || !from || !ts) continue
127 const body = /^Text: ?\n([\s\S]*)$/m.exec(block)?.[1] ?? ''
128 const permalink = /^Permalink: \[[^\]]*\]\((https?:[^)\s]+)\)/m.exec(block)?.[1] ?? null
129 const thread = permalink ? (/[?&]thread_ts=(\d+\.\d+)/.exec(permalink)?.[1] ?? null) : null
130 out.push({
131 channel: channel[2]!,
132 kind: channel[1] === 'DM' ? 'dm' : 'group',
133 participants: people(/^Participants: (.*)$/m.exec(block)?.[1] ?? ''),
134 from: { id: from[2]!, name: bare(from[1]!) },
135 ts,
136 text: body.replace(/\n+---\s*$/, '').trim(),
137 permalink,
138 thread: thread === ts ? null : thread,
139 })
140 }
141 return out
142}
143
144// 채널 기록: "=== Message from 이름 <메일> (U…) at 시각 ===" 다음 줄 "Message TS: …", 그 뒤 본문. 최신 것부터 오니 뒤집는다.
145// 답글이 달린 글은 본문 끝에 "Thread: N replies (latest: 시각)" 줄이 붙는다. 그 줄은 replies 로 옮긴다.
146export function parseHistory(text: string): SlackPost[] {
147 const parts = text.split(/^=== Message from (.+?) \((\w+)\) at (.+?) ===[ \t]*$/m)
148 const out: SlackPost[] = []
149 for (let index = 1; index + 3 < parts.length; index += 4) {
150 const rest = parts[index + 3] ?? ''
151 const ts = /^\s*Message TS: (\d+\.\d+)/.exec(rest)?.[1]
152 if (!ts) continue
153 const body = rest.replace(/^\s*Message TS: [^\n]*\n?/, '')
154 const thread = /^Thread: (\d+) repl(?:y|ies)\b[^\n]*$/m.exec(body)
155 out.push({
156 ts,
157 from: { id: parts[index + 1]!, name: bare(parts[index]!) },
158 at: parts[index + 2]!,
159 text: (thread ? body.replace(thread[0], '') : body).trim(),
160 replies: thread ? Number(thread[1]) : 0,
161 })
162 }
163 return out.reverse()
164}
165
166// 스레드: "=== THREAD PARENT MESSAGE ===" 와 "--- Reply i of n ---" 마다 From·Time·Message TS 줄, 그 뒤 본문. 오래된 것부터 온다.
167export function parseThread(text: string): SlackPost[] {
168 const out: SlackPost[] = []
169 for (const block of text.split(/^(?:=== THREAD PARENT MESSAGE ===|--- Reply \d+ of \d+ ---)[ \t]*$/m).slice(1)) {
170 const from = /^From: (.+?) \((\w+)\)[ \t]*$/m.exec(block)
171 const ts = /^Message TS: (\d+\.\d+)[ \t]*$/m.exec(block)
172 if (!from || !ts) continue
173 const body = block.slice(ts.index + ts[0].length).replace(/^=== THREAD REPLIES[^\n]*$/m, '')
174 out.push({ ts: ts[1]!, from: { id: from[2]!, name: bare(from[1]!) }, at: /^Time: (.*)$/m.exec(block)?.[1]?.trim() ?? '', text: body.trim(), replies: 0 })
175 }
176 return out
177}
178
179// Slack mrkdwn 의 링크 <url|글> → 글, <url> → url. 나머지는 그대로.
180export function slackText(text: string): string {
181 return text.replace(/<((?:https?|mailto):[^|>]+)\|([^>]+)>/g, '$2').replace(/<((?:https?|mailto):[^>]+)>/g, '$1')
182}
183
184// Slack ts("1792131860.241069") 비교. 소수까지 문자열로(배정밀도로 바꾸면 끝자리가 흐려진다).
185export function tsCompare(a: string, b: string): number {
186 const [ai = '0', af = ''] = a.split('.')
187 const [bi = '0', bf = ''] = b.split('.')
188 if (ai.length !== bi.length) return ai.length - bi.length
189 if (ai !== bi) return ai < bi ? -1 : 1
190 const [x, y] = [af.padEnd(6, '0'), bf.padEnd(6, '0')]
191 return x === y ? 0 : x < y ? -1 : 1
192}
193
194export const emptyInbox = (): SlackInbox => ({ watermark: null, notified: null, rooms: {} })
195
196// 미리보기 한 줄
197export function snippet(text: string): string {
198 return slackText(text).replace(/\s+/g, ' ').trim().slice(0, 200)
199}
200
201// 받은 메시지를 방에 넣는다. 워터마크보다 새 것만, 내가 보낸 것은 빼고. 닫은 방(지운 방)도 새 메시지면 다시 생긴다.
202// 처음 채우기(워터마크 없음)는 알리지 않고 읽은 것으로 넣는다(Slack 의 읽음을 알 수 없어 지켜보기 전 것은 세지 않는다).
203// 받은 게 없으면 워터마크를 지금(nowS)으로 둬 다음부터는 알린다.
204// fresh: 알릴 메시지(오래된 것부터). 검색은 한 번에 20개라 그 사이 더 오면 오래된 것은 방에 안 들어간다.
205export function mergeInbox(inbox: SlackInbox, messages: readonly SlackMessage[], me: string, nowS: number): { inbox: SlackInbox; fresh: SlackMessage[] } {
206 const known = inbox.watermark
207 const added = messages
208 .filter(message => message.from.id !== me && (known === null || tsCompare(message.ts, known) > 0))
209 .sort((a, b) => tsCompare(a.ts, b.ts))
210 const rooms = { ...inbox.rooms }
211 for (const message of added) {
212 const was = rooms[message.channel]
213 const others = message.participants.filter(person => person.id !== me).map(person => person.name)
214 rooms[message.channel] = {
215 id: message.channel,
216 kind: message.kind,
217 title: others.length > 0 ? others.join(', ') : (was?.title ?? message.from.name),
218 lastTs: message.ts,
219 lastFrom: message.from.name,
220 lastThread: message.thread,
221 preview: snippet(message.text),
222 unread: (was?.unread ?? 0) + (known === null ? 0 : 1),
223 link: message.permalink?.replace(/\/p\d+(?:\?.*)?$/, '') ?? was?.link ?? null,
224 }
225 }
226 const watermark = added.length > 0 ? added[added.length - 1]!.ts : (known ?? `${nowS}.000000`)
227 return { inbox: { ...inbox, watermark, rooms }, fresh: known === null ? [] : added }
228}
229
230// 방을 열어 읽은 기록으로 안 읽음을 지우고, 기록의 마지막 글(내 답장이면 lastFrom 이 null)을 미리보기로.
231// thread 는 읽은 것이 스레드면 그 부모 ts(채널 기록이면 null).
232export function readRoom(inbox: SlackInbox, channel: string, posts: readonly SlackPost[], me: string, thread: string | null = null): SlackInbox {
233 const room = inbox.rooms[channel]
234 if (!room) return inbox
235 const last = posts[posts.length - 1]
236 const isNewer = last !== undefined && tsCompare(last.ts, room.lastTs) >= 0
237 const next: SlackRoom = isNewer ? { ...room, unread: 0, lastTs: last.ts, lastFrom: last.from.id === me ? null : last.from.name, lastThread: thread, preview: snippet(last.text) } : { ...room, unread: 0 }
238 return { ...inbox, rooms: { ...inbox.rooms, [channel]: next } }
239}
240
241export function closeRoom(inbox: SlackInbox, channel: string): SlackInbox {
242 const { [channel]: _, ...rooms } = inbox.rooms
243 return { ...inbox, rooms }
244}
245
246// 최근 방부터
247export function roomList(inbox: SlackInbox): SlackRoom[] {
248 return Object.values(inbox.rooms).sort((a, b) => tsCompare(b.lastTs, a.lastTs))
249}
250
251export function unreadCount(inbox: SlackInbox): number {
252 return Object.values(inbox.rooms).reduce((sum, room) => sum + room.unread, 0)
253}
254
255// 받은 편지를 읽을 세션: 아무도 없거나, 나거나, 맡은 세션이 LEASE_MS 동안 소식이 없을 때.
256export function holdsLease(poller: SlackPoller | null, session: string, now: number): boolean {
257 return poller === null || poller.session === session || now - poller.at > LEASE_MS
258}
259
260// 알림: 방마다 하나. 여러 개면 마지막 글에 "외 N개".
261export function notices(fresh: readonly SlackMessage[], inbox: SlackInbox, more: (count: number) => string): Array<{ title: string; text: string }> {
262 const byRoom = new Map<string, SlackMessage[]>()
263 for (const message of fresh) byRoom.set(message.channel, [...(byRoom.get(message.channel) ?? []), message])
264 return [...byRoom].map(([channel, list]) => {
265 const last = list[list.length - 1]!
266 const room = inbox.rooms[channel]
267 const head = room?.kind === 'group' ? `${last.from.name}: ` : ''
268 return { title: room?.title ?? last.from.name, text: `${head}${snippet(last.text)}${list.length > 1 ? more(list.length - 1) : ''}` }
269 })
270}
271
272// "Claude에게 맡기기": 방 이름과 최근 대화를 입력창에.
273export function slackPrompt(room: SlackRoom, posts: readonly SlackPost[], head: string): string {
274 const recent = posts.slice(-10).map(post => `- ${post.from.name}: ${slackText(post.text).trim().slice(0, 600).replaceAll('\n', ' ')}`)
275 return [head, room.link, '', ...recent].filter(line => line !== null).join('\n')
276}
277hooks/i18n.ts 102 lines1// 화면 문구 사전: 영어(기본)와 한국어. desk-look 이 고른 언어(desk-look.lang 상태)를 따르고, desk-look 이
2// 없으면 Claude Code 의 /config 언어(language)를 따른다. T 는 한 객체를 계속 쓰고 setLang 이 내용만 바꾼다.
3
4export type Lang = 'en' | 'ko'
5
6const ko = {
7 // 여러 줄 입력칸 뒤 버튼: 쓴 글을 한 번에 비운다(엔진 칸의 ^U 는 한 줄씩만 지운다)
8 inputClear: '지우기',
9 slackNoServer: 'Slack MCP 서버가 연결돼 있지 않아요. /mcp 에서 Slack 을 연결하세요.',
10 slackLoading: 'Slack 을 읽는 중이에요…',
11 slackFailed: (reason: string) => `Slack 을 읽지 못했어요: ${reason}`,
12 slackNoUser: '내 Slack 사용자 ID 를 읽지 못했어요.',
13 slackEmpty: '최근 3일 동안 나에게 온 DM 이 없어요. 새로 오면 여기에 대화방이 생겨요.',
14 slackInbox: (rooms: number, unread: number) => (unread > 0 ? `대화방 ${rooms} · 안 읽음 ${unread}` : `대화방 ${rooms}`),
15 slackUnruled: '허용 규칙이 없어 desk-slack 이 Slack 을 부르지 않아요(새 메시지 확인·알림도 멈춤). ~/.claude/settings.json 의 permissions.allow 에 넣으세요:',
16 slackBack: '← 대화방',
17 slackCloseConfirm: '✕ 닫을까요?',
18 slackCloseAll: '모두 닫기',
19 slackCloseAllConfirm: '모두 닫을까요?',
20 slackOpenOn: 'Slack에서 열기',
21 slackMe: '나',
22 slackReplyLabel: '답장',
23 slackReplyPlaceholder: (title: string) => `${title} 에게 보낼 메시지`,
24 slackThreadPlaceholder: '이 스레드에 보낼 답글',
25 slackReplies: (count: number) => `답글 ${count}개`,
26 slackThreadUnruled: '허용 규칙이 없어 스레드 답글은 열지 않아요. ~/.claude/settings.json 의 permissions.allow 에 넣으세요:',
27 slackSend: '보내기',
28 slackSent: 'Slack 에 보냈어요.',
29 slackSendFailed: (reason: string) => `보내지 못했어요: ${reason}`,
30 slackMore: (count: number) => ` (외 ${count}개)`,
31 slackPromptHead: (title: string) => `Slack 대화: ${title}`,
32 handToClaude: 'Claude에게 맡기기',
33 // Slack 패널
34 cmdSlack: '나에게 온 Slack DM·그룹 DM 을 대화방으로 패널에 연다: 최근 대화·답장·닫기, 새 메시지는 알림 (Slack MCP 서버 필요)',
35 slackOpened: 'Slack 패널을 열었습니다.',
36 justNow: '방금',
37 minutesShort: (count: number) => `${count}분`,
38 hoursShort: (count: number) => `${count}시간`,
39 daysShort: (count: number) => `${count}일`,
40 filled: '입력창에 채웠어요. 고쳐서 Enter 로 보내세요.',
41 fillFailed: '입력창에 채우지 못했어요.',
42}
43
44export type Strings = typeof ko
45
46const en: Strings = {
47 inputClear: 'Clear',
48 slackNoServer: 'No Slack MCP server is connected. Connect Slack in /mcp.',
49 slackLoading: 'Reading Slack…',
50 slackFailed: (reason: string) => `Could not read Slack: ${reason}`,
51 slackNoUser: 'Could not read your Slack user ID.',
52 slackEmpty: 'No DMs to you in the last 3 days. A conversation shows up here when one arrives.',
53 slackInbox: (rooms: number, unread: number) => (unread > 0 ? `${rooms} conversations · ${unread} unread` : `${rooms} conversations`),
54 slackUnruled: 'With no allow rule, desk-slack does not call Slack (checking for new messages and notifications stop too). Add these to permissions.allow in ~/.claude/settings.json:',
55 slackBack: '← Conversations',
56 slackCloseConfirm: '✕ Close?',
57 slackCloseAll: 'Close all',
58 slackCloseAllConfirm: 'Close all?',
59 slackOpenOn: 'Open in Slack',
60 slackMe: 'You',
61 slackReplyLabel: 'Reply',
62 slackReplyPlaceholder: (title: string) => `Message to ${title}`,
63 slackThreadPlaceholder: 'Reply in this thread',
64 slackReplies: (count: number) => (count === 1 ? '1 reply' : `${count} replies`),
65 slackThreadUnruled: 'With no allow rule, desk-slack does not open thread replies. Add this to permissions.allow in ~/.claude/settings.json:',
66 slackSend: 'Send',
67 slackSent: 'Sent to Slack.',
68 slackSendFailed: (reason: string) => `Could not send: ${reason}`,
69 slackMore: (count: number) => ` (+${count} more)`,
70 slackPromptHead: (title: string) => `Slack conversation: ${title}`,
71 handToClaude: 'Hand to Claude',
72 // Slack pane
73 cmdSlack: 'Open the Slack DMs and group DMs sent to you as conversations in a pane: recent messages, replying, closing, and a notification for new ones (needs a Slack MCP server)',
74 slackOpened: 'Opened the Slack pane.',
75 justNow: 'now',
76 minutesShort: count => `${count}m`,
77 hoursShort: count => `${count}h`,
78 daysShort: count => `${count}d`,
79 filled: 'Put it in the prompt. Edit it and press Enter to send.',
80 fillFailed: "Couldn't fill the prompt.",
81}
82
83export const T: Strings = { ...en }
84let current: Lang = 'en'
85
86export function setLang(lang: Lang): boolean {
87 if (lang === current) return false
88 current = lang
89 Object.assign(T, lang === 'ko' ? ko : en)
90 return true
91}
92
93// Claude Code /config 의 language 값(자유 글: "Korean", "한국어", "ko" …). 모르면 영어.
94export function langFromConfig(value: unknown): Lang {
95 return typeof value === 'string' && /korean|한국|^ko(\b|[-_])/i.test(value.trim()) ? 'ko' : 'en'
96}
97
98// 설정 language: en·ko 면 그것, 그 밖은 auto(desk-look 의 언어, 없으면 Claude Code /config 의 language).
99export function langSetting(options: Record<string, unknown>): Lang | 'auto' {
100 return options.language === 'en' || options.language === 'ko' ? options.language : 'auto'
101}
102hooks/util.tsx 84 lines1import type { Elements, EngineInterface, TextHoverProps, UiPressArgument } from 'claude-code'
2import { T } from './i18n'
3
4// desk-look 에서 함께 쓰던 작은 도우미를 그대로 옮겨 온 것. 원본은 desk-slack/hooks/util.tsx 이고
5// scripts/sync-shared.sh 가 desk-asana 로 복사한다(cellWidth 는 desk-look markdown.tsx 와 같아야 한다).
6// 플러그인은 자기 디렉터리만 설치되므로 desk-look 의 파일을 가져올 수 없다.
7
8// 데스크톱 앱 CDS 토큰의 역할을 엔진 테마 키에 대응시킨다(desk-look 과 같은 색).
9export const COLORS = {
10 text: 'text',
11 muted: 'inactive',
12 border: 'subtle',
13 clay: 'claude',
14 accent: 'claude',
15 link: 'suggestion',
16 code: 'error',
17 success: 'success',
18 danger: 'error',
19 warning: 'warning',
20} as const
21
22export type Els = Elements['terminal']
23
24// 터미널 칸 수: 한글·한자·전각·이모지는 두 칸.
25export function cellWidth(text: string): number {
26 let width = 0
27 for (const ch of text) {
28 const code = ch.codePointAt(0) ?? 0
29 const isWide =
30 (code >= 0x1100 && code <= 0x115f) ||
31 (code >= 0x2e80 && code <= 0xa4cf) ||
32 (code >= 0xac00 && code <= 0xd7a3) ||
33 (code >= 0xf900 && code <= 0xfaff) ||
34 (code >= 0xfe30 && code <= 0xfe4f) ||
35 (code >= 0xff00 && code <= 0xff60) ||
36 (code >= 0xffe0 && code <= 0xffe6) ||
37 (code >= 0x1f300 && code <= 0x1faff)
38 width += isWide ? 2 : 1
39 }
40 return width
41}
42
43// 칸 수 기준으로 자르고 말줄임표를 붙인다.
44export function clip(text: string, width: number): string {
45 if (cellWidth(text) <= width) return text
46 let out = ''
47 for (const ch of text) {
48 if (cellWidth(out + ch) > width - 1) break
49 out += ch
50 }
51 return `${out}…`
52}
53
54export function ago(at: number, now: number): string {
55 const minutes = Math.max(0, Math.floor((now - at) / 60_000))
56 if (minutes < 1) return T.justNow
57 if (minutes < 60) return T.minutesShort(minutes)
58 const hours = Math.floor(minutes / 60)
59 if (hours < 24) return T.hoursShort(hours)
60 return T.daysShort(Math.floor(hours / 24))
61}
62
63// 줄 전체가 눌리는 버튼: 라벨 뒤에 칩·흐린 설명(Text)을 함께 넣는다.
64type RowButtonProps = { key: string; dimColor?: boolean; hotkey?: string; hover?: TextHoverProps; onPress: (press: UiPressArgument) => void }
65
66export function rowButton(els: Els, props: RowButtonProps, label: string, ...rest: unknown[]) {
67 const { Button } = els
68 const parts = rest.filter(part => part !== null && part !== undefined && part !== false && part !== '')
69 return (
70 <Button plain {...props}>
71 {label}
72 {parts as never}
73 </Button>
74 )
75}
76
77// "Claude에게 맡기기": 입력창이 비어 있으면 채우고, 쓰던 글이 있으면 뒤에 붙인다.
78export async function fillPrompt($: EngineInterface, text: string, done = T.filled) {
79 const box = await $.prompt.read().catch(() => ({ text: '', cursor: 0 }))
80 const isEmpty = box.text.trim() === ''
81 const filled = await $.prompt.fill({ text: isEmpty ? text : `\n\n${text}`, mode: isEmpty ? 'replace' : 'append' }).catch(() => null)
82 $.ui.toast(filled?.isFilled ? done : T.fillFailed)
83}
84types/index.d.ts 47 lines1// /desk-slack: 엔진에 붙은 Slack MCP 서버(claude.ai 커넥터)로 읽는 나에게 온 DM·그룹 DM.
2export type SlackServer = { name: string }
3export type SlackPerson = { id: string; name: string }
4// 검색으로 받은 메시지 하나(남이 나에게 보낸 것). ts 는 Slack 의 "1792131860.241069", thread 는 스레드 답글이면 부모 ts.
5export type SlackMessage = { channel: string; kind: 'dm' | 'group'; participants: SlackPerson[]; from: SlackPerson; ts: string; text: string; permalink: string | null; thread: string | null }
6// 방 안 기록의 글 하나. at 은 Slack 이 적어 준 시각 글자, replies 는 스레드 답글 수(스레드 안의 글은 0).
7export type SlackPost = { ts: string; from: SlackPerson; at: string; text: string; replies: number }
8// 대화방. title 은 나를 뺀 참가자, lastFrom 은 마지막 글쓴이(내 답장이면 null), link 는 Slack 에서 여는 채널 주소.
9// lastThread 는 마지막 글이 스레드 답글이면 그 부모 ts(방을 열면 그 스레드로). 이 필드 전에 저장된 방에는 없다.
10export type SlackRoom = { id: string; kind: 'dm' | 'group'; title: string; lastTs: string; lastFrom: string | null; lastThread?: string | null; preview: string; unread: number; link: string | null }
11// $.store 에 두는 받은 편지(세션을 넘어 남고 떠 있는 세션끼리 같이 본다). watermark: 마지막으로 넣은 메시지 ts.
12// 닫은 방은 rooms 에서 지운다: 워터마크가 앞으로만 가니 새 메시지가 와야 다시 생긴다.
13export type SlackInbox = { watermark: string | null; notified: string | null; rooms: Record<string, SlackRoom> }
14// 받은 편지를 읽는 세션(임대). at 은 마지막으로 읽은 시각(ms).
15export type SlackPoller = { session: string; at: number }
16// status: none 은 Slack 서버가 없음. unruled: allow 규칙이 없는 도구(있으면 Slack 을 부르지 않는다).
17// threadUnruled: 스레드 도구에 allow 규칙이 없으면 그 이름(스레드만 열지 않는다).
18export type SlackState = { status: 'loading' | 'ok' | 'none' | 'failed'; server: SlackServer | null; unruled: string[]; threadUnruled?: string; inbox: SlackInbox; error?: string }
19// thread 는 스레드를 보고 있으면 부모 ts(posts 는 부모와 답글), 아니면 null(채널 기록).
20export type SlackView = { channel: string; thread: string | null; status: 'loading' | 'ok' | 'failed'; posts: SlackPost[]; error?: string }
21
22// /desk-fold 로 접어 둔 패널(desk-look 의 FoldedPane 과 같은 모양): 다시 열 때의 제목, 앞에 있던 것인지, 스크롤 위치.
23export type FoldedPane = { id: string; title: string; isShown: boolean; offset: number }
24
25declare module 'claude-code' {
26 interface PluginState {
27 'desk-slack': {
28 // 받은 편지(store 를 비춘 것)와 펼친 대화방(null 이면 목록).
29 slack: SlackState | null
30 slackView: SlackView | null
31 // 방 닫기 확인처럼 잠깐 켜 두는 것.
32 open: Record<string, boolean>
33 // desk-look 이 읽는 값: 안 읽은 메시지 수(아래턱 ✉ 칩), 이 세션에 떠 있음, /desk-fold 로 접어 둔 패널.
34 unread: number
35 ready: boolean
36 folded: FoldedPane[] | null
37 // 패널이 지금 열려 있음(desk-look 의 /desk-fold 가 읽는다).
38 shown: boolean
39 }
40 // desk-look 의 값 가운데 이 플러그인이 읽는 것(그쪽 types 와 같은 모양): 화면 언어, 접어 둔 desk-look 패널.
41 'desk-look': {
42 lang: 'en' | 'ko'
43 folded: FoldedPane[] | null
44 }
45 }
46}
47