feat(ringcentral): add markdown conversion for RingCentral Mini-Markdown

RingCentral uses a different markdown format where _text_ means underline,
not italic. This adds automatic conversion:
- _italic_ -> *italic* (preserve italic intent)
- __bold__ -> **bold**
- Remove unsupported: ~~strikethrough~~, `code`, ```code blocks```
- # Heading -> **Heading** (bold)
- Preserve URLs in links from underscore conversion

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
John Lin 2026-01-28 10:49:17 +08:00
parent dba1763c81
commit 0b1aebe80f
No known key found for this signature in database
3 changed files with 211 additions and 2 deletions

View File

@ -1,5 +1,6 @@
import type { ResolvedRingCentralAccount } from "./accounts.js"; import type { ResolvedRingCentralAccount } from "./accounts.js";
import { getRingCentralPlatform } from "./auth.js"; import { getRingCentralPlatform } from "./auth.js";
import { toRingCentralMarkdown } from "./markdown.js";
import type { import type {
RingCentralChat, RingCentralChat,
RingCentralPost, RingCentralPost,
@ -20,7 +21,7 @@ export async function sendRingCentralMessage(params: {
const platform = await getRingCentralPlatform(account); const platform = await getRingCentralPlatform(account);
const body: Record<string, unknown> = {}; const body: Record<string, unknown> = {};
if (text) body.text = text; if (text) body.text = toRingCentralMarkdown(text);
if (attachments && attachments.length > 0) { if (attachments && attachments.length > 0) {
body.attachments = attachments; body.attachments = attachments;
} }
@ -41,7 +42,7 @@ export async function updateRingCentralMessage(params: {
const response = await platform.patch( const response = await platform.patch(
`${TM_API_BASE}/chats/${chatId}/posts/${postId}`, `${TM_API_BASE}/chats/${chatId}/posts/${postId}`,
{ text }, { text: toRingCentralMarkdown(text) },
); );
const result = (await response.json()) as RingCentralPost; const result = (await response.json()) as RingCentralPost;
return { postId: result.id }; return { postId: result.id };

View File

@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import { toRingCentralMarkdown, needsMarkdownConversion } from "./markdown.js";
describe("toRingCentralMarkdown", () => {
it("converts single underscore italic to asterisk", () => {
expect(toRingCentralMarkdown("_italic_")).toBe("*italic*");
expect(toRingCentralMarkdown("This is _italic_ text")).toBe("This is *italic* text");
});
it("preserves double asterisk bold", () => {
expect(toRingCentralMarkdown("**bold**")).toBe("**bold**");
expect(toRingCentralMarkdown("This is **bold** text")).toBe("This is **bold** text");
});
it("converts double underscore to bold", () => {
expect(toRingCentralMarkdown("__bold__")).toBe("**bold**");
});
it("removes strikethrough", () => {
expect(toRingCentralMarkdown("~~strikethrough~~")).toBe("strikethrough");
expect(toRingCentralMarkdown("This is ~~deleted~~ text")).toBe("This is deleted text");
});
it("removes code blocks", () => {
expect(toRingCentralMarkdown("```\ncode\n```")).toBe("code");
expect(toRingCentralMarkdown("```javascript\nconst x = 1;\n```")).toBe("const x = 1;");
});
it("removes inline code", () => {
expect(toRingCentralMarkdown("`code`")).toBe("code");
expect(toRingCentralMarkdown("Use `npm install` to install")).toBe("Use npm install to install");
});
it("converts headings to bold", () => {
expect(toRingCentralMarkdown("# Heading 1")).toBe("**Heading 1**");
expect(toRingCentralMarkdown("## Heading 2")).toBe("**Heading 2**");
expect(toRingCentralMarkdown("### Heading 3")).toBe("**Heading 3**");
});
it("normalizes bullet lists", () => {
expect(toRingCentralMarkdown("- item")).toBe("* item");
expect(toRingCentralMarkdown("+ item")).toBe("* item");
expect(toRingCentralMarkdown("* item")).toBe("* item");
});
it("preserves links", () => {
expect(toRingCentralMarkdown("[link](https://example.com)")).toBe("[link](https://example.com)");
});
it("preserves blockquotes", () => {
expect(toRingCentralMarkdown("> quote")).toBe("> quote");
});
it("preserves numbered lists", () => {
expect(toRingCentralMarkdown("1. first\n2. second")).toBe("1. first\n2. second");
});
it("handles complex markdown", () => {
const input = `# Title
This is _italic_ and **bold** text.
- Item 1
- Item 2
\`\`\`
code block
\`\`\`
Use \`inline code\` here.`;
const expected = `**Title**
This is *italic* and **bold** text.
* Item 1
* Item 2
code block
Use inline code here.`;
expect(toRingCentralMarkdown(input)).toBe(expected);
});
it("does not convert underscore in URLs or paths", () => {
// URLs with underscores should be preserved in links
expect(toRingCentralMarkdown("[link](https://example.com/path_with_underscore)"))
.toBe("[link](https://example.com/path_with_underscore)");
});
});
describe("needsMarkdownConversion", () => {
it("returns true for text with underscore italic", () => {
expect(needsMarkdownConversion("_italic_")).toBe(true);
});
it("returns true for text with strikethrough", () => {
expect(needsMarkdownConversion("~~strike~~")).toBe(true);
});
it("returns true for text with code blocks", () => {
expect(needsMarkdownConversion("```code```")).toBe(true);
});
it("returns true for text with inline code", () => {
expect(needsMarkdownConversion("`code`")).toBe(true);
});
it("returns true for text with headings", () => {
expect(needsMarkdownConversion("# Heading")).toBe(true);
});
it("returns false for plain text", () => {
expect(needsMarkdownConversion("plain text")).toBe(false);
});
it("returns false for already compatible markdown", () => {
expect(needsMarkdownConversion("*italic* and **bold**")).toBe(false);
});
});

View File

@ -0,0 +1,87 @@
/**
* Convert standard Markdown to RingCentral Mini-Markdown format.
*
* RingCentral Mini-Markdown differences:
* - `_text_` = underline (not italic)
* - `*text*` = italic
* - `**text**` = bold
* - `[text](url)` = link
* - `> quote` = blockquote
* - `* item` = bullet list
*
* Not supported: strikethrough (~~), code blocks (```), headings (#), etc.
*/
/**
* Convert standard markdown to RingCentral mini-markdown
*/
export function toRingCentralMarkdown(text: string): string {
let result = text;
// 1. Preserve links - temporarily replace them to avoid modifying URLs
const linkPlaceholders: string[] = [];
result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match) => {
const idx = linkPlaceholders.length;
linkPlaceholders.push(match);
return `\x00LINK_${idx}\x00`;
});
// 2. Convert __text__ (some markdown bold) to **text**
result = result.replace(/__([^_]+)__/g, "**$1**");
// 3. Convert single _text_ to *text* for italic
// Match _text_ but not inside words (e.g., snake_case_name)
result = result.replace(/(?<=^|[\s\p{P}])_([^_\n]+)_(?=$|[\s\p{P}])/gu, "*$1*");
// 4. Remove strikethrough ~~text~~ (not supported)
result = result.replace(/~~([^~]+)~~/g, "$1");
// 5. Convert code blocks to plain text (not well supported)
// Remove triple backtick code blocks
result = result.replace(/```[\s\S]*?```/g, (match) => {
// Extract content without the backticks and language identifier
const content = match.replace(/```\w*\n?/, "").replace(/\n?```$/, "");
return content;
});
// Convert inline code `text` to plain text
result = result.replace(/`([^`]+)`/g, "$1");
// 6. Convert headings to bold (# Heading -> **Heading**)
result = result.replace(/^#{1,6}\s+(.+)$/gm, "**$1**");
// 7. Convert horizontal rules (---, ***, ___) to simple separator
result = result.replace(/^[-*_]{3,}$/gm, "---");
// 8. Normalize bullet lists (-, +, *) to * for consistency
result = result.replace(/^(\s*)[-+]\s+/gm, "$1* ");
// 9. Restore links
result = result.replace(/\x00LINK_(\d+)\x00/g, (_match, idx) => {
return linkPlaceholders[Number(idx)];
});
// 10. Keep numbered lists as-is (1. item)
// 11. Keep blockquotes as-is (> quote)
return result;
}
/**
* Check if text contains markdown that needs conversion
*/
export function needsMarkdownConversion(text: string): boolean {
// Check for patterns that need conversion
return (
// Single underscore italic
/(?<![\\*_])_[^_\n]+_(?![_])/.test(text) ||
// Strikethrough
/~~[^~]+~~/.test(text) ||
// Code blocks
/```[\s\S]*?```/.test(text) ||
// Inline code
/`[^`]+`/.test(text) ||
// Headings
/^#{1,6}\s+.+$/m.test(text)
);
}