feat(bluebubbles): add text formatting support

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.
This commit is contained in:
Clawd 2026-01-22 00:20:54 -08:00
parent 36cfe75a0b
commit 88fa8835b5
4 changed files with 496 additions and 2 deletions

View File

@ -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);
});
});

View File

@ -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<number>();
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<BlueBubblesTextStyle>();
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
);
}

View File

@ -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<string, unknown> = {
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;

View File

@ -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";