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:
parent
36cfe75a0b
commit
88fa8835b5
122
extensions/bluebubbles/src/format.test.ts
Normal file
122
extensions/bluebubbles/src/format.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
340
extensions/bluebubbles/src/format.ts
Normal file
340
extensions/bluebubbles/src/format.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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";
|
||||
|
||||
Loading…
Reference in New Issue
Block a user