fix(line): synthesize media/auth/routing webhook regressions (openclaw#32546) thanks @Takhoffman

Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Takhoffman <781889+Takhoffman@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Tak Hoffman
2026-03-02 23:47:56 -06:00
committed by GitHub
parent 0b3bbfec06
commit 9a5bfb1fe5
11 changed files with 409 additions and 76 deletions

View File

@@ -7,6 +7,8 @@ import type {
LeaveEvent,
PostbackEvent,
} from "@line/bot-sdk";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import { resolveControlCommandGate } from "../channels/command-gating.js";
import type { OpenClawConfig } from "../config/config.js";
import {
resolveAllowlistProviderRuntimeGroupPolicy,
@@ -42,6 +44,19 @@ interface MediaRef {
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;
@@ -113,10 +128,15 @@ async function sendLinePairingReply(params: {
}
}
type LineAccessDecision = {
allowed: boolean;
commandAuthorized: boolean;
};
async function shouldProcessLineEvent(
event: MessageEvent | PostbackEvent,
context: LineHandlerContext,
): Promise<boolean> {
): Promise<LineAccessDecision> {
const { cfg, account } = context;
const { userId, groupId, roomId, isGroup } = getLineSourceInfo(event.source);
const senderId = userId ?? "";
@@ -159,45 +179,59 @@ async function shouldProcessLineEvent(
log: (message) => logVerbose(message),
});
const denied = { allowed: false, commandAuthorized: false };
if (isGroup) {
if (groupConfig?.enabled === false) {
logVerbose(`Blocked line group ${groupId ?? roomId ?? "unknown"} (group disabled)`);
return false;
return denied;
}
if (typeof groupAllowOverride !== "undefined") {
if (!senderId) {
logVerbose("Blocked line group message (group allowFrom override, no sender ID)");
return false;
return denied;
}
if (!isSenderAllowed({ allow: effectiveGroupAllow, senderId })) {
logVerbose(`Blocked line group sender ${senderId} (group allowFrom override)`);
return false;
return denied;
}
}
if (groupPolicy === "disabled") {
logVerbose("Blocked line group message (groupPolicy: disabled)");
return false;
return denied;
}
if (groupPolicy === "allowlist") {
if (!senderId) {
logVerbose("Blocked line group message (no sender ID, groupPolicy: allowlist)");
return false;
return denied;
}
if (!effectiveGroupAllow.hasEntries) {
logVerbose("Blocked line group message (groupPolicy: allowlist, no groupAllowFrom)");
return false;
return denied;
}
if (!isSenderAllowed({ allow: effectiveGroupAllow, senderId })) {
logVerbose(`Blocked line group message from ${senderId} (groupPolicy: allowlist)`);
return false;
return denied;
}
}
return true;
// Resolve command authorization using the same pattern as Telegram/Discord/Slack.
const allowForCommands = effectiveGroupAllow;
const senderAllowedForCommands = isSenderAllowed({ allow: allowForCommands, senderId });
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const rawText = resolveEventRawText(event);
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }],
allowTextCommands: true,
hasControlCommand: hasControlCommand(rawText, cfg),
});
return { allowed: true, commandAuthorized: commandGate.commandAuthorized };
}
if (dmPolicy === "disabled") {
logVerbose("Blocked line sender (dmPolicy: disabled)");
return false;
return denied;
}
const dmAllowed = dmPolicy === "open" || isSenderAllowed({ allow: effectiveDmAllow, senderId });
@@ -205,7 +239,7 @@ async function shouldProcessLineEvent(
if (dmPolicy === "pairing") {
if (!senderId) {
logVerbose("Blocked line sender (dmPolicy: pairing, no sender ID)");
return false;
return denied;
}
await sendLinePairingReply({
senderId,
@@ -215,24 +249,51 @@ async function shouldProcessLineEvent(
} else {
logVerbose(`Blocked line sender ${senderId || "unknown"} (dmPolicy: ${dmPolicy})`);
}
return false;
return denied;
}
return true;
// Resolve command authorization for DMs.
const allowForCommands = effectiveDmAllow;
const senderAllowedForCommands = isSenderAllowed({ allow: allowForCommands, senderId });
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const rawText = resolveEventRawText(event);
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [{ configured: allowForCommands.hasEntries, allowed: senderAllowedForCommands }],
allowTextCommands: true,
hasControlCommand: hasControlCommand(rawText, cfg),
});
return { allowed: true, commandAuthorized: commandGate.commandAuthorized };
}
/** Extract raw text from a LINE message or postback event for command detection. */
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 "";
}
async function handleMessageEvent(event: MessageEvent, context: LineHandlerContext): Promise<void> {
const { cfg, account, runtime, mediaMaxBytes, processMessage } = context;
const message = event.message;
if (!(await shouldProcessLineEvent(event, context))) {
const decision = await shouldProcessLineEvent(event, context);
if (!decision.allowed) {
return;
}
// Download media if applicable
const allMedia: MediaRef[] = [];
if (message.type === "image" || message.type === "video" || message.type === "audio") {
if (isDownloadableLineMessageType(message.type)) {
try {
const media = await downloadLineMedia(message.id, account.channelAccessToken, mediaMaxBytes);
allMedia.push({
@@ -255,6 +316,7 @@ async function handleMessageEvent(event: MessageEvent, context: LineHandlerConte
allMedia,
cfg,
account,
commandAuthorized: decision.commandAuthorized,
});
if (!messageContext) {
@@ -298,7 +360,8 @@ async function handlePostbackEvent(
const data = event.postback.data;
logVerbose(`line: received postback: ${data}`);
if (!(await shouldProcessLineEvent(event, context))) {
const decision = await shouldProcessLineEvent(event, context);
if (!decision.allowed) {
return;
}
@@ -306,6 +369,7 @@ async function handlePostbackEvent(
event,
cfg: context.cfg,
account: context.account,
commandAuthorized: decision.commandAuthorized,
});
if (!postbackContext) {
return;
@@ -344,6 +408,9 @@ export async function handleLineWebhookEvents(
}
} catch (err) {
context.runtime.error?.(danger(`line: event handler failed: ${String(err)}`));
// Continue processing remaining events in this batch. Webhook ACK is sent
// before processing, so dropping later events here would make them unrecoverable.
continue;
}
}
}