import type { MessageEvent, StickerEventMessage, EventSource, PostbackEvent } from "@line/bot-sdk"; import { formatInboundEnvelope } from "../auto-reply/envelope.js"; import { type HistoryEntry } from "../auto-reply/reply/history.js"; import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; import { formatLocationText, toLocationContext } from "../channels/location.js"; import { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js"; import { recordInboundSession } from "../channels/session.js"; import type { OpenClawConfig } from "../config/config.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolvePinnedMainDmOwnerFromAllowlist } from "../security/dm-policy-shared.js"; import { normalizeAllowFrom } from "./bot-access.js"; import { resolveLineGroupConfigEntry, resolveLineGroupHistoryKey } from "./group-keys.js"; import type { ResolvedLineAccount, LineGroupConfig } from "./types.js"; interface MediaRef { path: string; contentType?: string; } interface BuildLineMessageContextParams { event: MessageEvent; allMedia: MediaRef[]; cfg: OpenClawConfig; account: ResolvedLineAccount; commandAuthorized: boolean; groupHistories?: Map; historyLimit?: number; } 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 { const groupKey = resolveLineGroupHistoryKey({ groupId: source.type === "group" ? source.groupId : undefined, roomId: source.type === "room" ? source.roomId : undefined, }); if (groupKey) { return groupKey; } 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 }; } function resolveLineGroupSystemPrompt( groups: Record | undefined, source: LineSourceInfoWithPeerId, ): string | undefined { const entry = resolveLineGroupConfigEntry(groups, { groupId: source.groupId, roomId: source.roomId, }); return entry?.systemPrompt?.trim() || undefined; } async function finalizeLineInboundContext(params: { cfg: OpenClawConfig; account: ResolvedLineAccount; event: MessageEvent | PostbackEvent; route: LineRouteInfo; source: LineSourceInfoWithPeerId; rawBody: string; timestamp: number; messageSid: string; commandAuthorized: boolean; media: { firstPath: string | undefined; firstContentType?: string; paths?: string[]; types?: string[]; }; locationContext?: ReturnType; verboseLog: { kind: "inbound" | "postback"; mediaCount?: number }; inboundHistory?: Pick[]; }) { 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, envelopeOptions, previousTimestamp } = resolveInboundSessionEnvelopeContext({ cfg: params.cfg, agentId: params.route.agentId, 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, CommandAuthorized: params.commandAuthorized, OriginatingChannel: "line" as const, OriginatingTo: originatingTo, GroupSystemPrompt: params.source.isGroup ? resolveLineGroupSystemPrompt(params.account.config.groups, params.source) : undefined, InboundHistory: params.inboundHistory, }); const pinnedMainDmOwner = !params.source.isGroup ? resolvePinnedMainDmOwnerFromAllowlist({ dmScope: params.cfg.session?.dmScope, allowFrom: params.account.config.allowFrom, normalizeEntry: (entry) => normalizeAllowFrom([entry]).entries[0], }) : null; await recordInboundSession({ storePath, sessionKey: ctxPayload.SessionKey ?? params.route.sessionKey, ctx: ctxPayload, updateLastRoute: !params.source.isGroup ? { sessionKey: params.route.mainSessionKey, channel: "line", to: params.source.userId ?? params.source.peerId, accountId: params.route.accountId, mainDmOwnerPin: pinnedMainDmOwner && params.source.userId ? { ownerRecipient: pinnedMainDmOwner, senderRecipient: params.source.userId, onSkip: ({ ownerRecipient, senderRecipient }) => { logVerbose( `line: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`, ); }, } : undefined, } : undefined, onRecordError: (err) => { logVerbose(`line: failed updating session meta: ${String(err)}`); }, }); 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, commandAuthorized, groupHistories, historyLimit } = 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, }); } // Build pending history for group chats: unmentioned messages accumulated in // groupHistories are passed as InboundHistory so the agent has context about // the conversation that preceded the mention. const historyKey = isGroup ? peerId : undefined; const inboundHistory = historyKey && groupHistories && (historyLimit ?? 0) > 0 ? (groupHistories.get(historyKey) ?? []).map((entry) => ({ sender: entry.sender, body: entry.body, timestamp: entry.timestamp, })) : undefined; const { ctxPayload } = await finalizeLineInboundContext({ cfg, account, event, route, source: { userId, groupId, roomId, isGroup, peerId }, rawBody, timestamp, messageSid: messageId, commandAuthorized, 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 }, inboundHistory, }); 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; commandAuthorized: boolean; }) { const { event, cfg, account, commandAuthorized } = 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, commandAuthorized, 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;