긴 작업을 맡기고 자리를 비워도 끝나거나 확인이 필요할 때 데스크톱·음성·휴대폰 푸시로 알려줘요

긴 작업을 Claude에게 맡기고 자리를 비워도, 끝나거나 확인이 필요할 때 데스크톱·음성·휴대폰 푸시로 알려줘요.
/plugin marketplace add SeongGwangJu/k-mods
/plugin install done-alarm@k-mods
설치하면 바로 동작해요. 따로 명령어를 외울 필요 없이, 기본값(데스크톱 알림 켜짐)만으로:
AskUserQuestion), 한참 가만히 있으면 "확인이 필요해요" 알림이 뜹니다.상태가 궁금하거나 채널을 바로 확인하고 싶을 때만 /alarm을 씁니다.
/alarm. 어떤 채널이 켜져 있는지, 기준 시간, 음소거 여부를 보여줘요/alarm test. 켜진 채널마다 테스트 알림을 보내고 채널별로 ✓/✗ 결과를 보여줘요/alarm off / /alarm on. 이번 세션에서만 알림을 끄고 켭니다 (세션을 새로 시작하면 다시 켜져요)음소거 중에는 입력창 위 힌트 줄 끝에 · 알림 꺼짐이 조그맣게 표시됩니다.
/config 또는 /plugin configure done-alarm@k-mods)| 항목 | 기본값 | 설명 | | :- | :- | :- | | 최소 작업 시간(초) | 30 | 이보다 짧게 끝난 작업은 알리지 않아요 | | 데스크톱 알림 | 켜짐 | macOS는 알림센터, Linux는 notify-send | | 음성 알림 | 꺼짐 | 짧은 한국어 문장을 소리 내어 읽어요 | | ntfy 주제(topic) | (없음) | 휴대폰 무료 푸시. 아래 "ntfy로 휴대폰 받기" 참고 | | ntfy 서버 | https://ntfy.sh | 직접 운영하는 서버가 있으면 바꾸세요 | | Telegram 봇 토큰 / 채팅 ID | (없음) | 아래 "Telegram으로 받기" 참고 | | Webhook 주소 | (없음) | Slack·Discord Incoming Webhook. 아래 참고 | | 답변 요약 포함 | 꺼짐 | 외부로 나가는 알림에 Claude 답변 첫 줄을 함께 보내요. 데스크톱 알림에는 항상 포함됩니다 |
토큰·주소 같은 민감한 값은 /config 화면과 /alarm 상태에 절대 원문으로 보이지 않고 ●●●로만 표시됩니다.
jsg-done-alarm-7x9k2)./plugin configure done-alarm@k-mods에서 "ntfy 주제"에 같은 이름을 넣으세요./alarm test로 휴대폰에 알림이 오는지 확인하세요./newbot으로 봇을 만들고 토큰을 받으세요.https://api.telegram.org/bot<토큰>/getUpdates를 열어 chat.id 값을 확인하세요./plugin configure done-alarm@k-mods에서 봇 토큰과 채팅 ID를 넣으세요.https://hooks.slack.com/services/... 형태의 주소를 받습니다.https://discord.com/api/webhooks/... 주소를 받습니다.{text}로, Discord는 {content}로 보냅니다.Claude Code의 이벤트 두 가지를 지켜봐요.
turn.complete): 메인 대화에서, 중단되지 않고, 설정한 시간 이상 걸렸을 때만 "작업 끝" 알림을 보냅니다. 서브에이전트(하위 작업자)의 턴은 세지 않아요.Notification의 거울상인 classic.Notification. Claude가 권한을 묻거나 한참 가만히 있을 때 Claude Code 자체가 보내는 신호예요.AskUserQuestion 도구 호출(tool.call). Claude가 선택지를 묻는 질문을 띄우려 할 때예요. 이 mod는 지켜보기만 하고 질문을 막지 않아요.알림을 보내는 동안 세션이 멈추지 않도록, 알림 전송은 기다리지 않고 바로 흘려보냅니다(실패해도 세션에는 영향이 없어요).
osascript(데스크톱 알림), Linux에서 notify-send, 처음 한 번 uname(OS 확인). 셸을 거치지 않고 프로그램만 직접 실행해요.$.audio.speak로 시스템 음성 합성기를 씁니다(macOS는 say와 같은 것).api.telegram.org, 그리고 설정한 Webhook 주소. 설정하지 않은 채널은 아무 데도 연결하지 않아요. 그 외의 텔레메트리·분석 전송은 없습니다.classic.Notification의 메시지, 또는 질문 내용)을 그대로 씁니다. 영어로 올 수도 있어요./alarm test, /alarm 상태에서 확인 가능)./alarm off는 "이번 세션"에만 적용돼요. 다음 세션을 새로 시작하면 다시 켜진 상태로 돌아갑니다.이 저장소를 위해 새로 작성한 mod입니다 (다른 프로젝트를 고친 수정판이 아니에요). 저장소 전체의 라이선스(MIT)를 따릅니다.
테스트한 Claude Code 버전: 2.1.291
hooks/register.ts 310 lines1import type { EngineInterface, On, Register } from 'claude-code'
2
3import {
4 buildAppleScript,
5 buildFinishedMessage,
6 buildNeedsYouMessage,
7 buildStatusText,
8 buildTestReportText,
9 enabledChannelIds,
10 firstLineSummary,
11 normalizeOptions,
12 parseAlarmArgs,
13 projectNameOf,
14 rfc2047,
15 shouldNotifyFinished,
16 shouldNotifyNeedsYou,
17 truncate,
18 webhookBody,
19 type ChannelId,
20 type ChannelResult,
21 type NormalizedOptions,
22} from './format.js'
23
24// 한 번 울리면 이 안에서는 "확인이 필요해요" 알림을 다시 보내지 않는다.
25const NEEDS_YOU_DEBOUNCE_MS = 60_000
26
27const PHRASE_FINISHED = '작업이 끝났어요'
28const PHRASE_NEEDS_YOU = '확인이 필요해요'
29const TEST_MESSAGE = '🔔 done-alarm 테스트 알림이에요'
30const TITLE = 'Claude Code'
31
32type Platform = 'mac' | 'linux' | 'other'
33
34// session.start에서 한 번 정해 두는 값들. 모듈이 다시 로드되기 전까지는 그대로다.
35let platform: Platform = 'other'
36let muted = false
37let lastNeedsYouAt = -Infinity
38// 채널별로 가장 최근에 보내 본 결과. /alarm 상태에서 "최근 실패"를 보여주는 데 쓴다.
39const lastResult: Partial<Record<ChannelId, ChannelResult>> = {}
40
41type NotifyContext = {
42 platform: Platform
43 desktopMessage: string
44 remoteMessage: string
45 voicePhrase: string
46 tags: string
47 project: string
48}
49
50// ---------------------------------------------------------------------------
51// $를 받는 도우미는 전부 이 파일 최상위에 둔다 (정적 분석 규칙).
52// ---------------------------------------------------------------------------
53
54/** uname으로 한 번만 플랫폼을 확인한다. 실패하면 "기타"로 두고 데스크톱 알림은 건너뛴다. */
55async function detectPlatform($: EngineInterface): Promise<Platform> {
56 try {
57 const result = await $.process.run(['uname'])
58 const name = result.stdout.trim()
59 if (name === 'Darwin') return 'mac'
60 if (name === 'Linux') return 'linux'
61 return 'other'
62 } catch {
63 return 'other'
64 }
65}
66
67/** macOS는 osascript, Linux는 notify-send. 프로그램이 없으면 실패를 기록하고 넘어간다. */
68async function sendDesktop(
69 $: EngineInterface,
70 platform: Platform,
71 message: string,
72 project: string,
73): Promise<ChannelResult> {
74 try {
75 if (platform === 'mac') {
76 const script = buildAppleScript(message, project)
77 const result = await $.process.run(['osascript', '-e', script])
78 if (result.exitCode !== 0) return { ok: false, error: result.stderr.trim() || 'osascript 실행이 실패했어요' }
79 return { ok: true }
80 }
81 if (platform === 'linux') {
82 const result = await $.process.run(['notify-send', TITLE, message])
83 if (result.exitCode !== 0) return { ok: false, error: result.stderr.trim() || 'notify-send 실행이 실패했어요' }
84 return { ok: true }
85 }
86 return { ok: false, error: '데스크톱 알림을 지원하지 않는 OS예요' }
87 } catch (error) {
88 return { ok: false, error: error instanceof Error ? error.message : String(error) }
89 }
90}
91
92/** Yuna(한국어 음성)로 먼저 시도하고, 설치돼 있지 않으면 기본 음성으로 한 번 더 시도한다. */
93async function sendVoice($: EngineInterface, phrase: string): Promise<ChannelResult> {
94 try {
95 await $.audio.speak(phrase, { voice: 'Yuna' })
96 return { ok: true }
97 } catch {
98 try {
99 await $.audio.speak(phrase)
100 return { ok: true }
101 } catch (error) {
102 return { ok: false, error: error instanceof Error ? error.message : String(error) }
103 }
104 }
105}
106
107/** ntfy: 본문은 평문, 제목은 (필요하면) RFC 2047로 감싸 헤더가 한글 때문에 깨지지 않게 한다. */
108async function sendNtfy(
109 $: EngineInterface,
110 server: string,
111 topic: string,
112 message: string,
113 tags: string,
114): Promise<ChannelResult> {
115 try {
116 const url = `${server.replace(/\/+$/, '')}/${encodeURIComponent(topic)}`
117 const response = await $.http.fetch(url, {
118 method: 'POST',
119 body: message,
120 headers: { Title: rfc2047(TITLE), Tags: tags },
121 })
122 if (!response.ok) return { ok: false, error: `ntfy가 ${response.status}을 돌려줬어요` }
123 return { ok: true }
124 } catch (error) {
125 return { ok: false, error: error instanceof Error ? error.message : String(error) }
126 }
127}
128
129async function sendTelegram($: EngineInterface, token: string, chatId: string, text: string): Promise<ChannelResult> {
130 try {
131 const response = await $.http.fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
132 method: 'POST',
133 headers: { 'Content-Type': 'application/json' },
134 body: JSON.stringify({ chat_id: chatId, text }),
135 })
136 if (!response.ok) return { ok: false, error: `Telegram이 ${response.status}을 돌려줬어요` }
137 return { ok: true }
138 } catch (error) {
139 return { ok: false, error: error instanceof Error ? error.message : String(error) }
140 }
141}
142
143async function sendWebhook($: EngineInterface, url: string, text: string): Promise<ChannelResult> {
144 try {
145 const response = await $.http.fetch(url, {
146 method: 'POST',
147 headers: { 'Content-Type': 'application/json' },
148 body: JSON.stringify(webhookBody(url, text)),
149 })
150 if (!response.ok) return { ok: false, error: `Webhook이 ${response.status}을 돌려줬어요` }
151 return { ok: true }
152 } catch (error) {
153 return { ok: false, error: error instanceof Error ? error.message : String(error) }
154 }
155}
156
157/** 채널 하나를 보내고 결과를 lastResult에 남긴다. 실제 트리거와 /alarm test가 함께 쓴다. */
158async function runChannel(
159 $: EngineInterface,
160 id: ChannelId,
161 cfg: NormalizedOptions,
162 ctx: NotifyContext,
163): Promise<ChannelResult> {
164 const result = await (id === 'desktop'
165 ? sendDesktop($, ctx.platform, ctx.desktopMessage, ctx.project)
166 : id === 'voice'
167 ? sendVoice($, ctx.voicePhrase)
168 : id === 'ntfy'
169 ? sendNtfy($, cfg.ntfyServer, cfg.ntfyTopic, ctx.remoteMessage, ctx.tags)
170 : id === 'telegram'
171 ? sendTelegram($, cfg.telegramBotToken, cfg.telegramChatId, ctx.remoteMessage)
172 : sendWebhook($, cfg.webhookUrl, ctx.remoteMessage))
173 lastResult[id] = result
174 return result
175}
176
177/** 켜진 채널 전부에 동시에 보낸다. 하나가 실패해도 나머지는 그대로 진행한다(allSettled). */
178async function notifyAll($: EngineInterface, cfg: NormalizedOptions, ctx: NotifyContext): Promise<void> {
179 const ids = enabledChannelIds(cfg)
180 await Promise.allSettled(ids.map((id) => runChannel($, id, cfg, ctx)))
181}
182
183async function notifyFinished(
184 $: EngineInterface,
185 cfg: NormalizedOptions,
186 durationMs: number,
187 answer: string,
188): Promise<void> {
189 const project = projectNameOf(await $.session.cwd())
190 const summary = firstLineSummary(answer, 80)
191 await notifyAll($, cfg, {
192 platform,
193 desktopMessage: buildFinishedMessage({ durationMs, project, summary: summary || undefined }),
194 remoteMessage: buildFinishedMessage({ durationMs, project, summary: cfg.includeSummary && summary ? summary : undefined }),
195 voicePhrase: PHRASE_FINISHED,
196 tags: 'white_check_mark',
197 project,
198 })
199}
200
201async function notifyNeedsYou($: EngineInterface, cfg: NormalizedOptions, reason: string): Promise<void> {
202 const project = projectNameOf(await $.session.cwd())
203 const message = buildNeedsYouMessage({ project, reason: truncate(reason, 60) })
204 await notifyAll($, cfg, {
205 platform,
206 desktopMessage: message,
207 remoteMessage: message,
208 voicePhrase: PHRASE_NEEDS_YOU,
209 tags: 'raising_hand',
210 project,
211 })
212}
213
214async function runTest($: EngineInterface, cfg: NormalizedOptions): Promise<string> {
215 const project = projectNameOf(await $.session.cwd())
216 const ids = enabledChannelIds(cfg)
217 const ctx: NotifyContext = {
218 platform,
219 desktopMessage: TEST_MESSAGE,
220 remoteMessage: TEST_MESSAGE,
221 voicePhrase: TEST_MESSAGE,
222 tags: 'test_tube',
223 project,
224 }
225 const results: Partial<Record<ChannelId, ChannelResult>> = {}
226 await Promise.all(
227 ids.map(async (id) => {
228 results[id] = await runChannel($, id, cfg, ctx)
229 }),
230 )
231 return buildTestReportText(ids, results)
232}
233
234// ---------------------------------------------------------------------------
235
236export const register: Register = (on: On, options) => {
237 const cfg = normalizeOptions(options)
238
239 on('session.start', async ($, e, next) => {
240 await $.command.register({
241 name: 'alarm',
242 description: '작업 끝 알림 상태 확인 · 테스트 · 끄기/켜기',
243 argumentHint: '[test|on|off]',
244 immediate: true,
245 })
246 platform = await detectPlatform($)
247 return next(e)
248 })
249
250 // 트리거 1: 메인 대화의 턴이 끝났을 때. 세션 흐름을 늦추지 않도록 알림은 기다리지 않고 던진다.
251 on('turn.complete', async ($, e, next) => {
252 if (!muted && shouldNotifyFinished(e, cfg.minSeconds)) {
253 notifyFinished($, cfg, e.durationMs, e.answer).catch(() => {})
254 }
255 return next(e)
256 })
257
258 // 트리거 2-가: 설정 훅 Notification의 미러. 권한이 필요하거나 가만히 있은 지 오래됐을 때 온다.
259 // validate가 이것도 "막을 수 있는 훅"으로 보길래, AskUserQuestion과 같은 이유로 .catch를 단다.
260 on('classic.Notification', async ($, e, next) => {
261 if (!muted) {
262 const now = await $.clock.now()
263 if (shouldNotifyNeedsYou(lastNeedsYouAt, now, NEEDS_YOU_DEBOUNCE_MS)) {
264 lastNeedsYouAt = now
265 notifyNeedsYou($, cfg, e.message).catch(() => {})
266 }
267 }
268 return next(e)
269 }).catch(($, e, next) => next(e))
270
271 // 트리거 2-나: AskUserQuestion이 걸려 있는 것도 "확인이 필요해요"다. 막을 수 있는 훅이라
272 // .catch로 늘 통과시킨다. 이 mod는 지켜보기만 하고, 질문을 절대 막지 않는다.
273 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
274 if (!muted && e.tool === 'AskUserQuestion') {
275 const now = await $.clock.now()
276 if (shouldNotifyNeedsYou(lastNeedsYouAt, now, NEEDS_YOU_DEBOUNCE_MS)) {
277 lastNeedsYouAt = now
278 const reason = e.questions[0]?.question ?? '질문이 있어요'
279 notifyNeedsYou($, cfg, reason).catch(() => {})
280 }
281 }
282 return next(e)
283 }).catch(($, e, next) => next(e))
284
285 on('command.run', { command: 'alarm' }, async ($, e) => {
286 const action = parseAlarmArgs(e.args)
287 if (action === 'on') {
288 muted = false
289 $.ui.invalidate('ui.render')
290 return { text: '🔔 알림을 켰어요.' }
291 }
292 if (action === 'off') {
293 muted = true
294 $.ui.invalidate('ui.render')
295 return { text: '🔕 알림을 껐어요. 이 세션에서만 적용돼요.' }
296 }
297 if (action === 'test') {
298 return { text: await runTest($, cfg) }
299 }
300 return { text: buildStatusText(cfg, muted, lastResult) }
301 })
302
303 // 음소거일 때만 입력창 위 힌트 줄 끝에 짧게 표시한다. 그 외엔 아무 것도 그리지 않는다.
304 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
305 if (!muted) return next(e)
306 const tail = [e.props.tail, '알림 꺼짐'].filter(Boolean).join(' · ')
307 return next({ ...e, props: { ...e.props, tail } })
308 })
309}
310hooks/format.ts 225 lines1// $를 받지 않는 순수 함수만 모아 둔 파일. register.ts가 가져다 쓴다.
2// (CONTRIBUTING.md: "hooks/*.ts: $를 받지 않는 순수 함수. 테스트하기 쉽게 분리")
3
4/** 사용자가 켤 수 있는 알림 채널의 이름. */
5export type ChannelId = 'desktop' | 'voice' | 'ntfy' | 'telegram' | 'webhook'
6
7/** 채널 하나를 실제로 보내 본 결과. */
8export type ChannelResult = { ok: true } | { ok: false; error: string }
9
10/** plugin.json의 userConfig를 타입이 분명한 값으로 정리한 것. */
11export type NormalizedOptions = {
12 minSeconds: number
13 desktop: boolean
14 voice: boolean
15 ntfyTopic: string
16 ntfyServer: string
17 telegramBotToken: string
18 telegramChatId: string
19 webhookUrl: string
20 includeSummary: boolean
21}
22
23export const CHANNEL_IDS: readonly ChannelId[] = ['desktop', 'voice', 'ntfy', 'telegram', 'webhook']
24
25export const CHANNEL_LABELS: Record<ChannelId, string> = {
26 desktop: '데스크톱',
27 voice: '음성',
28 ntfy: 'ntfy',
29 telegram: 'Telegram',
30 webhook: 'Webhook',
31}
32
33function toBool(value: unknown, fallback: boolean): boolean {
34 return typeof value === 'boolean' ? value : fallback
35}
36
37function toNum(value: unknown, fallback: number): number {
38 return typeof value === 'number' && Number.isFinite(value) ? value : fallback
39}
40
41function toStr(value: unknown, fallback = ''): string {
42 return typeof value === 'string' ? value : fallback
43}
44
45/** register(on, options)가 받은 원값을 타입이 분명한 설정 객체로 바꾼다. */
46export function normalizeOptions(options: Record<string, unknown>): NormalizedOptions {
47 return {
48 minSeconds: toNum(options.min_seconds, 30),
49 desktop: toBool(options.desktop, true),
50 voice: toBool(options.voice, false),
51 ntfyTopic: toStr(options.ntfy_topic),
52 ntfyServer: toStr(options.ntfy_server, 'https://ntfy.sh'),
53 telegramBotToken: toStr(options.telegram_bot_token),
54 telegramChatId: toStr(options.telegram_chat_id),
55 webhookUrl: toStr(options.webhook_url),
56 includeSummary: toBool(options.include_summary, false),
57 }
58}
59
60/** 설정에서 실제로 켜져 있는 채널만 골라, 늘 같은 순서로 돌려준다. */
61export function enabledChannelIds(cfg: NormalizedOptions): ChannelId[] {
62 const ids: ChannelId[] = []
63 if (cfg.desktop) ids.push('desktop')
64 if (cfg.voice) ids.push('voice')
65 if (cfg.ntfyTopic) ids.push('ntfy')
66 if (cfg.telegramBotToken && cfg.telegramChatId) ids.push('telegram')
67 if (cfg.webhookUrl) ids.push('webhook')
68 return ids
69}
70
71/** 끝난 턴을 알릴지: 메인 대화만, 중단되지 않았고, 기준 시간 이상 걸렸을 때. */
72export function shouldNotifyFinished(
73 e: { agentId?: string; isAborted: boolean; durationMs: number },
74 minSeconds: number,
75): boolean {
76 return !e.agentId && !e.isAborted && e.durationMs >= minSeconds * 1000
77}
78
79/** "확인이 필요해요" 알림의 디바운스: 마지막 알림에서 이만큼 지나야 다시 보낸다. */
80export function shouldNotifyNeedsYou(lastAt: number, now: number, debounceMs: number): boolean {
81 return now - lastAt >= debounceMs
82}
83
84/** 1시간 2분, 3분 12초, 45초처럼 한국식으로 길이를 읽는다. */
85export function formatDuration(ms: number): string {
86 const totalSeconds = Math.max(0, Math.round(ms / 1000))
87 const hours = Math.floor(totalSeconds / 3600)
88 const minutes = Math.floor((totalSeconds % 3600) / 60)
89 const seconds = totalSeconds % 60
90 if (hours > 0) return `${hours}시간 ${minutes}분`
91 if (minutes > 0) return `${minutes}분 ${seconds}초`
92 return `${seconds}초`
93}
94
95/** 아주 단순한 마크다운 제거: 굵게·기울임·코드·링크·헤딩·목록 기호만 걷어낸다. */
96export function stripMarkdown(text: string): string {
97 return text
98 .replace(/`{1,3}([^`]*)`{1,3}/g, '$1')
99 .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
100 .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
101 .replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')
102 .replace(/^#{1,6}\s+/, '')
103 .replace(/^[-*+]\s+/, '')
104 .replace(/^>\s?/, '')
105 .trim()
106}
107
108/** text를 maxLen자 이내로 자르고, 잘렸으면 말줄임표 하나로 끝맺는다(전체 길이는 maxLen 유지). */
109export function truncate(text: string, maxLen: number): string {
110 if (text.length <= maxLen) return text
111 if (maxLen <= 1) return text.slice(0, maxLen)
112 return text.slice(0, maxLen - 1).trimEnd() + '…'
113}
114
115/** Claude 답변의 첫 줄을, 마크다운을 걷어내고 maxLen자로 잘라 요약으로 쓴다. */
116export function firstLineSummary(answer: string, maxLen = 80): string {
117 const firstLine = answer.split('\n').find((line) => line.trim().length > 0) ?? ''
118 return truncate(stripMarkdown(firstLine.trim()), maxLen)
119}
120
121/** $.session.cwd()가 돌려준 절대 경로에서 폴더 이름만 꺼낸다. */
122export function projectNameOf(cwd: string): string {
123 const normalized = cwd.replace(/[\\/]+$/, '')
124 const parts = normalized.split(/[\\/]/)
125 return parts[parts.length - 1] || cwd
126}
127
128/** "✅ 작업 끝 · 3분 12초 · my-project" (+ 요약이 있으면 다음 줄에). */
129export function buildFinishedMessage(params: { durationMs: number; project: string; summary?: string }): string {
130 const base = `✅ 작업 끝 · ${formatDuration(params.durationMs)} · ${params.project}`
131 return params.summary ? `${base}\n${params.summary}` : base
132}
133
134/** "🙋 확인이 필요해요 · my-project · <reason>". */
135export function buildNeedsYouMessage(params: { project: string; reason: string }): string {
136 return `🙋 확인이 필요해요 · ${params.project} · ${params.reason}`
137}
138
139/** AppleScript 문자열 리터럴 안에 넣을 수 있도록 \와 "를 이스케이프한다. */
140export function escapeAppleScript(text: string): string {
141 return text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
142}
143
144/**
145 * osascript -e로 넘길 전체 스크립트를 만든다. AppleScript 문자열 리터럴은 줄바꿈을
146 * 그대로 담지 못하므로, 메시지를 줄 단위로 쪼개 `& return &`로 이어 붙인다.
147 */
148export function buildAppleScript(message: string, project: string): string {
149 const body = message
150 .split('\n')
151 .map((line) => `"${escapeAppleScript(line)}"`)
152 .join(' & return & ')
153 return `display notification (${body}) with title "Claude Code" subtitle "${escapeAppleScript(project)}" sound name "Glass"`
154}
155
156/** 인쇄 가능한 ASCII만 있으면 그대로, 아니면 ntfy가 읽을 수 있는 RFC 2047(UTF-8 Base64)로 감싼다. */
157export function rfc2047(text: string): string {
158 if (/^[\x20-\x7e]*$/.test(text)) return text
159 const bytes = new TextEncoder().encode(text)
160 return `=?UTF-8?B?${bytes.toBase64()}?=`
161}
162
163/** Slack은 {text}, Discord는 {content}, 그 외는 {text}로 보낸다. */
164export function webhookBody(url: string, text: string): Record<string, string> {
165 if (/discord(app)?\.com/.test(url)) return { content: text }
166 return { text }
167}
168
169/** 값이 있으면 ●●●, 없으면 "미설정". 토큰이든 뭐든 실제 값은 절대 보여주지 않는다. */
170export function maskSensitive(value: string): string {
171 return value ? '●●●' : '미설정'
172}
173
174function onOff(flag: boolean): string {
175 return flag ? '켜짐' : '꺼짐'
176}
177
178function failureNote(id: ChannelId, lastResult: Partial<Record<ChannelId, ChannelResult>>): string {
179 const result = lastResult[id]
180 if (!result || result.ok) return ''
181 return ` (최근 실패: ${result.error})`
182}
183
184/** `/alarm`의 상태 출력. */
185export function buildStatusText(
186 cfg: NormalizedOptions,
187 muted: boolean,
188 lastResult: Partial<Record<ChannelId, ChannelResult>>,
189): string {
190 const lines: string[] = []
191 lines.push('🔔 작업 끝 알림 상태')
192 lines.push(`- 이 세션 알림: ${muted ? '꺼짐 · /alarm on으로 다시 켜세요' : '켜짐'}`)
193 lines.push(`- 데스크톱: ${onOff(cfg.desktop)}${failureNote('desktop', lastResult)}`)
194 lines.push(`- 음성: ${onOff(cfg.voice)}${failureNote('voice', lastResult)}`)
195 lines.push(`- ntfy: ${cfg.ntfyTopic ? '켜짐' : '꺼짐'}${failureNote('ntfy', lastResult)}`)
196 lines.push(
197 `- Telegram: ${cfg.telegramBotToken && cfg.telegramChatId ? `켜짐 (토큰 ${maskSensitive(cfg.telegramBotToken)})` : '꺼짐'}${failureNote('telegram', lastResult)}`,
198 )
199 lines.push(`- Webhook: ${cfg.webhookUrl ? `켜짐 (${maskSensitive(cfg.webhookUrl)})` : '꺼짐'}${failureNote('webhook', lastResult)}`)
200 lines.push(`- 기준 시간: ${cfg.minSeconds}초 이상 걸린 작업만 알려요`)
201 return lines.join('\n')
202}
203
204/** `/alarm test`의 채널별 결과 출력. */
205export function buildTestReportText(ids: ChannelId[], results: Partial<Record<ChannelId, ChannelResult>>): string {
206 if (ids.length === 0) {
207 return '🔔 켜진 채널이 없어요. /alarm 으로 상태를 보고 하나 이상 켜주세요.'
208 }
209 const lines = ['🔔 테스트 결과']
210 for (const id of ids) {
211 const result = results[id]
212 const label = CHANNEL_LABELS[id]
213 lines.push(result?.ok ? `✓ ${label}` : `✗ ${label}: ${result?.ok === false ? result.error : '알 수 없는 오류'}`)
214 }
215 return lines.join('\n')
216}
217
218/** `/alarm` 뒤에 붙는 말 한 마디를 해석한다. */
219export function parseAlarmArgs(args: string): 'status' | 'test' | 'on' | 'off' | 'unknown' {
220 const word = args.trim().split(/\s+/)[0]?.toLowerCase() ?? ''
221 if (word === '') return 'status'
222 if (word === 'test' || word === 'on' || word === 'off') return word
223 return 'unknown'
224}
225