| import { messagingApi } from "@line/bot-sdk"; |
| import { loadConfig } from "../config/config.js"; |
| import { logVerbose } from "../globals.js"; |
| import { recordChannelActivity } from "../infra/channel-activity.js"; |
| import { resolveLineAccount } from "./accounts.js"; |
| import type { LineSendResult } from "./types.js"; |
|
|
| |
| 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; |
|
|
| |
| const userProfileCache = new Map< |
| string, |
| { displayName: string; pictureUrl?: string; fetchedAt: number } |
| >(); |
| const PROFILE_CACHE_TTL_MS = 5 * 60 * 1000; |
|
|
| interface LineSendOpts { |
| channelAccessToken?: string; |
| accountId?: string; |
| verbose?: boolean; |
| mediaUrl?: string; |
| replyToken?: string; |
| } |
|
|
| function resolveToken( |
| explicit: string | undefined, |
| params: { accountId: string; channelAccessToken: string }, |
| ): string { |
| if (explicit?.trim()) { |
| return explicit.trim(); |
| } |
| if (!params.channelAccessToken) { |
| throw new Error( |
| `LINE channel access token missing for account "${params.accountId}" (set channels.line.channelAccessToken or LINE_CHANNEL_ACCESS_TOKEN).`, |
| ); |
| } |
| return params.channelAccessToken.trim(); |
| } |
|
|
| function normalizeTarget(to: string): string { |
| const trimmed = to.trim(); |
| if (!trimmed) { |
| throw new Error("Recipient is required for LINE sends"); |
| } |
|
|
| |
| 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 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), |
| address: location.address.slice(0, 100), |
| 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}`); |
| } |
| } |
|
|
| export async function sendMessageLine( |
| to: string, |
| text: string, |
| opts: LineSendOpts = {}, |
| ): Promise<LineSendResult> { |
| const cfg = loadConfig(); |
| const account = resolveLineAccount({ |
| cfg, |
| accountId: opts.accountId, |
| }); |
| const token = resolveToken(opts.channelAccessToken, account); |
| const chatId = normalizeTarget(to); |
|
|
| const client = new messagingApi.MessagingApiClient({ |
| channelAccessToken: token, |
| }); |
|
|
| const messages: Message[] = []; |
|
|
| |
| if (opts.mediaUrl?.trim()) { |
| messages.push(createImageMessage(opts.mediaUrl.trim())); |
| } |
|
|
| |
| if (text?.trim()) { |
| messages.push(createTextMessage(text.trim())); |
| } |
|
|
| if (messages.length === 0) { |
| throw new Error("Message must be non-empty for LINE sends"); |
| } |
|
|
| |
| if (opts.replyToken) { |
| await client.replyMessage({ |
| replyToken: opts.replyToken, |
| messages, |
| }); |
|
|
| recordChannelActivity({ |
| channel: "line", |
| accountId: account.accountId, |
| direction: "outbound", |
| }); |
|
|
| if (opts.verbose) { |
| logVerbose(`line: replied to ${chatId}`); |
| } |
|
|
| return { |
| messageId: "reply", |
| chatId, |
| }; |
| } |
|
|
| |
| await client.pushMessage({ |
| to: chatId, |
| messages, |
| }); |
|
|
| recordChannelActivity({ |
| channel: "line", |
| accountId: account.accountId, |
| direction: "outbound", |
| }); |
|
|
| if (opts.verbose) { |
| logVerbose(`line: pushed message to ${chatId}`); |
| } |
|
|
| return { |
| messageId: "push", |
| chatId, |
| }; |
| } |
|
|
| export async function pushMessageLine( |
| to: string, |
| text: string, |
| opts: LineSendOpts = {}, |
| ): Promise<LineSendResult> { |
| |
| return sendMessageLine(to, text, { ...opts, replyToken: undefined }); |
| } |
|
|
| export async function replyMessageLine( |
| replyToken: string, |
| messages: Message[], |
| opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, |
| ): Promise<void> { |
| const cfg = loadConfig(); |
| const account = resolveLineAccount({ |
| cfg, |
| accountId: opts.accountId, |
| }); |
| const token = resolveToken(opts.channelAccessToken, account); |
|
|
| const client = new messagingApi.MessagingApiClient({ |
| channelAccessToken: token, |
| }); |
|
|
| await client.replyMessage({ |
| replyToken, |
| messages, |
| }); |
|
|
| recordChannelActivity({ |
| channel: "line", |
| accountId: account.accountId, |
| direction: "outbound", |
| }); |
|
|
| if (opts.verbose) { |
| logVerbose(`line: replied with ${messages.length} messages`); |
| } |
| } |
|
|
| export async function pushMessagesLine( |
| to: string, |
| messages: Message[], |
| opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, |
| ): Promise<LineSendResult> { |
| if (messages.length === 0) { |
| throw new Error("Message must be non-empty for LINE sends"); |
| } |
|
|
| const cfg = loadConfig(); |
| const account = resolveLineAccount({ |
| cfg, |
| accountId: opts.accountId, |
| }); |
| const token = resolveToken(opts.channelAccessToken, account); |
| const chatId = normalizeTarget(to); |
|
|
| const client = new messagingApi.MessagingApiClient({ |
| channelAccessToken: token, |
| }); |
|
|
| await client |
| .pushMessage({ |
| to: chatId, |
| messages, |
| }) |
| .catch((err) => { |
| logLineHttpError(err, "push message"); |
| throw err; |
| }); |
|
|
| recordChannelActivity({ |
| channel: "line", |
| accountId: account.accountId, |
| direction: "outbound", |
| }); |
|
|
| if (opts.verbose) { |
| logVerbose(`line: pushed ${messages.length} messages to ${chatId}`); |
| } |
|
|
| return { |
| messageId: "push", |
| chatId, |
| }; |
| } |
|
|
| export function createFlexMessage( |
| altText: string, |
| contents: messagingApi.FlexContainer, |
| ): messagingApi.FlexMessage { |
| return { |
| type: "flex", |
| altText, |
| contents, |
| }; |
| } |
|
|
| |
| |
| |
| export async function pushImageMessage( |
| to: string, |
| originalContentUrl: string, |
| previewImageUrl?: string, |
| opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, |
| ): Promise<LineSendResult> { |
| const cfg = loadConfig(); |
| const account = resolveLineAccount({ |
| cfg, |
| accountId: opts.accountId, |
| }); |
| const token = resolveToken(opts.channelAccessToken, account); |
| const chatId = normalizeTarget(to); |
|
|
| const client = new messagingApi.MessagingApiClient({ |
| channelAccessToken: token, |
| }); |
|
|
| const imageMessage = createImageMessage(originalContentUrl, previewImageUrl); |
|
|
| await client.pushMessage({ |
| to: chatId, |
| messages: [imageMessage], |
| }); |
|
|
| recordChannelActivity({ |
| channel: "line", |
| accountId: account.accountId, |
| direction: "outbound", |
| }); |
|
|
| if (opts.verbose) { |
| logVerbose(`line: pushed image to ${chatId}`); |
| } |
|
|
| return { |
| messageId: "push", |
| chatId, |
| }; |
| } |
|
|
| |
| |
| |
| export async function pushLocationMessage( |
| to: string, |
| location: { |
| title: string; |
| address: string; |
| latitude: number; |
| longitude: number; |
| }, |
| opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, |
| ): Promise<LineSendResult> { |
| const cfg = loadConfig(); |
| const account = resolveLineAccount({ |
| cfg, |
| accountId: opts.accountId, |
| }); |
| const token = resolveToken(opts.channelAccessToken, account); |
| const chatId = normalizeTarget(to); |
|
|
| const client = new messagingApi.MessagingApiClient({ |
| channelAccessToken: token, |
| }); |
|
|
| const locationMessage = createLocationMessage(location); |
|
|
| await client.pushMessage({ |
| to: chatId, |
| messages: [locationMessage], |
| }); |
|
|
| recordChannelActivity({ |
| channel: "line", |
| accountId: account.accountId, |
| direction: "outbound", |
| }); |
|
|
| if (opts.verbose) { |
| logVerbose(`line: pushed location to ${chatId}`); |
| } |
|
|
| return { |
| messageId: "push", |
| chatId, |
| }; |
| } |
|
|
| |
| |
| |
| export async function pushFlexMessage( |
| to: string, |
| altText: string, |
| contents: FlexContainer, |
| opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, |
| ): Promise<LineSendResult> { |
| const cfg = loadConfig(); |
| const account = resolveLineAccount({ |
| cfg, |
| accountId: opts.accountId, |
| }); |
| const token = resolveToken(opts.channelAccessToken, account); |
| const chatId = normalizeTarget(to); |
|
|
| const client = new messagingApi.MessagingApiClient({ |
| channelAccessToken: token, |
| }); |
|
|
| const flexMessage: FlexMessage = { |
| type: "flex", |
| altText: altText.slice(0, 400), |
| contents, |
| }; |
|
|
| await client |
| .pushMessage({ |
| to: chatId, |
| messages: [flexMessage], |
| }) |
| .catch((err) => { |
| logLineHttpError(err, "push flex message"); |
| throw err; |
| }); |
|
|
| recordChannelActivity({ |
| channel: "line", |
| accountId: account.accountId, |
| direction: "outbound", |
| }); |
|
|
| if (opts.verbose) { |
| logVerbose(`line: pushed flex message to ${chatId}`); |
| } |
|
|
| return { |
| messageId: "push", |
| chatId, |
| }; |
| } |
|
|
| |
| |
| |
| export async function pushTemplateMessage( |
| to: string, |
| template: TemplateMessage, |
| opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, |
| ): Promise<LineSendResult> { |
| const cfg = loadConfig(); |
| const account = resolveLineAccount({ |
| cfg, |
| accountId: opts.accountId, |
| }); |
| const token = resolveToken(opts.channelAccessToken, account); |
| const chatId = normalizeTarget(to); |
|
|
| const client = new messagingApi.MessagingApiClient({ |
| channelAccessToken: token, |
| }); |
|
|
| await client.pushMessage({ |
| to: chatId, |
| messages: [template], |
| }); |
|
|
| recordChannelActivity({ |
| channel: "line", |
| accountId: account.accountId, |
| direction: "outbound", |
| }); |
|
|
| if (opts.verbose) { |
| logVerbose(`line: pushed template message to ${chatId}`); |
| } |
|
|
| return { |
| messageId: "push", |
| chatId, |
| }; |
| } |
|
|
| |
| |
| |
| export async function pushTextMessageWithQuickReplies( |
| to: string, |
| text: string, |
| quickReplyLabels: string[], |
| opts: { channelAccessToken?: string; accountId?: string; verbose?: boolean } = {}, |
| ): Promise<LineSendResult> { |
| const cfg = loadConfig(); |
| const account = resolveLineAccount({ |
| cfg, |
| accountId: opts.accountId, |
| }); |
| const token = resolveToken(opts.channelAccessToken, account); |
| const chatId = normalizeTarget(to); |
|
|
| const client = new messagingApi.MessagingApiClient({ |
| channelAccessToken: token, |
| }); |
|
|
| const message = createTextMessageWithQuickReplies(text, quickReplyLabels); |
|
|
| await client.pushMessage({ |
| to: chatId, |
| messages: [message], |
| }); |
|
|
| recordChannelActivity({ |
| channel: "line", |
| accountId: account.accountId, |
| direction: "outbound", |
| }); |
|
|
| if (opts.verbose) { |
| logVerbose(`line: pushed message with quick replies to ${chatId}`); |
| } |
|
|
| return { |
| messageId: "push", |
| chatId, |
| }; |
| } |
|
|
| |
| |
| |
| 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), |
| text: label, |
| }, |
| })); |
| return { items }; |
| } |
|
|
| |
| |
| |
| export function createTextMessageWithQuickReplies( |
| text: string, |
| quickReplyLabels: string[], |
| ): TextMessage & { quickReply: QuickReply } { |
| return { |
| type: "text", |
| text, |
| quickReply: createQuickReplyItems(quickReplyLabels), |
| }; |
| } |
|
|
| |
| |
| |
| export async function showLoadingAnimation( |
| chatId: string, |
| opts: { channelAccessToken?: string; accountId?: string; loadingSeconds?: number } = {}, |
| ): Promise<void> { |
| const cfg = loadConfig(); |
| const account = resolveLineAccount({ |
| cfg, |
| accountId: opts.accountId, |
| }); |
| const token = resolveToken(opts.channelAccessToken, account); |
|
|
| const client = new messagingApi.MessagingApiClient({ |
| channelAccessToken: token, |
| }); |
|
|
| try { |
| await client.showLoadingAnimation({ |
| chatId: normalizeTarget(chatId), |
| loadingSeconds: opts.loadingSeconds ?? 20, |
| }); |
| logVerbose(`line: showing loading animation to ${chatId}`); |
| } catch (err) { |
| |
| logVerbose(`line: loading animation failed (non-fatal): ${String(err)}`); |
| } |
| } |
|
|
| |
| |
| |
| export async function getUserProfile( |
| userId: string, |
| opts: { channelAccessToken?: string; accountId?: string; useCache?: boolean } = {}, |
| ): Promise<{ displayName: string; pictureUrl?: string } | null> { |
| const useCache = opts.useCache ?? true; |
|
|
| |
| 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 cfg = loadConfig(); |
| const account = resolveLineAccount({ |
| cfg, |
| accountId: opts.accountId, |
| }); |
| const token = resolveToken(opts.channelAccessToken, account); |
|
|
| const client = new messagingApi.MessagingApiClient({ |
| channelAccessToken: token, |
| }); |
|
|
| try { |
| const profile = await client.getProfile(userId); |
| const result = { |
| displayName: profile.displayName, |
| pictureUrl: profile.pictureUrl, |
| }; |
|
|
| |
| userProfileCache.set(userId, { |
| ...result, |
| fetchedAt: Date.now(), |
| }); |
|
|
| return result; |
| } catch (err) { |
| logVerbose(`line: failed to fetch profile for ${userId}: ${String(err)}`); |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| export async function getUserDisplayName( |
| userId: string, |
| opts: { channelAccessToken?: string; accountId?: string } = {}, |
| ): Promise<string> { |
| const profile = await getUserProfile(userId, opts); |
| return profile?.displayName ?? userId; |
| } |
|
|