Compare commits

...

7 Commits

Author SHA1 Message Date
Peter Steinberger
492c37a171 fix: harden message aborts + bluebubbles dm create (#1751) (thanks @tyler6204) 2026-01-25 10:02:41 +00:00
Tyler Yust
738e596c71 fix(bluebubbles): preserve friendly display names in formatTargetDisplay 2026-01-25 09:47:57 +00:00
Tyler Yust
bea6b3eb5b 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
2026-01-25 09:47:57 +00:00
Tyler Yust
00e00460c1 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.
2026-01-25 09:47:57 +00:00
Tyler Yust
64c6698ea4 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
2026-01-25 09:47:57 +00:00
Tyler Yust
f997a3f395 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.
2026-01-25 09:47:57 +00:00
Tyler Yust
626a37f1ce fix(bluebubbles): prefer DM resolution + hide routing markers 2026-01-25 09:47:57 +00:00
13 changed files with 400 additions and 29 deletions

View File

@ -24,6 +24,7 @@ Docs: https://docs.clawd.bot
- Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal. - Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal.
### Fixes ### Fixes
- BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing. - BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
- Web UI: hide internal `message_id` hints in chat bubbles. - Web UI: hide internal `message_id` hints in chat bubbles.
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent. - Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.

View File

@ -213,6 +213,7 @@ Prefer `chat_guid` for stable routing:
- `chat_id:123` - `chat_id:123`
- `chat_identifier:...` - `chat_identifier:...`
- Direct handles: `+15555550123`, `user@example.com` - Direct handles: `+15555550123`, `user@example.com`
- If a direct handle does not have an existing DM chat, Clawdbot will create one via `POST /api/v1/chat/new`. This requires the BlueBubbles Private API to be enabled.
## Security ## Security
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted. - Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.

View File

@ -25,9 +25,11 @@ import { resolveBlueBubblesMessageId } from "./monitor.js";
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js"; import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
import { sendMessageBlueBubbles } from "./send.js"; import { sendMessageBlueBubbles } from "./send.js";
import { import {
extractHandleFromChatGuid,
looksLikeBlueBubblesTargetId, looksLikeBlueBubblesTargetId,
normalizeBlueBubblesHandle, normalizeBlueBubblesHandle,
normalizeBlueBubblesMessagingTarget, normalizeBlueBubblesMessagingTarget,
parseBlueBubblesTarget,
} from "./targets.js"; } from "./targets.js";
import { bluebubblesMessageActions } from "./actions.js"; import { bluebubblesMessageActions } from "./actions.js";
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js"; import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
@ -148,6 +150,58 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
looksLikeId: looksLikeBlueBubblesTargetId, looksLikeId: looksLikeBlueBubblesTargetId,
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>", hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
}, },
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: { setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),

View File

