fix(bluebubbles): dedupe reflected self-chat duplicates (#38442)

* BlueBubbles: drop reflected self-chat duplicates

* Changelog: add BlueBubbles self-chat echo dedupe entry

* BlueBubbles: gate self-chat cache and expand coverage

* BlueBubbles: require explicit sender ids for self-chat dedupe

* BlueBubbles: harden self-chat cache

* BlueBubbles: move self-chat cache identity into cache

* BlueBubbles: gate self-chat cache to confirmed outbound sends

* Update CHANGELOG.md

* BlueBubbles: bound self-chat cache input work

* Tests: cover BlueBubbles cache cap under cleanup throttle

* BlueBubbles: canonicalize self-chat DM scope

* Tests: cover BlueBubbles mixed self-chat scope aliases
This commit is contained in:
Vincent Koc
2026-03-12 03:11:43 -04:00
committed by GitHub
parent 6c196c913f
commit 241e8cc553
7 changed files with 756 additions and 7 deletions

View File

@@ -17,9 +17,28 @@ describe("normalizeWebhookMessage", () => {
expect(result).not.toBeNull();
expect(result?.senderId).toBe("+15551234567");
expect(result?.senderIdExplicit).toBe(false);
expect(result?.chatGuid).toBe("iMessage;-;+15551234567");
});
it("marks explicit sender handles as explicit identity", () => {
const result = normalizeWebhookMessage({
type: "new-message",
data: {
guid: "msg-explicit-1",
text: "hello",
isGroup: false,
isFromMe: true,
handle: { address: "+15551234567" },
chatGuid: "iMessage;-;+15551234567",
},
});
expect(result).not.toBeNull();
expect(result?.senderId).toBe("+15551234567");
expect(result?.senderIdExplicit).toBe(true);
});
it("does not infer sender from group chatGuid when sender handle is missing", () => {
const result = normalizeWebhookMessage({
type: "new-message",
@@ -72,6 +91,7 @@ describe("normalizeWebhookReaction", () => {
expect(result).not.toBeNull();
expect(result?.senderId).toBe("+15551234567");
expect(result?.senderIdExplicit).toBe(false);
expect(result?.messageId).toBe("p:0/msg-1");
expect(result?.action).toBe("added");
});

View File

@@ -191,12 +191,13 @@ function readFirstChatRecord(message: Record<string, unknown>): Record<string, u
function extractSenderInfo(message: Record<string, unknown>): {
senderId: string;
senderIdExplicit: boolean;
senderName?: string;
} {
const handleValue = message.handle ?? message.sender;
const handle =
asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null);
const senderId =
const senderIdRaw =
readString(handle, "address") ??
readString(handle, "handle") ??
readString(handle, "id") ??
@@ -204,13 +205,18 @@ function extractSenderInfo(message: Record<string, unknown>): {
readString(message, "sender") ??
readString(message, "from") ??
"";
const senderId = senderIdRaw.trim();
const senderName =
readString(handle, "displayName") ??
readString(handle, "name") ??
readString(message, "senderName") ??
undefined;
return { senderId, senderName };
return {
senderId,
senderIdExplicit: Boolean(senderId),
senderName,
};
}
function extractChatContext(message: Record<string, unknown>): {
@@ -441,6 +447,7 @@ export type BlueBubblesParticipant = {
export type NormalizedWebhookMessage = {
text: string;
senderId: string;
senderIdExplicit: boolean;
senderName?: string;
messageId?: string;
timestamp?: number;
@@ -466,6 +473,7 @@ export type NormalizedWebhookReaction = {
action: "added" | "removed";
emoji: string;
senderId: string;
senderIdExplicit: boolean;
senderName?: string;
messageId: string;
timestamp?: number;
@@ -672,7 +680,7 @@ export function normalizeWebhookMessage(
readString(message, "subject") ??
"";
const { senderId, senderName } = extractSenderInfo(message);
const { senderId, senderIdExplicit, senderName } = extractSenderInfo(message);
const { chatGuid, chatIdentifier, chatId, chatName, isGroup, participants } =
extractChatContext(message);
const normalizedParticipants = normalizeParticipantList(participants);
@@ -717,7 +725,7 @@ export function normalizeWebhookMessage(
// BlueBubbles may omit `handle` in webhook payloads; for DM chat GUIDs we can still infer sender.
const senderFallbackFromChatGuid =
!senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
!senderIdExplicit && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
if (!normalizedSender) {
return null;
@@ -727,6 +735,7 @@ export function normalizeWebhookMessage(
return {
text,
senderId: normalizedSender,
senderIdExplicit,
senderName,
messageId,
timestamp,
@@ -777,7 +786,7 @@ export function normalizeWebhookReaction(
const emoji = (associatedEmoji?.trim() || mapping?.emoji) ?? `reaction:${associatedType}`;
const action = mapping?.action ?? resolveTapbackActionHint(associatedType) ?? "added";
const { senderId, senderName } = extractSenderInfo(message);
const { senderId, senderIdExplicit, senderName } = extractSenderInfo(message);
const { chatGuid, chatIdentifier, chatId, chatName, isGroup } = extractChatContext(message);
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
@@ -793,7 +802,7 @@ export function normalizeWebhookReaction(
: undefined;
const senderFallbackFromChatGuid =
!senderId && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
!senderIdExplicit && !isGroup && chatGuid ? extractHandleFromChatGuid(chatGuid) : null;
const normalizedSender = normalizeBlueBubblesHandle(senderId || senderFallbackFromChatGuid || "");
if (!normalizedSender) {
return null;
@@ -803,6 +812,7 @@ export function normalizeWebhookReaction(
action,
emoji,
senderId: normalizedSender,
senderIdExplicit,
senderName,
messageId: associatedGuid,
timestamp,

View File

@@ -38,6 +38,10 @@ import {
resolveBlueBubblesMessageId,
resolveReplyContextFromCache,
} from "./monitor-reply-cache.js";
import {
hasBlueBubblesSelfChatCopy,
rememberBlueBubblesSelfChatCopy,
} from "./monitor-self-chat-cache.js";
import type {
BlueBubblesCoreRuntime,
BlueBubblesRuntimeEnv,
@@ -47,7 +51,12 @@ import { isBlueBubblesPrivateApiEnabled } from "./probe.js";
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
import { normalizeSecretInputString } from "./secret-input.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender } from "./targets.js";
import {
extractHandleFromChatGuid,
formatBlueBubblesChatTarget,
isAllowedBlueBubblesSender,
normalizeBlueBubblesHandle,
} from "./targets.js";
const DEFAULT_TEXT_LIMIT = 4000;
const invalidAckReactions = new Set<string>();
@@ -80,6 +89,19 @@ function normalizeSnippet(value: string): string {
return stripMarkdown(value).replace(/\s+/g, " ").trim().toLowerCase();
}
function isBlueBubblesSelfChatMessage(
message: NormalizedWebhookMessage,
isGroup: boolean,
): boolean {
if (isGroup || !message.senderIdExplicit) {
return false;
}
const chatHandle =
(message.chatGuid ? extractHandleFromChatGuid(message.chatGuid) : null) ??
normalizeBlueBubblesHandle(message.chatIdentifier ?? "");
return Boolean(chatHandle) && chatHandle === message.senderId;
}
function prunePendingOutboundMessageIds(now = Date.now()): void {
const cutoff = now - PENDING_OUTBOUND_MESSAGE_ID_TTL_MS;
for (let i = pendingOutboundMessageIds.length - 1; i >= 0; i--) {
@@ -453,6 +475,16 @@ export async function processMessage(
? `removed ${tapbackParsed.emoji} reaction`
: `reacted with ${tapbackParsed.emoji}`
: text || placeholder;
const isSelfChatMessage = isBlueBubblesSelfChatMessage(message, isGroup);
const selfChatLookup = {
accountId: account.accountId,
chatGuid: message.chatGuid,
chatIdentifier: message.chatIdentifier,
chatId: message.chatId,
senderId: message.senderId,
body: rawBody,
timestamp: message.timestamp,
};
const cacheMessageId = message.messageId?.trim();
let messageShortId: string | undefined;
@@ -485,6 +517,9 @@ export async function processMessage(
body: rawBody,
});
if (pending) {
if (isSelfChatMessage) {
rememberBlueBubblesSelfChatCopy(selfChatLookup);
}
const displayId = getShortIdForUuid(cacheMessageId) || cacheMessageId;
const previewSource = pending.snippetRaw || rawBody;
const preview = previewSource
@@ -499,6 +534,11 @@ export async function processMessage(
return;
}
if (isSelfChatMessage && hasBlueBubblesSelfChatCopy(selfChatLookup)) {
logVerbose(core, runtime, `drop: reflected self-chat duplicate sender=${message.senderId}`);
return;
}
if (!rawBody) {
logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`);
return;

View File

@@ -0,0 +1,190 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
hasBlueBubblesSelfChatCopy,
rememberBlueBubblesSelfChatCopy,
resetBlueBubblesSelfChatCache,
} from "./monitor-self-chat-cache.js";
describe("BlueBubbles self-chat cache", () => {
const directLookup = {
accountId: "default",
chatGuid: "iMessage;-;+15551234567",
senderId: "+15551234567",
} as const;
afterEach(() => {
resetBlueBubblesSelfChatCache();
vi.useRealTimers();
});
it("matches repeated lookups for the same scope, timestamp, and text", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
rememberBlueBubblesSelfChatCopy({
...directLookup,
body: " hello\r\nworld ",
timestamp: 123,
});
expect(
hasBlueBubblesSelfChatCopy({
...directLookup,
body: "hello\nworld",
timestamp: 123,
}),
).toBe(true);
});
it("canonicalizes DM scope across chatIdentifier and chatGuid", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
rememberBlueBubblesSelfChatCopy({
accountId: "default",
chatIdentifier: "+15551234567",
senderId: "+15551234567",
body: "hello",
timestamp: 123,
});
expect(
hasBlueBubblesSelfChatCopy({
accountId: "default",
chatGuid: "iMessage;-;+15551234567",
senderId: "+15551234567",
body: "hello",
timestamp: 123,
}),
).toBe(true);
resetBlueBubblesSelfChatCache();
rememberBlueBubblesSelfChatCopy({
accountId: "default",
chatGuid: "iMessage;-;+15551234567",
senderId: "+15551234567",
body: "hello",
timestamp: 123,
});
expect(
hasBlueBubblesSelfChatCopy({
accountId: "default",
chatIdentifier: "+15551234567",
senderId: "+15551234567",
body: "hello",
timestamp: 123,
}),
).toBe(true);
});
it("expires entries after the ttl window", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
rememberBlueBubblesSelfChatCopy({
...directLookup,
body: "hello",
timestamp: 123,
});
vi.advanceTimersByTime(11_001);
expect(
hasBlueBubblesSelfChatCopy({
...directLookup,
body: "hello",
timestamp: 123,
}),
).toBe(false);
});
it("evicts older entries when the cache exceeds its cap", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
for (let i = 0; i < 513; i += 1) {
rememberBlueBubblesSelfChatCopy({
...directLookup,
body: `message-${i}`,
timestamp: i,
});
vi.advanceTimersByTime(1_001);
}
expect(
hasBlueBubblesSelfChatCopy({
...directLookup,
body: "message-0",
timestamp: 0,
}),
).toBe(false);
expect(
hasBlueBubblesSelfChatCopy({
...directLookup,
body: "message-512",
timestamp: 512,
}),
).toBe(true);
});
it("enforces the cache cap even when cleanup is throttled", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
for (let i = 0; i < 513; i += 1) {
rememberBlueBubblesSelfChatCopy({
...directLookup,
body: `burst-${i}`,
timestamp: i,
});
}
expect(
hasBlueBubblesSelfChatCopy({
...directLookup,
body: "burst-0",
timestamp: 0,
}),
).toBe(false);
expect(
hasBlueBubblesSelfChatCopy({
...directLookup,
body: "burst-512",
timestamp: 512,
}),
).toBe(true);
});
it("does not collide long texts that differ only in the middle", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
const prefix = "a".repeat(256);
const suffix = "b".repeat(256);
const longBodyA = `${prefix}${"x".repeat(300)}${suffix}`;
const longBodyB = `${prefix}${"y".repeat(300)}${suffix}`;
rememberBlueBubblesSelfChatCopy({
...directLookup,
body: longBodyA,
timestamp: 123,
});
expect(
hasBlueBubblesSelfChatCopy({
...directLookup,
body: longBodyA,
timestamp: 123,
}),
).toBe(true);
expect(
hasBlueBubblesSelfChatCopy({
...directLookup,
body: longBodyB,
timestamp: 123,
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,127 @@
import { createHash } from "node:crypto";
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
type SelfChatCacheKeyParts = {
accountId: string;
chatGuid?: string;
chatIdentifier?: string;
chatId?: number;
senderId: string;
};
type SelfChatLookup = SelfChatCacheKeyParts & {
body?: string;
timestamp?: number;
};
const SELF_CHAT_TTL_MS = 10_000;
const MAX_SELF_CHAT_CACHE_ENTRIES = 512;
const CLEANUP_MIN_INTERVAL_MS = 1_000;
const MAX_SELF_CHAT_BODY_CHARS = 32_768;
const cache = new Map<string, number>();
let lastCleanupAt = 0;
function normalizeBody(body: string | undefined): string | null {
if (!body) {
return null;
}
const bounded =
body.length > MAX_SELF_CHAT_BODY_CHARS ? body.slice(0, MAX_SELF_CHAT_BODY_CHARS) : body;
const normalized = bounded.replace(/\r\n?/g, "\n").trim();
return normalized ? normalized : null;
}
function isUsableTimestamp(timestamp: number | undefined): timestamp is number {
return typeof timestamp === "number" && Number.isFinite(timestamp);
}
function digestText(text: string): string {
return createHash("sha256").update(text).digest("base64url");
}
function trimOrUndefined(value?: string | null): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function resolveCanonicalChatTarget(parts: SelfChatCacheKeyParts): string | null {
const handleFromGuid = parts.chatGuid ? extractHandleFromChatGuid(parts.chatGuid) : null;
if (handleFromGuid) {
return handleFromGuid;
}
const normalizedIdentifier = normalizeBlueBubblesHandle(parts.chatIdentifier ?? "");
if (normalizedIdentifier) {
return normalizedIdentifier;
}
return (
trimOrUndefined(parts.chatGuid) ??
trimOrUndefined(parts.chatIdentifier) ??
(typeof parts.chatId === "number" ? String(parts.chatId) : null)
);
}
function buildScope(parts: SelfChatCacheKeyParts): string {
const target = resolveCanonicalChatTarget(parts) ?? parts.senderId;
return `${parts.accountId}:${target}`;
}
function cleanupExpired(now = Date.now()): void {
if (
lastCleanupAt !== 0 &&
now >= lastCleanupAt &&
now - lastCleanupAt < CLEANUP_MIN_INTERVAL_MS
) {
return;
}
lastCleanupAt = now;
for (const [key, seenAt] of cache.entries()) {
if (now - seenAt > SELF_CHAT_TTL_MS) {
cache.delete(key);
}
}
}
function enforceSizeCap(): void {
while (cache.size > MAX_SELF_CHAT_CACHE_ENTRIES) {
const oldestKey = cache.keys().next().value;
if (typeof oldestKey !== "string") {
break;
}
cache.delete(oldestKey);
}
}
function buildKey(lookup: SelfChatLookup): string | null {
const body = normalizeBody(lookup.body);
if (!body || !isUsableTimestamp(lookup.timestamp)) {
return null;
}
return `${buildScope(lookup)}:${lookup.timestamp}:${digestText(body)}`;
}
export function rememberBlueBubblesSelfChatCopy(lookup: SelfChatLookup): void {
cleanupExpired();
const key = buildKey(lookup);
if (!key) {
return;
}
cache.set(key, Date.now());
enforceSizeCap();
}
export function hasBlueBubblesSelfChatCopy(lookup: SelfChatLookup): boolean {
cleanupExpired();
const key = buildKey(lookup);
if (!key) {
return false;
}
const seenAt = cache.get(key);
return typeof seenAt === "number" && Date.now() - seenAt <= SELF_CHAT_TTL_MS;
}
export function resetBlueBubblesSelfChatCache(): void {
cache.clear();
lastCleanupAt = 0;
}

View File

@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
import { fetchBlueBubblesHistory } from "./history.js";
import { resetBlueBubblesSelfChatCache } from "./monitor-self-chat-cache.js";
import {
handleBlueBubblesWebhookRequest,
registerBlueBubblesWebhookTarget,
@@ -246,6 +247,7 @@ describe("BlueBubbles webhook monitor", () => {
vi.clearAllMocks();
// Reset short ID state between tests for predictable behavior
_resetBlueBubblesShortIdState();
resetBlueBubblesSelfChatCache();
mockFetchBlueBubblesHistory.mockResolvedValue({ entries: [], resolved: true });
mockReadAllowFromStore.mockResolvedValue([]);
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
@@ -259,6 +261,7 @@ describe("BlueBubbles webhook monitor", () => {
afterEach(() => {
unregister?.();
vi.useRealTimers();
});
describe("DM pairing behavior vs allowFrom", () => {
@@ -2676,5 +2679,363 @@ describe("BlueBubbles webhook monitor", () => {
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("drops reflected self-chat duplicates after a confirmed assistant outbound", async () => {
const account = createMockAccount({ dmPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
const { sendMessageBlueBubbles } = await import("./send.js");
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" });
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
return EMPTY_DISPATCH_RESULT;
});
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const timestamp = Date.now();
const inboundPayload = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-self-0",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
},
};
await handleBlueBubblesWebhookRequest(
createMockRequest("POST", "/bluebubbles-webhook", inboundPayload),
createMockResponse(),
);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
const fromMePayload = {
type: "new-message",
data: {
text: "replying now",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: true,
guid: "msg-self-1",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
},
};
await handleBlueBubblesWebhookRequest(
createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
createMockResponse(),
);
await flushAsync();
const reflectedPayload = {
type: "new-message",
data: {
text: "replying now",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-self-2",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
},
};
await handleBlueBubblesWebhookRequest(
createMockRequest("POST", "/bluebubbles-webhook", reflectedPayload),
createMockResponse(),
);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("does not drop inbound messages when no fromMe self-chat copy was seen", async () => {
const account = createMockAccount({ dmPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const inboundPayload = {
type: "new-message",
data: {
text: "genuinely new message",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-inbound-1",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
};
await handleBlueBubblesWebhookRequest(
createMockRequest("POST", "/bluebubbles-webhook", inboundPayload),
createMockResponse(),
);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
it("does not drop reflected copies after the self-chat cache TTL expires", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-07T00:00:00Z"));
const account = createMockAccount({ dmPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const timestamp = Date.now();
const fromMePayload = {
type: "new-message",
data: {
text: "ttl me",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: true,
guid: "msg-self-ttl-1",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
},
};
await handleBlueBubblesWebhookRequest(
createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
createMockResponse(),
);
await vi.runAllTimersAsync();
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
vi.advanceTimersByTime(10_001);
const reflectedPayload = {
type: "new-message",
data: {
text: "ttl me",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-self-ttl-2",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
},
};
await handleBlueBubblesWebhookRequest(
createMockRequest("POST", "/bluebubbles-webhook", reflectedPayload),
createMockResponse(),
);
await vi.runAllTimersAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
it("does not cache regular fromMe DMs as self-chat reflections", async () => {
const account = createMockAccount({ dmPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const timestamp = Date.now();
const fromMePayload = {
type: "new-message",
data: {
text: "shared text",
handle: { address: "+15557654321" },
isGroup: false,
isFromMe: true,
guid: "msg-normal-fromme",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
},
};
await handleBlueBubblesWebhookRequest(
createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
createMockResponse(),
);
await flushAsync();
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
const inboundPayload = {
type: "new-message",
data: {
text: "shared text",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-normal-inbound",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
},
};
await handleBlueBubblesWebhookRequest(
createMockRequest("POST", "/bluebubbles-webhook", inboundPayload),
createMockResponse(),
);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
it("does not drop user-authored self-chat prompts without a confirmed assistant outbound", async () => {
const account = createMockAccount({ dmPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const timestamp = Date.now();
const fromMePayload = {
type: "new-message",
data: {
text: "user-authored self prompt",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: true,
guid: "msg-self-user-1",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
},
};
await handleBlueBubblesWebhookRequest(
createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
createMockResponse(),
);
await flushAsync();
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
const reflectedPayload = {
type: "new-message",
data: {
text: "user-authored self prompt",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-self-user-2",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
},
};
await handleBlueBubblesWebhookRequest(
createMockRequest("POST", "/bluebubbles-webhook", reflectedPayload),
createMockResponse(),
);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
it("does not treat chatGuid-inferred sender ids as self-chat evidence", async () => {
const account = createMockAccount({ dmPolicy: "open" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const timestamp = Date.now();
const fromMePayload = {
type: "new-message",
data: {
text: "shared inferred text",
handle: null,
isGroup: false,
isFromMe: true,
guid: "msg-inferred-fromme",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
},
};
await handleBlueBubblesWebhookRequest(
createMockRequest("POST", "/bluebubbles-webhook", fromMePayload),
createMockResponse(),
);
await flushAsync();
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
const inboundPayload = {
type: "new-message",
data: {
text: "shared inferred text",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-inferred-inbound",
chatGuid: "iMessage;-;+15551234567",
date: timestamp,
},
};
await handleBlueBubblesWebhookRequest(
createMockRequest("POST", "/bluebubbles-webhook", inboundPayload),
createMockResponse(),
);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
});
});