2026-02-16 23:20:16 -05:00
|
|
|
import crypto from "node:crypto";
|
2026-02-17 13:36:48 +09:00
|
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
fix: comprehensive BlueBubbles and channel cleanup (#11093)
* feat(bluebubbles): auto-strip markdown from outbound messages (#7402)
* fix(security): add timeout to webhook body reading (#6762)
Adds 30-second timeout to readBody() in voice-call, bluebubbles, and nostr
webhook handlers. Prevents Slow-Loris DoS (CWE-400, CVSS 7.5).
Merged with existing maxBytes protection in voice-call.
* fix(security): unify Error objects and lint fixes in webhook timeouts (#6762)
* fix: prevent plugins from auto-enabling without user consent (#3961)
Changes default plugin enabled state from true to false in enablePluginEntry().
Preserves existing enabled:true values. Fixes #3932.
* fix: apply hierarchical mediaMaxMb config to all channels (#8749)
Generalizes resolveAttachmentMaxBytes() to use account → channel → global
config resolution for all channels, not just BlueBubbles. Fixes #7847.
* fix(bluebubbles): sanitize attachment filenames against header injection (#10333)
Strip ", \r, \n, and \\ from filenames after path.basename() to prevent
multipart Content-Disposition header injection (CWE-93, CVSS 5.4).
Also adds sanitization to setGroupIconBlueBubbles which had zero filename
sanitization.
* fix(lint): exclude extensions/ from Oxlint preflight check (#9313)
Extensions use PluginRuntime|null patterns that trigger
no-redundant-type-constituents because PluginRuntime resolves to any.
Excluding extensions/ from Oxlint unblocks user upgrades.
Re-applies the approach from closed PR #10087.
* fix(bluebubbles): add tempGuid to createNewChatWithMessage payload (#7745)
Non-Private-API mode (AppleScript) requires tempGuid in send payloads.
The main sendMessageBlueBubbles already had it, but createNewChatWithMessage
was missing it, causing 400 errors for new chat creation without Private API.
* fix: send stop-typing signal when run ends with NO_REPLY (#8785)
Adds onCleanup callback to the typing controller that fires when the
controller is cleaned up while typing was active (e.g., after NO_REPLY).
Channels using createTypingCallbacks automatically get stop-typing on
cleanup. This prevents the typing indicator from lingering in group chats
when the agent decides not to reply.
* fix(telegram): deduplicate skill commands in multi-agent setup (#5717)
Two fixes:
1. Skip duplicate workspace dirs when listing skill commands across agents.
Multiple agents sharing the same workspace would produce duplicate commands
with _2, _3 suffixes.
2. Clear stale commands via deleteMyCommands before registering new ones.
Commands from deleted skills now get cleaned up on restart.
* fix: add size limits to unbounded in-memory caches (#4948)
Adds max-size caps with oldest-entry eviction to prevent OOM in
long-running deployments:
- BlueBubbles serverInfoCache: 64 entries (already has TTL)
- Google Chat authCache: 32 entries
- Matrix directRoomCache: 1024 entries
- Discord presenceCache: 5000 entries per account
* fix: address review concerns (#11093)
- Chain deleteMyCommands → setMyCommands to prevent race condition (#5717)
- Rename enablePluginEntry to registerPluginEntry (now sets enabled: false)
- Add Slow-Loris timeout test for readJsonBody (#6023)
2026-02-07 05:00:55 -08:00
|
|
|
import { stripMarkdown } from "openclaw/plugin-sdk";
|
2026-01-18 03:17:30 +00:00
|
|
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
2026-02-13 21:15:56 -08:00
|
|
|
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
2026-02-15 01:10:45 +00:00
|
|
|
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
|
|
|
|
|
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
|
2026-01-18 03:17:30 +00:00
|
|
|
import {
|
|
|
|
|
blueBubblesFetchWithTimeout,
|
|
|
|
|
buildBlueBubblesApiUrl,
|
|
|
|
|
type BlueBubblesSendTarget,
|
|
|
|
|
} from "./types.js";
|
|
|
|
|
|
|
|
|
|
export type BlueBubblesSendOpts = {
|
|
|
|
|
serverUrl?: string;
|
|
|
|
|
password?: string;
|
|
|
|
|
accountId?: string;
|
|
|
|
|
timeoutMs?: number;
|
2026-01-30 03:15:10 +01:00
|
|
|
cfg?: OpenClawConfig;
|
2026-01-19 18:23:00 -08:00
|
|
|
/** Message GUID to reply to (reply threading) */
|
|
|
|
|
replyToMessageGuid?: string;
|
|
|
|
|
/** Part index for reply (default: 0) */
|
|
|
|
|
replyToPartIndex?: number;
|
|
|
|
|
/** Effect ID or short name for message effects (e.g., "slam", "balloons") */
|
|
|
|
|
effectId?: string;
|
2026-01-18 03:17:30 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type BlueBubblesSendResult = {
|
|
|
|
|
messageId: string;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-19 18:23:00 -08:00
|
|
|
/** Maps short effect names to full Apple effect IDs */
|
|
|
|
|
const EFFECT_MAP: Record<string, string> = {
|
|
|
|
|
// Bubble effects
|
|
|
|
|
slam: "com.apple.MobileSMS.expressivesend.impact",
|
|
|
|
|
loud: "com.apple.MobileSMS.expressivesend.loud",
|
|
|
|
|
gentle: "com.apple.MobileSMS.expressivesend.gentle",
|
|
|
|
|
invisible: "com.apple.MobileSMS.expressivesend.invisibleink",
|
2026-01-19 23:40:22 -08:00
|
|
|
"invisible-ink": "com.apple.MobileSMS.expressivesend.invisibleink",
|
|
|
|
|
"invisible ink": "com.apple.MobileSMS.expressivesend.invisibleink",
|
|
|
|
|
invisibleink: "com.apple.MobileSMS.expressivesend.invisibleink",
|
2026-01-19 18:23:00 -08:00
|
|
|
// Screen effects
|
|
|
|
|
echo: "com.apple.messages.effect.CKEchoEffect",
|
|
|
|
|
spotlight: "com.apple.messages.effect.CKSpotlightEffect",
|
|
|
|
|
balloons: "com.apple.messages.effect.CKHappyBirthdayEffect",
|
|
|
|
|
confetti: "com.apple.messages.effect.CKConfettiEffect",
|
|
|
|
|
love: "com.apple.messages.effect.CKHeartEffect",
|
2026-01-19 23:40:22 -08:00
|
|
|
heart: "com.apple.messages.effect.CKHeartEffect",
|
|
|
|
|
hearts: "com.apple.messages.effect.CKHeartEffect",
|
2026-01-19 18:23:00 -08:00
|
|
|
lasers: "com.apple.messages.effect.CKLasersEffect",
|
|
|
|
|
fireworks: "com.apple.messages.effect.CKFireworksEffect",
|
|
|
|
|
celebration: "com.apple.messages.effect.CKSparklesEffect",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function resolveEffectId(raw?: string): string | undefined {
|
2026-01-31 22:13:48 +09:00
|
|
|
if (!raw) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
2026-01-19 18:23:00 -08:00
|
|
|
const trimmed = raw.trim().toLowerCase();
|
2026-01-31 22:13:48 +09:00
|
|
|
if (EFFECT_MAP[trimmed]) {
|
|
|
|
|
return EFFECT_MAP[trimmed];
|
|
|
|
|
}
|
2026-01-19 23:40:22 -08:00
|
|
|
const normalized = trimmed.replace(/[\s_]+/g, "-");
|
2026-01-31 22:13:48 +09:00
|
|
|
if (EFFECT_MAP[normalized]) {
|
|
|
|
|
return EFFECT_MAP[normalized];
|
|
|
|
|
}
|
2026-01-19 23:40:22 -08:00
|
|
|
const compact = trimmed.replace(/[\s_-]+/g, "");
|
2026-01-31 22:13:48 +09:00
|
|
|
if (EFFECT_MAP[compact]) {
|
|
|
|
|
return EFFECT_MAP[compact];
|
|
|
|
|
}
|
2026-01-19 23:40:22 -08:00
|
|
|
return raw;
|
2026-01-19 18:23:00 -08:00
|
|
|
}
|
|
|
|
|
|
2026-01-18 03:17:30 +00:00
|
|
|
type BlueBubblesChatRecord = Record<string, unknown>;
|
|
|
|
|
|
|
|
|
|
function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
|
|
|
|
|
const candidates = [
|
|
|
|
|
chat.chatGuid,
|
|
|
|
|
chat.guid,
|
|
|
|
|
chat.chat_guid,
|
|
|
|
|
chat.identifier,
|
|
|
|
|
chat.chatIdentifier,
|
|
|
|
|
chat.chat_identifier,
|
|
|
|
|
];
|
|
|
|
|
for (const candidate of candidates) {
|
2026-01-31 22:13:48 +09:00
|
|
|
if (typeof candidate === "string" && candidate.trim()) {
|
|
|
|
|
return candidate.trim();
|
|
|
|
|
}
|
2026-01-18 03:17:30 +00:00
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function extractChatId(chat: BlueBubblesChatRecord): number | null {
|
|
|
|
|
const candidates = [chat.chatId, chat.id, chat.chat_id];
|
|
|
|
|
for (const candidate of candidates) {
|
2026-01-31 22:13:48 +09:00
|
|
|
if (typeof candidate === "number" && Number.isFinite(candidate)) {
|
|
|
|
|
return candidate;
|
|
|
|
|
}
|
2026-01-18 03:17:30 +00:00
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-21 00:14:55 -08:00
|
|
|
function extractChatIdentifierFromChatGuid(chatGuid: string): string | null {
|
|
|
|
|
const parts = chatGuid.split(";");
|
2026-01-31 22:13:48 +09:00
|
|
|
if (parts.length < 3) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-01-21 00:14:55 -08:00
|
|
|
const identifier = parts[2]?.trim();
|
|
|
|
|
return identifier ? identifier : null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 03:17:30 +00:00
|
|
|
function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
|
|
|
|
|
const raw =
|
|
|
|
|
(Array.isArray(chat.participants) ? chat.participants : null) ??
|
|
|
|
|
(Array.isArray(chat.handles) ? chat.handles : null) ??
|
|
|
|
|
(Array.isArray(chat.participantHandles) ? chat.participantHandles : null);
|
2026-01-31 22:13:48 +09:00
|
|
|
if (!raw) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
2026-01-18 03:17:30 +00:00
|
|
|
const out: string[] = [];
|
|
|
|
|
for (const entry of raw) {
|
|
|
|
|
if (typeof entry === "string") {
|
|
|
|
|
out.push(entry);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (entry && typeof entry === "object") {
|
|
|
|
|
const record = entry as Record<string, unknown>;
|
|
|
|
|
const candidate =
|
|
|
|
|
(typeof record.address === "string" && record.address) ||
|
|
|
|
|
(typeof record.handle === "string" && record.handle) ||
|
|
|
|
|
(typeof record.id === "string" && record.id) ||
|
|
|
|
|
(typeof record.identifier === "string" && record.identifier);
|
2026-01-31 22:13:48 +09:00
|
|
|
if (candidate) {
|
|
|
|
|
out.push(candidate);
|
|
|
|
|
}
|
2026-01-18 03:17:30 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function queryChats(params: {
|
|
|
|
|
baseUrl: string;
|
|
|
|
|
password: string;
|
|
|
|
|
timeoutMs?: number;
|
|
|
|
|
offset: number;
|
|
|
|
|
limit: number;
|
|
|
|
|
}): Promise<BlueBubblesChatRecord[]> {
|
|
|
|
|
const url = buildBlueBubblesApiUrl({
|
|
|
|
|
baseUrl: params.baseUrl,
|
|
|
|
|
path: "/api/v1/chat/query",
|
|
|
|
|
password: params.password,
|
|
|
|
|
});
|
|
|
|
|
const res = await blueBubblesFetchWithTimeout(
|
|
|
|
|
url,
|
|
|
|
|
{
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
limit: params.limit,
|
|
|
|
|
offset: params.offset,
|
|
|
|
|
with: ["participants"],
|
|
|
|
|
}),
|
|
|
|
|
},
|
|
|
|
|
params.timeoutMs,
|
|
|
|
|
);
|
2026-01-31 22:13:48 +09:00
|
|
|
if (!res.ok) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
2026-01-18 03:17:30 +00:00
|
|
|
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
|
|
|
|
const data = payload && typeof payload.data !== "undefined" ? (payload.data as unknown) : null;
|
|
|
|
|
return Array.isArray(data) ? (data as BlueBubblesChatRecord[]) : [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function resolveChatGuidForTarget(params: {
|
|
|
|
|
baseUrl: string;
|
|
|
|
|
password: string;
|
|
|
|
|
timeoutMs?: number;
|
|
|
|
|
target: BlueBubblesSendTarget;
|
|
|
|
|
}): Promise<string | null> {
|
2026-01-31 22:13:48 +09:00
|
|
|
if (params.target.kind === "chat_guid") {
|
|
|
|
|
return params.target.chatGuid;
|
|
|
|
|
}
|
2026-01-18 03:17:30 +00:00
|
|
|
|
|
|
|
|
const normalizedHandle =
|
|
|
|
|
params.target.kind === "handle" ? normalizeBlueBubblesHandle(params.target.address) : "";
|
|
|
|
|
const targetChatId = params.target.kind === "chat_id" ? params.target.chatId : null;
|
|
|
|
|
const targetChatIdentifier =
|
|
|
|
|
params.target.kind === "chat_identifier" ? params.target.chatIdentifier : null;
|
|
|
|
|
|
|
|
|
|
const limit = 500;
|
2026-01-19 23:40:22 -08:00
|
|
|
let participantMatch: string | null = null;
|
2026-01-18 03:17:30 +00:00
|
|
|
for (let offset = 0; offset < 5000; offset += limit) {
|
|
|
|
|
const chats = await queryChats({
|
|
|
|
|
baseUrl: params.baseUrl,
|
|
|
|
|
password: params.password,
|
|
|
|
|
timeoutMs: params.timeoutMs,
|
|
|
|
|
offset,
|
|
|
|
|
limit,
|
|
|
|
|
});
|
2026-01-31 22:13:48 +09:00
|
|
|
if (chats.length === 0) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
2026-01-18 03:17:30 +00:00
|
|
|
for (const chat of chats) {
|
|
|
|
|
if (targetChatId != null) {
|
|
|
|
|
const chatId = extractChatId(chat);
|
|
|
|
|
if (chatId != null && chatId === targetChatId) {
|
|
|
|
|
return extractChatGuid(chat);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (targetChatIdentifier) {
|
|
|
|
|
const guid = extractChatGuid(chat);
|
2026-01-21 00:14:55 -08:00
|
|
|
if (guid) {
|
|
|
|
|
// Back-compat: some callers might pass a full chat GUID.
|
2026-01-31 22:13:48 +09:00
|
|
|
if (guid === targetChatIdentifier) {
|
|
|
|
|
return guid;
|
|
|
|
|
}
|
2026-01-21 00:14:55 -08:00
|
|
|
|
|
|
|
|
// Primary match: BlueBubbles `chat_identifier:*` targets correspond to the
|
|
|
|
|
// third component of the chat GUID: `service;(+|-) ;identifier`.
|
|
|
|
|
const guidIdentifier = extractChatIdentifierFromChatGuid(guid);
|
2026-01-31 22:13:48 +09:00
|
|
|
if (guidIdentifier && guidIdentifier === targetChatIdentifier) {
|
|
|
|
|
return guid;
|
|
|
|
|
}
|
2026-01-21 00:14:55 -08:00
|
|
|
}
|
|
|
|
|
|
2026-01-18 03:17:30 +00:00
|
|
|
const identifier =
|
|
|
|
|
typeof chat.identifier === "string"
|
|
|
|
|
? chat.identifier
|
|
|
|
|
: typeof chat.chatIdentifier === "string"
|
|
|
|
|
? chat.chatIdentifier
|
|
|
|
|
: typeof chat.chat_identifier === "string"
|
|
|
|
|
? chat.chat_identifier
|
|
|
|
|
: "";
|
2026-01-31 22:13:48 +09:00
|
|
|
if (identifier && identifier === targetChatIdentifier) {
|
|
|
|
|
return guid ?? extractChatGuid(chat);
|
|
|
|
|
}
|
2026-01-18 03:17:30 +00:00
|
|
|
}
|
|
|
|
|
if (normalizedHandle) {
|
2026-01-19 23:40:22 -08:00
|
|
|
const guid = extractChatGuid(chat);
|
|
|
|
|
const directHandle = guid ? extractHandleFromChatGuid(guid) : null;
|
|
|
|
|
if (directHandle && directHandle === normalizedHandle) {
|
|
|
|
|
return guid;
|
|
|
|
|
}
|
|
|
|
|
if (!participantMatch && guid) {
|
fix(bluebubbles): route phone-number targets to direct chats; prevent internal IDs leaking in cross-context prefix (#1751)
* fix(bluebubbles): prefer DM resolution + hide routing markers
* fix(bluebubbles): prevent message routing to group chats when targeting phone numbers
When sending a message to a phone number like +12622102921, the
resolveChatGuidForTarget function was finding and returning a GROUP
CHAT containing that phone number instead of a direct DM chat.
The bug was in the participantMatch fallback logic which matched ANY
chat containing the phone number as a participant, including groups.
This fix adds a check to ensure participantMatch only considers DM
chats (identified by ';-;' separator in the chat GUID). Group chats
(identified by ';+;' separator) are now explicitly excluded from
handle-based matching.
If a phone number only exists in a group chat (no direct DM exists),
the function now correctly returns null, which causes the send to
fail with a clear error rather than accidentally messaging a group.
Added test case to verify this behavior.
* feat(bluebubbles): auto-create new DM chats when sending to unknown phone numbers
When sending to a phone number that doesn't have an existing chat,
instead of failing with 'chatGuid not found', now automatically creates
a new chat using the /api/v1/chat/new endpoint.
- Added createNewChatWithMessage() helper function
- When resolveChatGuidForTarget returns null for a handle target,
uses the new chat endpoint with addresses array and message
- Includes helpful error message if Private API isn't enabled
- Only applies to handle targets (phone numbers), not group chats
* fix(bluebubbles): hide internal routing metadata in cross-context markers
When sending cross-context messages via BlueBubbles, the origin marker was
exposing internal chat_guid routing info like '[from bluebubbles:chat_guid:any;-;+19257864429]'.
This adds a formatTargetDisplay() function to the BlueBubbles plugin that:
- Extracts phone numbers from chat_guid formats (iMessage;-;+1234567890 -> +1234567890)
- Normalizes handles for clean display
- Avoids returning raw chat_guid formats containing internal routing metadata
Now cross-context markers show clean identifiers like '[from +19257864429]' instead
of exposing internal routing details to recipients.
* fix: prevent cross-context decoration on direct message tool sends
Two fixes:
1. Cross-context decoration (e.g., '[from +19257864429]' prefix) was being
added to ALL messages sent to a different target, even when the agent
was just composing a new message via the message tool. This decoration
should only be applied when forwarding/relaying messages between chats.
Fix: Added skipCrossContextDecoration flag to ChannelThreadingToolContext.
The message tool now sets this flag to true, so direct sends don't get
decorated. The buildCrossContextDecoration function checks this flag
and returns null when set.
2. Aborted requests were still completing because the abort signal wasn't
being passed through the message tool execution chain.
Fix: Added abortSignal propagation from message tool → runMessageAction →
executeSendAction → sendMessage → deliverOutboundPayloads. Added abort
checks at key points in the chain to fail fast when aborted.
Files changed:
- src/channels/plugins/types.core.ts: Added skipCrossContextDecoration field
- src/infra/outbound/outbound-policy.ts: Check skip flag before decorating
- src/agents/tools/message-tool.ts: Set skip flag, accept and pass abort signal
- src/infra/outbound/message-action-runner.ts: Pass abort signal through
- src/infra/outbound/outbound-send-service.ts: Check and pass abort signal
- src/infra/outbound/message.ts: Pass abort signal to delivery
* fix(bluebubbles): preserve friendly display names in formatTargetDisplay
2026-01-25 02:03:08 -08:00
|
|
|
// Only consider DM chats (`;-;` separator) as participant matches.
|
|
|
|
|
// Group chats (`;+;` separator) should never match when searching by handle/phone.
|
|
|
|
|
// This prevents routing "send to +1234567890" to a group chat that contains that number.
|
|
|
|
|
const isDmChat = guid.includes(";-;");
|
|
|
|
|
if (isDmChat) {
|
|
|
|
|
const participants = extractParticipantAddresses(chat).map((entry) =>
|
|
|
|
|
normalizeBlueBubblesHandle(entry),
|
|
|
|
|
);
|
|
|
|
|
if (participants.includes(normalizedHandle)) {
|
|
|
|
|
participantMatch = guid;
|
|
|
|
|
}
|
2026-01-19 23:40:22 -08:00
|
|
|
}
|
2026-01-18 03:17:30 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-19 23:40:22 -08:00
|
|
|
return participantMatch;
|
2026-01-18 03:17:30 +00:00
|
|
|
}
|
|
|
|
|
|
fix(bluebubbles): route phone-number targets to direct chats; prevent internal IDs leaking in cross-context prefix (#1751)
* fix(bluebubbles): prefer DM resolution + hide routing markers
* fix(bluebubbles): prevent message routing to group chats when targeting phone numbers
When sending a message to a phone number like +12622102921, the
resolveChatGuidForTarget function was finding and returning a GROUP
CHAT containing that phone number instead of a direct DM chat.
The bug was in the participantMatch fallback logic which matched ANY
chat containing the phone number as a participant, including groups.
This fix adds a check to ensure participantMatch only considers DM
chats (identified by ';-;' separator in the chat GUID). Group chats
(identified by ';+;' separator) are now explicitly excluded from
handle-based matching.
If a phone number only exists in a group chat (no direct DM exists),
the function now correctly returns null, which causes the send to
fail with a clear error rather than accidentally messaging a group.
Added test case to verify this behavior.
* feat(bluebubbles): auto-create new DM chats when sending to unknown phone numbers
When sending to a phone number that doesn't have an existing chat,
instead of failing with 'chatGuid not found', now automatically creates
a new chat using the /api/v1/chat/new endpoint.
- Added createNewChatWithMessage() helper function
- When resolveChatGuidForTarget returns null for a handle target,
uses the new chat endpoint with addresses array and message
- Includes helpful error message if Private API isn't enabled
- Only applies to handle targets (phone numbers), not group chats
* fix(bluebubbles): hide internal routing metadata in cross-context markers
When sending cross-context messages via BlueBubbles, the origin marker was
exposing internal chat_guid routing info like '[from bluebubbles:chat_guid:any;-;+19257864429]'.
This adds a formatTargetDisplay() function to the BlueBubbles plugin that:
- Extracts phone numbers from chat_guid formats (iMessage;-;+1234567890 -> +1234567890)
- Normalizes handles for clean display
- Avoids returning raw chat_guid formats containing internal routing metadata
Now cross-context markers show clean identifiers like '[from +19257864429]' instead
of exposing internal routing details to recipients.
* fix: prevent cross-context decoration on direct message tool sends
Two fixes:
1. Cross-context decoration (e.g., '[from +19257864429]' prefix) was being
added to ALL messages sent to a different target, even when the agent
was just composing a new message via the message tool. This decoration
should only be applied when forwarding/relaying messages between chats.
Fix: Added skipCrossContextDecoration flag to ChannelThreadingToolContext.
The message tool now sets this flag to true, so direct sends don't get
decorated. The buildCrossContextDecoration function checks this flag
and returns null when set.
2. Aborted requests were still completing because the abort signal wasn't
being passed through the message tool execution chain.
Fix: Added abortSignal propagation from message tool → runMessageAction →
executeSendAction → sendMessage → deliverOutboundPayloads. Added abort
checks at key points in the chain to fail fast when aborted.
Files changed:
- src/channels/plugins/types.core.ts: Added skipCrossContextDecoration field
- src/infra/outbound/outbound-policy.ts: Check skip flag before decorating
- src/agents/tools/message-tool.ts: Set skip flag, accept and pass abort signal
- src/infra/outbound/message-action-runner.ts: Pass abort signal through
- src/infra/outbound/outbound-send-service.ts: Check and pass abort signal
- src/infra/outbound/message.ts: Pass abort signal to delivery
* fix(bluebubbles): preserve friendly display names in formatTargetDisplay
2026-01-25 02:03:08 -08:00
|
|
|
/**
|
|
|
|
|
* Creates a new chat (DM) and optionally 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 url = buildBlueBubblesApiUrl({
|
|
|
|
|
baseUrl: params.baseUrl,
|
|
|
|
|
path: "/api/v1/chat/new",
|
|
|
|
|
password: params.password,
|
|
|
|
|
});
|
|
|
|
|
const payload = {
|
|
|
|
|
addresses: [params.address],
|
|
|
|
|
message: params.message,
|
fix: comprehensive BlueBubbles and channel cleanup (#11093)
* feat(bluebubbles): auto-strip markdown from outbound messages (#7402)
* fix(security): add timeout to webhook body reading (#6762)
Adds 30-second timeout to readBody() in voice-call, bluebubbles, and nostr
webhook handlers. Prevents Slow-Loris DoS (CWE-400, CVSS 7.5).
Merged with existing maxBytes protection in voice-call.
* fix(security): unify Error objects and lint fixes in webhook timeouts (#6762)
* fix: prevent plugins from auto-enabling without user consent (#3961)
Changes default plugin enabled state from true to false in enablePluginEntry().
Preserves existing enabled:true values. Fixes #3932.
* fix: apply hierarchical mediaMaxMb config to all channels (#8749)
Generalizes resolveAttachmentMaxBytes() to use account → channel → global
config resolution for all channels, not just BlueBubbles. Fixes #7847.
* fix(bluebubbles): sanitize attachment filenames against header injection (#10333)
Strip ", \r, \n, and \\ from filenames after path.basename() to prevent
multipart Content-Disposition header injection (CWE-93, CVSS 5.4).
Also adds sanitization to setGroupIconBlueBubbles which had zero filename
sanitization.
* fix(lint): exclude extensions/ from Oxlint preflight check (#9313)
Extensions use PluginRuntime|null patterns that trigger
no-redundant-type-constituents because PluginRuntime resolves to any.
Excluding extensions/ from Oxlint unblocks user upgrades.
Re-applies the approach from closed PR #10087.
* fix(bluebubbles): add tempGuid to createNewChatWithMessage payload (#7745)
Non-Private-API mode (AppleScript) requires tempGuid in send payloads.
The main sendMessageBlueBubbles already had it, but createNewChatWithMessage
was missing it, causing 400 errors for new chat creation without Private API.
* fix: send stop-typing signal when run ends with NO_REPLY (#8785)
Adds onCleanup callback to the typing controller that fires when the
controller is cleaned up while typing was active (e.g., after NO_REPLY).
Channels using createTypingCallbacks automatically get stop-typing on
cleanup. This prevents the typing indicator from lingering in group chats
when the agent decides not to reply.
* fix(telegram): deduplicate skill commands in multi-agent setup (#5717)
Two fixes:
1. Skip duplicate workspace dirs when listing skill commands across agents.
Multiple agents sharing the same workspace would produce duplicate commands
with _2, _3 suffixes.
2. Clear stale commands via deleteMyCommands before registering new ones.
Commands from deleted skills now get cleaned up on restart.
* fix: add size limits to unbounded in-memory caches (#4948)
Adds max-size caps with oldest-entry eviction to prevent OOM in
long-running deployments:
- BlueBubbles serverInfoCache: 64 entries (already has TTL)
- Google Chat authCache: 32 entries
- Matrix directRoomCache: 1024 entries
- Discord presenceCache: 5000 entries per account
* fix: address review concerns (#11093)
- Chain deleteMyCommands → setMyCommands to prevent race condition (#5717)
- Rename enablePluginEntry to registerPluginEntry (now sets enabled: false)
- Add Slow-Loris timeout test for readJsonBody (#6023)
2026-02-07 05:00:55 -08:00
|
|
|
tempGuid: `temp-${crypto.randomUUID()}`,
|
fix(bluebubbles): route phone-number targets to direct chats; prevent internal IDs leaking in cross-context prefix (#1751)
* fix(bluebubbles): prefer DM resolution + hide routing markers
* fix(bluebubbles): prevent message routing to group chats when targeting phone numbers
When sending a message to a phone number like +12622102921, the
resolveChatGuidForTarget function was finding and returning a GROUP
CHAT containing that phone number instead of a direct DM chat.
The bug was in the participantMatch fallback logic which matched ANY
chat containing the phone number as a participant, including groups.
This fix adds a check to ensure participantMatch only considers DM
chats (identified by ';-;' separator in the chat GUID). Group chats
(identified by ';+;' separator) are now explicitly excluded from
handle-based matching.
If a phone number only exists in a group chat (no direct DM exists),
the function now correctly returns null, which causes the send to
fail with a clear error rather than accidentally messaging a group.
Added test case to verify this behavior.
* feat(bluebubbles): auto-create new DM chats when sending to unknown phone numbers
When sending to a phone number that doesn't have an existing chat,
instead of failing with 'chatGuid not found', now automatically creates
a new chat using the /api/v1/chat/new endpoint.
- Added createNewChatWithMessage() helper function
- When resolveChatGuidForTarget returns null for a handle target,
uses the new chat endpoint with addresses array and message
- Includes helpful error message if Private API isn't enabled
- Only applies to handle targets (phone numbers), not group chats
* fix(bluebubbles): hide internal routing metadata in cross-context markers
When sending cross-context messages via BlueBubbles, the origin marker was
exposing internal chat_guid routing info like '[from bluebubbles:chat_guid:any;-;+19257864429]'.
This adds a formatTargetDisplay() function to the BlueBubbles plugin that:
- Extracts phone numbers from chat_guid formats (iMessage;-;+1234567890 -> +1234567890)
- Normalizes handles for clean display
- Avoids returning raw chat_guid formats containing internal routing metadata
Now cross-context markers show clean identifiers like '[from +19257864429]' instead
of exposing internal routing details to recipients.
* fix: prevent cross-context decoration on direct message tool sends
Two fixes:
1. Cross-context decoration (e.g., '[from +19257864429]' prefix) was being
added to ALL messages sent to a different target, even when the agent
was just composing a new message via the message tool. This decoration
should only be applied when forwarding/relaying messages between chats.
Fix: Added skipCrossContextDecoration flag to ChannelThreadingToolContext.
The message tool now sets this flag to true, so direct sends don't get
decorated. The buildCrossContextDecoration function checks this flag
and returns null when set.
2. Aborted requests were still completing because the abort signal wasn't
being passed through the message tool execution chain.
Fix: Added abortSignal propagation from message tool → runMessageAction →
executeSendAction → sendMessage → deliverOutboundPayloads. Added abort
checks at key points in the chain to fail fast when aborted.
Files changed:
- src/channels/plugins/types.core.ts: Added skipCrossContextDecoration field
- src/infra/outbound/outbound-policy.ts: Check skip flag before decorating
- src/agents/tools/message-tool.ts: Set skip flag, accept and pass abort signal
- src/infra/outbound/message-action-runner.ts: Pass abort signal through
- src/infra/outbound/outbound-send-service.ts: Check and pass abort signal
- src/infra/outbound/message.ts: Pass abort signal to delivery
* fix(bluebubbles): preserve friendly display names in formatTargetDisplay
2026-01-25 02:03:08 -08:00
|
|
|
};
|
|
|
|
|
const res = await blueBubblesFetchWithTimeout(
|
|
|
|
|
url,
|
|
|
|
|
{
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify(payload),
|
|
|
|
|
},
|
|
|
|
|
params.timeoutMs,
|
|
|
|
|
);
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const errorText = await res.text();
|
|
|
|
|
// Check for Private API not enabled error
|
2026-01-31 21:13:13 +09:00
|
|
|
if (
|
|
|
|
|
res.status === 400 ||
|
|
|
|
|
res.status === 403 ||
|
|
|
|
|
errorText.toLowerCase().includes("private api")
|
|
|
|
|
) {
|
fix(bluebubbles): route phone-number targets to direct chats; prevent internal IDs leaking in cross-context prefix (#1751)
* fix(bluebubbles): prefer DM resolution + hide routing markers
* fix(bluebubbles): prevent message routing to group chats when targeting phone numbers
When sending a message to a phone number like +12622102921, the
resolveChatGuidForTarget function was finding and returning a GROUP
CHAT containing that phone number instead of a direct DM chat.
The bug was in the participantMatch fallback logic which matched ANY
chat containing the phone number as a participant, including groups.
This fix adds a check to ensure participantMatch only considers DM
chats (identified by ';-;' separator in the chat GUID). Group chats
(identified by ';+;' separator) are now explicitly excluded from
handle-based matching.
If a phone number only exists in a group chat (no direct DM exists),
the function now correctly returns null, which causes the send to
fail with a clear error rather than accidentally messaging a group.
Added test case to verify this behavior.
* feat(bluebubbles): auto-create new DM chats when sending to unknown phone numbers
When sending to a phone number that doesn't have an existing chat,
instead of failing with 'chatGuid not found', now automatically creates
a new chat using the /api/v1/chat/new endpoint.
- Added createNewChatWithMessage() helper function
- When resolveChatGuidForTarget returns null for a handle target,
uses the new chat endpoint with addresses array and message
- Includes helpful error message if Private API isn't enabled
- Only applies to handle targets (phone numbers), not group chats
* fix(bluebubbles): hide internal routing metadata in cross-context markers
When sending cross-context messages via BlueBubbles, the origin marker was
exposing internal chat_guid routing info like '[from bluebubbles:chat_guid:any;-;+19257864429]'.
This adds a formatTargetDisplay() function to the BlueBubbles plugin that:
- Extracts phone numbers from chat_guid formats (iMessage;-;+1234567890 -> +1234567890)
- Normalizes handles for clean display
- Avoids returning raw chat_guid formats containing internal routing metadata
Now cross-context markers show clean identifiers like '[from +19257864429]' instead
of exposing internal routing details to recipients.
* fix: prevent cross-context decoration on direct message tool sends
Two fixes:
1. Cross-context decoration (e.g., '[from +19257864429]' prefix) was being
added to ALL messages sent to a different target, even when the agent
was just composing a new message via the message tool. This decoration
should only be applied when forwarding/relaying messages between chats.
Fix: Added skipCrossContextDecoration flag to ChannelThreadingToolContext.
The message tool now sets this flag to true, so direct sends don't get
decorated. The buildCrossContextDecoration function checks this flag
and returns null when set.
2. Aborted requests were still completing because the abort signal wasn't
being passed through the message tool execution chain.
Fix: Added abortSignal propagation from message tool → runMessageAction →
executeSendAction → sendMessage → deliverOutboundPayloads. Added abort
checks at key points in the chain to fail fast when aborted.
Files changed:
- src/channels/plugins/types.core.ts: Added skipCrossContextDecoration field
- src/infra/outbound/outbound-policy.ts: Check skip flag before decorating
- src/agents/tools/message-tool.ts: Set skip flag, accept and pass abort signal
- src/infra/outbound/message-action-runner.ts: Pass abort signal through
- src/infra/outbound/outbound-send-service.ts: Check and pass abort signal
- src/infra/outbound/message.ts: Pass abort signal to delivery
* fix(bluebubbles): preserve friendly display names in formatTargetDisplay
2026-01-25 02:03:08 -08:00
|
|
|
throw new Error(
|
|
|
|
|
`BlueBubbles send failed: Cannot create new chat - Private API must be enabled. Original error: ${errorText || res.status}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`);
|
|
|
|
|
}
|
|
|
|
|
const body = await res.text();
|
2026-01-31 22:13:48 +09:00
|
|
|
if (!body) {
|
|
|
|
|
return { messageId: "ok" };
|
|
|
|
|
}
|
fix(bluebubbles): route phone-number targets to direct chats; prevent internal IDs leaking in cross-context prefix (#1751)
* fix(bluebubbles): prefer DM resolution + hide routing markers
* fix(bluebubbles): prevent message routing to group chats when targeting phone numbers
When sending a message to a phone number like +12622102921, the
resolveChatGuidForTarget function was finding and returning a GROUP
CHAT containing that phone number instead of a direct DM chat.
The bug was in the participantMatch fallback logic which matched ANY
chat containing the phone number as a participant, including groups.
This fix adds a check to ensure participantMatch only considers DM
chats (identified by ';-;' separator in the chat GUID). Group chats
(identified by ';+;' separator) are now explicitly excluded from
handle-based matching.
If a phone number only exists in a group chat (no direct DM exists),
the function now correctly returns null, which causes the send to
fail with a clear error rather than accidentally messaging a group.
Added test case to verify this behavior.
* feat(bluebubbles): auto-create new DM chats when sending to unknown phone numbers
When sending to a phone number that doesn't have an existing chat,
instead of failing with 'chatGuid not found', now automatically creates
a new chat using the /api/v1/chat/new endpoint.
- Added createNewChatWithMessage() helper function
- When resolveChatGuidForTarget returns null for a handle target,
uses the new chat endpoint with addresses array and message
- Includes helpful error message if Private API isn't enabled
- Only applies to handle targets (phone numbers), not group chats
* fix(bluebubbles): hide internal routing metadata in cross-context markers
When sending cross-context messages via BlueBubbles, the origin marker was
exposing internal chat_guid routing info like '[from bluebubbles:chat_guid:any;-;+19257864429]'.
This adds a formatTargetDisplay() function to the BlueBubbles plugin that:
- Extracts phone numbers from chat_guid formats (iMessage;-;+1234567890 -> +1234567890)
- Normalizes handles for clean display
- Avoids returning raw chat_guid formats containing internal routing metadata
Now cross-context markers show clean identifiers like '[from +19257864429]' instead
of exposing internal routing details to recipients.
* fix: prevent cross-context decoration on direct message tool sends
Two fixes:
1. Cross-context decoration (e.g., '[from +19257864429]' prefix) was being
added to ALL messages sent to a different target, even when the agent
was just composing a new message via the message tool. This decoration
should only be applied when forwarding/relaying messages between chats.
Fix: Added skipCrossContextDecoration flag to ChannelThreadingToolContext.
The message tool now sets this flag to true, so direct sends don't get
decorated. The buildCrossContextDecoration function checks this flag
and returns null when set.
2. Aborted requests were still completing because the abort signal wasn't
being passed through the message tool execution chain.
Fix: Added abortSignal propagation from message tool → runMessageAction →
executeSendAction → sendMessage → deliverOutboundPayloads. Added abort
checks at key points in the chain to fail fast when aborted.
Files changed:
- src/channels/plugins/types.core.ts: Added skipCrossContextDecoration field
- src/infra/outbound/outbound-policy.ts: Check skip flag before decorating
- src/agents/tools/message-tool.ts: Set skip flag, accept and pass abort signal
- src/infra/outbound/message-action-runner.ts: Pass abort signal through
- src/infra/outbound/outbound-send-service.ts: Check and pass abort signal
- src/infra/outbound/message.ts: Pass abort signal to delivery
* fix(bluebubbles): preserve friendly display names in formatTargetDisplay
2026-01-25 02:03:08 -08:00
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(body) as unknown;
|
2026-02-15 01:10:45 +00:00
|
|
|
return { messageId: extractBlueBubblesMessageId(parsed) };
|
fix(bluebubbles): route phone-number targets to direct chats; prevent internal IDs leaking in cross-context prefix (#1751)
* fix(bluebubbles): prefer DM resolution + hide routing markers
* fix(bluebubbles): prevent message routing to group chats when targeting phone numbers
When sending a message to a phone number like +12622102921, the
resolveChatGuidForTarget function was finding and returning a GROUP
CHAT containing that phone number instead of a direct DM chat.
The bug was in the participantMatch fallback logic which matched ANY
chat containing the phone number as a participant, including groups.
This fix adds a check to ensure participantMatch only considers DM
chats (identified by ';-;' separator in the chat GUID). Group chats
(identified by ';+;' separator) are now explicitly excluded from
handle-based matching.
If a phone number only exists in a group chat (no direct DM exists),
the function now correctly returns null, which causes the send to
fail with a clear error rather than accidentally messaging a group.
Added test case to verify this behavior.
* feat(bluebubbles): auto-create new DM chats when sending to unknown phone numbers
When sending to a phone number that doesn't have an existing chat,
instead of failing with 'chatGuid not found', now automatically creates
a new chat using the /api/v1/chat/new endpoint.
- Added createNewChatWithMessage() helper function
- When resolveChatGuidForTarget returns null for a handle target,
uses the new chat endpoint with addresses array and message
- Includes helpful error message if Private API isn't enabled
- Only applies to handle targets (phone numbers), not group chats
* fix(bluebubbles): hide internal routing metadata in cross-context markers
When sending cross-context messages via BlueBubbles, the origin marker was
exposing internal chat_guid routing info like '[from bluebubbles:chat_guid:any;-;+19257864429]'.
This adds a formatTargetDisplay() function to the BlueBubbles plugin that:
- Extracts phone numbers from chat_guid formats (iMessage;-;+1234567890 -> +1234567890)
- Normalizes handles for clean display
- Avoids returning raw chat_guid formats containing internal routing metadata
Now cross-context markers show clean identifiers like '[from +19257864429]' instead
of exposing internal routing details to recipients.
* fix: prevent cross-context decoration on direct message tool sends
Two fixes:
1. Cross-context decoration (e.g., '[from +19257864429]' prefix) was being
added to ALL messages sent to a different target, even when the agent
was just composing a new message via the message tool. This decoration
should only be applied when forwarding/relaying messages between chats.
Fix: Added skipCrossContextDecoration flag to ChannelThreadingToolContext.
The message tool now sets this flag to true, so direct sends don't get
decorated. The buildCrossContextDecoration function checks this flag
and returns null when set.
2. Aborted requests were still completing because the abort signal wasn't
being passed through the message tool execution chain.
Fix: Added abortSignal propagation from message tool → runMessageAction →
executeSendAction → sendMessage → deliverOutboundPayloads. Added abort
checks at key points in the chain to fail fast when aborted.
Files changed:
- src/channels/plugins/types.core.ts: Added skipCrossContextDecoration field
- src/infra/outbound/outbound-policy.ts: Check skip flag before decorating
- src/agents/tools/message-tool.ts: Set skip flag, accept and pass abort signal
- src/infra/outbound/message-action-runner.ts: Pass abort signal through
- src/infra/outbound/outbound-send-service.ts: Check and pass abort signal
- src/infra/outbound/message.ts: Pass abort signal to delivery
* fix(bluebubbles): preserve friendly display names in formatTargetDisplay
2026-01-25 02:03:08 -08:00
|
|
|
} catch {
|
|
|
|
|
return { messageId: "ok" };
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 03:17:30 +00:00
|
|
|
export async function sendMessageBlueBubbles(
|
|
|
|
|
to: string,
|
|
|
|
|
text: string,
|
|
|
|
|
opts: BlueBubblesSendOpts = {},
|
|
|
|
|
): Promise<BlueBubblesSendResult> {
|
2026-02-16 21:11:53 -05:00
|
|
|
const trimmedText = text ?? "";
|
2026-01-18 03:17:30 +00:00
|
|
|
if (!trimmedText.trim()) {
|
|
|
|
|
throw new Error("BlueBubbles send requires text");
|
|
|
|
|
}
|
fix: comprehensive BlueBubbles and channel cleanup (#11093)
* feat(bluebubbles): auto-strip markdown from outbound messages (#7402)
* fix(security): add timeout to webhook body reading (#6762)
Adds 30-second timeout to readBody() in voice-call, bluebubbles, and nostr
webhook handlers. Prevents Slow-Loris DoS (CWE-400, CVSS 7.5).
Merged with existing maxBytes protection in voice-call.
* fix(security): unify Error objects and lint fixes in webhook timeouts (#6762)
* fix: prevent plugins from auto-enabling without user consent (#3961)
Changes default plugin enabled state from true to false in enablePluginEntry().
Preserves existing enabled:true values. Fixes #3932.
* fix: apply hierarchical mediaMaxMb config to all channels (#8749)
Generalizes resolveAttachmentMaxBytes() to use account → channel → global
config resolution for all channels, not just BlueBubbles. Fixes #7847.
* fix(bluebubbles): sanitize attachment filenames against header injection (#10333)
Strip ", \r, \n, and \\ from filenames after path.basename() to prevent
multipart Content-Disposition header injection (CWE-93, CVSS 5.4).
Also adds sanitization to setGroupIconBlueBubbles which had zero filename
sanitization.
* fix(lint): exclude extensions/ from Oxlint preflight check (#9313)
Extensions use PluginRuntime|null patterns that trigger
no-redundant-type-constituents because PluginRuntime resolves to any.
Excluding extensions/ from Oxlint unblocks user upgrades.
Re-applies the approach from closed PR #10087.
* fix(bluebubbles): add tempGuid to createNewChatWithMessage payload (#7745)
Non-Private-API mode (AppleScript) requires tempGuid in send payloads.
The main sendMessageBlueBubbles already had it, but createNewChatWithMessage
was missing it, causing 400 errors for new chat creation without Private API.
* fix: send stop-typing signal when run ends with NO_REPLY (#8785)
Adds onCleanup callback to the typing controller that fires when the
controller is cleaned up while typing was active (e.g., after NO_REPLY).
Channels using createTypingCallbacks automatically get stop-typing on
cleanup. This prevents the typing indicator from lingering in group chats
when the agent decides not to reply.
* fix(telegram): deduplicate skill commands in multi-agent setup (#5717)
Two fixes:
1. Skip duplicate workspace dirs when listing skill commands across agents.
Multiple agents sharing the same workspace would produce duplicate commands
with _2, _3 suffixes.
2. Clear stale commands via deleteMyCommands before registering new ones.
Commands from deleted skills now get cleaned up on restart.
* fix: add size limits to unbounded in-memory caches (#4948)
Adds max-size caps with oldest-entry eviction to prevent OOM in
long-running deployments:
- BlueBubbles serverInfoCache: 64 entries (already has TTL)
- Google Chat authCache: 32 entries
- Matrix directRoomCache: 1024 entries
- Discord presenceCache: 5000 entries per account
* fix: address review concerns (#11093)
- Chain deleteMyCommands → setMyCommands to prevent race condition (#5717)
- Rename enablePluginEntry to registerPluginEntry (now sets enabled: false)
- Add Slow-Loris timeout test for readJsonBody (#6023)
2026-02-07 05:00:55 -08:00
|
|
|
// Strip markdown early and validate - ensures messages like "***" or "---" don't become empty
|
|
|
|
|
const strippedText = stripMarkdown(trimmedText);
|
|
|
|
|
if (!strippedText.trim()) {
|
|
|
|
|
throw new Error("BlueBubbles send requires text (message was empty after markdown removal)");
|
|
|
|
|
}
|
2026-01-18 03:17:30 +00:00
|
|
|
|
|
|
|
|
const account = resolveBlueBubblesAccount({
|
|
|
|
|
cfg: opts.cfg ?? {},
|
|
|
|
|
accountId: opts.accountId,
|
|
|
|
|
});
|
|
|
|
|
const baseUrl = opts.serverUrl?.trim() || account.config.serverUrl?.trim();
|
|
|
|
|
const password = opts.password?.trim() || account.config.password?.trim();
|
2026-01-31 22:13:48 +09:00
|
|
|
if (!baseUrl) {
|
|
|
|
|
throw new Error("BlueBubbles serverUrl is required");
|
|
|
|
|
}
|
|
|
|
|
if (!password) {
|
|
|
|
|
throw new Error("BlueBubbles password is required");
|
|
|
|
|
}
|
2026-02-13 21:15:56 -08:00
|
|
|
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId);
|
2026-01-18 03:17:30 +00:00
|
|
|
|
2026-02-15 01:10:45 +00:00
|
|
|
const target = resolveBlueBubblesSendTarget(to);
|
2026-01-18 03:17:30 +00:00
|
|
|
const chatGuid = await resolveChatGuidForTarget({
|
|
|
|
|
baseUrl,
|
|
|
|
|
password,
|
|
|
|
|
timeoutMs: opts.timeoutMs,
|
|
|
|
|
target,
|
|
|
|
|
});
|
|
|
|
|
if (!chatGuid) {
|
fix(bluebubbles): route phone-number targets to direct chats; prevent internal IDs leaking in cross-context prefix (#1751)
* fix(bluebubbles): prefer DM resolution + hide routing markers
* fix(bluebubbles): prevent message routing to group chats when targeting phone numbers
When sending a message to a phone number like +12622102921, the
resolveChatGuidForTarget function was finding and returning a GROUP
CHAT containing that phone number instead of a direct DM chat.
The bug was in the participantMatch fallback logic which matched ANY
chat containing the phone number as a participant, including groups.
This fix adds a check to ensure participantMatch only considers DM
chats (identified by ';-;' separator in the chat GUID). Group chats
(identified by ';+;' separator) are now explicitly excluded from
handle-based matching.
If a phone number only exists in a group chat (no direct DM exists),
the function now correctly returns null, which causes the send to
fail with a clear error rather than accidentally messaging a group.
Added test case to verify this behavior.
* feat(bluebubbles): auto-create new DM chats when sending to unknown phone numbers
When sending to a phone number that doesn't have an existing chat,
instead of failing with 'chatGuid not found', now automatically creates
a new chat using the /api/v1/chat/new endpoint.
- Added createNewChatWithMessage() helper function
- When resolveChatGuidForTarget returns null for a handle target,
uses the new chat endpoint with addresses array and message
- Includes helpful error message if Private API isn't enabled
- Only applies to handle targets (phone numbers), not group chats
* fix(bluebubbles): hide internal routing metadata in cross-context markers
When sending cross-context messages via BlueBubbles, the origin marker was
exposing internal chat_guid routing info like '[from bluebubbles:chat_guid:any;-;+19257864429]'.
This adds a formatTargetDisplay() function to the BlueBubbles plugin that:
- Extracts phone numbers from chat_guid formats (iMessage;-;+1234567890 -> +1234567890)
- Normalizes handles for clean display
- Avoids returning raw chat_guid formats containing internal routing metadata
Now cross-context markers show clean identifiers like '[from +19257864429]' instead
of exposing internal routing details to recipients.
* fix: prevent cross-context decoration on direct message tool sends
Two fixes:
1. Cross-context decoration (e.g., '[from +19257864429]' prefix) was being
added to ALL messages sent to a different target, even when the agent
was just composing a new message via the message tool. This decoration
should only be applied when forwarding/relaying messages between chats.
Fix: Added skipCrossContextDecoration flag to ChannelThreadingToolContext.
The message tool now sets this flag to true, so direct sends don't get
decorated. The buildCrossContextDecoration function checks this flag
and returns null when set.
2. Aborted requests were still completing because the abort signal wasn't
being passed through the message tool execution chain.
Fix: Added abortSignal propagation from message tool → runMessageAction →
executeSendAction → sendMessage → deliverOutboundPayloads. Added abort
checks at key points in the chain to fail fast when aborted.
Files changed:
- src/channels/plugins/types.core.ts: Added skipCrossContextDecoration field
- src/infra/outbound/outbound-policy.ts: Check skip flag before decorating
- src/agents/tools/message-tool.ts: Set skip flag, accept and pass abort signal
- src/infra/outbound/message-action-runner.ts: Pass abort signal through
- src/infra/outbound/outbound-send-service.ts: Check and pass abort signal
- src/infra/outbound/message.ts: Pass abort signal to delivery
* fix(bluebubbles): preserve friendly display names in formatTargetDisplay
2026-01-25 02:03:08 -08:00
|
|
|
// If target is a phone number/handle and no existing chat found,
|
|
|
|
|
// auto-create a new DM chat using the /api/v1/chat/new endpoint
|
|
|
|
|
if (target.kind === "handle") {
|
|
|
|
|
return createNewChatWithMessage({
|
|
|
|
|
baseUrl,
|
|
|
|
|
password,
|
|
|
|
|
address: target.address,
|
fix: comprehensive BlueBubbles and channel cleanup (#11093)
* feat(bluebubbles): auto-strip markdown from outbound messages (#7402)
* fix(security): add timeout to webhook body reading (#6762)
Adds 30-second timeout to readBody() in voice-call, bluebubbles, and nostr
webhook handlers. Prevents Slow-Loris DoS (CWE-400, CVSS 7.5).
Merged with existing maxBytes protection in voice-call.
* fix(security): unify Error objects and lint fixes in webhook timeouts (#6762)
* fix: prevent plugins from auto-enabling without user consent (#3961)
Changes default plugin enabled state from true to false in enablePluginEntry().
Preserves existing enabled:true values. Fixes #3932.
* fix: apply hierarchical mediaMaxMb config to all channels (#8749)
Generalizes resolveAttachmentMaxBytes() to use account → channel → global
config resolution for all channels, not just BlueBubbles. Fixes #7847.
* fix(bluebubbles): sanitize attachment filenames against header injection (#10333)
Strip ", \r, \n, and \\ from filenames after path.basename() to prevent
multipart Content-Disposition header injection (CWE-93, CVSS 5.4).
Also adds sanitization to setGroupIconBlueBubbles which had zero filename
sanitization.
* fix(lint): exclude extensions/ from Oxlint preflight check (#9313)
Extensions use PluginRuntime|null patterns that trigger
no-redundant-type-constituents because PluginRuntime resolves to any.
Excluding extensions/ from Oxlint unblocks user upgrades.
Re-applies the approach from closed PR #10087.
* fix(bluebubbles): add tempGuid to createNewChatWithMessage payload (#7745)
Non-Private-API mode (AppleScript) requires tempGuid in send payloads.
The main sendMessageBlueBubbles already had it, but createNewChatWithMessage
was missing it, causing 400 errors for new chat creation without Private API.
* fix: send stop-typing signal when run ends with NO_REPLY (#8785)
Adds onCleanup callback to the typing controller that fires when the
controller is cleaned up while typing was active (e.g., after NO_REPLY).
Channels using createTypingCallbacks automatically get stop-typing on
cleanup. This prevents the typing indicator from lingering in group chats
when the agent decides not to reply.
* fix(telegram): deduplicate skill commands in multi-agent setup (#5717)
Two fixes:
1. Skip duplicate workspace dirs when listing skill commands across agents.
Multiple agents sharing the same workspace would produce duplicate commands
with _2, _3 suffixes.
2. Clear stale commands via deleteMyCommands before registering new ones.
Commands from deleted skills now get cleaned up on restart.
* fix: add size limits to unbounded in-memory caches (#4948)
Adds max-size caps with oldest-entry eviction to prevent OOM in
long-running deployments:
- BlueBubbles serverInfoCache: 64 entries (already has TTL)
- Google Chat authCache: 32 entries
- Matrix directRoomCache: 1024 entries
- Discord presenceCache: 5000 entries per account
* fix: address review concerns (#11093)
- Chain deleteMyCommands → setMyCommands to prevent race condition (#5717)
- Rename enablePluginEntry to registerPluginEntry (now sets enabled: false)
- Add Slow-Loris timeout test for readJsonBody (#6023)
2026-02-07 05:00:55 -08:00
|
|
|
message: strippedText,
|
fix(bluebubbles): route phone-number targets to direct chats; prevent internal IDs leaking in cross-context prefix (#1751)
* fix(bluebubbles): prefer DM resolution + hide routing markers
* fix(bluebubbles): prevent message routing to group chats when targeting phone numbers
When sending a message to a phone number like +12622102921, the
resolveChatGuidForTarget function was finding and returning a GROUP
CHAT containing that phone number instead of a direct DM chat.
The bug was in the participantMatch fallback logic which matched ANY
chat containing the phone number as a participant, including groups.
This fix adds a check to ensure participantMatch only considers DM
chats (identified by ';-;' separator in the chat GUID). Group chats
(identified by ';+;' separator) are now explicitly excluded from
handle-based matching.
If a phone number only exists in a group chat (no direct DM exists),
the function now correctly returns null, which causes the send to
fail with a clear error rather than accidentally messaging a group.
Added test case to verify this behavior.
* feat(bluebubbles): auto-create new DM chats when sending to unknown phone numbers
When sending to a phone number that doesn't have an existing chat,
instead of failing with 'chatGuid not found', now automatically creates
a new chat using the /api/v1/chat/new endpoint.
- Added createNewChatWithMessage() helper function
- When resolveChatGuidForTarget returns null for a handle target,
uses the new chat endpoint with addresses array and message
- Includes helpful error message if Private API isn't enabled
- Only applies to handle targets (phone numbers), not group chats
* fix(bluebubbles): hide internal routing metadata in cross-context markers
When sending cross-context messages via BlueBubbles, the origin marker was
exposing internal chat_guid routing info like '[from bluebubbles:chat_guid:any;-;+19257864429]'.
This adds a formatTargetDisplay() function to the BlueBubbles plugin that:
- Extracts phone numbers from chat_guid formats (iMessage;-;+1234567890 -> +1234567890)
- Normalizes handles for clean display
- Avoids returning raw chat_guid formats containing internal routing metadata
Now cross-context markers show clean identifiers like '[from +19257864429]' instead
of exposing internal routing details to recipients.
* fix: prevent cross-context decoration on direct message tool sends
Two fixes:
1. Cross-context decoration (e.g., '[from +19257864429]' prefix) was being
added to ALL messages sent to a different target, even when the agent
was just composing a new message via the message tool. This decoration
should only be applied when forwarding/relaying messages between chats.
Fix: Added skipCrossContextDecoration flag to ChannelThreadingToolContext.
The message tool now sets this flag to true, so direct sends don't get
decorated. The buildCrossContextDecoration function checks this flag
and returns null when set.
2. Aborted requests were still completing because the abort signal wasn't
being passed through the message tool execution chain.
Fix: Added abortSignal propagation from message tool → runMessageAction →
executeSendAction → sendMessage → deliverOutboundPayloads. Added abort
checks at key points in the chain to fail fast when aborted.
Files changed:
- src/channels/plugins/types.core.ts: Added skipCrossContextDecoration field
- src/infra/outbound/outbound-policy.ts: Check skip flag before decorating
- src/agents/tools/message-tool.ts: Set skip flag, accept and pass abort signal
- src/infra/outbound/message-action-runner.ts: Pass abort signal through
- src/infra/outbound/outbound-send-service.ts: Check and pass abort signal
- src/infra/outbound/message.ts: Pass abort signal to delivery
* fix(bluebubbles): preserve friendly display names in formatTargetDisplay
2026-01-25 02:03:08 -08:00
|
|
|
timeoutMs: opts.timeoutMs,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-01-18 03:17:30 +00:00
|
|
|
throw new Error(
|
|
|
|
|
"BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-01-19 18:23:00 -08:00
|
|
|
const effectId = resolveEffectId(opts.effectId);
|
2026-02-13 21:15:56 -08:00
|
|
|
const wantsReplyThread = Boolean(opts.replyToMessageGuid?.trim());
|
|
|
|
|
const wantsEffect = Boolean(effectId);
|
|
|
|
|
const needsPrivateApi = wantsReplyThread || wantsEffect;
|
|
|
|
|
const canUsePrivateApi = needsPrivateApi && privateApiStatus !== false;
|
|
|
|
|
if (wantsEffect && privateApiStatus === false) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
"BlueBubbles send failed: reply/effect requires Private API, but it is disabled on the BlueBubbles server.",
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-01-18 03:17:30 +00:00
|
|
|
const payload: Record<string, unknown> = {
|
|
|
|
|
chatGuid,
|
|
|
|
|
tempGuid: crypto.randomUUID(),
|
fix: comprehensive BlueBubbles and channel cleanup (#11093)
* feat(bluebubbles): auto-strip markdown from outbound messages (#7402)
* fix(security): add timeout to webhook body reading (#6762)
Adds 30-second timeout to readBody() in voice-call, bluebubbles, and nostr
webhook handlers. Prevents Slow-Loris DoS (CWE-400, CVSS 7.5).
Merged with existing maxBytes protection in voice-call.
* fix(security): unify Error objects and lint fixes in webhook timeouts (#6762)
* fix: prevent plugins from auto-enabling without user consent (#3961)
Changes default plugin enabled state from true to false in enablePluginEntry().
Preserves existing enabled:true values. Fixes #3932.
* fix: apply hierarchical mediaMaxMb config to all channels (#8749)
Generalizes resolveAttachmentMaxBytes() to use account → channel → global
config resolution for all channels, not just BlueBubbles. Fixes #7847.
* fix(bluebubbles): sanitize attachment filenames against header injection (#10333)
Strip ", \r, \n, and \\ from filenames after path.basename() to prevent
multipart Content-Disposition header injection (CWE-93, CVSS 5.4).
Also adds sanitization to setGroupIconBlueBubbles which had zero filename
sanitization.
* fix(lint): exclude extensions/ from Oxlint preflight check (#9313)
Extensions use PluginRuntime|null patterns that trigger
no-redundant-type-constituents because PluginRuntime resolves to any.
Excluding extensions/ from Oxlint unblocks user upgrades.
Re-applies the approach from closed PR #10087.
* fix(bluebubbles): add tempGuid to createNewChatWithMessage payload (#7745)
Non-Private-API mode (AppleScript) requires tempGuid in send payloads.
The main sendMessageBlueBubbles already had it, but createNewChatWithMessage
was missing it, causing 400 errors for new chat creation without Private API.
* fix: send stop-typing signal when run ends with NO_REPLY (#8785)
Adds onCleanup callback to the typing controller that fires when the
controller is cleaned up while typing was active (e.g., after NO_REPLY).
Channels using createTypingCallbacks automatically get stop-typing on
cleanup. This prevents the typing indicator from lingering in group chats
when the agent decides not to reply.
* fix(telegram): deduplicate skill commands in multi-agent setup (#5717)
Two fixes:
1. Skip duplicate workspace dirs when listing skill commands across agents.
Multiple agents sharing the same workspace would produce duplicate commands
with _2, _3 suffixes.
2. Clear stale commands via deleteMyCommands before registering new ones.
Commands from deleted skills now get cleaned up on restart.
* fix: add size limits to unbounded in-memory caches (#4948)
Adds max-size caps with oldest-entry eviction to prevent OOM in
long-running deployments:
- BlueBubbles serverInfoCache: 64 entries (already has TTL)
- Google Chat authCache: 32 entries
- Matrix directRoomCache: 1024 entries
- Discord presenceCache: 5000 entries per account
* fix: address review concerns (#11093)
- Chain deleteMyCommands → setMyCommands to prevent race condition (#5717)
- Rename enablePluginEntry to registerPluginEntry (now sets enabled: false)
- Add Slow-Loris timeout test for readJsonBody (#6023)
2026-02-07 05:00:55 -08:00
|
|
|
message: strippedText,
|
2026-01-18 03:17:30 +00:00
|
|
|
};
|
2026-02-13 21:15:56 -08:00
|
|
|
if (canUsePrivateApi) {
|
2026-01-19 18:23:00 -08:00
|
|
|
payload.method = "private-api";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add reply threading support
|
2026-02-13 21:15:56 -08:00
|
|
|
if (wantsReplyThread && canUsePrivateApi) {
|
2026-01-19 18:23:00 -08:00
|
|
|
payload.selectedMessageGuid = opts.replyToMessageGuid;
|
|
|
|
|
payload.partIndex = typeof opts.replyToPartIndex === "number" ? opts.replyToPartIndex : 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add message effects support
|
|
|
|
|
if (effectId) {
|
|
|
|
|
payload.effectId = effectId;
|
|
|
|
|
}
|
2026-01-18 03:17:30 +00:00
|
|
|
|
|
|
|
|
const url = buildBlueBubblesApiUrl({
|
|
|
|
|
baseUrl,
|
|
|
|
|
path: "/api/v1/message/text",
|
|
|
|
|
password,
|
|
|
|
|
});
|
|
|
|
|
const res = await blueBubblesFetchWithTimeout(
|
|
|
|
|
url,
|
|
|
|
|
{
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify(payload),
|
|
|
|
|
},
|
|
|
|
|
opts.timeoutMs,
|
|
|
|
|
);
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const errorText = await res.text();
|
|
|
|
|
throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);
|
|
|
|
|
}
|
|
|
|
|
const body = await res.text();
|
2026-01-31 22:13:48 +09:00
|
|
|
if (!body) {
|
|
|
|
|
return { messageId: "ok" };
|
|
|
|
|
}
|
2026-01-18 03:17:30 +00:00
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(body) as unknown;
|
2026-02-15 01:10:45 +00:00
|
|
|
return { messageId: extractBlueBubblesMessageId(parsed) };
|
2026-01-18 03:17:30 +00:00
|
|
|
} catch {
|
|
|
|
|
return { messageId: "ok" };
|
|
|
|
|
}
|
|
|
|
|
}
|