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:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
|
||||
const ACTION_TARGET_ALIASES: Partial<Record<ChannelMessageActionName, string[]>> = {
|
||||
unsend: ["messageId"],
|
||||
edit: ["messageId"],
|
||||
react: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
renameGroup: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
setGroupIcon: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
addParticipant: ["chatGuid", "chatIdentifier", "chatId"],
|
||||
|
||||
Reference in New Issue
Block a user