feat: implement short ID mapping for BlueBubbles messages and enhance reply context caching

- Added functionality to resolve short message IDs to full UUIDs and vice versa, optimizing token usage.
- Introduced a reply cache to store message context for replies when metadata is omitted in webhook payloads.
- Updated message handling to utilize short IDs for outbound messages and replies, improving efficiency.
- Enhanced error messages to clarify required parameters for actions like react, edit, and unsend.
- Added tests to ensure correct behavior of new features and maintain existing functionality.
This commit is contained in:
Tyler Yust
2026-01-21 00:14:55 -08:00
parent 89c5035aa2
commit b073deee20
10 changed files with 720 additions and 67 deletions

View File

@@ -14,6 +14,7 @@ import {
} from "clawdbot/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { resolveBlueBubblesMessageId } from "./monitor.js";
import { isMacOS26OrHigher } from "./probe.js";
import { sendBlueBubblesReaction } from "./reactions.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
@@ -77,7 +78,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
return { to, accountId };
},
handleAction: async ({ action, params, cfg, accountId }) => {
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
const account = resolveBlueBubblesAccount({
cfg: cfg as ClawdbotConfig,
accountId: accountId ?? undefined,
@@ -86,7 +87,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
const password = account.config.password?.trim();
const opts = { cfg: cfg as ClawdbotConfig, accountId: accountId ?? undefined };
// Helper to resolve chatGuid from various params
// Helper to resolve chatGuid from various params or session context
const resolveChatGuid = async (): Promise<string> => {
const chatGuid = readStringParam(params, "chatGuid");
if (chatGuid?.trim()) return chatGuid.trim();
@@ -94,6 +95,8 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
const chatIdentifier = readStringParam(params, "chatIdentifier");
const chatId = readNumberParam(params, "chatId", { integer: true });
const to = readStringParam(params, "to");
// Fall back to session context if no explicit target provided
const contextTarget = toolContext?.currentChannelId?.trim();
const target = chatIdentifier?.trim()
? ({
@@ -104,7 +107,9 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget)
: to
? mapTarget(to)
: null;
: contextTarget
? mapTarget(contextTarget)
: null;
if (!target) {
throw new Error(`BlueBubbles ${action} requires chatGuid, chatIdentifier, chatId, or to.`);
@@ -127,16 +132,18 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
});
if (isEmpty && !remove) {
throw new Error(
"BlueBubbles react requires emoji parameter. Use action=react with emoji=<emoji> and messageId=<message_guid>.",
"BlueBubbles react requires emoji parameter. Use action=react with emoji=<emoji> and messageId=<message_id>.",
);
}
const messageId = readStringParam(params, "messageId");
if (!messageId) {
const rawMessageId = readStringParam(params, "messageId");
if (!rawMessageId) {
throw new Error(
"BlueBubbles react requires messageId parameter (the message GUID to react to). " +
"Use action=react with messageId=<message_guid>, emoji=<emoji>, and to/chatGuid to identify the chat.",
"BlueBubbles react requires messageId parameter (the message ID to react to). " +
"Use action=react with messageId=<message_id>, emoji=<emoji>, and to/chatGuid to identify the chat.",
);
}
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = resolveBlueBubblesMessageId(rawMessageId);
const partIndex = readNumberParam(params, "partIndex", { integer: true });
const resolvedChatGuid = await resolveChatGuid();
@@ -161,20 +168,22 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
"Apple removed the ability to edit iMessages in this version.",
);
}
const messageId = readStringParam(params, "messageId");
const rawMessageId = readStringParam(params, "messageId");
const newText =
readStringParam(params, "text") ??
readStringParam(params, "newText") ??
readStringParam(params, "message");
if (!messageId || !newText) {
if (!rawMessageId || !newText) {
const missing: string[] = [];
if (!messageId) missing.push("messageId (the message GUID to edit)");
if (!rawMessageId) missing.push("messageId (the message ID to edit)");
if (!newText) missing.push("text (the new message content)");
throw new Error(
`BlueBubbles edit requires: ${missing.join(", ")}. ` +
`Use action=edit with messageId=<message_guid>, text=<new_content>.`,
`Use action=edit with messageId=<message_id>, text=<new_content>.`,
);
}
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = resolveBlueBubblesMessageId(rawMessageId);
const partIndex = readNumberParam(params, "partIndex", { integer: true });
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
@@ -184,18 +193,20 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
backwardsCompatMessage: backwardsCompatMessage ?? undefined,
});
return jsonResult({ ok: true, edited: messageId });
return jsonResult({ ok: true, edited: rawMessageId });
}
// Handle unsend action
if (action === "unsend") {
const messageId = readStringParam(params, "messageId");
if (!messageId) {
const rawMessageId = readStringParam(params, "messageId");
if (!rawMessageId) {
throw new Error(
"BlueBubbles unsend requires messageId parameter (the message GUID to unsend). " +
"Use action=unsend with messageId=<message_guid>.",
"BlueBubbles unsend requires messageId parameter (the message ID to unsend). " +
"Use action=unsend with messageId=<message_id>.",
);
}
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = resolveBlueBubblesMessageId(rawMessageId);
const partIndex = readNumberParam(params, "partIndex", { integer: true });
await unsendBlueBubblesMessage(messageId, {
@@ -203,24 +214,26 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
partIndex: typeof partIndex === "number" ? partIndex : undefined,
});
return jsonResult({ ok: true, unsent: messageId });
return jsonResult({ ok: true, unsent: rawMessageId });
}
// Handle reply action
if (action === "reply") {
const messageId = readStringParam(params, "messageId");
const rawMessageId = readStringParam(params, "messageId");
const text = readMessageText(params);
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
if (!messageId || !text || !to) {
if (!rawMessageId || !text || !to) {
const missing: string[] = [];
if (!messageId) missing.push("messageId (the message GUID to reply to)");
if (!rawMessageId) missing.push("messageId (the message ID to reply to)");
if (!text) missing.push("text or message (the reply message content)");
if (!to) missing.push("to or target (the chat target)");
throw new Error(
`BlueBubbles reply requires: ${missing.join(", ")}. ` +
`Use action=reply with messageId=<message_guid>, message=<your reply>, target=<chat_target>.`,
`Use action=reply with messageId=<message_id>, message=<your reply>, target=<chat_target>.`,
);
}
// Resolve short ID (e.g., "1", "2") to full UUID
const messageId = resolveBlueBubblesMessageId(rawMessageId);
const partIndex = readNumberParam(params, "partIndex", { integer: true });
const result = await sendMessageBlueBubbles(to, text, {
@@ -229,7 +242,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined,
});
return jsonResult({ ok: true, messageId: result.messageId, repliedTo: messageId });
return jsonResult({ ok: true, messageId: result.messageId, repliedTo: rawMessageId });
}
// Handle sendWithEffect action

View File

@@ -6,6 +6,8 @@ import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
import {
handleBlueBubblesWebhookRequest,
registerBlueBubblesWebhookTarget,
resolveBlueBubblesMessageId,
_resetBlueBubblesShortIdState,
} from "./monitor.js";
import { setBlueBubblesRuntime } from "./runtime.js";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
@@ -223,6 +225,8 @@ describe("BlueBubbles webhook monitor", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset short ID state between tests for predictable behavior
_resetBlueBubblesShortIdState();
mockReadAllowFromStore.mockResolvedValue([]);
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
mockResolveRequireMention.mockReturnValue(false);
@@ -467,6 +471,98 @@ describe("BlueBubbles webhook monitor", () => {
expect(handled).toBe(false);
});
it("parses chatId when provided as a string (webhook variant)", async () => {
const { resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(resolveChatGuidForTarget).mockClear();
const account = createMockAccount({ groupPolicy: "open" });
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello from group",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "msg-1",
chatId: "123",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
expect.objectContaining({
target: { kind: "chat_id", chatId: 123 },
}),
);
});
it("extracts chatGuid from nested chat object fields (webhook variant)", async () => {
const { sendMessageBlueBubbles, resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(sendMessageBlueBubbles).mockClear();
vi.mocked(resolveChatGuidForTarget).mockClear();
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
});
const account = createMockAccount({ groupPolicy: "open" });
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello from group",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "msg-1",
chat: { chatGuid: "iMessage;+;chat123456" },
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
"chat_guid:iMessage;+;chat123456",
expect.any(String),
expect.any(Object),
);
});
});
describe("DM pairing behavior vs allowFrom", () => {
@@ -1075,13 +1171,85 @@ describe("BlueBubbles webhook monitor", () => {
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
// ReplyToId is the full UUID since it wasn't previously cached
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
expect(callArgs.ctx.ReplyToBody).toBe("original message");
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
// Body still uses the full UUID since it wasn't cached
expect(callArgs.ctx.Body).toContain("[Replying to +15550000000 id:msg-0]");
expect(callArgs.ctx.Body).toContain("original message");
});
it("hydrates missing reply sender/body from the recent-message cache", async () => {
const account = createMockAccount({ dmPolicy: "open", groupPolicy: "open" });
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const chatGuid = "iMessage;+;chat-reply-cache";
const originalPayload = {
type: "new-message",
data: {
text: "original message (cached)",
handle: { address: "+15550000000" },
isGroup: true,
isFromMe: false,
guid: "cache-msg-0",
chatGuid,
date: Date.now(),
},
};
const originalReq = createMockRequest("POST", "/bluebubbles-webhook", originalPayload);
const originalRes = createMockResponse();
await handleBlueBubblesWebhookRequest(originalReq, originalRes);
await new Promise((resolve) => setTimeout(resolve, 50));
// Only assert the reply message behavior below.
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
const replyPayload = {
type: "new-message",
data: {
text: "replying now",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "cache-msg-1",
chatGuid,
// Only the GUID is provided; sender/body must be hydrated.
replyToMessageGuid: "cache-msg-0",
date: Date.now(),
},
};
const replyReq = createMockRequest("POST", "/bluebubbles-webhook", replyPayload);
const replyRes = createMockResponse();
await handleBlueBubblesWebhookRequest(replyReq, replyRes);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
// ReplyToId uses short ID "1" (first cached message) for token savings
expect(callArgs.ctx.ReplyToId).toBe("1");
expect(callArgs.ctx.ReplyToBody).toBe("original message (cached)");
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
// Body uses short ID for token savings
expect(callArgs.ctx.Body).toContain("[Replying to +15550000000 id:1]");
expect(callArgs.ctx.Body).toContain("original message (cached)");
});
it("falls back to threadOriginatorGuid when reply metadata is absent", async () => {
const account = createMockAccount({ dmPolicy: "open" });
const config: ClawdbotConfig = {};
@@ -1436,8 +1604,9 @@ describe("BlueBubbles webhook monitor", () => {
await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50));
// Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2")
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
"BlueBubbles sent message id: msg-123",
'Assistant sent "replying now" [message_id:2]',
expect.objectContaining({
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
}),
@@ -1605,6 +1774,92 @@ describe("BlueBubbles webhook monitor", () => {
});
});
describe("short message ID mapping", () => {
it("assigns sequential short IDs to messages", async () => {
const account = createMockAccount({ dmPolicy: "open" });
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-uuid-12345",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
// MessageSid should be short ID "1" instead of full UUID
expect(callArgs.ctx.MessageSid).toBe("1");
});
it("resolves short ID back to UUID", async () => {
const account = createMockAccount({ dmPolicy: "open" });
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-uuid-12345",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50));
// The short ID "1" should resolve back to the full UUID
expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345");
});
it("returns UUID unchanged when not in cache", () => {
expect(resolveBlueBubblesMessageId("msg-not-cached")).toBe("msg-not-cached");
});
it("returns short ID unchanged when numeric but not in cache", () => {
expect(resolveBlueBubblesMessageId("999")).toBe("999");
});
});
describe("fromMe messages", () => {
it("ignores messages from self (fromMe=true)", async () => {
const account = createMockAccount();

View File

@@ -31,6 +31,165 @@ const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
const DEFAULT_TEXT_LIMIT = 4000;
const invalidAckReactions = new Set<string>();
const REPLY_CACHE_MAX = 2000;
const REPLY_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
type BlueBubblesReplyCacheEntry = {
accountId: string;
messageId: string;
shortId: string;
chatGuid?: string;
chatIdentifier?: string;
chatId?: number;
senderLabel?: string;
body?: string;
timestamp: number;
};
// Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body.
const blueBubblesReplyCacheByMessageId = new Map<string, BlueBubblesReplyCacheEntry>();
// Bidirectional maps for short ID ↔ UUID resolution (token savings optimization)
const blueBubblesShortIdToUuid = new Map<string, string>();
const blueBubblesUuidToShortId = new Map<string, string>();
let blueBubblesShortIdCounter = 0;
function trimOrUndefined(value?: string | null): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function generateShortId(): string {
blueBubblesShortIdCounter += 1;
return String(blueBubblesShortIdCounter);
}
function rememberBlueBubblesReplyCache(
entry: Omit<BlueBubblesReplyCacheEntry, "shortId">,
): BlueBubblesReplyCacheEntry {
const messageId = entry.messageId.trim();
if (!messageId) {
return { ...entry, shortId: "" };
}
// Check if we already have a short ID for this UUID
let shortId = blueBubblesUuidToShortId.get(messageId);
if (!shortId) {
shortId = generateShortId();
blueBubblesShortIdToUuid.set(shortId, messageId);
blueBubblesUuidToShortId.set(messageId, shortId);
}
const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, shortId };
// Refresh insertion order.
blueBubblesReplyCacheByMessageId.delete(messageId);
blueBubblesReplyCacheByMessageId.set(messageId, fullEntry);
// Opportunistic prune.
const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
for (const [key, value] of blueBubblesReplyCacheByMessageId) {
if (value.timestamp < cutoff) {
blueBubblesReplyCacheByMessageId.delete(key);
// Clean up short ID mappings for expired entries
if (value.shortId) {
blueBubblesShortIdToUuid.delete(value.shortId);
blueBubblesUuidToShortId.delete(key);
}
continue;
}
break;
}
while (blueBubblesReplyCacheByMessageId.size > REPLY_CACHE_MAX) {
const oldest = blueBubblesReplyCacheByMessageId.keys().next().value as string | undefined;
if (!oldest) break;
const oldEntry = blueBubblesReplyCacheByMessageId.get(oldest);
blueBubblesReplyCacheByMessageId.delete(oldest);
// Clean up short ID mappings for evicted entries
if (oldEntry?.shortId) {
blueBubblesShortIdToUuid.delete(oldEntry.shortId);
blueBubblesUuidToShortId.delete(oldest);
}
}
return fullEntry;
}
/**
* Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles UUID.
* Returns the input unchanged if it's already a UUID or not found in the mapping.
*/
export function resolveBlueBubblesMessageId(shortOrUuid: string): string {
const trimmed = shortOrUuid.trim();
if (!trimmed) return trimmed;
// If it looks like a short ID (numeric), try to resolve it
if (/^\d+$/.test(trimmed)) {
const uuid = blueBubblesShortIdToUuid.get(trimmed);
if (uuid) return uuid;
}
// Return as-is (either already a UUID or not found)
return trimmed;
}
/**
* Resets the short ID state. Only use in tests.
* @internal
*/
export function _resetBlueBubblesShortIdState(): void {
blueBubblesShortIdToUuid.clear();
blueBubblesUuidToShortId.clear();
blueBubblesReplyCacheByMessageId.clear();
blueBubblesShortIdCounter = 0;
}
/**
* Gets the short ID for a UUID, if one exists.
*/
function getShortIdForUuid(uuid: string): string | undefined {
return blueBubblesUuidToShortId.get(uuid.trim());
}
function resolveReplyContextFromCache(params: {
accountId: string;
replyToId: string;
chatGuid?: string;
chatIdentifier?: string;
chatId?: number;
}): BlueBubblesReplyCacheEntry | null {
const replyToId = params.replyToId.trim();
if (!replyToId) return null;
const cached = blueBubblesReplyCacheByMessageId.get(replyToId);
if (!cached) return null;
if (cached.accountId !== params.accountId) return null;
const cutoff = Date.now() - REPLY_CACHE_TTL_MS;
if (cached.timestamp < cutoff) {
blueBubblesReplyCacheByMessageId.delete(replyToId);
return null;
}
const chatGuid = trimOrUndefined(params.chatGuid);
const chatIdentifier = trimOrUndefined(params.chatIdentifier);
const cachedChatGuid = trimOrUndefined(cached.chatGuid);
const cachedChatIdentifier = trimOrUndefined(cached.chatIdentifier);
const chatId = typeof params.chatId === "number" ? params.chatId : undefined;
const cachedChatId = typeof cached.chatId === "number" ? cached.chatId : undefined;
// Avoid cross-chat collisions if we have identifiers.
if (chatGuid && cachedChatGuid && chatGuid !== cachedChatGuid) return null;
if (!chatGuid && chatIdentifier && cachedChatIdentifier && chatIdentifier !== cachedChatIdentifier) {
return null;
}
if (!chatGuid && !chatIdentifier && chatId && cachedChatId && chatId !== cachedChatId) {
return null;
}
return cached;
}
type BlueBubblesCoreRuntime = ReturnType<typeof getBlueBubblesRuntime>;
function logVerbose(core: BlueBubblesCoreRuntime, runtime: BlueBubblesRuntimeEnv, message: string): void {
@@ -219,12 +378,15 @@ function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
function formatReplyContext(message: {
replyToId?: string;
replyToShortId?: string;
replyToBody?: string;
replyToSender?: string;
}): string | null {
if (!message.replyToId && !message.replyToBody && !message.replyToSender) return null;
const sender = message.replyToSender?.trim() || "unknown sender";
const idPart = message.replyToId ? ` id:${message.replyToId}` : "";
// Prefer short ID for token savings
const displayId = message.replyToShortId || message.replyToId;
const idPart = displayId ? ` id:${displayId}` : "";
const body = message.replyToBody?.trim();
if (!body) {
return `[Replying to ${sender}${idPart}]\n[/Replying]`;
@@ -404,6 +566,15 @@ function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undef
return undefined;
}
function extractChatIdentifierFromChatGuid(chatGuid?: string | null): string | undefined {
const guid = chatGuid?.trim();
if (!guid) return undefined;
const parts = guid.split(";");
if (parts.length < 3) return undefined;
const identifier = parts[2]?.trim();
return identifier || undefined;
}
function formatGroupAllowlistEntry(params: {
chatGuid?: string;
chatId?: number;
@@ -550,20 +721,31 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
const chatGuid =
readString(message, "chatGuid") ??
readString(message, "chat_guid") ??
readString(chat, "chatGuid") ??
readString(chat, "chat_guid") ??
readString(chat, "guid") ??
readString(chatFromList, "chatGuid") ??
readString(chatFromList, "chat_guid") ??
readString(chatFromList, "guid");
const chatIdentifier =
readString(message, "chatIdentifier") ??
readString(message, "chat_identifier") ??
readString(chat, "chatIdentifier") ??
readString(chat, "chat_identifier") ??
readString(chat, "identifier") ??
readString(chatFromList, "chatIdentifier") ??
readString(chatFromList, "chat_identifier") ??
readString(chatFromList, "identifier");
readString(chatFromList, "identifier") ??
extractChatIdentifierFromChatGuid(chatGuid);
const chatId =
readNumber(message, "chatId") ??
readNumber(message, "chat_id") ??
readNumber(chat, "id") ??
readNumber(chatFromList, "id");
readNumberLike(message, "chatId") ??
readNumberLike(message, "chat_id") ??
readNumberLike(chat, "chatId") ??
readNumberLike(chat, "chat_id") ??
readNumberLike(chat, "id") ??
readNumberLike(chatFromList, "chatId") ??
readNumberLike(chatFromList, "chat_id") ??
readNumberLike(chatFromList, "id");
const chatName =
readString(message, "chatName") ??
readString(chat, "displayName") ??
@@ -679,19 +861,30 @@ function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedW
const chatGuid =
readString(message, "chatGuid") ??
readString(message, "chat_guid") ??
readString(chat, "chatGuid") ??
readString(chat, "chat_guid") ??
readString(chat, "guid") ??
readString(chatFromList, "chatGuid") ??
readString(chatFromList, "chat_guid") ??
readString(chatFromList, "guid");
const chatIdentifier =
readString(message, "chatIdentifier") ??
readString(message, "chat_identifier") ??
readString(chat, "chatIdentifier") ??
readString(chat, "chat_identifier") ??
readString(chat, "identifier") ??
readString(chatFromList, "chatIdentifier") ??
readString(chatFromList, "chat_identifier") ??
readString(chatFromList, "identifier");
readString(chatFromList, "identifier") ??
extractChatIdentifierFromChatGuid(chatGuid);
const chatId =
readNumberLike(message, "chatId") ??
readNumberLike(message, "chat_id") ??
readNumberLike(chat, "chatId") ??
readNumberLike(chat, "chat_id") ??
readNumberLike(chat, "id") ??
readNumberLike(chatFromList, "chatId") ??
readNumberLike(chatFromList, "chat_id") ??
readNumberLike(chatFromList, "id");
const chatName =
readString(message, "chatName") ??
@@ -901,14 +1094,36 @@ async function processMessage(
target: WebhookTarget,
): Promise<void> {
const { account, config, runtime, core, statusSink } = target;
if (message.fromMe) return;
const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
const text = message.text.trim();
const attachments = message.attachments ?? [];
const placeholder = buildMessagePlaceholder(message);
if (!text && !placeholder) {
const rawBody = text || placeholder;
// Cache messages (including fromMe) so later replies can resolve sender/body even when
// BlueBubbles webhook payloads omit nested reply metadata.
const cacheMessageId = message.messageId?.trim();
let messageShortId: string | undefined;
if (cacheMessageId) {
const cacheEntry = rememberBlueBubblesReplyCache({
accountId: account.accountId,
messageId: cacheMessageId,
chatGuid: message.chatGuid,
chatIdentifier: message.chatIdentifier,
chatId: message.chatId,
senderLabel: message.fromMe ? "me" : message.senderId,
body: rawBody,
timestamp: message.timestamp ?? Date.now(),
});
messageShortId = cacheEntry.shortId;
}
if (message.fromMe) return;
if (!rawBody) {
logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`);
return;
}
@@ -1199,12 +1414,42 @@ async function processMessage(
}
}
}
const rawBody = text.trim() || placeholder;
const replyContext = formatReplyContext(message);
let replyToId = message.replyToId;
let replyToBody = message.replyToBody;
let replyToSender = message.replyToSender;
let replyToShortId: string | undefined;
if (replyToId && (!replyToBody || !replyToSender)) {
const cached = resolveReplyContextFromCache({
accountId: account.accountId,
replyToId,
chatGuid: message.chatGuid,
chatIdentifier: message.chatIdentifier,
chatId: message.chatId,
});
if (cached) {
if (!replyToBody && cached.body) replyToBody = cached.body;
if (!replyToSender && cached.senderLabel) replyToSender = cached.senderLabel;
replyToShortId = cached.shortId;
if (core.logging.shouldLogVerbose()) {
const preview = (cached.body ?? "").replace(/\s+/g, " ").slice(0, 120);
logVerbose(
core,
runtime,
`reply-context cache hit replyToId=${replyToId} sender=${replyToSender ?? ""} body="${preview}"`,
);
}
}
}
// If no cached short ID, try to get one from the UUID directly
if (replyToId && !replyToShortId) {
replyToShortId = getShortIdForUuid(replyToId);
}
const replyContext = formatReplyContext({ replyToId, replyToShortId, replyToBody, replyToSender });
const baseBody = replyContext ? `${rawBody}\n\n${replyContext}` : rawBody;
const fromLabel = isGroup
? `group:${peerId}`
: message.senderName || `user:${message.senderId}`;
const fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`;
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
const groupMembers = isGroup
? formatGroupMembers({
@@ -1230,12 +1475,12 @@ async function processMessage(
});
let chatGuidForActions = chatGuid;
if (!chatGuidForActions && baseUrl && password) {
const target =
const target =
isGroup && (chatId || chatIdentifier)
? chatId
? { kind: "chat_id", chatId }
: { kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" }
: { kind: "handle", address: message.senderId };
? ({ kind: "chat_id", chatId } as const)
: ({ kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" } as const)
: ({ kind: "handle", address: message.senderId } as const);
if (target.kind !== "chat_identifier" || target.chatIdentifier) {
chatGuidForActions =
(await resolveChatGuidForTarget({
@@ -1316,10 +1561,23 @@ async function processMessage(
? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
: message.senderId;
const maybeEnqueueOutboundMessageId = (messageId?: string) => {
const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string) => {
const trimmed = messageId?.trim();
if (!trimmed || trimmed === "ok" || trimmed === "unknown") return;
core.system.enqueueSystemEvent(`BlueBubbles sent message id: ${trimmed}`, {
// Cache outbound message to get short ID
const cacheEntry = rememberBlueBubblesReplyCache({
accountId: account.accountId,
messageId: trimmed,
chatGuid: chatGuidForActions ?? chatGuid,
chatIdentifier,
chatId,
senderLabel: "me",
body: snippet ?? "",
timestamp: Date.now(),
});
const displayId = cacheEntry.shortId || trimmed;
const preview = snippet ? ` "${snippet.slice(0, 12)}${snippet.length > 12 ? "…" : ""}"` : "";
core.system.enqueueSystemEvent(`Assistant sent${preview} [message_id:${displayId}]`, {
sessionKey: route.sessionKey,
contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`,
});
@@ -1343,16 +1601,18 @@ async function processMessage(
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
ConversationLabel: fromLabel,
ReplyToId: message.replyToId,
ReplyToBody: message.replyToBody,
ReplyToSender: message.replyToSender,
// Use short ID for token savings (agent can use this to reference the message)
ReplyToId: replyToShortId || replyToId,
ReplyToBody: replyToBody,
ReplyToSender: replyToSender,
GroupSubject: groupSubject,
GroupMembers: groupMembers,
SenderName: message.senderName || undefined,
SenderId: message.senderId,
Provider: "bluebubbles",
Surface: "bluebubbles",
MessageSid: message.messageId,
// Use short ID for token savings (agent can use this to reference the message)
MessageSid: messageShortId || message.messageId,
Timestamp: message.timestamp,
OriginatingChannel: "bluebubbles",
OriginatingTo: `bluebubbles:${outboundTarget}`,
@@ -1385,7 +1645,8 @@ async function processMessage(
replyToId: payload.replyToId ?? null,
accountId: account.accountId,
});
maybeEnqueueOutboundMessageId(result.messageId);
const cachedBody = (caption ?? "").trim() || "<media:attachment>";
maybeEnqueueOutboundMessageId(result.messageId, cachedBody);
sentMessage = true;
statusSink?.({ lastOutboundAt: Date.now() });
}
@@ -1407,7 +1668,7 @@ async function processMessage(
accountId: account.accountId,
replyToMessageGuid: replyToMessageGuid || undefined,
});
maybeEnqueueOutboundMessageId(result.messageId);
maybeEnqueueOutboundMessageId(result.messageId, chunk);
sentMessage = true;
statusSink?.({ lastOutboundAt: Date.now() });
}
@@ -1541,7 +1802,9 @@ async function processReaction(
const senderLabel = reaction.senderName || reaction.senderId;
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${reaction.messageId}`;
// Use short ID for token savings
const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId;
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${messageDisplayId}`;
core.system.enqueueSystemEvent(text, {
sessionKey: route.sessionKey,
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,

View File

@@ -20,32 +20,101 @@ const REACTION_TYPES = new Set([
]);
const REACTION_ALIASES = new Map<string, string>([
// General
["heart", "love"],
["love", "love"],
["❤", "love"],
["❤️", "love"],
["red_heart", "love"],
["thumbs_up", "like"],
["thumbs-down", "dislike"],
["thumbsup", "like"],
["thumbs-up", "like"],
["thumbsup", "like"],
["like", "like"],
["thumb", "like"],
["ok", "like"],
["thumbs_down", "dislike"],
["thumbsdown", "dislike"],
["thumbs-down", "dislike"],
["dislike", "dislike"],
["boo", "dislike"],
["no", "dislike"],
// Laugh
["haha", "laugh"],
["lol", "laugh"],
["lmao", "laugh"],
["rofl", "laugh"],
["😂", "laugh"],
["🤣", "laugh"],
["xd", "laugh"],
["laugh", "laugh"],
// Emphasize / exclaim
["emphasis", "emphasize"],
["emphasize", "emphasize"],
["exclaim", "emphasize"],
["!!", "emphasize"],
["‼", "emphasize"],
["‼️", "emphasize"],
["❗", "emphasize"],
["important", "emphasize"],
["bang", "emphasize"],
// Question
["question", "question"],
["?", "question"],
["❓", "question"],
["❔", "question"],
["ask", "question"],
// Apple/Messages names
["loved", "love"],
["liked", "like"],
["disliked", "dislike"],
["laughed", "laugh"],
["emphasized", "emphasize"],
["questioned", "question"],
// Colloquial / informal
["fire", "love"],
["🔥", "love"],
["wow", "emphasize"],
["!", "emphasize"],
// Edge: generic emoji name forms
["heart_eyes", "love"],
["smile", "laugh"],
["smiley", "laugh"],
["happy", "laugh"],
["joy", "laugh"],
]);
const REACTION_EMOJIS = new Map<string, string>([
// Love
["❤️", "love"],
["❤", "love"],
["♥️", "love"],
["♥", "love"],
["😍", "love"],
["💕", "love"],
// Like
["👍", "like"],
["👌", "like"],
// Dislike
["👎", "dislike"],
["🙅", "dislike"],
// Laugh
["😂", "laugh"],
["🤣", "laugh"],
["😆", "laugh"],
["😁", "laugh"],
["😹", "laugh"],
// Emphasize
["‼️", "emphasize"],
["‼", "emphasize"],
["!!", "emphasize"],
["❗", "emphasize"],
["❕", "emphasize"],
["!", "emphasize"],
// Question
["❓", "question"],
["❔", "question"],
["?", "question"],
]);
function resolveAccount(params: BlueBubblesReactionOpts) {

View File

@@ -96,6 +96,33 @@ describe("send", () => {
expect(result).toBe("iMessage;-;chat123");
});
it("matches chat_identifier against the 3rd component of chat GUID", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;+;chat660250192681427962",
participants: [],
},
],
}),
});
const target: BlueBubblesSendTarget = {
kind: "chat_identifier",
chatIdentifier: "chat660250192681427962",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("iMessage;+;chat660250192681427962");
});
it("resolves handle target by matching participant", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,

View File

@@ -133,6 +133,13 @@ function extractChatId(chat: BlueBubblesChatRecord): number | null {
return null;
}
function extractChatIdentifierFromChatGuid(chatGuid: string): string | null {
const parts = chatGuid.split(";");
if (parts.length < 3) return null;
const identifier = parts[2]?.trim();
return identifier ? identifier : null;
}
function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
const raw =
(Array.isArray(chat.participants) ? chat.participants : null) ??
@@ -223,7 +230,16 @@ export async function resolveChatGuidForTarget(params: {
}
if (targetChatIdentifier) {
const guid = extractChatGuid(chat);
if (guid && guid === targetChatIdentifier) return guid;
if (guid) {
// Back-compat: some callers might pass a full chat GUID.
if (guid === targetChatIdentifier) return guid;
// Primary match: BlueBubbles `chat_identifier:*` targets correspond to the
// third component of the chat GUID: `service;(+|-) ;identifier`.
const guidIdentifier = extractChatIdentifierFromChatGuid(guid);
if (guidIdentifier && guidIdentifier === targetChatIdentifier) return guid;
}
const identifier =
typeof chat.identifier === "string"
? chat.identifier
@@ -232,7 +248,7 @@ export async function resolveChatGuidForTarget(params: {
: typeof chat.chat_identifier === "string"
? chat.chat_identifier
: "";
if (identifier && identifier === targetChatIdentifier) return extractChatGuid(chat);
if (identifier && identifier === targetChatIdentifier) return guid ?? extractChatGuid(chat);
}
if (normalizedHandle) {
const guid = extractChatGuid(chat);