import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { createAgendaCard, createAppleTvRemoteCard, createDeviceControlCard, createEventCard, createMediaPlayerCard, } from "./flex-templates.js"; import type { LineChannelData } from "./types.js"; /** * Parse LINE-specific directives from text and extract them into ReplyPayload fields. * * Supported directives: * - [[quick_replies: option1, option2, option3]] * - [[location: title | address | latitude | longitude]] * - [[confirm: question | yes_label | no_label]] * - [[buttons: title | text | btn1:data1, btn2:data2]] * - [[media_player: title | artist | source | imageUrl | playing/paused]] * - [[event: title | date | time | location | description]] * - [[agenda: title | event1_title:event1_time, event2_title:event2_time, ...]] * - [[device: name | type | status | ctrl1:data1, ctrl2:data2]] * - [[appletv_remote: name | status]] */ export function parseLineDirectives(payload: ReplyPayload): ReplyPayload { let text = payload.text; if (!text) { return payload; } const result: ReplyPayload = { ...payload }; const lineData: LineChannelData = { ...(result.channelData?.line as LineChannelData | undefined), }; const toSlug = (value: string): string => normalizeLowercaseStringOrEmpty(value) .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, "") || "device"; const lineActionData = (action: string, extras?: Record): string => { const base = [`line.action=${encodeURIComponent(action)}`]; if (extras) { for (const [key, value] of Object.entries(extras)) { base.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); } } return base.join("&"); }; const quickRepliesMatch = text.match(/\[\[quick_replies:\s*([^\]]+)\]\]/i); if (quickRepliesMatch) { const options = quickRepliesMatch[1] .split(",") .map((s) => s.trim()) .filter(Boolean); if (options.length > 0) { lineData.quickReplies = [...(lineData.quickReplies || []), ...options]; } text = text.replace(quickRepliesMatch[0], "").trim(); } const locationMatch = text.match(/\[\[location:\s*([^\]]+)\]\]/i); if (locationMatch && !lineData.location) { const parts = locationMatch[1].split("|").map((s) => s.trim()); if (parts.length >= 4) { const [title, address, latStr, lonStr] = parts; const latitude = parseFloat(latStr); const longitude = parseFloat(lonStr); if (!isNaN(latitude) && !isNaN(longitude)) { lineData.location = { title: title || "Location", address: address || "", latitude, longitude, }; } } text = text.replace(locationMatch[0], "").trim(); } const confirmMatch = text.match(/\[\[confirm:\s*([^\]]+)\]\]/i); if (confirmMatch && !lineData.templateMessage) { const parts = confirmMatch[1].split("|").map((s) => s.trim()); if (parts.length >= 3) { const [question, yesPart, noPart] = parts; const [yesLabel, yesData] = yesPart.includes(":") ? yesPart.split(":").map((s) => s.trim()) : [yesPart, normalizeLowercaseStringOrEmpty(yesPart)]; const [noLabel, noData] = noPart.includes(":") ? noPart.split(":").map((s) => s.trim()) : [noPart, normalizeLowercaseStringOrEmpty(noPart)]; lineData.templateMessage = { type: "confirm", text: question, confirmLabel: yesLabel, confirmData: yesData, cancelLabel: noLabel, cancelData: noData, altText: question, }; } text = text.replace(confirmMatch[0], "").trim(); } const buttonsMatch = text.match(/\[\[buttons:\s*([^\]]+)\]\]/i); if (buttonsMatch && !lineData.templateMessage) { const parts = buttonsMatch[1].split("|").map((s) => s.trim()); if (parts.length >= 3) { const [title, bodyText, actionsStr] = parts; const actions = actionsStr.split(",").map((actionStr) => { const trimmed = actionStr.trim(); const colonIndex = (() => { const index = trimmed.indexOf(":"); if (index === -1) { return -1; } const lower = normalizeLowercaseStringOrEmpty(trimmed); if (lower.startsWith("http://") || lower.startsWith("https://")) { return -1; } return index; })(); let label: string; let data: string; if (colonIndex === -1) { label = trimmed; data = trimmed; } else { label = trimmed.slice(0, colonIndex).trim(); data = trimmed.slice(colonIndex + 1).trim(); } if (data.startsWith("http://") || data.startsWith("https://")) { return { type: "uri" as const, label, uri: data }; } if (data.includes("=")) { return { type: "postback" as const, label, data }; } return { type: "message" as const, label, data: data || label }; }); if (actions.length > 0) { lineData.templateMessage = { type: "buttons", title, text: bodyText, actions: actions.slice(0, 4), altText: `${title}: ${bodyText}`, }; } } text = text.replace(buttonsMatch[0], "").trim(); } const mediaPlayerMatch = text.match(/\[\[media_player:\s*([^\]]+)\]\]/i); if (mediaPlayerMatch && !lineData.flexMessage) { const parts = mediaPlayerMatch[1].split("|").map((s) => s.trim()); if (parts.length >= 1) { const [title, artist, source, imageUrl, statusStr] = parts; const isPlaying = normalizeLowercaseStringOrEmpty(statusStr) === "playing"; const validImageUrl = imageUrl?.startsWith("https://") ? imageUrl : undefined; const deviceKey = toSlug(source || title || "media"); const card = createMediaPlayerCard({ title: title || "Unknown Track", subtitle: artist || undefined, source: source || undefined, imageUrl: validImageUrl, isPlaying: statusStr ? isPlaying : undefined, controls: { previous: { data: lineActionData("previous", { "line.device": deviceKey }) }, play: { data: lineActionData("play", { "line.device": deviceKey }) }, pause: { data: lineActionData("pause", { "line.device": deviceKey }) }, next: { data: lineActionData("next", { "line.device": deviceKey }) }, }, }); lineData.flexMessage = { altText: `🎵 ${title}${artist ? ` - ${artist}` : ""}`, contents: card, }; } text = text.replace(mediaPlayerMatch[0], "").trim(); } const eventMatch = text.match(/\[\[event:\s*([^\]]+)\]\]/i); if (eventMatch && !lineData.flexMessage) { const parts = eventMatch[1].split("|").map((s) => s.trim()); if (parts.length >= 2) { const [title, date, time, location, description] = parts; const card = createEventCard({ title: title || "Event", date: date || "TBD", time: time || undefined, location: location || undefined, description: description || undefined, }); lineData.flexMessage = { altText: `📅 ${title} - ${date}${time ? ` ${time}` : ""}`, contents: card, }; } text = text.replace(eventMatch[0], "").trim(); } const appleTvMatch = text.match(/\[\[appletv_remote:\s*([^\]]+)\]\]/i); if (appleTvMatch && !lineData.flexMessage) { const parts = appleTvMatch[1].split("|").map((s) => s.trim()); if (parts.length >= 1) { const [deviceName, status] = parts; const deviceKey = toSlug(deviceName || "apple_tv"); const card = createAppleTvRemoteCard({ deviceName: deviceName || "Apple TV", status: status || undefined, actionData: { up: lineActionData("up", { "line.device": deviceKey }), down: lineActionData("down", { "line.device": deviceKey }), left: lineActionData("left", { "line.device": deviceKey }), right: lineActionData("right", { "line.device": deviceKey }), select: lineActionData("select", { "line.device": deviceKey }), menu: lineActionData("menu", { "line.device": deviceKey }), home: lineActionData("home", { "line.device": deviceKey }), play: lineActionData("play", { "line.device": deviceKey }), pause: lineActionData("pause", { "line.device": deviceKey }), volumeUp: lineActionData("volume_up", { "line.device": deviceKey }), volumeDown: lineActionData("volume_down", { "line.device": deviceKey }), mute: lineActionData("mute", { "line.device": deviceKey }), }, }); lineData.flexMessage = { altText: `📺 ${deviceName || "Apple TV"} Remote`, contents: card, }; } text = text.replace(appleTvMatch[0], "").trim(); } const agendaMatch = text.match(/\[\[agenda:\s*([^\]]+)\]\]/i); if (agendaMatch && !lineData.flexMessage) { const parts = agendaMatch[1].split("|").map((s) => s.trim()); if (parts.length >= 2) { const [title, eventsStr] = parts; const events = eventsStr.split(",").map((eventStr) => { const trimmed = eventStr.trim(); const colonIdx = trimmed.lastIndexOf(":"); if (colonIdx > 0) { return { title: trimmed.slice(0, colonIdx).trim(), time: trimmed.slice(colonIdx + 1).trim(), }; } return { title: trimmed }; }); const card = createAgendaCard({ title: title || "Agenda", events, }); lineData.flexMessage = { altText: `📋 ${title} (${events.length} events)`, contents: card, }; } text = text.replace(agendaMatch[0], "").trim(); } const deviceMatch = text.match(/\[\[device:\s*([^\]]+)\]\]/i); if (deviceMatch && !lineData.flexMessage) { const parts = deviceMatch[1].split("|").map((s) => s.trim()); if (parts.length >= 1) { const [deviceName, deviceType, status, controlsStr] = parts; const deviceKey = toSlug(deviceName || "device"); const controls = controlsStr ? controlsStr.split(",").map((ctrlStr) => { const [label, data] = ctrlStr.split(":").map((s) => s.trim()); const action = data || normalizeLowercaseStringOrEmpty(label).replace(/\s+/g, "_"); return { label, data: lineActionData(action, { "line.device": deviceKey }) }; }) : []; const card = createDeviceControlCard({ deviceName: deviceName || "Device", deviceType: deviceType || undefined, status: status || undefined, controls, }); lineData.flexMessage = { altText: `📱 ${deviceName}${status ? `: ${status}` : ""}`, contents: card, }; } text = text.replace(deviceMatch[0], "").trim(); } text = text.replace(/\n{3,}/g, "\n\n").trim(); result.text = text || undefined; if (Object.keys(lineData).length > 0) { result.channelData = { ...result.channelData, line: lineData }; } return result; } export function hasLineDirectives(text: string): boolean { return /\[\[(quick_replies|location|confirm|buttons|media_player|event|agenda|device|appletv_remote):/i.test( text, ); }