Compare commits

...

4 Commits

Author SHA1 Message Date
Peter Steinberger
56fab1df69 fix: harden bluebubbles short ids and fetch wrapper (#1369) (thanks @tyler6204) 2026-01-21 17:05:36 +00:00
Tyler Yust
bf529f56b4 feat: enhance message context with full ID support for replies and caching
- Updated message processing to include full message IDs alongside short IDs for better context resolution.
- Improved reply handling by caching inbound messages, allowing for accurate sender and body resolution without exposing dropped content.
- Adjusted tests to validate the new full ID properties and their integration into the message handling workflow.
2026-01-21 16:50:29 +00:00
Tyler Yust
1ada67ffb3 feat: enhance message handling with short ID resolution and reply context improvements
- Implemented resolution of short message IDs to full UUIDs in both text and media sending functions.
- Updated reply context formatting to optimize token usage by including only necessary information.
- Introduced truncation for long reply bodies to further reduce token consumption.
- Adjusted tests to reflect changes in reply context handling and message ID resolution.
2026-01-21 16:50:28 +00:00
Tyler Yust
c607dfc1d6 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.
2026-01-21 16:50:28 +00:00
22 changed files with 903 additions and 88 deletions

View File

@ -18,6 +18,8 @@ Docs: https://docs.clawd.bot
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5. - Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen. - Configure: seed model fallbacks from the allowlist selection when multiple models are chosen.
- Model picker: list the full catalog when no model allowlist is configured. - Model picker: list the full catalog when no model allowlist is configured.
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1369) Thanks @tyler6204.
- Infra: preserve fetch helper methods when wrapping abort signals. (#1369)
## 2026.1.20 ## 2026.1.20

View File

@ -3022,6 +3022,9 @@ Template placeholders are expanded in `tools.media.*.models[].args` and `tools.m
| `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per channel) | | `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per channel) |
| `{{To}}` | Destination identifier | | `{{To}}` | Destination identifier |
| `{{MessageSid}}` | Channel message id (when available) | | `{{MessageSid}}` | Channel message id (when available) |
| `{{MessageSidFull}}` | Provider-specific full message id when `MessageSid` is shortened |
| `{{ReplyToId}}` | Reply-to message id (when available) |
| `{{ReplyToIdFull}}` | Provider-specific full reply-to id when `ReplyToId` is shortened |
| `{{SessionId}}` | Current session UUID | | `{{SessionId}}` | Current session UUID |
| `{{IsNewSession}}` | `"true"` when a new session was created | | `{{IsNewSession}}` | `"true"` when a new session was created |
| `{{MediaUrl}}` | Inbound media pseudo-URL (if present) | | `{{MediaUrl}}` | Inbound media pseudo-URL (if present) |

View File

@ -38,6 +38,10 @@ vi.mock("./attachments.js", () => ({
sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }), sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }),
})); }));
vi.mock("./monitor.js", () => ({
resolveBlueBubblesMessageId: vi.fn((id: string) => id),
}));
describe("bluebubblesMessageActions", () => { describe("bluebubblesMessageActions", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@ -358,6 +362,106 @@ describe("bluebubblesMessageActions", () => {
); );
}); });
it("uses toolContext currentChannelId when no explicit target is provided", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
const { resolveChatGuidForTarget } = await import("./send.js");
vi.mocked(resolveChatGuidForTarget).mockResolvedValueOnce("iMessage;-;+15550001111");
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await bluebubblesMessageActions.handleAction({
action: "react",
params: {
emoji: "👍",
messageId: "msg-456",
},
cfg,
accountId: null,
toolContext: {
currentChannelId: "bluebubbles:chat_guid:iMessage;-;+15550001111",
},
});
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
expect.objectContaining({
target: { kind: "chat_guid", chatGuid: "iMessage;-;+15550001111" },
}),
);
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
chatGuid: "iMessage;-;+15550001111",
}),
);
});
it("resolves short messageId before reacting", async () => {
const { resolveBlueBubblesMessageId } = await import("./monitor.js");
const { sendBlueBubblesReaction } = await import("./reactions.js");
vi.mocked(resolveBlueBubblesMessageId).mockReturnValueOnce("resolved-uuid");
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await bluebubblesMessageActions.handleAction({
action: "react",
params: {
emoji: "❤️",
messageId: "1",
chatGuid: "iMessage;-;+15551234567",
},
cfg,
accountId: null,
});
expect(resolveBlueBubblesMessageId).toHaveBeenCalledWith("1", { requireKnownShortId: true });
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
messageGuid: "resolved-uuid",
}),
);
});
it("propagates short-id errors from the resolver", async () => {
const { resolveBlueBubblesMessageId } = await import("./monitor.js");
vi.mocked(resolveBlueBubblesMessageId).mockImplementationOnce(() => {
throw new Error("short id expired");
});
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await expect(
bluebubblesMessageActions.handleAction({
action: "react",
params: {
emoji: "❤️",
messageId: "999",
chatGuid: "iMessage;-;+15551234567",
},
cfg,
accountId: null,
}),
).rejects.toThrow("short id expired");
});
it("accepts message param for edit action", async () => { it("accepts message param for edit action", async () => {
const { editBlueBubblesMessage } = await import("./chat.js"); const { editBlueBubblesMessage } = await import("./chat.js");

View File

@ -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, { requireKnownShortId: true });
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, { requireKnownShortId: true });
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, { requireKnownShortId: true });
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, { requireKnownShortId: true });
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

View File

@ -20,6 +20,7 @@ import {
resolveDefaultBlueBubblesAccountId, resolveDefaultBlueBubblesAccountId,
} from "./accounts.js"; } from "./accounts.js";
import { BlueBubblesConfigSchema } from "./config-schema.js"; import { BlueBubblesConfigSchema } from "./config-schema.js";
import { resolveBlueBubblesMessageId } from "./monitor.js";
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js"; import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
import { sendMessageBlueBubbles } from "./send.js"; import { sendMessageBlueBubbles } from "./send.js";
import { import {
@ -65,7 +66,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
threading: { threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({ buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.trim() || undefined, currentChannelId: context.To?.trim() || undefined,
currentThreadTs: context.ReplyToId, currentThreadTs: context.ReplyToIdFull ?? context.ReplyToId,
hasRepliedRef, hasRepliedRef,
}), }),
}, },
@ -237,7 +238,11 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
return { ok: true, to: trimmed }; return { ok: true, to: trimmed };
}, },
sendText: async ({ cfg, to, text, accountId, replyToId }) => { sendText: async ({ cfg, to, text, accountId, replyToId }) => {
const replyToMessageGuid = typeof replyToId === "string" ? replyToId.trim() : ""; const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
// Resolve short ID (e.g., "5") to full UUID
const replyToMessageGuid = rawReplyToId
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
: "";
const result = await sendMessageBlueBubbles(to, text, { const result = await sendMessageBlueBubbles(to, text, {
cfg: cfg as ClawdbotConfig, cfg: cfg as ClawdbotConfig,
accountId: accountId ?? undefined, accountId: accountId ?? undefined,

View File

@ -4,8 +4,9 @@ import { fileURLToPath } from "node:url";
import { resolveChannelMediaMaxBytes, type ClawdbotConfig } from "clawdbot/plugin-sdk"; import { resolveChannelMediaMaxBytes, type ClawdbotConfig } from "clawdbot/plugin-sdk";
import { sendBlueBubblesAttachment } from "./attachments.js"; import { sendBlueBubblesAttachment } from "./attachments.js";
import { sendMessageBlueBubbles } from "./send.js"; import { resolveBlueBubblesMessageId } from "./monitor.js";
import { getBlueBubblesRuntime } from "./runtime.js"; import { getBlueBubblesRuntime } from "./runtime.js";
import { sendMessageBlueBubbles } from "./send.js";
const HTTP_URL_RE = /^https?:\/\//i; const HTTP_URL_RE = /^https?:\/\//i;
const MB = 1024 * 1024; const MB = 1024 * 1024;
@ -134,12 +135,17 @@ export async function sendBlueBubblesMedia(params: {
} }
} }
// Resolve short ID (e.g., "5") to full UUID
const replyToMessageGuid = replyToId?.trim()
? resolveBlueBubblesMessageId(replyToId.trim(), { requireKnownShortId: true })
: undefined;
const attachmentResult = await sendBlueBubblesAttachment({ const attachmentResult = await sendBlueBubblesAttachment({
to, to,
buffer, buffer,
filename: resolvedFilename ?? "attachment", filename: resolvedFilename ?? "attachment",
contentType: resolvedContentType ?? undefined, contentType: resolvedContentType ?? undefined,
replyToMessageGuid: replyToId?.trim() || undefined, replyToMessageGuid,
opts: { opts: {
cfg, cfg,
accountId, accountId,
@ -151,7 +157,7 @@ export async function sendBlueBubblesMedia(params: {
await sendMessageBlueBubbles(to, trimmedCaption, { await sendMessageBlueBubbles(to, trimmedCaption, {
cfg, cfg,
accountId, accountId,
replyToMessageGuid: replyToId?.trim() || undefined, replyToMessageGuid,
}); });
} }

View File

@ -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,86 @@ 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");
expect(callArgs.ctx.Body).toContain("[Replying to +15550000000 id:msg-0]"); // Body uses just the ID (no sender) for token savings
expect(callArgs.ctx.Body).toContain("[Replying to 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.ReplyToIdFull).toBe("cache-msg-0");
expect(callArgs.ctx.ReplyToBody).toBe("original message (cached)");
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
// Body uses just the short ID (no sender) for token savings
expect(callArgs.ctx.Body).toContain("[Replying to 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 +1605,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 +1775,99 @@ 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");
expect(callArgs.ctx.MessageSidFull).toBe("msg-uuid-12345");
});
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");
});
it("throws when numeric short ID is missing and requireKnownShortId is set", () => {
expect(() =>
resolveBlueBubblesMessageId("999", { requireKnownShortId: true }),
).toThrow(/short message id/i);
});
});
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();

View File

@ -31,6 +31,173 @@ 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,
opts?: { requireKnownShortId?: boolean },
): 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;
if (opts?.requireKnownShortId) {
throw new Error(
`BlueBubbles short message id "${trimmed}" is no longer available. Use MessageSidFull.`,
);
}
}
// 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 {
@ -217,19 +384,29 @@ function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
return ""; return "";
} }
const REPLY_BODY_TRUNCATE_LENGTH = 60;
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"; // Prefer short ID for token savings
const idPart = message.replyToId ? ` id:${message.replyToId}` : ""; const displayId = message.replyToShortId || message.replyToId;
const body = message.replyToBody?.trim(); // Only include sender if we don't have an ID (fallback)
if (!body) { const label = displayId ? `id:${displayId}` : (message.replyToSender?.trim() || "unknown");
return `[Replying to ${sender}${idPart}]\n[/Replying]`; const rawBody = message.replyToBody?.trim();
if (!rawBody) {
return `[Replying to ${label}]\n[/Replying]`;
} }
return `[Replying to ${sender}${idPart}]\n${body}\n[/Replying]`; // Truncate long reply bodies for token savings
const body =
rawBody.length > REPLY_BODY_TRUNCATE_LENGTH
? `${rawBody.slice(0, REPLY_BODY_TRUNCATE_LENGTH)}`
: rawBody;
return `[Replying to ${label}]\n${body}\n[/Replying]`;
} }
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined { function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
@ -404,6 +581,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 +736,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 +876,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 +1109,39 @@ 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;
const cacheMessageId = message.messageId?.trim();
let messageShortId: string | undefined;
const cacheInboundMessage = () => {
if (!cacheMessageId) return;
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) {
// Cache from-me messages so reply context can resolve sender/body.
cacheInboundMessage();
return;
}
if (!rawBody) {
logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`); logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`);
return; return;
} }
@ -1148,6 +1381,10 @@ async function processMessage(
return; return;
} }
// Cache allowed inbound messages so later replies can resolve sender/body without
// surfacing dropped content (allowlist/mention/command gating).
cacheInboundMessage();
const baseUrl = account.config.serverUrl?.trim(); const baseUrl = account.config.serverUrl?.trim();
const password = account.config.password?.trim(); const password = account.config.password?.trim();
const maxBytes = const maxBytes =
@ -1199,12 +1436,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 +1497,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 +1583,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 +1623,20 @@ 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, ReplyToIdFull: replyToId,
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,
MessageSidFull: message.messageId,
Timestamp: message.timestamp, Timestamp: message.timestamp,
OriginatingChannel: "bluebubbles", OriginatingChannel: "bluebubbles",
OriginatingTo: `bluebubbles:${outboundTarget}`, OriginatingTo: `bluebubbles:${outboundTarget}`,
@ -1367,6 +1651,11 @@ async function processMessage(
cfg: config, cfg: config,
dispatcherOptions: { dispatcherOptions: {
deliver: async (payload) => { deliver: async (payload) => {
const rawReplyToId = typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
// Resolve short ID (e.g., "5") to full UUID
const replyToMessageGuid = rawReplyToId
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
: "";
const mediaList = payload.mediaUrls?.length const mediaList = payload.mediaUrls?.length
? payload.mediaUrls ? payload.mediaUrls
: payload.mediaUrl : payload.mediaUrl
@ -1382,10 +1671,11 @@ async function processMessage(
to: outboundTarget, to: outboundTarget,
mediaUrl, mediaUrl,
caption: caption ?? undefined, caption: caption ?? undefined,
replyToId: payload.replyToId ?? null, replyToId: replyToMessageGuid || 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() });
} }
@ -1400,14 +1690,12 @@ async function processMessage(
if (!chunks.length && payload.text) chunks.push(payload.text); if (!chunks.length && payload.text) chunks.push(payload.text);
if (!chunks.length) return; if (!chunks.length) return;
for (const chunk of chunks) { for (const chunk of chunks) {
const replyToMessageGuid =
typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
const result = await sendMessageBlueBubbles(outboundTarget, chunk, { const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
cfg: config, cfg: config,
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 +1829,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}`,

