import { messagingApi } from "@line/bot-sdk"; import { loadConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logVerbose } from "../globals.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; import { resolveLineAccount } from "./accounts.js"; import { resolveLineChannelAccessToken } from "./channel-access-token.js"; import type { LineSendResult } from "./types.js"; // Use the messaging API types directly type Message = messagingApi.Message; type TextMessage = messagingApi.TextMessage; type ImageMessage = messagingApi.ImageMessage; type LocationMessage = messagingApi.LocationMessage; type FlexMessage = messagingApi.FlexMessage; type FlexContainer = messagingApi.FlexContainer; type TemplateMessage = messagingApi.TemplateMessage; type QuickReply = messagingApi.QuickReply; type QuickReplyItem = messagingApi.QuickReplyItem; // Cache for user profiles const userProfileCache = new Map< string, { displayName: string; pictureUrl?: string; fetchedAt: number } >(); const PROFILE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes interface LineSendOpts { cfg?: OpenClawConfig; channelAccessToken?: string; accountId?: string; verbose?: boolean; mediaUrl?: string; replyToken?: string; } type LineClientOpts = Pick; type LinePushOpts = Pick; interface LinePushBehavior { errorContext?: string; verboseMessage?: (chatId: string, messageCount: number) => string; } interface LineReplyBehavior { verboseMessage?: (messageCount: number) => string; } function normalizeTarget(to: string): string { const trimmed = to.trim(); if (!trimmed) { throw new Error("Recipient is required for LINE sends"); } // Strip internal prefixes let normalized = trimmed .replace(/^line:group:/i, "") .replace(/^line:room:/i, "") .replace(/^line:user:/i, "") .replace(/^line:/i, ""); if (!normalized) { throw new Error("Recipient is required for LINE sends"); } return normalized; } function createLineMessagingClient(opts: LineClientOpts): { account: ReturnType; client: messagingApi.MessagingApiClient; } { const cfg = opts.cfg ?? loadConfig(); const account = resolveLineAccount({ cfg, accountId: opts.accountId, }); const token = resolveLineChannelAccessToken(opts.channelAccessToken, account); const client = new messagingApi.MessagingApiClient({ channelAccessToken: token, }); return { account, client }; } function createLinePushContext( to: string, opts: LineClientOpts, ): { account: ReturnType; client: messagingApi.MessagingApiClient; chatId: string; } { const { account, client } = createLineMessagingClient(opts); const chatId = normalizeTarget(to); return { account, client, chatId }; } function createTextMessage(text: string): TextMessage { return { type: "text", text }; } export function createImageMessage( originalContentUrl: string, previewImageUrl?: string, ): ImageMessage { return { type: "image", originalContentUrl, previewImageUrl: previewImageUrl ?? originalContentUrl, }; } export function createLocationMessage(location: { title: string; address: string; latitude: number; longitude: number; }): LocationMessage { return { type: "location", title: location.title.slice(0, 100), // LINE limit address: location.address.slice(0, 100), // LINE limit latitude: location.latitude, longitude: location.longitude, }; } function logLineHttpError(err: unknown, context: string): void { if (!err || typeof err !== "object") { return; } const { status, statusText, body } = err as { status?: number; statusText?: string; body?: string; }; if (typeof body === "string") { const summary = status ? `${status} ${statusText ?? ""}`.trim() : "unknown status"; logVerbose(`line: ${context} failed (${summary}): ${body}`); } } function recordLineOutboundActivity(accountId: string): void { recordChannelActivity({ channel: "line", accountId, direction: "outbound", }); } async function pushLineMessages( to: string, messages: Message[], opts: LinePushOpts = {}, behavior: LinePushBehavior = {}, ): Promise { if (messages.length === 0) { throw new Error("Message must be non-empty for LINE sends"); } const { account, client, chatId } = createLinePushContext(to, opts); const pushRequest = client.pushMessage({ to: chatId, messages, }); if (behavior.errorContext) { const errorContext = behavior.errorContext; await pushRequest.catch((err) => { logLineHttpError(err, errorContext); throw err; }); } else { await pushRequest; } recordLineOutboundActivity(account.accountId); if (opts.verbose) { const logMessage = behavior.verboseMessage?.(chatId, messages.length) ?? `line: pushed ${messages.length} messages to ${chatId}`; logVerbose(logMessage); } return { messageId: "push", chatId, }; } async function replyLineMessages( replyToken: string, messages: Message[], opts: LinePushOpts = {}, behavior: LineReplyBehavior = {}, ): Promise { const { account, client } = createLineMessagingClient(opts); await client.replyMessage({ replyToken, messages, }); recordLineOutboundActivity(account.accountId); if (opts.verbose) { logVerbose( behavior.verboseMessage?.(messages.length) ?? `line: replied with ${messages.length} messages`, ); } } export async function sendMessageLine( to: string, text: string, opts: LineSendOpts = {}, ): Promise { const chatId = normalizeTarget(to); const messages: Message[] = []; // Add media if provided if (opts.mediaUrl?.trim()) { messages.push(createImageMessage(opts.mediaUrl.trim())); } // Add text message if (text?.trim()) { messages.push(createTextMessage(text.trim())); } if (messages.length === 0) { throw new Error("Message must be non-empty for LINE sends"); } // Use reply if we have a reply token, otherwise push if (opts.replyToken) { await replyLineMessages(opts.replyToken, messages, opts, { verboseMessage: () => `line: replied to ${chatId}`, }); return { messageId: "reply", chatId, }; } // Push message (for proactive messaging) return pushLineMessages(chatId, messages, opts, { verboseMessage: (resolvedChatId) => `line: pushed message to ${resolvedChatId}`, }); } export async function pushMessageLine( to: string, text: string, opts: LineSendOpts = {}, ): Promise { // Force push (no reply token) return sendMessageLine(to, text, { ...opts, replyToken: undefined }); } export async function replyMessageLine( replyToken: string, messages: Message[], opts: LinePushOpts = {}, ): Promise { await replyLineMessages(replyToken, messages, opts); } export async function pushMessagesLine( to: string, messages: Message[], opts: LinePushOpts = {}, ): Promise { return pushLineMessages(to, messages, opts, { errorContext: "push message", }); } export function createFlexMessage( altText: string, contents: messagingApi.FlexContainer, ): messagingApi.FlexMessage { return { type: "flex", altText, contents, }; } /** * Push an image message to a user/group */ export async function pushImageMessage( to: string, originalContentUrl: string, previewImageUrl?: string, opts: LinePushOpts = {}, ): Promise { return pushLineMessages(to, [createImageMessage(originalContentUrl, previewImageUrl)], opts, { verboseMessage: (chatId) => `line: pushed image to ${chatId}`, }); } /** * Push a location message to a user/group */ export async function pushLocationMessage( to: string, location: { title: string; address: string; latitude: number; longitude: number; }, opts: LinePushOpts = {}, ): Promise { return pushLineMessages(to, [createLocationMessage(location)], opts, { verboseMessage: (chatId) => `line: pushed location to ${chatId}`, }); } /** * Push a Flex Message to a user/group */ export async function pushFlexMessage( to: string, altText: string, contents: FlexContainer, opts: LinePushOpts = {}, ): Promise { const flexMessage: FlexMessage = { type: "flex", altText: altText.slice(0, 400), // LINE limit contents, }; return pushLineMessages(to, [flexMessage], opts, { errorContext: "push flex message", verboseMessage: (chatId) => `line: pushed flex message to ${chatId}`, }); } /** * Push a Template Message to a user/group */ export async function pushTemplateMessage( to: string, template: TemplateMessage, opts: LinePushOpts = {}, ): Promise { return pushLineMessages(to, [template], opts, { verboseMessage: (chatId) => `line: pushed template message to ${chatId}`, }); } /** * Push a text message with quick reply buttons */ export async function pushTextMessageWithQuickReplies( to: string, text: string, quickReplyLabels: string[], opts: LinePushOpts = {}, ): Promise { const message = createTextMessageWithQuickReplies(text, quickReplyLabels); return pushLineMessages(to, [message], opts, { verboseMessage: (chatId) => `line: pushed message with quick replies to ${chatId}`, }); } /** * Create quick reply buttons to attach to a message */ export function createQuickReplyItems(labels: string[]): QuickReply { const items: QuickReplyItem[] = labels.slice(0, 13).map((label) => ({ type: "action", action: { type: "message", label: label.slice(0, 20), // LINE limit: 20 chars text: label, }, })); return { items }; } /** * Create a text message with quick reply buttons */ export function createTextMessageWithQuickReplies( text: string, quickReplyLabels: string[], ): TextMessage & { quickReply: QuickReply } { return { type: "text", text, quickReply: createQuickReplyItems(quickReplyLabels), }; } /** * Show loading animation to user (lasts up to 20 seconds or until next message) */ export async function showLoadingAnimation( chatId: string, opts: { channelAccessToken?: string; accountId?: string; loadingSeconds?: number } = {}, ): Promise { const { client } = createLineMessagingClient(opts); try { await client.showLoadingAnimation({ chatId: normalizeTarget(chatId), loadingSeconds: opts.loadingSeconds ?? 20, }); logVerbose(`line: showing loading animation to ${chatId}`); } catch (err) { // Loading animation may fail for groups or unsupported clients - ignore logVerbose(`line: loading animation failed (non-fatal): ${String(err)}`); } } /** * Fetch user profile (display name, picture URL) */ export async function getUserProfile( userId: string, opts: { channelAccessToken?: string; accountId?: string; useCache?: boolean } = {}, ): Promise<{ displayName: string; pictureUrl?: string } | null> { const useCache = opts.useCache ?? true; // Check cache first if (useCache) { const cached = userProfileCache.get(userId); if (cached && Date.now() - cached.fetchedAt < PROFILE_CACHE_TTL_MS) { return { displayName: cached.displayName, pictureUrl: cached.pictureUrl }; } } const { client } = createLineMessagingClient(opts); try { const profile = await client.getProfile(userId); const result = { displayName: profile.displayName, pictureUrl: profile.pictureUrl, }; // Cache the result userProfileCache.set(userId, { ...result, fetchedAt: Date.now(), }); return result; } catch (err) { logVerbose(`line: failed to fetch profile for ${userId}: ${String(err)}`); return null; } } /** * Get user's display name (with fallback to userId) */ export async function getUserDisplayName( userId: string, opts: { channelAccessToken?: string; accountId?: string } = {}, ): Promise { const profile = await getUserProfile(userId, opts); return profile?.displayName ?? userId; }