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: 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
|
||||||
|
|
||||||
|
|||||||
@@ -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) |
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Resolve short ID (e.g., "1", "2") to full UUID
|
// 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 partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
const resolvedChatGuid = await resolveChatGuid();
|
const resolvedChatGuid = await resolveChatGuid();
|
||||||
|
|
||||||
@@ -183,7 +183,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Resolve short ID (e.g., "1", "2") to full UUID
|
// 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 partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
|
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
|
||||||
|
|
||||||
@@ -206,7 +206,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Resolve short ID (e.g., "1", "2") to full UUID
|
// 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 partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
|
|
||||||
await unsendBlueBubblesMessage(messageId, {
|
await unsendBlueBubblesMessage(messageId, {
|
||||||
@@ -233,7 +233,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Resolve short ID (e.g., "1", "2") to full UUID
|
// 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 partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
|
|
||||||
const result = await sendMessageBlueBubbles(to, text, {
|
const result = await sendMessageBlueBubbles(to, text, {
|
||||||
|
|||||||
@@ -240,7 +240,9 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
||||||
const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
|
const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
|
||||||
// Resolve short ID (e.g., "5") to full UUID
|
// 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, {
|
const result = await sendMessageBlueBubbles(to, text, {
|
||||||
cfg: cfg as ClawdbotConfig,
|
cfg: cfg as ClawdbotConfig,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export async function sendBlueBubblesMedia(params: {
|
|||||||
|
|
||||||
// Resolve short ID (e.g., "5") to full UUID
|
// Resolve short ID (e.g., "5") to full UUID
|
||||||
const replyToMessageGuid = replyToId?.trim()
|
const replyToMessageGuid = replyToId?.trim()
|
||||||
? resolveBlueBubblesMessageId(replyToId.trim())
|
? resolveBlueBubblesMessageId(replyToId.trim(), { requireKnownShortId: true })
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const attachmentResult = await sendBlueBubblesAttachment({
|
const attachmentResult = await sendBlueBubblesAttachment({
|
||||||
|
|||||||
@@ -1860,6 +1860,12 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
it("returns short ID unchanged when numeric but not in cache", () => {
|
it("returns short ID unchanged when numeric but not in cache", () => {
|
||||||
expect(resolveBlueBubblesMessageId("999")).toBe("999");
|
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", () => {
|
||||||
|
|||||||
@@ -119,7 +119,10 @@ function rememberBlueBubblesReplyCache(
|
|||||||
* Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles UUID.
|
* 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.
|
* 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();
|
const trimmed = shortOrUuid.trim();
|
||||||
if (!trimmed) return trimmed;
|
if (!trimmed) return trimmed;
|
||||||
|
|
||||||
@@ -127,6 +130,11 @@ export function resolveBlueBubblesMessageId(shortOrUuid: string): string {
|
|||||||
if (/^\d+$/.test(trimmed)) {
|
if (/^\d+$/.test(trimmed)) {
|
||||||
const uuid = blueBubblesShortIdToUuid.get(trimmed);
|
const uuid = blueBubblesShortIdToUuid.get(trimmed);
|
||||||
if (uuid) return uuid;
|
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 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() : "";
|
const rawReplyToId = typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
|
||||||
// Resolve short ID (e.g., "5") to full UUID
|
// Resolve short ID (e.g., "5") to full UUID
|
||||||
const replyToMessageGuid = rawReplyToId
|
const replyToMessageGuid = rawReplyToId
|
||||||
? resolveBlueBubblesMessageId(rawReplyToId)
|
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
|
||||||
: "";
|
: "";
|
||||||
const mediaList = payload.mediaUrls?.length
|
const mediaList = payload.mediaUrls?.length
|
||||||
? payload.mediaUrls
|
? payload.mediaUrls
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { runNodeHost } from "../../node-host/runner.js";
|
|||||||
import {
|
import {
|
||||||
runNodeDaemonInstall,
|
runNodeDaemonInstall,
|
||||||
runNodeDaemonRestart,
|
runNodeDaemonRestart,
|
||||||
runNodeDaemonStart,
|
|
||||||
runNodeDaemonStatus,
|
runNodeDaemonStatus,
|
||||||
runNodeDaemonStop,
|
runNodeDaemonStop,
|
||||||
runNodeDaemonUninstall,
|
runNodeDaemonUninstall,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user