diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md index cc6f26e31..f26f3c91c 100644 --- a/docs/channels/bluebubbles.md +++ b/docs/channels/bluebubbles.md @@ -98,6 +98,7 @@ Per-group configuration: ## Typing + read receipts - **Typing indicators**: Sent automatically before and during response generation. - **Read receipts**: Controlled by `channels.bluebubbles.sendReadReceipts` (default: `true`). +- **Typing indicators**: Clawdbot sends typing start events; BlueBubbles clears typing automatically on send or timeout (manual stop via DELETE is unreliable). ```json5 { diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 2eaf21fcd..97bfd632c 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -26,7 +26,11 @@ import { BlueBubblesConfigSchema } from "./config-schema.js"; import { probeBlueBubbles } from "./probe.js"; import { sendMessageBlueBubbles } from "./send.js"; import { sendBlueBubblesAttachment } from "./attachments.js"; -import { normalizeBlueBubblesHandle } from "./targets.js"; +import { + looksLikeBlueBubblesTargetId, + normalizeBlueBubblesHandle, + normalizeBlueBubblesMessagingTarget, +} from "./targets.js"; import { bluebubblesMessageActions } from "./actions.js"; import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js"; import { blueBubblesOnboardingAdapter } from "./onboarding.js"; @@ -153,6 +157,13 @@ export const bluebubblesPlugin: ChannelPlugin = { ]; }, }, + messaging: { + normalizeTarget: normalizeBlueBubblesMessagingTarget, + targetResolver: { + looksLikeId: looksLikeBlueBubblesTargetId, + hint: "", + }, + }, setup: { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index e8e5df1c5..ee28b4ed2 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -1001,6 +1001,7 @@ async function processMessage( CommandAuthorized: commandAuthorized, }; + let sentMessage = false; if (chatGuidForActions && baseUrl && password) { logVerbose(core, runtime, `typing start (pre-dispatch) chatGuid=${chatGuidForActions}`); try { @@ -1031,6 +1032,7 @@ async function processMessage( cfg: config, accountId: account.accountId, }); + sentMessage = true; statusSink?.({ lastOutboundAt: Date.now() }); } }, @@ -1048,15 +1050,7 @@ async function processMessage( } }, onIdle: () => { - if (!chatGuidForActions) return; - if (!baseUrl || !password) return; - logVerbose(core, runtime, `typing stop chatGuid=${chatGuidForActions}`); - void sendBlueBubblesTyping(chatGuidForActions, false, { - cfg: config, - accountId: account.accountId, - }).catch((err) => { - runtime.error?.(`[bluebubbles] typing stop failed: ${String(err)}`); - }); + // BlueBubbles typing stop (DELETE) does not clear bubbles reliably; wait for timeout. }, onError: (err, info) => { runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`); @@ -1070,14 +1064,8 @@ async function processMessage( }, }); } finally { - if (chatGuidForActions && baseUrl && password) { - logVerbose(core, runtime, `typing stop (finalize) chatGuid=${chatGuidForActions}`); - void sendBlueBubblesTyping(chatGuidForActions, false, { - cfg: config, - accountId: account.accountId, - }).catch((err) => { - runtime.error?.(`[bluebubbles] typing stop failed: ${String(err)}`); - }); + if (chatGuidForActions && baseUrl && password && !sentMessage) { + // BlueBubbles typing stop (DELETE) does not clear bubbles reliably; wait for timeout. } } } diff --git a/extensions/bluebubbles/src/targets.test.ts b/extensions/bluebubbles/src/targets.test.ts new file mode 100644 index 000000000..9fa5b8df7 --- /dev/null +++ b/extensions/bluebubbles/src/targets.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { + looksLikeBlueBubblesTargetId, + normalizeBlueBubblesMessagingTarget, +} from "./targets.js"; + +describe("normalizeBlueBubblesMessagingTarget", () => { + it("normalizes chat_guid targets", () => { + expect(normalizeBlueBubblesMessagingTarget("chat_guid:ABC-123")).toBe("chat_guid:ABC-123"); + }); + + it("normalizes group numeric targets to chat_id", () => { + expect(normalizeBlueBubblesMessagingTarget("group:123")).toBe("chat_id:123"); + }); + + it("strips provider prefix and normalizes handles", () => { + expect( + normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com"), + ).toBe("imessage:user@example.com"); + }); +}); + +describe("looksLikeBlueBubblesTargetId", () => { + it("accepts chat targets", () => { + expect(looksLikeBlueBubblesTargetId("chat_guid:ABC-123")).toBe(true); + }); + + it("accepts email handles", () => { + expect(looksLikeBlueBubblesTargetId("user@example.com")).toBe(true); + }); + + it("accepts phone numbers with punctuation", () => { + expect(looksLikeBlueBubblesTargetId("+1 (555) 123-4567")).toBe(true); + }); + + it("rejects display names", () => { + expect(looksLikeBlueBubblesTargetId("Jane Doe")).toBe(false); + }); +}); diff --git a/extensions/bluebubbles/src/targets.ts b/extensions/bluebubbles/src/targets.ts index c377c7747..676cb8634 100644 --- a/extensions/bluebubbles/src/targets.ts +++ b/extensions/bluebubbles/src/targets.ts @@ -25,6 +25,13 @@ function stripPrefix(value: string, prefix: string): string { return value.slice(prefix.length).trim(); } +function stripBlueBubblesPrefix(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ""; + if (!trimmed.toLowerCase().startsWith("bluebubbles:")) return trimmed; + return trimmed.slice("bluebubbles:".length).trim(); +} + export function normalizeBlueBubblesHandle(raw: string): string { const trimmed = raw.trim(); if (!trimmed) return ""; @@ -36,6 +43,55 @@ export function normalizeBlueBubblesHandle(raw: string): string { return trimmed.replace(/\s+/g, ""); } +export function normalizeBlueBubblesMessagingTarget(raw: string): string | undefined { + let trimmed = raw.trim(); + if (!trimmed) return undefined; + trimmed = stripBlueBubblesPrefix(trimmed); + if (!trimmed) return undefined; + try { + const parsed = parseBlueBubblesTarget(trimmed); + if (parsed.kind === "chat_id") return `chat_id:${parsed.chatId}`; + if (parsed.kind === "chat_guid") return `chat_guid:${parsed.chatGuid}`; + if (parsed.kind === "chat_identifier") return `chat_identifier:${parsed.chatIdentifier}`; + const handle = normalizeBlueBubblesHandle(parsed.to); + if (!handle) return undefined; + return parsed.service === "auto" ? handle : `${parsed.service}:${handle}`; + } catch { + return trimmed; + } +} + +export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) return false; + const candidate = stripBlueBubblesPrefix(trimmed); + if (!candidate) return false; + const lowered = candidate.toLowerCase(); + if (/^(imessage|sms|auto):/.test(lowered)) return true; + if ( + /^(chat_id|chatid|chat|chat_guid|chatguid|guid|chat_identifier|chatidentifier|chatident|group):/.test( + lowered, + ) + ) { + return true; + } + if (candidate.includes("@")) return true; + const digitsOnly = candidate.replace(/[\s().-]/g, ""); + if (/^\+?\d{3,}$/.test(digitsOnly)) return true; + if (normalized) { + const normalizedTrimmed = normalized.trim(); + if (!normalizedTrimmed) return false; + const normalizedLower = normalizedTrimmed.toLowerCase(); + if ( + /^(imessage|sms|auto):/.test(normalizedLower) || + /^(chat_id|chat_guid|chat_identifier):/.test(normalizedLower) + ) { + return true; + } + } + return false; +} + export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget { const trimmed = raw.trim(); if (!trimmed) throw new Error("BlueBubbles target is required"); diff --git a/src/config/schema.ts b/src/config/schema.ts index 68f19eb8a..3a943da39 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -250,6 +250,7 @@ const FIELD_LABELS: Record = { "channels.slack": "Slack", "channels.signal": "Signal", "channels.imessage": "iMessage", + "channels.bluebubbles": "BlueBubbles", "channels.msteams": "MS Teams", "channels.telegram.botToken": "Telegram Bot Token", "channels.telegram.dmPolicy": "Telegram DM Policy", @@ -268,6 +269,7 @@ const FIELD_LABELS: Record = { "channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)", "channels.signal.dmPolicy": "Signal DM Policy", "channels.imessage.dmPolicy": "iMessage DM Policy", + "channels.bluebubbles.dmPolicy": "BlueBubbles DM Policy", "channels.discord.dm.policy": "Discord DM Policy", "channels.discord.retry.attempts": "Discord Retry Attempts", "channels.discord.retry.minDelayMs": "Discord Retry Min Delay (ms)", @@ -540,6 +542,8 @@ const FIELD_HELP: Record = { 'Direct message access control ("pairing" recommended). "open" requires channels.signal.allowFrom=["*"].', "channels.imessage.dmPolicy": 'Direct message access control ("pairing" recommended). "open" requires channels.imessage.allowFrom=["*"].', + "channels.bluebubbles.dmPolicy": + 'Direct message access control ("pairing" recommended). "open" requires channels.bluebubbles.allowFrom=["*"].', "channels.discord.dm.policy": 'Direct message access control ("pairing" recommended). "open" requires channels.discord.dm.allowFrom=["*"].', "channels.discord.retry.attempts": diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 3adbf200d..7147c4fee 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -482,6 +482,79 @@ export const IMessageConfigSchema = IMessageAccountSchemaBase.extend({ }); }); +const BlueBubblesAllowFromEntry = z.union([z.string(), z.number()]); + +const BlueBubblesActionSchema = z + .object({ + reactions: z.boolean().optional(), + edit: z.boolean().optional(), + unsend: z.boolean().optional(), + reply: z.boolean().optional(), + sendWithEffect: z.boolean().optional(), + renameGroup: z.boolean().optional(), + addParticipant: z.boolean().optional(), + removeParticipant: z.boolean().optional(), + leaveGroup: z.boolean().optional(), + sendAttachment: z.boolean().optional(), + }) + .strict() + .optional(); + +const BlueBubblesGroupConfigSchema = z + .object({ + requireMention: z.boolean().optional(), + }) + .strict(); + +export const BlueBubblesAccountSchemaBase = z + .object({ + name: z.string().optional(), + capabilities: z.array(z.string()).optional(), + configWrites: z.boolean().optional(), + enabled: z.boolean().optional(), + serverUrl: z.string().optional(), + password: z.string().optional(), + webhookPath: z.string().optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), + allowFrom: z.array(BlueBubblesAllowFromEntry).optional(), + groupAllowFrom: z.array(BlueBubblesAllowFromEntry).optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), + textChunkLimit: z.number().int().positive().optional(), + mediaMaxMb: z.number().int().positive().optional(), + sendReadReceipts: z.boolean().optional(), + blockStreaming: z.boolean().optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + groups: z.record(z.string(), BlueBubblesGroupConfigSchema.optional()).optional(), + }) + .strict(); + +export const BlueBubblesAccountSchema = BlueBubblesAccountSchemaBase.superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: 'channels.bluebubbles.accounts.*.dmPolicy="open" requires allowFrom to include "*"', + }); +}); + +export const BlueBubblesConfigSchema = BlueBubblesAccountSchemaBase.extend({ + accounts: z.record(z.string(), BlueBubblesAccountSchema.optional()).optional(), + actions: BlueBubblesActionSchema, +}).superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.bluebubbles.dmPolicy="open" requires channels.bluebubbles.allowFrom to include "*"', + }); +}); + export const MSTeamsChannelSchema = z .object({ requireMention: z.boolean().optional(), diff --git a/src/config/zod-schema.providers.ts b/src/config/zod-schema.providers.ts index f0524631d..1fd7319fe 100644 --- a/src/config/zod-schema.providers.ts +++ b/src/config/zod-schema.providers.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { + BlueBubblesConfigSchema, DiscordConfigSchema, IMessageConfigSchema, MSTeamsConfigSchema, @@ -28,6 +29,7 @@ export const ChannelsSchema = z slack: SlackConfigSchema.optional(), signal: SignalConfigSchema.optional(), imessage: IMessageConfigSchema.optional(), + bluebubbles: BlueBubblesConfigSchema.optional(), msteams: MSTeamsConfigSchema.optional(), }) .passthrough()