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:
parent
dba1763c81
commit
0b1aebe80f
@ -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 };
|
||||||
|
|||||||
121
extensions/ringcentral/src/markdown.test.ts
Normal file
121
extensions/ringcentral/src/markdown.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
87
extensions/ringcentral/src/markdown.ts
Normal file
87
extensions/ringcentral/src/markdown.ts
Normal 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user