View File

@ -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) {

View File

@ -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,

View File

@ -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);

View File

@ -299,6 +299,8 @@ export async function runAgentTurnWithFallback(params: {
const { text, skip } = normalizeStreamingText(payload); const { text, skip } = normalizeStreamingText(payload);
const hasPayloadMedia = (payload.mediaUrls?.length ?? 0) > 0; const hasPayloadMedia = (payload.mediaUrls?.length ?? 0) > 0;
if (skip && !hasPayloadMedia) return; if (skip && !hasPayloadMedia) return;
const currentMessageId =
params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid;
const taggedPayload = applyReplyTagsToPayload( const taggedPayload = applyReplyTagsToPayload(
{ {
text, text,
@ -308,12 +310,12 @@ export async function runAgentTurnWithFallback(params: {
replyToTag: payload.replyToTag, replyToTag: payload.replyToTag,
replyToCurrent: payload.replyToCurrent, replyToCurrent: payload.replyToCurrent,
}, },
params.sessionCtx.MessageSid, currentMessageId,
); );
// Let through payloads with audioAsVoice flag even if empty (need to track it) // Let through payloads with audioAsVoice flag even if empty (need to track it)
if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) return; if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) return;
const parsed = parseReplyDirectives(taggedPayload.text ?? "", { const parsed = parseReplyDirectives(taggedPayload.text ?? "", {
currentMessageId: params.sessionCtx.MessageSid, currentMessageId,
silentToken: SILENT_REPLY_TOKEN, silentToken: SILENT_REPLY_TOKEN,
}); });
const cleaned = parsed.text || undefined; const cleaned = parsed.text || undefined;

View File

@ -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
}; };
} }

