openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
737 lines (736 loc) • 21.8 kB
JavaScript
import { a as normalizeLowercaseStringOrEmpty } from "./string-coerce-mnp54Vah.js";
import { _ as parseStrictFiniteNumber } from "./number-coercion-CJQ8TR--.js";
import { l as normalizeStringEntries } from "./string-normalization-WNUDCpXX.js";
import { At as boolean, Et as array, Nn as record, Rn as string, Tn as object, Xn as union, wn as number, yt as _enum } from "./schemas-6cH6bZ7o.js";
import { z as requireOpenAllowFrom } from "./zod-schema.core-1wQTyhOa.js";
import { r as buildChannelConfigSchema } from "./config-schema-CO2ikh7C.js";
import "./number-runtime-DBLVDypr.js";
import "./string-coerce-runtime-CEGJWkQ_.js";
import "./channel-config-schema-D7l1xlvD.js";
import { f as requireChannelOpenAllowFrom } from "./extension-shared-B8fkO3TV.js";
import { n as createEventCard, t as createAgendaCard } from "./schedule-cards-Bq74H30B.js";
//#region extensions/line/src/config-schema.ts
const DmPolicySchema = _enum([
"open",
"allowlist",
"pairing",
"disabled"
]);
const GroupPolicySchema = _enum([
"open",
"allowlist",
"disabled"
]);
const ThreadBindingsSchema = object({
enabled: boolean().optional(),
idleHours: number().optional(),
maxAgeHours: number().optional(),
spawnSessions: boolean().optional(),
defaultSpawnContext: _enum(["isolated", "fork"]).optional(),
spawnSubagentSessions: boolean().optional(),
spawnAcpSessions: boolean().optional()
}).strict();
const LineCommonConfigSchemaBase = object({
enabled: boolean().optional(),
channelAccessToken: string().optional(),
channelSecret: string().optional(),
tokenFile: string().optional(),
secretFile: string().optional(),
name: string().optional(),
allowFrom: array(union([string(), number()])).optional(),
groupAllowFrom: array(union([string(), number()])).optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
responsePrefix: string().optional(),
mediaMaxMb: number().optional(),
webhookPath: string().optional(),
threadBindings: ThreadBindingsSchema.optional()
});
const LineGroupConfigSchema = object({
enabled: boolean().optional(),
allowFrom: array(union([string(), number()])).optional(),
requireMention: boolean().optional(),
systemPrompt: string().optional(),
skills: array(string()).optional()
}).strict();
const LineAccountConfigSchema = LineCommonConfigSchemaBase.extend({ groups: record(string(), LineGroupConfigSchema.optional()).optional() }).strict().superRefine((value, ctx) => {
requireChannelOpenAllowFrom({
channel: "line",
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
requireOpenAllowFrom
});
});
const LineConfigSchema = LineCommonConfigSchemaBase.extend({
accounts: record(string(), LineAccountConfigSchema.optional()).optional(),
defaultAccount: string().optional(),
groups: record(string(), LineGroupConfigSchema.optional()).optional()
}).strict().superRefine((value, ctx) => {
requireChannelOpenAllowFrom({
channel: "line",
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
requireOpenAllowFrom
});
});
const LineChannelConfigSchema = buildChannelConfigSchema(LineConfigSchema);
//#endregion
//#region extensions/line/src/flex-templates/media-control-cards.ts
/**
* Create a media player card for Sonos, Spotify, Apple Music, etc.
*
* Editorial design: Album art hero with gradient overlay for text,
* prominent now-playing indicator, refined playback controls.
*/
function createMediaPlayerCard(params) {
const { title, subtitle, source, imageUrl, isPlaying, progress, controls, extraActions } = params;
const trackInfo = [{
type: "text",
text: title,
weight: "bold",
size: "xl",
color: "#111111",
wrap: true
}];
if (subtitle) trackInfo.push({
type: "text",
text: subtitle,
size: "md",
color: "#666666",
wrap: true,
margin: "sm"
});
const statusItems = [];
if (isPlaying !== void 0) statusItems.push({
type: "box",
layout: "horizontal",
contents: [{
type: "box",
layout: "vertical",
contents: [],
width: "8px",
height: "8px",
backgroundColor: isPlaying ? "#06C755" : "#CCCCCC",
cornerRadius: "4px"
}, {
type: "text",
text: isPlaying ? "Now Playing" : "Paused",
size: "xs",
color: isPlaying ? "#06C755" : "#888888",
weight: "bold",
margin: "sm"
}],
alignItems: "center"
});
if (source) statusItems.push({
type: "text",
text: source,
size: "xs",
color: "#AAAAAA",
margin: statusItems.length > 0 ? "lg" : void 0
});
if (progress) statusItems.push({
type: "text",
text: progress,
size: "xs",
color: "#888888",
align: "end",
flex: 1
});
const bodyContents = [{
type: "box",
layout: "vertical",
contents: trackInfo
}];
if (statusItems.length > 0) bodyContents.push({
type: "box",
layout: "horizontal",
contents: statusItems,
margin: "lg",
alignItems: "center"
});
const bubble = {
type: "bubble",
size: "mega",
body: {
type: "box",
layout: "vertical",
contents: bodyContents,
paddingAll: "xl",
backgroundColor: "#FFFFFF"
}
};
if (imageUrl) bubble.hero = {
type: "image",
url: imageUrl,
size: "full",
aspectRatio: "1:1",
aspectMode: "cover"
};
if (controls || extraActions?.length) {
const footerContents = [];
if (controls) {
const controlButtons = [];
if (controls.previous) controlButtons.push({
type: "button",
action: {
type: "postback",
label: "⏮",
data: controls.previous.data
},
style: "secondary",
flex: 1,
height: "sm"
});
if (controls.play) controlButtons.push({
type: "button",
action: {
type: "postback",
label: "▶",
data: controls.play.data
},
style: isPlaying ? "secondary" : "primary",
flex: 1,
height: "sm",
margin: controls.previous ? "md" : void 0
});
if (controls.pause) controlButtons.push({
type: "button",
action: {
type: "postback",
label: "⏸",
data: controls.pause.data
},
style: isPlaying ? "primary" : "secondary",
flex: 1,
height: "sm",
margin: controlButtons.length > 0 ? "md" : void 0
});
if (controls.next) controlButtons.push({
type: "button",
action: {
type: "postback",
label: "⏭",
data: controls.next.data
},
style: "secondary",
flex: 1,
height: "sm",
margin: controlButtons.length > 0 ? "md" : void 0
});
if (controlButtons.length > 0) footerContents.push({
type: "box",
layout: "horizontal",
contents: controlButtons
});
}
if (extraActions?.length) footerContents.push({
type: "box",
layout: "horizontal",
contents: extraActions.slice(0, 2).map((action, index) => ({
type: "button",
action: {
type: "postback",
label: action.label.slice(0, 15),
data: action.data
},
style: "secondary",
flex: 1,
height: "sm",
margin: index > 0 ? "md" : void 0
})),
margin: "md"
});
if (footerContents.length > 0) bubble.footer = {
type: "box",
layout: "vertical",
contents: footerContents,
paddingAll: "lg",
backgroundColor: "#FAFAFA"
};
}
return bubble;
}
/**
* Create an Apple TV remote card with a D-pad and control rows.
*/
function createAppleTvRemoteCard(params) {
const { deviceName, status, actionData } = params;
const headerContents = [{
type: "text",
text: deviceName,
weight: "bold",
size: "xl",
color: "#111111",
wrap: true
}];
if (status) headerContents.push({
type: "text",
text: status,
size: "sm",
color: "#666666",
wrap: true,
margin: "sm"
});
const makeButton = (label, data, style = "secondary") => ({
type: "button",
action: {
type: "postback",
label,
data
},
style,
height: "sm",
flex: 1
});
const dpadRows = [
{
type: "box",
layout: "horizontal",
contents: [
{ type: "filler" },
makeButton("↑", actionData.up),
{ type: "filler" }
]
},
{
type: "box",
layout: "horizontal",
contents: [
makeButton("←", actionData.left),
makeButton("OK", actionData.select, "primary"),
makeButton("→", actionData.right)
],
margin: "md"
},
{
type: "box",
layout: "horizontal",
contents: [
{ type: "filler" },
makeButton("↓", actionData.down),
{ type: "filler" }
],
margin: "md"
}
];
const menuRow = {
type: "box",
layout: "horizontal",
contents: [makeButton("Menu", actionData.menu), makeButton("Home", actionData.home)],
margin: "lg"
};
const playbackRow = {
type: "box",
layout: "horizontal",
contents: [makeButton("Play", actionData.play), makeButton("Pause", actionData.pause)],
margin: "md"
};
const volumeRow = {
type: "box",
layout: "horizontal",
contents: [
makeButton("Vol +", actionData.volumeUp),
makeButton("Mute", actionData.mute),
makeButton("Vol -", actionData.volumeDown)
],
margin: "md"
};
return {
type: "bubble",
size: "mega",
body: {
type: "box",
layout: "vertical",
contents: [
{
type: "box",
layout: "vertical",
contents: headerContents
},
{
type: "separator",
margin: "lg",
color: "#EEEEEE"
},
...dpadRows,
menuRow,
playbackRow,
volumeRow
],
paddingAll: "xl",
backgroundColor: "#FFFFFF"
}
};
}
/**
* Create a device control card for Apple TV, smart home devices, etc.
*
* Editorial design: Device-focused header with status indicator,
* clean control grid with clear visual hierarchy.
*/
function createDeviceControlCard(params) {
const { deviceName, deviceType, status, isOnline, imageUrl, controls } = params;
const headerContents = [{
type: "box",
layout: "horizontal",
contents: [{
type: "box",
layout: "vertical",
contents: [],
width: "10px",
height: "10px",
backgroundColor: isOnline !== false ? "#06C755" : "#FF5555",
cornerRadius: "5px"
}, {
type: "text",
text: deviceName,
weight: "bold",
size: "xl",
color: "#111111",
wrap: true,
flex: 1,
margin: "md"
}],
alignItems: "center"
}];
if (deviceType) headerContents.push({
type: "text",
text: deviceType,
size: "sm",
color: "#888888",
margin: "sm"
});
if (status) headerContents.push({
type: "box",
layout: "vertical",
contents: [{
type: "text",
text: status,
size: "sm",
color: "#444444",
wrap: true
}],
margin: "lg",
paddingAll: "md",
backgroundColor: "#F8F9FA",
cornerRadius: "md"
});
const bubble = {
type: "bubble",
size: "mega",
body: {
type: "box",
layout: "vertical",
contents: headerContents,
paddingAll: "xl",
backgroundColor: "#FFFFFF"
}
};
if (imageUrl) bubble.hero = {
type: "image",
url: imageUrl,
size: "full",
aspectRatio: "16:9",
aspectMode: "cover"
};
if (controls.length > 0) {
const rows = [];
const limitedControls = controls.slice(0, 6);
for (let i = 0; i < limitedControls.length; i += 2) {
const rowButtons = [];
for (let j = i; j < Math.min(i + 2, limitedControls.length); j++) {
const ctrl = limitedControls[j];
const buttonLabel = ctrl.icon ? `${ctrl.icon} ${ctrl.label}` : ctrl.label;
rowButtons.push({
type: "button",
action: {
type: "postback",
label: buttonLabel.slice(0, 18),
data: ctrl.data
},
style: ctrl.style ?? "secondary",
flex: 1,
height: "sm",
margin: j > i ? "md" : void 0
});
}
if (rowButtons.length === 1) rowButtons.push({ type: "filler" });
rows.push({
type: "box",
layout: "horizontal",
contents: rowButtons,
margin: i > 0 ? "md" : void 0
});
}
bubble.footer = {
type: "box",
layout: "vertical",
contents: rows,
paddingAll: "lg",
backgroundColor: "#FAFAFA"
};
}
return bubble;
}
//#endregion
//#region extensions/line/src/reply-payload-transform.ts
/**
* 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]]
*/
function parseLineDirectives(payload) {
let text = payload.text;
if (!text) return payload;
const result = { ...payload };
const lineData = { ...result.channelData?.line };
const toSlug = (value) => normalizeLowercaseStringOrEmpty(value).replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "device";
const lineActionData = (action, extras) => {
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 = normalizeStringEntries(quickRepliesMatch[1].split(","));
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 = parseStrictFiniteNumber(latStr);
const longitude = parseStrictFiniteNumber(lonStr);
if (latitude !== void 0 && longitude !== void 0) 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;
let data;
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",
label,
uri: data
};
if (data.includes("=")) return {
type: "postback",
label,
data
};
return {
type: "message",
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 : void 0;
const deviceKey = toSlug(source || title || "media");
const card = createMediaPlayerCard({
title: title || "Unknown Track",
subtitle: artist || void 0,
source: source || void 0,
imageUrl: validImageUrl,
isPlaying: statusStr ? isPlaying : void 0,
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 || void 0,
location: location || void 0,
description: description || void 0
});
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 || void 0,
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());
return {
label,
data: lineActionData(data || normalizeLowercaseStringOrEmpty(label).replace(/\s+/g, "_"), { "line.device": deviceKey })
};
}) : [];
const card = createDeviceControlCard({
deviceName: deviceName || "Device",
deviceType: deviceType || void 0,
status: status || void 0,
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 || void 0;
if (Object.keys(lineData).length > 0) result.channelData = {
...result.channelData,
line: lineData
};
return result;
}
function hasLineDirectives(text) {
return /\[\[(quick_replies|location|confirm|buttons|media_player|event|agenda|device|appletv_remote):/i.test(text);
}
//#endregion
export { createMediaPlayerCard as a, createDeviceControlCard as i, parseLineDirectives as n, LineChannelConfigSchema as o, createAppleTvRemoteCard as r, LineConfigSchema as s, hasLineDirectives as t };