From 574b848863bd5b0ebe662fb954eb7053bd01d566 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Mon, 19 Jan 2026 23:40:22 -0800 Subject: [PATCH] feat: enhance BlueBubbles message actions with support for message editing, reply metadata, and improved effect handling --- extensions/bluebubbles/src/actions.test.ts | 59 +++ extensions/bluebubbles/src/actions.ts | 29 +- extensions/bluebubbles/src/channel.ts | 39 +- extensions/bluebubbles/src/monitor.test.ts | 193 ++++++++ extensions/bluebubbles/src/monitor.ts | 415 ++++++++++++++++-- extensions/bluebubbles/src/reactions.ts | 4 +- extensions/bluebubbles/src/send.test.ts | 71 +++ extensions/bluebubbles/src/send.ts | 38 +- extensions/bluebubbles/src/targets.ts | 2 +- src/agents/pi-embedded-runner/run/params.ts | 3 + src/agents/pi-embedded-runner/run/types.ts | 3 + ...pi-embedded-subscribe.handlers.messages.ts | 12 +- src/agents/pi-embedded-subscribe.ts | 12 +- src/agents/pi-embedded-subscribe.types.ts | 3 + src/agents/tools/message-tool.test.ts | 59 +++ src/agents/tools/message-tool.ts | 58 ++- .../reply/agent-runner-execution.ts | 3 + .../outbound/message-action-runner.test.ts | 204 ++++++++- src/infra/outbound/message-action-runner.ts | 170 ++++++- ui/src/styles/components.css | 9 + ui/src/ui/views/channels.ts | 4 +- ui/src/ui/views/config-form.node.ts | 59 ++- 22 files changed, 1366 insertions(+), 83 deletions(-) diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts index a07cbdf8c..b328c3de3 100644 --- a/extensions/bluebubbles/src/actions.test.ts +++ b/extensions/bluebubbles/src/actions.test.ts @@ -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" }, + }); + }); }); }); diff --git a/extensions/bluebubbles/src/actions.ts b/extensions/bluebubbles/src/actions.ts index af1c53818..cc3b3ce39 100644 --- a/extensions/bluebubbles/src/actions.ts +++ b/extensions/bluebubbles/src/actions.ts @@ -42,6 +42,10 @@ function mapTarget(raw: string): BlueBubblesSendTarget { }; } +function readMessageText(params: Record): string | undefined { + return readStringParam(params, "text") ?? readStringParam(params, "message"); +} + /** Supported action names for BlueBubbles */ const SUPPORTED_ACTIONS = new Set([ "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=, text=, to=.`, + `Use action=reply with messageId=, message=, 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=, to=, effectId=.`, + `Use action=sendWithEffect with message=, target=, effectId=.`, ); } diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 97bfd632c..25290d2a2 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -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 = { } 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 = { 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) => { diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index e6d9ea2d1..ad859a3c5 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -28,6 +28,14 @@ vi.mock("./attachments.js", () => ({ }), })); +vi.mock("./reactions.js", async () => { + const actual = await vi.importActual("./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); diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index ee28b4ed2..2ae499c61 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -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(); type BlueBubblesCoreRuntime = ReturnType; @@ -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 | null, key: string): nu return undefined; } +function extractReplyMetadata(message: Record): { + 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): Record | null { const chats = message["chats"]; if (!Array.isArray(chats) || chats.length === 0) return null; @@ -201,6 +292,108 @@ function readFirstChatRecord(message: Record): Record(); + 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(); + 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): Record | null { const dataRaw = payload.data ?? payload.payload ?? payload.event; const data = @@ -331,13 +553,18 @@ function normalizeWebhookMessage(payload: Record): 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): NormalizedWe const normalizedSender = normalizeBlueBubblesHandle(senderId); if (!normalizedSender) return null; + const replyMetadata = extractReplyMetadata(message); return { text, @@ -375,6 +603,10 @@ function normalizeWebhookMessage(payload: Record): 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): 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 { 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 { 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 { diff --git a/extensions/bluebubbles/src/reactions.ts b/extensions/bluebubbles/src/reactions.ts index 05819f8ca..d7a2beaa8 100644 --- a/extensions/bluebubbles/src/reactions.ts +++ b/extensions/bluebubbles/src/reactions.ts @@ -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, diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index 4a97c4646..94db776ca 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -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, diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index deb00ad4e..2ef30cb7a 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -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 = { 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 = { 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( diff --git a/extensions/bluebubbles/src/targets.ts b/extensions/bluebubbles/src/targets.ts index e27220777..ec11d9e84 100644 --- a/extensions/bluebubbles/src/targets.ts +++ b/extensions/bluebubbles/src/targets.ts @@ -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] === "-") { diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index 1ebc129b1..52e85d3b3 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -61,6 +61,9 @@ export type RunEmbeddedPiAgentParams = { text?: string; mediaUrls?: string[]; audioAsVoice?: boolean; + replyToId?: string; + replyToTag?: boolean; + replyToCurrent?: boolean; }) => void | Promise; onBlockReplyFlush?: () => void | Promise; blockReplyBreak?: "text_end" | "message_end"; diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index 87940f4d4..8aa0ec195 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -56,6 +56,9 @@ export type EmbeddedRunAttemptParams = { text?: string; mediaUrls?: string[]; audioAsVoice?: boolean; + replyToId?: string; + replyToTag?: boolean; + replyToCurrent?: boolean; }) => void | Promise; onBlockReplyFlush?: () => void | Promise; blockReplyBreak?: "text_end" | "message_end"; diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index 7e75f56f4..b2074d866 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -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, }); } } diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index fed64fdde..1ed83100f 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -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, }); }; diff --git a/src/agents/pi-embedded-subscribe.types.ts b/src/agents/pi-embedded-subscribe.types.ts index 07953cdd8..766ff7f18 100644 --- a/src/agents/pi-embedded-subscribe.types.ts +++ b/src/agents/pi-embedded-subscribe.types.ts @@ -19,6 +19,9 @@ export type SubscribeEmbeddedPiSessionParams = { text?: string; mediaUrls?: string[]; audioAsVoice?: boolean; + replyToId?: string; + replyToTag?: boolean; + replyToCurrent?: boolean; }) => void | Promise; /** Flush pending block replies (e.g., before tool execution to preserve message boundaries). */ onBlockReplyFlush?: () => void | Promise; diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index de56ba8c6..b1f887813 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -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([])); + }); +}); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index cf16993e0..7a293fd4c 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -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([ + "renameGroup", + "addParticipant", + "removeParticipant", + "leaveGroup", +]); function buildRoutingSchema() { return { @@ -37,7 +45,26 @@ function buildRoutingSchema() { function buildSendSchema(options: { includeButtons: boolean }) { const props: Record = { 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 { diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index c423d3356..ccc793f34 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -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, ); diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index 0b89ff31a..2326dc080 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -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: "", + }, + normalizeTarget: (raw) => raw.trim(), + }, + actions: { + listActions: () => ["sendAttachment"], + handleAction: handleAction as NonNullable["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; + 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"), + ); + }); +}); diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 8db2d1e5b..0f79559aa 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -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, 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; + action: ChannelMessageActionName; + dryRun?: boolean; +}): Promise { + 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): 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") { diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index ee27aa0fb..e04492531 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -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; diff --git a/ui/src/ui/views/channels.ts b/ui/src/ui/views/channels.ts index 10118ea8c..3eab5b0df 100644 --- a/ui/src/ui/views/channels.ts +++ b/ui/src/ui/views/channels.ts @@ -215,7 +215,9 @@ function renderGenericAccount(account: ChannelAccountSnapshot) {
Connected - ${account.connected ? "Yes" : "No"} + + ${account.connected == null ? "n/a" : account.connected ? "Yes" : "No"} +
Last inbound diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts index 7d55f2226..8550d8125 100644 --- a/ui/src/ui/views/config-form.node.ts +++ b/ui/src/ui/views/config-form.node.ts @@ -99,6 +99,55 @@ export function renderNode(params: { `; } + + 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` + + `; + } + } } if (schema.enum) { @@ -254,9 +303,7 @@ export function renderNode(params: { ? schema.default : false; return html` -