View File

@ -377,7 +377,7 @@ export async function runReplyAgent(params: {
directlySentBlockKeys, directlySentBlockKeys,
replyToMode, replyToMode,
replyToChannel, replyToChannel,
currentMessageId: sessionCtx.MessageSid, currentMessageId: sessionCtx.MessageSidFull ?? sessionCtx.MessageSid,
messageProvider: followupRun.run.messageProvider, messageProvider: followupRun.run.messageProvider,
messagingToolSentTexts: runResult.messagingToolSentTexts, messagingToolSentTexts: runResult.messagingToolSentTexts,
messagingToolSentTargets: runResult.messagingToolSentTargets, messagingToolSentTargets: runResult.messagingToolSentTargets,

View File

@ -350,7 +350,7 @@ export async function runPreparedReply(
const authProfileIdSource = sessionEntry?.authProfileOverrideSource; const authProfileIdSource = sessionEntry?.authProfileOverrideSource;
const followupRun = { const followupRun = {
prompt: queuedBody, prompt: queuedBody,
messageId: sessionCtx.MessageSid, messageId: sessionCtx.MessageSidFull ?? sessionCtx.MessageSid,
summaryLine: baseBodyTrimmedRaw, summaryLine: baseBodyTrimmedRaw,
enqueuedAt: Date.now(), enqueuedAt: Date.now(),
// Originating channel for reply routing. // Originating channel for reply routing.

View File

@ -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 () => {

View File

@ -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();
}; };

View File

@ -38,10 +38,14 @@ export type MsgContext = {
AccountId?: string; AccountId?: string;
ParentSessionKey?: string; ParentSessionKey?: string;
MessageSid?: string; MessageSid?: string;
/** Provider-specific full message id when MessageSid is a shortened alias. */
MessageSidFull?: string;
MessageSids?: string[]; MessageSids?: string[];
MessageSidFirst?: string; MessageSidFirst?: string;
MessageSidLast?: string; MessageSidLast?: string;
ReplyToId?: string; ReplyToId?: string;
/** Provider-specific full reply-to id when ReplyToId is a shortened alias. */
ReplyToIdFull?: string;
ReplyToBody?: string; ReplyToBody?: string;
ReplyToSender?: string; ReplyToSender?: string;
ForwardedFrom?: string; ForwardedFrom?: string;

View File

@ -212,6 +212,7 @@ export type ChannelThreadingContext = {
Channel?: string; Channel?: string;
To?: string; To?: string;
ReplyToId?: string; ReplyToId?: string;
ReplyToIdFull?: string;
ThreadLabel?: string; ThreadLabel?: string;
MessageThreadId?: string | number; MessageThreadId?: string | number;
}; };

View File

@ -6,7 +6,6 @@ import { runNodeHost } from "../../node-host/runner.js";
import { import {
runNodeDaemonInstall, runNodeDaemonInstall,
runNodeDaemonRestart, runNodeDaemonRestart,
runNodeDaemonStart,
runNodeDaemonStatus, runNodeDaemonStatus,
runNodeDaemonStop, runNodeDaemonStop,
runNodeDaemonUninstall, runNodeDaemonUninstall,

View File

@ -1,5 +1,5 @@
export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch { export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch {
return (input: RequestInfo | URL, init?: RequestInit) => { const wrapped = ((input: RequestInfo | URL, init?: RequestInit) => {
const signal = init?.signal; const signal = init?.signal;
if (!signal) return fetchImpl(input, init); if (!signal) return fetchImpl(input, init);
if (typeof AbortSignal !== "undefined" && signal instanceof AbortSignal) { if (typeof AbortSignal !== "undefined" && signal instanceof AbortSignal) {
@ -25,5 +25,6 @@ export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch
}); });
} }
return response; return response;
}; }) as typeof fetch;
return Object.assign(wrapped, fetchImpl);
} }

View File

@ -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"],