feat: enhance BlueBubbles message actions with support for message editing, reply metadata, and improved effect handling

This commit is contained in:
Tyler Yust
2026-01-19 23:40:22 -08:00
committed by Peter Steinberger
parent 2e6c58bf75
commit 574b848863
22 changed files with 1366 additions and 83 deletions

View File

@@ -355,5 +355,64 @@ describe("bluebubblesMessageActions", () => {
}),
);
});
it("accepts message param for edit action", async () => {
const { editBlueBubblesMessage } = await import("./chat.js");
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
await bluebubblesMessageActions.handleAction({
action: "edit",
params: { messageId: "msg-123", message: "updated" },
cfg,
accountId: null,
});
expect(editBlueBubblesMessage).toHaveBeenCalledWith(
"msg-123",
"updated",
expect.objectContaining({ cfg, accountId: undefined }),
);
});
it("accepts message/target aliases for sendWithEffect", async () => {
const { sendMessageBlueBubbles } = await import("./send.js");
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
const result = await bluebubblesMessageActions.handleAction({
action: "sendWithEffect",
params: {
message: "peekaboo",
target: "+15551234567",
effect: "invisible ink",
},
cfg,
accountId: null,
});
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
"+15551234567",
"peekaboo",
expect.objectContaining({ effectId: "invisible ink" }),
);
expect(result).toMatchObject({
details: { ok: true, messageId: "msg-123", effect: "invisible ink" },
});
});
});
});

View File

