diff --git a/extensions/line/src/card-command.ts b/extensions/line/src/card-command.ts new file mode 100644 index 000000000..762faa012 --- /dev/null +++ b/extensions/line/src/card-command.ts @@ -0,0 +1,338 @@ +import type { ClawdbotPluginApi, LineChannelData, ReplyPayload } from "clawdbot/plugin-sdk"; +import { + createActionCard, + createImageCard, + createInfoCard, + createListCard, + createReceiptCard, + type CardAction, + type ListItem, +} from "clawdbot/plugin-sdk"; + +const CARD_USAGE = `Usage: /card "title" "body" [options] + +Types: + info "Title" "Body" ["Footer"] + image "Title" "Caption" --url + action "Title" "Body" --actions "Btn1|url1,Btn2|text2" + list "Title" "Item1|Desc1,Item2|Desc2" + receipt "Title" "Item1:$10,Item2:$20" --total "$30" + confirm "Question?" --yes "Yes|data" --no "No|data" + buttons "Title" "Text" --actions "Btn1|url1,Btn2|data2" + +Examples: + /card info "Welcome" "Thanks for joining!" + /card image "Product" "Check it out" --url https://example.com/img.jpg + /card action "Menu" "Choose an option" --actions "Order|/order,Help|/help"`; + +function buildLineReply(lineData: LineChannelData): ReplyPayload { + return { + channelData: { + line: lineData, + }, + }; +} + +/** + * Parse action string format: "Label|data,Label2|data2" + * Data can be a URL (uri action) or plain text (message action) or key=value (postback) + */ +function parseActions(actionsStr: string | undefined): CardAction[] { + if (!actionsStr) return []; + + const results: CardAction[] = []; + + for (const part of actionsStr.split(",")) { + const [label, data] = part + .trim() + .split("|") + .map((s) => s.trim()); + if (!label) continue; + + const actionData = data || label; + + if (actionData.startsWith("http://") || actionData.startsWith("https://")) { + results.push({ + label, + action: { type: "uri", label: label.slice(0, 20), uri: actionData }, + }); + } else if (actionData.includes("=")) { + results.push({ + label, + action: { + type: "postback", + label: label.slice(0, 20), + data: actionData.slice(0, 300), + displayText: label, + }, + }); + } else { + results.push({ + label, + action: { type: "message", label: label.slice(0, 20), text: actionData }, + }); + } + } + + return results; +} + +/** + * Parse list items format: "Item1|Subtitle1,Item2|Subtitle2" + */ +function parseListItems(itemsStr: string): ListItem[] { + return itemsStr + .split(",") + .map((part) => { + const [title, subtitle] = part + .trim() + .split("|") + .map((s) => s.trim()); + return { title: title || "", subtitle }; + }) + .filter((item) => item.title); +} + +/** + * Parse receipt items format: "Item1:$10,Item2:$20" + */ +function parseReceiptItems(itemsStr: string): Array<{ name: string; value: string }> { + return itemsStr + .split(",") + .map((part) => { + const colonIndex = part.lastIndexOf(":"); + if (colonIndex === -1) { + return { name: part.trim(), value: "" }; + } + return { + name: part.slice(0, colonIndex).trim(), + value: part.slice(colonIndex + 1).trim(), + }; + }) + .filter((item) => item.name); +} + +/** + * Parse quoted arguments from command string + * Supports: /card type "arg1" "arg2" "arg3" --flag value + */ +function parseCardArgs(argsStr: string): { + type: string; + args: string[]; + flags: Record; +} { + const result: { type: string; args: string[]; flags: Record } = { + type: "", + args: [], + flags: {}, + }; + + // Extract type (first word) + const typeMatch = argsStr.match(/^(\w+)/); + if (typeMatch) { + result.type = typeMatch[1].toLowerCase(); + argsStr = argsStr.slice(typeMatch[0].length).trim(); + } + + // Extract quoted arguments + const quotedRegex = /"([^"]*?)"/g; + let match; + while ((match = quotedRegex.exec(argsStr)) !== null) { + result.args.push(match[1]); + } + + // Extract flags (--key value or --key "value") + const flagRegex = /--(\w+)\s+(?:"([^"]*?)"|(\S+))/g; + while ((match = flagRegex.exec(argsStr)) !== null) { + result.flags[match[1]] = match[2] ?? match[3]; + } + + return result; +} + +export function registerLineCardCommand(api: ClawdbotPluginApi): void { + api.registerCommand({ + name: "card", + description: "Send a rich card message (LINE).", + acceptsArgs: true, + requireAuth: false, + handler: async (ctx) => { + const argsStr = ctx.args?.trim() ?? ""; + if (!argsStr) return { text: CARD_USAGE }; + + const parsed = parseCardArgs(argsStr); + const { type, args, flags } = parsed; + + if (!type) return { text: CARD_USAGE }; + + // Only LINE supports rich cards; fallback to text elsewhere. + if (ctx.channel !== "line") { + const fallbackText = args.join(" - "); + return { text: `[${type} card] ${fallbackText}`.trim() }; + } + + try { + switch (type) { + case "info": { + const [title = "Info", body = "", footer] = args; + const bubble = createInfoCard(title, body, footer); + return buildLineReply({ + flexMessage: { + altText: `${title}: ${body}`.slice(0, 400), + contents: bubble, + }, + }); + } + + case "image": { + const [title = "Image", caption = ""] = args; + const imageUrl = flags.url || flags.image; + if (!imageUrl) { + return { text: "Error: Image card requires --url " }; + } + const bubble = createImageCard(imageUrl, title, caption); + return buildLineReply({ + flexMessage: { + altText: `${title}: ${caption}`.slice(0, 400), + contents: bubble, + }, + }); + } + + case "action": { + const [title = "Actions", body = ""] = args; + const actions = parseActions(flags.actions); + if (actions.length === 0) { + return { text: 'Error: Action card requires --actions "Label1|data1,Label2|data2"' }; + } + const bubble = createActionCard(title, body, actions, { + imageUrl: flags.url || flags.image, + }); + return buildLineReply({ + flexMessage: { + altText: `${title}: ${body}`.slice(0, 400), + contents: bubble, + }, + }); + } + + case "list": { + const [title = "List", itemsStr = ""] = args; + const items = parseListItems(itemsStr || flags.items || ""); + if (items.length === 0) { + return { + text: + 'Error: List card requires items. Usage: /card list "Title" "Item1|Desc1,Item2|Desc2"', + }; + } + const bubble = createListCard(title, items); + return buildLineReply({ + flexMessage: { + altText: `${title}: ${items.map((i) => i.title).join(", ")}`.slice(0, 400), + contents: bubble, + }, + }); + } + + case "receipt": { + const [title = "Receipt", itemsStr = ""] = args; + const items = parseReceiptItems(itemsStr || flags.items || ""); + const total = flags.total ? { label: "Total", value: flags.total } : undefined; + const footer = flags.footer; + + if (items.length === 0) { + return { + text: + 'Error: Receipt card requires items. Usage: /card receipt "Title" "Item1:$10,Item2:$20" --total "$30"', + }; + } + + const bubble = createReceiptCard({ title, items, total, footer }); + return buildLineReply({ + flexMessage: { + altText: `${title}: ${items.map((i) => `${i.name} ${i.value}`).join(", ")}`.slice( + 0, + 400, + ), + contents: bubble, + }, + }); + } + + case "confirm": { + const [question = "Confirm?"] = args; + const yesStr = flags.yes || "Yes|yes"; + const noStr = flags.no || "No|no"; + + const [yesLabel, yesData] = yesStr.split("|").map((s) => s.trim()); + const [noLabel, noData] = noStr.split("|").map((s) => s.trim()); + + return buildLineReply({ + templateMessage: { + type: "confirm", + text: question, + confirmLabel: yesLabel || "Yes", + confirmData: yesData || "yes", + cancelLabel: noLabel || "No", + cancelData: noData || "no", + altText: question, + }, + }); + } + + case "buttons": { + const [title = "Menu", text = "Choose an option"] = args; + const actionsStr = flags.actions || ""; + const actionParts = parseActions(actionsStr); + + if (actionParts.length === 0) { + return { text: 'Error: Buttons card requires --actions "Label1|data1,Label2|data2"' }; + } + + const templateActions: Array<{ + type: "message" | "uri" | "postback"; + label: string; + data?: string; + uri?: string; + }> = actionParts.map((a) => { + const action = a.action; + const label = action.label ?? a.label; + if (action.type === "uri") { + return { type: "uri" as const, label, uri: (action as { uri: string }).uri }; + } + if (action.type === "postback") { + return { + type: "postback" as const, + label, + data: (action as { data: string }).data, + }; + } + return { + type: "message" as const, + label, + data: (action as { text: string }).text, + }; + }); + + return buildLineReply({ + templateMessage: { + type: "buttons", + title, + text, + thumbnailImageUrl: flags.url || flags.image, + actions: templateActions, + }, + }); + } + + default: + return { + text: `Unknown card type: "${type}". Available types: info, image, action, list, receipt, confirm, buttons`, + }; + } + } catch (err) { + return { text: `Error creating card: ${String(err)}` }; + } + }, + }); +}