// Message hook mapper tests cover mapping runtime messages into hook payloads. import { beforeEach, describe, expect, it } from "vitest"; import { finalizeInboundContextForSdk } from "../auto-reply/reply/inbound-context.js"; import type { FinalizedMsgContext } from "../auto-reply/templating.js"; import type { ChannelMessagingAdapter } from "../channels/plugins/types.core.js"; import type { OpenClawConfig } from "../config/config.js"; import type { DiagnosticTraceContext } from "../infra/diagnostic-trace-context.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { buildCanonicalSentMessageHookContext, deriveInboundMessageHookContext, toPluginInboundClaimPair, toInternalMessagePreprocessedContext, toInternalMessageReceivedContext, toInternalMessageSentContext, toInternalMessageTranscribedContext, toPluginMessageContext, toPluginMessageReceivedEvent, toPluginMessageSentEvent, } from "./message-hook-mappers.js"; type ResolveInboundConversationParams = Parameters< NonNullable >[0]; interface MinimalInboundHookContext { from: string; content: string; channelId: string; isGroup: boolean; } function makeInboundCtx(overrides: Partial = {}): FinalizedMsgContext { return { From: "demo-chat:user:123", To: "demo-chat:chat:456", Body: "body", BodyForAgent: "body-for-agent", BodyForCommands: "commands-body", RawBody: "raw-body", Transcript: "hello transcript", Timestamp: 1710000000, Provider: "demo-chat", Surface: "demo-chat", OriginatingChannel: "demo-chat", OriginatingTo: "demo-chat:chat:456", SessionKey: "session-1", AccountId: "acc-1", MessageSid: "msg-1", SenderId: "sender-1", SenderName: "User One", SenderUsername: "userone", SenderE164: "+15551234567", MessageThreadId: 42, media: [ { path: "/tmp/audio.ogg", url: "https://cdn.example.com/audio.ogg", contentType: "audio/ogg", }, ], GroupSubject: "ops", GroupChannel: "ops-room", GroupSpace: "guild-1", ...overrides, } as FinalizedMsgContext; } describe("message hook mappers", () => { beforeEach(() => { setActivePluginRegistry( createTestRegistry([ { pluginId: "claim-chat", source: "test", plugin: { ...createChannelTestPluginBase({ id: "claim-chat", label: "Claim chat" }), messaging: { resolveInboundConversation: ({ from, to, isGroup, }: { from?: string; to?: string; isGroup?: boolean; }) => { const normalizedTo = to?.replace(/^channel:/i, "").trim(); const normalizedFrom = from?.replace(/^claim-chat:/i, "").trim(); if (isGroup && normalizedTo) { return { conversationId: `channel:${normalizedTo}` }; } if (normalizedFrom) { return { conversationId: `user:${normalizedFrom}` }; } return null; }, }, }, }, { pluginId: "thread-claim-chat", source: "test", plugin: { ...createChannelTestPluginBase({ id: "thread-claim-chat", label: "Thread claim chat" }), messaging: { resolveInboundConversation: ({ to, threadId, threadParentId, }: ResolveInboundConversationParams) => { if (threadId) { return { conversationId: String(threadId), ...(threadParentId ? { parentConversationId: `channel:${threadParentId}` } : {}), }; } return to ? { conversationId: to } : null; }, }, }, }, ]), ); }); it("preserves producer enrichment and minimal mapper input compatibility", () => { const derived = deriveInboundMessageHookContext(makeInboundCtx()); derived.runId = "run-1"; derived.trace = { traceId: "11111111111111111111111111111111", spanId: "2222222222222222", }; derived.callDepth = 2; derived.mediaStagingPending = true; derived.originalMedia = []; const minimal: MinimalInboundHookContext = { from: "sender", content: "hello", channelId: "demo-chat", isGroup: false, }; expect(toPluginMessageContext(minimal)).toMatchObject({ channelId: "demo-chat" }); }); it("derives canonical inbound context with body precedence and group metadata", () => { const canonical = deriveInboundMessageHookContext(makeInboundCtx()); expect(canonical.content).toBe("commands-body"); expect(canonical.channelId).toBe("demo-chat"); expect(canonical.conversationId).toBe("demo-chat:chat:456"); expect(canonical.messageId).toBe("msg-1"); expect(canonical.isGroup).toBe(true); expect(canonical.groupId).toBe("demo-chat:chat:456"); expect(canonical.guildId).toBe("guild-1"); }); it("normalizes canonical inbound message id precedence", () => { expect( deriveInboundMessageHookContext( makeInboundCtx({ MessageSidFull: "full-message-id", MessageSid: "short-message-id" }), ).messageId, ).toBe("full-message-id"); expect( deriveInboundMessageHookContext( makeInboundCtx({ MessageSidFull: " ", MessageSid: "short-message-id" }), ).messageId, ).toBe("short-message-id"); }); it("uses the session key as the Control UI conversation id", () => { const canonical = deriveInboundMessageHookContext( makeInboundCtx({ From: undefined, To: undefined, OriginatingTo: undefined, Provider: "webchat", Surface: "webchat", OriginatingChannel: "webchat", SessionKey: "agent:main:adopted", }), ); expect(canonical.channelId).toBe("webchat"); expect(canonical.conversationId).toBe("agent:main:adopted"); }); it("maps inbound reply metadata into canonical and plugin payloads", () => { const canonical = deriveInboundMessageHookContext( makeInboundCtx({ ReplyToId: "discord-message-42", ReplyToIdFull: "discord:channel-1:discord-message-42", ReplyToBody: "quoted Discord reply body", ReplyToSender: "Ada", ReplyToIsQuote: true, }), ); expect(canonical.replyToId).toBe("discord-message-42"); expect(canonical.replyToIdFull).toBe("discord:channel-1:discord-message-42"); expect(canonical.replyToBody).toBe("quoted Discord reply body"); expect(canonical.replyToSender).toBe("Ada"); expect(canonical.replyToIsQuote).toBe(true); expect(toPluginMessageContext(canonical)).toMatchObject({ replyToId: "discord-message-42", replyToIdFull: "discord:channel-1:discord-message-42", replyToBody: "quoted Discord reply body", replyToSender: "Ada", replyToIsQuote: true, }); const { context: claimContext, event: claimEvent } = toPluginInboundClaimPair(canonical); expect(claimContext).toMatchObject({ replyToId: "discord-message-42", replyToIdFull: "discord:channel-1:discord-message-42", replyToBody: "quoted Discord reply body", replyToSender: "Ada", replyToIsQuote: true, }); expect(claimEvent).toMatchObject({ replyToId: "discord-message-42", replyToIdFull: "discord:channel-1:discord-message-42", replyToBody: "quoted Discord reply body", replyToSender: "Ada", replyToIsQuote: true, }); expect(claimEvent.metadata).toMatchObject({ replyToId: "discord-message-42", replyToIdFull: "discord:channel-1:discord-message-42", replyToBody: "quoted Discord reply body", replyToSender: "Ada", replyToIsQuote: true, }); const receivedEvent = toPluginMessageReceivedEvent(canonical); expect(receivedEvent).toMatchObject({ replyToId: "discord-message-42", replyToIdFull: "discord:channel-1:discord-message-42", replyToBody: "quoted Discord reply body", replyToSender: "Ada", replyToIsQuote: true, }); expect(receivedEvent.metadata).toMatchObject({ replyToId: "discord-message-42", replyToIdFull: "discord:channel-1:discord-message-42", replyToBody: "quoted Discord reply body", replyToSender: "Ada", replyToIsQuote: true, }); }); it.each([ { name: "absent", fields: {}, expected: {} }, { name: "undefined", fields: { replyToId: undefined, replyToIdFull: undefined, replyToBody: undefined, replyToSender: undefined, replyToIsQuote: undefined, }, expected: {}, }, { name: "empty and false", fields: { replyToId: "", replyToIdFull: "", replyToBody: "", replyToSender: "", replyToIsQuote: false, }, expected: { replyToId: "", replyToIdFull: "", replyToBody: "", replyToSender: "", replyToIsQuote: false, }, }, ])("preserves $name optional reply fields across hook projections", ({ fields, expected }) => { const canonical = { from: "sender", content: "hello", channelId: "demo-chat", isGroup: false, ...fields, }; const { context, event } = toPluginInboundClaimPair(canonical); const received = toPluginMessageReceivedEvent(canonical); for (const output of [toPluginMessageContext(canonical), context, event, received]) { const entries = Object.entries(output).filter(([key]) => key.startsWith("replyTo")); expect(entries).toEqual(Object.entries(expected)); } for (const metadata of [event.metadata, received.metadata]) { expect(metadata).toMatchObject({ replyToId: undefined, replyToIdFull: undefined, replyToBody: undefined, replyToSender: undefined, replyToIsQuote: undefined, ...expected, }); expect(Object.keys(metadata ?? {}).filter((key) => key.startsWith("replyTo"))).toEqual([ "replyToId", "replyToIdFull", "replyToBody", "replyToSender", "replyToIsQuote", ]); } }); it("checks reply-field presence before reading a sent context", () => { const reads: PropertyKey[] = []; const canonical = new Proxy( buildCanonicalSentMessageHookContext({ to: "target", content: "reply", success: true, channelId: "demo-chat", }), { get(target, key, receiver) { if (typeof key === "string" && key.startsWith("replyTo")) { reads.push(key); } return Reflect.get(target, key, receiver); }, }, ); expect(toPluginMessageContext(canonical)).toEqual({ channelId: "demo-chat", accountId: undefined, conversationId: "target", }); expect(reads).toEqual([]); }); it("falls back to raw body when command body is blank", () => { const canonical = deriveInboundMessageHookContext( makeInboundCtx({ BodyForCommands: " \n\t", RawBody: "Readiness probe failed", }), ); expect(canonical.content).toBe("Readiness probe failed"); expect(toPluginMessageReceivedEvent(canonical).content).toBe("Readiness probe failed"); expect(toInternalMessageReceivedContext(canonical).content).toBe("Readiness probe failed"); }); it("keeps nonblank command body ahead of raw body for hook content", () => { const canonical = deriveInboundMessageHookContext( makeInboundCtx({ BodyForCommands: "/status", RawBody: "Readiness probe failed", }), ); expect(canonical.content).toBe("/status"); }); it("supports explicit content/messageId overrides", () => { const canonical = deriveInboundMessageHookContext(makeInboundCtx(), { content: "override-content", messageId: "override-msg", }); expect(canonical.content).toBe("override-content"); expect(canonical.messageId).toBe("override-msg"); }); it("uses OriginatingTo as the canonical conversation when To is flat", () => { const canonical = deriveInboundMessageHookContext( makeInboundCtx({ To: "demo-chat:chat:456", OriginatingTo: "demo-chat:chat:456:topic:42", MessageThreadId: 42, }), ); expect(canonical.to).toBe("demo-chat:chat:456"); expect(canonical.conversationId).toBe("demo-chat:chat:456:topic:42"); expect(canonical.originatingTo).toBe("demo-chat:chat:456:topic:42"); const pluginContext = toPluginMessageContext(canonical); expect(pluginContext.conversationId).toBe("demo-chat:chat:456:topic:42"); const receivedEvent = toPluginMessageReceivedEvent(canonical); expect(receivedEvent.metadata?.to).toBe("demo-chat:chat:456"); expect(receivedEvent.metadata?.originatingTo).toBe("demo-chat:chat:456:topic:42"); expect(receivedEvent.metadata?.threadId).toBe(42); const internalReceived = toInternalMessageReceivedContext(canonical); expect(internalReceived.conversationId).toBe("demo-chat:chat:456:topic:42"); expect(internalReceived.metadata?.to).toBe("demo-chat:chat:456"); expect(internalReceived.metadata?.threadId).toBe(42); }); it("preserves multi-attachment arrays for inbound claim metadata", () => { const canonical = deriveInboundMessageHookContext( makeInboundCtx({ media: [ { path: "/tmp/tree.jpg", url: "https://example.test/tree.jpg", contentType: "image/jpeg", }, { path: "/tmp/ramp.jpg", url: "https://example.test/ramp.jpg", contentType: "image/jpeg", }, ], }), ); expect(canonical.mediaPath).toBe("/tmp/tree.jpg"); expect(canonical.mediaUrl).toBe("https://example.test/tree.jpg"); expect(canonical.mediaType).toBe("image/jpeg"); expect(canonical.mediaPaths).toEqual(["/tmp/tree.jpg", "/tmp/ramp.jpg"]); expect(canonical.mediaUrls).toEqual([ "https://example.test/tree.jpg", "https://example.test/ramp.jpg", ]); expect(canonical.mediaTypes).toEqual(["image/jpeg", "image/jpeg"]); const { event: claimEvent } = toPluginInboundClaimPair(canonical); expect(claimEvent.metadata?.mediaPath).toBe("/tmp/tree.jpg"); expect(claimEvent.metadata?.mediaUrl).toBe("https://example.test/tree.jpg"); expect(claimEvent.metadata?.mediaType).toBe("image/jpeg"); expect(claimEvent.metadata?.mediaPaths).toEqual(["/tmp/tree.jpg", "/tmp/ramp.jpg"]); expect(claimEvent.metadata?.mediaUrls).toEqual([ "https://example.test/tree.jpg", "https://example.test/ramp.jpg", ]); expect(claimEvent.metadata?.mediaTypes).toEqual(["image/jpeg", "image/jpeg"]); expect(claimEvent.media).toEqual([ { path: "/tmp/tree.jpg", url: "https://example.test/tree.jpg", contentType: "image/jpeg", kind: "image", }, { path: "/tmp/ramp.jpg", url: "https://example.test/ramp.jpg", contentType: "image/jpeg", kind: "image", }, ]); }); it.each([ { name: "legacy-only", input: { media: undefined, MediaPath: "/tmp/legacy.png", MediaType: "image/png" }, expectedPath: "/tmp/legacy.png", }, { name: "facts-only", input: { media: [{ path: "/tmp/fact.png", contentType: "image/png" }] }, expectedPath: "/tmp/fact.png", }, { name: "both-equal", input: { MediaPath: "/tmp/equal.png", media: [{ path: "/tmp/equal.png", contentType: "image/png" }], }, expectedPath: "/tmp/equal.png", }, { name: "both-conflict", input: { MediaPath: "/tmp/legacy-conflict.png", media: [{ path: "/tmp/canonical.png", contentType: "image/png" }], }, expectedPath: "/tmp/canonical.png", }, { name: "sparse", input: { media: [{}, { path: "/tmp/sparse.png", contentType: "image/png" }] }, expectedPath: "/tmp/sparse.png", expectedIndex: 1, }, { name: "type-only", input: { media: [{ contentType: "image/png" }] }, expectedPath: undefined, }, { name: "media-only", input: { Body: "", media: [{ path: "/tmp/media-only.png", kind: "image" as const }] }, expectedPath: "/tmp/media-only.png", }, ])("dual-emits $name hook media from canonical facts", (testCase) => { const ctx = finalizeInboundContextForSdk(makeInboundCtx(testCase.input)); const { event } = toPluginInboundClaimPair(deriveInboundMessageHookContext(ctx)); const expectedIndex = "expectedIndex" in testCase ? (testCase.expectedIndex ?? 0) : 0; expect(event.media?.[expectedIndex]?.path).toBe(testCase.expectedPath); expect(event.metadata?.mediaPath).toBe(testCase.expectedPath); }); it("withholds pending remote media from every inbound hook event", () => { const canonical = { ...deriveInboundMessageHookContext( makeInboundCtx({ media: [ { path: "/remote-host/photo.jpg", url: "media://remote/photo.jpg", contentType: "image/jpeg", }, ], }), ), mediaStagingPending: true, }; const expectedOriginalMedia = [ { path: "/remote-host/photo.jpg", url: "media://remote/photo.jpg", contentType: "image/jpeg", kind: "image", }, ]; for (const event of [ toPluginInboundClaimPair(canonical).event, toPluginMessageReceivedEvent(canonical), toInternalMessageReceivedContext(canonical), toInternalMessageTranscribedContext(canonical, {}), toInternalMessagePreprocessedContext(canonical, {}), ]) { expect(event.media).toBeUndefined(); expect(event.originalMedia).toEqual(expectedOriginalMedia); expect(event.mediaStagingPending).toBe(true); } expect(toPluginInboundClaimPair(canonical).event.metadata?.mediaPath).toBeUndefined(); expect(toPluginMessageReceivedEvent(canonical).metadata?.mediaPath).toBeUndefined(); expect(toInternalMessageReceivedContext(canonical).metadata?.mediaPath).toBeUndefined(); }); it("projects retained facts into hook media metadata", () => { const canonical = deriveInboundMessageHookContext( makeInboundCtx({ media: [ { path: "/tmp/tree.jpg", contentType: "image/jpeg" }, { url: "https://example.test/ramp.jpg", kind: "image" }, ], }), ); expect(canonical).toMatchObject({ media: [ { path: "/tmp/tree.jpg", contentType: "image/jpeg", kind: "image" }, { url: "https://example.test/ramp.jpg", kind: "image" }, ], mediaPath: "/tmp/tree.jpg", mediaUrl: "/tmp/tree.jpg", mediaType: "image/jpeg", mediaPaths: ["/tmp/tree.jpg"], mediaUrls: ["/tmp/tree.jpg", "https://example.test/ramp.jpg"], mediaTypes: ["image/jpeg", "image"], }); const staged = deriveInboundMessageHookContext( makeInboundCtx({ media: [ { path: "/tmp/staged/tree.jpg", url: "/tmp/staged/tree.jpg", contentType: "image/jpeg", workspaceDir: "/tmp/staged", }, ], }), ); expect(staged).toMatchObject({ mediaPath: "/tmp/staged/tree.jpg", mediaUrl: "/tmp/staged/tree.jpg", mediaPaths: ["/tmp/staged/tree.jpg"], mediaUrls: ["/tmp/staged/tree.jpg"], }); }); it("uses complete staged compatibility projections in hook media metadata", () => { const canonical = deriveInboundMessageHookContext( finalizeInboundContextForSdk( makeInboundCtx({ media: [ { path: "/remote/tree.jpg", contentType: "image/jpeg", messageId: "tree" }, { path: "/remote/ramp.jpg", contentType: "image/jpeg", messageId: "ramp" }, ], MediaPaths: ["/tmp/staged/tree.jpg", "/tmp/staged/ramp.jpg"], MediaUrls: ["file:///tmp/staged/tree.jpg", "file:///tmp/staged/ramp.jpg"], MediaTypes: ["image/jpeg", "image/jpeg"], MediaStaged: true, }), ), ); expect(canonical).toMatchObject({ mediaPath: "/tmp/staged/tree.jpg", mediaUrl: "file:///tmp/staged/tree.jpg", mediaType: "image/jpeg", mediaPaths: ["/tmp/staged/tree.jpg", "/tmp/staged/ramp.jpg"], mediaUrls: ["file:///tmp/staged/tree.jpg", "file:///tmp/staged/ramp.jpg"], mediaTypes: ["image/jpeg", "image/jpeg"], }); }); it("maps canonical inbound context to plugin/internal received payloads", () => { const trace: DiagnosticTraceContext = { traceId: "11111111111111111111111111111111", spanId: "2222222222222222", parentSpanId: "3333333333333333", }; const canonical = { ...deriveInboundMessageHookContext( makeInboundCtx({ TopicName: "Deployments", media: [ { path: "/tmp/audio.ogg", url: "https://cdn.example.com/audio.ogg", contentType: "audio/ogg", }, { path: "/tmp/photo.jpg", url: "https://cdn.example.com/photo.jpg", contentType: "image/jpeg", }, ], }), ), runId: "run-1", trace, callDepth: 2, }; const pluginContext = toPluginMessageContext(canonical); const receivedEvent = toPluginMessageReceivedEvent(canonical); const { metadata: receivedMetadata, ...receivedEventBase } = receivedEvent; expect(pluginContext).toEqual({ channelId: "demo-chat", accountId: "acc-1", conversationId: "demo-chat:chat:456", sessionKey: "session-1", runId: "run-1", messageId: "msg-1", senderId: "sender-1", trace, traceId: "11111111111111111111111111111111", spanId: "2222222222222222", parentSpanId: "3333333333333333", callDepth: 2, }); expect(pluginContext.trace).not.toBe(trace); expect(pluginContext.trace).toEqual(trace); expect(Object.isFrozen(pluginContext.trace)).toBe(true); expect(receivedEvent.trace).not.toBe(trace); expect(receivedEvent.trace).toEqual(trace); expect(Object.isFrozen(receivedEvent.trace)).toBe(true); expect(receivedEventBase).toEqual({ from: "demo-chat:user:123", content: "commands-body", timestamp: 1710000000, threadId: 42, messageId: "msg-1", senderId: "sender-1", sessionKey: "session-1", runId: "run-1", media: [ { path: "/tmp/audio.ogg", url: "https://cdn.example.com/audio.ogg", contentType: "audio/ogg", kind: "audio", }, { path: "/tmp/photo.jpg", url: "https://cdn.example.com/photo.jpg", contentType: "image/jpeg", kind: "image", }, ], trace: receivedEvent.trace, traceId: "11111111111111111111111111111111", spanId: "2222222222222222", parentSpanId: "3333333333333333", }); expect(receivedMetadata?.messageId).toBe("msg-1"); expect(receivedMetadata?.senderName).toBe("User One"); expect(receivedMetadata?.threadId).toBe(42); expect(receivedMetadata?.topicName).toBe("Deployments"); expect(receivedMetadata?.mediaPath).toBe("/tmp/audio.ogg"); expect(receivedMetadata?.mediaUrl).toBe("https://cdn.example.com/audio.ogg"); expect(receivedMetadata?.mediaType).toBe("audio/ogg"); expect(receivedMetadata?.mediaPaths).toEqual(["/tmp/audio.ogg", "/tmp/photo.jpg"]); expect(receivedMetadata?.mediaUrls).toEqual([ "https://cdn.example.com/audio.ogg", "https://cdn.example.com/photo.jpg", ]); expect(receivedMetadata?.mediaTypes).toEqual(["audio/ogg", "image/jpeg"]); const internalReceived = toInternalMessageReceivedContext(canonical); const { metadata: internalMetadata, ...internalReceivedBase } = internalReceived; expect(internalReceivedBase).toEqual({ from: "demo-chat:user:123", content: "commands-body", timestamp: 1710000000, channelId: "demo-chat", accountId: "acc-1", conversationId: "demo-chat:chat:456", messageId: "msg-1", media: receivedEvent.media, }); expect(internalMetadata?.senderUsername).toBe("userone"); expect(internalMetadata?.senderE164).toBe("+15551234567"); expect(internalMetadata?.topicName).toBe("Deployments"); expect(internalMetadata?.mediaPath).toBe("/tmp/audio.ogg"); expect(internalMetadata?.mediaUrl).toBe("https://cdn.example.com/audio.ogg"); expect(internalMetadata?.mediaType).toBe("audio/ogg"); expect(internalMetadata?.mediaPaths).toEqual(["/tmp/audio.ogg", "/tmp/photo.jpg"]); expect(internalMetadata?.mediaUrls).toEqual([ "https://cdn.example.com/audio.ogg", "https://cdn.example.com/photo.jpg", ]); expect(internalMetadata?.mediaTypes).toEqual(["audio/ogg", "image/jpeg"]); }); it("passes frozen trace copies to inbound claim and sent plugin hooks", () => { const trace: DiagnosticTraceContext = { traceId: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", spanId: "bbbbbbbbbbbbbbbb", parentSpanId: "cccccccccccccccc", traceFlags: "01", }; const inbound = { ...deriveInboundMessageHookContext(makeInboundCtx()), trace, }; const { context: inboundContext, event: inboundEvent } = toPluginInboundClaimPair(inbound); expect(inboundContext.trace).not.toBe(trace); expect(inboundContext.trace).toEqual(trace); expect(Object.isFrozen(inboundContext.trace)).toBe(true); expect(inboundEvent.trace).not.toBe(trace); expect(inboundEvent.trace).toEqual(trace); expect(Object.isFrozen(inboundEvent.trace)).toBe(true); const sent = buildCanonicalSentMessageHookContext({ to: "demo-chat:chat:456", content: "reply", success: true, channelId: "demo-chat", trace, }); const sentEvent = toPluginMessageSentEvent(sent); expect(sentEvent.trace).not.toBe(trace); expect(sentEvent.trace).toEqual(trace); expect(Object.isFrozen(sentEvent.trace)).toBe(true); }); it("uses channel plugin claim resolvers for grouped conversations", () => { const canonical = deriveInboundMessageHookContext( makeInboundCtx({ Provider: "claim-chat", Surface: "claim-chat", OriginatingChannel: "claim-chat", To: "channel:123456789012345678", OriginatingTo: "channel:123456789012345678", GroupChannel: "general", GroupSubject: "guild", }), ); expect(toPluginInboundClaimPair(canonical).context).toEqual({ channelId: "claim-chat", accountId: "acc-1", conversationId: "channel:123456789012345678", sessionKey: "session-1", parentConversationId: undefined, senderId: "sender-1", messageId: "msg-1", runId: undefined, trace: undefined, traceId: undefined, spanId: undefined, parentSpanId: undefined, callDepth: undefined, }); }); it("does not fall back when a channel rejects inbound claim resolution", () => { const canonical = deriveInboundMessageHookContext( makeInboundCtx({ Provider: "claim-chat", Surface: "claim-chat", OriginatingChannel: "claim-chat", From: undefined, To: "channel:room-123", OriginatingTo: "channel:room-123", GroupChannel: undefined, GroupSubject: undefined, }), ); const { context, event } = toPluginInboundClaimPair(canonical); expect(context.conversationId).toBeUndefined(); expect(event.conversationId).toBeUndefined(); }); it("passes thread parent ids to channel plugin claim resolvers", () => { const canonical = deriveInboundMessageHookContext( makeInboundCtx({ Provider: "thread-claim-chat", Surface: "thread-claim-chat", OriginatingChannel: "thread-claim-chat", To: "channel:1510164477642014740", OriginatingTo: "channel:1510164477642014740", MessageThreadId: "1510164477642014740", ThreadParentId: "1510164477642014999", GroupChannel: "thread", GroupSubject: "guild", }), ); expect(toPluginInboundClaimPair(canonical).context).toMatchObject({ channelId: "thread-claim-chat", conversationId: "1510164477642014740", parentConversationId: "channel:1510164477642014999", }); }); it("uses channel plugin claim resolvers for direct-message conversations", () => { const canonical = deriveInboundMessageHookContext( makeInboundCtx({ Provider: "claim-chat", Surface: "claim-chat", OriginatingChannel: "claim-chat", From: "claim-chat:1177378744822943744", To: "channel:1480574946919846079", OriginatingTo: "channel:1480574946919846079", GroupChannel: undefined, GroupSubject: undefined, }), ); expect(toPluginInboundClaimPair(canonical).context).toEqual({ channelId: "claim-chat", accountId: "acc-1", conversationId: "user:1177378744822943744", sessionKey: "session-1", parentConversationId: undefined, senderId: "sender-1", messageId: "msg-1", runId: undefined, trace: undefined, traceId: undefined, spanId: undefined, parentSpanId: undefined, callDepth: undefined, }); }); it("maps transcribed and preprocessed internal payloads", () => { const cfg = {} as OpenClawConfig; const canonical = deriveInboundMessageHookContext(makeInboundCtx({ Transcript: undefined })); const transcribed = toInternalMessageTranscribedContext(canonical, cfg); expect(transcribed.transcript).toBe(""); expect(transcribed.cfg).toBe(cfg); expect(transcribed.media).toEqual([ expect.objectContaining({ path: "/tmp/audio.ogg", contentType: "audio/ogg" }), ]); const preprocessed = toInternalMessagePreprocessedContext(canonical, cfg); expect(preprocessed.transcript).toBeUndefined(); expect(preprocessed.isGroup).toBe(true); expect(preprocessed.groupId).toBe("demo-chat:chat:456"); expect(preprocessed.cfg).toBe(cfg); expect(preprocessed.media).toEqual(transcribed.media); }); it("maps sent context consistently for plugin/internal hooks", () => { const canonical = buildCanonicalSentMessageHookContext({ to: "demo-chat:chat:456", content: "reply", success: false, error: "network error", channelId: "demo-chat", accountId: "acc-1", sessionKey: "session-1", messageId: "out-1", runId: "run-out-1", isGroup: true, groupId: "demo-chat:chat:456", }); expect(toPluginMessageContext(canonical)).toEqual({ channelId: "demo-chat", accountId: "acc-1", conversationId: "demo-chat:chat:456", sessionKey: "session-1", runId: "run-out-1", messageId: "out-1", }); expect(toPluginMessageSentEvent(canonical)).toEqual({ to: "demo-chat:chat:456", content: "reply", success: false, messageId: "out-1", sessionKey: "session-1", runId: "run-out-1", error: "network error", }); expect(toInternalMessageSentContext(canonical)).toEqual({ to: "demo-chat:chat:456", content: "reply", success: false, error: "network error", channelId: "demo-chat", accountId: "acc-1", conversationId: "demo-chat:chat:456", messageId: "out-1", isGroup: true, groupId: "demo-chat:chat:456", }); }); it("projects normalized location and stable provider update identity", () => { const canonical = deriveInboundMessageHookContext( makeInboundCtx({ LocationLat: 43.8376, LocationLon: 18.4534, LocationAccuracy: 12, LocationSource: "live", LocationIsLive: true, LocationLivePeriodSeconds: 900, ProviderUpdateId: "9002", ProviderUpdateKind: "edited_message", ProviderMessageTimestamp: 1_786_094_460_000, ProviderEditTimestamp: 1_786_094_520_000, }), ); const { event } = toPluginInboundClaimPair(canonical); expect(event.location).toEqual({ latitude: 43.8376, longitude: 18.4534, accuracy: 12, source: "live", isLive: true, livePeriodSeconds: 900, }); expect(event.providerUpdate).toEqual({ id: "9002", kind: "edited_message", messageId: "msg-1", messageTimestamp: 1_786_094_460_000, editedTimestamp: 1_786_094_520_000, }); expect(toPluginMessageReceivedEvent(canonical)).toMatchObject({ location: event.location, providerUpdate: event.providerUpdate, }); }); });