@@ -42,6 +42,10 @@ function mapTarget(raw: string): BlueBubblesSendTarget {
};
}
function readMessageText(params: Record<string, unknown>): string | undefined {
return readStringParam(params, "text") ?? readStringParam(params, "message");
}
/** Supported action names for BlueBubbles */
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>([
"react",
@@ -161,7 +165,10 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
// Handle edit action
if (action === "edit") {
const messageId = readStringParam(params, "messageId");
const newText = readStringParam(params, "text") ?? readStringParam(params, "newText");
const newText =
readStringParam(params, "text") ??
readStringParam(params, "newText") ??
readStringParam(params, "message");
if (!messageId || !newText) {
const missing: string[] = [];
if (!messageId) missing.push("messageId (the message GUID to edit)");
@@ -205,16 +212,16 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
// Handle reply action
if (action === "reply") {
const messageId = readStringParam(params, "messageId");
const text = readStringParam(params, "text");
const to = readStringParam(params, "to");
const text = readMessageText(params);
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
if (!messageId || !text || !to) {
const missing: string[] = [];
if (!messageId) missing.push("messageId (the message GUID to reply to)");
if (!text) missing.push("text (the reply message content)");
if (!to) missing.push("to (the chat target)");
if (!text) missing.push("text or message (the reply message content)");
if (!to) missing.push("to or target (the chat target)");
throw new Error(
`BlueBubbles reply requires: ${missing.join(", ")}. ` +
`Use action=reply with messageId=<message_guid>, text=<your reply>, to=<chat_target>.`,
`Use action=reply with messageId=<message_guid>, message=<your reply>, target=<chat_target>.`,
);
}
const partIndex = readNumberParam(params, "partIndex", { integer: true });
@@ -230,20 +237,20 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
// Handle sendWithEffect action
if (action === "sendWithEffect") {
const text = readStringParam(params, "text");
const to = readStringParam(params, "to");
const text = readMessageText(params);
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
const effectId = readStringParam(params, "effectId") ?? readStringParam(params, "effect");
if (!text || !to || !effectId) {
const missing: string[] = [];
if (!text) missing.push("text (the message content)");
if (!to) missing.push("to (the chat target)");
if (!text) missing.push("text or message (the message content)");
if (!to) missing.push("to or target (the chat target)");
if (!effectId)
missing.push(
"effectId or effect (e.g., slam, loud, gentle, invisible-ink, confetti, lasers, fireworks, balloons, heart)",
);
throw new Error(
`BlueBubbles sendWithEffect requires: ${missing.join(", ")}. ` +
`Use action=sendWithEffect with text=<message>, to=<chat_target>, effectId=<effect_name>.`,
`Use action=sendWithEffect with message=<message>, target=<chat_target>, effectId=<effect_name>.`,
);
}

View File

@@ -23,7 +23,7 @@ import {
resolveDefaultBlueBubblesAccountId,
} from "./accounts.js";
import { BlueBubblesConfigSchema } from "./config-schema.js";
import { probeBlueBubbles } from "./probe.js";
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
import { sendMessageBlueBubbles } from "./send.js";
import { sendBlueBubblesAttachment } from "./attachments.js";
import {
@@ -254,10 +254,12 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, accountId }) => {
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
const replyToMessageGuid = typeof replyToId === "string" ? replyToId.trim() : "";
const result = await sendMessageBlueBubbles(to, text, {
cfg: cfg as ClawdbotConfig,
accountId: accountId ?? undefined,
replyToMessageGuid: replyToMessageGuid || undefined,
});
return { channel: "bluebubbles", ...result };
},
@@ -358,20 +360,25 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
password: account.config.password ?? null,
timeoutMs,
}),
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.baseUrl,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
}),
buildAccountSnapshot: ({ account, runtime, probe }) => {
const running = runtime?.running ?? false;
const probeOk = (probe as BlueBubblesProbe | undefined)?.ok;
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.baseUrl,
running,
connected: probeOk ?? running,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
};
},
},
gateway: {
startAccount: async (ctx) => {

View File

@@ -28,6 +28,14 @@ vi.mock("./attachments.js", () => ({
}),
}));
vi.mock("./reactions.js", async () => {
const actual = await vi.importActual<typeof import("./reactions.js")>("./reactions.js");
return {
...actual,
sendBlueBubblesReaction: vi.fn().mockResolvedValue(undefined),
};
});
// Mock runtime
const mockEnqueueSystemEvent = vi.fn();
const mockBuildPairingReply = vi.fn(() => "Pairing code: TESTCODE");
@@ -781,6 +789,45 @@ describe("BlueBubbles webhook monitor", () => {
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("treats chat_guid groups as group even when isGroup=false", async () => {
const account = createMockAccount({
groupPolicy: "allowlist",
dmPolicy: "open",
});
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello from group",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
chatGuid: "iMessage;+;chat123456",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("allows group messages from allowed chat_guid in groupAllowFrom", async () => {
const account = createMockAccount({
groupPolicy: "allowlist",
@@ -941,6 +988,152 @@ describe("BlueBubbles webhook monitor", () => {
});
});
describe("group metadata", () => {
it("includes group subject + members in ctx", async () => {
const account = createMockAccount({ groupPolicy: "open" });
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello group",
handle: { address: "+15551234567" },
isGroup: true,
isFromMe: false,
guid: "msg-1",
chatGuid: "iMessage;+;chat123456",
chatName: "Family",
participants: [
{ address: "+15551234567", displayName: "Alice" },
{ address: "+15557654321", displayName: "Bob" },
],
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
expect(callArgs.ctx.GroupSubject).toBe("Family");
expect(callArgs.ctx.GroupMembers).toBe("Alice (+15551234567), Bob (+15557654321)");
});
});
describe("reply metadata", () => {
it("surfaces reply fields in ctx when provided", async () => {
const account = createMockAccount({ dmPolicy: "open" });
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "replying now",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
chatGuid: "iMessage;-;+15551234567",
replyTo: {
guid: "msg-0",
text: "original message",
handle: { address: "+15550000000", displayName: "Alice" },
},
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
expect(callArgs.ctx.ReplyToBody).toBe("original message");
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
});
});
describe("ack reactions", () => {
it("sends ack reaction when configured", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
vi.mocked(sendBlueBubblesReaction).mockClear();
const account = createMockAccount({ dmPolicy: "open" });
const config: ClawdbotConfig = {
messages: {
ackReaction: "❤️",
ackReactionScope: "direct",
},
};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
chatGuid: "iMessage;-;+15551234567",
messageGuid: "msg-1",
emoji: "❤️",
opts: expect.objectContaining({ accountId: "default" }),
}),
);
});
});
describe("command gating", () => {
it("allows control command to bypass mention gating when authorized", async () => {
mockResolveRequireMention.mockReturnValue(true);

View File

@@ -5,9 +5,11 @@ import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
import { downloadBlueBubblesAttachment } from "./attachments.js";
import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender, normalizeBlueBubblesHandle } from "./targets.js";
import { resolveAckReaction } from "../../../src/agents/identity.js";
import type { BlueBubblesAccountConfig, BlueBubblesAttachment } from "./types.js";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
import { getBlueBubblesRuntime } from "./runtime.js";
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
export type BlueBubblesRuntimeEnv = {
log?: (message: string) => void;
@@ -25,6 +27,7 @@ export type BlueBubblesMonitorOptions = {
const DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook";
const DEFAULT_TEXT_LIMIT = 4000;
const invalidAckReactions = new Set<string>();
type BlueBubblesCoreRuntime = ReturnType<typeof getBlueBubblesRuntime>;
@@ -34,6 +37,29 @@ function logVerbose(core: BlueBubblesCoreRuntime, runtime: BlueBubblesRuntimeEnv
}
}
function logGroupAllowlistHint(params: {
runtime: BlueBubblesRuntimeEnv;
reason: string;
entry: string | null;
chatName?: string;
}): void {
const logger = params.runtime.log;
if (!logger) return;
const nameHint = params.chatName ? ` (group name: ${params.chatName})` : "";
if (params.entry) {
logger(
`[bluebubbles] group message blocked (${params.reason}). Allow this group by adding ` +
`"${params.entry}" to channels.bluebubbles.groupAllowFrom${nameHint}.`,
);
return;
}
logger(
`[bluebubbles] group message blocked (${params.reason}). Allow groups by setting ` +
`channels.bluebubbles.groupPolicy="open" or adding a group id to ` +
`channels.bluebubbles.groupAllowFrom${nameHint}.`,
);
}
type WebhookTarget = {
account: ResolvedBlueBubblesAccount;
config: ClawdbotConfig;
@@ -194,6 +220,71 @@ function readNumberLike(record: Record<string, unknown> | null, key: string): nu
return undefined;
}
function extractReplyMetadata(message: Record<string, unknown>): {
replyToId?: string;
replyToBody?: string;
replyToSender?: string;
} {
const replyRaw =
message["replyTo"] ??
message["reply_to"] ??
message["replyToMessage"] ??
message["reply_to_message"] ??
message["repliedMessage"] ??
message["quotedMessage"] ??
message["associatedMessage"] ??
message["reply"];
const replyRecord = asRecord(replyRaw);
const replyHandle = asRecord(replyRecord?.["handle"]) ?? asRecord(replyRecord?.["sender"]) ?? null;
const replySenderRaw =
readString(replyHandle, "address") ??
readString(replyHandle, "handle") ??
readString(replyHandle, "id") ??
readString(replyRecord, "senderId") ??
readString(replyRecord, "sender") ??
readString(replyRecord, "from");
const normalizedSender = replySenderRaw
? normalizeBlueBubblesHandle(replySenderRaw) || replySenderRaw.trim()
: undefined;
const replyToBody =
readString(replyRecord, "text") ??
readString(replyRecord, "body") ??
readString(replyRecord, "message") ??
readString(replyRecord, "subject") ??
undefined;
const directReplyId =
readString(message, "replyToMessageGuid") ??
readString(message, "replyToGuid") ??
readString(message, "replyGuid") ??
readString(message, "selectedMessageGuid") ??
readString(message, "selectedMessageId") ??
readString(message, "replyToMessageId") ??
readString(message, "replyId") ??
readString(replyRecord, "guid") ??
readString(replyRecord, "id") ??
readString(replyRecord, "messageId");
const associatedType =
readNumberLike(message, "associatedMessageType") ??
readNumberLike(message, "associated_message_type");
const associatedGuid =
readString(message, "associatedMessageGuid") ??
readString(message, "associated_message_guid") ??
readString(message, "associatedMessageId");
const isReactionAssociation =
typeof associatedType === "number" && REACTION_TYPE_MAP.has(associatedType);
const replyToId = directReplyId ?? (!isReactionAssociation ? associatedGuid : undefined);
return {
replyToId: replyToId?.trim() || undefined,
replyToBody: replyToBody?.trim() || undefined,
replyToSender: normalizedSender || undefined,
};
}
function readFirstChatRecord(message: Record<string, unknown>): Record<string, unknown> | null {
const chats = message["chats"];
if (!Array.isArray(chats) || chats.length === 0) return null;
@@ -201,6 +292,108 @@ function readFirstChatRecord(message: Record<string, unknown>): Record<string, u
return asRecord(first);
}
function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | null {
if (typeof entry === "string" || typeof entry === "number") {
const raw = String(entry).trim();
if (!raw) return null;
const normalized = normalizeBlueBubblesHandle(raw) || raw;
return normalized ? { id: normalized } : null;
}
const record = asRecord(entry);
if (!record) return null;
const nestedHandle =
asRecord(record["handle"]) ?? asRecord(record["sender"]) ?? asRecord(record["contact"]) ?? null;
const idRaw =
readString(record, "address") ??
readString(record, "handle") ??
readString(record, "id") ??
readString(record, "phoneNumber") ??
readString(record, "phone_number") ??
readString(record, "email") ??
readString(nestedHandle, "address") ??
readString(nestedHandle, "handle") ??
readString(nestedHandle, "id");
const nameRaw =
readString(record, "displayName") ??
readString(record, "name") ??
readString(record, "title") ??
readString(nestedHandle, "displayName") ??
readString(nestedHandle, "name");
const normalizedId = idRaw ? normalizeBlueBubblesHandle(idRaw) || idRaw.trim() : "";
if (!normalizedId) return null;
const name = nameRaw?.trim() || undefined;
return { id: normalizedId, name };
}
function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] {
if (!Array.isArray(raw) || raw.length === 0) return [];
const seen = new Set<string>();
const output: BlueBubblesParticipant[] = [];
for (const entry of raw) {
const normalized = normalizeParticipantEntry(entry);
if (!normalized?.id) continue;
const key = normalized.id.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
output.push(normalized);
}
return output;
}
function formatGroupMembers(params: {
participants?: BlueBubblesParticipant[];
fallback?: BlueBubblesParticipant;
}): string | undefined {
const seen = new Set<string>();
const ordered: BlueBubblesParticipant[] = [];
for (const entry of params.participants ?? []) {
if (!entry?.id) continue;
const key = entry.id.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
ordered.push(entry);
}
if (ordered.length === 0 && params.fallback?.id) {
ordered.push(params.fallback);
}
if (ordered.length === 0) return undefined;
return ordered
.map((entry) => (entry.name ? `${entry.name} (${entry.id})` : entry.id))
.join(", ");
}
function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undefined {
const guid = chatGuid?.trim();
if (!guid) return undefined;
const parts = guid.split(";");
if (parts.length >= 3) {
if (parts[1] === "+") return true;
if (parts[1] === "-") return false;
}
if (guid.includes(";+;")) return true;
if (guid.includes(";-;")) return false;
return undefined;
}
function formatGroupAllowlistEntry(params: {
chatGuid?: string;
chatId?: number;
chatIdentifier?: string;
}): string | null {
const guid = params.chatGuid?.trim();
if (guid) return `chat_guid:${guid}`;
const chatId = params.chatId;
if (typeof chatId === "number" && Number.isFinite(chatId)) return `chat_id:${chatId}`;
const identifier = params.chatIdentifier?.trim();
if (identifier) return `chat_identifier:${identifier}`;
return null;
}
type BlueBubblesParticipant = {
id: string;
name?: string;
};
type NormalizedWebhookMessage = {
text: string;
senderId: string;
@@ -215,6 +408,10 @@ type NormalizedWebhookMessage = {
fromMe?: boolean;
attachments?: BlueBubblesAttachment[];
balloonBundleId?: string;
participants?: BlueBubblesParticipant[];
replyToId?: string;
replyToBody?: string;
replyToSender?: string;
};
type NormalizedWebhookReaction = {
@@ -252,6 +449,31 @@ function maskSecret(value: string): string {
return `${value.slice(0, 2)}***${value.slice(-2)}`;
}
function resolveBlueBubblesAckReaction(params: {
cfg: ClawdbotConfig;
agentId: string;
core: BlueBubblesCoreRuntime;
runtime: BlueBubblesRuntimeEnv;
}): string | null {
const raw = resolveAckReaction(params.cfg, params.agentId).trim();
if (!raw) return null;
try {
normalizeBlueBubblesReactionInput(raw);
return raw;
} catch {
const key = raw.toLowerCase();
if (!invalidAckReactions.has(key)) {
invalidAckReactions.add(key);
logVerbose(
params.core,
params.runtime,
`ack reaction skipped (unsupported for BlueBubbles): ${raw}`,
);
}
return null;
}
}
function extractMessagePayload(payload: Record<string, unknown>): Record<string, unknown> | null {
const dataRaw = payload.data ?? payload.payload ?? payload.event;
const data =
@@ -331,13 +553,18 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
: Array.isArray(chatsParticipants)
? chatsParticipants
: [];
const normalizedParticipants = normalizeParticipantList(participants);
const participantsCount = participants.length;
const isGroup =
const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
const explicitIsGroup =
readBoolean(message, "isGroup") ??
readBoolean(message, "is_group") ??
readBoolean(chat, "isGroup") ??
readBoolean(message, "group") ??
(participantsCount > 2 ? true : false);
readBoolean(message, "group");
const isGroup =
typeof groupFromChatGuid === "boolean"
? groupFromChatGuid
: explicitIsGroup ?? (participantsCount > 2 ? true : false);
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
const messageId =
@@ -360,6 +587,7 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
const normalizedSender = normalizeBlueBubblesHandle(senderId);
if (!normalizedSender) return null;
const replyMetadata = extractReplyMetadata(message);
return {
text,
@@ -375,6 +603,10 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
fromMe,
attachments: extractAttachments(message),
balloonBundleId,
participants: normalizedParticipants,
replyToId: replyMetadata.replyToId,
replyToBody: replyMetadata.replyToBody,
replyToSender: replyMetadata.replyToSender,
};
}
@@ -451,12 +683,16 @@ function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedW
? chatsParticipants
: [];
const participantsCount = participants.length;
const isGroup =
const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
const explicitIsGroup =
readBoolean(message, "isGroup") ??
readBoolean(message, "is_group") ??
readBoolean(chat, "isGroup") ??
readBoolean(message, "group") ??
(participantsCount > 2 ? true : false);
readBoolean(message, "group");
const isGroup =
typeof groupFromChatGuid === "boolean"
? groupFromChatGuid
: explicitIsGroup ?? (participantsCount > 2 ? true : false);
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
const timestampRaw =
@@ -637,6 +873,8 @@ async function processMessage(
): Promise<void> {
const { account, config, runtime, core, statusSink } = target;
if (message.fromMe) return;
const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
const text = message.text.trim();
const attachments = message.attachments ?? [];
@@ -648,7 +886,7 @@ async function processMessage(
logVerbose(
core,
runtime,
`msg sender=${message.senderId} group=${message.isGroup} textLen=${text.length} attachments=${attachments.length} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
`msg sender=${message.senderId} group=${isGroup} textLen=${text.length} attachments=${attachments.length} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
);
const dmPolicy = account.config.dmPolicy ?? "pairing";
@@ -667,15 +905,33 @@ async function processMessage(
]
.map((entry) => String(entry).trim())
.filter(Boolean);
const groupAllowEntry = formatGroupAllowlistEntry({
chatGuid: message.chatGuid,
chatId: message.chatId ?? undefined,
chatIdentifier: message.chatIdentifier ?? undefined,
});
const groupName = message.chatName?.trim() || undefined;
if (message.isGroup) {
if (isGroup) {
if (groupPolicy === "disabled") {
logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)");
logGroupAllowlistHint({
runtime,
reason: "groupPolicy=disabled",
entry: groupAllowEntry,
chatName: groupName,
});
return;
}
if (groupPolicy === "allowlist") {
if (effectiveGroupAllowFrom.length === 0) {
logVerbose(core, runtime, "Blocked BlueBubbles group message (no allowlist)");
logGroupAllowlistHint({
runtime,
reason: "groupPolicy=allowlist (empty allowlist)",
entry: groupAllowEntry,
chatName: groupName,
});
return;
}
const allowed = isAllowedBlueBubblesSender({
@@ -696,6 +952,12 @@ async function processMessage(
runtime,
`drop: group sender not allowed sender=${message.senderId} allowFrom=${effectiveGroupAllowFrom.join(",")}`,
);
logGroupAllowlistHint({
runtime,
reason: "groupPolicy=allowlist (not allowlisted)",
entry: groupAllowEntry,
chatName: groupName,
});
return;
}
}
@@ -767,7 +1029,7 @@ async function processMessage(
const chatId = message.chatId ?? undefined;
const chatGuid = message.chatGuid ?? undefined;
const chatIdentifier = message.chatIdentifier ?? undefined;
const peerId = message.isGroup
const peerId = isGroup
? chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group")
: message.senderId;
@@ -776,7 +1038,7 @@ async function processMessage(
channel: "bluebubbles",
accountId: account.accountId,
peer: {
kind: message.isGroup ? "group" : "dm",
kind: isGroup ? "group" : "dm",
id: peerId,
},
});
@@ -784,7 +1046,7 @@ async function processMessage(
// Mention gating for group chats (parity with iMessage/WhatsApp)
const messageText = text;
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId);
const wasMentioned = message.isGroup
const wasMentioned = isGroup
? core.channel.mentions.matchesMentionPatterns(messageText, mentionRegexes)
: true;
const canDetectMention = mentionRegexes.length > 0;
@@ -819,7 +1081,7 @@ async function processMessage(
})
: false;
const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands;
const commandAuthorized = message.isGroup
const commandAuthorized = isGroup
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
@@ -830,7 +1092,7 @@ async function processMessage(
: dmAuthorized;
// Block control commands from unauthorized senders in groups
if (message.isGroup && hasControlCmd && !commandAuthorized) {
if (isGroup && hasControlCmd && !commandAuthorized) {
logVerbose(
core,
runtime,
@@ -841,7 +1103,7 @@ async function processMessage(
// Allow control commands to bypass mention gating when authorized (parity with iMessage)
const shouldBypassMention =
message.isGroup &&
isGroup &&
requireMention &&
!wasMentioned &&
commandAuthorized &&
@@ -849,7 +1111,7 @@ async function processMessage(
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
// Skip group messages that require mention but weren't mentioned
if (message.isGroup && requireMention && canDetectMention && !wasMentioned && !shouldBypassMention) {
if (isGroup && requireMention && canDetectMention && !wasMentioned && !shouldBypassMention) {
logVerbose(core, runtime, `bluebubbles: skipping group message (no mention)`);
return;
}
@@ -906,9 +1168,16 @@ async function processMessage(
}
}
const rawBody = text.trim() || placeholder;
const fromLabel = message.isGroup
const fromLabel = isGroup
? `group:${peerId}`
: message.senderName || `user:${message.senderId}`;
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
const groupMembers = isGroup
? formatGroupMembers({
participants: message.participants,
fallback: message.senderId ? { id: message.senderId, name: message.senderName } : undefined,
})
: undefined;
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
agentId: route.agentId,
});
@@ -927,8 +1196,8 @@ async function processMessage(
});
let chatGuidForActions = chatGuid;
if (!chatGuidForActions && baseUrl && password) {
const target =
message.isGroup && (chatId || chatIdentifier)
const target =
isGroup && (chatId || chatIdentifier)
? chatId
? { kind: "chat_id", chatId }
: { kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" }
@@ -943,6 +1212,48 @@ async function processMessage(
}
}
const ackReactionScope = config.messages?.ackReactionScope ?? "group-mentions";
const removeAckAfterReply = config.messages?.removeAckAfterReply ?? false;
const ackReactionValue = resolveBlueBubblesAckReaction({
cfg: config,
agentId: route.agentId,
core,
runtime,
});
const shouldAckReaction = () => {
if (!ackReactionValue) return false;
if (ackReactionScope === "all") return true;
if (ackReactionScope === "direct") return !isGroup;
if (ackReactionScope === "group-all") return isGroup;
if (ackReactionScope === "group-mentions") {
if (!isGroup) return false;
if (!requireMention) return false;
if (!canDetectMention) return false;
return effectiveWasMentioned;
}
return false;
};
const ackMessageId = message.messageId?.trim() || "";
const ackReactionPromise =
shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue
? sendBlueBubblesReaction({
chatGuid: chatGuidForActions,
messageGuid: ackMessageId,
emoji: ackReactionValue,
opts: { cfg: config, accountId: account.accountId },
}).then(
() => true,
(err) => {
logVerbose(
core,
runtime,
`ack reaction failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`,
);
return false;
},
)
: null;
// Respect sendReadReceipts config (parity with WhatsApp)
const sendReadReceipts = account.config.sendReadReceipts !== false;
if (chatGuidForActions && baseUrl && password && sendReadReceipts) {
@@ -961,7 +1272,7 @@ async function processMessage(
logVerbose(core, runtime, "mark read skipped (missing chatGuid or credentials)");
}
const outboundTarget = message.isGroup
const outboundTarget = isGroup
? formatBlueBubblesChatTarget({
chatId,
chatGuid: chatGuidForActions ?? chatGuid,
@@ -983,12 +1294,17 @@ async function processMessage(
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaType: mediaTypes[0],
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
From: message.isGroup ? `group:${peerId}` : `bluebubbles:${message.senderId}`,
From: isGroup ? `group:${peerId}` : `bluebubbles:${message.senderId}`,
To: `bluebubbles:${outboundTarget}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: message.isGroup ? "group" : "direct",
ChatType: isGroup ? "group" : "direct",
ConversationLabel: fromLabel,
ReplyToId: message.replyToId,
ReplyToBody: message.replyToBody,
ReplyToSender: message.replyToSender,
GroupSubject: groupSubject,
GroupMembers: groupMembers,
SenderName: message.senderName || undefined,
SenderId: message.senderId,
Provider: "bluebubbles",
@@ -1028,9 +1344,12 @@ async function processMessage(
if (!chunks.length && payload.text) chunks.push(payload.text);
if (!chunks.length) return;
for (const chunk of chunks) {
const replyToMessageGuid =
typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
await sendMessageBlueBubbles(outboundTarget, chunk, {
cfg: config,
accountId: account.accountId,
replyToMessageGuid: replyToMessageGuid || undefined,
});
sentMessage = true;
statusSink?.({ lastOutboundAt: Date.now() });
@@ -1064,6 +1383,31 @@ async function processMessage(
},
});
} finally {
if (
removeAckAfterReply &&
sentMessage &&
ackReactionPromise &&
ackReactionValue &&
chatGuidForActions &&
ackMessageId
) {
void ackReactionPromise.then((didAck) => {
if (!didAck) return;
sendBlueBubblesReaction({
chatGuid: chatGuidForActions,
messageGuid: ackMessageId,
emoji: ackReactionValue,
remove: true,
opts: { cfg: config, accountId: account.accountId },
}).catch((err) => {
logVerbose(
core,
runtime,
`ack reaction removal failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`,
);
});
});
}
if (chatGuidForActions && baseUrl && password && !sentMessage) {
// BlueBubbles typing stop (DELETE) does not clear bubbles reliably; wait for timeout.
}
@@ -1150,7 +1494,7 @@ async function processReaction(
export async function monitorBlueBubblesProvider(
options: BlueBubblesMonitorOptions,
): Promise<{ stop: () => void }> {
): Promise<void> {
const { account, config, runtime, abortSignal, statusSink } = options;
const core = getBlueBubblesRuntime();
const path = options.webhookPath?.trim() || DEFAULT_WEBHOOK_PATH;
@@ -1164,21 +1508,22 @@ export async function monitorBlueBubblesProvider(
statusSink,
});
const stop = () => {
unregister();
};
return await new Promise((resolve) => {
const stop = () => {
unregister();
resolve();
};
if (abortSignal?.aborted) {
stop();
return;
}
if (abortSignal?.aborted) {
stop();
} else {
abortSignal?.addEventListener("abort", stop, { once: true });
}
runtime.log?.(
`[${account.accountId}] BlueBubbles webhook listening on ${normalizeWebhookPath(path)}`,
);
return { stop };
runtime.log?.(
`[${account.accountId}] BlueBubbles webhook listening on ${normalizeWebhookPath(path)}`,
);
});
}
export function resolveWebhookPathFromConfig(config?: BlueBubblesAccountConfig): string {

View File

@@ -60,7 +60,7 @@ function resolveAccount(params: BlueBubblesReactionOpts) {
return { baseUrl, password };
}
function normalizeReactionInput(emoji: string, remove?: boolean): string {
export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {
const trimmed = emoji.trim();
if (!trimmed) throw new Error("BlueBubbles reaction requires an emoji or name.");
let raw = trimmed.toLowerCase();
@@ -85,7 +85,7 @@ export async function sendBlueBubblesReaction(params: {
const messageGuid = params.messageGuid.trim();
if (!chatGuid) throw new Error("BlueBubbles reaction requires chatGuid.");
if (!messageGuid) throw new Error("BlueBubbles reaction requires messageGuid.");
const reaction = normalizeReactionInput(params.emoji, params.remove);
const reaction = normalizeBlueBubblesReactionInput(params.emoji, params.remove);
const { baseUrl, password } = resolveAccount(params.opts ?? {});
const url = buildBlueBubblesApiUrl({
baseUrl,

View File

@@ -128,6 +128,38 @@ describe("send", () => {
expect(result).toBe("iMessage;-;+15551234567");
});
it("prefers direct chat guid when handle also appears in a group chat", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;+;group-123",
participants: [{ address: "+15551234567" }, { address: "+15550001111" }],
},
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
});
const target: BlueBubblesSendTarget = {
kind: "handle",
address: "+15551234567",
service: "imessage",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
expect(result).toBe("iMessage;-;+15551234567");
});
it("returns null when chat not found", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
@@ -380,6 +412,45 @@ describe("send", () => {
expect(body.partIndex).toBe(1);
});
it("normalizes effect names and uses private-api for effects", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;-;+15551234567",
participants: [{ address: "+15551234567" }],
},
],
}),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "msg-uuid-125" },
}),
),
});
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
effectId: "invisible ink",
});
expect(result.messageId).toBe("msg-uuid-125");
expect(mockFetch).toHaveBeenCalledTimes(2);
const sendCall = mockFetch.mock.calls[1];
const body = JSON.parse(sendCall[1].body);
expect(body.method).toBe("private-api");
expect(body.effectId).toBe("com.apple.MobileSMS.expressivesend.invisibleink");
});
it("sends message with chat_guid target directly", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,

View File

@@ -1,7 +1,11 @@
import crypto from "node:crypto";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js";
import {
extractHandleFromChatGuid,
normalizeBlueBubblesHandle,
parseBlueBubblesTarget,
} from "./targets.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import {
blueBubblesFetchWithTimeout,
@@ -34,12 +38,17 @@ const EFFECT_MAP: Record<string, string> = {
loud: "com.apple.MobileSMS.expressivesend.loud",
gentle: "com.apple.MobileSMS.expressivesend.gentle",
invisible: "com.apple.MobileSMS.expressivesend.invisibleink",
"invisible-ink": "com.apple.MobileSMS.expressivesend.invisibleink",
"invisible ink": "com.apple.MobileSMS.expressivesend.invisibleink",
invisibleink: "com.apple.MobileSMS.expressivesend.invisibleink",
// Screen effects
echo: "com.apple.messages.effect.CKEchoEffect",
spotlight: "com.apple.messages.effect.CKSpotlightEffect",
balloons: "com.apple.messages.effect.CKHappyBirthdayEffect",
confetti: "com.apple.messages.effect.CKConfettiEffect",
love: "com.apple.messages.effect.CKHeartEffect",
heart: "com.apple.messages.effect.CKHeartEffect",
hearts: "com.apple.messages.effect.CKHeartEffect",
lasers: "com.apple.messages.effect.CKLasersEffect",
fireworks: "com.apple.messages.effect.CKFireworksEffect",
celebration: "com.apple.messages.effect.CKSparklesEffect",
@@ -48,7 +57,12 @@ const EFFECT_MAP: Record<string, string> = {
function resolveEffectId(raw?: string): string | undefined {
if (!raw) return undefined;
const trimmed = raw.trim().toLowerCase();
return EFFECT_MAP[trimmed] ?? raw;
if (EFFECT_MAP[trimmed]) return EFFECT_MAP[trimmed];
const normalized = trimmed.replace(/[\s_]+/g, "-");
if (EFFECT_MAP[normalized]) return EFFECT_MAP[normalized];
const compact = trimmed.replace(/[\s_-]+/g, "");
if (EFFECT_MAP[compact]) return EFFECT_MAP[compact];
return raw;
}
function resolveSendTarget(raw: string): BlueBubblesSendTarget {
@@ -184,6 +198,7 @@ export async function resolveChatGuidForTarget(params: {
params.target.kind === "chat_identifier" ? params.target.chatIdentifier : null;
const limit = 500;
let participantMatch: string | null = null;
for (let offset = 0; offset < 5000; offset += limit) {
const chats = await queryChats({
baseUrl: params.baseUrl,
@@ -214,16 +229,23 @@ export async function resolveChatGuidForTarget(params: {
if (identifier && identifier === targetChatIdentifier) return extractChatGuid(chat);
}
if (normalizedHandle) {
const participants = extractParticipantAddresses(chat).map((entry) =>
normalizeBlueBubblesHandle(entry),
);
if (participants.includes(normalizedHandle)) {
return extractChatGuid(chat);
const guid = extractChatGuid(chat);
const directHandle = guid ? extractHandleFromChatGuid(guid) : null;
if (directHandle && directHandle === normalizedHandle) {
return guid;
}
if (!participantMatch && guid) {
const participants = extractParticipantAddresses(chat).map((entry) =>
normalizeBlueBubblesHandle(entry),
);
if (participants.includes(normalizedHandle)) {
participantMatch = guid;
}
}
}
}
}
return null;
return participantMatch;
}
export async function sendMessageBlueBubbles(

View File

@@ -48,7 +48,7 @@ export function normalizeBlueBubblesHandle(raw: string): string {
* BlueBubbles chat_guid format for DM: "service;-;handle" (e.g., "iMessage;-;+19257864429")
* Group chat format: "service;+;groupId" (has "+" instead of "-")
*/
function extractHandleFromChatGuid(chatGuid: string): string | null {
export function extractHandleFromChatGuid(chatGuid: string): string | null {
const parts = chatGuid.split(";");
// DM format: service;-;handle (3 parts, middle is "-")
if (parts.length === 3 && parts[1] === "-") {

View File

@@ -61,6 +61,9 @@ export type RunEmbeddedPiAgentParams = {
text?: string;
mediaUrls?: string[];
audioAsVoice?: boolean;
replyToId?: string;
replyToTag?: boolean;
replyToCurrent?: boolean;
}) => void | Promise<void>;
onBlockReplyFlush?: () => void | Promise<void>;
blockReplyBreak?: "text_end" | "message_end";

View File

@@ -56,6 +56,9 @@ export type EmbeddedRunAttemptParams = {
text?: string;
mediaUrls?: string[];
audioAsVoice?: boolean;
replyToId?: string;
replyToTag?: boolean;
replyToCurrent?: boolean;
}) => void | Promise<void>;
onBlockReplyFlush?: () => void | Promise<void>;
blockReplyBreak?: "text_end" | "message_end";

View File

@@ -226,13 +226,23 @@ export function handleMessageEnd(
);
} else {
ctx.state.lastBlockReplyText = text;
const { text: cleanedText, mediaUrls, audioAsVoice } = parseReplyDirectives(text);
const {
text: cleanedText,
mediaUrls,
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
} = parseReplyDirectives(text);
// Emit if there's content OR audioAsVoice flag (to propagate the flag).
if (cleanedText || (mediaUrls && mediaUrls.length > 0) || audioAsVoice) {
void onBlockReply({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
});
}
}

View File

@@ -342,13 +342,23 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
assistantTexts.push(chunk);
if (!params.onBlockReply) return;
const splitResult = parseReplyDirectives(chunk);
const { text: cleanedText, mediaUrls, audioAsVoice } = splitResult;
const {
text: cleanedText,
mediaUrls,
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
} = splitResult;
// Skip empty payloads, but always emit if audioAsVoice is set (to propagate the flag)
if (!cleanedText && (!mediaUrls || mediaUrls.length === 0) && !audioAsVoice) return;
void params.onBlockReply({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
audioAsVoice,
replyToId,
replyToTag,
replyToCurrent,
});
};

View File

@@ -19,6 +19,9 @@ export type SubscribeEmbeddedPiSessionParams = {
text?: string;
mediaUrls?: string[];
audioAsVoice?: boolean;
replyToId?: string;
replyToTag?: boolean;
replyToCurrent?: boolean;
}) => void | Promise<void>;
/** Flush pending block replies (e.g., before tool execution to preserve message boundaries). */
onBlockReplyFlush?: () => void | Promise<void>;

View File

@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import type { ChannelPlugin } from "../../channels/plugins/types.js";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { createMessageTool } from "./message-tool.js";
const mocks = vi.hoisted(() => ({
@@ -82,3 +85,59 @@ describe("message tool mirroring", () => {
expect(mocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled();
});
});
describe("message tool description", () => {
const bluebubblesPlugin: ChannelPlugin = {
id: "bluebubbles",
meta: {
id: "bluebubbles",
label: "BlueBubbles",
selectionLabel: "BlueBubbles",
docsPath: "/channels/bluebubbles",
blurb: "BlueBubbles test plugin.",
},
capabilities: { chatTypes: ["direct", "group"], media: true },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
},
messaging: {
normalizeTarget: (raw) => {
const trimmed = raw.trim().replace(/^bluebubbles:/i, "");
const lower = trimmed.toLowerCase();
if (lower.startsWith("chat_guid:")) {
const guid = trimmed.slice("chat_guid:".length);
const parts = guid.split(";");
if (parts.length === 3 && parts[1] === "-") {
return parts[2]?.trim() || trimmed;
}
return `chat_guid:${guid}`;
}
return trimmed;
},
},
actions: {
listActions: () =>
["react", "renameGroup", "addParticipant", "removeParticipant", "leaveGroup"] as const,
},
};
it("hides BlueBubbles group actions for DM targets", () => {
setActivePluginRegistry(
createTestRegistry([{ pluginId: "bluebubbles", source: "test", plugin: bluebubblesPlugin }]),
);
const tool = createMessageTool({
config: {} as never,
currentChannelProvider: "bluebubbles",
currentChannelId: "bluebubbles:chat_guid:iMessage;-;+15551234567",
});
expect(tool.description).not.toContain("renameGroup");
expect(tool.description).not.toContain("addParticipant");
expect(tool.description).not.toContain("removeParticipant");
expect(tool.description).not.toContain("leaveGroup");
setActivePluginRegistry(createTestRegistry([]));
});
});

View File

@@ -14,15 +14,23 @@ import {
resolveMirroredTranscriptText,
} from "../../config/sessions.js";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js";
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
import { getToolResult, runMessageAction } from "../../infra/outbound/message-action-runner.js";
import { resolveSessionAgentId } from "../agent-scope.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import { channelTargetSchema, channelTargetsSchema, stringEnum } from "../schema/typebox.js";
import { listChannelSupportedActions } from "../channel-tools.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
const BLUEBUBBLES_GROUP_ACTIONS = new Set<ChannelMessageActionName>([
"renameGroup",
"addParticipant",
"removeParticipant",
"leaveGroup",
]);
function buildRoutingSchema() {
return {
@@ -37,7 +45,26 @@ function buildRoutingSchema() {
function buildSendSchema(options: { includeButtons: boolean }) {
const props: Record<string, unknown> = {
message: Type.Optional(Type.String()),
effectId: Type.Optional(
Type.String({
description: "Message effect name/id for sendWithEffect (e.g., invisible ink).",
}),
),
effect: Type.Optional(
Type.String({ description: "Alias for effectId (e.g., invisible-ink, balloons)." }),
),
media: Type.Optional(Type.String()),
filename: Type.Optional(Type.String()),
buffer: Type.Optional(
Type.String({
description: "Base64 payload for attachments (optionally a data: URL).",
}),
),
contentType: Type.Optional(Type.String()),
mimeType: Type.Optional(Type.String()),
caption: Type.Optional(Type.String()),
path: Type.Optional(Type.String()),
filePath: Type.Optional(Type.String()),
replyTo: Type.Optional(Type.String()),
threadId: Type.Optional(Type.String()),
asVoice: Type.Optional(Type.Boolean()),
@@ -228,17 +255,43 @@ function resolveAgentAccountId(value?: string): string | undefined {
return normalizeAccountId(trimmed);
}
function filterActionsForContext(params: {
actions: ChannelMessageActionName[];
channel?: string;
currentChannelId?: string;
}): ChannelMessageActionName[] {
const channel = normalizeMessageChannel(params.channel);
if (!channel || channel !== "bluebubbles") return params.actions;
const currentChannelId = params.currentChannelId?.trim();
if (!currentChannelId) return params.actions;
const normalizedTarget =
normalizeTargetForProvider(channel, currentChannelId) ?? currentChannelId;
const lowered = normalizedTarget.trim().toLowerCase();
const isGroupTarget =
lowered.startsWith("chat_guid:") ||
lowered.startsWith("chat_id:") ||
lowered.startsWith("chat_identifier:") ||
lowered.startsWith("group:");
if (isGroupTarget) return params.actions;
return params.actions.filter((action) => !BLUEBUBBLES_GROUP_ACTIONS.has(action));
}
function buildMessageToolDescription(options?: {
config?: ClawdbotConfig;
currentChannel?: string;
currentChannelId?: string;
}): string {
const baseDescription = "Send, delete, and manage messages via channel plugins.";
// If we have a current channel, show only its supported actions
if (options?.currentChannel) {
const channelActions = listChannelSupportedActions({
cfg: options.config,
const channelActions = filterActionsForContext({
actions: listChannelSupportedActions({
cfg: options.config,
channel: options.currentChannel,
}),
channel: options.currentChannel,
currentChannelId: options.currentChannelId,
});
if (channelActions.length > 0) {
// Always include "send" as a base action
@@ -265,6 +318,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
const description = buildMessageToolDescription({
config: options?.config,
currentChannel: options?.currentChannelProvider,
currentChannelId: options?.currentChannelId,
});
return {

View File

@@ -302,6 +302,9 @@ export async function runAgentTurnWithFallback(params: {
text,
mediaUrls: payload.mediaUrls,
mediaUrl: payload.mediaUrls?.[0],
replyToId: payload.replyToId,
replyToTag: payload.replyToTag,
replyToCurrent: payload.replyToCurrent,
},
params.sessionCtx.MessageSid,
);

View File

@@ -1,4 +1,8 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
@@ -6,7 +10,14 @@ import { createIMessageTestPlugin, createTestRegistry } from "../../test-utils/c
import { slackPlugin } from "../../../extensions/slack/src/channel.js";
import { telegramPlugin } from "../../../extensions/telegram/src/channel.js";
import { whatsappPlugin } from "../../../extensions/whatsapp/src/channel.js";
import { loadWebMedia } from "../../web/media.js";
import { runMessageAction } from "./message-action-runner.js";
import { jsonResult } from "../../agents/tools/common.js";
import type { ChannelPlugin } from "../../channels/plugins/types.js";
vi.mock("../../web/media.js", () => ({
loadWebMedia: vi.fn(),
}));
const slackConfig = {
channels: {
@@ -64,6 +75,67 @@ describe("runMessageAction context isolation", () => {
afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
});
it("maps sendAttachment media to buffer + filename", async () => {
const filePath = path.join(os.tmpdir(), `clawdbot-attachment-${Date.now()}.txt`);
await fs.writeFile(filePath, "hello");
const handleAction = vi.fn(async (ctx) => {
return jsonResult({ ok: true, params: ctx.params });
});
const testPlugin: ChannelPlugin = {
id: "bluebubbles",
meta: {
id: "bluebubbles",
label: "BlueBubbles",
selectionLabel: "BlueBubbles",
docsPath: "/channels/bluebubbles",
blurb: "BlueBubbles test plugin.",
},
capabilities: { chatTypes: ["direct", "group"], media: true },
config: {
listAccountIds: () => [],
resolveAccount: () => ({}),
},
messaging: {
targetResolver: {
looksLikeId: () => true,
hint: "<target>",
},
normalizeTarget: (raw) => raw.trim(),
},
actions: {
listActions: () => ["sendAttachment"],
handleAction: handleAction as NonNullable<ChannelPlugin["actions"]>["handleAction"],
},
};
setActivePluginRegistry(
createTestRegistry([{ pluginId: "bluebubbles", source: "test", plugin: testPlugin }]),
);
try {
const result = await runMessageAction({
cfg: { channels: { bluebubbles: {} } } as ClawdbotConfig,
action: "sendAttachment",
params: {
channel: "bluebubbles",
target: "chat_guid:TEST",
media: filePath,
},
dryRun: false,
});
expect(result.kind).toBe("action");
expect(handleAction).toHaveBeenCalledTimes(1);
const params = handleAction.mock.calls[0]?.[0]?.params as Record<string, unknown>;
expect(params.filename).toBe(path.basename(filePath));
expect(params.buffer).toBe(Buffer.from("hello").toString("base64"));
} finally {
await fs.unlink(filePath).catch(() => {});
}
});
it("allows send when target matches current channel", async () => {
const result = await runMessageAction({
cfg: slackConfig,
@@ -80,6 +152,21 @@ describe("runMessageAction context isolation", () => {
expect(result.kind).toBe("send");
});
it("defaults to current channel when target is omitted", async () => {
const result = await runMessageAction({
cfg: slackConfig,
action: "send",
params: {
channel: "slack",
message: "hi",
},
toolContext: { currentChannelId: "C12345678" },
dryRun: true,
});
expect(result.kind).toBe("send");
});
it("allows media-only send when target matches current channel", async () => {
const result = await runMessageAction({
cfg: slackConfig,
@@ -210,6 +297,33 @@ describe("runMessageAction context isolation", () => {
expect(result.kind).toBe("send");
});
it("infers channel + target from tool context when missing", async () => {
const multiConfig = {
channels: {
slack: {
botToken: "xoxb-test",
appToken: "xapp-test",
},
telegram: {
token: "tg-test",
},
},
} as ClawdbotConfig;
const result = await runMessageAction({
cfg: multiConfig,
action: "send",
params: {
message: "hi",
},
toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" },
dryRun: true,
});
expect(result.kind).toBe("send");
expect(result.channel).toBe("slack");
});
it("blocks cross-provider sends by default", async () => {
await expect(
runMessageAction({
@@ -253,3 +367,91 @@ describe("runMessageAction context isolation", () => {
).rejects.toThrow(/Cross-context messaging denied/);
});
});
describe("runMessageAction sendAttachment hydration", () => {
const attachmentPlugin: ChannelPlugin = {
id: "bluebubbles",
meta: {
id: "bluebubbles",
label: "BlueBubbles",
selectionLabel: "BlueBubbles",
docsPath: "/channels/bluebubbles",
blurb: "BlueBubbles test plugin.",
},
capabilities: { chatTypes: ["direct"], media: true },
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({ enabled: true }),
isConfigured: () => true,
},
actions: {
listActions: () => ["sendAttachment"],
supportsAction: ({ action }) => action === "sendAttachment",
handleAction: async ({ params }) =>
jsonResult({
ok: true,
buffer: params.buffer,
filename: params.filename,
caption: params.caption,
contentType: params.contentType,
}),
},
};
beforeEach(() => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "bluebubbles",
source: "test",
plugin: attachmentPlugin,
},
]),
);
vi.mocked(loadWebMedia).mockResolvedValue({
buffer: Buffer.from("hello"),
contentType: "image/png",
kind: "image",
fileName: "pic.png",
});
});
afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
vi.clearAllMocks();
});
it("hydrates buffer and filename from media for sendAttachment", async () => {
const cfg = {
channels: {
bluebubbles: {
enabled: true,
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
} as ClawdbotConfig;
const result = await runMessageAction({
cfg,
action: "sendAttachment",
params: {
channel: "bluebubbles",
target: "+15551234567",
media: "https://example.com/pic.png",
message: "caption",
},
});
expect(result.kind).toBe("action");
expect(result.payload).toMatchObject({
ok: true,
filename: "pic.png",
caption: "caption",
contentType: "image/png",
});
expect((result.payload as { buffer?: string }).buffer).toBe(
Buffer.from("hello").toString("base64"),
);
});
});

View File

@@ -1,3 +1,6 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import {
readNumberParam,
@@ -12,7 +15,12 @@ import type {
ChannelThreadingToolContext,
} from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { GatewayClientMode, GatewayClientName } from "../../utils/message-channel.js";
import {
isDeliverableMessageChannel,
normalizeMessageChannel,
type GatewayClientMode,
type GatewayClientName,
} from "../../utils/message-channel.js";
import {
listConfiguredMessageChannels,
resolveMessageChannelSelection,
@@ -30,6 +38,8 @@ import {
import { executePollAction, executeSendAction } from "./outbound-send-service.js";
import { actionHasTarget, actionRequiresTarget } from "./message-action-spec.js";
import { resolveChannelTarget } from "./target-resolver.js";
import { loadWebMedia } from "../../web/media.js";
import { extensionForMime } from "../../media/mime.js";
export type MessageActionRunnerGateway = {
url?: string;
@@ -194,6 +204,124 @@ function readBooleanParam(params: Record<string, unknown>, key: string): boolean
return undefined;
}
function resolveAttachmentMaxBytes(params: {
cfg: ClawdbotConfig;
channel: ChannelId;
accountId?: string | null;
}): number | undefined {
const fallback = params.cfg.agents?.defaults?.mediaMaxMb;
if (params.channel !== "bluebubbles") {
return typeof fallback === "number" ? fallback * 1024 * 1024 : undefined;
}
const accountId = typeof params.accountId === "string" ? params.accountId.trim() : "";
const channelCfg = params.cfg.channels?.bluebubbles;
const accountCfg = accountId ? channelCfg?.accounts?.[accountId] : undefined;
const limitMb =
accountCfg?.mediaMaxMb ?? channelCfg?.mediaMaxMb ?? params.cfg.agents?.defaults?.mediaMaxMb;
return typeof limitMb === "number" ? limitMb * 1024 * 1024 : undefined;
}
function inferAttachmentFilename(params: {
mediaHint?: string;
contentType?: string;
}): string | undefined {
const mediaHint = params.mediaHint?.trim();
if (mediaHint) {
try {
if (mediaHint.startsWith("file://")) {
const filePath = fileURLToPath(mediaHint);
const base = path.basename(filePath);
if (base) return base;
} else if (/^https?:\/\//i.test(mediaHint)) {
const url = new URL(mediaHint);
const base = path.basename(url.pathname);
if (base) return base;
} else {
const base = path.basename(mediaHint);
if (base) return base;
}
} catch {
// fall through to content-type based default
}
}
const ext = params.contentType ? extensionForMime(params.contentType) : undefined;
return ext ? `attachment${ext}` : "attachment";
}
function normalizeBase64Payload(params: { base64?: string; contentType?: string }): {
base64?: string;
contentType?: string;
} {
if (!params.base64) return { base64: params.base64, contentType: params.contentType };
const match = /^data:([^;]+);base64,(.*)$/i.exec(params.base64.trim());
if (!match) return { base64: params.base64, contentType: params.contentType };
const [, mime, payload] = match;
return {
base64: payload,
contentType: params.contentType ?? mime,
};
}
async function hydrateSendAttachmentParams(params: {
cfg: ClawdbotConfig;
channel: ChannelId;
accountId?: string | null;
args: Record<string, unknown>;
action: ChannelMessageActionName;
dryRun?: boolean;
}): Promise<void> {
if (params.action !== "sendAttachment") return;
const mediaHint = readStringParam(params.args, "media", { trim: false });
const fileHint =
readStringParam(params.args, "path", { trim: false }) ??
readStringParam(params.args, "filePath", { trim: false });
const contentTypeParam =
readStringParam(params.args, "contentType") ?? readStringParam(params.args, "mimeType");
const caption = readStringParam(params.args, "caption", { allowEmpty: true })?.trim();
const message = readStringParam(params.args, "message", { allowEmpty: true })?.trim();
if (!caption && message) params.args.caption = message;
const rawBuffer = readStringParam(params.args, "buffer", { trim: false });
const normalized = normalizeBase64Payload({
base64: rawBuffer,
contentType: contentTypeParam ?? undefined,
});
if (normalized.base64 !== rawBuffer && normalized.base64) {
params.args.buffer = normalized.base64;
if (normalized.contentType && !contentTypeParam) {
params.args.contentType = normalized.contentType;
}
}
const filename = readStringParam(params.args, "filename");
const mediaSource = mediaHint ?? fileHint;
if (!params.dryRun && !readStringParam(params.args, "buffer", { trim: false }) && mediaSource) {
const maxBytes = resolveAttachmentMaxBytes({
cfg: params.cfg,
channel: params.channel,
accountId: params.accountId,
});
const media = await loadWebMedia(mediaSource, maxBytes);
params.args.buffer = media.buffer.toString("base64");
if (!contentTypeParam && media.contentType) {
params.args.contentType = media.contentType;
}
if (!filename) {
params.args.filename = inferAttachmentFilename({
mediaHint: media.fileName ?? mediaSource,
contentType: media.contentType ?? contentTypeParam ?? undefined,
});
}
} else if (!filename) {
params.args.filename = inferAttachmentFilename({
mediaHint: mediaSource,
contentType: contentTypeParam ?? undefined,
});
}
}
function parseButtonsParam(params: Record<string, unknown>): void {
const raw = params.buttons;
if (typeof raw !== "string") return;
@@ -534,6 +662,29 @@ export async function runMessageAction(
return handleBroadcastAction(input, params);
}
const explicitTarget = typeof params.target === "string" ? params.target.trim() : "";
const hasLegacyTarget =
(typeof params.to === "string" && params.to.trim().length > 0) ||
(typeof params.channelId === "string" && params.channelId.trim().length > 0);
if (
!explicitTarget &&
!hasLegacyTarget &&
actionRequiresTarget(action) &&
!actionHasTarget(action, params)
) {
const inferredTarget = input.toolContext?.currentChannelId?.trim();
if (inferredTarget) {
params.target = inferredTarget;
}
}
const explicitChannel = typeof params.channel === "string" ? params.channel.trim() : "";
if (!explicitChannel) {
const inferredChannel = normalizeMessageChannel(input.toolContext?.currentChannelProvider);
if (inferredChannel && isDeliverableMessageChannel(inferredChannel)) {
params.channel = inferredChannel;
}
}
applyTargetToParams({ action, args: params });
if (actionRequiresTarget(action)) {
if (!actionHasTarget(action, params)) {
@@ -545,6 +696,15 @@ export async function runMessageAction(
const accountId = readStringParam(params, "accountId") ?? input.defaultAccountId;
const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun"));
await hydrateSendAttachmentParams({
cfg,
channel,
accountId,
args: params,
action,
dryRun,
});
await resolveActionTarget({
cfg,
channel,
@@ -561,6 +721,14 @@ export async function runMessageAction(
cfg,
});
await hydrateSendAttachmentParams({
cfg,
channel,
accountId,
args: params,
dryRun,
});
const gateway = resolveGateway(input);
if (action === "send") {

View File

@@ -345,6 +345,15 @@
align-items: center;
}
.config-form .field.checkbox {
grid-template-columns: 18px minmax(0, 1fr);
column-gap: 10px;
}
.config-form .field.checkbox input[type="checkbox"] {
margin: 0;
}
.form-grid {
display: grid;
gap: 12px;

View File

@@ -215,7 +215,9 @@ function renderGenericAccount(account: ChannelAccountSnapshot) {
</div>
<div>
<span class="label">Connected</span>
<span>${account.connected ? "Yes" : "No"}</span>
<span>
${account.connected == null ? "n/a" : account.connected ? "Yes" : "No"}
</span>
</div>
<div>
<span class="label">Last inbound</span>

View File

@@ -99,6 +99,55 @@ export function renderNode(params: {
</label>
`;
}
const primitiveTypes = new Set(
nonNull
.map((variant) => schemaType(variant))
.filter((variant): variant is string => Boolean(variant)),
);
const normalizedTypes = new Set(
[...primitiveTypes].map((variant) => (variant === "integer" ? "number" : variant)),
);
const primitiveOnly = [...normalizedTypes].every((variant) =>
["string", "number", "boolean"].includes(variant),
);
if (primitiveOnly && normalizedTypes.size > 0) {
const hasString = normalizedTypes.has("string");
const hasNumber = normalizedTypes.has("number");
const hasBoolean = normalizedTypes.has("boolean");
if (hasBoolean && normalizedTypes.size === 1) {
return renderNode({
...params,
schema: { ...schema, type: "boolean", anyOf: undefined, oneOf: undefined },
});
}
if (hasString || hasNumber) {
const displayValue = value ?? schema.default ?? "";
return html`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<input
type=${hasNumber && !hasString ? "number" : "text"}
.value=${displayValue == null ? "" : String(displayValue)}
?disabled=${disabled}
@input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value;
if (hasString || !hasNumber || raw.trim() === "" || /[^0-9-.]/.test(raw)) {
onPatch(path, raw === "" ? undefined : raw);
return;
}
const parsed = Number(raw);
onPatch(path, Number.isNaN(parsed) ? raw : parsed);
}}
/>
</label>
`;
}
}
}
if (schema.enum) {
@@ -254,9 +303,7 @@ export function renderNode(params: {
? schema.default
: false;
return html`
<label class="field">
${showLabel ? html`<span>${label}</span>` : nothing}
${help ? html`<div class="muted">${help}</div>` : nothing}
<label class="field checkbox">
<input
type="checkbox"
.checked=${displayValue}
@@ -264,6 +311,12 @@ export function renderNode(params: {
@change=${(e: Event) =>
onPatch(path, (e.target as HTMLInputElement).checked)}
/>
${showLabel ? html`<span>${label}</span>` : nothing}
${help
? html`<div class="muted" style="grid-column: 1 / -1;">
${help}
</div>`
: nothing}
</label>
`;
}