475 lines
12 KiB
TypeScript
475 lines
12 KiB
TypeScript
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<LineSendOpts, "cfg" | "channelAccessToken" | "accountId">;
|
|
type LinePushOpts = Pick<LineSendOpts, "cfg" | "channelAccessToken" | "accountId" | "verbose">;
|
|
|
|
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<typeof resolveLineAccount>;
|
|
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<typeof resolveLineAccount>;
|
|
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<LineSendResult> {
|
|
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<void> {
|
|
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<LineSendResult> {
|
|
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<LineSendResult> {
|
|
// Force push (no reply token)
|
|
return sendMessageLine(to, text, { ...opts, replyToken: undefined });
|
|
}
|
|
|
|
export async function replyMessageLine(
|
|
replyToken: string,
|
|
messages: Message[],
|
|
opts: LinePushOpts = {},
|
|
): Promise<void> {
|
|
await replyLineMessages(replyToken, messages, opts);
|
|
}
|
|
|
|
export async function pushMessagesLine(
|
|
to: string,
|
|
messages: Message[],
|
|
opts: LinePushOpts = {},
|
|
): Promise<LineSendResult> {
|
|
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<LineSendResult> {
|
|
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<LineSendResult> {
|
|
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<LineSendResult> {
|
|
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<LineSendResult> {
|
|
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<LineSendResult> {
|
|
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<void> {
|
|
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<string> {
|
|
const profile = await getUserProfile(userId, opts);
|
|
return profile?.displayName ?? userId;
|
|
}
|