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:
parent
89c5035aa2
commit
b073deee20
@ -14,6 +14,7 @@ import {
|
|||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
|
|
||||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||||
|
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||||
import { isMacOS26OrHigher } from "./probe.js";
|
import { isMacOS26OrHigher } from "./probe.js";
|
||||||
import { sendBlueBubblesReaction } from "./reactions.js";
|
import { sendBlueBubblesReaction } from "./reactions.js";
|
||||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.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;
|
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
|
||||||
return { to, accountId };
|
return { to, accountId };
|
||||||
},
|
},
|
||||||
handleAction: async ({ action, params, cfg, accountId }) => {
|
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
||||||
const account = resolveBlueBubblesAccount({
|
const account = resolveBlueBubblesAccount({
|
||||||
cfg: cfg as ClawdbotConfig,
|
cfg: cfg as ClawdbotConfig,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
@ -86,7 +87,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
const password = account.config.password?.trim();
|
const password = account.config.password?.trim();
|
||||||
const opts = { cfg: cfg as ClawdbotConfig, accountId: accountId ?? undefined };
|
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 resolveChatGuid = async (): Promise<string> => {
|
||||||
const chatGuid = readStringParam(params, "chatGuid");
|
const chatGuid = readStringParam(params, "chatGuid");
|
||||||
if (chatGuid?.trim()) return chatGuid.trim();
|
if (chatGuid?.trim()) return chatGuid.trim();
|
||||||
@ -94,6 +95,8 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
const chatIdentifier = readStringParam(params, "chatIdentifier");
|
const chatIdentifier = readStringParam(params, "chatIdentifier");
|
||||||
const chatId = readNumberParam(params, "chatId", { integer: true });
|
const chatId = readNumberParam(params, "chatId", { integer: true });
|
||||||
const to = readStringParam(params, "to");
|
const to = readStringParam(params, "to");
|
||||||
|
// Fall back to session context if no explicit target provided
|
||||||
|
const contextTarget = toolContext?.currentChannelId?.trim();
|
||||||
|
|
||||||
const target = chatIdentifier?.trim()
|
const target = chatIdentifier?.trim()
|
||||||
? ({
|
? ({
|
||||||
@ -104,7 +107,9 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget)
|
? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget)
|
||||||
: to
|
: to
|
||||||
? mapTarget(to)
|
? mapTarget(to)
|
||||||
: null;
|
: contextTarget
|
||||||
|
? mapTarget(contextTarget)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
throw new Error(`BlueBubbles ${action} requires chatGuid, chatIdentifier, chatId, or to.`);
|
throw new Error(`BlueBubbles ${action} requires chatGuid, chatIdentifier, chatId, or to.`);
|
||||||
@ -127,16 +132,18 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
});
|
});
|
||||||
if (isEmpty && !remove) {
|
if (isEmpty && !remove) {
|
||||||
throw new Error(
|
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");
|
const rawMessageId = readStringParam(params, "messageId");
|
||||||
if (!messageId) {
|
if (!rawMessageId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"BlueBubbles react requires messageId parameter (the message GUID to react to). " +
|
"BlueBubbles react requires messageId parameter (the message ID to react to). " +
|
||||||
"Use action=react with messageId=<message_guid>, emoji=<emoji>, and to/chatGuid to identify the chat.",
|
"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 partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
const resolvedChatGuid = await resolveChatGuid();
|
const resolvedChatGuid = await resolveChatGuid();
|
||||||
|
|
||||||
@ -161,20 +168,22 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
"Apple removed the ability to edit iMessages in this version.",
|
"Apple removed the ability to edit iMessages in this version.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const messageId = readStringParam(params, "messageId");
|
const rawMessageId = readStringParam(params, "messageId");
|
||||||
const newText =
|
const newText =
|
||||||
readStringParam(params, "text") ??
|
readStringParam(params, "text") ??
|
||||||
readStringParam(params, "newText") ??
|
readStringParam(params, "newText") ??
|
||||||
readStringParam(params, "message");
|
readStringParam(params, "message");
|
||||||
if (!messageId || !newText) {
|
if (!rawMessageId || !newText) {
|
||||||
const missing: string[] = [];
|
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)");
|
if (!newText) missing.push("text (the new message content)");
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`BlueBubbles edit requires: ${missing.join(", ")}. ` +
|
`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 partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
|
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
|
||||||
|
|
||||||
@ -184,18 +193,20 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
backwardsCompatMessage: backwardsCompatMessage ?? undefined,
|
backwardsCompatMessage: backwardsCompatMessage ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return jsonResult({ ok: true, edited: messageId });
|
return jsonResult({ ok: true, edited: rawMessageId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle unsend action
|
// Handle unsend action
|
||||||
if (action === "unsend") {
|
if (action === "unsend") {
|
||||||
const messageId = readStringParam(params, "messageId");
|
const rawMessageId = readStringParam(params, "messageId");
|
||||||
if (!messageId) {
|
if (!rawMessageId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"BlueBubbles unsend requires messageId parameter (the message GUID to unsend). " +
|
"BlueBubbles unsend requires messageId parameter (the message ID to unsend). " +
|
||||||
"Use action=unsend with messageId=<message_guid>.",
|
"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 });
|
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
|
|
||||||
await unsendBlueBubblesMessage(messageId, {
|
await unsendBlueBubblesMessage(messageId, {
|
||||||
@ -203,24 +214,26 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return jsonResult({ ok: true, unsent: messageId });
|
return jsonResult({ ok: true, unsent: rawMessageId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle reply action
|
// Handle reply action
|
||||||
if (action === "reply") {
|
if (action === "reply") {
|
||||||
const messageId = readStringParam(params, "messageId");
|
const rawMessageId = readStringParam(params, "messageId");
|
||||||
const text = readMessageText(params);
|
const text = readMessageText(params);
|
||||||
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
|
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
|
||||||
if (!messageId || !text || !to) {
|
if (!rawMessageId || !text || !to) {
|
||||||
const missing: string[] = [];
|
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 (!text) missing.push("text or message (the reply message content)");
|
||||||
if (!to) missing.push("to or target (the chat target)");
|
if (!to) missing.push("to or target (the chat target)");
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`BlueBubbles reply requires: ${missing.join(", ")}. ` +
|
`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 partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
|
|
||||||
const result = await sendMessageBlueBubbles(to, text, {
|
const result = await sendMessageBlueBubbles(to, text, {
|
||||||
@ -229,7 +242,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined,
|
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
|
// Handle sendWithEffect action
|
||||||
|
|||||||
@ -6,6 +6,8 @@ import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
|||||||
import {
|
import {
|
||||||
handleBlueBubblesWebhookRequest,
|
handleBlueBubblesWebhookRequest,
|
||||||
registerBlueBubblesWebhookTarget,
|
registerBlueBubblesWebhookTarget,
|
||||||
|
resolveBlueBubblesMessageId,
|
||||||
|
_resetBlueBubblesShortIdState,
|
||||||
} from "./monitor.js";
|
} from "./monitor.js";
|
||||||
import { setBlueBubblesRuntime } from "./runtime.js";
|
import { setBlueBubblesRuntime } from "./runtime.js";
|
||||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||||
@ -223,6 +225,8 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// Reset short ID state between tests for predictable behavior
|
||||||
|
_resetBlueBubblesShortIdState();
|
||||||
mockReadAllowFromStore.mockResolvedValue([]);
|
mockReadAllowFromStore.mockResolvedValue([]);
|
||||||
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
|
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
|
||||||
mockResolveRequireMention.mockReturnValue(false);
|
mockResolveRequireMention.mockReturnValue(false);
|
||||||
@ -467,6 +471,98 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
|
|
||||||
expect(handled).toBe(false);
|
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", () => {
|
describe("DM pairing behavior vs allowFrom", () => {
|
||||||
@ -1075,13 +1171,85 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
|
|
||||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
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.ReplyToId).toBe("msg-0");
|
||||||
expect(callArgs.ctx.ReplyToBody).toBe("original message");
|
expect(callArgs.ctx.ReplyToBody).toBe("original message");
|
||||||
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
|
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("[Replying to +15550000000 id:msg-0]");
|
||||||
expect(callArgs.ctx.Body).toContain("original message");
|
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 () => {
|
it("falls back to threadOriginatorGuid when reply metadata is absent", async () => {
|
||||||
const account = createMockAccount({ dmPolicy: "open" });
|
const account = createMockAccount({ dmPolicy: "open" });
|
||||||
const config: ClawdbotConfig = {};
|
const config: ClawdbotConfig = {};
|
||||||
@ -1436,8 +1604,9 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
await handleBlueBubblesWebhookRequest(req, res);
|
await handleBlueBubblesWebhookRequest(req, res);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
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(
|
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||||
"BlueBubbles sent message id: msg-123",
|
'Assistant sent "replying now" [message_id:2]',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
sessionKey: "agent:main:bluebubbles:dm:+15551234567",
|
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", () => {
|
describe("fromMe messages", () => {
|
||||||
it("ignores messages from self (fromMe=true)", async () => {
|
it("ignores messages from self (fromMe=true)", async () => {
|
||||||
const account = createMockAccount();
|
const account = createMockAccount();
|
||||||
|
|||||||
@ -31,6 +31,165 @@ const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
|
|||||||
const DEFAULT_TEXT_LIMIT = 4000;
|
const DEFAULT_TEXT_LIMIT = 4000;
|
||||||
const invalidAckReactions = new Set<string>();
|
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>;
|
type BlueBubblesCoreRuntime = ReturnType<typeof getBlueBubblesRuntime>;
|
||||||
|
|
||||||
function logVerbose(core: BlueBubblesCoreRuntime, runtime: BlueBubblesRuntimeEnv, message: string): void {
|
function logVerbose(core: BlueBubblesCoreRuntime, runtime: BlueBubblesRuntimeEnv, message: string): void {
|
||||||
@ -219,12 +378,15 @@ function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
|
|||||||
|
|
||||||
function formatReplyContext(message: {
|
function formatReplyContext(message: {
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
|
replyToShortId?: string;
|
||||||
replyToBody?: string;
|
replyToBody?: string;
|
||||||
replyToSender?: string;
|
replyToSender?: string;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
if (!message.replyToId && !message.replyToBody && !message.replyToSender) return null;
|
if (!message.replyToId && !message.replyToBody && !message.replyToSender) return null;
|
||||||
const sender = message.replyToSender?.trim() || "unknown sender";
|
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();
|
const body = message.replyToBody?.trim();
|
||||||
if (!body) {
|
if (!body) {
|
||||||
return `[Replying to ${sender}${idPart}]\n[/Replying]`;
|
return `[Replying to ${sender}${idPart}]\n[/Replying]`;
|
||||||
@ -404,6 +566,15 @@ function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undef
|
|||||||
return undefined;
|
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: {
|
function formatGroupAllowlistEntry(params: {
|
||||||
chatGuid?: string;
|
chatGuid?: string;
|
||||||
chatId?: number;
|
chatId?: number;
|
||||||
@ -550,20 +721,31 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
|
|||||||
const chatGuid =
|
const chatGuid =
|
||||||
readString(message, "chatGuid") ??
|
readString(message, "chatGuid") ??
|
||||||
readString(message, "chat_guid") ??
|
readString(message, "chat_guid") ??
|
||||||
|
readString(chat, "chatGuid") ??
|
||||||
|
readString(chat, "chat_guid") ??
|
||||||
readString(chat, "guid") ??
|
readString(chat, "guid") ??
|
||||||
|
readString(chatFromList, "chatGuid") ??
|
||||||
|
readString(chatFromList, "chat_guid") ??
|
||||||
readString(chatFromList, "guid");
|
readString(chatFromList, "guid");
|
||||||
const chatIdentifier =
|
const chatIdentifier =
|
||||||
readString(message, "chatIdentifier") ??
|
readString(message, "chatIdentifier") ??
|
||||||
readString(message, "chat_identifier") ??
|
readString(message, "chat_identifier") ??
|
||||||
|
readString(chat, "chatIdentifier") ??
|
||||||
|
readString(chat, "chat_identifier") ??
|
||||||
readString(chat, "identifier") ??
|
readString(chat, "identifier") ??
|
||||||
readString(chatFromList, "chatIdentifier") ??
|
readString(chatFromList, "chatIdentifier") ??
|
||||||
readString(chatFromList, "chat_identifier") ??
|
readString(chatFromList, "chat_identifier") ??
|
||||||
readString(chatFromList, "identifier");
|
readString(chatFromList, "identifier") ??
|
||||||
|
extractChatIdentifierFromChatGuid(chatGuid);
|
||||||
const chatId =
|
const chatId =
|
||||||
readNumber(message, "chatId") ??
|
readNumberLike(message, "chatId") ??
|
||||||
readNumber(message, "chat_id") ??
|
readNumberLike(message, "chat_id") ??
|
||||||
readNumber(chat, "id") ??
|
readNumberLike(chat, "chatId") ??
|
||||||
readNumber(chatFromList, "id");
|
readNumberLike(chat, "chat_id") ??
|
||||||
|
readNumberLike(chat, "id") ??
|
||||||
|
readNumberLike(chatFromList, "chatId") ??
|
||||||
|
readNumberLike(chatFromList, "chat_id") ??
|
||||||
|
readNumberLike(chatFromList, "id");
|
||||||
const chatName =
|
const chatName =
|
||||||
readString(message, "chatName") ??
|
readString(message, "chatName") ??
|
||||||
readString(chat, "displayName") ??
|
readString(chat, "displayName") ??
|
||||||
@ -679,19 +861,30 @@ function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedW
|
|||||||
const chatGuid =
|
const chatGuid =
|
||||||
readString(message, "chatGuid") ??
|
readString(message, "chatGuid") ??
|
||||||
readString(message, "chat_guid") ??
|
readString(message, "chat_guid") ??
|
||||||
|
readString(chat, "chatGuid") ??
|
||||||
|
readString(chat, "chat_guid") ??
|
||||||
readString(chat, "guid") ??
|
readString(chat, "guid") ??
|
||||||
|
readString(chatFromList, "chatGuid") ??
|
||||||
|
readString(chatFromList, "chat_guid") ??
|
||||||
readString(chatFromList, "guid");
|
readString(chatFromList, "guid");
|
||||||
const chatIdentifier =
|
const chatIdentifier =
|
||||||
readString(message, "chatIdentifier") ??
|
readString(message, "chatIdentifier") ??
|
||||||
readString(message, "chat_identifier") ??
|
readString(message, "chat_identifier") ??
|
||||||
|
readString(chat, "chatIdentifier") ??
|
||||||
|
readString(chat, "chat_identifier") ??
|
||||||
readString(chat, "identifier") ??
|
readString(chat, "identifier") ??
|
||||||
readString(chatFromList, "chatIdentifier") ??
|
readString(chatFromList, "chatIdentifier") ??
|
||||||
readString(chatFromList, "chat_identifier") ??
|
readString(chatFromList, "chat_identifier") ??
|
||||||
readString(chatFromList, "identifier");
|
readString(chatFromList, "identifier") ??
|
||||||
|
extractChatIdentifierFromChatGuid(chatGuid);
|
||||||
const chatId =
|
const chatId =
|
||||||
readNumberLike(message, "chatId") ??
|
readNumberLike(message, "chatId") ??
|
||||||
readNumberLike(message, "chat_id") ??
|
readNumberLike(message, "chat_id") ??
|
||||||
|
readNumberLike(chat, "chatId") ??
|
||||||
|
readNumberLike(chat, "chat_id") ??
|
||||||
readNumberLike(chat, "id") ??
|
readNumberLike(chat, "id") ??
|
||||||
|
readNumberLike(chatFromList, "chatId") ??
|
||||||
|
readNumberLike(chatFromList, "chat_id") ??
|
||||||
readNumberLike(chatFromList, "id");
|
readNumberLike(chatFromList, "id");
|
||||||
const chatName =
|
const chatName =
|
||||||
readString(message, "chatName") ??
|
readString(message, "chatName") ??
|
||||||
@ -901,14 +1094,36 @@ async function processMessage(
|
|||||||
target: WebhookTarget,
|
target: WebhookTarget,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { account, config, runtime, core, statusSink } = target;
|
const { account, config, runtime, core, statusSink } = target;
|
||||||
if (message.fromMe) return;
|
|
||||||
const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
|
const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
|
||||||
const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
|
const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
|
||||||
|
|
||||||
const text = message.text.trim();
|
const text = message.text.trim();
|
||||||
const attachments = message.attachments ?? [];
|
const attachments = message.attachments ?? [];
|
||||||
const placeholder = buildMessagePlaceholder(message);
|
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}`);
|
logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1199,12 +1414,42 @@ async function processMessage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const rawBody = text.trim() || placeholder;
|
let replyToId = message.replyToId;
|
||||||
const replyContext = formatReplyContext(message);
|
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 baseBody = replyContext ? `${rawBody}\n\n${replyContext}` : rawBody;
|
||||||
const fromLabel = isGroup
|
const fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`;
|
||||||
? `group:${peerId}`
|
|
||||||
: message.senderName || `user:${message.senderId}`;
|
|
||||||
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
|
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
|
||||||
const groupMembers = isGroup
|
const groupMembers = isGroup
|
||||||
? formatGroupMembers({
|
? formatGroupMembers({
|
||||||
@ -1230,12 +1475,12 @@ async function processMessage(
|
|||||||
});
|
});
|
||||||
let chatGuidForActions = chatGuid;
|
let chatGuidForActions = chatGuid;
|
||||||
if (!chatGuidForActions && baseUrl && password) {
|
if (!chatGuidForActions && baseUrl && password) {
|
||||||
const target =
|
const target =
|
||||||
isGroup && (chatId || chatIdentifier)
|
isGroup && (chatId || chatIdentifier)
|
||||||
? chatId
|
? chatId
|
||||||
? { kind: "chat_id", chatId }
|
? ({ kind: "chat_id", chatId } as const)
|
||||||
: { kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" }
|
: ({ kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" } as const)
|
||||||
: { kind: "handle", address: message.senderId };
|
: ({ kind: "handle", address: message.senderId } as const);
|
||||||
if (target.kind !== "chat_identifier" || target.chatIdentifier) {
|
if (target.kind !== "chat_identifier" || target.chatIdentifier) {
|
||||||
chatGuidForActions =
|
chatGuidForActions =
|
||||||
(await resolveChatGuidForTarget({
|
(await resolveChatGuidForTarget({
|
||||||
@ -1316,10 +1561,23 @@ async function processMessage(
|
|||||||
? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
|
? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
|
||||||
: message.senderId;
|
: message.senderId;
|
||||||
|
|
||||||
const maybeEnqueueOutboundMessageId = (messageId?: string) => {
|
const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string) => {
|
||||||
const trimmed = messageId?.trim();
|
const trimmed = messageId?.trim();
|
||||||
if (!trimmed || trimmed === "ok" || trimmed === "unknown") return;
|
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,
|
sessionKey: route.sessionKey,
|
||||||
contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`,
|
contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`,
|
||||||
});
|
});
|
||||||
@ -1343,16 +1601,18 @@ async function processMessage(
|
|||||||
AccountId: route.accountId,
|
AccountId: route.accountId,
|
||||||
ChatType: isGroup ? "group" : "direct",
|
ChatType: isGroup ? "group" : "direct",
|
||||||
ConversationLabel: fromLabel,
|
ConversationLabel: fromLabel,
|
||||||
ReplyToId: message.replyToId,
|
// Use short ID for token savings (agent can use this to reference the message)
|
||||||
ReplyToBody: message.replyToBody,
|
ReplyToId: replyToShortId || replyToId,
|
||||||
ReplyToSender: message.replyToSender,
|
ReplyToBody: replyToBody,
|
||||||
|
ReplyToSender: replyToSender,
|
||||||
GroupSubject: groupSubject,
|
GroupSubject: groupSubject,
|
||||||
GroupMembers: groupMembers,
|
GroupMembers: groupMembers,
|
||||||
SenderName: message.senderName || undefined,
|
SenderName: message.senderName || undefined,
|
||||||
SenderId: message.senderId,
|
SenderId: message.senderId,
|
||||||
Provider: "bluebubbles",
|
Provider: "bluebubbles",
|
||||||
Surface: "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,
|
Timestamp: message.timestamp,
|
||||||
OriginatingChannel: "bluebubbles",
|
OriginatingChannel: "bluebubbles",
|
||||||
OriginatingTo: `bluebubbles:${outboundTarget}`,
|
OriginatingTo: `bluebubbles:${outboundTarget}`,
|
||||||
@ -1385,7 +1645,8 @@ async function processMessage(
|
|||||||
replyToId: payload.replyToId ?? null,
|
replyToId: payload.replyToId ?? null,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
});
|
});
|
||||||
maybeEnqueueOutboundMessageId(result.messageId);
|
const cachedBody = (caption ?? "").trim() || "<media:attachment>";
|
||||||
|
maybeEnqueueOutboundMessageId(result.messageId, cachedBody);
|
||||||
sentMessage = true;
|
sentMessage = true;
|
||||||
statusSink?.({ lastOutboundAt: Date.now() });
|
statusSink?.({ lastOutboundAt: Date.now() });
|
||||||
}
|
}
|
||||||
@ -1407,7 +1668,7 @@ async function processMessage(
|
|||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
replyToMessageGuid: replyToMessageGuid || undefined,
|
replyToMessageGuid: replyToMessageGuid || undefined,
|
||||||
});
|
});
|
||||||
maybeEnqueueOutboundMessageId(result.messageId);
|
maybeEnqueueOutboundMessageId(result.messageId, chunk);
|
||||||
sentMessage = true;
|
sentMessage = true;
|
||||||
statusSink?.({ lastOutboundAt: Date.now() });
|
statusSink?.({ lastOutboundAt: Date.now() });
|
||||||
}
|
}
|
||||||
@ -1541,7 +1802,9 @@ async function processReaction(
|
|||||||
|
|
||||||
const senderLabel = reaction.senderName || reaction.senderId;
|
const senderLabel = reaction.senderName || reaction.senderId;
|
||||||
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
|
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, {
|
core.system.enqueueSystemEvent(text, {
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
|
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>([
|
const REACTION_ALIASES = new Map<string, string>([
|
||||||
|
// General
|
||||||
["heart", "love"],
|
["heart", "love"],
|
||||||
|
["love", "love"],
|
||||||
|
["❤", "love"],
|
||||||
|
["❤️", "love"],
|
||||||
|
["red_heart", "love"],
|
||||||
["thumbs_up", "like"],
|
["thumbs_up", "like"],
|
||||||
["thumbs-down", "dislike"],
|
["thumbsup", "like"],
|
||||||
|
["thumbs-up", "like"],
|
||||||
|
["thumbsup", "like"],
|
||||||
|
["like", "like"],
|
||||||
|
["thumb", "like"],
|
||||||
|
["ok", "like"],
|
||||||
["thumbs_down", "dislike"],
|
["thumbs_down", "dislike"],
|
||||||
|
["thumbsdown", "dislike"],
|
||||||
|
["thumbs-down", "dislike"],
|
||||||
|
["dislike", "dislike"],
|
||||||
|
["boo", "dislike"],
|
||||||
|
["no", "dislike"],
|
||||||
|
// Laugh
|
||||||
["haha", "laugh"],
|
["haha", "laugh"],
|
||||||
["lol", "laugh"],
|
["lol", "laugh"],
|
||||||
|
["lmao", "laugh"],
|
||||||
|
["rofl", "laugh"],
|
||||||
|
["😂", "laugh"],
|
||||||
|
["🤣", "laugh"],
|
||||||
|
["xd", "laugh"],
|
||||||
|
["laugh", "laugh"],
|
||||||
|
// Emphasize / exclaim
|
||||||
["emphasis", "emphasize"],
|
["emphasis", "emphasize"],
|
||||||
|
["emphasize", "emphasize"],
|
||||||
["exclaim", "emphasize"],
|
["exclaim", "emphasize"],
|
||||||
|
["!!", "emphasize"],
|
||||||
|
["‼", "emphasize"],
|
||||||
|
["‼️", "emphasize"],
|
||||||
|
["❗", "emphasize"],
|
||||||
|
["important", "emphasize"],
|
||||||
|
["bang", "emphasize"],
|
||||||
|
// Question
|
||||||
["question", "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>([
|
const REACTION_EMOJIS = new Map<string, string>([
|
||||||
|
// Love
|
||||||
["❤️", "love"],
|
["❤️", "love"],
|
||||||
["❤", "love"],
|
["❤", "love"],
|
||||||
["♥️", "love"],
|
["♥️", "love"],
|
||||||
|
["♥", "love"],
|
||||||
["😍", "love"],
|
["😍", "love"],
|
||||||
|
["💕", "love"],
|
||||||
|
// Like
|
||||||
["👍", "like"],
|
["👍", "like"],
|
||||||
|
["👌", "like"],
|
||||||
|
// Dislike
|
||||||
["👎", "dislike"],
|
["👎", "dislike"],
|
||||||
|
["🙅", "dislike"],
|
||||||
|
// Laugh
|
||||||
["😂", "laugh"],
|
["😂", "laugh"],
|
||||||
["🤣", "laugh"],
|
["🤣", "laugh"],
|
||||||
["😆", "laugh"],
|
["😆", "laugh"],
|
||||||
|
["😁", "laugh"],
|
||||||
|
["😹", "laugh"],
|
||||||
|
// Emphasize
|
||||||
["‼️", "emphasize"],
|
["‼️", "emphasize"],
|
||||||
["‼", "emphasize"],
|
["‼", "emphasize"],
|
||||||
|
["!!", "emphasize"],
|
||||||
["❗", "emphasize"],
|
["❗", "emphasize"],
|
||||||
|
["❕", "emphasize"],
|
||||||
|
["!", "emphasize"],
|
||||||
|
// Question
|
||||||
["❓", "question"],
|
["❓", "question"],
|
||||||
["❔", "question"],
|
["❔", "question"],
|
||||||
|
["?", "question"],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function resolveAccount(params: BlueBubblesReactionOpts) {
|
function resolveAccount(params: BlueBubblesReactionOpts) {
|
||||||
|
|||||||
@ -96,6 +96,33 @@ describe("send", () => {
|
|||||||
expect(result).toBe("iMessage;-;chat123");
|
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 () => {
|
it("resolves handle target by matching participant", async () => {
|
||||||
mockFetch.mockResolvedValueOnce({
|
mockFetch.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@ -133,6 +133,13 @@ function extractChatId(chat: BlueBubblesChatRecord): number | null {
|
|||||||
return 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[] {
|
function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
|
||||||
const raw =
|
const raw =
|
||||||
(Array.isArray(chat.participants) ? chat.participants : null) ??
|
(Array.isArray(chat.participants) ? chat.participants : null) ??
|
||||||
@ -223,7 +230,16 @@ export async function resolveChatGuidForTarget(params: {
|
|||||||
}
|
}
|
||||||
if (targetChatIdentifier) {
|
if (targetChatIdentifier) {
|
||||||
const guid = extractChatGuid(chat);
|
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 =
|
const identifier =
|
||||||
typeof chat.identifier === "string"
|
typeof chat.identifier === "string"
|
||||||
? chat.identifier
|
? chat.identifier
|
||||||
@ -232,7 +248,7 @@ export async function resolveChatGuidForTarget(params: {
|
|||||||
: typeof chat.chat_identifier === "string"
|
: typeof chat.chat_identifier === "string"
|
||||||
? chat.chat_identifier
|
? chat.chat_identifier
|
||||||
: "";
|
: "";
|
||||||
if (identifier && identifier === targetChatIdentifier) return extractChatGuid(chat);
|
if (identifier && identifier === targetChatIdentifier) return guid ?? extractChatGuid(chat);
|
||||||
}
|
}
|
||||||
if (normalizedHandle) {
|
if (normalizedHandle) {
|
||||||
const guid = extractChatGuid(chat);
|
const guid = extractChatGuid(chat);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type { NormalizedUsage } from "../../agents/usage.js";
|
import type { NormalizedUsage } from "../../agents/usage.js";
|
||||||
import { getChannelDock } from "../../channels/dock.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 { normalizeChannelId } from "../../channels/registry.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
|
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
|
||||||
@ -21,17 +21,25 @@ export function buildThreadingToolContext(params: {
|
|||||||
}): ChannelThreadingToolContext {
|
}): ChannelThreadingToolContext {
|
||||||
const { sessionCtx, config, hasRepliedRef } = params;
|
const { sessionCtx, config, hasRepliedRef } = params;
|
||||||
if (!config) return {};
|
if (!config) return {};
|
||||||
const provider = normalizeChannelId(sessionCtx.Provider);
|
const rawProvider = sessionCtx.Provider?.trim().toLowerCase();
|
||||||
if (!provider) return {};
|
if (!rawProvider) return {};
|
||||||
const dock = getChannelDock(provider);
|
const provider = normalizeChannelId(rawProvider);
|
||||||
if (!dock?.threading?.buildToolContext) return {};
|
|
||||||
// WhatsApp context isolation keys off conversation id, not the bot's own number.
|
// WhatsApp context isolation keys off conversation id, not the bot's own number.
|
||||||
const threadingTo =
|
const threadingTo =
|
||||||
provider === "whatsapp"
|
rawProvider === "whatsapp"
|
||||||
? (sessionCtx.From ?? sessionCtx.To)
|
? (sessionCtx.From ?? sessionCtx.To)
|
||||||
: provider === "imessage" && sessionCtx.ChatType === "direct"
|
: rawProvider === "imessage" && sessionCtx.ChatType === "direct"
|
||||||
? (sessionCtx.From ?? sessionCtx.To)
|
? (sessionCtx.From ?? sessionCtx.To)
|
||||||
: 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 =
|
const context =
|
||||||
dock.threading.buildToolContext({
|
dock.threading.buildToolContext({
|
||||||
cfg: config,
|
cfg: config,
|
||||||
@ -47,7 +55,7 @@ export function buildThreadingToolContext(params: {
|
|||||||
}) ?? {};
|
}) ?? {};
|
||||||
return {
|
return {
|
||||||
...context,
|
...context,
|
||||||
currentChannelProvider: provider,
|
currentChannelProvider: provider!, // guaranteed non-null since dock exists
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -140,7 +140,7 @@ describe("createTypingSignaler", () => {
|
|||||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
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 typing = createMockTypingController();
|
||||||
const signaler = createTypingSignaler({
|
const signaler = createTypingSignaler({
|
||||||
typing,
|
typing,
|
||||||
@ -150,8 +150,9 @@ describe("createTypingSignaler", () => {
|
|||||||
|
|
||||||
await signaler.signalToolStart();
|
await signaler.signalToolStart();
|
||||||
|
|
||||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
expect(typing.startTypingLoop).toHaveBeenCalled();
|
||||||
expect(typing.refreshTypingTtl).not.toHaveBeenCalled();
|
expect(typing.refreshTypingTtl).toHaveBeenCalled();
|
||||||
|
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refreshes ttl on tool start when active after text", async () => {
|
it("refreshes ttl on tool start when active after text", async () => {
|
||||||
|
|||||||
@ -95,13 +95,13 @@ export function createTypingSignaler(params: {
|
|||||||
|
|
||||||
const signalToolStart = async () => {
|
const signalToolStart = async () => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
if (!hasRenderableText) return;
|
// Start typing as soon as tools begin executing, even before the first text delta.
|
||||||
if (!typing.isActive()) {
|
if (!typing.isActive()) {
|
||||||
await typing.startTypingLoop();
|
await typing.startTypingLoop();
|
||||||
typing.refreshTypingTtl();
|
typing.refreshTypingTtl();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Keep typing indicator alive during tool execution without changing mode semantics.
|
// Keep typing indicator alive during tool execution.
|
||||||
typing.refreshTypingTtl();
|
typing.refreshTypingTtl();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -57,6 +57,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
|
|||||||
const ACTION_TARGET_ALIASES: Partial<Record<ChannelMessageActionName, string[]>> = {
|
const ACTION_TARGET_ALIASES: Partial<Record<ChannelMessageActionName, string[]>> = {
|
||||||
unsend: ["messageId"],
|
unsend: ["messageId"],
|
||||||
edit: ["messageId"],
|
edit: ["messageId"],
|
||||||
|
react: ["chatGuid", "chatIdentifier", "chatId"],
|
||||||
renameGroup: ["chatGuid", "chatIdentifier", "chatId"],
|
renameGroup: ["chatGuid", "chatIdentifier", "chatId"],
|
||||||
setGroupIcon: ["chatGuid", "chatIdentifier", "chatId"],
|
setGroupIcon: ["chatGuid", "chatIdentifier", "chatId"],
|
||||||
addParticipant: ["chatGuid", "chatIdentifier", "chatId"],
|
addParticipant: ["chatGuid", "chatIdentifier", "chatId"],
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user