mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-13 21:36:40 +00:00
refactor(runtime): harden channel-registry cache invalidation and split outbound delivery flow
This commit is contained in:
@@ -240,6 +240,212 @@ type DeliverOutboundPayloadsParams = DeliverOutboundPayloadsCoreParams & {
|
||||
skipQueue?: boolean;
|
||||
};
|
||||
|
||||
type MessageSentEvent = {
|
||||
success: boolean;
|
||||
content: string;
|
||||
error?: string;
|
||||
messageId?: string;
|
||||
};
|
||||
|
||||
function hasMediaPayload(payload: ReplyPayload): boolean {
|
||||
return Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
function hasChannelDataPayload(payload: ReplyPayload): boolean {
|
||||
return Boolean(payload.channelData && Object.keys(payload.channelData).length > 0);
|
||||
}
|
||||
|
||||
function normalizePayloadForChannelDelivery(
|
||||
payload: ReplyPayload,
|
||||
channelId: string,
|
||||
): ReplyPayload | null {
|
||||
const hasMedia = hasMediaPayload(payload);
|
||||
const hasChannelData = hasChannelDataPayload(payload);
|
||||
const rawText = typeof payload.text === "string" ? payload.text : "";
|
||||
const normalizedText =
|
||||
channelId === "whatsapp" ? rawText.replace(/^(?:[ \t]*\r?\n)+/, "") : rawText;
|
||||
if (!normalizedText.trim()) {
|
||||
if (!hasMedia && !hasChannelData) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
text: "",
|
||||
};
|
||||
}
|
||||
if (normalizedText === rawText) {
|
||||
return payload;
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
text: normalizedText,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePayloadsForChannelDelivery(
|
||||
payloads: ReplyPayload[],
|
||||
channel: Exclude<OutboundChannel, "none">,
|
||||
): ReplyPayload[] {
|
||||
const normalizedPayloads: ReplyPayload[] = [];
|
||||
for (const payload of normalizeReplyPayloadsForDelivery(payloads)) {
|
||||
let sanitizedPayload = payload;
|
||||
// Strip HTML tags for plain-text surfaces (WhatsApp, Signal, etc.)
|
||||
// Models occasionally produce <br>, <b>, etc. that render as literal text.
|
||||
// See https://github.com/openclaw/openclaw/issues/31884
|
||||
if (isPlainTextSurface(channel) && payload.text) {
|
||||
// Telegram sendPayload uses textMode:"html". Preserve raw HTML in this path.
|
||||
if (!(channel === "telegram" && payload.channelData)) {
|
||||
sanitizedPayload = { ...payload, text: sanitizeForPlainText(payload.text) };
|
||||
}
|
||||
}
|
||||
const normalized = normalizePayloadForChannelDelivery(sanitizedPayload, channel);
|
||||
if (normalized) {
|
||||
normalizedPayloads.push(normalized);
|
||||
}
|
||||
}
|
||||
return normalizedPayloads;
|
||||
}
|
||||
|
||||
function buildPayloadSummary(payload: ReplyPayload): NormalizedOutboundPayload {
|
||||
return {
|
||||
text: payload.text ?? "",
|
||||
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
|
||||
channelData: payload.channelData,
|
||||
};
|
||||
}
|
||||
|
||||
function createMessageSentEmitter(params: {
|
||||
hookRunner: ReturnType<typeof getGlobalHookRunner>;
|
||||
channel: Exclude<OutboundChannel, "none">;
|
||||
to: string;
|
||||
accountId?: string;
|
||||
sessionKeyForInternalHooks?: string;
|
||||
mirrorIsGroup?: boolean;
|
||||
mirrorGroupId?: string;
|
||||
}): { emitMessageSent: (event: MessageSentEvent) => void; hasMessageSentHooks: boolean } {
|
||||
const hasMessageSentHooks = params.hookRunner?.hasHooks("message_sent") ?? false;
|
||||
const canEmitInternalHook = Boolean(params.sessionKeyForInternalHooks);
|
||||
const emitMessageSent = (event: MessageSentEvent) => {
|
||||
if (!hasMessageSentHooks && !canEmitInternalHook) {
|
||||
return;
|
||||
}
|
||||
const canonical = buildCanonicalSentMessageHookContext({
|
||||
to: params.to,
|
||||
content: event.content,
|
||||
success: event.success,
|
||||
error: event.error,
|
||||
channelId: params.channel,
|
||||
accountId: params.accountId ?? undefined,
|
||||
conversationId: params.to,
|
||||
messageId: event.messageId,
|
||||
isGroup: params.mirrorIsGroup,
|
||||
groupId: params.mirrorGroupId,
|
||||
});
|
||||
if (hasMessageSentHooks) {
|
||||
fireAndForgetHook(
|
||||
params.hookRunner!.runMessageSent(
|
||||
toPluginMessageSentEvent(canonical),
|
||||
toPluginMessageContext(canonical),
|
||||
),
|
||||
"deliverOutboundPayloads: message_sent plugin hook failed",
|
||||
(message) => {
|
||||
log.warn(message);
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!canEmitInternalHook) {
|
||||
return;
|
||||
}
|
||||
fireAndForgetHook(
|
||||
triggerInternalHook(
|
||||
createInternalHookEvent(
|
||||
"message",
|
||||
"sent",
|
||||
params.sessionKeyForInternalHooks!,
|
||||
toInternalMessageSentContext(canonical),
|
||||
),
|
||||
),
|
||||
"deliverOutboundPayloads: message:sent internal hook failed",
|
||||
(message) => {
|
||||
log.warn(message);
|
||||
},
|
||||
);
|
||||
};
|
||||
return { emitMessageSent, hasMessageSentHooks };
|
||||
}
|
||||
|
||||
async function applyMessageSendingHook(params: {
|
||||
hookRunner: ReturnType<typeof getGlobalHookRunner>;
|
||||
enabled: boolean;
|
||||
payload: ReplyPayload;
|
||||
payloadSummary: NormalizedOutboundPayload;
|
||||
to: string;
|
||||
channel: Exclude<OutboundChannel, "none">;
|
||||
accountId?: string;
|
||||
}): Promise<{
|
||||
cancelled: boolean;
|
||||
payload: ReplyPayload;
|
||||
payloadSummary: NormalizedOutboundPayload;
|
||||
}> {
|
||||
if (!params.enabled) {
|
||||
return {
|
||||
cancelled: false,
|
||||
payload: params.payload,
|
||||
payloadSummary: params.payloadSummary,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const sendingResult = await params.hookRunner!.runMessageSending(
|
||||
{
|
||||
to: params.to,
|
||||
content: params.payloadSummary.text,
|
||||
metadata: {
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
mediaUrls: params.payloadSummary.mediaUrls,
|
||||
},
|
||||
},
|
||||
{
|
||||
channelId: params.channel,
|
||||
accountId: params.accountId ?? undefined,
|
||||
},
|
||||
);
|
||||
if (sendingResult?.cancel) {
|
||||
return {
|
||||
cancelled: true,
|
||||
payload: params.payload,
|
||||
payloadSummary: params.payloadSummary,
|
||||
};
|
||||
}
|
||||
if (sendingResult?.content == null) {
|
||||
return {
|
||||
cancelled: false,
|
||||
payload: params.payload,
|
||||
payloadSummary: params.payloadSummary,
|
||||
};
|
||||
}
|
||||
const payload = {
|
||||
...params.payload,
|
||||
text: sendingResult.content,
|
||||
};
|
||||
return {
|
||||
cancelled: false,
|
||||
payload,
|
||||
payloadSummary: {
|
||||
...params.payloadSummary,
|
||||
text: sendingResult.content,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
// Don't block delivery on hook failure.
|
||||
return {
|
||||
cancelled: false,
|
||||
payload: params.payload,
|
||||
payloadSummary: params.payloadSummary,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function deliverOutboundPayloads(
|
||||
params: DeliverOutboundPayloadsParams,
|
||||
): Promise<OutboundDeliveryResult[]> {
|
||||
@@ -439,60 +645,21 @@ async function deliverOutboundPayloadsCore(
|
||||
})),
|
||||
};
|
||||
};
|
||||
const hasMediaPayload = (payload: ReplyPayload): boolean =>
|
||||
Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
const hasChannelDataPayload = (payload: ReplyPayload): boolean =>
|
||||
Boolean(payload.channelData && Object.keys(payload.channelData).length > 0);
|
||||
const normalizePayloadForChannelDelivery = (
|
||||
payload: ReplyPayload,
|
||||
channelId: string,
|
||||
): ReplyPayload | null => {
|
||||
const hasMedia = hasMediaPayload(payload);
|
||||
const hasChannelData = hasChannelDataPayload(payload);
|
||||
const rawText = typeof payload.text === "string" ? payload.text : "";
|
||||
const normalizedText =
|
||||
channelId === "whatsapp" ? rawText.replace(/^(?:[ \t]*\r?\n)+/, "") : rawText;
|
||||
if (!normalizedText.trim()) {
|
||||
if (!hasMedia && !hasChannelData) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
text: "",
|
||||
};
|
||||
}
|
||||
if (normalizedText === rawText) {
|
||||
return payload;
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
text: normalizedText,
|
||||
};
|
||||
};
|
||||
const normalizedPayloads: ReplyPayload[] = [];
|
||||
for (const payload of normalizeReplyPayloadsForDelivery(payloads)) {
|
||||
let sanitizedPayload = payload;
|
||||
// Strip HTML tags for plain-text surfaces (WhatsApp, Signal, etc.)
|
||||
// Models occasionally produce <br>, <b>, etc. that render as literal text.
|
||||
// See https://github.com/openclaw/openclaw/issues/31884
|
||||
if (isPlainTextSurface(channel) && payload.text) {
|
||||
// Telegram sendPayload uses textMode:"html". Preserve raw HTML in this path.
|
||||
if (!(channel === "telegram" && payload.channelData)) {
|
||||
sanitizedPayload = { ...payload, text: sanitizeForPlainText(payload.text) };
|
||||
}
|
||||
}
|
||||
const normalized = normalizePayloadForChannelDelivery(sanitizedPayload, channel);
|
||||
if (normalized) {
|
||||
normalizedPayloads.push(normalized);
|
||||
}
|
||||
}
|
||||
const normalizedPayloads = normalizePayloadsForChannelDelivery(payloads, channel);
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
const sessionKeyForInternalHooks = params.mirror?.sessionKey ?? params.session?.key;
|
||||
const mirrorIsGroup = params.mirror?.isGroup;
|
||||
const mirrorGroupId = params.mirror?.groupId;
|
||||
const hasMessageSentHooks = hookRunner?.hasHooks("message_sent") ?? false;
|
||||
const { emitMessageSent, hasMessageSentHooks } = createMessageSentEmitter({
|
||||
hookRunner,
|
||||
channel,
|
||||
to,
|
||||
accountId,
|
||||
sessionKeyForInternalHooks,
|
||||
mirrorIsGroup,
|
||||
mirrorGroupId,
|
||||
});
|
||||
const hasMessageSendingHooks = hookRunner?.hasHooks("message_sending") ?? false;
|
||||
const canEmitInternalHook = Boolean(sessionKeyForInternalHooks);
|
||||
if (hasMessageSentHooks && params.session?.agentId && !sessionKeyForInternalHooks) {
|
||||
log.warn(
|
||||
"deliverOutboundPayloads: session.agentId present without session key; internal message:sent hook will be skipped",
|
||||
@@ -504,91 +671,25 @@ async function deliverOutboundPayloadsCore(
|
||||
);
|
||||
}
|
||||
for (const payload of normalizedPayloads) {
|
||||
const payloadSummary: NormalizedOutboundPayload = {
|
||||
text: payload.text ?? "",
|
||||
mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []),
|
||||
channelData: payload.channelData,
|
||||
};
|
||||
const emitMessageSent = (params: {
|
||||
success: boolean;
|
||||
content: string;
|
||||
error?: string;
|
||||
messageId?: string;
|
||||
}) => {
|
||||
if (!hasMessageSentHooks && !canEmitInternalHook) {
|
||||
return;
|
||||
}
|
||||
const canonical = buildCanonicalSentMessageHookContext({
|
||||
to,
|
||||
content: params.content,
|
||||
success: params.success,
|
||||
error: params.error,
|
||||
channelId: channel,
|
||||
accountId: accountId ?? undefined,
|
||||
conversationId: to,
|
||||
messageId: params.messageId,
|
||||
isGroup: mirrorIsGroup,
|
||||
groupId: mirrorGroupId,
|
||||
});
|
||||
if (hasMessageSentHooks) {
|
||||
fireAndForgetHook(
|
||||
hookRunner!.runMessageSent(
|
||||
toPluginMessageSentEvent(canonical),
|
||||
toPluginMessageContext(canonical),
|
||||
),
|
||||
"deliverOutboundPayloads: message_sent plugin hook failed",
|
||||
(message) => {
|
||||
log.warn(message);
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!canEmitInternalHook) {
|
||||
return;
|
||||
}
|
||||
fireAndForgetHook(
|
||||
triggerInternalHook(
|
||||
createInternalHookEvent(
|
||||
"message",
|
||||
"sent",
|
||||
sessionKeyForInternalHooks!,
|
||||
toInternalMessageSentContext(canonical),
|
||||
),
|
||||
),
|
||||
"deliverOutboundPayloads: message:sent internal hook failed",
|
||||
(message) => {
|
||||
log.warn(message);
|
||||
},
|
||||
);
|
||||
};
|
||||
let payloadSummary = buildPayloadSummary(payload);
|
||||
try {
|
||||
throwIfAborted(abortSignal);
|
||||
|
||||
// Run message_sending plugin hook (may modify content or cancel)
|
||||
let effectivePayload = payload;
|
||||
if (hasMessageSendingHooks) {
|
||||
try {
|
||||
const sendingResult = await hookRunner!.runMessageSending(
|
||||
{
|
||||
to,
|
||||
content: payloadSummary.text,
|
||||
metadata: { channel, accountId, mediaUrls: payloadSummary.mediaUrls },
|
||||
},
|
||||
{
|
||||
channelId: channel,
|
||||
accountId: accountId ?? undefined,
|
||||
},
|
||||
);
|
||||
if (sendingResult?.cancel) {
|
||||
continue;
|
||||
}
|
||||
if (sendingResult?.content != null) {
|
||||
effectivePayload = { ...payload, text: sendingResult.content };
|
||||
payloadSummary.text = sendingResult.content;
|
||||
}
|
||||
} catch {
|
||||
// Don't block delivery on hook failure
|
||||
}
|
||||
const hookResult = await applyMessageSendingHook({
|
||||
hookRunner,
|
||||
enabled: hasMessageSendingHooks,
|
||||
payload,
|
||||
payloadSummary,
|
||||
to,
|
||||
channel,
|
||||
accountId,
|
||||
});
|
||||
if (hookResult.cancelled) {
|
||||
continue;
|
||||
}
|
||||
const effectivePayload = hookResult.payload;
|
||||
payloadSummary = hookResult.payloadSummary;
|
||||
|
||||
params.onPayload?.(payloadSummary);
|
||||
const sendOverrides = {
|
||||
|
||||
Reference in New Issue
Block a user