mirror of
https://github.com/clawdbot/clawdbot.git
synced 2026-02-01 03:47:45 +01:00
feat: enhance BlueBubbles message actions with support for message editing, reply metadata, and improved effect handling
This commit is contained in:
committed by
Peter Steinberger
parent
2e6c58bf75
commit
574b848863
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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] === "-") {
|
||||
|
||||
Reference in New Issue
Block a user