SLOPSHOPPER

desk-slack

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…

newpanecommandtoasttimer
★ 2v0.59.2MITupdated 2026-10-26KyongSik-Yoon/cc-desktop-mod/desk-slack
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · desk-slack
│ ┃ Slack ✕ › fix the failing auth test and add an audit log call │ ┃ 0 conversations r: ↻ │ ┃ ⏺ Read(src/auth.ts) │ ┃ No Slack MCP server is connected. Connect ⎿ Read 6 lines │ ┃ Slack in /mcp. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /desk-slack │ ⎿ desk-slack: No Slack MCP server is connected. Connect Slack in / │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Slack
0 conversations r: ↻ No Slack MCP server is connected. Connect Slack in /mcp.
README

desk-slack: Slack DMs in a Claude Code side pane

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.

Install

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 DMs

The 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.

  • The list shows one row per conversation, most recent first: ● 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
  • Click a conversation for its latest 30 messages, both sides. Write in the box at the bottom and press Enter to reply there. ↻ (r in every view) reloads, ← Conversations (b, also under the reply box) goes back
  • Under a message with replies, N 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 it
  • Hand to Claude fills the prompt with the conversation's recent messages; Open in Slack opens it in Slack
  • While any Claude Code session is running, desk-slack checks for new messages once a minute and sends a notification per conversation through your notification channel (slackNotify 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 finds
  • "Unread" is desk-slack's own: opening a conversation here does not mark it read in Slack, and reading it in Slack does not clear it here
  • The Slack connector adds a "Sent using Claude" line to messages sent through it
  • desk-slack calls Slack only while all four of its Slack tools have an allow rule in permissions.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

What it runs, reads and sends

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.

Hooks and what they do

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 pane
  • session.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-fold
  • command.run: handles /desk-slack only

Settings

Search 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 otherwise
  • slackNotify: 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 notifications

License

MIT (LICENSE)

Source 5 files
hooks/register.tsx 467 lines
1import { 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}
467
hooks/slack.ts 277 lines
1import 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}
277
hooks/i18n.ts 102 lines
1// 화면 문구 사전: 영어(기본)와 한국어. 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}
102
hooks/util.tsx 84 lines
1import 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}
84
types/index.d.ts 47 lines
1// /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