Spracherkennung für: .ts vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]
import type { webhook } from "@line/bot-sdk";
import {
buildMentionRegexes,
matchesMentionPatterns,
resolveInboundMentionDecision,
} from "openclaw/plugin-sdk/channel-inbound";
import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
import { hasControlCommand, resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/config-runtime";
import {
readChannelAllowFromStore,
resolvePairingIdLabel,
upsertChannelPairingRequest,
} from "openclaw/plugin-sdk/conversation-runtime";
import { evaluateMatchedGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
import { createClaimableDedupe, type ClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
import {
DEFAULT_GROUP_HISTORY_LIMIT,
clearHistoryEntriesIfEnabled,
recordPendingHistoryEntryIfEnabled,
type HistoryEntry,
} from "openclaw/plugin-sdk/reply-history";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import {
firstDefined,
isSenderAllowed,
normalizeAllowFrom,
normalizeDmAllowFromWithStore,
type NormalizedAllowFrom,
} from "./bot-access.js";
import {
buildLineMessageContext,
buildLinePostbackContext,
getLineSourceInfo,
type LineInboundContext,
} from "./bot-message-context.js";
import { downloadLineMedia } from "./download.js";
import { resolveLineGroupConfigEntry } from "./group-keys.js";
import { pushMessageLine, replyMessageLine } from "./send.js";
import type { LineGroupConfig, ResolvedLineAccount } from "./types.js";
type FollowEvent = webhook.FollowEvent;
type JoinEvent = webhook.JoinEvent;
type LeaveEvent = webhook.LeaveEvent;
type MessageEvent = webhook.MessageEvent;
type PostbackEvent = webhook.PostbackEvent;
type UnfollowEvent = webhook.UnfollowEvent;
type WebhookEvent = webhook.Event;
interface MediaRef {
path: string;
contentType?: string;
}
const LINE_DOWNLOADABLE_MESSAGE_TYPES: ReadonlySet<string> = new Set([
"image",
"video",
"audio",
"file",
]);
function isDownloadableLineMessageType(
messageType: MessageEvent["message"]["type"],
): messageType is "image" | "video" | "audio" | "file" {
return LINE_DOWNLOADABLE_MESSAGE_TYPES.has(messageType);
}
export interface LineHandlerContext {
cfg: OpenClawConfig;
account: ResolvedLineAccount;
runtime: RuntimeEnv;
mediaMaxBytes: number;
processMessage: (ctx: LineInboundContext) => Promise<void>;
replayCache?: LineWebhookReplayCache;
groupHistories?: Map<string, HistoryEntry[]>;
historyLimit?: number;
}
const LINE_WEBHOOK_REPLAY_WINDOW_MS = 10 * 60 * 1000;
const LINE_WEBHOOK_REPLAY_MAX_ENTRIES = 4096;
export type LineWebhookReplayCache = ClaimableDedupe;
export class LineRetryableWebhookError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "LineRetryableWebhookError";
}
}
export function createLineWebhookReplayCache(): LineWebhookReplayCache {
return createClaimableDedupe({
ttlMs: LINE_WEBHOOK_REPLAY_WINDOW_MS,
memoryMaxSize: LINE_WEBHOOK_REPLAY_MAX_ENTRIES,
});
}
function buildLineWebhookReplayKey(
event: WebhookEvent,
accountId: string,
): { key: string; eventId: string } | null {
if (event.type === "message") {
const messageId = event.message?.id?.trim();
if (messageId) {
return {
key: `${accountId}|message:${messageId}`,
eventId: `message:${messageId}`,
};
}
}
const eventId = (event as { webhookEventId?: string }).webhookEventId?.trim();
if (!eventId) {
return null;
}
const source = (
event as {
source?: { type?: string; userId?: string; groupId?: string; roomId?: string };
}
).source;
const sourceId =
source?.type === "group"
? `group:${source.groupId ?? ""}`
: source?.type === "room"
? `room:${source.roomId ?? ""}`
: `user:${source?.userId ?? ""}`;
return { key: `${accountId}|${event.type}|${sourceId}|${eventId}`, eventId: `event:${eventId}` };
}
type LineReplayCandidate = {
key: string;
eventId: string;
cache: LineWebhookReplayCache;
};
function getLineReplayCandidate(
event: WebhookEvent,
context: LineHandlerContext,
): LineReplayCandidate | null {
const replay = buildLineWebhookReplayKey(event, context.account.accountId);
const cache = context.replayCache;
if (!replay || !cache) {
return null;
}
return { key: replay.key, eventId: replay.eventId, cache };
}
async function claimLineReplayEvent(
candidate: LineReplayCandidate,
): Promise<{ skip: true; inFlightResult?: Promise<void> } | { skip: false }> {
const claim = await candidate.cache.claim(candidate.key);
if (claim.kind === "claimed") {
return { skip: false };
}
if (claim.kind === "inflight") {
logVerbose(`line: skipped in-flight replayed webhook event ${candidate.eventId}`);
return { skip: true, inFlightResult: claim.pending.then(() => undefined) };
}
logVerbose(`line: skipped replayed webhook event ${candidate.eventId}`);
return { skip: true };
}
function resolveLineGroupConfig(params: {
config: ResolvedLineAccount["config"];
groupId?: string;
roomId?: string;
}): LineGroupConfig | undefined {
return resolveLineGroupConfigEntry(params.config.groups, {
groupId: params.groupId,
roomId: params.roomId,
});
}
async function sendLinePairingReply(params: {
senderId: string;
replyToken?: string;
context: LineHandlerContext;
}): Promise<void> {
const { senderId, replyToken, context } = params;
const idLabel = (() => {
try {
return resolvePairingIdLabel("line");
} catch {
return "lineUserId";
}
})();
await createChannelPairingChallengeIssuer({
channel: "line",
upsertPairingRequest: async ({ id, meta }) =>
await upsertChannelPairingRequest({
channel: "line",
id,
accountId: context.account.accountId,
meta,
}),
})({
senderId,
senderIdLine: `Your ${idLabel}: ${senderId}`,
onCreated: () => {
logVerbose(`line pairing request sender=${senderId}`);
},
sendPairingReply: async (text) => {
if (replyToken) {
try {
await replyMessageLine(replyToken, [{ type: "text", text }], {
cfg: context.cfg,
accountId: context.account.accountId,
channelAccessToken: context.account.channelAccessToken,
});
return;
} catch (err) {
logVerbose(`line pairing reply failed for ${senderId}: ${String(err)}`);
}
}
try {
await pushMessageLine(`line:${senderId}`, text, {
cfg: context.cfg,
accountId: context.account.accountId,
channelAccessToken: context.account.channelAccessToken,
});
} catch (err) {
logVerbose(`line pairing reply failed for ${senderId}: ${String(err)}`);
}
},
});
}
async function shouldProcessLineEvent(
event: MessageEvent | PostbackEvent,
context: LineHandlerContext,
): Promise<{ allowed: boolean; commandAuthorized: boolean }> {
const denied = { allowed: false, commandAuthorized: false };
const { cfg, account } = context;
const { userId, groupId, roomId, isGroup } = getLineSourceInfo(event.source);
const senderId = userId ?? "";
const dmPolicy = account.config.dmPolicy ?? "pairing";
const storeAllowFrom = await readChannelAllowFromStore(
"line",
undefined,
account.accountId,
).catch(() => []);
const effectiveDmAllow = normalizeDmAllowFromWithStore({
allowFrom: account.config.allowFrom,
storeAllowFrom,
dmPolicy,
});
const groupConfig = resolveLineGroupConfig({ config: account.config, groupId, roomId });
const groupAllowOverride = groupConfig?.allowFrom;
const fallbackGroupAllowFrom = account.config.allowFrom?.length
? account.config.allowFrom
: undefined;
const groupAllowFrom = firstDefined(
groupAllowOverride,
account.config.groupAllowFrom,
fallbackGroupAllowFrom,
);
const effectiveGroupAllow = normalizeAllowFrom(groupAllowFrom);
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
const { groupPolicy, providerMissingFallbackApplied } =
resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.line !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerKey: "line",
accountId: account.accountId,
log: (message) => logVerbose(message),
});
if (isGroup) {
if (groupConfig?.enabled === false) {
logVerbose(`Blocked line group ${groupId ?? roomId ?? "unknown"} (group disabled)`);
return denied;
}
if (groupAllowOverride !== undefined) {
if (!senderId) {
logVerbose("Blocked line group message (group allowFrom override, no sender ID)");
return denied;
}
if (!isSenderAllowed({ allow: effectiveGroupAllow, senderId })) {
logVerbose(`Blocked line group sender ${senderId} (group allowFrom override)`);
return denied;
}
}
const senderGroupAccess = evaluateMatchedGroupAccessForPolicy({
groupPolicy,
requireMatchInput: true,
hasMatchInput: Boolean(senderId),
allowlistConfigured: effectiveGroupAllow.entries.length > 0,
allowlistMatched:
Boolean(senderId) &&
isSenderAllowed({
allow: effectiveGroupAllow,
senderId,
}),
});
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "disabled") {
logVerbose("Blocked line group message (groupPolicy: disabled)");
return denied;
}
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "missing_match_input") {
logVerbose("Blocked line group message (no sender ID, groupPolicy: allowlist)");
return denied;
}
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "empty_allowlist") {
logVerbose("Blocked line group message (groupPolicy: allowlist, no groupAllowFrom)");
return denied;
}
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "not_allowlisted") {
logVerbose(`Blocked line group message from ${senderId} (groupPolicy: allowlist)`);
return denied;
}
return {
allowed: true,
commandAuthorized: resolveLineCommandAuthorized({
cfg,
event,
senderId,
allow: effectiveGroupAllow,
}),
};
}
if (dmPolicy === "disabled") {
logVerbose("Blocked line sender (dmPolicy: disabled)");
return denied;
}
const dmAllowed = dmPolicy === "open" || isSenderAllowed({ allow: effectiveDmAllow, senderId });
if (!dmAllowed) {
if (dmPolicy === "pairing") {
if (!senderId) {
logVerbose("Blocked line sender (dmPolicy: pairing, no sender ID)");
return denied;
}
await sendLinePairingReply({
senderId,
replyToken: "replyToken" in event ? event.replyToken : undefined,
context,
});
} else {
logVerbose(`Blocked line sender ${senderId || "unknown"} (dmPolicy: ${dmPolicy})`);
}
return denied;
}
return {
allowed: true,
commandAuthorized: resolveLineCommandAuthorized({
cfg,
event,
senderId,
allow: effectiveDmAllow,
}),
};
}
function getLineMentionees(
message: MessageEvent["message"],
): Array<{ type?: string; isSelf?: boolean }> {
if (message.type !== "text") {
return [];
}
const mentionees = (
message as Record<string, unknown> & {
mention?: { mentionees?: Array<{ type?: string; isSelf?: boolean }> };
}
).mention?.mentionees;
return Array.isArray(mentionees) ? mentionees : [];
}
function isLineBotMentioned(message: MessageEvent["message"]): boolean {
return getLineMentionees(message).some((m) => m.isSelf === true || m.type === "all");
}
function hasAnyLineMention(message: MessageEvent["message"]): boolean {
return getLineMentionees(message).length > 0;
}
function resolveEventRawText(event: MessageEvent | PostbackEvent): string {
if (event.type === "message") {
const msg = event.message;
if (msg.type === "text") {
return msg.text;
}
return "";
}
if (event.type === "postback") {
return event.postback?.data?.trim() ?? "";
}
return "";
}
function resolveLineCommandAuthorized(params: {
cfg: OpenClawConfig;
event: MessageEvent | PostbackEvent;
senderId?: string;
allow: NormalizedAllowFrom;
}): boolean {
const senderAllowedForCommands = isSenderAllowed({
allow: params.allow,
senderId: params.senderId,
});
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
const rawText = resolveEventRawText(params.event);
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [{ configured: params.allow.hasEntries, allowed: senderAllowedForCommands }],
allowTextCommands: true,
hasControlCommand: hasControlCommand(rawText, params.cfg),
});
return commandGate.commandAuthorized;
}
async function handleMessageEvent(event: MessageEvent, context: LineHandlerContext): Promise<void> {
const { cfg, account, runtime, mediaMaxBytes, processMessage } = context;
const message = event.message;
const decision = await shouldProcessLineEvent(event, context);
if (!decision.allowed) {
return;
}
const { isGroup, groupId, roomId } = getLineSourceInfo(event.source);
if (isGroup) {
const groupConfig = resolveLineGroupConfig({ config: account.config, groupId, roomId });
const requireMention = groupConfig?.requireMention !== false;
const rawText = message.type === "text" ? message.text : "";
const sourceInfo = getLineSourceInfo(event.source);
const peerId = groupId ?? roomId ?? sourceInfo.userId ?? "unknown";
const { agentId } = resolveAgentRoute({
cfg,
channel: "line",
accountId: account.accountId,
peer: { kind: "group", id: peerId },
});
const mentionRegexes = buildMentionRegexes(cfg, agentId);
const wasMentionedByNative = isLineBotMentioned(message);
const wasMentionedByPattern =
message.type === "text" ? matchesMentionPatterns(rawText, mentionRegexes) : false;
const wasMentioned = wasMentionedByNative || wasMentionedByPattern;
const mentionDecision = resolveInboundMentionDecision({
facts: {
canDetectMention: message.type === "text",
wasMentioned,
hasAnyMention: hasAnyLineMention(message),
implicitMentionKinds: [],
},
policy: {
isGroup: true,
requireMention,
allowTextCommands: true,
hasControlCommand: hasControlCommand(rawText, cfg),
commandAuthorized: decision.commandAuthorized,
},
});
if (mentionDecision.shouldSkip) {
logVerbose(`line: skipping group message (requireMention, not mentioned)`);
const historyKey = groupId ?? roomId;
const senderId = sourceInfo.userId ?? "unknown";
if (historyKey && context.groupHistories) {
recordPendingHistoryEntryIfEnabled({
historyMap: context.groupHistories,
historyKey,
limit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
entry: {
sender: `user:${senderId}`,
body: rawText || `<${message.type}>`,
timestamp: event.timestamp,
},
});
}
return;
}
}
const allMedia: MediaRef[] = [];
if (isDownloadableLineMessageType(message.type)) {
try {
const media = await downloadLineMedia(message.id, account.channelAccessToken, mediaMaxBytes);
allMedia.push({
path: media.path,
contentType: media.contentType,
});
} catch (err) {
const errMsg = String(err);
if (errMsg.includes("exceeds") && errMsg.includes("limit")) {
logVerbose(`line: media exceeds size limit for message ${message.id}`);
} else {
runtime.error?.(danger(`line: failed to download media: ${errMsg}`));
}
}
}
const messageContext = await buildLineMessageContext({
event,
allMedia,
cfg,
account,
commandAuthorized: decision.commandAuthorized,
groupHistories: context.groupHistories,
historyLimit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
});
if (!messageContext) {
logVerbose("line: skipping empty message");
return;
}
await processMessage(messageContext);
if (isGroup && context.groupHistories) {
const historyKey = groupId ?? roomId;
if (historyKey && context.groupHistories.has(historyKey)) {
clearHistoryEntriesIfEnabled({
historyMap: context.groupHistories,
historyKey,
limit: context.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
});
}
}
}
async function handleFollowEvent(event: FollowEvent, _context: LineHandlerContext): Promise<void> {
const { userId } = getLineSourceInfo(event.source);
logVerbose(`line: user ${userId ?? "unknown"} followed`);
}
async function handleUnfollowEvent(
event: UnfollowEvent,
_context: LineHandlerContext,
): Promise<void> {
const { userId } = getLineSourceInfo(event.source);
logVerbose(`line: user ${userId ?? "unknown"} unfollowed`);
}
async function handleJoinEvent(event: JoinEvent, _context: LineHandlerContext): Promise<void> {
const { groupId, roomId } = getLineSourceInfo(event.source);
logVerbose(`line: bot joined ${groupId ? `group ${groupId}` : `room ${roomId}`}`);
}
async function handleLeaveEvent(event: LeaveEvent, _context: LineHandlerContext): Promise<void> {
const { groupId, roomId } = getLineSourceInfo(event.source);
logVerbose(`line: bot left ${groupId ? `group ${groupId}` : `room ${roomId}`}`);
}
async function handlePostbackEvent(
event: PostbackEvent,
context: LineHandlerContext,
): Promise<void> {
const data = event.postback.data;
logVerbose(`line: received postback: ${data}`);
const decision = await shouldProcessLineEvent(event, context);
if (!decision.allowed) {
return;
}
const postbackContext = await buildLinePostbackContext({
event,
cfg: context.cfg,
account: context.account,
commandAuthorized: decision.commandAuthorized,
});
if (!postbackContext) {
return;
}
await context.processMessage(postbackContext);
}
export async function handleLineWebhookEvents(
events: WebhookEvent[],
context: LineHandlerContext,
): Promise<void> {
let firstError: unknown;
for (const event of events) {
const replayCandidate = getLineReplayCandidate(event, context);
const replaySkip = replayCandidate ? await claimLineReplayEvent(replayCandidate) : null;
if (replaySkip?.skip) {
if (replaySkip.inFlightResult) {
try {
await replaySkip.inFlightResult;
} catch (err) {
context.runtime.error?.(danger(`line: replayed in-flight event failed: ${String(err)}`));
firstError ??= err;
}
}
continue;
}
try {
switch (event.type) {
case "message":
await handleMessageEvent(event, context);
break;
case "follow":
await handleFollowEvent(event, context);
break;
case "unfollow":
await handleUnfollowEvent(event, context);
break;
case "join":
await handleJoinEvent(event, context);
break;
case "leave":
await handleLeaveEvent(event, context);
break;
case "postback":
await handlePostbackEvent(event, context);
break;
default:
logVerbose(`line: unhandled event type: ${(event as WebhookEvent).type}`);
}
if (replayCandidate) {
await replayCandidate.cache.commit(replayCandidate.key);
}
} catch (err) {
if (replayCandidate) {
if (err instanceof LineRetryableWebhookError) {
replayCandidate.cache.release(replayCandidate.key, { error: err });
} else {
await replayCandidate.cache.commit(replayCandidate.key);
}
}
context.runtime.error?.(danger(`line: event handler failed: ${String(err)}`));
firstError ??= err;
}
}
if (firstError) {
throw firstError;
}
}
¤ Dauer der Verarbeitung: 0.19 Sekunden
(vorverarbeitet am 2026-04-27)
¤
*© Formatika GbR, Deutschland
|
|