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
This commit is contained in:
@@ -333,7 +333,13 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
||||
name: "message",
|
||||
description,
|
||||
parameters: schema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
execute: async (_toolCallId, args, signal) => {
|
||||
// Check if already aborted before doing any work
|
||||
if (signal?.aborted) {
|
||||
const err = new Error("Message send aborted");
|
||||
err.name = "AbortError";
|
||||
throw err;
|
||||
}
|
||||
const params = args as Record<string, unknown>;
|
||||
const cfg = options?.config ?? loadConfig();
|
||||
const action = readStringParam(params, "action", {
|
||||
@@ -366,6 +372,9 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
||||
currentThreadTs: options?.currentThreadTs,
|
||||
replyToMode: options?.replyToMode,
|
||||
hasRepliedRef: options?.hasRepliedRef,
|
||||
// Direct tool invocations should not add cross-context decoration.
|
||||
// The agent is composing a message, not forwarding from another chat.
|
||||
skipCrossContextDecoration: true,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -379,6 +388,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
||||
agentId: options?.agentSessionKey
|
||||
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg })
|
||||
: undefined,
|
||||
abortSignal: signal,
|
||||
});
|
||||
|
||||
const toolResult = getToolResult(result);
|
||||
|
||||
@@ -240,6 +240,12 @@ export type ChannelThreadingToolContext = {
|
||||
currentThreadTs?: string;
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
hasRepliedRef?: { value: boolean };
|
||||
/**
|
||||
* When true, skip cross-context decoration (e.g., "[from X]" prefix).
|
||||
* Use this for direct tool invocations where the agent is composing a new message,
|
||||
* not forwarding/relaying a message from another conversation.
|
||||
*/
|
||||
skipCrossContextDecoration?: boolean;
|
||||
};
|
||||
|
||||
export type ChannelMessagingAdapter = {
|
||||
|
||||
@@ -64,6 +64,7 @@ export type RunMessageActionParams = {
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
dryRun?: boolean;
|
||||
abortSignal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type MessageActionRunResult =
|
||||
@@ -507,6 +508,7 @@ type ResolvedActionContext = {
|
||||
input: RunMessageActionParams;
|
||||
agentId?: string;
|
||||
resolvedTarget?: ResolvedMessagingTarget;
|
||||
abortSignal?: AbortSignal;
|
||||
};
|
||||
function resolveGateway(input: RunMessageActionParams): MessageActionRunnerGateway | undefined {
|
||||
if (!input.gateway) return undefined;
|
||||
@@ -592,8 +594,28 @@ async function handleBroadcastAction(
|
||||
};
|
||||
}
|
||||
|
||||
function throwIfAborted(abortSignal?: AbortSignal): void {
|
||||
if (abortSignal?.aborted) {
|
||||
const err = new Error("Message send aborted");
|
||||
err.name = "AbortError";
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActionRunResult> {
|
||||
const { cfg, params, channel, accountId, dryRun, gateway, input, agentId, resolvedTarget } = ctx;
|
||||
const {
|
||||
cfg,
|
||||
params,
|
||||
channel,
|
||||
accountId,
|
||||
dryRun,
|
||||
gateway,
|
||||
input,
|
||||
agentId,
|
||||
resolvedTarget,
|
||||
abortSignal,
|
||||
} = ctx;
|
||||
throwIfAborted(abortSignal);
|
||||
const action: ChannelMessageActionName = "send";
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
// Support media, path, and filePath parameters for attachments
|
||||
@@ -676,6 +698,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
}
|
||||
const mirrorMediaUrls =
|
||||
mergedMediaUrls.length > 0 ? mergedMediaUrls : mediaUrl ? [mediaUrl] : undefined;
|
||||
throwIfAborted(abortSignal);
|
||||
const send = await executeSendAction({
|
||||
ctx: {
|
||||
cfg,
|
||||
@@ -695,6 +718,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
mediaUrls: mirrorMediaUrls,
|
||||
}
|
||||
: undefined,
|
||||
abortSignal,
|
||||
},
|
||||
to,
|
||||
message,
|
||||
@@ -718,7 +742,8 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
}
|
||||
|
||||
async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActionRunResult> {
|
||||
const { cfg, params, channel, accountId, dryRun, gateway, input } = ctx;
|
||||
const { cfg, params, channel, accountId, dryRun, gateway, input, abortSignal } = ctx;
|
||||
throwIfAborted(abortSignal);
|
||||
const action: ChannelMessageActionName = "poll";
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const question = readStringParam(params, "pollQuestion", {
|
||||
@@ -777,7 +802,8 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
}
|
||||
|
||||
async function handlePluginAction(ctx: ResolvedActionContext): Promise<MessageActionRunResult> {
|
||||
const { cfg, params, channel, accountId, dryRun, gateway, input } = ctx;
|
||||
const { cfg, params, channel, accountId, dryRun, gateway, input, abortSignal } = ctx;
|
||||
throwIfAborted(abortSignal);
|
||||
const action = input.action as Exclude<ChannelMessageActionName, "send" | "poll" | "broadcast">;
|
||||
if (dryRun) {
|
||||
return {
|
||||
@@ -930,6 +956,7 @@ export async function runMessageAction(
|
||||
input,
|
||||
agentId: resolvedAgentId,
|
||||
resolvedTarget,
|
||||
abortSignal: input.abortSignal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -942,6 +969,7 @@ export async function runMessageAction(
|
||||
dryRun,
|
||||
gateway,
|
||||
input,
|
||||
abortSignal: input.abortSignal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -953,5 +981,6 @@ export async function runMessageAction(
|
||||
dryRun,
|
||||
gateway,
|
||||
input,
|
||||
abortSignal: input.abortSignal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ type MessageSendParams = {
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
};
|
||||
abortSignal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type MessageSendResult = {
|
||||
@@ -167,6 +168,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
|
||||
gifPlayback: params.gifPlayback,
|
||||
deps: params.deps,
|
||||
bestEffort: params.bestEffort,
|
||||
abortSignal: params.abortSignal,
|
||||
mirror: params.mirror
|
||||
? {
|
||||
...params.mirror,
|
||||
|
||||
@@ -119,6 +119,8 @@ export async function buildCrossContextDecoration(params: {
|
||||
accountId?: string | null;
|
||||
}): Promise<CrossContextDecoration | null> {
|
||||
if (!params.toolContext?.currentChannelId) return null;
|
||||
// Skip decoration for direct tool sends (agent composing, not forwarding)
|
||||
if (params.toolContext.skipCrossContextDecoration) return null;
|
||||
if (!isCrossContextTarget(params)) return null;
|
||||
|
||||
const markerConfig = params.cfg.tools?.message?.crossContext?.marker;
|
||||
@@ -131,11 +133,11 @@ export async function buildCrossContextDecoration(params: {
|
||||
targetId: params.toolContext.currentChannelId,
|
||||
accountId: params.accountId ?? undefined,
|
||||
})) ?? params.toolContext.currentChannelId;
|
||||
// Don't force group formatting here; currentChannelId can be a DM or a group.
|
||||
const originLabel = formatTargetDisplay({
|
||||
channel: params.channel,
|
||||
target: params.toolContext.currentChannelId,
|
||||
display: currentName,
|
||||
kind: "group",
|
||||
});
|
||||
const prefixTemplate = markerConfig?.prefix ?? "[from {channel}] ";
|
||||
const suffixTemplate = markerConfig?.suffix ?? "";
|
||||
|
||||
@@ -32,6 +32,7 @@ export type OutboundSendContext = {
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
};
|
||||
abortSignal?: AbortSignal;
|
||||
};
|
||||
|
||||
function extractToolPayload(result: AgentToolResult<unknown>): unknown {
|
||||
@@ -56,6 +57,14 @@ function extractToolPayload(result: AgentToolResult<unknown>): unknown {
|
||||
return result.content ?? result;
|
||||
}
|
||||
|
||||
function throwIfAborted(abortSignal?: AbortSignal): void {
|
||||
if (abortSignal?.aborted) {
|
||||
const err = new Error("Message send aborted");
|
||||
err.name = "AbortError";
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeSendAction(params: {
|
||||
ctx: OutboundSendContext;
|
||||
to: string;
|
||||
@@ -70,6 +79,7 @@ export async function executeSendAction(params: {
|
||||
toolResult?: AgentToolResult<unknown>;
|
||||
sendResult?: MessageSendResult;
|
||||
}> {
|
||||
throwIfAborted(params.ctx.abortSignal);
|
||||
if (!params.ctx.dryRun) {
|
||||
const handled = await dispatchChannelMessageAction({
|
||||
channel: params.ctx.channel,
|
||||
@@ -103,6 +113,7 @@ export async function executeSendAction(params: {
|
||||
}
|
||||
}
|
||||
|
||||
throwIfAborted(params.ctx.abortSignal);
|
||||
const result: MessageSendResult = await sendMessage({
|
||||
cfg: params.ctx.cfg,
|
||||
to: params.to,
|
||||
@@ -117,6 +128,7 @@ export async function executeSendAction(params: {
|
||||
deps: params.ctx.deps,
|
||||
gateway: params.ctx.gateway,
|
||||
mirror: params.ctx.mirror,
|
||||
abortSignal: params.ctx.abortSignal,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -100,7 +100,12 @@ export function formatTargetDisplay(params: {
|
||||
if (!trimmedTarget) return trimmedTarget;
|
||||
if (trimmedTarget.startsWith("#") || trimmedTarget.startsWith("@")) return trimmedTarget;
|
||||
|
||||
const withoutPrefix = trimmedTarget.replace(/^telegram:/i, "");
|
||||
const channelPrefix = `${params.channel}:`;
|
||||
const withoutProvider = trimmedTarget.toLowerCase().startsWith(channelPrefix)
|
||||
? trimmedTarget.slice(channelPrefix.length)
|
||||
: trimmedTarget;
|
||||
|
||||
const withoutPrefix = withoutProvider.replace(/^telegram:/i, "");
|
||||
if (/^channel:/i.test(withoutPrefix)) {
|
||||
return `#${withoutPrefix.replace(/^channel:/i, "")}`;
|
||||
}
|
||||
@@ -119,14 +124,23 @@ function preserveTargetCase(channel: ChannelId, raw: string, normalized: string)
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function detectTargetKind(raw: string, preferred?: TargetResolveKind): TargetResolveKind {
|
||||
function detectTargetKind(
|
||||
channel: ChannelId,
|
||||
raw: string,
|
||||
preferred?: TargetResolveKind,
|
||||
): TargetResolveKind {
|
||||
if (preferred) return preferred;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return "group";
|
||||
|
||||
if (trimmed.startsWith("@") || /^<@!?/.test(trimmed) || /^user:/i.test(trimmed)) return "user";
|
||||
if (trimmed.startsWith("#") || /^channel:/i.test(trimmed)) {
|
||||
return "group";
|
||||
if (trimmed.startsWith("#") || /^channel:/i.test(trimmed)) return "group";
|
||||
|
||||
// For some channels (e.g., BlueBubbles/iMessage), bare phone numbers are almost always DM targets.
|
||||
if ((channel === "bluebubbles" || channel === "imessage") && /^\+?\d{6,}$/.test(trimmed)) {
|
||||
return "user";
|
||||
}
|
||||
|
||||
return "group";
|
||||
}
|
||||
|
||||
@@ -282,7 +296,7 @@ export async function resolveMessagingTarget(params: {
|
||||
const plugin = getChannelPlugin(params.channel);
|
||||
const providerLabel = plugin?.meta?.label ?? params.channel;
|
||||
const hint = plugin?.messaging?.targetResolver?.hint;
|
||||
const kind = detectTargetKind(raw, params.preferredKind);
|
||||
const kind = detectTargetKind(params.channel, raw, params.preferredKind);
|
||||
const normalized = normalizeTargetForProvider(params.channel, raw) ?? raw;
|
||||
const looksLikeTargetId = (): boolean => {
|
||||
const trimmed = raw.trim();
|
||||
@@ -291,7 +305,12 @@ export async function resolveMessagingTarget(params: {
|
||||
if (lookup) return lookup(trimmed, normalized);
|
||||
if (/^(channel|group|user):/i.test(trimmed)) return true;
|
||||
if (/^[@#]/.test(trimmed)) return true;
|
||||
if (/^\+?\d{6,}$/.test(trimmed)) return true;
|
||||
if (/^\+?\d{6,}$/.test(trimmed)) {
|
||||
// BlueBubbles/iMessage phone numbers should usually resolve via the directory to a DM chat,
|
||||
// otherwise the provider may pick an existing group containing that handle.
|
||||
if (params.channel === "bluebubbles" || params.channel === "imessage") return false;
|
||||
return true;
|
||||
}
|
||||
if (trimmed.includes("@thread")) return true;
|
||||
if (/^(conversation|user):/i.test(trimmed)) return true;
|
||||
return false;
|
||||
@@ -353,6 +372,24 @@ export async function resolveMessagingTarget(params: {
|
||||
candidates: match.entries,
|
||||
};
|
||||
}
|
||||
// For iMessage-style channels, allow sending directly to the normalized handle
|
||||
// even if the directory doesn't contain an entry yet.
|
||||
if (
|
||||
(params.channel === "bluebubbles" || params.channel === "imessage") &&
|
||||
/^\+?\d{6,}$/.test(query)
|
||||
) {
|
||||
const directTarget = preserveTargetCase(params.channel, raw, normalized);
|
||||
return {
|
||||
ok: true,
|
||||
target: {
|
||||
to: directTarget,
|
||||
kind,
|
||||
display: stripTargetPrefixes(raw),
|
||||
source: "normalized",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: unknownTargetError(providerLabel, raw, hint),
|
||||
@@ -367,16 +404,32 @@ export async function lookupDirectoryDisplay(params: {
|
||||
runtime?: RuntimeEnv;
|
||||
}): Promise<string | undefined> {
|
||||
const normalized = normalizeTargetForProvider(params.channel, params.targetId) ?? params.targetId;
|
||||
const candidates = await getDirectoryEntries({
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
kind: "group",
|
||||
runtime: params.runtime,
|
||||
preferLiveOnMiss: false,
|
||||
});
|
||||
const entry = candidates.find(
|
||||
(candidate) => normalizeDirectoryEntryId(params.channel, candidate) === normalized,
|
||||
);
|
||||
|
||||
// Targets can resolve to either peers (DMs) or groups. Try both.
|
||||
const [groups, users] = await Promise.all([
|
||||
getDirectoryEntries({
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
kind: "group",
|
||||
runtime: params.runtime,
|
||||
preferLiveOnMiss: false,
|
||||
}),
|
||||
getDirectoryEntries({
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
kind: "user",
|
||||
runtime: params.runtime,
|
||||
preferLiveOnMiss: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
const findMatch = (candidates: ChannelDirectoryEntry[]) =>
|
||||
candidates.find(
|
||||
(candidate) => normalizeDirectoryEntryId(params.channel, candidate) === normalized,
|
||||
);
|
||||
|
||||
const entry = findMatch(groups) ?? findMatch(users);
|
||||
return entry?.name ?? entry?.handle ?? undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user