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:
Tyler Yust
2026-03-19 11:40:34 +09:00
committed by GitHub
parent ffc1d5459c
commit a290f5e50f
12 changed files with 1380 additions and 32 deletions

View File

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

View File

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

View File

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

View File

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

View File

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