mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 08:50:21 +00:00
fix: persist outbound sends and skip stale cron deliveries (#50092)
* fix(bluebubbles): auto-create chats for new numbers, persist outbound messages to session transcripts
Two fixes for BlueBubbles message tool behavior:
1. **Attachment sends to new phone numbers**: sendBlueBubblesAttachment now
auto-creates a new DM chat (via /api/v1/chat/new) when no existing chat
is found for a handle target, matching the behavior already present in
sendMessageBlueBubbles for text sends. The existing createNewChatWithMessage
is refactored into a reusable createChatForHandle that returns the chatGuid.
2. **Outbound message session persistence**: Ensures outbound messages sent
via the message tool are reliably tracked in session transcripts:
- ensureOutboundSessionEntry now falls back to directly creating a session
store entry when recordSessionMetaFromInbound returns null, guaranteeing
a sessionId exists for the subsequent mirror append.
- appendAssistantMessageToSessionTranscript now normalizes the session key
(lowercased) when looking up the store, preventing case mismatches
between the store keys and the mirror sessionKey.
Tests added for all changes.
* test(slack): verify outbound session tracking and new target sends for Slack
The shared infrastructure changes from the BlueBubbles fix (session key
normalization in transcript.ts and fallback session entry creation in
outbound-session.ts) already cover Slack. Slack's sendMessageSlack uses
conversations.open to auto-create DM channels for new user targets.
Add tests confirming:
- Slack user DM and channel session route resolution (outbound.test.ts)
- Slack session key normalization for transcript append (sessions.test.ts)
- Slack outbound sendText/sendMedia to new user and channel targets (channel.test.ts)
* fix(cron): skip stale delayed deliveries
* fix: prep PR #50092
This commit is contained in:
@@ -484,4 +484,94 @@ describe("sendBlueBubblesAttachment", () => {
|
||||
expect(bodyText).not.toContain('name="selectedMessageGuid"');
|
||||
expect(bodyText).not.toContain('name="partIndex"');
|
||||
});
|
||||
|
||||
it("auto-creates a new chat when sending to a phone number with no existing chat", async () => {
|
||||
// First call: resolveChatGuidForTarget queries chats, returns empty (no match)
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
});
|
||||
// Second call: createChatForHandle creates new chat
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { chatGuid: "iMessage;-;+15559876543", guid: "iMessage;-;+15559876543" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
// Third call: actual attachment send
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(JSON.stringify({ data: { guid: "attach-msg-1" } })),
|
||||
});
|
||||
|
||||
const result = await sendBlueBubblesAttachment({
|
||||
to: "+15559876543",
|
||||
buffer: new Uint8Array([1, 2, 3]),
|
||||
filename: "photo.jpg",
|
||||
contentType: "image/jpeg",
|
||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("attach-msg-1");
|
||||
// Verify chat creation was called
|
||||
const createCallBody = JSON.parse(mockFetch.mock.calls[1][1].body);
|
||||
expect(createCallBody.addresses).toEqual(["+15559876543"]);
|
||||
// Verify attachment was sent to the newly created chat
|
||||
const attachBody = mockFetch.mock.calls[2][1]?.body as Uint8Array;
|
||||
const attachText = decodeBody(attachBody);
|
||||
expect(attachText).toContain("iMessage;-;+15559876543");
|
||||
});
|
||||
|
||||
it("retries chatGuid resolution after creating a chat with no returned guid", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
});
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(JSON.stringify({ data: {} })),
|
||||
});
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [{ guid: "iMessage;-;+15557654321" }] }),
|
||||
});
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(JSON.stringify({ data: { guid: "attach-msg-2" } })),
|
||||
});
|
||||
|
||||
const result = await sendBlueBubblesAttachment({
|
||||
to: "+15557654321",
|
||||
buffer: new Uint8Array([4, 5, 6]),
|
||||
filename: "photo.jpg",
|
||||
contentType: "image/jpeg",
|
||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("attach-msg-2");
|
||||
const createCallBody = JSON.parse(mockFetch.mock.calls[1][1].body);
|
||||
expect(createCallBody.addresses).toEqual(["+15557654321"]);
|
||||
const attachBody = mockFetch.mock.calls[3][1]?.body as Uint8Array;
|
||||
const attachText = decodeBody(attachBody);
|
||||
expect(attachText).toContain("iMessage;-;+15557654321");
|
||||
});
|
||||
|
||||
it("still throws for non-handle targets when chatGuid is not found", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendBlueBubblesAttachment({
|
||||
to: "chat_id:999",
|
||||
buffer: new Uint8Array([1, 2, 3]),
|
||||
filename: "photo.jpg",
|
||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||
}),
|
||||
).rejects.toThrow("chatGuid not found");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import { resolveRequestUrl } from "./request-url.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import { getBlueBubblesRuntime, warnBlueBubbles } from "./runtime.js";
|
||||
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
|
||||
import { resolveChatGuidForTarget } from "./send.js";
|
||||
import { resolveChatGuidForTarget, createChatForHandle } from "./send.js";
|
||||
import {
|
||||
blueBubblesFetchWithTimeout,
|
||||
buildBlueBubblesApiUrl,
|
||||
@@ -180,16 +180,37 @@ export async function sendBlueBubblesAttachment(params: {
|
||||
}
|
||||
|
||||
const target = resolveBlueBubblesSendTarget(to);
|
||||
const chatGuid = await resolveChatGuidForTarget({
|
||||
let chatGuid = await resolveChatGuidForTarget({
|
||||
baseUrl,
|
||||
password,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
target,
|
||||
});
|
||||
if (!chatGuid) {
|
||||
throw new Error(
|
||||
"BlueBubbles attachment send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
|
||||
);
|
||||
// For handle targets (phone numbers/emails), auto-create a new DM chat
|
||||
if (target.kind === "handle") {
|
||||
const created = await createChatForHandle({
|
||||
baseUrl,
|
||||
password,
|
||||
address: target.address,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
chatGuid = created.chatGuid;
|
||||
// If we still don't have a chatGuid, try resolving again (chat was created server-side)
|
||||
if (!chatGuid) {
|
||||
chatGuid = await resolveChatGuidForTarget({
|
||||
baseUrl,
|
||||
password,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
target,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!chatGuid) {
|
||||
throw new Error(
|
||||
"BlueBubbles attachment send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "./test-mocks.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js";
|
||||
import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js";
|
||||
import { sendMessageBlueBubbles, resolveChatGuidForTarget, createChatForHandle } from "./send.js";
|
||||
import {
|
||||
BLUE_BUBBLES_PRIVATE_API_STATUS,
|
||||
installBlueBubblesFetchTestHooks,
|
||||
@@ -781,4 +781,109 @@ describe("send", () => {
|
||||
expect(body.tempGuid.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createChatForHandle", () => {
|
||||
it("creates a new chat and returns chatGuid from response", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { guid: "iMessage;-;+15559876543", chatGuid: "iMessage;-;+15559876543" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const result = await createChatForHandle({
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
address: "+15559876543",
|
||||
message: "Hello!",
|
||||
});
|
||||
|
||||
expect(result.chatGuid).toBe("iMessage;-;+15559876543");
|
||||
expect(result.messageId).toBeDefined();
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.addresses).toEqual(["+15559876543"]);
|
||||
expect(body.message).toBe("Hello!");
|
||||
});
|
||||
|
||||
it("creates a new chat without a message when message is omitted", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { guid: "iMessage;-;+15559876543" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const result = await createChatForHandle({
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
address: "+15559876543",
|
||||
});
|
||||
|
||||
expect(result.chatGuid).toBe("iMessage;-;+15559876543");
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(body.message).toBe("");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["data.chatGuid", { data: { chatGuid: "shape-chat-guid" } }, "shape-chat-guid"],
|
||||
["data.guid", { data: { guid: "shape-guid" } }, "shape-guid"],
|
||||
[
|
||||
"data.chats[0].guid",
|
||||
{ data: { chats: [{ guid: "shape-array-guid" }] } },
|
||||
"shape-array-guid",
|
||||
],
|
||||
["data.chat.guid", { data: { chat: { guid: "shape-object-guid" } } }, "shape-object-guid"],
|
||||
])("extracts chatGuid from %s", async (_label, responseBody, expectedChatGuid) => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(JSON.stringify(responseBody)),
|
||||
});
|
||||
|
||||
const result = await createChatForHandle({
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
address: "+15559876543",
|
||||
});
|
||||
|
||||
expect(result.chatGuid).toBe(expectedChatGuid);
|
||||
});
|
||||
|
||||
it("throws when Private API is not enabled", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
text: () => Promise.resolve("Private API not enabled"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
createChatForHandle({
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
address: "+15559876543",
|
||||
}),
|
||||
).rejects.toThrow("Private API must be enabled");
|
||||
});
|
||||
|
||||
it("returns null chatGuid when response has no chat data", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(JSON.stringify({ data: {} })),
|
||||
});
|
||||
|
||||
const result = await createChatForHandle({
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
address: "+15559876543",
|
||||
message: "Hello",
|
||||
});
|
||||
|
||||
expect(result.chatGuid).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -312,16 +312,20 @@ export async function resolveChatGuidForTarget(params: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new chat (DM) and optionally sends an initial message.
|
||||
* Creates a new DM chat for the given address and returns the chat GUID.
|
||||
* Requires Private API to be enabled in BlueBubbles.
|
||||
*
|
||||
* If a `message` is provided it is sent as the initial message in the new chat;
|
||||
* otherwise an empty-string message body is used (BlueBubbles still creates the
|
||||
* chat but will not deliver a visible bubble).
|
||||
*/
|
||||
async function createNewChatWithMessage(params: {
|
||||
export async function createChatForHandle(params: {
|
||||
baseUrl: string;
|
||||
password: string;
|
||||
address: string;
|
||||
message: string;
|
||||
message?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<BlueBubblesSendResult> {
|
||||
}): Promise<{ chatGuid: string | null; messageId: string }> {
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl: params.baseUrl,
|
||||
path: "/api/v1/chat/new",
|
||||
@@ -329,7 +333,7 @@ async function createNewChatWithMessage(params: {
|
||||
});
|
||||
const payload = {
|
||||
addresses: [params.address],
|
||||
message: params.message,
|
||||
message: params.message ?? "",
|
||||
tempGuid: `temp-${crypto.randomUUID()}`,
|
||||
};
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
@@ -343,7 +347,6 @@ async function createNewChatWithMessage(params: {
|
||||
);
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
// Check for Private API not enabled error
|
||||
if (
|
||||
res.status === 400 ||
|
||||
res.status === 403 ||
|
||||
@@ -355,7 +358,64 @@ async function createNewChatWithMessage(params: {
|
||||
}
|
||||
throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`);
|
||||
}
|
||||
return parseBlueBubblesMessageResponse(res);
|
||||
const body = await res.text();
|
||||
let messageId = "ok";
|
||||
let chatGuid: string | null = null;
|
||||
if (body) {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as Record<string, unknown>;
|
||||
messageId = extractBlueBubblesMessageId(parsed);
|
||||
// Extract chatGuid from the response data
|
||||
const data = parsed.data as Record<string, unknown> | undefined;
|
||||
if (data) {
|
||||
chatGuid =
|
||||
(typeof data.chatGuid === "string" && data.chatGuid) ||
|
||||
(typeof data.guid === "string" && data.guid) ||
|
||||
null;
|
||||
// Also try nested chats array (some BB versions nest it)
|
||||
if (!chatGuid) {
|
||||
const chats = data.chats ?? data.chat;
|
||||
if (Array.isArray(chats) && chats.length > 0) {
|
||||
const first = chats[0] as Record<string, unknown> | undefined;
|
||||
chatGuid =
|
||||
(typeof first?.guid === "string" && first.guid) ||
|
||||
(typeof first?.chatGuid === "string" && first.chatGuid) ||
|
||||
null;
|
||||
} else if (chats && typeof chats === "object" && !Array.isArray(chats)) {
|
||||
const chatObj = chats as Record<string, unknown>;
|
||||
chatGuid =
|
||||
(typeof chatObj.guid === "string" && chatObj.guid) ||
|
||||
(typeof chatObj.chatGuid === "string" && chatObj.chatGuid) ||
|
||||
null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
return { chatGuid, messageId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new chat (DM) and sends an initial message.
|
||||
* Requires Private API to be enabled in BlueBubbles.
|
||||
*/
|
||||
async function createNewChatWithMessage(params: {
|
||||
baseUrl: string;
|
||||
password: string;
|
||||
address: string;
|
||||
message: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<BlueBubblesSendResult> {
|
||||
const result = await createChatForHandle({
|
||||
baseUrl: params.baseUrl,
|
||||
password: params.password,
|
||||
address: params.address,
|
||||
message: params.message,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
return { messageId: result.messageId };
|
||||
}
|
||||
|
||||
export async function sendMessageBlueBubbles(
|
||||
|
||||
@@ -308,6 +308,84 @@ describe("slackPlugin agentPrompt", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("slackPlugin outbound new targets", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: "xoxb-test",
|
||||
appToken: "xapp-test",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("sends to a new user target via DM without erroring", async () => {
|
||||
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-new-user", channelId: "D999" });
|
||||
const sendText = slackPlugin.outbound?.sendText;
|
||||
expect(sendText).toBeDefined();
|
||||
|
||||
const result = await sendText!({
|
||||
cfg,
|
||||
to: "user:U99NEW",
|
||||
text: "hello new user",
|
||||
accountId: "default",
|
||||
deps: { sendSlack },
|
||||
});
|
||||
|
||||
expect(sendSlack).toHaveBeenCalledWith(
|
||||
"user:U99NEW",
|
||||
"hello new user",
|
||||
expect.objectContaining({ cfg }),
|
||||
);
|
||||
expect(result).toEqual({ channel: "slack", messageId: "m-new-user", channelId: "D999" });
|
||||
});
|
||||
|
||||
it("sends to a new channel target without erroring", async () => {
|
||||
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-new-chan", channelId: "C555" });
|
||||
const sendText = slackPlugin.outbound?.sendText;
|
||||
expect(sendText).toBeDefined();
|
||||
|
||||
const result = await sendText!({
|
||||
cfg,
|
||||
to: "channel:C555NEW",
|
||||
text: "hello channel",
|
||||
accountId: "default",
|
||||
deps: { sendSlack },
|
||||
});
|
||||
|
||||
expect(sendSlack).toHaveBeenCalledWith(
|
||||
"channel:C555NEW",
|
||||
"hello channel",
|
||||
expect.objectContaining({ cfg }),
|
||||
);
|
||||
expect(result).toEqual({ channel: "slack", messageId: "m-new-chan", channelId: "C555" });
|
||||
});
|
||||
|
||||
it("sends media to a new user target without erroring", async () => {
|
||||
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-new-media", channelId: "D888" });
|
||||
const sendMedia = slackPlugin.outbound?.sendMedia;
|
||||
expect(sendMedia).toBeDefined();
|
||||
|
||||
const result = await sendMedia!({
|
||||
cfg,
|
||||
to: "user:U88NEW",
|
||||
text: "here is a file",
|
||||
mediaUrl: "https://example.com/file.png",
|
||||
accountId: "default",
|
||||
deps: { sendSlack },
|
||||
});
|
||||
|
||||
expect(sendSlack).toHaveBeenCalledWith(
|
||||
"user:U88NEW",
|
||||
"here is a file",
|
||||
expect.objectContaining({
|
||||
cfg,
|
||||
mediaUrl: "https://example.com/file.png",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ channel: "slack", messageId: "m-new-media", channelId: "D888" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("slackPlugin config", () => {
|
||||
it("treats HTTP mode accounts with bot token + signing secret as configured", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
|
||||
Reference in New Issue
Block a user