diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 126a73131..88b8e5a2a 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -25,9 +25,11 @@ import { resolveBlueBubblesMessageId } from "./monitor.js"; import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js"; import { sendMessageBlueBubbles } from "./send.js"; import { + extractHandleFromChatGuid, looksLikeBlueBubblesTargetId, normalizeBlueBubblesHandle, normalizeBlueBubblesMessagingTarget, + parseBlueBubblesTarget, } from "./targets.js"; import { bluebubblesMessageActions } from "./actions.js"; import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js"; @@ -148,6 +150,58 @@ export const bluebubblesPlugin: ChannelPlugin = { looksLikeId: looksLikeBlueBubblesTargetId, hint: "", }, + formatTargetDisplay: ({ target, display }) => { + const shouldParseDisplay = (value: string): boolean => { + if (looksLikeBlueBubblesTargetId(value)) return true; + return /^(bluebubbles:|chat_guid:|chat_id:|chat_identifier:)/i.test(value); + }; + + // Helper to extract a clean handle from any BlueBubbles target format + const extractCleanDisplay = (value: string | undefined): string | null => { + const trimmed = value?.trim(); + if (!trimmed) return null; + try { + const parsed = parseBlueBubblesTarget(trimmed); + if (parsed.kind === "chat_guid") { + const handle = extractHandleFromChatGuid(parsed.chatGuid); + if (handle) return handle; + } + if (parsed.kind === "handle") { + return normalizeBlueBubblesHandle(parsed.to); + } + } catch { + // Fall through + } + // Strip common prefixes and try raw extraction + const stripped = trimmed + .replace(/^bluebubbles:/i, "") + .replace(/^chat_guid:/i, "") + .replace(/^chat_id:/i, "") + .replace(/^chat_identifier:/i, ""); + const handle = extractHandleFromChatGuid(stripped); + if (handle) return handle; + // Don't return raw chat_guid formats - they contain internal routing info + if (stripped.includes(";-;") || stripped.includes(";+;")) return null; + return stripped; + }; + + // Try to get a clean display from the display parameter first + const trimmedDisplay = display?.trim(); + if (trimmedDisplay) { + if (!shouldParseDisplay(trimmedDisplay)) { + return trimmedDisplay; + } + const cleanDisplay = extractCleanDisplay(trimmedDisplay); + if (cleanDisplay) return cleanDisplay; + } + + // Fall back to extracting from target + const cleanTarget = extractCleanDisplay(target); + if (cleanTarget) return cleanTarget; + + // Last resort: return display or target as-is + return display?.trim() || target?.trim() || ""; + }, }, setup: { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index 0b8b77a1f..6509ec3bf 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -187,6 +187,47 @@ describe("send", () => { expect(result).toBe("iMessage;-;+15551234567"); }); + it("returns null when handle only exists in group chat (not DM)", async () => { + // This is the critical fix: if a phone number only exists as a participant in a group chat + // (no direct DM chat), we should NOT send to that group. Return null instead. + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;+;group-the-council", + participants: [ + { address: "+12622102921" }, + { address: "+15550001111" }, + { address: "+15550002222" }, + ], + }, + ], + }), + }) + // Empty second page to stop pagination + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [] }), + }); + + const target: BlueBubblesSendTarget = { + kind: "handle", + address: "+12622102921", + service: "imessage", + }; + const result = await resolveChatGuidForTarget({ + baseUrl: "http://localhost:1234", + password: "test", + target, + }); + + // Should return null, NOT the group chat GUID + expect(result).toBeNull(); + }); + it("returns null when chat not found", async () => { mockFetch.mockResolvedValueOnce({ ok: true, diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index 675063d6d..bc4bb14bd 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -257,11 +257,17 @@ export async function resolveChatGuidForTarget(params: { return guid; } if (!participantMatch && guid) { - const participants = extractParticipantAddresses(chat).map((entry) => - normalizeBlueBubblesHandle(entry), - ); - if (participants.includes(normalizedHandle)) { - participantMatch = guid; + // 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; + } } } } @@ -270,6 +276,55 @@ export async function resolveChatGuidForTarget(params: { return participantMatch; } +/** + * 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 { + const url = buildBlueBubblesApiUrl({ + baseUrl: params.baseUrl, + path: "/api/v1/chat/new", + password: params.password, + }); + const payload = { + addresses: [params.address], + message: params.message, + }; + 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 + if (res.status === 400 || res.status === 403 || errorText.toLowerCase().includes("private api")) { + 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(); + if (!body) return { messageId: "ok" }; + try { + const parsed = JSON.parse(body) as unknown; + return { messageId: extractMessageId(parsed) }; + } catch { + return { messageId: "ok" }; + } +} + export async function sendMessageBlueBubbles( to: string, text: string, @@ -297,6 +352,17 @@ export async function sendMessageBlueBubbles( target, }); if (!chatGuid) { + // 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, + message: trimmedText, + timeoutMs: opts.timeoutMs, + }); + } throw new Error( "BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.", ); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 6552564e9..eae4356db 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -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; 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); diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 99f847fda..6a76743f2 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -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 = { diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 50ddce227..2fe4cfeb6 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -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 { - 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 0 ? mergedMediaUrls : mediaUrl ? [mediaUrl] : undefined; + throwIfAborted(abortSignal); const send = await executeSendAction({ ctx: { cfg, @@ -695,6 +718,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise { - 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 { - 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; 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, }); } diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index fcb90c295..6f5f88bd2 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -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 { 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 ?? ""; diff --git a/src/infra/outbound/outbound-send-service.ts b/src/infra/outbound/outbound-send-service.ts index dd5dfd5e6..88a64d251 100644 --- a/src/infra/outbound/outbound-send-service.ts +++ b/src/infra/outbound/outbound-send-service.ts @@ -32,6 +32,7 @@ export type OutboundSendContext = { text?: string; mediaUrls?: string[]; }; + abortSignal?: AbortSignal; }; function extractToolPayload(result: AgentToolResult): unknown { @@ -56,6 +57,14 @@ function extractToolPayload(result: AgentToolResult): 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; 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 { diff --git a/src/infra/outbound/target-resolver.ts b/src/infra/outbound/target-resolver.ts index d21685a93..9f9d2f8d2 100644 --- a/src/infra/outbound/target-resolver.ts +++ b/src/infra/outbound/target-resolver.ts @@ -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 { 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; }