import type { MessageEvent, StickerEventMessage, EventSource, PostbackEvent } from "@line/bot-sdk"; import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../auto-reply/envelope.js"; import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; import { formatLocationText, toLocationContext } from "../channels/location.js"; import type { OpenClawConfig } from "../config/config.js"; import { readSessionUpdatedAt, recordSessionMetaFromInbound, resolveStorePath, updateLastRoute, } from "../config/sessions.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; import type { ResolvedLineAccount } from "./types.js"; interface MediaRef { path: string; contentType?: string; } interface BuildLineMessageContextParams { event: MessageEvent; allMedia: MediaRef[]; cfg: OpenClawConfig; account: ResolvedLineAccount; } export type LineSourceInfo = { userId?: string; groupId?: string; roomId?: string; isGroup: boolean; }; export function getLineSourceInfo(source: EventSource): LineSourceInfo { const userId = source.type === "user" ? source.userId : source.type === "group" ? source.userId : source.type === "room" ? source.userId : undefined; const groupId = source.type === "group" ? source.groupId : undefined; const roomId = source.type === "room" ? source.roomId : undefined; const isGroup = source.type === "group" || source.type === "room"; return { userId, groupId, roomId, isGroup }; } function buildPeerId(source: EventSource): string { if (source.type === "group" && source.groupId) { return `group:${source.groupId}`; } if (source.type === "room" && source.roomId) { return `room:${source.roomId}`; } if (source.type === "user" && source.userId) { return source.userId; } return "unknown"; } function resolveLineInboundRoute(params: { source: EventSource; cfg: OpenClawConfig; account: ResolvedLineAccount; }): { userId?: string; groupId?: string; roomId?: string; isGroup: boolean; peerId: string; route: ReturnType; } { recordChannelActivity({ channel: "line", accountId: params.account.accountId, direction: "inbound", }); const { userId, groupId, roomId, isGroup } = getLineSourceInfo(params.source); const peerId = buildPeerId(params.source); const route = resolveAgentRoute({ cfg: params.cfg, channel: "line", accountId: params.account.accountId, peer: { kind: isGroup ? "group" : "direct", id: peerId, }, }); return { userId, groupId, roomId, isGroup, peerId, route }; } // Common LINE sticker package descriptions const STICKER_PACKAGES: Record = { "1": "Moon & James", "2": "Cony & Brown", "3": "Brown & Friends", "4": "Moon Special", "11537": "Cony", "11538": "Brown", "11539": "Moon", "6136": "Cony's Happy Life", "6325": "Brown's Life", "6359": "Choco", "6362": "Sally", "6370": "Edward", "789": "LINE Characters", }; function describeStickerKeywords(sticker: StickerEventMessage): string { // Use sticker keywords if available (LINE provides these for some stickers) const keywords = (sticker as StickerEventMessage & { keywords?: string[] }).keywords; if (keywords && keywords.length > 0) { return keywords.slice(0, 3).join(", "); } // Use sticker text if available const stickerText = (sticker as StickerEventMessage & { text?: string }).text; if (stickerText) { return stickerText; } return ""; } function extractMessageText(message: MessageEvent["message"]): string { if (message.type === "text") { return message.text; } if (message.type === "location") { const loc = message; return ( formatLocationText({ latitude: loc.latitude, longitude: loc.longitude, name: loc.title, address: loc.address, }) ?? "" ); } if (message.type === "sticker") { const sticker = message; const packageName = STICKER_PACKAGES[sticker.packageId] ?? "sticker"; const keywords = describeStickerKeywords(sticker); if (keywords) { return `[Sent a ${packageName} sticker: ${keywords}]`; } return `[Sent a ${packageName} sticker]`; } return ""; } function extractMediaPlaceholder(message: MessageEvent["message"]): string { switch (message.type) { case "image": return ""; case "video": return ""; case "audio": return ""; case "file": return ""; default: return ""; } } type LineRouteInfo = ReturnType; type LineSourceInfoWithPeerId = LineSourceInfo & { peerId: string }; function resolveLineConversationLabel(params: { isGroup: boolean; groupId?: string; roomId?: string; senderLabel: string; }): string { return params.isGroup ? params.groupId ? `group:${params.groupId}` : params.roomId ? `room:${params.roomId}` : "unknown-group" : params.senderLabel; } function resolveLineAddresses(params: { isGroup: boolean; groupId?: string; roomId?: string; userId?: string; peerId: string; }): { fromAddress: string; toAddress: string; originatingTo: string } { const fromAddress = params.isGroup ? params.groupId ? `line:group:${params.groupId}` : params.roomId ? `line:room:${params.roomId}` : `line:${params.peerId}` : `line:${params.userId ?? params.peerId}`; const toAddress = params.isGroup ? fromAddress : `line:${params.userId ?? params.peerId}`; const originatingTo = params.isGroup ? fromAddress : `line:${params.userId ?? params.peerId}`; return { fromAddress, toAddress, originatingTo }; } async function finalizeLineInboundContext(params: { cfg: OpenClawConfig; account: ResolvedLineAccount; event: MessageEvent | PostbackEvent; route: LineRouteInfo; source: LineSourceInfoWithPeerId; rawBody: string; timestamp: number; messageSid: string; media: { firstPath: string | undefined; firstContentType?: string; paths?: string[]; types?: string[]; }; locationContext?: ReturnType; verboseLog: { kind: "inbound" | "postback"; mediaCount?: number }; }) { const { fromAddress, toAddress, originatingTo } = resolveLineAddresses({ isGroup: params.source.isGroup, groupId: params.source.groupId, roomId: params.source.roomId, userId: params.source.userId, peerId: params.source.peerId, }); const senderId = params.source.userId ?? "unknown"; const senderLabel = params.source.userId ? `user:${params.source.userId}` : "unknown"; const conversationLabel = resolveLineConversationLabel({ isGroup: params.source.isGroup, groupId: params.source.groupId, roomId: params.source.roomId, senderLabel, }); const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.route.agentId, }); const envelopeOptions = resolveEnvelopeFormatOptions(params.cfg); const previousTimestamp = readSessionUpdatedAt({ storePath, sessionKey: params.route.sessionKey, }); const body = formatInboundEnvelope({ channel: "LINE", from: conversationLabel, timestamp: params.timestamp, body: params.rawBody, chatType: params.source.isGroup ? "group" : "direct", sender: { id: senderId, }, previousTimestamp, envelope: envelopeOptions, }); const ctxPayload = finalizeInboundContext({ Body: body, BodyForAgent: params.rawBody, RawBody: params.rawBody, CommandBody: params.rawBody, From: fromAddress, To: toAddress, SessionKey: params.route.sessionKey, AccountId: params.route.accountId, ChatType: params.source.isGroup ? "group" : "direct", ConversationLabel: conversationLabel, GroupSubject: params.source.isGroup ? (params.source.groupId ?? params.source.roomId) : undefined, SenderId: senderId, Provider: "line", Surface: "line", MessageSid: params.messageSid, Timestamp: params.timestamp, MediaPath: params.media.firstPath, MediaType: params.media.firstContentType, MediaUrl: params.media.firstPath, MediaPaths: params.media.paths, MediaUrls: params.media.paths, MediaTypes: params.media.types, ...params.locationContext, OriginatingChannel: "line" as const, OriginatingTo: originatingTo, }); void recordSessionMetaFromInbound({ storePath, sessionKey: ctxPayload.SessionKey ?? params.route.sessionKey, ctx: ctxPayload, }).catch((err) => { logVerbose(`line: failed updating session meta: ${String(err)}`); }); if (!params.source.isGroup) { await updateLastRoute({ storePath, sessionKey: params.route.mainSessionKey, deliveryContext: { channel: "line", to: params.source.userId ?? params.source.peerId, accountId: params.route.accountId, }, ctx: ctxPayload, }); } if (shouldLogVerbose()) { const preview = body.slice(0, 200).replace(/\n/g, "\\n"); const mediaInfo = params.verboseLog.kind === "inbound" && (params.verboseLog.mediaCount ?? 0) > 1 ? ` mediaCount=${params.verboseLog.mediaCount}` : ""; const label = params.verboseLog.kind === "inbound" ? "line inbound" : "line postback"; logVerbose( `${label}: from=${ctxPayload.From} len=${body.length}${mediaInfo} preview="${preview}"`, ); } return { ctxPayload, replyToken: (params.event as { replyToken: string }).replyToken }; } export async function buildLineMessageContext(params: BuildLineMessageContextParams) { const { event, allMedia, cfg, account } = params; const source = event.source; const { userId, groupId, roomId, isGroup, peerId, route } = resolveLineInboundRoute({ source, cfg, account, }); const message = event.message; const messageId = message.id; const timestamp = event.timestamp; // Build message body const textContent = extractMessageText(message); const placeholder = extractMediaPlaceholder(message); let rawBody = textContent || placeholder; if (!rawBody && allMedia.length > 0) { rawBody = `${allMedia.length > 1 ? ` (${allMedia.length} images)` : ""}`; } if (!rawBody && allMedia.length === 0) { return null; } let locationContext: ReturnType | undefined; if (message.type === "location") { const loc = message; locationContext = toLocationContext({ latitude: loc.latitude, longitude: loc.longitude, name: loc.title, address: loc.address, }); } const { ctxPayload } = await finalizeLineInboundContext({ cfg, account, event, route, source: { userId, groupId, roomId, isGroup, peerId }, rawBody, timestamp, messageSid: messageId, media: { firstPath: allMedia[0]?.path, firstContentType: allMedia[0]?.contentType, paths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined, types: allMedia.length > 0 ? (allMedia.map((m) => m.contentType).filter(Boolean) as string[]) : undefined, }, locationContext, verboseLog: { kind: "inbound", mediaCount: allMedia.length }, }); return { ctxPayload, event, userId, groupId, roomId, isGroup, route, replyToken: event.replyToken, accountId: account.accountId, }; } export async function buildLinePostbackContext(params: { event: PostbackEvent; cfg: OpenClawConfig; account: ResolvedLineAccount; }) { const { event, cfg, account } = params; const source = event.source; const { userId, groupId, roomId, isGroup, peerId, route } = resolveLineInboundRoute({ source, cfg, account, }); const timestamp = event.timestamp; const rawData = event.postback?.data?.trim() ?? ""; if (!rawData) { return null; } let rawBody = rawData; if (rawData.includes("line.action=")) { const params = new URLSearchParams(rawData); const action = params.get("line.action") ?? ""; const device = params.get("line.device"); rawBody = device ? `line action ${action} device ${device}` : `line action ${action}`; } const messageSid = event.replyToken ? `postback:${event.replyToken}` : `postback:${timestamp}`; const { ctxPayload } = await finalizeLineInboundContext({ cfg, account, event, route, source: { userId, groupId, roomId, isGroup, peerId }, rawBody, timestamp, messageSid, media: { firstPath: "", firstContentType: undefined, paths: undefined, types: undefined, }, verboseLog: { kind: "postback" }, }); return { ctxPayload, event, userId, groupId, roomId, isGroup, route, replyToken: event.replyToken, accountId: account.accountId, }; } export type LineMessageContext = NonNullable>>; export type LinePostbackContext = NonNullable>>; export type LineInboundContext = LineMessageContext | LinePostbackContext;