SLOPSHOPPER

flex-message-th

สร้าง LINE Flex Message จากคำสั่งภาษาไทย 6 แบบ ดูตัวอย่างในแผง และได้ JSON พร้อมส่ง LINE Messaging API

newpanecommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · flex-message-th
│ ┃ flex-message-th ✕ › fix the failing auth test and add an audit log call │ ┃ ยังไม่ได้เลือก Flex (/flex show <id>) │ ⏺ 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 │ │ › /flex │ ⎿ flex-message-th: 💚 Flex Message TH (Mod 12) │ ⎿ flex-message-th: ======================================== │ ⎿ flex-message-th: สร้าง: │ ⎿ flex-message-th: /flex product <ชื่อ> <ราคา> [ราคาเดิม] [https │ ⎿ flex-message-th: /flex receipt <ร้าน> <รายการ:ราคา,...> หรือ │ ⎿ flex-message-th: /flex profile <ชื่อ> | <ตำแหน่ง> │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · flex-message-th
ยังไม่ได้เลือก Flex (/flex show <id>)
README

Mod 12: Flex Message TH

สร้าง LINE Flex Message จากคำสั่งภาษาไทยสั้นๆ แทนการเขียน JSON เอง มี template 6 แบบ (สินค้า, ใบเสร็จ, โปรไฟล์, เมนูอาหาร, บัตรสมาชิก, carousel) ดูตัวอย่างได้ในแผงด้านข้างซึ่งวาดจาก JSON จริง กดปุ่มในตัวอย่างเพื่อดูว่า LINE จะส่งข้อความอะไร แล้วเอา JSON ไปส่งผ่าน LINE Messaging API ได้ทันที Flex ที่สร้างไว้จะจำไว้ข้าม session

คำสั่ง

คำสั่งทำอะไร
/flex product เสื้อยืดลายไทย 299 399สินค้า: ชื่อ ราคา [ราคาเดิม] [https://รูป]
/flex receipt ร้านส้มตำ ส้มตำ:60,ลาบ:70ใบเสร็จ รวมยอดให้เอง (หรือ /flex receipt ร้าน 350)
`/flex profile สมชาย ใจดี \นักพัฒนา`โปรไฟล์: ชื่อ \ตำแหน่ง
/flex menu ร้านป้าแดง ส้มตำ:60,ลาบ:70เมนูอาหาร กดแต่ละแถวเพื่อสั่ง
/flex member สมชาย 1250 [Gold]บัตรสมาชิก มีแถบความคืบหน้าไประดับถัดไป
/flex carousel เสื้อ:299,หมวก:199หลายสินค้าเลื่อนดูได้ (2-12 ชิ้น)
/flex demoสร้างตัวอย่างครบ 6 แบบ
/flex listดูรายการที่สร้างไว้
/flex show <id>ดูตัวอย่างในแผง
/flex json <id>JSON พร้อมส่ง LINE Messaging API
/flex export <id>บันทึกเป็น flex-<id>.json
/flex delete <id> / /flex clearลบ / ลบทั้งหมด

ติดตั้ง

/plugin install flex-message-th --marketplace tksoft11/claude-code-mods-th

ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)

โค้ดสอนอะไร

เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:

  1. โครงสร้าง Flex Message: bubble, carousel และ component (box, text, button, image, separator)
  2. ทำ template เป็นฟังก์ชันที่คืน bubble
  3. แปลงข้อความที่พิมพ์ เช่น ส้มตำ:60,ลาบ:70 เป็นข้อมูล
  4. เก็บข้อมูลข้าม session ด้วย $.store
  5. ทำตัวอย่างแบบข้อความไว้ใช้ตอนเปิดแผงไม่ได้
  6. วาด JSON เป็น element ของ Claude Code แบบ recursive และทำปุ่มเลื่อน carousel
  7. ทำคำสั่งที่มี subcommand หลายตัว

หมายเหตุ

  • ตัวอย่างในแผงเป็นการจำลองหน้าตาคร่าวๆ ใน terminal ไม่ได้เหมือน LINE จริง 100% ก่อนส่งจริงให้ตรวจกับ Flex Message Simulator
  • รูปจะใส่ให้เฉพาะเมื่อมี URL แบบ https:// (LINE บังคับ) ถ้าไม่ใส่ การ์ดจะไม่มีรูป
  • Mod นี้ยังไม่ส่งเข้า LINE ให้เอง วิธีส่งเขียนไว้ในส่วน "การบ้าน / ต่อยอด" ท้ายไฟล์ register.tsx
Source 1 files
hooks/register.tsx 509 lines
1/**
2 * ============================================================================
3 * MOD 12: Flex Message TH - สร้าง LINE Flex Message จากคำสั่งภาษาไทย (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: LINE Flex Message (https://developers.line.biz/en/docs/messaging-api/using-flex-messages/)
6 *
7 * ปกติ Flex Message ต้องเขียน JSON ยาวๆ เอง Mod นี้สร้างให้จากคำสั่งสั้นๆ
8 *
9 * หน้าที่ของ Mod นี้:
10 * 1. Template ไทย 6 แบบ: สินค้า, ใบเสร็จ, โปรไฟล์, เมนูอาหาร, บัตรสมาชิก, carousel
11 * 2. ดูตัวอย่างในแผงด้านข้าง (วาดจาก JSON จริง กดปุ่มดูได้ว่า LINE จะส่งข้อความอะไร)
12 * 3. /flex json ได้ JSON พร้อมส่ง LINE Messaging API, /flex export บันทึกเป็นไฟล์
13 * 4. จำ Flex ที่สร้างไว้ข้าม session ด้วย $.store
14 */
15
16import type { EngineInterface, Register } from 'claude-code'
17
18// ==========================================================================
19// บทที่ 1: โครงสร้างของ Flex Message (ตามสเปคของ LINE)
20// ==========================================================================
21// bubble = การ์ด 1 ใบ มีส่วน header / hero (รูปใหญ่) / body / footer
22// carousel = bubble หลายใบเลื่อนดูได้
23// ข้างในเป็น component: box (กล่อง), text, button, image, separator
24type FlexAction = { type: 'message' | 'uri'; label: string; text?: string; uri?: string }
25
26interface FlexNode {
27  type: string
28  layout?: 'vertical' | 'horizontal' | 'baseline'
29  contents?: FlexNode[]
30  text?: string
31  color?: string
32  size?: string
33  weight?: 'regular' | 'bold'
34  decoration?: 'line-through'
35  align?: 'start' | 'center' | 'end'
36  wrap?: boolean
37  flex?: number
38  margin?: string
39  spacing?: string
40  backgroundColor?: string
41  paddingAll?: string
42  cornerRadius?: string
43  width?: string
44  height?: string
45  url?: string
46  aspectRatio?: string
47  aspectMode?: 'cover' | 'fit'
48  style?: 'primary' | 'secondary' | 'link'
49  action?: FlexAction
50}
51
52interface FlexBubble {
53  type: 'bubble'
54  header?: FlexNode
55  hero?: FlexNode
56  body?: FlexNode
57  footer?: FlexNode
58}
59
60interface FlexCarousel {
61  type: 'carousel'
62  contents: FlexBubble[]
63}
64
65interface SavedFlex {
66  id: number
67  name: string
68  altText: string // ข้อความที่แสดงในแจ้งเตือนของ LINE (บังคับต้องมี)
69  flex: FlexBubble | FlexCarousel
70  createdAt: number
71}
72
73const LINE_GREEN = '#06c755'
74
75// ฟังก์ชันช่วยสร้าง component สั้นๆ
76const text = (value: string, extra: Partial<FlexNode> = {}): FlexNode => ({ type: 'text', text: value, ...extra })
77const box = (layout: FlexNode['layout'], contents: FlexNode[], extra: Partial<FlexNode> = {}): FlexNode => ({ type: 'box', layout, contents, ...extra })
78const button = (label: string, message: string, extra: Partial<FlexNode> = {}): FlexNode => ({
79  type: 'button',
80  action: { type: 'message', label, text: message },
81  ...extra,
82})
83const baht = (n: number) => `฿${n.toLocaleString('en-US')}`
84
85// ==========================================================================
86// บทที่ 2: Template ไทย - แต่ละอันคือฟังก์ชันที่คืน bubble
87// ==========================================================================
88type Item = { name: string; price: number }
89
90function productBubble(name: string, price: number, fullPrice?: number, imageUrl?: string): FlexBubble {
91  const priceRow = [text(baht(price), { weight: 'bold', size: 'xl', color: LINE_GREEN, flex: 0 })]
92  if (fullPrice && fullPrice > price) {
93    priceRow.push(text(baht(fullPrice), { size: 'sm', color: '#aaaaaa', decoration: 'line-through', margin: 'md', flex: 0 }))
94  }
95  return {
96    type: 'bubble',
97    // hero (รูป) ใส่เฉพาะเมื่อมี URL รูปจริง เพราะ LINE บังคับต้องเป็น https
98    ...(imageUrl ? { hero: { type: 'image', url: imageUrl, size: 'full', aspectRatio: '20:13', aspectMode: 'cover' as const } } : {}),
99    body: box('vertical', [text(name, { weight: 'bold', size: 'xl', wrap: true }), box('baseline', priceRow, { margin: 'md' })]),
100    footer: box('horizontal', [button('ใส่ตะกร้า', `ใส่ ${name} ลงตะกร้า`, { style: 'secondary' }), button('ซื้อเลย', `ซื้อ ${name}`, { style: 'primary', color: LINE_GREEN })], { spacing: 'sm' }),
101  }
102}
103
104function receiptBubble(shop: string, items: Item[]): FlexBubble {
105  const total = items.reduce((sum, item) => sum + item.price, 0)
106  return {
107    type: 'bubble',
108    header: box('vertical', [text(shop, { weight: 'bold', size: 'xl', color: '#ffffff' }), text('ใบเสร็จรับเงิน', { size: 'sm', color: '#ffffff' })], {
109      backgroundColor: LINE_GREEN,
110      paddingAll: '20px',
111    }),
112    body: box('vertical', [
113      ...items.map(item => box('horizontal', [text(item.name, { size: 'sm', color: '#555555', flex: 4 }), text(baht(item.price), { size: 'sm', align: 'end', flex: 2 })], { margin: 'md' })),
114      { type: 'separator', margin: 'xl' },
115      box('horizontal', [text('รวมทั้งหมด', { size: 'sm', weight: 'bold' }), text(baht(total), { size: 'sm', weight: 'bold', align: 'end' })], { margin: 'xl' }),
116    ]),
117    footer: box('vertical', [button('ดูรายละเอียด', `ดูรายละเอียดใบเสร็จ ${shop}`, { style: 'primary', color: LINE_GREEN })]),
118  }
119}
120
121function profileBubble(name: string, role: string): FlexBubble {
122  return {
123    type: 'bubble',
124    body: box('vertical', [text(`👤 ${name}`, { weight: 'bold', size: 'lg' }), text(role, { size: 'sm', color: '#888888', wrap: true })]),
125    footer: box('horizontal', [button('ติดต่อ', `ขอติดต่อ ${name}`, { style: 'primary', color: LINE_GREEN })]),
126  }
127}
128
129function menuBubble(restaurant: string, dishes: Item[]): FlexBubble {
130  return {
131    type: 'bubble',
132    header: box('vertical', [text(`🍜 ${restaurant}`, { weight: 'bold', size: 'lg', color: '#ffffff' })], { backgroundColor: '#ff6b35', paddingAll: '15px' }),
133    // กดแต่ละแถวเพื่อสั่งได้: ใส่ action ที่ box ของแถวนั้น
134    body: box(
135      'vertical',
136      dishes.map(dish =>
137        box('horizontal', [text(dish.name, { size: 'sm', flex: 3 }), text(baht(dish.price), { size: 'sm', align: 'end', flex: 1, weight: 'bold', color: '#ff6b35' })], {
138          margin: 'md',
139          action: { type: 'message', label: dish.name.slice(0, 20), text: `สั่ง ${dish.name}` },
140        }),
141      ),
142    ),
143    footer: box('vertical', [button('ดูเมนูทั้งหมด', `ดูเมนูทั้งหมดของ ${restaurant}`, { style: 'primary', color: '#ff6b35' })]),
144  }
145}
146
147// ระดับสมาชิกตามแต้ม -> คำนวณ progress ไประดับถัดไป
148const TIERS = [
149  { name: 'Silver', min: 0 },
150  { name: 'Gold', min: 1000 },
151  { name: 'Platinum', min: 5000 },
152]
153
154function memberBubble(name: string, points: number, level?: string): FlexBubble {
155  const index = TIERS.findLastIndex(t => points >= t.min)
156  const tier = TIERS[Math.max(0, index)] ?? { name: 'Silver', min: 0 }
157  const nextTier = TIERS[index + 1]
158  const percent = nextTier ? Math.round(((points - tier.min) / (nextTier.min - tier.min)) * 100) : 100
159
160  return {
161    type: 'bubble',
162    header: box('vertical', [text('บัตรสมาชิก', { size: 'xs', color: '#ffffff' }), text(level || tier.name, { weight: 'bold', size: 'xl', color: '#ffd700', margin: 'md' })], {
163      backgroundColor: '#1a1a2e',
164      paddingAll: '20px',
165    }),
166    body: box('vertical', [
167      text(name, { weight: 'bold', size: 'md' }),
168      box('horizontal', [text('คะแนนสะสม', { size: 'xs', color: '#aaaaaa' }), text(`${points.toLocaleString('en-US')} แต้ม`, { size: 'sm', weight: 'bold', align: 'end' })], { margin: 'xl' }),
169      // progress bar = box สีเทา ข้างในมี box สีเข้มกว้างตามเปอร์เซ็นต์
170      box('vertical', [box('vertical', [], { backgroundColor: '#1a1a2e', height: '6px', width: `${percent}%`, cornerRadius: '3px' })], {
171        backgroundColor: '#eeeeee',
172        height: '6px',
173        cornerRadius: '3px',
174        margin: 'md',
175      }),
176      text(nextTier ? `อีก ${(nextTier.min - points).toLocaleString('en-US')} แต้มถึง ${nextTier.name}` : 'ระดับสูงสุดแล้ว 🎉', { size: 'xs', color: '#aaaaaa', margin: 'sm' }),
177    ]),
178    footer: box('horizontal', [button('ใช้คะแนน', 'ใช้คะแนน', { style: 'primary', color: '#1a1a2e' }), button('ประวัติ', 'ดูประวัติแต้ม', { style: 'secondary' })], { spacing: 'sm' }),
179  }
180}
181
182// ==========================================================================
183// บทที่ 3: แปลงข้อความที่พิมพ์เป็นข้อมูล
184// ==========================================================================
185// "ส้มตำ:60,ลาบ:70" -> [{ name: 'ส้มตำ', price: 60 }, { name: 'ลาบ', price: 70 }]
186function parseItems(raw: string): Item[] {
187  return raw
188    .split(',')
189    .map(part => {
190      const [name = '', price = '0'] = part.split(':')
191      return { name: name.trim(), price: Number(price.replace(/[^\d.]/g, '')) || 0 }
192    })
193    .filter(item => item.name)
194}
195
196// แยกคำที่อยู่ก่อนรายการ "a:1,b:2" ออกมาเป็นชื่อร้าน
197function splitNameAndItems(words: string[]): [string, string] {
198  const at = words.findIndex(w => w.includes(':'))
199  if (at < 0) return [words.join(' '), '']
200  return [words.slice(0, at).join(' '), words.slice(at).join(' ')]
201}
202
203// ==========================================================================
204// บทที่ 4: เก็บ Flex ที่สร้างไว้ใน $.store (จำข้าม session)
205// ==========================================================================
206let saved: SavedFlex[] = []
207let nextId = 0
208let previewId: number | undefined // Flex ที่กำลังแสดงในแผง
209let carouselIndex = 0
210let projectRoot = ''
211const PANE = 'flex-message-th'
212
213async function persist($: EngineInterface): Promise<void> {
214  await $.store.set('flexes', { saved, nextId })
215}
216
217async function saveFlex($: EngineInterface, name: string, altText: string, flex: FlexBubble | FlexCarousel): Promise<SavedFlex> {
218  const item: SavedFlex = { id: nextId++, name, altText: altText.slice(0, 400), flex, createdAt: await $.clock.now() }
219  saved = [...saved, item].slice(-30) // เก็บแค่ 30 อันล่าสุด
220  await persist($)
221  return item
222}
223
224function findFlex(arg: string): SavedFlex | undefined {
225  return saved.find(f => f.id === Number(arg))
226}
227
228// สิ่งที่ส่งเข้า LINE Messaging API จริง (ใส่ใน messages: [...])
229function toLineMessage(f: SavedFlex) {
230  return { type: 'flex', altText: f.altText, contents: f.flex }
231}
232
233function bubblesOf(f: SavedFlex): FlexBubble[] {
234  return f.flex.type === 'carousel' ? f.flex.contents : [f.flex]
235}
236
237// ==========================================================================
238// บทที่ 5: ตัวอย่างแบบข้อความ (ใช้ตอนเปิดแผงไม่ได้ เช่นรัน claude -p)
239// ==========================================================================
240function nodeToLines(node: FlexNode | undefined, indent = ''): string[] {
241  if (!node) return []
242  if (node.type === 'text') return [`${indent}${node.text ?? ''}`]
243  if (node.type === 'button') return [`${indent}[ ${node.action?.label ?? ''} ]`]
244  if (node.type === 'image') return [`${indent}🖼️ ${node.url ?? ''}`]
245  if (node.type === 'separator') return [`${indent}────────────`]
246  if (node.type !== 'box') return []
247  const children = node.contents ?? []
248  // แถวแนวนอนรวมเป็นบรรทัดเดียว
249  if (node.layout !== 'vertical') return [indent + children.flatMap(c => nodeToLines(c)).join('  ')]
250  return children.flatMap(c => nodeToLines(c, indent))
251}
252
253function bubbleToText(b: FlexBubble): string {
254  return [b.header, b.hero, b.body, b.footer].flatMap(part => nodeToLines(part, '  ')).join('\n')
255}
256
257// ==========================================================================
258// บทที่ 6: วาด Flex ในแผงด้วย element ของ Claude Code (แปลงทีละ node แบบ recursive)
259// ==========================================================================
260// สีแบบ 8 หลัก (#ffffffcc) ตัด alpha ออก ให้ทุกหน้าจอวาดได้
261function solid(color: string | undefined): string | undefined {
262  return color && /^#[0-9a-f]{6}/i.test(color) ? color.slice(0, 7) : undefined
263}
264
265export const register: Register = on => {
266  on('session.start', async ($, e, next) => {
267    projectRoot = e.cwd
268    const stored = (await $.store.get('flexes')) as { saved?: SavedFlex[]; nextId?: number } | undefined
269    saved = stored?.saved ?? []
270    nextId = stored?.nextId ?? 0
271    await $.command.register({ name: 'flex', description: 'สร้าง LINE Flex Message (/flex help)', argumentHint: '<template> ...' })
272    return next(e)
273  })
274
275  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
276    const { Box, Text, Button } = $.ui.resolve(e)
277    const current = previewId === undefined ? undefined : findFlex(String(previewId))
278    if (!current) return <Text dimColor>ยังไม่ได้เลือก Flex (/flex show &lt;id&gt;)</Text>
279
280    const bubbles = bubblesOf(current)
281    const index = Math.min(carouselIndex, bubbles.length - 1)
282    const bubble = bubbles[index]
283    if (!bubble) return <Text dimColor>Flex นี้ว่างเปล่า</Text>
284
285    // แปลง node ของ Flex เป็น element; path ใช้เป็น key ของปุ่มให้ไม่ซ้ำกัน
286    const draw = (node: FlexNode | undefined, path: string): JSX.Element | null => {
287      if (!node) return null
288      switch (node.type) {
289        case 'text':
290          return (
291            <Text color={solid(node.color)} bold={node.weight === 'bold'} strikethrough={node.decoration === 'line-through'} wrap="wrap">
292              {node.text ?? ''}
293            </Text>
294          )
295        case 'button': {
296          const action = node.action
297          return (
298            <Button
299              key={`btn-${path}`}
300              label={action?.label ?? 'ปุ่ม'}
301              variant={node.style === 'primary' ? 'primary' : undefined}
302              onPress={() => $.ui.toast(`💬 LINE จะส่ง: ${action?.text ?? action?.uri ?? ''}`)}
303            />
304          )
305        }
306        case 'image':
307          return <Text dimColor>🖼️ รูป {node.aspectRatio ?? ''}</Text>
308        case 'separator':
309          return <Text dimColor>────────────────</Text>
310        case 'box': {
311          const isRow = node.layout === 'horizontal' || node.layout === 'baseline'
312          const children = (node.contents ?? []).map((child, i) => draw(child, `${path}-${i}`))
313          // progress bar (box ว่างที่มีความกว้าง) วาดเป็นแถบตัวอักษร
314          if ((node.contents ?? []).length === 0 && node.width) {
315            const filled = Math.round((parseInt(node.width, 10) || 0) / 10)
316            return <Text color={solid(node.backgroundColor)}>{'█'.repeat(filled) + '░'.repeat(10 - filled)}</Text>
317          }
318          return (
319            <Box flexDirection={isRow ? 'row' : 'column'} gap={isRow ? 1 : 0} justifyContent={isRow ? 'space-between' : undefined} backgroundColor={solid(node.backgroundColor)} paddingX={node.backgroundColor ? 1 : 0}>
320              {children}
321            </Box>
322          )
323        }
324        default:
325          return null
326      }
327    }
328
329    return (
330      <Box flexDirection="column" gap={1}>
331        <Text dimColor>
332          #{current.id} {current.name}
333          {bubbles.length > 1 ? ` · ใบที่ ${index + 1}/${bubbles.length}` : ''}
334        </Text>
335        <Box flexDirection="column" borderStyle="round" borderColor={LINE_GREEN}>
336          {draw(bubble.header, 'h')}
337          {draw(bubble.hero, 'i')}
338          {draw(bubble.body, 'b')}
339          {draw(bubble.footer, 'f')}
340        </Box>
341        {bubbles.length > 1 && (
342          <Box gap={2}>
343            <Button key="prev" label="◀ ก่อนหน้า" onPress={() => {
344              carouselIndex = Math.max(0, index - 1)
345              $.ui.invalidate('ui.render')
346            }} />
347            <Button key="next" label="ถัดไป ▶" onPress={() => {
348              carouselIndex = Math.min(bubbles.length - 1, index + 1)
349              $.ui.invalidate('ui.render')
350            }} />
351          </Box>
352        )}
353      </Box>
354    )
355  })
356
357  // ==========================================================================
358  // บทที่ 7: คำสั่ง /flex
359  // ==========================================================================
360  on('command.run', { command: 'flex' }, async ($, e) => {
361    const words = e.args.trim().split(/\s+/).filter(Boolean)
362    const [sub = 'help', ...rest] = words
363
364    // สร้างแล้วเปิดแผงให้ดูทันที (ถ้าเปิดไม่ได้ แสดงเป็นข้อความแทน)
365    const created = async (f: SavedFlex) => {
366      previewId = f.id
367      carouselIndex = 0
368      const opened = await $.ui.open({ id: PANE, title: '💚 Flex Preview' })
369      $.ui.invalidate('ui.render')
370      const preview = opened.isPlaced ? '' : `\n${bubblesOf(f).map(bubbleToText).join('\n  ──── ใบถัดไป ────\n')}`
371      return { text: `💚 สร้าง Flex #${f.id} ${f.name} แล้ว (/flex json ${f.id} เพื่อเอา JSON)${preview}` }
372    }
373
374    switch (sub) {
375      case 'product': {
376        // /flex product เสื้อยืดลายไทย 299 [ราคาเดิม] [https://รูป]
377        const url = rest.find(w => /^https:\/\//.test(w))
378        const numbers = rest.filter(w => /^\d+(\.\d+)?$/.test(w)).map(Number)
379        const name = rest.filter(w => w !== url && !/^\d+(\.\d+)?$/.test(w)).join(' ')
380        if (!name || numbers[0] === undefined) return { text: 'วิธีใช้: /flex product <ชื่อ> <ราคา> [ราคาเดิม] [https://รูป]' }
381        return created(await saveFlex($, `สินค้า ${name}`, `${name} ราคา ${baht(numbers[0])}`, productBubble(name, numbers[0], numbers[1], url)))
382      }
383      case 'receipt': {
384        // /flex receipt ร้านส้มตำ ส้มตำ:60,ลาบ:70  หรือ  /flex receipt ร้านส้มตำ 350
385        const [shop, rawItems] = splitNameAndItems(rest)
386        let items = parseItems(rawItems)
387        if (items.length === 0) {
388          const total = Number(rest.at(-1))
389          if (!total) return { text: 'วิธีใช้: /flex receipt <ร้าน> <รายการ:ราคา,...>  หรือ  /flex receipt <ร้าน> <ยอดรวม>' }
390          items = [{ name: 'ยอดชำระ', price: total }]
391          const shopName = rest.slice(0, -1).join(' ')
392          return created(await saveFlex($, `ใบเสร็จ ${shopName}`, `ใบเสร็จ ${shopName} ${baht(total)}`, receiptBubble(shopName, items)))
393        }
394        const total = items.reduce((s, i) => s + i.price, 0)
395        return created(await saveFlex($, `ใบเสร็จ ${shop}`, `ใบเสร็จ ${shop} ${baht(total)}`, receiptBubble(shop, items)))
396      }
397      case 'profile': {
398        // /flex profile สมชาย ใจดี | นักพัฒนา Full-Stack
399        const joined = rest.join(' ')
400        const [name = '', role = ''] = joined.includes('|') ? joined.split('|').map(s => s.trim()) : [rest[0] ?? '', rest.slice(1).join(' ')]
401        if (!name) return { text: 'วิธีใช้: /flex profile <ชื่อ> | <ตำแหน่ง>' }
402        return created(await saveFlex($, `โปรไฟล์ ${name}`, `โปรไฟล์ ${name}`, profileBubble(name, role)))
403      }
404      case 'menu': {
405        // /flex menu ร้านป้าแดง ส้มตำ:60,ลาบ:70,ข้าวเหนียว:20
406        const [restaurant, rawItems] = splitNameAndItems(rest)
407        const dishes = parseItems(rawItems)
408        if (!restaurant || dishes.length === 0) return { text: 'วิธีใช้: /flex menu <ร้าน> <เมนู:ราคา,เมนู:ราคา>' }
409        return created(await saveFlex($, `เมนู ${restaurant}`, `เมนูร้าน ${restaurant}`, menuBubble(restaurant, dishes)))
410      }
411      case 'member': {
412        // /flex member สมชาย 1250 [Gold]
413        const at = rest.findIndex(w => /^\d+$/.test(w))
414        if (at <= 0) return { text: 'วิธีใช้: /flex member <ชื่อ> <แต้ม> [ระดับ]' }
415        const name = rest.slice(0, at).join(' ')
416        const points = Number(rest[at])
417        return created(await saveFlex($, `สมาชิก ${name}`, `บัตรสมาชิก ${name} ${points} แต้ม`, memberBubble(name, points, rest.slice(at + 1).join(' ') || undefined)))
418      }
419      case 'carousel': {
420        // /flex carousel เสื้อยืด:299,กางเกง:459,หมวก:199
421        const items = parseItems(rest.join(' '))
422        if (items.length < 2) return { text: 'วิธีใช้: /flex carousel <ชื่อ:ราคา,ชื่อ:ราคา,...> (อย่างน้อย 2 ชิ้น สูงสุด 12)' }
423        const flex: FlexCarousel = { type: 'carousel', contents: items.slice(0, 12).map(i => productBubble(i.name, i.price)) }
424        return created(await saveFlex($, `Carousel ${items.length} ชิ้น`, `สินค้า ${items.map(i => i.name).join(', ')}`, flex))
425      }
426      case 'demo': {
427        await saveFlex($, 'สินค้า เสื้อยืดลายไทย', 'เสื้อยืดลายไทย ราคา ฿299', productBubble('เสื้อยืดลายไทย', 299, 399))
428        await saveFlex($, 'ใบเสร็จ ร้านส้มตำป้าแดง', 'ใบเสร็จ ร้านส้มตำป้าแดง ฿150', receiptBubble('ร้านส้มตำป้าแดง', parseItems('ส้มตำไทย:60,ลาบหมู:70,ข้าวเหนียว:20')))
429        await saveFlex($, 'โปรไฟล์ สมชาย ใจดี', 'โปรไฟล์ สมชาย ใจดี', profileBubble('สมชาย ใจดี', 'นักพัฒนา Full-Stack'))
430        await saveFlex($, 'เมนู ร้านป้าแดง', 'เมนูร้านป้าแดง', menuBubble('ร้านป้าแดง', parseItems('ส้มตำ:60,ลาบ:70,ข้าวเหนียว:20,ไก่ย่าง:120')))
431        await saveFlex($, 'สมาชิก สมชาย', 'บัตรสมาชิก สมชาย 1250 แต้ม', memberBubble('สมชาย', 1250))
432        const last = await saveFlex($, 'Carousel 3 ชิ้น', 'สินค้า เสื้อยืด, กางเกง, หมวก', {
433          type: 'carousel',
434          contents: parseItems('เสื้อยืด:299,กางเกง:459,หมวก:199').map(i => productBubble(i.name, i.price)),
435        })
436        const result = await created(last)
437        return { text: `🎨 สร้างตัวอย่าง 6 แบบแล้ว (#${last.id - 5} ถึง #${last.id}) ดูรายการด้วย /flex list\n${result.text}` }
438      }
439      case 'list': {
440        if (saved.length === 0) return { text: 'ยังไม่มี Flex ลอง /flex demo' }
441        return { text: ['💚 Flex ที่สร้างไว้', ...saved.map(f => `  #${f.id} ${f.name} (${f.flex.type})`)].join('\n') }
442      }
443      case 'show': {
444        const f = findFlex(rest[0] ?? '')
445        if (!f) return { text: `❌ ไม่พบ Flex #${rest[0] ?? ''} (ดูรายการด้วย /flex list)` }
446        return created(f)
447      }
448      case 'json': {
449        const f = findFlex(rest[0] ?? '')
450        if (!f) return { text: `❌ ไม่พบ Flex #${rest[0] ?? ''}` }
451        return { text: `JSON ของ Flex #${f.id} (ใส่ใน messages: [...] ของ LINE Messaging API)\n\`\`\`json\n${JSON.stringify(toLineMessage(f), null, 2)}\n\`\`\`` }
452      }
453      case 'export': {
454        const f = findFlex(rest[0] ?? '')
455        if (!f) return { text: `❌ ไม่พบ Flex #${rest[0] ?? ''}` }
456        const sep = projectRoot.includes('\\') ? '\\' : '/'
457        const path = `${projectRoot}${sep}flex-${f.id}.json`
458        await $.fs.write(path, JSON.stringify(toLineMessage(f), null, 2) + '\n')
459        return { text: `💾 บันทึกแล้ว: ${path}` }
460      }
461      case 'delete': {
462        const f = findFlex(rest[0] ?? '')
463        if (!f) return { text: `❌ ไม่พบ Flex #${rest[0] ?? ''}` }
464        saved = saved.filter(x => x !== f)
465        await persist($)
466        if (previewId === f.id) {
467          previewId = undefined
468          $.ui.invalidate('ui.render')
469        }
470        return { text: `🗑️ ลบ Flex #${f.id} แล้ว` }
471      }
472      case 'clear':
473        saved = []
474        previewId = undefined
475        await persist($)
476        $.ui.invalidate('ui.render')
477        return { text: '🗑️ ลบ Flex ทั้งหมดแล้ว' }
478      default:
479        return {
480          text: `💚 Flex Message TH (Mod 12)
481========================================
482สร้าง:
483  /flex product <ชื่อ> <ราคา> [ราคาเดิม] [https://รูป]
484  /flex receipt <ร้าน> <รายการ:ราคา,...>   หรือ   <ร้าน> <ยอดรวม>
485  /flex profile <ชื่อ> | <ตำแหน่ง>
486  /flex menu <ร้าน> <เมนู:ราคา,เมนู:ราคา>
487  /flex member <ชื่อ> <แต้ม> [ระดับ]
488  /flex carousel <ชื่อ:ราคา,ชื่อ:ราคา,...>
489  /flex demo                 สร้างตัวอย่างครบ 6 แบบ
490
491จัดการ:
492  /flex list | show <id> | json <id> | export <id> | delete <id> | clear`,
493        }
494    }
495  })
496}
497
498/**
499 * ========================================================================
500 * การบ้าน / ต่อยอด
501 * ========================================================================
502 * 1. ส่งเข้า LINE จริง: เพิ่ม userConfig (sensitive) สำหรับ Channel Access Token แล้วใช้
503 *    $.http.fetch("https://api.line.me/v2/bot/message/push", { method: "POST",
504 *      headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
505 *      body: JSON.stringify({ to: userId, messages: [toLineMessage(f)] }) })
506 * 2. ให้ Claude สร้าง Flex จากคำบรรยาย: $.prompt.submit("สร้าง Flex เมนูร้านกาแฟ ...") แล้วตรวจ JSON ที่ได้
507 * 3. ตรวจ JSON กับ Flex Message Simulator ของ LINE ก่อนส่งจริง
508 */
509