diff --git a/extensions/ringcentral/src/api.ts b/extensions/ringcentral/src/api.ts index 851642c64..ff711f6a7 100644 --- a/extensions/ringcentral/src/api.ts +++ b/extensions/ringcentral/src/api.ts @@ -1,5 +1,6 @@ import type { ResolvedRingCentralAccount } from "./accounts.js"; import { getRingCentralPlatform } from "./auth.js"; +import { toRingCentralMarkdown } from "./markdown.js"; import type { RingCentralChat, RingCentralPost, @@ -20,7 +21,7 @@ export async function sendRingCentralMessage(params: { const platform = await getRingCentralPlatform(account); const body: Record = {}; - if (text) body.text = text; + if (text) body.text = toRingCentralMarkdown(text); if (attachments && attachments.length > 0) { body.attachments = attachments; } @@ -41,7 +42,7 @@ export async function updateRingCentralMessage(params: { const response = await platform.patch( `${TM_API_BASE}/chats/${chatId}/posts/${postId}`, - { text }, + { text: toRingCentralMarkdown(text) }, ); const result = (await response.json()) as RingCentralPost; return { postId: result.id }; diff --git a/extensions/ringcentral/src/markdown.test.ts b/extensions/ringcentral/src/markdown.test.ts new file mode 100644 index 000000000..93faa8bc3 --- /dev/null +++ b/extensions/ringcentral/src/markdown.test.ts @@ -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); + }); +}); diff --git a/extensions/ringcentral/src/markdown.ts b/extensions/ringcentral/src/markdown.ts new file mode 100644 index 000000000..9df29d5d4 --- /dev/null +++ b/extensions/ringcentral/src/markdown.ts @@ -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 + /(?