SLOPSHOPPER

smartwatch-notify-th

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

newpanecommandnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · smartwatch-notify-th
│ ┃ smartwatch-notify-th ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ 08:53 │ ⏺ Read(src/auth.ts) │ ┃ │ ⌚ ยังไม่ตั้งค่า │ ⎿ Read 6 lines │ ┃ │ ยังไม่มีแจ้งเตือน │ ⏺ 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 │ │ › /smartwatch │ ⎿ smartwatch-notify-th: ⌚ Smartwatch Notify TH (Mod 22) │ ⎿ smartwatch-notify-th: ======================================== │ ⎿ smartwatch-notify-th: /smartwatch setup สร้าง topic ส่ว │ ⎿ smartwatch-notify-th: /smartwatch test ส่งข้อความทดสอบ │ ⎿ smartwatch-notify-th: /smartwatch on | off เปิด/ปิดการแจ้ง │ ⎿ smartwatch-notify-th: /smartwatch min <วินาที> แจ้งงานเสร็จเฉพ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · smartwatch-notify-th
╭──────────────────────────────────────────────────────────╮ │ 08:53 │ │ ⌚ ยังไม่ตั้งค่า │ │ ยังไม่มีแจ้งเตือน │ ╰──────────────────────────────────────────────────────────╯
README

Mod 22: Smartwatch Notify TH

สั่ง 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 แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:

  1. สุ่มชื่อ topic ด้วย crypto.getRandomValues
  2. ช่วงห้ามรบกวนที่ข้ามเที่ยงคืน
  3. ส่งแจ้งเตือนแบบ JSON ไป ntfy (ภาษาไทยไม่เพี้ยน)
  4. ฟัง turn.start, turn.complete และ classic.Notification (ตอน Claude รออนุญาต)
  5. หน้าปัดนาฬิกาในแผง

หมายเหตุ

ใครรู้ชื่อ topic ก็อ่านแจ้งเตือนได้ Mod จึงสุ่มชื่อยาวให้ และค่าเริ่มต้นไม่ส่งเนื้อหาคำตอบ ถ้าต้องการความเป็นส่วนตัวเต็มที่ ให้ตั้ง ntfy server ของตัวเอง แนวคิดเดิมที่อ่านแจ้งเตือน LINE/Gmail และข้อมูลสุขภาพจากมือถือ Mod ทำไม่ได้ เพราะไม่มีทางเข้าถึงข้อมูลเหล่านั้น จึงกลับทิศเป็นส่งแจ้งเตือนจาก Claude Code ไปที่มือถือและนาฬิกาแทน

Source 1 files
hooks/register.tsx 292 lines
1/**
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