ส่งแจ้งเตือนจาก Claude Code ไปมือถือและสมาร์ทวอทช์ผ่าน ntfy เมื่องานเสร็จ รออนุญาต หรือเกิด error พร้อมหน้าปัดสรุปในแผง

สั่ง Claude ทำงานยาวแล้วลุกไปทำอย่างอื่นได้เลย Mod นี้ส่งแจ้งเตือนไปที่แอป ntfy บนมือถือเมื่อ Claude ทำงานเสร็จ รออนุญาตหรือรอคำตอบ หรือเกิด error ถ้านาฬิกา (Apple Watch, Wear OS, Galaxy Watch, Mi Band) จับคู่กับมือถืออยู่ ข้อมือจะสั่นตามเอง มีช่วงห้ามรบกวนและหน้าปัดสรุปในแผงด้านข้าง
| คำสั่ง | ทำอะไร |
|---|---|
/smartwatch setup | สร้าง topic ส่วนตัว + วิธีติดตั้งแอปบนมือถือ |
/smartwatch setup new | สร้าง topic ใหม่ (ถ้าชื่อเดิมหลุด) |
/smartwatch test | ส่งข้อความทดสอบ |
/smartwatch on / off | เปิด/ปิดการแจ้งเตือน |
/smartwatch min <วินาที> | แจ้งงานเสร็จเฉพาะงานที่นานกว่านี้ (ค่าเริ่มต้น 30, 0 = ทุกงาน) |
/smartwatch dnd 22:00-07:00 / dnd off | ช่วงห้ามรบกวน |
/smartwatch preview on / off | ส่งตัวอย่างคำตอบไปด้วย (ค่าเริ่มต้น: ปิด) |
/smartwatch server <url> | ใช้ ntfy server ของตัวเอง |
/smartwatch hook add <url> | ส่งต่อไปห้องแชต Discord หรือ Slack (Incoming Webhook URL) |
/smartwatch hook list / hook remove <เลข> | ดู / ลบ webhook |
/smartwatch status | ดูการตั้งค่า + หน้าปัดในแผง |
/plugin install smartwatch-notify-th --marketplace tksoft11/claude-code-mods-th
ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)
เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:
ใครรู้ชื่อ topic ก็อ่านแจ้งเตือนได้ Mod จึงสุ่มชื่อยาวให้ และค่าเริ่มต้นไม่ส่งเนื้อหาคำตอบ ถ้าต้องการความเป็นส่วนตัวเต็มที่ ให้ตั้ง ntfy server ของตัวเอง แนวคิดเดิมที่อ่านแจ้งเตือน LINE/Gmail และข้อมูลสุขภาพจากมือถือ Mod ทำไม่ได้ เพราะไม่มีทางเข้าถึงข้อมูลเหล่านั้น จึงกลับทิศเป็นส่งแจ้งเตือนจาก Claude Code ไปที่มือถือและนาฬิกาแทน
hooks/register.tsx 292 lines1/**
2 * ============================================================================
3 * MOD 22: Smartwatch Notify TH - แจ้งเตือนจาก Claude Code ไปมือถือและนาฬิกา (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: สมาร์ทวอทช์ที่สั่นเตือนเมื่อมีเรื่องสำคัญ
6 *
7 * ปัญหา: สั่ง Claude ทำงานยาวแล้วลุกไปทำอย่างอื่น ไม่รู้ว่าเสร็จหรือยัง หรือติดรออนุญาตอยู่
8 *
9 * หน้าที่ของ Mod นี้:
10 * 1. ส่งแจ้งเตือนผ่าน ntfy (https://ntfy.sh) ไปแอป ntfy บนมือถือ
11 * นาฬิกาที่จับคู่กับมือถือ (Apple Watch, Wear OS, Galaxy Watch, Mi Band) จะสั่นตามเอง
12 * 2. แจ้งเมื่อ: งานเสร็จ (ถ้านานกว่าที่ตั้งไว้), Claude รออนุญาต/รอคำตอบ, เกิด error
13 * 3. โหมดห้ามรบกวนตามช่วงเวลา, เลือกได้ว่าจะส่งตัวอย่างคำตอบไปด้วยไหม
14 * 4. แผง "หน้าปัดนาฬิกา" สรุปแจ้งเตือนล่าสุด
15 * 5. ส่งต่อไปห้องแชตของทีมผ่าน Webhook ของ Discord / Slack ได้ด้วย
16 *
17 * ความเป็นส่วนตัว: ใครรู้ชื่อ topic ก็อ่านแจ้งเตือนได้ Mod จึงสุ่มชื่อยาวให้ และค่าเริ่มต้นไม่ส่งเนื้อหาคำตอบ
18 */
19
20import type { EngineInterface, Register } from 'claude-code'
21
22interface Settings {
23 enabled: boolean
24 server: string
25 topic: string
26 minSeconds: number // แจ้งงานเสร็จเฉพาะ turn ที่นานกว่านี้
27 dnd?: { from: string; to: string } // "22:00" - "07:00"
28 preview: boolean // ส่งข้อความตัวอย่างของคำตอบไปด้วยไหม
29 webhooks?: Webhook[] // ส่งต่อไป Discord / Slack ด้วย
30}
31interface Webhook {
32 kind: 'discord' | 'slack'
33 url: string
34}
35interface Sent {
36 at: number
37 title: string
38 ok: boolean
39}
40
41const PANE = 'smartwatch-notify-th'
42let settings: Settings = { enabled: false, server: 'https://ntfy.sh', topic: '', minSeconds: 30, preview: false }
43let sent: Sent[] = []
44let turnStartedAt = 0
45
46// ==========================================================================
47// บทที่ 1: สุ่มชื่อ topic ยาวๆ (ชื่อ topic = รหัสผ่าน ใครรู้ก็อ่านได้)
48// ==========================================================================
49function randomTopic(): string {
50 const bytes = new Uint8Array(12)
51 crypto.getRandomValues(bytes)
52 return `claude-th-${[...bytes].map(b => b.toString(16).padStart(2, '0')).join('')}`
53}
54
55// ==========================================================================
56// บทที่ 2: ช่วงห้ามรบกวน (รองรับข้ามเที่ยงคืน เช่น 22:00-07:00)
57// ==========================================================================
58function minutesOf(hhmm: string): number {
59 const [h = '0', m = '0'] = hhmm.split(':')
60 return Number(h) * 60 + Number(m)
61}
62
63function inDnd(now: number): boolean {
64 if (!settings.dnd) return false
65 const d = new Date(now)
66 const t = d.getHours() * 60 + d.getMinutes()
67 const from = minutesOf(settings.dnd.from)
68 const to = minutesOf(settings.dnd.to)
69 return from <= to ? t >= from && t < to : t >= from || t < to
70}
71
72// ==========================================================================
73// บทที่ 3: ส่งแจ้งเตือนแบบ JSON ไปที่ ntfy (ส่งภาษาไทยได้ไม่เพี้ยน)
74// ==========================================================================
75async function send($: EngineInterface, title: string, message: string, priority: 1 | 2 | 3 | 4 | 5, tags: string[], force = false): Promise<boolean> {
76 const now = await $.clock.now()
77 const hooks = settings.webhooks ?? []
78 if (!force && (!settings.enabled || (!settings.topic && hooks.length === 0) || inDnd(now))) return false
79 const post = async (url: string, body: unknown) => {
80 try {
81 return (await $.http.fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).ok
82 } catch {
83 return false
84 }
85 }
86 const results: boolean[] = []
87 if (settings.topic) results.push(await post(settings.server, { topic: settings.topic, title, message, priority, tags }))
88 // Discord ใช้ฟิลด์ content, Slack ใช้ text (รองรับ *ตัวหนา* แบบ mrkdwn)
89 for (const h of hooks) results.push(await post(h.url, h.kind === 'discord' ? { content: `**${title}**\n${message}`, username: 'Claude Code' } : { text: `*${title}*\n${message}` }))
90 const ok = results.length > 0 && results.every(Boolean)
91 sent = [...sent, { at: now, title, ok }].slice(-20)
92 $.ui.invalidate('ui.render')
93 return ok
94}
95
96async function saveSettings($: EngineInterface): Promise<void> {
97 await $.store.set('settings', settings)
98}
99
100function duration(ms: number): string {
101 const s = Math.round(ms / 1000)
102 return s < 60 ? `${s} วิ` : `${Math.floor(s / 60)} นาที ${s % 60} วิ`
103}
104
105const help = () => `⌚ Smartwatch Notify TH (Mod 22)
106========================================
107/smartwatch setup สร้าง topic ส่วนตัว + วิธีติดตั้งแอปบนมือถือ
108/smartwatch test ส่งข้อความทดสอบ
109/smartwatch on | off เปิด/ปิดการแจ้งเตือน
110/smartwatch min <วินาที> แจ้งงานเสร็จเฉพาะงานที่นานกว่านี้ (ตอนนี้ ${settings.minSeconds} วิ)
111/smartwatch dnd 22:00-07:00 | dnd off ช่วงห้ามรบกวน
112/smartwatch preview on|off ส่งตัวอย่างคำตอบของ Claude ไปด้วย (ค่าเริ่มต้น: ปิด)
113/smartwatch server <url> ใช้ ntfy server ของตัวเอง
114/smartwatch hook add <url> ส่งต่อไป Discord/Slack (Webhook URL) | hook list | hook remove <เลข>
115/smartwatch status ดูการตั้งค่า + หน้าปัดในแผง`
116
117export const register: Register = on => {
118 on('session.start', async ($, e, next) => {
119 const saved = (await $.store.get('settings')) as Partial<Settings> | undefined
120 settings = { ...settings, ...saved }
121 await $.command.register({ name: 'smartwatch', description: 'ส่งแจ้งเตือนจาก Claude Code ไปมือถือ/นาฬิกา (/smartwatch help)', argumentHint: 'setup|test|on|off|dnd|min|status' })
122 return next(e)
123 })
124
125 // ==========================================================================
126 // บทที่ 4: ฟัง event ที่ควรแจ้ง - เริ่ม/จบ turn และการแจ้งเตือนของ Claude Code เอง
127 // ==========================================================================
128 on('turn.start', async ($, e, next) => {
129 if (!e.agentId) turnStartedAt = await $.clock.now()
130 return next(e)
131 })
132
133 on('turn.complete', async ($, e, next) => {
134 const result = await next(e)
135 if (e.agentId) return result // นับเฉพาะงานหลัก ไม่นับ subagent
136 const took = (await $.clock.now()) - turnStartedAt
137 if (e.reason === 'error') {
138 void send($, '❌ Claude Code เจอ error', 'งานหยุดกลางคัน กลับไปดูที่เครื่องได้เลย', 4, ['warning'])
139 } else if (e.reason !== 'aborted' && took >= settings.minSeconds * 1000) {
140 const preview = settings.preview ? `\n${e.answer.replace(/\s+/g, ' ').slice(0, 120)}` : ''
141 void send($, '✅ Claude ทำงานเสร็จแล้ว', `ใช้เวลา ${duration(took)}${preview}`, 3, ['white_check_mark'])
142 }
143 return result
144 })
145
146 // Claude Code ยิงแจ้งเตือนเองเมื่อรออนุญาตหรือรอคำตอบนาน -> ส่งต่อไปที่นาฬิกา
147 on('classic.Notification', async ($, e, next) => {
148 void send($, '⏳ Claude รอคุณอยู่', e.message.slice(0, 160), 4, ['hourglass'])
149 return next(e)
150 })
151
152 // ==========================================================================
153 // บทที่ 5: หน้าปัดนาฬิกาในแผง
154 // ==========================================================================
155 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
156 const { Box, Text } = $.ui.resolve(e)
157 const now = new Date(await $.clock.now())
158 const clock = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
159 const state = !settings.topic && !settings.webhooks?.length ? 'ยังไม่ตั้งค่า' : !settings.enabled ? 'ปิดอยู่' : inDnd(now.getTime()) ? 'ห้ามรบกวน' : 'พร้อมแจ้ง'
160 return (
161 <Box flexDirection="column" alignItems="center" borderStyle="round" borderColor="#455a64" paddingX={2}>
162 <Text bold color="#26a69a">
163 {clock}
164 </Text>
165 <Text dimColor>⌚ {state}</Text>
166 {sent.slice(-5).reverse().map(s => (
167 <Text wrap="truncate-end">
168 {s.ok ? '·' : '✗'} {new Date(s.at).toTimeString().slice(0, 5)} {s.title}
169 </Text>
170 ))}
171 {sent.length === 0 && <Text dimColor>ยังไม่มีแจ้งเตือน</Text>}
172 </Box>
173 )
174 })
175
176 on('command.run', { command: 'smartwatch' }, async ($, e) => {
177 const [sub = 'help', arg] = e.args.trim().split(/\s+/).filter(Boolean)
178
179 switch (sub) {
180 case 'setup': {
181 if (!settings.topic || arg === 'new') settings.topic = randomTopic()
182 settings.enabled = true
183 await saveSettings($)
184 return {
185 text: [
186 '⌚ ตั้งค่าแจ้งเตือนไปมือถือ/นาฬิกา',
187 '----------------------------------------',
188 '1. ติดตั้งแอป "ntfy" บนมือถือ (App Store / Google Play)',
189 `2. กด + แล้ว Subscribe topic: ${settings.topic}`,
190 ` (server: ${settings.server})`,
191 '3. เปิดการแจ้งเตือนของแอป ntfy ในแอปนาฬิกาของมือถือ ถ้าอยากให้ข้อมือสั่น',
192 '4. พิมพ์ /smartwatch test',
193 '',
194 '🔒 ชื่อ topic คือรหัสผ่าน อย่าแชร์ ถ้าหลุดให้สร้างใหม่ด้วย /smartwatch setup new',
195 ].join('\n'),
196 }
197 }
198 case 'test': {
199 if (!settings.topic && !settings.webhooks?.length) return { text: 'ยังไม่ได้ตั้งค่า พิมพ์ /smartwatch setup หรือ /smartwatch hook add <url> ก่อน' }
200 const ok = await send($, '⌚ ทดสอบจาก Claude Code', 'ถ้าเห็นข้อความนี้ แปลว่าตั้งค่าสำเร็จ 🎉', 3, ['tada'], true)
201 return { text: ok ? '✅ ส่งแล้ว ดูที่มือถือได้เลย' : `❌ ส่งไม่สำเร็จ ตรวจ server: ${settings.server}` }
202 }
203 case 'on':
204 case 'off':
205 settings.enabled = sub === 'on'
206 await saveSettings($)
207 $.ui.invalidate('ui.render')
208 return { text: settings.enabled ? '🔔 เปิดการแจ้งเตือนแล้ว' : '🔕 ปิดการแจ้งเตือนแล้ว' }
209 case 'min':
210 if (arg === undefined || !/^\d+$/.test(arg)) return { text: 'วิธีใช้: /smartwatch min 30 (0 = แจ้งทุกงาน)' }
211 settings.minSeconds = Number(arg)
212 await saveSettings($)
213 return { text: `⏱️ แจ้งงานเสร็จเฉพาะงานที่นานกว่า ${settings.minSeconds} วินาที` }
214 case 'dnd': {
215 if (arg === 'off') {
216 settings.dnd = undefined
217 } else {
218 const m = /^(\d{1,2}:\d{2})-(\d{1,2}:\d{2})$/.exec(arg ?? '')
219 const valid = (t: string | undefined) => /^([01]?\d|2[0-3]):[0-5]\d$/.test(t ?? '')
220 if (!m || !valid(m[1]) || !valid(m[2])) return { text: 'วิธีใช้: /smartwatch dnd 22:00-07:00 หรือ /smartwatch dnd off' }
221 settings.dnd = { from: m[1] ?? '22:00', to: m[2] ?? '07:00' }
222 }
223 await saveSettings($)
224 return { text: settings.dnd ? `🌙 ห้ามรบกวน ${settings.dnd.from}-${settings.dnd.to}` : '☀️ ปิดช่วงห้ามรบกวนแล้ว' }
225 }
226 case 'preview':
227 settings.preview = arg === 'on'
228 await saveSettings($)
229 return { text: settings.preview ? '👀 จะส่งตัวอย่างคำตอบ 120 ตัวอักษรไปด้วย (ข้อมูลจะผ่าน server ของ ntfy)' : '🙈 ไม่ส่งเนื้อหาคำตอบ' }
230 case 'server': {
231 try {
232 settings.server = new URL(arg ?? '').origin
233 } catch {
234 return { text: 'วิธีใช้: /smartwatch server https://ntfy.example.com' }
235 }
236 await saveSettings($)
237 return { text: `🖥️ ใช้ server ${settings.server}` }
238 }
239 case 'hook': {
240 // ==================================================================
241 // Webhook ของ Discord / Slack - URL คือกุญแจ เก็บในเครื่อง และแสดงแบบปิดบัง
242 // ==================================================================
243 const hooks = settings.webhooks ?? []
244 const rest = e.args.trim().split(/\s+/).slice(2)
245 if (arg === 'add') {
246 const url = rest[0] ?? ''
247 let host = ''
248 try {
249 host = new URL(url).hostname
250 } catch {}
251 const kind = /(^|\.)discord(app)?\.com$/.test(host) && url.includes('/api/webhooks/') ? 'discord' : host === 'hooks.slack.com' ? 'slack' : undefined
252 if (!kind) return { text: 'วิธีใช้: /smartwatch hook add https://discord.com/api/webhooks/... หรือ https://hooks.slack.com/services/...' }
253 if (hooks.some(h => h.url === url)) return { text: 'ℹ️ มี webhook นี้อยู่แล้ว' }
254 settings.webhooks = [...hooks, { kind, url }]
255 settings.enabled = true
256 await saveSettings($)
257 const ok = await send($, '🔗 เชื่อม Claude Code สำเร็จ', 'จากนี้จะแจ้งเมื่องานเสร็จ / Claude รอคุณ / เกิด error', 3, ['link'], true)
258 return { text: `${ok ? '✅' : '⚠️'} เพิ่ม ${kind} webhook แล้ว${ok ? ' (ส่งข้อความทดสอบไปแล้ว)' : ' แต่ส่งทดสอบไม่สำเร็จ ตรวจ URL อีกที'}` }
259 }
260 if (arg === 'remove') {
261 const n = Number(rest[0])
262 if (!Number.isInteger(n) || n < 1 || n > hooks.length) return { text: 'วิธีใช้: /smartwatch hook remove 1 (ดูเลขจาก /smartwatch hook list)' }
263 settings.webhooks = hooks.filter((_, i) => i !== n - 1)
264 await saveSettings($)
265 return { text: '🗑️ ลบ webhook แล้ว' }
266 }
267 return { text: hooks.length ? ['🔗 Webhook', ...hooks.map((h, i) => ` ${i + 1}. ${h.kind} ${h.url.slice(0, 40)}…`)].join('\n') : 'ยังไม่มี webhook (/smartwatch hook add <url>)' }
268 }
269 case 'status':
270 await $.ui.open({ id: PANE, title: '⌚ Watch' })
271 return {
272 text: [
273 `⌚ ${settings.enabled ? '🔔 เปิด' : '🔕 ปิด'} | topic: ${settings.topic ? `${settings.topic.slice(0, 14)}…` : 'ยังไม่ตั้ง'} | server: ${settings.server}`,
274 `งานเสร็จ: แจ้งเมื่อนานกว่า ${settings.minSeconds} วิ | ห้ามรบกวน: ${settings.dnd ? `${settings.dnd.from}-${settings.dnd.to}` : 'ไม่ตั้ง'} | ตัวอย่างคำตอบ: ${settings.preview ? 'ส่ง' : 'ไม่ส่ง'}`,
275 `Webhook: ${(settings.webhooks ?? []).map(h => h.kind).join(', ') || 'ไม่มี'} | ส่งไปแล้ว ${sent.length} ครั้งใน session นี้`,
276 ].join('\n'),
277 }
278 default:
279 return { text: help() }
280 }
281 })
282}
283
284/**
285 * ========================================================================
286 * การบ้าน / ต่อยอด
287 * ========================================================================
288 * 1. ส่งเข้า LINE แทน ntfy: ใช้ LINE Messaging API push message (ต้องมี Channel Access Token)
289 * 2. ปุ่มกดตอบจากนาฬิกา: ntfy รองรับ actions ส่ง HTTP กลับมาที่เครื่องได้
290 * 3. แจ้งเตือนเมื่อ Mod 21 เจอ service ล่ม
291 */
292