feat: implement reply context handling in BlueBubbles messaging, enhancing message formatting and metadata resolution

This commit is contained in:
Tyler Yust
2026-01-20 01:25:42 -08:00
committed by Peter Steinberger
parent 20bc89d96c
commit e5514d4854
5 changed files with 97 additions and 3 deletions

View File

@@ -495,4 +495,43 @@ describe("monitorIMessageProvider", () => {
expect(body).toContain("Test Group id:99");
expect(body).toContain("+15550001111: @clawd hi");
});
it("includes reply context when imessage reply metadata is present", async () => {
const run = monitorIMessageProvider();
await waitForSubscribe();
notificationHandler?.({
method: "message",
params: {
message: {
id: 12,
chat_id: 55,
sender: "+15550001111",
is_from_me: false,
text: "replying now",
is_group: false,
reply_to_id: 9001,
reply_to_text: "original message",
reply_to_sender: "+15559998888",
},
},
});
await flush();
closeResolve?.();
await run;
expect(replyMock).toHaveBeenCalled();
const ctx = replyMock.mock.calls[0]?.[0] as {
Body?: string;
ReplyToId?: string;
ReplyToBody?: string;
ReplyToSender?: string;
};
expect(ctx.ReplyToId).toBe("9001");
expect(ctx.ReplyToBody).toBe("original message");
expect(ctx.ReplyToSender).toBe("+15559998888");
expect(String(ctx.Body ?? "")).toContain("[Replying to +15559998888 id:9001]");
expect(String(ctx.Body ?? "")).toContain("original message");
});
});

View File

@@ -92,6 +92,29 @@ async function detectRemoteHostFromCliPath(cliPath: string): Promise<string | un
}
}
type IMessageReplyContext = {
id?: string;
body: string;
sender?: string;
};
function normalizeReplyField(value: unknown): string | undefined {
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
if (typeof value === "number") return String(value);
return undefined;
}
function describeReplyContext(message: IMessagePayload): IMessageReplyContext | null {
const body = normalizeReplyField(message.reply_to_text);
if (!body) return null;
const id = normalizeReplyField(message.reply_to_id);
const sender = normalizeReplyField(message.reply_to_sender);
return { body, id, sender };
}
export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): Promise<void> {
const runtime = resolveRuntime(opts);
const cfg = opts.config ?? loadConfig();
@@ -324,6 +347,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
const placeholder = kind ? `<media:${kind}>` : attachments?.length ? "<media:attachment>" : "";
const bodyText = messageText || placeholder;
if (!bodyText) return;
const replyContext = describeReplyContext(message);
const createdAt = message.created_at ? Date.parse(message.created_at) : undefined;
const historyKey = isGroup
? String(chatId ?? chatGuid ?? chatIdentifier ?? "unknown")
@@ -414,11 +438,16 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
storePath,
sessionKey: route.sessionKey,
});
const replySuffix = replyContext
? `\n\n[Replying to ${replyContext.sender ?? "unknown sender"}${
replyContext.id ? ` id:${replyContext.id}` : ""
}]\n${replyContext.body}\n[/Replying]`
: "";
const body = formatInboundEnvelope({
channel: "iMessage",
from: fromLabel,
timestamp: createdAt,
body: bodyText,
body: `${bodyText}${replySuffix}`,
chatType: isGroup ? "group" : "direct",
sender: { name: senderNormalized, id: sender },
previousTimestamp,
@@ -462,6 +491,9 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
Provider: "imessage",
Surface: "imessage",
MessageSid: message.id ? String(message.id) : undefined,
ReplyToId: replyContext?.id,
ReplyToBody: replyContext?.body,
ReplyToSender: replyContext?.sender,
Timestamp: createdAt,
MediaPath: mediaPath,
MediaType: mediaType,

View File

@@ -13,6 +13,9 @@ export type IMessagePayload = {
sender?: string | null;
is_from_me?: boolean | null;
text?: string | null;
reply_to_id?: number | string | null;
reply_to_text?: string | null;
reply_to_sender?: string | null;
created_at?: string | null;
attachments?: IMessageAttachment[] | null;
chat_identifier?: string | null;

View File

@@ -65,4 +65,11 @@ describe("sendMessageIMessage", () => {
expect(params.file).toBe("/tmp/imessage-media.jpg");
expect(params.text).toBe("<media:image>");
});
it("returns message id when rpc provides one", async () => {
requestMock.mockResolvedValue({ ok: true, id: 123 });
const { sendMessageIMessage } = await loadSendMessageIMessage();
const result = await sendMessageIMessage("chat_id:7", "hello");
expect(result.messageId).toBe("123");
});
});

View File

@@ -23,6 +23,18 @@ export type IMessageSendResult = {
messageId: string;
};
function resolveMessageId(result: Record<string, unknown> | null | undefined): string | null {
if (!result) return null;
const raw =
(typeof result.messageId === "string" && result.messageId.trim()) ||
(typeof result.message_id === "string" && result.message_id.trim()) ||
(typeof result.id === "string" && result.id.trim()) ||
(typeof result.guid === "string" && result.guid.trim()) ||
(typeof result.message_id === "number" ? String(result.message_id) : null) ||
(typeof result.id === "number" ? String(result.id) : null);
return raw ? String(raw).trim() : null;
}
async function resolveAttachment(
mediaUrl: string,
maxBytes: number,
@@ -97,11 +109,12 @@ export async function sendMessageIMessage(
const client = opts.client ?? (await createIMessageRpcClient({ cliPath, dbPath }));
const shouldClose = !opts.client;
try {
const result = await client.request<{ ok?: boolean }>("send", params, {
const result = await client.request<Record<string, unknown>>("send", params, {
timeoutMs: opts.timeoutMs,
});
const resolvedId = resolveMessageId(result);
return {
messageId: result?.ok ? "ok" : "unknown",
messageId: resolvedId ?? (result?.ok ? "ok" : "unknown"),
};
} finally {
if (shouldClose) {