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>
This commit is contained in:
465
src/line/bot-message-context.ts
Normal file
465
src/line/bot-message-context.ts
Normal file
@@ -0,0 +1,465 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user