mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-28 10:22:32 +00:00
fix(bluebubbles): enable group participant enrichment by default, add fallback fetch and handle field aliases
This commit is contained in:
@@ -42,7 +42,7 @@ const bluebubblesAccountSchema = z
|
||||
allowFrom: AllowFromListSchema,
|
||||
groupAllowFrom: AllowFromListSchema,
|
||||
groupPolicy: GroupPolicySchema.optional(),
|
||||
enrichGroupParticipantsFromContacts: z.boolean().optional(),
|
||||
enrichGroupParticipantsFromContacts: z.boolean().optional().default(true),
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
|
||||
@@ -78,6 +78,48 @@ describe("normalizeWebhookMessage", () => {
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.senderId).toBe("+15551234567");
|
||||
});
|
||||
|
||||
it("normalizes participant handles from the handles field", () => {
|
||||
const result = normalizeWebhookMessage({
|
||||
type: "new-message",
|
||||
data: {
|
||||
guid: "msg-handles-1",
|
||||
text: "hello group",
|
||||
isGroup: true,
|
||||
isFromMe: false,
|
||||
handle: { address: "+15550000000" },
|
||||
chatGuid: "iMessage;+;chat123456",
|
||||
handles: [
|
||||
{ address: "+15551234567", displayName: "Alice" },
|
||||
{ address: "+15557654321", displayName: "Bob" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.participants).toEqual([
|
||||
{ id: "+15551234567", name: "Alice" },
|
||||
{ id: "+15557654321", name: "Bob" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes participant handles from the participantHandles field", () => {
|
||||
const result = normalizeWebhookMessage({
|
||||
type: "new-message",
|
||||
data: {
|
||||
guid: "msg-participant-handles-1",
|
||||
text: "hello group",
|
||||
isGroup: true,
|
||||
isFromMe: false,
|
||||
handle: { address: "+15550000000" },
|
||||
chatGuid: "iMessage;+;chat123456",
|
||||
participantHandles: [{ address: "+15551234567" }, "+15557654321"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.participants).toEqual([{ id: "+15551234567" }, { id: "+15557654321" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeWebhookReaction", () => {
|
||||
|
||||
@@ -189,6 +189,25 @@ function readFirstChatRecord(message: Record<string, unknown>): Record<string, u
|
||||
return asRecord(first);
|
||||
}
|
||||
|
||||
function readParticipantEntries(record: Record<string, unknown> | null): unknown[] | undefined {
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const participants = record["participants"];
|
||||
if (Array.isArray(participants)) {
|
||||
return participants;
|
||||
}
|
||||
const handles = record["handles"];
|
||||
if (Array.isArray(handles)) {
|
||||
return handles;
|
||||
}
|
||||
const participantHandles = record["participantHandles"];
|
||||
if (Array.isArray(participantHandles)) {
|
||||
return participantHandles;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractSenderInfo(message: Record<string, unknown>): {
|
||||
senderId: string;
|
||||
senderIdExplicit: boolean;
|
||||
@@ -265,16 +284,11 @@ function extractChatContext(message: Record<string, unknown>): {
|
||||
readString(chatFromList, "name") ??
|
||||
undefined;
|
||||
|
||||
const chatParticipants = chat ? chat["participants"] : undefined;
|
||||
const messageParticipants = message["participants"];
|
||||
const chatsParticipants = chatFromList ? chatFromList["participants"] : undefined;
|
||||
const participants = Array.isArray(chatParticipants)
|
||||
? chatParticipants
|
||||
: Array.isArray(messageParticipants)
|
||||
? messageParticipants
|
||||
: Array.isArray(chatsParticipants)
|
||||
? chatsParticipants
|
||||
: [];
|
||||
const participants =
|
||||
readParticipantEntries(chat) ??
|
||||
readParticipantEntries(message) ??
|
||||
readParticipantEntries(chatFromList) ??
|
||||
[];
|
||||
const participantsCount = participants.length;
|
||||
const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
|
||||
const explicitIsGroup =
|
||||
@@ -336,13 +350,14 @@ function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | nul
|
||||
return { id: normalizedId, name };
|
||||
}
|
||||
|
||||
function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] {
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
export function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] {
|
||||
const entries = Array.isArray(raw) ? raw : (readParticipantEntries(asRecord(raw)) ?? []);
|
||||
if (entries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const output: BlueBubblesParticipant[] = [];
|
||||
for (const entry of raw) {
|
||||
for (const entry of entries) {
|
||||
const normalized = normalizeParticipantEntry(entry);
|
||||
if (!normalized?.id) {
|
||||
continue;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
formatGroupAllowlistEntry,
|
||||
formatGroupMembers,
|
||||
formatReplyTag,
|
||||
normalizeParticipantList,
|
||||
parseTapbackText,
|
||||
resolveGroupFlagFromChatGuid,
|
||||
resolveTapbackContext,
|
||||
@@ -62,6 +63,7 @@ import {
|
||||
isAllowedBlueBubblesSender,
|
||||
normalizeBlueBubblesHandle,
|
||||
} from "./targets.js";
|
||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||
|
||||
const DEFAULT_TEXT_LIMIT = 4000;
|
||||
const invalidAckReactions = new Set<string>();
|
||||
@@ -94,6 +96,134 @@ function normalizeSnippet(value: string): string {
|
||||
return stripMarkdown(value).replace(/\s+/g, " ").trim().toLowerCase();
|
||||
}
|
||||
|
||||
type BlueBubblesChatRecord = Record<string, unknown>;
|
||||
|
||||
function blueBubblesPolicy(allowPrivateNetwork: boolean | undefined) {
|
||||
return allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined;
|
||||
}
|
||||
|
||||
function extractBlueBubblesChatGuid(chat: BlueBubblesChatRecord): string | undefined {
|
||||
const candidates = [chat.chatGuid, chat.guid, chat.chat_guid];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === "string" && candidate.trim()) {
|
||||
return candidate.trim();
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractBlueBubblesChatId(chat: BlueBubblesChatRecord): number | undefined {
|
||||
const candidates = [chat.chatId, chat.id, chat.chat_id];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractChatIdentifierFromChatGuid(chatGuid: string): string | undefined {
|
||||
const parts = chatGuid.split(";");
|
||||
if (parts.length < 3) {
|
||||
return undefined;
|
||||
}
|
||||
const identifier = parts[2]?.trim();
|
||||
return identifier || undefined;
|
||||
}
|
||||
|
||||
function extractBlueBubblesChatIdentifier(chat: BlueBubblesChatRecord): string | undefined {
|
||||
const candidates = [chat.chatIdentifier, chat.chat_identifier, chat.identifier];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === "string" && candidate.trim()) {
|
||||
return candidate.trim();
|
||||
}
|
||||
}
|
||||
const chatGuid = extractBlueBubblesChatGuid(chat);
|
||||
return chatGuid ? extractChatIdentifierFromChatGuid(chatGuid) : undefined;
|
||||
}
|
||||
|
||||
async function queryBlueBubblesChats(params: {
|
||||
baseUrl: string;
|
||||
password: string;
|
||||
timeoutMs?: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
allowPrivateNetwork?: boolean;
|
||||
}): Promise<BlueBubblesChatRecord[]> {
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl: params.baseUrl,
|
||||
path: "/api/v1/chat/query",
|
||||
password: params.password,
|
||||
});
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
with: ["participants"],
|
||||
}),
|
||||
},
|
||||
params.timeoutMs,
|
||||
blueBubblesPolicy(params.allowPrivateNetwork),
|
||||
);
|
||||
if (!res.ok) {
|
||||
return [];
|
||||
}
|
||||
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
||||
const data = payload && typeof payload.data !== "undefined" ? (payload.data as unknown) : null;
|
||||
return Array.isArray(data) ? (data as BlueBubblesChatRecord[]) : [];
|
||||
}
|
||||
|
||||
async function fetchBlueBubblesParticipantsForInboundMessage(params: {
|
||||
baseUrl: string;
|
||||
password: string;
|
||||
chatGuid?: string;
|
||||
chatId?: number;
|
||||
chatIdentifier?: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
}): Promise<import("./monitor-normalize.js").BlueBubblesParticipant[] | null> {
|
||||
if (!params.chatGuid && params.chatId == null && !params.chatIdentifier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const limit = 500;
|
||||
for (let offset = 0; offset < 5000; offset += limit) {
|
||||
const chats = await queryBlueBubblesChats({
|
||||
baseUrl: params.baseUrl,
|
||||
password: params.password,
|
||||
offset,
|
||||
limit,
|
||||
allowPrivateNetwork: params.allowPrivateNetwork,
|
||||
});
|
||||
if (chats.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const chat of chats) {
|
||||
const chatGuid = extractBlueBubblesChatGuid(chat);
|
||||
const chatId = extractBlueBubblesChatId(chat);
|
||||
const chatIdentifier = extractBlueBubblesChatIdentifier(chat);
|
||||
const matches =
|
||||
(params.chatGuid && chatGuid === params.chatGuid) ||
|
||||
(params.chatId != null && chatId === params.chatId) ||
|
||||
(params.chatIdentifier &&
|
||||
(chatIdentifier === params.chatIdentifier || chatGuid === params.chatIdentifier));
|
||||
if (matches) {
|
||||
return normalizeParticipantList(chat);
|
||||
}
|
||||
}
|
||||
|
||||
if (chats.length < limit) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isBlueBubblesSelfChatMessage(
|
||||
message: NormalizedWebhookMessage,
|
||||
isGroup: boolean,
|
||||
@@ -784,6 +914,31 @@ export async function processMessage(
|
||||
return;
|
||||
}
|
||||
|
||||
const baseUrl = normalizeSecretInputString(account.config.serverUrl);
|
||||
const password = normalizeSecretInputString(account.config.password);
|
||||
|
||||
if (isGroup && !message.participants?.length && baseUrl && password) {
|
||||
try {
|
||||
const fetchedParticipants = await fetchBlueBubblesParticipantsForInboundMessage({
|
||||
baseUrl,
|
||||
password,
|
||||
chatGuid: message.chatGuid,
|
||||
chatId: message.chatId,
|
||||
chatIdentifier: message.chatIdentifier,
|
||||
allowPrivateNetwork: account.config.allowPrivateNetwork === true,
|
||||
});
|
||||
if (fetchedParticipants?.length) {
|
||||
message.participants = fetchedParticipants;
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`bluebubbles: participant fallback lookup failed chat=${peerId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isGroup &&
|
||||
account.config.enrichGroupParticipantsFromContacts === true &&
|
||||
@@ -800,8 +955,6 @@ export async function processMessage(
|
||||
// surfacing dropped content (allowlist/mention/command gating).
|
||||
cacheInboundMessage();
|
||||
|
||||
const baseUrl = normalizeSecretInputString(account.config.serverUrl);
|
||||
const password = normalizeSecretInputString(account.config.password);
|
||||
const maxBytes =
|
||||
account.config.mediaMaxMb && account.config.mediaMaxMb > 0
|
||||
? account.config.mediaMaxMb * 1024 * 1024
|
||||
|
||||
@@ -108,6 +108,7 @@ const mockChunkTextWithMode = vi.fn((text: string) => (text ? [text] : []));
|
||||
const mockChunkMarkdownTextWithMode = vi.fn((text: string) => (text ? [text] : []));
|
||||
const mockResolveChunkMode = vi.fn(() => "length" as const);
|
||||
const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory);
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
function createMockRuntime(): PluginRuntime {
|
||||
return createBlueBubblesMonitorTestRuntime({
|
||||
@@ -180,6 +181,12 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
});
|
||||
resetBlueBubblesMonitorTestState({
|
||||
createRuntime: createMockRuntime,
|
||||
fetchHistoryMock: mockFetchBlueBubblesHistory,
|
||||
@@ -201,6 +208,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
unregister?.();
|
||||
setBlueBubblesParticipantContactDepsForTest();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("DM pairing behavior vs allowFrom", () => {
|
||||
@@ -499,9 +507,13 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
expect(callArgs.ctx.GroupMembers).toBe("Alice (+15551234567), Bob (+15557654321)");
|
||||
});
|
||||
|
||||
it("does not enrich group participants unless the config flag is enabled", async () => {
|
||||
it("does not enrich group participants when the config flag is disabled", async () => {
|
||||
const resolvePhoneNames = vi.fn(async () => new Map([["5551234567", "Alice Contact"]]));
|
||||
setupWebhookTarget();
|
||||
setupWebhookTarget({
|
||||
account: createMockAccount({
|
||||
enrichGroupParticipantsFromContacts: false,
|
||||
}),
|
||||
});
|
||||
setBlueBubblesParticipantContactDepsForTest({
|
||||
platform: "darwin",
|
||||
resolvePhoneNames,
|
||||
@@ -558,6 +570,57 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches missing group participants from the BlueBubbles API before contact enrichment", async () => {
|
||||
const resolvePhoneNames = vi.fn(
|
||||
async (phoneKeys: string[]) =>
|
||||
new Map(
|
||||
phoneKeys.map((phoneKey) => [
|
||||
phoneKey,
|
||||
phoneKey === "5551234567" ? "Alice Contact" : "Bob Contact",
|
||||
]),
|
||||
),
|
||||
);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;+;chat123456",
|
||||
participants: [{ address: "+15551234567" }, { address: "+15557654321" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
setupWebhookTarget({
|
||||
account: createMockAccount({
|
||||
enrichGroupParticipantsFromContacts: true,
|
||||
}),
|
||||
});
|
||||
setBlueBubblesParticipantContactDepsForTest({
|
||||
platform: "darwin",
|
||||
resolvePhoneNames,
|
||||
});
|
||||
|
||||
const payload = createTimestampedNewMessagePayloadForTest({
|
||||
text: "hello bert",
|
||||
isGroup: true,
|
||||
chatGuid: "iMessage;+;chat123456",
|
||||
chatName: "Family",
|
||||
});
|
||||
|
||||
await dispatchWebhookPayload(payload);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/v1/chat/query"),
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
expect(resolvePhoneNames).toHaveBeenCalledTimes(1);
|
||||
expect(getFirstDispatchCall().ctx.GroupMembers).toBe(
|
||||
"Alice Contact (+15551234567), Bob Contact (+15557654321)",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not read local contacts before mention gating allows the message", async () => {
|
||||
const resolvePhoneNames = vi.fn(async () => new Map([["5551234567", "Alice Contact"]]));
|
||||
setupWebhookTarget({
|
||||
|
||||
@@ -107,6 +107,7 @@ const mockChunkTextWithMode = vi.fn((text: string) => (text ? [text] : []));
|
||||
const mockChunkMarkdownTextWithMode = vi.fn((text: string) => (text ? [text] : []));
|
||||
const mockResolveChunkMode = vi.fn(() => "length" as const);
|
||||
const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory);
|
||||
const mockFetch = vi.fn();
|
||||
const TEST_WEBHOOK_PASSWORD = "secret-token";
|
||||
|
||||
function createMockRuntime(): PluginRuntime {
|
||||
@@ -142,6 +143,12 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
let unregister: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
});
|
||||
resetBlueBubblesMonitorTestState({
|
||||
createRuntime: createMockRuntime,
|
||||
fetchHistoryMock: mockFetchBlueBubblesHistory,
|
||||
@@ -156,6 +163,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
|
||||
afterEach(() => {
|
||||
unregister?.();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function setupWebhookTarget(params?: {
|
||||
|
||||
@@ -239,6 +239,37 @@ describe("BlueBubblesConfigSchema", () => {
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults enrichGroupParticipantsFromContacts to true", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "secret", // pragma: allowlist secret
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
if (!parsed.success) {
|
||||
return;
|
||||
}
|
||||
expect(parsed.data.enrichGroupParticipantsFromContacts).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults account enrichGroupParticipantsFromContacts to true", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
accounts: {
|
||||
work: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "secret", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
if (!parsed.success) {
|
||||
return;
|
||||
}
|
||||
const accountConfig = (
|
||||
parsed.data as { accounts?: { work?: { enrichGroupParticipantsFromContacts?: boolean } } }
|
||||
).accounts?.work;
|
||||
expect(accountConfig?.enrichGroupParticipantsFromContacts).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bluebubbles group policy", () => {
|
||||
|
||||
@@ -33,7 +33,7 @@ export type BlueBubblesAccountConfig = {
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
/** Group message handling policy. */
|
||||
groupPolicy?: GroupPolicy;
|
||||
/** Enrich unnamed group participants with local macOS Contacts names after gating. Default: false. */
|
||||
/** Enrich unnamed group participants with local macOS Contacts names after gating. Default: true. */
|
||||
enrichGroupParticipantsFromContacts?: boolean;
|
||||
/** Max group messages to keep as history context (0 disables). */
|
||||
historyLimit?: number;
|
||||
|
||||
Reference in New Issue
Block a user