fix: harden bluebubbles short ids and fetch wrapper (#1369) (thanks @tyler6204)
This commit is contained in:
@@ -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: seed model fallbacks from the allowlist selection when multiple models are chosen.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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) |
|
||||
| `{{To}}` | Destination identifier |
|
||||
| `{{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 |
|
||||
| `{{IsNewSession}}` | `"true"` when a new session was created |
|
||||
| `{{MediaUrl}}` | Inbound media pseudo-URL (if present) |
|
||||
|
||||
@@ -38,6 +38,10 @@ vi.mock("./attachments.js", () => ({
|
||||
sendBlueBubblesAttachment: vi.fn().mockResolvedValue({ messageId: "att-msg-123" }),
|
||||
}));
|
||||
|
||||
vi.mock("./monitor.js", () => ({
|
||||
resolveBlueBubblesMessageId: vi.fn((id: string) => id),
|
||||
}));
|
||||
|
||||
describe("bluebubblesMessageActions", () => {
|
||||
beforeEach(() => {
|
||||
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 () => {
|
||||
const { editBlueBubblesMessage } = await import("./chat.js");
|
||||
|
||||
|
||||
@@ -143,7 +143,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
);
|
||||
}
|
||||
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||
const messageId = resolveBlueBubblesMessageId(rawMessageId);
|
||||
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||
const resolvedChatGuid = await resolveChatGuid();
|
||||
|
||||
@@ -183,7 +183,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
);
|
||||
}
|
||||
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||
const messageId = resolveBlueBubblesMessageId(rawMessageId);
|
||||
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
|
||||
|
||||
@@ -206,7 +206,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
);
|
||||
}
|
||||
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||
const messageId = resolveBlueBubblesMessageId(rawMessageId);
|
||||
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||
|
||||
await unsendBlueBubblesMessage(messageId, {
|
||||
@@ -233,7 +233,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
);
|
||||
}
|
||||
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||
const messageId = resolveBlueBubblesMessageId(rawMessageId);
|
||||
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||
|
||||
const result = await sendMessageBlueBubbles(to, text, {
|
||||
|
||||
@@ -240,7 +240,9 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
||||
const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
|
||||
// Resolve short ID (e.g., "5") to full UUID
|
||||
const replyToMessageGuid = rawReplyToId ? resolveBlueBubblesMessageId(rawReplyToId) : "";
|
||||
const replyToMessageGuid = rawReplyToId
|
||||
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
|
||||
: "";
|
||||
const result = await sendMessageBlueBubbles(to, text, {
|
||||
cfg: cfg as ClawdbotConfig,
|
||||
accountId: accountId ?? undefined,
|
||||
|
||||
@@ -137,7 +137,7 @@ export async function sendBlueBubblesMedia(params: {
|
||||
|
||||
// Resolve short ID (e.g., "5") to full UUID
|
||||
const replyToMessageGuid = replyToId?.trim()
|
||||
? resolveBlueBubblesMessageId(replyToId.trim())
|
||||
? resolveBlueBubblesMessageId(replyToId.trim(), { requireKnownShortId: true })
|
||||
: undefined;
|
||||
|
||||
const attachmentResult = await sendBlueBubblesAttachment({
|
||||
|
||||
@@ -1860,6 +1860,12 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -119,7 +119,10 @@ function rememberBlueBubblesReplyCache(
|
||||
* 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 {
|
||||
export function resolveBlueBubblesMessageId(
|
||||
shortOrUuid: string,
|
||||
opts?: { requireKnownShortId?: boolean },
|
||||
): string {
|
||||
const trimmed = shortOrUuid.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
|
||||
@@ -127,6 +130,11 @@ export function resolveBlueBubblesMessageId(shortOrUuid: string): string {
|
||||
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)
|
||||
@@ -1646,7 +1654,7 @@ async function processMessage(
|
||||
const rawReplyToId = typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
|
||||
// Resolve short ID (e.g., "5") to full UUID
|
||||
const replyToMessageGuid = rawReplyToId
|
||||
? resolveBlueBubblesMessageId(rawReplyToId)
|
||||
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
|
||||
: "";
|
||||
const mediaList = payload.mediaUrls?.length
|
||||
? payload.mediaUrls
|
||||
|
||||
@@ -6,7 +6,6 @@ import { runNodeHost } from "../../node-host/runner.js";
|
||||
import {
|
||||
runNodeDaemonInstall,
|
||||
runNodeDaemonRestart,
|
||||
runNodeDaemonStart,
|
||||
runNodeDaemonStatus,
|
||||
runNodeDaemonStop,
|
||||
runNodeDaemonUninstall,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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;
|
||||
if (!signal) return fetchImpl(input, init);
|
||||
if (typeof AbortSignal !== "undefined" && signal instanceof AbortSignal) {
|
||||
@@ -25,5 +25,6 @@ export function wrapFetchWithAbortSignal(fetchImpl: typeof fetch): typeof fetch
|
||||
});
|
||||
}
|
||||
return response;
|
||||
};
|
||||
}) as typeof fetch;
|
||||
return Object.assign(wrapped, fetchImpl);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user