fix(bluebubbles): enable group participant enrichment by default, add fallback fetch and handle field aliases

This commit is contained in:
Tyler Yust
2026-03-26 05:44:18 -07:00
parent 68c6abe32b
commit cc077ef1ef
8 changed files with 331 additions and 19 deletions

View File

@@ -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(),

View File

@@ -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", () => {

View File

@@ -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;

View File

@@ -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

View File

@@ -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({

View File

@@ -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?: {

View File

@@ -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", () => {

View File

@@ -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;