From b073deee20be18c107cfb7310edb8f41e8a178f1 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Wed, 21 Jan 2026 00:14:55 -0800 Subject: [PATCH] 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. --- extensions/bluebubbles/src/actions.ts | 59 ++-- extensions/bluebubbles/src/monitor.test.ts | 257 ++++++++++++++++- extensions/bluebubbles/src/monitor.ts | 317 +++++++++++++++++++-- extensions/bluebubbles/src/reactions.ts | 71 ++++- extensions/bluebubbles/src/send.test.ts | 27 ++ extensions/bluebubbles/src/send.ts | 20 +- src/auto-reply/reply/agent-runner-utils.ts | 24 +- src/auto-reply/reply/typing-mode.test.ts | 7 +- src/auto-reply/reply/typing-mode.ts | 4 +- src/infra/outbound/message-action-spec.ts | 1 + 10 files changed, 720 insertions(+), 67 deletions(-) diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index 1e69fbfa8..3630f91fa 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -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 => { 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= and messageId=.", + "BlueBubbles react requires emoji parameter. Use action=react with emoji= and messageId=.", ); } - 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=, emoji=, and to/chatGuid to identify the chat.", + "BlueBubbles react requires messageId parameter (the message ID to react to). " + + "Use action=react with messageId=, 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=, text=.`, + `Use action=edit with messageId=, text=.`, ); } + // 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=.", + "BlueBubbles unsend requires messageId parameter (the message ID to unsend). " + + "Use action=unsend with messageId=.", ); } + // 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=, target=.`, + `Use action=reply with messageId=, message=, 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 diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index e91e88611..14c896427 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -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(); diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 7133107cc..4c2b4a225 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -31,6 +31,165 @@ const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook"; const DEFAULT_TEXT_LIMIT = 4000; const invalidAckReactions = new Set(); +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(); + +// Bidirectional maps for short ID ↔ UUID resolution (token savings optimization) +const blueBubblesShortIdToUuid = new Map(); +const blueBubblesUuidToShortId = new Map(); +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 { + 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; 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): 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): 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 { 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() || ""; + 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}`, diff --git a/extensions/bluebubbles/src/reactions.ts b/extensions/bluebubbles/src/reactions.ts index d7a2beaa8..09176ef14 100644 --- a/extensions/bluebubbles/src/reactions.ts +++ b/extensions/bluebubbles/src/reactions.ts @@ -20,32 +20,101 @@ const REACTION_TYPES = new Set([ ]); const REACTION_ALIASES = new Map([ + // 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([ + // 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) { diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index f39abdd5e..0b8b77a1f 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -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, diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index 868184c42..675063d6d 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -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); diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index 62ce6ff71..b1b3ae890 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -1,6 +1,6 @@ import type { NormalizedUsage } from "../../agents/usage.js"; import { getChannelDock } from "../../channels/dock.js"; -import type { ChannelThreadingToolContext } from "../../channels/plugins/types.js"; +import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plugins/types.js"; import { normalizeChannelId } from "../../channels/registry.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js"; @@ -21,17 +21,25 @@ export function buildThreadingToolContext(params: { }): ChannelThreadingToolContext { const { sessionCtx, config, hasRepliedRef } = params; if (!config) return {}; - const provider = normalizeChannelId(sessionCtx.Provider); - if (!provider) return {}; - const dock = getChannelDock(provider); - if (!dock?.threading?.buildToolContext) return {}; + const rawProvider = sessionCtx.Provider?.trim().toLowerCase(); + if (!rawProvider) return {}; + const provider = normalizeChannelId(rawProvider); // WhatsApp context isolation keys off conversation id, not the bot's own number. const threadingTo = - provider === "whatsapp" + rawProvider === "whatsapp" ? (sessionCtx.From ?? sessionCtx.To) - : provider === "imessage" && sessionCtx.ChatType === "direct" + : rawProvider === "imessage" && sessionCtx.ChatType === "direct" ? (sessionCtx.From ?? sessionCtx.To) : sessionCtx.To; + // Fallback for unrecognized/plugin channels (e.g., BlueBubbles before plugin registry init) + const dock = provider ? getChannelDock(provider) : undefined; + if (!dock?.threading?.buildToolContext) { + return { + currentChannelId: threadingTo?.trim() || undefined, + currentChannelProvider: provider ?? (rawProvider as ChannelId), + hasRepliedRef, + }; + } const context = dock.threading.buildToolContext({ cfg: config, @@ -47,7 +55,7 @@ export function buildThreadingToolContext(params: { }) ?? {}; return { ...context, - currentChannelProvider: provider, + currentChannelProvider: provider!, // guaranteed non-null since dock exists }; } diff --git a/src/auto-reply/reply/typing-mode.test.ts b/src/auto-reply/reply/typing-mode.test.ts index 766cbe803..064e58adf 100644 --- a/src/auto-reply/reply/typing-mode.test.ts +++ b/src/auto-reply/reply/typing-mode.test.ts @@ -140,7 +140,7 @@ describe("createTypingSignaler", () => { expect(typing.startTypingOnText).not.toHaveBeenCalled(); }); - it("does not start typing on tool start before text", async () => { + it("starts typing on tool start before text", async () => { const typing = createMockTypingController(); const signaler = createTypingSignaler({ typing, @@ -150,8 +150,9 @@ describe("createTypingSignaler", () => { await signaler.signalToolStart(); - expect(typing.startTypingLoop).not.toHaveBeenCalled(); - expect(typing.refreshTypingTtl).not.toHaveBeenCalled(); + expect(typing.startTypingLoop).toHaveBeenCalled(); + expect(typing.refreshTypingTtl).toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); }); it("refreshes ttl on tool start when active after text", async () => { diff --git a/src/auto-reply/reply/typing-mode.ts b/src/auto-reply/reply/typing-mode.ts index b2e62d8c4..0d73a3794 100644 --- a/src/auto-reply/reply/typing-mode.ts +++ b/src/auto-reply/reply/typing-mode.ts @@ -95,13 +95,13 @@ export function createTypingSignaler(params: { const signalToolStart = async () => { if (disabled) return; - if (!hasRenderableText) return; + // Start typing as soon as tools begin executing, even before the first text delta. if (!typing.isActive()) { await typing.startTypingLoop(); typing.refreshTypingTtl(); return; } - // Keep typing indicator alive during tool execution without changing mode semantics. + // Keep typing indicator alive during tool execution. typing.refreshTypingTtl(); }; diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index 782f8dc8b..c4f712e0f 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -57,6 +57,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record> = { unsend: ["messageId"], edit: ["messageId"], + react: ["chatGuid", "chatIdentifier", "chatId"], renameGroup: ["chatGuid", "chatIdentifier", "chatId"], setGroupIcon: ["chatGuid", "chatIdentifier", "chatId"], addParticipant: ["chatGuid", "chatIdentifier", "chatId"],