Compare commits
7 Commits
main
...
fix/bluebu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
492c37a171 | ||
|
|
738e596c71 | ||
|
|
bea6b3eb5b | ||
|
|
00e00460c1 | ||
|
|
64c6698ea4 | ||
|
|
f997a3f395 | ||
|
|
626a37f1ce |
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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.",
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 = {
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 ?? "";
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user