@ -187,6 +187,47 @@ describe("send", () => {
expect(result).toBe("iMessage;-;+15551234567"); 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 () => { it("returns null when chat not found", async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
ok: true, ok: true,
@ -344,14 +385,14 @@ describe("send", () => {
).rejects.toThrow("password is required"); ).rejects.toThrow("password is required");
}); });
it("throws when chatGuid cannot be resolved", async () => { it("throws when chatGuid cannot be resolved for non-handle targets", async () => {
mockFetch.mockResolvedValue({ mockFetch.mockResolvedValue({
ok: true, ok: true,
json: () => Promise.resolve({ data: [] }), json: () => Promise.resolve({ data: [] }),
}); });
await expect( await expect(
sendMessageBlueBubbles("+15559999999", "Hello", { sendMessageBlueBubbles("chat_id:999", "Hello", {
serverUrl: "http://localhost:1234", serverUrl: "http://localhost:1234",
password: "test", password: "test",
}), }),
@ -398,6 +439,57 @@ describe("send", () => {
expect(body.method).toBeUndefined(); expect(body.method).toBeUndefined();
}); });
it("creates a new chat when handle target is missing", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "new-msg-guid" },
}),
),
});
const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("new-msg-guid");
expect(mockFetch).toHaveBeenCalledTimes(2);
const createCall = mockFetch.mock.calls[1];
expect(createCall[0]).toContain("/api/v1/chat/new");
const body = JSON.parse(createCall[1].body);
expect(body.addresses).toEqual(["+15550009999"]);
expect(body.message).toBe("Hello new chat");
});
it("throws when creating a new chat requires Private API", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
})
.mockResolvedValueOnce({
ok: false,
status: 403,
text: () => Promise.resolve("Private API not enabled"),
});
await expect(
sendMessageBlueBubbles("+15550008888", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("Private API must be enabled");
});
it("uses private-api when reply metadata is present", async () => { it("uses private-api when reply metadata is present", async () => {
mockFetch mockFetch
.mockResolvedValueOnce({ .mockResolvedValueOnce({

View File

@ -257,11 +257,17 @@ export async function resolveChatGuidForTarget(params: {
return guid; return guid;
} }
if (!participantMatch && guid) { if (!participantMatch && guid) {
const participants = extractParticipantAddresses(chat).map((entry) => // Only consider DM chats (`;-;` separator) as participant matches.
normalizeBlueBubblesHandle(entry), // 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.
if (participants.includes(normalizedHandle)) { const isDmChat = guid.includes(";-;");
participantMatch = guid; 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; 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<BlueBubblesSendResult> {
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( export async function sendMessageBlueBubbles(
to: string, to: string,
text: string, text: string,
@ -297,6 +352,17 @@ export async function sendMessageBlueBubbles(
target, target,
}); });
if (!chatGuid) { 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( throw new Error(
"BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.", "BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
); );

View File

@ -333,7 +333,13 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
name: "message", name: "message",
description, description,
parameters: schema, 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 params = args as Record<string, unknown>;
const cfg = options?.config ?? loadConfig(); const cfg = options?.config ?? loadConfig();
const action = readStringParam(params, "action", { const action = readStringParam(params, "action", {
@ -366,6 +372,9 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
currentThreadTs: options?.currentThreadTs, currentThreadTs: options?.currentThreadTs,
replyToMode: options?.replyToMode, replyToMode: options?.replyToMode,
hasRepliedRef: options?.hasRepliedRef, 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; : undefined;
@ -379,6 +388,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
agentId: options?.agentSessionKey agentId: options?.agentSessionKey
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg }) ? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg })
: undefined, : undefined,
abortSignal: signal,
}); });
const toolResult = getToolResult(result); const toolResult = getToolResult(result);

View File

@ -240,6 +240,12 @@ export type ChannelThreadingToolContext = {
currentThreadTs?: string; currentThreadTs?: string;
replyToMode?: "off" | "first" | "all"; replyToMode?: "off" | "first" | "all";
hasRepliedRef?: { value: boolean }; 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 = { export type ChannelMessagingAdapter = {

View File

@ -321,6 +321,44 @@ describe("runMessageAction context isolation", () => {
}), }),
).rejects.toThrow(/Cross-context messaging denied/); ).rejects.toThrow(/Cross-context messaging denied/);
}); });
it("aborts send when abortSignal is already aborted", async () => {
const controller = new AbortController();
controller.abort();
await expect(
runMessageAction({
cfg: slackConfig,
action: "send",
params: {
channel: "slack",
target: "#C12345678",
message: "hi",
},
dryRun: true,
abortSignal: controller.signal,
}),
).rejects.toMatchObject({ name: "AbortError" });
});
it("aborts broadcast when abortSignal is already aborted", async () => {
const controller = new AbortController();
controller.abort();
await expect(
runMessageAction({
cfg: slackConfig,
action: "broadcast",
params: {
targets: ["channel:C12345678"],
channel: "slack",
message: "hi",
},
dryRun: true,
abortSignal: controller.signal,
}),
).rejects.toMatchObject({ name: "AbortError" });
});
}); });
describe("runMessageAction sendAttachment hydration", () => { describe("runMessageAction sendAttachment hydration", () => {

View File

@ -64,6 +64,7 @@ export type RunMessageActionParams = {
sessionKey?: string; sessionKey?: string;
agentId?: string; agentId?: string;
dryRun?: boolean; dryRun?: boolean;
abortSignal?: AbortSignal;
}; };
export type MessageActionRunResult = export type MessageActionRunResult =
@ -507,6 +508,7 @@ type ResolvedActionContext = {
input: RunMessageActionParams; input: RunMessageActionParams;
agentId?: string; agentId?: string;
resolvedTarget?: ResolvedMessagingTarget; resolvedTarget?: ResolvedMessagingTarget;
abortSignal?: AbortSignal;
}; };
function resolveGateway(input: RunMessageActionParams): MessageActionRunnerGateway | undefined { function resolveGateway(input: RunMessageActionParams): MessageActionRunnerGateway | undefined {
if (!input.gateway) return undefined; if (!input.gateway) return undefined;
@ -524,6 +526,7 @@ async function handleBroadcastAction(
input: RunMessageActionParams, input: RunMessageActionParams,
params: Record<string, unknown>, params: Record<string, unknown>,
): Promise<MessageActionRunResult> { ): Promise<MessageActionRunResult> {
throwIfAborted(input.abortSignal);
const broadcastEnabled = input.cfg.tools?.message?.broadcast?.enabled !== false; const broadcastEnabled = input.cfg.tools?.message?.broadcast?.enabled !== false;
if (!broadcastEnabled) { if (!broadcastEnabled) {
throw new Error("Broadcast is disabled. Set tools.message.broadcast.enabled to true."); throw new Error("Broadcast is disabled. Set tools.message.broadcast.enabled to true.");
@ -548,8 +551,11 @@ async function handleBroadcastAction(
error?: string; error?: string;
result?: MessageSendResult; result?: MessageSendResult;
}> = []; }> = [];
const isAbortError = (err: unknown): boolean => err instanceof Error && err.name === "AbortError";
for (const targetChannel of targetChannels) { for (const targetChannel of targetChannels) {
throwIfAborted(input.abortSignal);
for (const target of rawTargets) { for (const target of rawTargets) {
throwIfAborted(input.abortSignal);
try { try {
const resolved = await resolveChannelTarget({ const resolved = await resolveChannelTarget({
cfg: input.cfg, cfg: input.cfg,
@ -573,6 +579,7 @@ async function handleBroadcastAction(
result: sendResult.kind === "send" ? sendResult.sendResult : undefined, result: sendResult.kind === "send" ? sendResult.sendResult : undefined,
}); });
} catch (err) { } catch (err) {
if (isAbortError(err)) throw err;
results.push({ results.push({
channel: targetChannel, channel: targetChannel,
to: target, to: target,
@ -592,8 +599,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> { 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 action: ChannelMessageActionName = "send";
const to = readStringParam(params, "to", { required: true }); const to = readStringParam(params, "to", { required: true });
// Support media, path, and filePath parameters for attachments // Support media, path, and filePath parameters for attachments
@ -676,6 +703,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
} }
const mirrorMediaUrls = const mirrorMediaUrls =
mergedMediaUrls.length > 0 ? mergedMediaUrls : mediaUrl ? [mediaUrl] : undefined; mergedMediaUrls.length > 0 ? mergedMediaUrls : mediaUrl ? [mediaUrl] : undefined;
throwIfAborted(abortSignal);
const send = await executeSendAction({ const send = await executeSendAction({
ctx: { ctx: {
cfg, cfg,
@ -695,6 +723,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
mediaUrls: mirrorMediaUrls, mediaUrls: mirrorMediaUrls,
} }
: undefined, : undefined,
abortSignal,
}, },
to, to,
message, message,
@ -718,7 +747,8 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
} }
async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActionRunResult> { 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 action: ChannelMessageActionName = "poll";
const to = readStringParam(params, "to", { required: true }); const to = readStringParam(params, "to", { required: true });
const question = readStringParam(params, "pollQuestion", { const question = readStringParam(params, "pollQuestion", {
@ -777,7 +807,8 @@ async function handlePollAction(ctx: ResolvedActionContext): Promise<MessageActi
} }
async function handlePluginAction(ctx: ResolvedActionContext): Promise<MessageActionRunResult> { 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">; const action = input.action as Exclude<ChannelMessageActionName, "send" | "poll" | "broadcast">;
if (dryRun) { if (dryRun) {
return { return {
@ -930,6 +961,7 @@ export async function runMessageAction(
input, input,
agentId: resolvedAgentId, agentId: resolvedAgentId,
resolvedTarget, resolvedTarget,
abortSignal: input.abortSignal,
}); });
} }
@ -942,6 +974,7 @@ export async function runMessageAction(
dryRun, dryRun,
gateway, gateway,
input, input,
abortSignal: input.abortSignal,
}); });
} }
@ -953,5 +986,6 @@ export async function runMessageAction(
dryRun, dryRun,
gateway, gateway,
input, input,
abortSignal: input.abortSignal,
}); });
} }

View File

@ -50,6 +50,7 @@ type MessageSendParams = {
text?: string; text?: string;
mediaUrls?: string[]; mediaUrls?: string[];
}; };
abortSignal?: AbortSignal;
}; };
export type MessageSendResult = { export type MessageSendResult = {
@ -167,6 +168,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
gifPlayback: params.gifPlayback, gifPlayback: params.gifPlayback,
deps: params.deps, deps: params.deps,
bestEffort: params.bestEffort, bestEffort: params.bestEffort,
abortSignal: params.abortSignal,
mirror: params.mirror mirror: params.mirror
? { ? {
...params.mirror, ...params.mirror,

View File

@ -119,6 +119,8 @@ export async function buildCrossContextDecoration(params: {
accountId?: string | null; accountId?: string | null;
}): Promise<CrossContextDecoration | null> { }): Promise<CrossContextDecoration | null> {
if (!params.toolContext?.currentChannelId) return 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; if (!isCrossContextTarget(params)) return null;
const markerConfig = params.cfg.tools?.message?.crossContext?.marker; const markerConfig = params.cfg.tools?.message?.crossContext?.marker;
@ -131,11 +133,11 @@ export async function buildCrossContextDecoration(params: {
targetId: params.toolContext.currentChannelId, targetId: params.toolContext.currentChannelId,
accountId: params.accountId ?? undefined, accountId: params.accountId ?? undefined,
})) ?? params.toolContext.currentChannelId; })) ?? params.toolContext.currentChannelId;
// Don't force group formatting here; currentChannelId can be a DM or a group.
const originLabel = formatTargetDisplay({ const originLabel = formatTargetDisplay({
channel: params.channel, channel: params.channel,
target: params.toolContext.currentChannelId, target: params.toolContext.currentChannelId,
display: currentName, display: currentName,
kind: "group",
}); });
const prefixTemplate = markerConfig?.prefix ?? "[from {channel}] "; const prefixTemplate = markerConfig?.prefix ?? "[from {channel}] ";
const suffixTemplate = markerConfig?.suffix ?? ""; const suffixTemplate = markerConfig?.suffix ?? "";

View File

@ -32,6 +32,7 @@ export type OutboundSendContext = {
text?: string; text?: string;
mediaUrls?: string[]; mediaUrls?: string[];
}; };
abortSignal?: AbortSignal;
}; };
function extractToolPayload(result: AgentToolResult<unknown>): unknown { function extractToolPayload(result: AgentToolResult<unknown>): unknown {
@ -56,6 +57,14 @@ function extractToolPayload(result: AgentToolResult<unknown>): unknown {
return result.content ?? result; 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: { export async function executeSendAction(params: {
ctx: OutboundSendContext; ctx: OutboundSendContext;
to: string; to: string;
@ -70,6 +79,7 @@ export async function executeSendAction(params: {
toolResult?: AgentToolResult<unknown>; toolResult?: AgentToolResult<unknown>;
sendResult?: MessageSendResult; sendResult?: MessageSendResult;
}> { }> {
throwIfAborted(params.ctx.abortSignal);
if (!params.ctx.dryRun) { if (!params.ctx.dryRun) {
const handled = await dispatchChannelMessageAction({ const handled = await dispatchChannelMessageAction({
channel: params.ctx.channel, channel: params.ctx.channel,
@ -103,6 +113,7 @@ export async function executeSendAction(params: {
} }
} }
throwIfAborted(params.ctx.abortSignal);
const result: MessageSendResult = await sendMessage({ const result: MessageSendResult = await sendMessage({
cfg: params.ctx.cfg, cfg: params.ctx.cfg,
to: params.to, to: params.to,
@ -117,6 +128,7 @@ export async function executeSendAction(params: {
deps: params.ctx.deps, deps: params.ctx.deps,
gateway: params.ctx.gateway, gateway: params.ctx.gateway,
mirror: params.ctx.mirror, mirror: params.ctx.mirror,
abortSignal: params.ctx.abortSignal,
}); });
return { return {

View File

@ -100,7 +100,12 @@ export function formatTargetDisplay(params: {
if (!trimmedTarget) return trimmedTarget; if (!trimmedTarget) return trimmedTarget;
if (trimmedTarget.startsWith("#") || trimmedTarget.startsWith("@")) 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)) { if (/^channel:/i.test(withoutPrefix)) {
return `#${withoutPrefix.replace(/^channel:/i, "")}`; return `#${withoutPrefix.replace(/^channel:/i, "")}`;
} }
@ -119,14 +124,23 @@ function preserveTargetCase(channel: ChannelId, raw: string, normalized: string)
return trimmed; return trimmed;
} }
function detectTargetKind(raw: string, preferred?: TargetResolveKind): TargetResolveKind { function detectTargetKind(
channel: ChannelId,
raw: string,
preferred?: TargetResolveKind,
): TargetResolveKind {
if (preferred) return preferred; if (preferred) return preferred;
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return "group"; if (!trimmed) return "group";
if (trimmed.startsWith("@") || /^<@!?/.test(trimmed) || /^user:/i.test(trimmed)) return "user"; if (trimmed.startsWith("@") || /^<@!?/.test(trimmed) || /^user:/i.test(trimmed)) return "user";
if (trimmed.startsWith("#") || /^channel:/i.test(trimmed)) { if (trimmed.startsWith("#") || /^channel:/i.test(trimmed)) return "group";
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"; return "group";
} }
@ -282,7 +296,7 @@ export async function resolveMessagingTarget(params: {
const plugin = getChannelPlugin(params.channel); const plugin = getChannelPlugin(params.channel);
const providerLabel = plugin?.meta?.label ?? params.channel; const providerLabel = plugin?.meta?.label ?? params.channel;
const hint = plugin?.messaging?.targetResolver?.hint; 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 normalized = normalizeTargetForProvider(params.channel, raw) ?? raw;
const looksLikeTargetId = (): boolean => { const looksLikeTargetId = (): boolean => {
const trimmed = raw.trim(); const trimmed = raw.trim();
@ -291,7 +305,12 @@ export async function resolveMessagingTarget(params: {
if (lookup) return lookup(trimmed, normalized); if (lookup) return lookup(trimmed, normalized);
if (/^(channel|group|user):/i.test(trimmed)) return true; if (/^(channel|group|user):/i.test(trimmed)) return true;
if (/^[@#]/.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 (trimmed.includes("@thread")) return true;
if (/^(conversation|user):/i.test(trimmed)) return true; if (/^(conversation|user):/i.test(trimmed)) return true;
return false; return false;
@ -353,6 +372,24 @@ export async function resolveMessagingTarget(params: {
candidates: match.entries, 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 { return {
ok: false, ok: false,
error: unknownTargetError(providerLabel, raw, hint), error: unknownTargetError(providerLabel, raw, hint),
@ -367,16 +404,32 @@ export async function lookupDirectoryDisplay(params: {
runtime?: RuntimeEnv; runtime?: RuntimeEnv;
}): Promise<string | undefined> { }): Promise<string | undefined> {
const normalized = normalizeTargetForProvider(params.channel, params.targetId) ?? params.targetId; const normalized = normalizeTargetForProvider(params.channel, params.targetId) ?? params.targetId;
const candidates = await getDirectoryEntries({
cfg: params.cfg, // Targets can resolve to either peers (DMs) or groups. Try both.
channel: params.channel, const [groups, users] = await Promise.all([
accountId: params.accountId, getDirectoryEntries({
kind: "group", cfg: params.cfg,
runtime: params.runtime, channel: params.channel,
preferLiveOnMiss: false, accountId: params.accountId,
}); kind: "group",
const entry = candidates.find( runtime: params.runtime,
(candidate) => normalizeDirectoryEntryId(params.channel, candidate) === normalized, 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; return entry?.name ?? entry?.handle ?? undefined;
} }