fix: harden message aborts + bluebubbles dm create (#1751) (thanks @tyler6204)
This commit is contained in:
@@ -24,6 +24,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal.
|
- Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles
|
||||||
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
|
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
|
||||||
- Web UI: hide internal `message_id` hints in chat bubbles.
|
- Web UI: hide internal `message_id` hints in chat bubbles.
|
||||||
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ Prefer `chat_guid` for stable routing:
|
|||||||
- `chat_id:123`
|
- `chat_id:123`
|
||||||
- `chat_identifier:...`
|
- `chat_identifier:...`
|
||||||
- Direct handles: `+15555550123`, `user@example.com`
|
- Direct handles: `+15555550123`, `user@example.com`
|
||||||
|
- If a direct handle does not have an existing DM chat, Clawdbot will create one via `POST /api/v1/chat/new`. This requires the BlueBubbles Private API to be enabled.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
|
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
|
||||||
|
|||||||
@@ -385,14 +385,14 @@ describe("send", () => {
|
|||||||
).rejects.toThrow("password is required");
|
).rejects.toThrow("password is required");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when chatGuid cannot be resolved", async () => {
|
it("throws when chatGuid cannot be resolved for non-handle targets", async () => {
|
||||||
mockFetch.mockResolvedValue({
|
mockFetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () => Promise.resolve({ data: [] }),
|
json: () => Promise.resolve({ data: [] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sendMessageBlueBubbles("+15559999999", "Hello", {
|
sendMessageBlueBubbles("chat_id:999", "Hello", {
|
||||||
serverUrl: "http://localhost:1234",
|
serverUrl: "http://localhost:1234",
|
||||||
password: "test",
|
password: "test",
|
||||||
}),
|
}),
|
||||||
@@ -439,6 +439,57 @@ describe("send", () => {
|
|||||||
expect(body.method).toBeUndefined();
|
expect(body.method).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("creates a new chat when handle target is missing", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ data: [] }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: () =>
|
||||||
|
Promise.resolve(
|
||||||
|
JSON.stringify({
|
||||||
|
data: { guid: "new-msg-guid" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", {
|
||||||
|
serverUrl: "http://localhost:1234",
|
||||||
|
password: "test",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.messageId).toBe("new-msg-guid");
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
const createCall = mockFetch.mock.calls[1];
|
||||||
|
expect(createCall[0]).toContain("/api/v1/chat/new");
|
||||||
|
const body = JSON.parse(createCall[1].body);
|
||||||
|
expect(body.addresses).toEqual(["+15550009999"]);
|
||||||
|
expect(body.message).toBe("Hello new chat");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when creating a new chat requires Private API", async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ data: [] }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
text: () => Promise.resolve("Private API not enabled"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sendMessageBlueBubbles("+15550008888", "Hello", {
|
||||||
|
serverUrl: "http://localhost:1234",
|
||||||
|
password: "test",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow("Private API must be enabled");
|
||||||
|
});
|
||||||
|
|
||||||
it("uses private-api when reply metadata is present", async () => {
|
it("uses private-api when reply metadata is present", async () => {
|
||||||
mockFetch
|
mockFetch
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
|
|||||||
@@ -321,6 +321,44 @@ describe("runMessageAction context isolation", () => {
|
|||||||
}),
|
}),
|
||||||
).rejects.toThrow(/Cross-context messaging denied/);
|
).rejects.toThrow(/Cross-context messaging denied/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("aborts send when abortSignal is already aborted", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runMessageAction({
|
||||||
|
cfg: slackConfig,
|
||||||
|
action: "send",
|
||||||
|
params: {
|
||||||
|
channel: "slack",
|
||||||
|
target: "#C12345678",
|
||||||
|
message: "hi",
|
||||||
|
},
|
||||||
|
dryRun: true,
|
||||||
|
abortSignal: controller.signal,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ name: "AbortError" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts broadcast when abortSignal is already aborted", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runMessageAction({
|
||||||
|
cfg: slackConfig,
|
||||||
|
action: "broadcast",
|
||||||
|
params: {
|
||||||
|
targets: ["channel:C12345678"],
|
||||||
|
channel: "slack",
|
||||||
|
message: "hi",
|
||||||
|
},
|
||||||
|
dryRun: true,
|
||||||
|
abortSignal: controller.signal,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ name: "AbortError" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("runMessageAction sendAttachment hydration", () => {
|
describe("runMessageAction sendAttachment hydration", () => {
|
||||||
|
|||||||
@@ -526,6 +526,7 @@ async function handleBroadcastAction(
|
|||||||
input: RunMessageActionParams,
|
input: RunMessageActionParams,
|
||||||
params: Record<string, unknown>,
|
params: Record<string, unknown>,
|
||||||
): Promise<MessageActionRunResult> {
|
): Promise<MessageActionRunResult> {
|
||||||
|
throwIfAborted(input.abortSignal);
|
||||||
const broadcastEnabled = input.cfg.tools?.message?.broadcast?.enabled !== false;
|
const broadcastEnabled = input.cfg.tools?.message?.broadcast?.enabled !== false;
|
||||||
if (!broadcastEnabled) {
|
if (!broadcastEnabled) {
|
||||||
throw new Error("Broadcast is disabled. Set tools.message.broadcast.enabled to true.");
|
throw new Error("Broadcast is disabled. Set tools.message.broadcast.enabled to true.");
|
||||||
@@ -550,8 +551,11 @@ async function handleBroadcastAction(
|
|||||||
error?: string;
|
error?: string;
|
||||||
result?: MessageSendResult;
|
result?: MessageSendResult;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
const isAbortError = (err: unknown): boolean => err instanceof Error && err.name === "AbortError";
|
||||||
for (const targetChannel of targetChannels) {
|
for (const targetChannel of targetChannels) {
|
||||||
|
throwIfAborted(input.abortSignal);
|
||||||
for (const target of rawTargets) {
|
for (const target of rawTargets) {
|
||||||
|
throwIfAborted(input.abortSignal);
|
||||||
try {
|
try {
|
||||||
const resolved = await resolveChannelTarget({
|
const resolved = await resolveChannelTarget({
|
||||||
cfg: input.cfg,
|
cfg: input.cfg,
|
||||||
@@ -575,6 +579,7 @@ async function handleBroadcastAction(
|
|||||||
result: sendResult.kind === "send" ? sendResult.sendResult : undefined,
|
result: sendResult.kind === "send" ? sendResult.sendResult : undefined,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (isAbortError(err)) throw err;
|
||||||
results.push({
|
results.push({
|
||||||
channel: targetChannel,
|
channel: targetChannel,
|
||||||
to: target,
|
to: target,
|
||||||
|
|||||||
Reference in New Issue
Block a user