openclaw / src /hooks /message-hook-mappers.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
d197cf3 verified
Raw History Blame Contribute Delete
24.2 kB
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
readNonBlankString,
} from "@openclaw/normalization-core/string-coerce";
import type { FinalizedMsgContext } from "../auto-reply/templating.js";
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
freezeDiagnosticTraceContext,
type DiagnosticTraceContext,
} from "../infra/diagnostic-trace-context.js";
import { normalizeMediaFacts } from "../media/media-facts.js";
import type {
PluginHookInboundClaimContext,
PluginHookInboundClaimEvent,
PluginHookInboundMessageMetadata,
PluginHookMessageContext,
PluginHookMessageReceivedEvent,
PluginHookMessageSentEvent,
} from "../plugins/hook-message.types.js";
import { internalSessionConversationId } from "../utils/message-channel-constants.js";
import { stripChannelPrefix } from "../utils/string-readers.js";
import type {
MessagePreprocessedHookContext,
MessageReceivedHookContext,
MessageSentHookContext,
MessageTranscribedHookContext,
} from "./internal-hooks.js";
import { projectMessageHookMediaFacts, type MessageHookMediaFact } from "./message-hook-media.js";
type CanonicalSentMessageHookContext = {
to: string;
content: string;
success: boolean;
error?: string;
channelId: string;
accountId?: string;
conversationId?: string;
sessionKey?: string;
runId?: string;
messageId?: string;
trace?: DiagnosticTraceContext;
callDepth?: number;
isGroup?: boolean;
groupId?: string;
};
function assignRemoteMediaStagingMetadata(
target: Record<string, unknown>,
canonical: CanonicalInboundMessageHookContext,
) {
const metadata = {
mediaRemoteHost: canonical.mediaRemoteHost,
mediaStagingPending: canonical.mediaStagingPending,
originalMediaPath: canonical.originalMediaPath,
originalMediaUrl: canonical.originalMediaUrl,
originalMediaType: canonical.originalMediaType,
originalMediaPaths: canonical.originalMediaPaths,
originalMediaUrls: canonical.originalMediaUrls,
originalMediaTypes: canonical.originalMediaTypes,
};
for (const [key, value] of Object.entries(metadata)) {
if (value !== undefined) {
target[key] = value;
}
}
}
function projectHookMediaState(canonical: CanonicalInboundMessageHookContext) {
const stagingPending = canonical.mediaStagingPending === true;
const media = stagingPending ? undefined : canonical.media;
const originalMedia = canonical.originalMedia ?? (stagingPending ? canonical.media : undefined);
return {
...(media?.length ? { media: media.map((entry) => Object.assign({}, entry)) } : {}),
...(originalMedia?.length
? { originalMedia: originalMedia.map((entry) => Object.assign({}, entry)) }
: {}),
...(stagingPending ? { mediaStagingPending: true as const } : {}),
};
}
function deriveInboundMessageHookContextBase(
ctx: FinalizedMsgContext,
overrides?: {
content?: string;
messageId?: string;
},
) {
const content =
overrides?.content ??
readNonBlankString(ctx.BodyForCommands) ??
readNonBlankString(ctx.RawBody) ??
readNonBlankString(ctx.Body) ??
"";
const channelId = normalizeLowercaseStringOrEmpty(
ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider ?? "",
);
const conversationId =
ctx.OriginatingTo ??
ctx.To ??
ctx.From ??
internalSessionConversationId(channelId, ctx.SessionKey);
const isGroup = Boolean(ctx.GroupSubject || ctx.GroupChannel);
const media = normalizeMediaFacts(ctx.media);
const hookMedia = projectMessageHookMediaFacts(media);
const compact = (values: Array<string | undefined>) => {
const entries = values.filter((value): value is string => Boolean(value));
return entries.length > 0 ? entries : undefined;
};
const mediaPaths = compact(media.map((fact) => fact.path));
const mediaUrls = compact(media.map((fact) => fact.url ?? fact.path));
const mediaTypes = compact(media.map((fact) => fact.contentType ?? fact.kind));
const firstMedia = media[0];
const hasLocation =
typeof ctx.LocationLat === "number" &&
Number.isFinite(ctx.LocationLat) &&
typeof ctx.LocationLon === "number" &&
Number.isFinite(ctx.LocationLon);
const locationSource: NonNullable<PluginHookInboundClaimEvent["location"]>["source"] =
ctx.LocationSource === "pin" || ctx.LocationSource === "place" || ctx.LocationSource === "live"
? ctx.LocationSource
: undefined;
const providerUpdateId = normalizeOptionalString(ctx.ProviderUpdateId);
const providerUpdateKind = normalizeOptionalString(ctx.ProviderUpdateKind);
return {
from: ctx.From ?? "",
to: ctx.To,
content,
body: ctx.Body,
bodyForAgent: ctx.BodyForAgent,
transcript: ctx.Transcript,
timestamp:
typeof ctx.Timestamp === "number" && Number.isFinite(ctx.Timestamp)
? ctx.Timestamp
: undefined,
channelId,
accountId: ctx.AccountId,
conversationId,
sessionKey: ctx.SessionKey,
agentId: ctx.AgentId,
messageId:
normalizeOptionalString(overrides?.messageId) ??
normalizeOptionalString(ctx.MessageSidFull) ??
normalizeOptionalString(ctx.MessageSid) ??
normalizeOptionalString(ctx.MessageSidFirst) ??
normalizeOptionalString(ctx.MessageSidLast),
senderId: ctx.SenderId,
senderName: ctx.SenderName,
senderUsername: ctx.SenderUsername,
senderE164: ctx.SenderE164,
replyToId: ctx.ReplyToId,
replyToIdFull: ctx.ReplyToIdFull,
replyToBody: ctx.ReplyToBody,
replyToSender: ctx.ReplyToSender,
replyToIsQuote: ctx.ReplyToIsQuote,
provider: ctx.Provider,
surface: ctx.Surface,
threadId: ctx.MessageThreadId,
threadParentId: ctx.ThreadParentId,
...(hookMedia.length > 0 ? { media: hookMedia } : {}),
mediaPath: firstMedia?.path ?? mediaPaths?.[0],
mediaUrl: firstMedia?.url ?? firstMedia?.path ?? mediaUrls?.[0],
mediaType: firstMedia?.contentType ?? firstMedia?.kind ?? mediaTypes?.[0],
mediaPaths,
mediaUrls,
mediaTypes,
originatingChannel: ctx.OriginatingChannel,
originatingTo: ctx.OriginatingTo,
guildId: ctx.GroupSpace,
channelName: ctx.GroupChannel,
isGroup,
groupId: isGroup ? conversationId : undefined,
topicName: ctx.TopicName,
...(hasLocation
? {
location: {
latitude: ctx.LocationLat as number,
longitude: ctx.LocationLon as number,
...(typeof ctx.LocationAccuracy === "number" ? { accuracy: ctx.LocationAccuracy } : {}),
...(ctx.LocationName ? { name: ctx.LocationName } : {}),
...(ctx.LocationAddress ? { address: ctx.LocationAddress } : {}),
...(locationSource ? { source: locationSource } : {}),
...(typeof ctx.LocationIsLive === "boolean" ? { isLive: ctx.LocationIsLive } : {}),
...(typeof ctx.LocationLivePeriodSeconds === "number" &&
Number.isFinite(ctx.LocationLivePeriodSeconds)
? { livePeriodSeconds: ctx.LocationLivePeriodSeconds }
: {}),
...(ctx.LocationCaption ? { caption: ctx.LocationCaption } : {}),
},
}
: {}),
...(providerUpdateId && providerUpdateKind
? {
providerUpdate: {
id: providerUpdateId,
kind: providerUpdateKind,
...(normalizeOptionalString(ctx.MessageSidFull ?? ctx.MessageSid)
? { messageId: normalizeOptionalString(ctx.MessageSidFull ?? ctx.MessageSid) }
: {}),
...(typeof ctx.ProviderMessageTimestamp === "number" &&
Number.isFinite(ctx.ProviderMessageTimestamp)
? { messageTimestamp: ctx.ProviderMessageTimestamp }
: {}),
...(typeof ctx.ProviderEditTimestamp === "number" &&
Number.isFinite(ctx.ProviderEditTimestamp)
? { editedTimestamp: ctx.ProviderEditTimestamp }
: {}),
},
}
: {}),
};
}
type DerivedInboundMessageHookContext = ReturnType<typeof deriveInboundMessageHookContextBase>;
type InboundMessageHookMetadataFields = Pick<
PluginHookInboundMessageMetadata,
| "mediaPath"
| "mediaUrl"
| "mediaType"
| "mediaPaths"
| "mediaUrls"
| "mediaTypes"
| "originalMediaPath"
| "originalMediaUrl"
| "originalMediaType"
| "originalMediaPaths"
| "originalMediaUrls"
| "originalMediaTypes"
| "mediaStagingPending"
>;
type CanonicalInboundMessageHookContext = Pick<
DerivedInboundMessageHookContext,
"from" | "content" | "channelId" | "isGroup"
> &
Partial<DerivedInboundMessageHookContext> &
Partial<Pick<PluginHookMessageContext, "runId" | "trace" | "callDepth">> &
Partial<InboundMessageHookMetadataFields> & {
originalMedia?: MessageHookMediaFact[];
mediaRemoteHost?: string;
};
export function deriveInboundMessageHookContext(
ctx: FinalizedMsgContext,
overrides?: {
content?: string;
messageId?: string;
},
): CanonicalInboundMessageHookContext {
return deriveInboundMessageHookContextBase(ctx, overrides);
}
export function buildCanonicalSentMessageHookContext(params: {
to: string;
content: string;
success: boolean;
error?: string;
channelId: string;
accountId?: string;
conversationId?: string;
sessionKey?: string;
runId?: string;
messageId?: string;
trace?: DiagnosticTraceContext;
callDepth?: number;
isGroup?: boolean;
groupId?: string;
}): CanonicalSentMessageHookContext {
return {
to: params.to,
content: params.content,
success: params.success,
error: params.error,
channelId: params.channelId,
accountId: params.accountId,
conversationId: params.conversationId ?? params.to,
sessionKey: params.sessionKey,
runId: params.runId,
messageId: params.messageId,
trace: params.trace,
callDepth: params.callDepth,
isGroup: params.isGroup,
groupId: params.groupId,
};
}
/** Resolves the outbound hook target for a reply produced by an inbound channel turn. */
export function resolveInboundReplyHookTarget(
finalized: FinalizedMsgContext,
hookCtx: CanonicalInboundMessageHookContext,
): string {
if (typeof finalized.OriginatingTo === "string" && finalized.OriginatingTo.trim()) {
return finalized.OriginatingTo;
}
if (hookCtx.isGroup) {
return hookCtx.conversationId ?? hookCtx.to ?? hookCtx.from;
}
return hookCtx.from || hookCtx.conversationId || hookCtx.to || "";
}
type DiagnosticTraceHookFields = Pick<
PluginHookMessageContext,
"trace" | "traceId" | "spanId" | "parentSpanId"
>;
function assignTraceFields(
target: DiagnosticTraceHookFields,
trace?: DiagnosticTraceContext,
): void {
if (!trace) {
return;
}
const safeTrace = freezeDiagnosticTraceContext(trace);
target.trace = safeTrace;
target.traceId = safeTrace.traceId;
if (safeTrace.spanId) {
target.spanId = safeTrace.spanId;
}
if (safeTrace.parentSpanId) {
target.parentSpanId = safeTrace.parentSpanId;
}
}
function projectHookReplyFields(
canonical: CanonicalInboundMessageHookContext | CanonicalSentMessageHookContext,
) {
// Sent contexts may omit reply fields; empty strings and false remain meaningful.
return {
...("replyToId" in canonical && canonical.replyToId !== undefined
? { replyToId: canonical.replyToId }
: {}),
...("replyToIdFull" in canonical && canonical.replyToIdFull !== undefined
? { replyToIdFull: canonical.replyToIdFull }
: {}),
...("replyToBody" in canonical && canonical.replyToBody !== undefined
? { replyToBody: canonical.replyToBody }
: {}),
...("replyToSender" in canonical && canonical.replyToSender !== undefined
? { replyToSender: canonical.replyToSender }
: {}),
...("replyToIsQuote" in canonical && canonical.replyToIsQuote !== undefined
? { replyToIsQuote: canonical.replyToIsQuote }
: {}),
};
}
export function toPluginMessageContext(
canonical: CanonicalInboundMessageHookContext | CanonicalSentMessageHookContext,
): PluginHookMessageContext {
const context: PluginHookMessageContext = {
channelId: canonical.channelId,
accountId: canonical.accountId,
conversationId: canonical.conversationId,
};
if (canonical.sessionKey) {
context.sessionKey = canonical.sessionKey;
}
if (canonical.runId) {
context.runId = canonical.runId;
}
if (canonical.messageId) {
context.messageId = canonical.messageId;
}
if ("senderId" in canonical && canonical.senderId) {
context.senderId = canonical.senderId;
}
Object.assign(context, projectHookReplyFields(canonical));
assignTraceFields(context, canonical.trace);
if (canonical.callDepth != null) {
context.callDepth = canonical.callDepth;
}
return context;
}
function resolveInboundConversation(canonical: CanonicalInboundMessageHookContext): {
conversationId?: string;
parentConversationId?: string;
} {
const channelId = normalizeChannelId(canonical.channelId);
const pluginResolved = channelId
? getChannelPlugin(channelId)?.messaging?.resolveInboundConversation?.({
from: canonical.from,
to: canonical.to ?? canonical.originatingTo,
conversationId: canonical.conversationId,
threadId: canonical.threadId,
threadParentId: canonical.threadParentId,
isGroup: canonical.isGroup,
})
: undefined;
if (pluginResolved === null) {
// A plugin-owned null is an explicit rejection, so generic parsing must not reclaim it.
return {};
}
if (pluginResolved) {
return {
conversationId: normalizeOptionalString(pluginResolved.conversationId),
parentConversationId: normalizeOptionalString(pluginResolved.parentConversationId),
};
}
const baseConversationId = stripChannelPrefix(
canonical.to ?? canonical.originatingTo ?? canonical.conversationId,
canonical.channelId,
);
return { conversationId: baseConversationId };
}
function buildPluginInboundClaimContext(
canonical: CanonicalInboundMessageHookContext,
conversation: {
conversationId?: string;
parentConversationId?: string;
},
): PluginHookInboundClaimContext {
const context: PluginHookInboundClaimContext = {
channelId: canonical.channelId,
accountId: canonical.accountId,
conversationId: conversation.conversationId,
sessionKey: canonical.sessionKey,
agentId: canonical.agentId,
parentConversationId: conversation.parentConversationId,
senderId: canonical.senderId,
messageId: canonical.messageId,
runId: canonical.runId,
callDepth: canonical.callDepth,
};
Object.assign(context, projectHookReplyFields(canonical));
assignTraceFields(context, canonical.trace);
return context;
}
function buildPluginInboundClaimEvent(
canonical: CanonicalInboundMessageHookContext,
context: PluginHookInboundClaimContext,
extras?: {
commandAuthorized?: boolean;
wasMentioned?: boolean;
},
): PluginHookInboundClaimEvent {
const event: PluginHookInboundClaimEvent = {
content: canonical.content,
body: canonical.body,
bodyForAgent: canonical.bodyForAgent,
transcript: canonical.transcript,
timestamp: canonical.timestamp,
channel: canonical.channelId,
accountId: canonical.accountId,
conversationId: context.conversationId,
parentConversationId: context.parentConversationId,
senderId: canonical.senderId,
senderName: canonical.senderName,
senderUsername: canonical.senderUsername,
...projectHookReplyFields(canonical),
threadId: canonical.threadId,
messageId: canonical.messageId,
sessionKey: canonical.sessionKey,
runId: canonical.runId,
isGroup: canonical.isGroup,
commandAuthorized: extras?.commandAuthorized,
wasMentioned: extras?.wasMentioned,
...(canonical.location ? { location: { ...canonical.location } } : {}),
...(canonical.providerUpdate ? { providerUpdate: { ...canonical.providerUpdate } } : {}),
...projectHookMediaState(canonical),
metadata: {
from: canonical.from,
to: canonical.to,
provider: canonical.provider,
surface: canonical.surface,
originatingChannel: canonical.originatingChannel,
originatingTo: canonical.originatingTo,
senderE164: canonical.senderE164,
replyToId: canonical.replyToId,
replyToIdFull: canonical.replyToIdFull,
replyToBody: canonical.replyToBody,
replyToSender: canonical.replyToSender,
replyToIsQuote: canonical.replyToIsQuote,
mediaPath: canonical.mediaStagingPending ? undefined : canonical.mediaPath,
mediaUrl: canonical.mediaStagingPending ? undefined : canonical.mediaUrl,
mediaType: canonical.mediaStagingPending ? undefined : canonical.mediaType,
mediaPaths: canonical.mediaStagingPending ? undefined : canonical.mediaPaths,
mediaUrls: canonical.mediaStagingPending ? undefined : canonical.mediaUrls,
mediaTypes: canonical.mediaStagingPending ? undefined : canonical.mediaTypes,
guildId: canonical.guildId,
channelName: canonical.channelName,
groupId: canonical.groupId,
topicName: canonical.topicName,
},
};
if (event.metadata) {
assignRemoteMediaStagingMetadata(event.metadata, canonical);
}
assignTraceFields(event, canonical.trace);
return event;
}
export function toPluginInboundClaimPair(
canonical: CanonicalInboundMessageHookContext,
extras?: {
commandAuthorized?: boolean;
wasMentioned?: boolean;
},
): {
context: PluginHookInboundClaimContext;
event: PluginHookInboundClaimEvent;
} {
const conversation = resolveInboundConversation(canonical);
const context = buildPluginInboundClaimContext(canonical, conversation);
return {
context,
event: buildPluginInboundClaimEvent(canonical, context, extras),
};
}
export function toPluginMessageReceivedEvent(
canonical: CanonicalInboundMessageHookContext,
): PluginHookMessageReceivedEvent {
const event: PluginHookMessageReceivedEvent = {
from: canonical.from,
content: canonical.content,
timestamp: canonical.timestamp,
threadId: canonical.threadId,
messageId: canonical.messageId,
senderId: canonical.senderId,
...projectHookReplyFields(canonical),
sessionKey: canonical.sessionKey,
runId: canonical.runId,
...(canonical.location ? { location: { ...canonical.location } } : {}),
...(canonical.providerUpdate ? { providerUpdate: { ...canonical.providerUpdate } } : {}),
...projectHookMediaState(canonical),
metadata: {
to: canonical.to,
provider: canonical.provider,
surface: canonical.surface,
threadId: canonical.threadId,
originatingChannel: canonical.originatingChannel,
originatingTo: canonical.originatingTo,
messageId: canonical.messageId,
senderId: canonical.senderId,
senderName: canonical.senderName,
senderUsername: canonical.senderUsername,
senderE164: canonical.senderE164,
replyToId: canonical.replyToId,
replyToIdFull: canonical.replyToIdFull,
replyToBody: canonical.replyToBody,
replyToSender: canonical.replyToSender,
replyToIsQuote: canonical.replyToIsQuote,
mediaPath: canonical.mediaStagingPending ? undefined : canonical.mediaPath,
mediaUrl: canonical.mediaStagingPending ? undefined : canonical.mediaUrl,
mediaType: canonical.mediaStagingPending ? undefined : canonical.mediaType,
mediaPaths: canonical.mediaStagingPending ? undefined : canonical.mediaPaths,
mediaUrls: canonical.mediaStagingPending ? undefined : canonical.mediaUrls,
mediaTypes: canonical.mediaStagingPending ? undefined : canonical.mediaTypes,
guildId: canonical.guildId,
channelName: canonical.channelName,
topicName: canonical.topicName,
},
};
if (event.metadata) {
assignRemoteMediaStagingMetadata(event.metadata, canonical);
}
assignTraceFields(event, canonical.trace);
return event;
}
export function toPluginMessageSentEvent(
canonical: CanonicalSentMessageHookContext,
): PluginHookMessageSentEvent {
const event: PluginHookMessageSentEvent = {
to: canonical.to,
content: canonical.content,
success: canonical.success,
...(canonical.messageId ? { messageId: canonical.messageId } : {}),
...(canonical.sessionKey ? { sessionKey: canonical.sessionKey } : {}),
...(canonical.runId ? { runId: canonical.runId } : {}),
...(canonical.error ? { error: canonical.error } : {}),
};
assignTraceFields(event, canonical.trace);
return event;
}
export function toInternalMessageReceivedContext(
canonical: CanonicalInboundMessageHookContext,
): MessageReceivedHookContext {
const context: MessageReceivedHookContext = {
from: canonical.from,
content: canonical.content,
timestamp: canonical.timestamp,
channelId: canonical.channelId,
accountId: canonical.accountId,
conversationId: canonical.conversationId,
messageId: canonical.messageId,
...projectHookMediaState(canonical),
metadata: {
to: canonical.to,
provider: canonical.provider,
surface: canonical.surface,
threadId: canonical.threadId,
senderId: canonical.senderId,
senderName: canonical.senderName,
senderUsername: canonical.senderUsername,
senderE164: canonical.senderE164,
mediaPath: canonical.mediaStagingPending ? undefined : canonical.mediaPath,
mediaUrl: canonical.mediaStagingPending ? undefined : canonical.mediaUrl,
mediaType: canonical.mediaStagingPending ? undefined : canonical.mediaType,
mediaPaths: canonical.mediaStagingPending ? undefined : canonical.mediaPaths,
mediaUrls: canonical.mediaStagingPending ? undefined : canonical.mediaUrls,
mediaTypes: canonical.mediaStagingPending ? undefined : canonical.mediaTypes,
guildId: canonical.guildId,
channelName: canonical.channelName,
topicName: canonical.topicName,
},
};
if (context.metadata) {
assignRemoteMediaStagingMetadata(context.metadata, canonical);
}
return context;
}
export function toInternalMessageTranscribedContext(
canonical: CanonicalInboundMessageHookContext,
cfg: OpenClawConfig,
): MessageTranscribedHookContext & { cfg: OpenClawConfig } {
const shared = toInternalInboundMessageHookContextBase(canonical);
return {
...shared,
transcript: canonical.transcript ?? "",
cfg,
};
}
export function toInternalMessagePreprocessedContext(
canonical: CanonicalInboundMessageHookContext,
cfg: OpenClawConfig,
): MessagePreprocessedHookContext & { cfg: OpenClawConfig } {
const shared = toInternalInboundMessageHookContextBase(canonical);
return {
...shared,
transcript: canonical.transcript,
isGroup: canonical.isGroup,
groupId: canonical.groupId,
cfg,
};
}
function toInternalInboundMessageHookContextBase(canonical: CanonicalInboundMessageHookContext) {
return {
from: canonical.from,
to: canonical.to,
body: canonical.body,
bodyForAgent: canonical.bodyForAgent,
timestamp: canonical.timestamp,
channelId: canonical.channelId,
conversationId: canonical.conversationId,
messageId: canonical.messageId,
senderId: canonical.senderId,
senderName: canonical.senderName,
senderUsername: canonical.senderUsername,
provider: canonical.provider,
surface: canonical.surface,
...projectHookMediaState(canonical),
mediaPath: canonical.mediaStagingPending ? undefined : canonical.mediaPath,
mediaType: canonical.mediaStagingPending ? undefined : canonical.mediaType,
};
}
export function toInternalMessageSentContext(
canonical: CanonicalSentMessageHookContext,
): MessageSentHookContext {
return {
to: canonical.to,
content: canonical.content,
success: canonical.success,
...(canonical.error ? { error: canonical.error } : {}),
channelId: canonical.channelId,
accountId: canonical.accountId,
conversationId: canonical.conversationId,
messageId: canonical.messageId,
...(canonical.isGroup != null ? { isGroup: canonical.isGroup } : {}),
...(canonical.groupId ? { groupId: canonical.groupId } : {}),
};
}