From 88fa8835b5a8266ee1f2703c5578198524d6d0e6 Mon Sep 17 00:00:00 2001 From: Clawd Date: Thu, 22 Jan 2026 00:20:54 -0800 Subject: [PATCH] feat(bluebubbles): add text formatting support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert markdown formatting to BlueBubbles textFormatting array: - **bold** → bold style - *italic* → italic style - ~~strikethrough~~ → strikethrough style - ++underline++ → underline style Auto-enables private-api method when formatting is detected. Includes 18 tests covering all formatting scenarios. --- extensions/bluebubbles/src/format.test.ts | 122 ++++++++ extensions/bluebubbles/src/format.ts | 340 ++++++++++++++++++++++ extensions/bluebubbles/src/send.ts | 25 +- src/plugin-sdk/index.ts | 11 + 4 files changed, 496 insertions(+), 2 deletions(-) create mode 100644 extensions/bluebubbles/src/format.test.ts create mode 100644 extensions/bluebubbles/src/format.ts diff --git a/extensions/bluebubbles/src/format.test.ts b/extensions/bluebubbles/src/format.test.ts new file mode 100644 index 000000000..76cd67eee --- /dev/null +++ b/extensions/bluebubbles/src/format.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; + +import { + hasFormattingMarkers, + markdownToBlueBubblesText, + type BlueBubblesFormattedText, +} from "./format.js"; + +describe("markdownToBlueBubblesText", () => { + it("converts bold markdown", () => { + const result = markdownToBlueBubblesText("Hello **world**!"); + expect(result.text).toBe("Hello world!"); + expect(result.textFormatting).toEqual([{ start: 6, length: 5, styles: ["bold"] }]); + }); + + it("converts italic markdown with asterisks", () => { + const result = markdownToBlueBubblesText("Hello *world*!"); + expect(result.text).toBe("Hello world!"); + expect(result.textFormatting).toEqual([{ start: 6, length: 5, styles: ["italic"] }]); + }); + + it("converts italic markdown with underscores", () => { + const result = markdownToBlueBubblesText("Hello _world_!"); + expect(result.text).toBe("Hello world!"); + expect(result.textFormatting).toEqual([{ start: 6, length: 5, styles: ["italic"] }]); + }); + + it("converts strikethrough markdown", () => { + const result = markdownToBlueBubblesText("Hello ~~world~~!"); + expect(result.text).toBe("Hello world!"); + expect(result.textFormatting).toEqual([{ start: 6, length: 5, styles: ["strikethrough"] }]); + }); + + it("converts underline with ++ syntax", () => { + const result = markdownToBlueBubblesText("Hello ++world++!"); + expect(result.text).toBe("Hello world!"); + expect(result.textFormatting).toEqual([{ start: 6, length: 5, styles: ["underline"] }]); + }); + + it("converts multiple formatting styles", () => { + const result = markdownToBlueBubblesText("**Bold** *Italic* ~~Strike~~ ++Under++"); + expect(result.text).toBe("Bold Italic Strike Under"); + expect(result.textFormatting).toContainEqual({ start: 0, length: 4, styles: ["bold"] }); + expect(result.textFormatting).toContainEqual({ start: 5, length: 6, styles: ["italic"] }); + expect(result.textFormatting).toContainEqual({ + start: 12, + length: 6, + styles: ["strikethrough"], + }); + expect(result.textFormatting).toContainEqual({ start: 19, length: 5, styles: ["underline"] }); + }); + + it("handles nested bold and italic", () => { + const result = markdownToBlueBubblesText("***bold italic***"); + expect(result.text).toBe("bold italic"); + // Should have both bold and italic styles + const boldSpan = result.textFormatting.find((f) => f.styles.includes("bold")); + const italicSpan = result.textFormatting.find((f) => f.styles.includes("italic")); + expect(boldSpan).toBeDefined(); + expect(italicSpan).toBeDefined(); + }); + + it("handles empty string", () => { + const result = markdownToBlueBubblesText(""); + expect(result.text).toBe(""); + expect(result.textFormatting).toEqual([]); + }); + + it("handles plain text without formatting", () => { + const result = markdownToBlueBubblesText("Hello world!"); + expect(result.text).toBe("Hello world!"); + expect(result.textFormatting).toEqual([]); + }); + + it("handles unclosed underline markers as literal text", () => { + const result = markdownToBlueBubblesText("Hello ++world"); + expect(result.text).toBe("Hello ++world"); + expect(result.textFormatting).toEqual([]); + }); + + it("handles bold with double underscores", () => { + const result = markdownToBlueBubblesText("__bold__"); + expect(result.text).toBe("bold"); + // Note: In standard markdown, __ is bold (same as **) + expect(result.textFormatting).toEqual([{ start: 0, length: 4, styles: ["bold"] }]); + }); + + it("ignores code spans (not supported by iMessage)", () => { + const result = markdownToBlueBubblesText("Hello `code` world"); + expect(result.text).toBe("Hello code world"); + // Code spans should not produce formatting (not supported) + expect(result.textFormatting).toEqual([]); + }); +}); + +describe("hasFormattingMarkers", () => { + it("detects bold markers", () => { + expect(hasFormattingMarkers("**bold**")).toBe(true); + expect(hasFormattingMarkers("__bold__")).toBe(true); + }); + + it("detects italic markers", () => { + expect(hasFormattingMarkers("*italic*")).toBe(true); + expect(hasFormattingMarkers("_italic_")).toBe(true); + }); + + it("detects strikethrough markers", () => { + expect(hasFormattingMarkers("~~strike~~")).toBe(true); + }); + + it("detects underline markers", () => { + expect(hasFormattingMarkers("++underline++")).toBe(true); + }); + + it("returns false for plain text", () => { + expect(hasFormattingMarkers("hello world")).toBe(false); + }); + + it("returns false for empty string", () => { + expect(hasFormattingMarkers("")).toBe(false); + }); +}); diff --git a/extensions/bluebubbles/src/format.ts b/extensions/bluebubbles/src/format.ts new file mode 100644 index 000000000..c2753b9cb --- /dev/null +++ b/extensions/bluebubbles/src/format.ts @@ -0,0 +1,340 @@ +import { + markdownToIR, + type MarkdownIR, + type MarkdownStyle, + type MarkdownStyleSpan, +} from "clawdbot/plugin-sdk"; + +export type BlueBubblesTextStyle = "bold" | "italic" | "underline" | "strikethrough"; + +export type BlueBubblesTextFormatting = { + start: number; + length: number; + styles: BlueBubblesTextStyle[]; +}; + +export type BlueBubblesFormattedText = { + text: string; + textFormatting: BlueBubblesTextFormatting[]; +}; + +/** + * Maps Clawdbot markdown IR style to BlueBubbles text style. + * Returns null for styles that BlueBubbles doesn't support. + */ +function mapStyle(style: MarkdownStyle): BlueBubblesTextStyle | null { + switch (style) { + case "bold": + return "bold"; + case "italic": + return "italic"; + case "strikethrough": + return "strikethrough"; + // code and code_block could be mapped to a monospace style if BB ever supports it + case "code": + case "code_block": + case "spoiler": + return null; + default: + return null; + } +} + +/** + * Represents a span with its position and style for merging overlapping styles. + */ +type StyleEvent = { + pos: number; + isEnd: boolean; + style: BlueBubblesTextStyle; + spanIndex: number; +}; + +/** + * Converts markdown IR styles to BlueBubbles textFormatting array. + * BlueBubbles expects each formatting entry to have start, length, and an array of styles. + * When multiple styles overlap at the same position, they should be combined. + */ +function convertStylesToFormatting( + styles: MarkdownStyleSpan[], + textLength: number, +): BlueBubblesTextFormatting[] { + if (styles.length === 0) return []; + + // Map IR styles to BB styles, filtering out unsupported ones + const mappedSpans: Array<{ start: number; end: number; style: BlueBubblesTextStyle }> = []; + for (const span of styles) { + const mapped = mapStyle(span.style); + if (mapped) { + mappedSpans.push({ + start: Math.max(0, span.start), + end: Math.min(span.end, textLength), + style: mapped, + }); + } + } + + if (mappedSpans.length === 0) return []; + + // Create events for style starts and ends + const events: StyleEvent[] = []; + mappedSpans.forEach((span, index) => { + if (span.end > span.start) { + events.push({ pos: span.start, isEnd: false, style: span.style, spanIndex: index }); + events.push({ pos: span.end, isEnd: true, style: span.style, spanIndex: index }); + } + }); + + // Sort events: by position, then ends before starts at the same position + events.sort((a, b) => { + if (a.pos !== b.pos) return a.pos - b.pos; + // Ends come before starts at the same position + if (a.isEnd !== b.isEnd) return a.isEnd ? -1 : 1; + return 0; + }); + + // Collect all boundary positions + const boundaries = new Set(); + for (const event of events) { + boundaries.add(event.pos); + } + const sortedBoundaries = [...boundaries].sort((a, b) => a - b); + + // Track active styles at each segment + const result: BlueBubblesTextFormatting[] = []; + const activeStyles = new Set(); + + for (let i = 0; i < sortedBoundaries.length; i++) { + const pos = sortedBoundaries[i]; + const nextPos = sortedBoundaries[i + 1]; + + // Process all events at this position + for (const event of events) { + if (event.pos !== pos) continue; + if (event.isEnd) { + activeStyles.delete(event.style); + } else { + activeStyles.add(event.style); + } + } + + // If we have active styles and there's a next position, emit a formatting entry + if (activeStyles.size > 0 && nextPos !== undefined && nextPos > pos) { + result.push({ + start: pos, + length: nextPos - pos, + styles: [...activeStyles].sort(), + }); + } + } + + // Merge adjacent entries with the same styles + const merged: BlueBubblesTextFormatting[] = []; + for (const entry of result) { + const prev = merged[merged.length - 1]; + if ( + prev && + prev.start + prev.length === entry.start && + prev.styles.length === entry.styles.length && + prev.styles.every((s, i) => s === entry.styles[i]) + ) { + prev.length += entry.length; + } else { + merged.push({ ...entry }); + } + } + + return merged; +} + +/** + * Parses custom underline syntax (++text++) from plain text and returns underline spans. + * This is applied to text that has already had markdown removed. + */ +function extractUnderlineSpans( + input: string, +): { text: string; spans: Array<{ start: number; end: number }> } { + const spans: Array<{ start: number; end: number }> = []; + let result = ""; + let i = 0; + + while (i < input.length) { + // Check for opening ++ + if (input[i] === "+" && input[i + 1] === "+") { + const startMarker = i; + i += 2; + + // Find closing ++ + let foundClose = false; + let content = ""; + while (i < input.length) { + if (input[i] === "+" && input[i + 1] === "+") { + foundClose = true; + i += 2; + break; + } + content += input[i]; + i++; + } + + if (foundClose && content.length > 0) { + const spanStart = result.length; + result += content; + spans.push({ start: spanStart, end: result.length }); + } else { + // No closing ++, treat as literal + result += input.slice(startMarker, i); + } + } else { + result += input[i]; + i++; + } + } + + return { text: result, spans }; +} + +/** + * Pre-processes the input to extract underline markers before markdown parsing. + * Returns the text with underline markers removed and a map of original positions to new positions. + */ +function preprocessUnderline(input: string): { + text: string; + underlineRanges: Array<{ origStart: number; origEnd: number; content: string }>; +} { + const underlineRanges: Array<{ origStart: number; origEnd: number; content: string }> = []; + let result = ""; + let i = 0; + + while (i < input.length) { + if (input[i] === "+" && input[i + 1] === "+") { + const startMarker = i; + i += 2; + + let content = ""; + let foundClose = false; + while (i < input.length) { + if (input[i] === "+" && input[i + 1] === "+") { + foundClose = true; + i += 2; + if (content.length > 0) { + underlineRanges.push({ + origStart: startMarker, + origEnd: i, + content, + }); + result += content; + } else { + result += "++"; + } + break; + } + content += input[i]; + i++; + } + + // If we hit end of string without finding closing ++ + if (!foundClose && content.length > 0) { + result += "++" + content; + } + } else { + result += input[i]; + i++; + } + } + + return { text: result, underlineRanges }; +} + +/** + * Converts markdown text to BlueBubbles formatted text with textFormatting array. + * + * Supported markdown: + * - **bold** or __bold__ + * - *italic* or _italic_ + * - ~~strikethrough~~ + * - ++underline++ (custom syntax) + * + * @param markdown - The markdown-formatted input text + * @returns Object with plain text and BlueBubbles textFormatting array + */ +export function markdownToBlueBubblesText(markdown: string): BlueBubblesFormattedText { + if (!markdown || !markdown.trim()) { + return { text: markdown ?? "", textFormatting: [] }; + } + + // Step 1: Extract underline ranges from original input + const { text: withoutUnderlineMarkers, underlineRanges } = preprocessUnderline(markdown); + + // Step 2: Parse the remaining markdown to IR + const ir = markdownToIR(withoutUnderlineMarkers, { + linkify: false, + enableSpoilers: false, + headingStyle: "none", + blockquotePrefix: "", + }); + + // Step 3: Convert IR styles to BlueBubbles formatting + const irFormatting = convertStylesToFormatting(ir.styles, ir.text.length); + + // Step 4: Map underline ranges from original positions to IR text positions + // We need to track how markdown parsing shifted positions + const underlineSpans: Array<{ start: number; end: number }> = []; + + if (underlineRanges.length > 0) { + // Build a mapping from original text positions to IR text positions + // This is tricky because we need to account for: + // 1. Underline markers already removed (done in preprocessUnderline) + // 2. Markdown syntax removed by markdownToIR + + // Simple approach: find the underline content in the IR text + // This works because we've already removed the ++ markers + for (const range of underlineRanges) { + const content = range.content; + // Find where this content appears in the IR text + // We need to search from an approximate position based on the original location + const searchStart = Math.max( + 0, + Math.floor((range.origStart / markdown.length) * ir.text.length) - content.length, + ); + const idx = ir.text.indexOf(content, searchStart); + if (idx !== -1) { + underlineSpans.push({ start: idx, end: idx + content.length }); + } + } + } + + // Step 5: Create underline formatting entries + const underlineFormatting: BlueBubblesTextFormatting[] = underlineSpans + .filter((span) => span.end > span.start && span.end <= ir.text.length) + .map((span) => ({ + start: span.start, + length: span.end - span.start, + styles: ["underline" as BlueBubblesTextStyle], + })); + + // Step 6: Merge all formatting and sort by position + const allFormatting = [...irFormatting, ...underlineFormatting]; + allFormatting.sort((a, b) => a.start - b.start); + + return { + text: ir.text, + textFormatting: allFormatting, + }; +} + +/** + * Checks if the text has any formatting that BlueBubbles can render. + */ +export function hasFormattingMarkers(text: string): boolean { + if (!text) return false; + // Check for markdown-style formatting markers + return ( + /\*\*[^*]+\*\*/.test(text) || // bold + /\*[^*]+\*/.test(text) || // italic + /__[^_]+__/.test(text) || // bold (alt) + /_[^_]+_/.test(text) || // italic (alt) + /~~[^~]+~~/.test(text) || // strikethrough + /\+\+[^+]+\+\+/.test(text) // underline + ); +} diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index 675063d6d..aae6caecd 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -1,6 +1,11 @@ import crypto from "node:crypto"; import { resolveBlueBubblesAccount } from "./accounts.js"; +import { + hasFormattingMarkers, + markdownToBlueBubblesText, + type BlueBubblesTextFormatting, +} from "./format.js"; import { extractHandleFromChatGuid, normalizeBlueBubblesHandle, @@ -301,17 +306,33 @@ export async function sendMessageBlueBubbles( "BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.", ); } + + // Check if text contains formatting markers and convert them + let messageText = trimmedText; + let textFormatting: BlueBubblesTextFormatting[] = []; + if (hasFormattingMarkers(trimmedText)) { + const formatted = markdownToBlueBubblesText(trimmedText); + messageText = formatted.text; + textFormatting = formatted.textFormatting; + } + const effectId = resolveEffectId(opts.effectId); - const needsPrivateApi = Boolean(opts.replyToMessageGuid || effectId); + const hasFormatting = textFormatting.length > 0; + const needsPrivateApi = Boolean(opts.replyToMessageGuid || effectId || hasFormatting); const payload: Record = { chatGuid, tempGuid: crypto.randomUUID(), - message: trimmedText, + message: messageText, }; if (needsPrivateApi) { payload.method = "private-api"; } + // Add text formatting support (requires private-api) + if (hasFormatting) { + payload.textFormatting = textFormatting; + } + // Add reply threading support if (opts.replyToMessageGuid) { payload.selectedMessageGuid = opts.replyToMessageGuid; diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 1da3650fe..85484b3ee 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -287,3 +287,14 @@ export { collectBlueBubblesStatusIssues } from "../channels/plugins/status-issue // Media utilities export { loadWebMedia, type WebMediaResult } from "../web/media.js"; + +// Markdown utilities +export { + chunkMarkdownIR, + markdownToIR, + type MarkdownIR, + type MarkdownLinkSpan, + type MarkdownParseOptions, + type MarkdownStyle, + type MarkdownStyleSpan, +} from "../markdown/ir.js";