Files
clawdbot/src/line/bot-message-context.ts
plum-dawg c96ffa7186 feat: Add Line plugin (#1630)
* feat: add LINE plugin (#1630) (thanks @plum-dawg)

* feat: complete LINE plugin (#1630) (thanks @plum-dawg)

* chore: drop line plugin node_modules (#1630) (thanks @plum-dawg)

* test: mock /context report in commands test (#1630) (thanks @plum-dawg)

* test: limit macOS CI workers to avoid OOM (#1630) (thanks @plum-dawg)

* test: reduce macOS CI vitest workers (#1630) (thanks @plum-dawg)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-25 12:22:36 +00:00

466 lines
12 KiB
TypeScript

import type {
MessageEvent,
TextEventMessage,
StickerEventMessage,
LocationEventMessage,
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 { ClawdbotConfig } 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: ClawdbotConfig;
account: ResolvedLineAccount;
}
function getSourceInfo(source: EventSource): {
userId?: string;
groupId?: string;
roomId?: string;
isGroup: boolean;
} {
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";
}
// Common LINE sticker package descriptions
const STICKER_PACKAGES: Record<string, string> = {
"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 as TextEventMessage).text;
}
if (message.type === "location") {
const loc = message as LocationEventMessage;
return (
formatLocationText({
latitude: loc.latitude,
longitude: loc.longitude,
name: loc.title,
address: loc.address,
}) ?? ""
);
}
if (message.type === "sticker") {
const sticker = message as StickerEventMessage;
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 "<media:image>";
case "video":
return "<media:video>";
case "audio":
return "<media:audio>";
case "file":
return "<media:document>";
default:
return "";
}
}
export async function buildLineMessageContext(params: BuildLineMessageContextParams) {
const { event, allMedia, cfg, account } = params;
recordChannelActivity({
channel: "line",
accountId: account.accountId,
direction: "inbound",
});
const source = event.source;
const { userId, groupId, roomId, isGroup } = getSourceInfo(source);
const peerId = buildPeerId(source);
const route = resolveAgentRoute({
cfg,
channel: "line",
accountId: account.accountId,
peer: {
kind: isGroup ? "group" : "dm",
id: peerId,
},
});
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 = `<media:image>${allMedia.length > 1 ? ` (${allMedia.length} images)` : ""}`;
}
if (!rawBody && allMedia.length === 0) {
return null;
}
// Build sender info
const senderId = userId ?? "unknown";
const senderLabel = userId ? `user:${userId}` : "unknown";
// Build conversation label
const conversationLabel = isGroup
? groupId
? `group:${groupId}`
: roomId
? `room:${roomId}`
: "unknown-group"
: senderLabel;
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = formatInboundEnvelope({
channel: "LINE",
from: conversationLabel,
timestamp,
body: rawBody,
chatType: isGroup ? "group" : "direct",
sender: {
id: senderId,
},
previousTimestamp,
envelope: envelopeOptions,
});
// Build location context if applicable
let locationContext: ReturnType<typeof toLocationContext> | undefined;
if (message.type === "location") {
const loc = message as LocationEventMessage;
locationContext = toLocationContext({
latitude: loc.latitude,
longitude: loc.longitude,
name: loc.title,
address: loc.address,
});
}
const fromAddress = isGroup
? groupId
? `line:group:${groupId}`
: roomId
? `line:room:${roomId}`
: `line:${peerId}`
: `line:${userId ?? peerId}`;
const toAddress = isGroup ? fromAddress : `line:${userId ?? peerId}`;
const originatingTo = isGroup ? fromAddress : `line:${userId ?? peerId}`;
const ctxPayload = finalizeInboundContext({
Body: body,
RawBody: rawBody,
CommandBody: rawBody,
From: fromAddress,
To: toAddress,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
ConversationLabel: conversationLabel,
GroupSubject: isGroup ? (groupId ?? roomId) : undefined,
SenderId: senderId,
Provider: "line",
Surface: "line",
MessageSid: messageId,
Timestamp: timestamp,
MediaPath: allMedia[0]?.path,
MediaType: allMedia[0]?.contentType,
MediaUrl: allMedia[0]?.path,
MediaPaths: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
MediaUrls: allMedia.length > 0 ? allMedia.map((m) => m.path) : undefined,
MediaTypes:
allMedia.length > 0
? (allMedia.map((m) => m.contentType).filter(Boolean) as string[])
: undefined,
...locationContext,
OriginatingChannel: "line" as const,
OriginatingTo: originatingTo,
});
void recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
}).catch((err) => {
logVerbose(`line: failed updating session meta: ${String(err)}`);
});
if (!isGroup) {
await updateLastRoute({
storePath,
sessionKey: route.mainSessionKey,
deliveryContext: {
channel: "line",
to: userId ?? peerId,
accountId: route.accountId,
},
ctx: ctxPayload,
});
}
if (shouldLogVerbose()) {
const preview = body.slice(0, 200).replace(/\n/g, "\\n");
const mediaInfo = allMedia.length > 1 ? ` mediaCount=${allMedia.length}` : "";
logVerbose(
`line inbound: from=${ctxPayload.From} len=${body.length}${mediaInfo} preview="${preview}"`,
);
}
return {
ctxPayload,
event,
userId,
groupId,
roomId,
isGroup,
route,
replyToken: event.replyToken,
accountId: account.accountId,
};
}
export async function buildLinePostbackContext(params: {
event: PostbackEvent;
cfg: ClawdbotConfig;
account: ResolvedLineAccount;
}) {
const { event, cfg, account } = params;
recordChannelActivity({
channel: "line",
accountId: account.accountId,
direction: "inbound",
});
const source = event.source;
const { userId, groupId, roomId, isGroup } = getSourceInfo(source);
const peerId = buildPeerId(source);
const route = resolveAgentRoute({
cfg,
channel: "line",
accountId: account.accountId,
peer: {
kind: isGroup ? "group" : "dm",
id: peerId,
},
});
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 senderId = userId ?? "unknown";
const senderLabel = userId ? `user:${userId}` : "unknown";
const conversationLabel = isGroup
? groupId
? `group:${groupId}`
: roomId
? `room:${roomId}`
: "unknown-group"
: senderLabel;
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = formatInboundEnvelope({
channel: "LINE",
from: conversationLabel,
timestamp,
body: rawBody,
chatType: isGroup ? "group" : "direct",
sender: {
id: senderId,
},
previousTimestamp,
envelope: envelopeOptions,
});
const fromAddress = isGroup
? groupId
? `line:group:${groupId}`
: roomId
? `line:room:${roomId}`
: `line:${peerId}`
: `line:${userId ?? peerId}`;
const toAddress = isGroup ? fromAddress : `line:${userId ?? peerId}`;
const originatingTo = isGroup ? fromAddress : `line:${userId ?? peerId}`;
const ctxPayload = finalizeInboundContext({
Body: body,
RawBody: rawBody,
CommandBody: rawBody,
From: fromAddress,
To: toAddress,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
ConversationLabel: conversationLabel,
GroupSubject: isGroup ? (groupId ?? roomId) : undefined,
SenderId: senderId,
Provider: "line",
Surface: "line",
MessageSid: event.replyToken ? `postback:${event.replyToken}` : `postback:${timestamp}`,
Timestamp: timestamp,
MediaPath: "",
MediaType: undefined,
MediaUrl: "",
MediaPaths: undefined,
MediaUrls: undefined,
MediaTypes: undefined,
OriginatingChannel: "line" as const,
OriginatingTo: originatingTo,
});
void recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
}).catch((err) => {
logVerbose(`line: failed updating session meta: ${String(err)}`);
});
if (!isGroup) {
await updateLastRoute({
storePath,
sessionKey: route.mainSessionKey,
deliveryContext: {
channel: "line",
to: userId ?? peerId,
accountId: route.accountId,
},
ctx: ctxPayload,
});
}
if (shouldLogVerbose()) {
const preview = body.slice(0, 200).replace(/\n/g, "\\n");
logVerbose(`line postback: from=${ctxPayload.From} len=${body.length} preview="${preview}"`);
}
return {
ctxPayload,
event,
userId,
groupId,
roomId,
isGroup,
route,
replyToken: event.replyToken,
accountId: account.accountId,
};
}
export type LineMessageContext = NonNullable<Awaited<ReturnType<typeof buildLineMessageContext>>>;
export type LinePostbackContext = NonNullable<Awaited<ReturnType<typeof buildLinePostbackContext>>>;
export type LineInboundContext = LineMessageContext | LinePostbackContext;