491 lines
17 KiB
TypeScript
491 lines
17 KiB
TypeScript
import { hasControlCommand } from "../../auto-reply/command-detection.js";
|
|
import {
|
|
formatInboundEnvelope,
|
|
formatInboundFromLabel,
|
|
resolveEnvelopeFormatOptions,
|
|
type EnvelopeFormatOptions,
|
|
} from "../../auto-reply/envelope.js";
|
|
import {
|
|
buildPendingHistoryContextFromMap,
|
|
recordPendingHistoryEntryIfEnabled,
|
|
type HistoryEntry,
|
|
} from "../../auto-reply/reply/history.js";
|
|
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
|
|
import { buildMentionRegexes, matchesMentionPatterns } from "../../auto-reply/reply/mentions.js";
|
|
import { resolveControlCommandGate } from "../../channels/command-gating.js";
|
|
import { logInboundDrop } from "../../channels/logging.js";
|
|
import type { OpenClawConfig } from "../../config/config.js";
|
|
import {
|
|
resolveChannelGroupPolicy,
|
|
resolveChannelGroupRequireMention,
|
|
} from "../../config/group-policy.js";
|
|
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
|
import {
|
|
DM_GROUP_ACCESS_REASON,
|
|
resolveDmGroupAccessWithLists,
|
|
} from "../../security/dm-policy-shared.js";
|
|
import { truncateUtf16Safe } from "../../utils.js";
|
|
import {
|
|
formatIMessageChatTarget,
|
|
isAllowedIMessageSender,
|
|
normalizeIMessageHandle,
|
|
} from "../targets.js";
|
|
import type { MonitorIMessageOpts, IMessagePayload } from "./types.js";
|
|
|
|
type IMessageReplyContext = {
|
|
id?: string;
|
|
body: string;
|
|
sender?: string;
|
|
};
|
|
|
|
function normalizeReplyField(value: unknown): string | undefined {
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
return trimmed ? trimmed : undefined;
|
|
}
|
|
if (typeof value === "number") {
|
|
return String(value);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function describeReplyContext(message: IMessagePayload): IMessageReplyContext | null {
|
|
const body = normalizeReplyField(message.reply_to_text);
|
|
if (!body) {
|
|
return null;
|
|
}
|
|
const id = normalizeReplyField(message.reply_to_id);
|
|
const sender = normalizeReplyField(message.reply_to_sender);
|
|
return { body, id, sender };
|
|
}
|
|
|
|
export type IMessageInboundDispatchDecision = {
|
|
kind: "dispatch";
|
|
isGroup: boolean;
|
|
chatId?: number;
|
|
chatGuid?: string;
|
|
chatIdentifier?: string;
|
|
groupId?: string;
|
|
historyKey?: string;
|
|
sender: string;
|
|
senderNormalized: string;
|
|
route: ReturnType<typeof resolveAgentRoute>;
|
|
bodyText: string;
|
|
createdAt?: number;
|
|
replyContext: IMessageReplyContext | null;
|
|
effectiveWasMentioned: boolean;
|
|
commandAuthorized: boolean;
|
|
// Used for allowlist checks for control commands.
|
|
effectiveDmAllowFrom: string[];
|
|
effectiveGroupAllowFrom: string[];
|
|
};
|
|
|
|
export type IMessageInboundDecision =
|
|
| { kind: "drop"; reason: string }
|
|
| { kind: "pairing"; senderId: string }
|
|
| IMessageInboundDispatchDecision;
|
|
|
|
export function resolveIMessageInboundDecision(params: {
|
|
cfg: OpenClawConfig;
|
|
accountId: string;
|
|
message: IMessagePayload;
|
|
opts?: Pick<MonitorIMessageOpts, "requireMention">;
|
|
messageText: string;
|
|
bodyText: string;
|
|
allowFrom: string[];
|
|
groupAllowFrom: string[];
|
|
groupPolicy: string;
|
|
dmPolicy: string;
|
|
storeAllowFrom: string[];
|
|
historyLimit: number;
|
|
groupHistories: Map<string, HistoryEntry[]>;
|
|
echoCache?: { has: (scope: string, lookup: { text?: string; messageId?: string }) => boolean };
|
|
logVerbose?: (msg: string) => void;
|
|
}): IMessageInboundDecision {
|
|
const senderRaw = params.message.sender ?? "";
|
|
const sender = senderRaw.trim();
|
|
if (!sender) {
|
|
return { kind: "drop", reason: "missing sender" };
|
|
}
|
|
const senderNormalized = normalizeIMessageHandle(sender);
|
|
if (params.message.is_from_me) {
|
|
return { kind: "drop", reason: "from me" };
|
|
}
|
|
|
|
const chatId = params.message.chat_id ?? undefined;
|
|
const chatGuid = params.message.chat_guid ?? undefined;
|
|
const chatIdentifier = params.message.chat_identifier ?? undefined;
|
|
|
|
const groupIdCandidate = chatId !== undefined ? String(chatId) : undefined;
|
|
const groupListPolicy = groupIdCandidate
|
|
? resolveChannelGroupPolicy({
|
|
cfg: params.cfg,
|
|
channel: "imessage",
|
|
accountId: params.accountId,
|
|
groupId: groupIdCandidate,
|
|
})
|
|
: {
|
|
allowlistEnabled: false,
|
|
allowed: true,
|
|
groupConfig: undefined,
|
|
defaultConfig: undefined,
|
|
};
|
|
|
|
// If the owner explicitly configures a chat_id under imessage.groups, treat that thread as a
|
|
// "group" for permission gating + session isolation, even when is_group=false.
|
|
const treatAsGroupByConfig = Boolean(
|
|
groupIdCandidate && groupListPolicy.allowlistEnabled && groupListPolicy.groupConfig,
|
|
);
|
|
const isGroup = Boolean(params.message.is_group) || treatAsGroupByConfig;
|
|
if (isGroup && !chatId) {
|
|
return { kind: "drop", reason: "group without chat_id" };
|
|
}
|
|
|
|
const groupId = isGroup ? groupIdCandidate : undefined;
|
|
const accessDecision = resolveDmGroupAccessWithLists({
|
|
isGroup,
|
|
dmPolicy: params.dmPolicy,
|
|
groupPolicy: params.groupPolicy,
|
|
allowFrom: params.allowFrom,
|
|
groupAllowFrom: params.groupAllowFrom,
|
|
storeAllowFrom: params.storeAllowFrom,
|
|
groupAllowFromFallbackToAllowFrom: false,
|
|
isSenderAllowed: (allowFrom) =>
|
|
isAllowedIMessageSender({
|
|
allowFrom,
|
|
sender,
|
|
chatId,
|
|
chatGuid,
|
|
chatIdentifier,
|
|
}),
|
|
});
|
|
const effectiveDmAllowFrom = accessDecision.effectiveAllowFrom;
|
|
const effectiveGroupAllowFrom = accessDecision.effectiveGroupAllowFrom;
|
|
const dmAuthorized = !isGroup && accessDecision.decision === "allow";
|
|
|
|
if (accessDecision.decision !== "allow") {
|
|
if (isGroup) {
|
|
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_DISABLED) {
|
|
params.logVerbose?.("Blocked iMessage group message (groupPolicy: disabled)");
|
|
return { kind: "drop", reason: "groupPolicy disabled" };
|
|
}
|
|
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_EMPTY_ALLOWLIST) {
|
|
params.logVerbose?.(
|
|
"Blocked iMessage group message (groupPolicy: allowlist, no groupAllowFrom)",
|
|
);
|
|
return { kind: "drop", reason: "groupPolicy allowlist (empty groupAllowFrom)" };
|
|
}
|
|
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.GROUP_POLICY_NOT_ALLOWLISTED) {
|
|
params.logVerbose?.(`Blocked iMessage sender ${sender} (not in groupAllowFrom)`);
|
|
return { kind: "drop", reason: "not in groupAllowFrom" };
|
|
}
|
|
params.logVerbose?.(`Blocked iMessage group message (${accessDecision.reason})`);
|
|
return { kind: "drop", reason: accessDecision.reason };
|
|
}
|
|
if (accessDecision.reasonCode === DM_GROUP_ACCESS_REASON.DM_POLICY_DISABLED) {
|
|
return { kind: "drop", reason: "dmPolicy disabled" };
|
|
}
|
|
if (accessDecision.decision === "pairing") {
|
|
return { kind: "pairing", senderId: senderNormalized };
|
|
}
|
|
params.logVerbose?.(`Blocked iMessage sender ${sender} (dmPolicy=${params.dmPolicy})`);
|
|
return { kind: "drop", reason: "dmPolicy blocked" };
|
|
}
|
|
|
|
if (isGroup && groupListPolicy.allowlistEnabled && !groupListPolicy.allowed) {
|
|
params.logVerbose?.(
|
|
`imessage: skipping group message (${groupId ?? "unknown"}) not in allowlist`,
|
|
);
|
|
return { kind: "drop", reason: "group id not in allowlist" };
|
|
}
|
|
|
|
const route = resolveAgentRoute({
|
|
cfg: params.cfg,
|
|
channel: "imessage",
|
|
accountId: params.accountId,
|
|
peer: {
|
|
kind: isGroup ? "group" : "direct",
|
|
id: isGroup ? String(chatId ?? "unknown") : senderNormalized,
|
|
},
|
|
});
|
|
const mentionRegexes = buildMentionRegexes(params.cfg, route.agentId);
|
|
const messageText = params.messageText.trim();
|
|
const bodyText = params.bodyText.trim();
|
|
if (!bodyText) {
|
|
return { kind: "drop", reason: "empty body" };
|
|
}
|
|
|
|
// Echo detection: check if the received message matches a recently sent message (within 5 seconds).
|
|
// Scope by conversation so same text in different chats is not conflated.
|
|
const inboundMessageId = params.message.id != null ? String(params.message.id) : undefined;
|
|
if (params.echoCache && (messageText || inboundMessageId)) {
|
|
const echoScope = buildIMessageEchoScope({
|
|
accountId: params.accountId,
|
|
isGroup,
|
|
chatId,
|
|
sender,
|
|
});
|
|
if (
|
|
params.echoCache.has(echoScope, {
|
|
text: messageText || undefined,
|
|
messageId: inboundMessageId,
|
|
})
|
|
) {
|
|
params.logVerbose?.(
|
|
describeIMessageEchoDropLog({ messageText, messageId: inboundMessageId }),
|
|
);
|
|
return { kind: "drop", reason: "echo" };
|
|
}
|
|
}
|
|
|
|
const replyContext = describeReplyContext(params.message);
|
|
const createdAt = params.message.created_at ? Date.parse(params.message.created_at) : undefined;
|
|
const historyKey = isGroup
|
|
? String(chatId ?? chatGuid ?? chatIdentifier ?? "unknown")
|
|
: undefined;
|
|
|
|
const mentioned = isGroup ? matchesMentionPatterns(messageText, mentionRegexes) : true;
|
|
const requireMention = resolveChannelGroupRequireMention({
|
|
cfg: params.cfg,
|
|
channel: "imessage",
|
|
accountId: params.accountId,
|
|
groupId,
|
|
requireMentionOverride: params.opts?.requireMention,
|
|
overrideOrder: "before-config",
|
|
});
|
|
const canDetectMention = mentionRegexes.length > 0;
|
|
|
|
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
|
const ownerAllowedForCommands =
|
|
effectiveDmAllowFrom.length > 0
|
|
? isAllowedIMessageSender({
|
|
allowFrom: effectiveDmAllowFrom,
|
|
sender,
|
|
chatId,
|
|
chatGuid,
|
|
chatIdentifier,
|
|
})
|
|
: false;
|
|
const groupAllowedForCommands =
|
|
effectiveGroupAllowFrom.length > 0
|
|
? isAllowedIMessageSender({
|
|
allowFrom: effectiveGroupAllowFrom,
|
|
sender,
|
|
chatId,
|
|
chatGuid,
|
|
chatIdentifier,
|
|
})
|
|
: false;
|
|
const hasControlCommandInMessage = hasControlCommand(messageText, params.cfg);
|
|
const commandGate = resolveControlCommandGate({
|
|
useAccessGroups,
|
|
authorizers: [
|
|
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
|
|
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
|
|
],
|
|
allowTextCommands: true,
|
|
hasControlCommand: hasControlCommandInMessage,
|
|
});
|
|
const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized;
|
|
if (isGroup && commandGate.shouldBlock) {
|
|
if (params.logVerbose) {
|
|
logInboundDrop({
|
|
log: params.logVerbose,
|
|
channel: "imessage",
|
|
reason: "control command (unauthorized)",
|
|
target: sender,
|
|
});
|
|
}
|
|
return { kind: "drop", reason: "control command (unauthorized)" };
|
|
}
|
|
|
|
const shouldBypassMention =
|
|
isGroup && requireMention && !mentioned && commandAuthorized && hasControlCommandInMessage;
|
|
const effectiveWasMentioned = mentioned || shouldBypassMention;
|
|
if (isGroup && requireMention && canDetectMention && !mentioned && !shouldBypassMention) {
|
|
params.logVerbose?.(`imessage: skipping group message (no mention)`);
|
|
recordPendingHistoryEntryIfEnabled({
|
|
historyMap: params.groupHistories,
|
|
historyKey: historyKey ?? "",
|
|
limit: params.historyLimit,
|
|
entry: historyKey
|
|
? {
|
|
sender: senderNormalized,
|
|
body: bodyText,
|
|
timestamp: createdAt,
|
|
messageId: params.message.id ? String(params.message.id) : undefined,
|
|
}
|
|
: null,
|
|
});
|
|
return { kind: "drop", reason: "no mention" };
|
|
}
|
|
|
|
return {
|
|
kind: "dispatch",
|
|
isGroup,
|
|
chatId,
|
|
chatGuid,
|
|
chatIdentifier,
|
|
groupId,
|
|
historyKey,
|
|
sender,
|
|
senderNormalized,
|
|
route,
|
|
bodyText,
|
|
createdAt,
|
|
replyContext,
|
|
effectiveWasMentioned,
|
|
commandAuthorized,
|
|
effectiveDmAllowFrom,
|
|
effectiveGroupAllowFrom,
|
|
};
|
|
}
|
|
|
|
export function buildIMessageInboundContext(params: {
|
|
cfg: OpenClawConfig;
|
|
decision: IMessageInboundDispatchDecision;
|
|
message: IMessagePayload;
|
|
envelopeOptions?: EnvelopeFormatOptions;
|
|
previousTimestamp?: number;
|
|
remoteHost?: string;
|
|
media?: {
|
|
path?: string;
|
|
type?: string;
|
|
paths?: string[];
|
|
types?: Array<string | undefined>;
|
|
};
|
|
historyLimit: number;
|
|
groupHistories: Map<string, HistoryEntry[]>;
|
|
}): {
|
|
ctxPayload: ReturnType<typeof finalizeInboundContext>;
|
|
fromLabel: string;
|
|
chatTarget?: string;
|
|
imessageTo: string;
|
|
inboundHistory?: Array<{ sender: string; body: string; timestamp?: number }>;
|
|
} {
|
|
const envelopeOptions = params.envelopeOptions ?? resolveEnvelopeFormatOptions(params.cfg);
|
|
const { decision } = params;
|
|
const chatId = decision.chatId;
|
|
const chatTarget =
|
|
decision.isGroup && chatId != null ? formatIMessageChatTarget(chatId) : undefined;
|
|
|
|
const replySuffix = decision.replyContext
|
|
? `\n\n[Replying to ${decision.replyContext.sender ?? "unknown sender"}${
|
|
decision.replyContext.id ? ` id:${decision.replyContext.id}` : ""
|
|
}]\n${decision.replyContext.body}\n[/Replying]`
|
|
: "";
|
|
|
|
const fromLabel = formatInboundFromLabel({
|
|
isGroup: decision.isGroup,
|
|
groupLabel: params.message.chat_name ?? undefined,
|
|
groupId: chatId !== undefined ? String(chatId) : "unknown",
|
|
groupFallback: "Group",
|
|
directLabel: decision.senderNormalized,
|
|
directId: decision.sender,
|
|
});
|
|
|
|
const body = formatInboundEnvelope({
|
|
channel: "iMessage",
|
|
from: fromLabel,
|
|
timestamp: decision.createdAt,
|
|
body: `${decision.bodyText}${replySuffix}`,
|
|
chatType: decision.isGroup ? "group" : "direct",
|
|
sender: { name: decision.senderNormalized, id: decision.sender },
|
|
previousTimestamp: params.previousTimestamp,
|
|
envelope: envelopeOptions,
|
|
});
|
|
|
|
let combinedBody = body;
|
|
if (decision.isGroup && decision.historyKey) {
|
|
combinedBody = buildPendingHistoryContextFromMap({
|
|
historyMap: params.groupHistories,
|
|
historyKey: decision.historyKey,
|
|
limit: params.historyLimit,
|
|
currentMessage: combinedBody,
|
|
formatEntry: (entry) =>
|
|
formatInboundEnvelope({
|
|
channel: "iMessage",
|
|
from: fromLabel,
|
|
timestamp: entry.timestamp,
|
|
body: `${entry.body}${entry.messageId ? ` [id:${entry.messageId}]` : ""}`,
|
|
chatType: "group",
|
|
senderLabel: entry.sender,
|
|
envelope: envelopeOptions,
|
|
}),
|
|
});
|
|
}
|
|
|
|
const imessageTo = (decision.isGroup ? chatTarget : undefined) || `imessage:${decision.sender}`;
|
|
const inboundHistory =
|
|
decision.isGroup && decision.historyKey && params.historyLimit > 0
|
|
? (params.groupHistories.get(decision.historyKey) ?? []).map((entry) => ({
|
|
sender: entry.sender,
|
|
body: entry.body,
|
|
timestamp: entry.timestamp,
|
|
}))
|
|
: undefined;
|
|
|
|
const ctxPayload = finalizeInboundContext({
|
|
Body: combinedBody,
|
|
BodyForAgent: decision.bodyText,
|
|
InboundHistory: inboundHistory,
|
|
RawBody: decision.bodyText,
|
|
CommandBody: decision.bodyText,
|
|
From: decision.isGroup
|
|
? `imessage:group:${chatId ?? "unknown"}`
|
|
: `imessage:${decision.sender}`,
|
|
To: imessageTo,
|
|
SessionKey: decision.route.sessionKey,
|
|
AccountId: decision.route.accountId,
|
|
ChatType: decision.isGroup ? "group" : "direct",
|
|
ConversationLabel: fromLabel,
|
|
GroupSubject: decision.isGroup ? (params.message.chat_name ?? undefined) : undefined,
|
|
GroupMembers: decision.isGroup
|
|
? (params.message.participants ?? []).filter(Boolean).join(", ")
|
|
: undefined,
|
|
SenderName: decision.senderNormalized,
|
|
SenderId: decision.sender,
|
|
Provider: "imessage",
|
|
Surface: "imessage",
|
|
MessageSid: params.message.id ? String(params.message.id) : undefined,
|
|
ReplyToId: decision.replyContext?.id,
|
|
ReplyToBody: decision.replyContext?.body,
|
|
ReplyToSender: decision.replyContext?.sender,
|
|
Timestamp: decision.createdAt,
|
|
MediaPath: params.media?.path,
|
|
MediaType: params.media?.type,
|
|
MediaUrl: params.media?.path,
|
|
MediaPaths:
|
|
params.media?.paths && params.media.paths.length > 0 ? params.media.paths : undefined,
|
|
MediaTypes:
|
|
params.media?.types && params.media.types.length > 0 ? params.media.types : undefined,
|
|
MediaUrls:
|
|
params.media?.paths && params.media.paths.length > 0 ? params.media.paths : undefined,
|
|
MediaRemoteHost: params.remoteHost,
|
|
WasMentioned: decision.effectiveWasMentioned,
|
|
CommandAuthorized: decision.commandAuthorized,
|
|
OriginatingChannel: "imessage" as const,
|
|
OriginatingTo: imessageTo,
|
|
});
|
|
|
|
return { ctxPayload, fromLabel, chatTarget, imessageTo, inboundHistory };
|
|
}
|
|
|
|
export function buildIMessageEchoScope(params: {
|
|
accountId: string;
|
|
isGroup: boolean;
|
|
chatId?: number;
|
|
sender: string;
|
|
}): string {
|
|
return `${params.accountId}:${params.isGroup ? formatIMessageChatTarget(params.chatId) : `imessage:${params.sender}`}`;
|
|
}
|
|
|
|
export function describeIMessageEchoDropLog(params: {
|
|
messageText: string;
|
|
messageId?: string;
|
|
}): string {
|
|
const preview = truncateUtf16Safe(params.messageText, 50);
|
|
const messageIdPart = params.messageId ? ` id=${params.messageId}` : "";
|
|
return `imessage: skipping echo message${messageIdPart}: "${preview}"`;
|
|
}
|