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, 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) => { 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["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; 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 & Partial> & Partial & { 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 } : {}), }; }