fix: harden bluebubbles short ids and fetch wrapper (#1369) (thanks @tyler6204)

This commit is contained in:
Peter Steinberger
2026-01-21 17:05:36 +00:00
parent c3adc50cb2
commit cd25d69b4d
10 changed files with 136 additions and 11 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: 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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