diff --git a/extensions/ringcentral/src/api.ts b/extensions/ringcentral/src/api.ts index ff711f6a7..1ff88776b 100644 --- a/extensions/ringcentral/src/api.ts +++ b/extensions/ringcentral/src/api.ts @@ -6,6 +6,7 @@ import type { RingCentralPost, RingCentralUser, RingCentralAttachment, + RingCentralAdaptiveCard, } from "./types.js"; // Team Messaging API endpoints @@ -164,6 +165,60 @@ export async function downloadRingCentralAttachment(params: { }; } +// Adaptive Cards API +export async function sendRingCentralAdaptiveCard(params: { + account: ResolvedRingCentralAccount; + chatId: string; + card: RingCentralAdaptiveCard; + fallbackText?: string; +}): Promise<{ cardId?: string } | null> { + const { account, chatId, card, fallbackText } = params; + const platform = await getRingCentralPlatform(account); + + const body = { + ...card, + type: "AdaptiveCard", + $schema: card.$schema ?? "http://adaptivecards.io/schemas/adaptive-card.json", + version: card.version ?? "1.3", + ...(fallbackText ? { fallbackText } : {}), + }; + + const response = await platform.post(`${TM_API_BASE}/chats/${chatId}/adaptive-cards`, body); + const result = (await response.json()) as { id?: string }; + return result ? { cardId: result.id } : null; +} + +export async function updateRingCentralAdaptiveCard(params: { + account: ResolvedRingCentralAccount; + cardId: string; + card: RingCentralAdaptiveCard; + fallbackText?: string; +}): Promise<{ cardId?: string }> { + const { account, cardId, card, fallbackText } = params; + const platform = await getRingCentralPlatform(account); + + const body = { + ...card, + type: "AdaptiveCard", + $schema: card.$schema ?? "http://adaptivecards.io/schemas/adaptive-card.json", + version: card.version ?? "1.3", + ...(fallbackText ? { fallbackText } : {}), + }; + + const response = await platform.put(`${TM_API_BASE}/adaptive-cards/${cardId}`, body); + const result = (await response.json()) as { id?: string }; + return { cardId: result.id }; +} + +export async function deleteRingCentralAdaptiveCard(params: { + account: ResolvedRingCentralAccount; + cardId: string; +}): Promise { + const { account, cardId } = params; + const platform = await getRingCentralPlatform(account); + await platform.delete(`${TM_API_BASE}/adaptive-cards/${cardId}`); +} + export async function probeRingCentral( account: ResolvedRingCentralAccount, ): Promise<{ ok: boolean; error?: string; elapsedMs: number }> { diff --git a/extensions/ringcentral/src/config-schema.ts b/extensions/ringcentral/src/config-schema.ts index 3281611a5..373e1b796 100644 --- a/extensions/ringcentral/src/config-schema.ts +++ b/extensions/ringcentral/src/config-schema.ts @@ -42,6 +42,7 @@ const RingCentralAccountSchemaBase = z allowBots: z.boolean().optional(), botExtensionId: z.string().optional(), selfOnly: z.boolean().optional(), + useAdaptiveCards: z.boolean().optional(), // Use Adaptive Cards for code blocks (default: false) }) .strict(); diff --git a/extensions/ringcentral/src/markdown.test.ts b/extensions/ringcentral/src/markdown.test.ts index 93faa8bc3..ba0acf059 100644 --- a/extensions/ringcentral/src/markdown.test.ts +++ b/extensions/ringcentral/src/markdown.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { toRingCentralMarkdown, needsMarkdownConversion } from "./markdown.js"; +import { toRingCentralMarkdown, needsMarkdownConversion, hasCodeBlocks, markdownToAdaptiveCard } from "./markdown.js"; describe("toRingCentralMarkdown", () => { it("converts single underscore italic to asterisk", () => { @@ -119,3 +119,50 @@ describe("needsMarkdownConversion", () => { expect(needsMarkdownConversion("*italic* and **bold**")).toBe(false); }); }); + +describe("hasCodeBlocks", () => { + it("returns true for text with code blocks", () => { + expect(hasCodeBlocks("```\ncode\n```")).toBe(true); + expect(hasCodeBlocks("```js\nconst x = 1;\n```")).toBe(true); + }); + + it("returns false for text without code blocks", () => { + expect(hasCodeBlocks("plain text")).toBe(false); + expect(hasCodeBlocks("`inline code`")).toBe(false); + }); +}); + +describe("markdownToAdaptiveCard", () => { + it("converts simple text to adaptive card", () => { + const result = markdownToAdaptiveCard("Hello world"); + expect(result.type).toBe("AdaptiveCard"); + expect(result.version).toBe("1.3"); + expect(result.body).toHaveLength(1); + expect(result.body[0].type).toBe("TextBlock"); + expect(result.body[0].text).toBe("Hello world"); + }); + + it("converts code blocks with monospace font", () => { + const result = markdownToAdaptiveCard("```js\nconst x = 1;\n```"); + expect(result.body).toHaveLength(1); + expect(result.body[0].fontType).toBe("Monospace"); + expect(result.body[0].text).toBe("const x = 1;"); + }); + + it("handles mixed text and code blocks", () => { + const result = markdownToAdaptiveCard("Some text\n\n```\ncode\n```\n\nMore text"); + expect(result.body).toHaveLength(3); + expect(result.body[0].text).toBe("Some text"); + expect(result.body[1].fontType).toBe("Monospace"); + expect(result.body[1].text).toBe("code"); + expect(result.body[2].text).toBe("More text"); + }); + + it("converts headings with bold styling", () => { + const result = markdownToAdaptiveCard("# Title"); + expect(result.body).toHaveLength(1); + expect(result.body[0].weight).toBe("Bolder"); + expect(result.body[0].size).toBe("Medium"); + expect(result.body[0].text).toBe("Title"); + }); +}); diff --git a/extensions/ringcentral/src/markdown.ts b/extensions/ringcentral/src/markdown.ts index 9df29d5d4..e2711782a 100644 --- a/extensions/ringcentral/src/markdown.ts +++ b/extensions/ringcentral/src/markdown.ts @@ -85,3 +85,74 @@ export function needsMarkdownConversion(text: string): boolean { /^#{1,6}\s+.+$/m.test(text) ); } + +/** + * Check if text contains code blocks that would benefit from Adaptive Card formatting + */ +export function hasCodeBlocks(text: string): boolean { + return /```[\s\S]*?```/.test(text); +} + +/** + * Convert markdown text to an Adaptive Card structure. + * This preserves code blocks with proper formatting. + */ +export function markdownToAdaptiveCard(text: string): { + type: "AdaptiveCard"; + $schema: string; + version: string; + body: Array<{ type: string; text?: string; wrap?: boolean; fontType?: string; size?: string; weight?: string }>; +} { + const body: Array<{ type: string; text?: string; wrap?: boolean; fontType?: string; size?: string; weight?: string }> = []; + + // Split by code blocks + const parts = text.split(/(```[\s\S]*?```)/g); + + for (const part of parts) { + if (!part.trim()) continue; + + if (part.startsWith("```")) { + // Extract language and code + const match = part.match(/```(\w*)\n?([\s\S]*?)\n?```/); + if (match) { + const [, , code] = match; + // Add code block with monospace font + body.push({ + type: "TextBlock", + text: code.trim(), + wrap: true, + fontType: "Monospace", + }); + } + } else { + // Regular text - convert markdown + const converted = toRingCentralMarkdown(part.trim()); + if (converted) { + // Check if it's a heading (starts with **) + const headingMatch = converted.match(/^\*\*(.+)\*\*$/); + if (headingMatch && !converted.includes("\n")) { + body.push({ + type: "TextBlock", + text: headingMatch[1], + wrap: true, + size: "Medium", + weight: "Bolder", + }); + } else { + body.push({ + type: "TextBlock", + text: converted, + wrap: true, + }); + } + } + } + } + + return { + type: "AdaptiveCard", + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.3", + body, + }; +} diff --git a/extensions/ringcentral/src/types.ts b/extensions/ringcentral/src/types.ts index 961c88cab..0d15559f8 100644 --- a/extensions/ringcentral/src/types.ts +++ b/extensions/ringcentral/src/types.ts @@ -53,6 +53,56 @@ export type RingCentralMention = { name?: string; }; +// Adaptive Cards types (Microsoft Adaptive Card schema v1.3) +export type RingCentralAdaptiveCardElement = { + type: string; + text?: string; + size?: "Small" | "Default" | "Medium" | "Large" | "ExtraLarge"; + weight?: "Lighter" | "Default" | "Bolder"; + color?: "Default" | "Dark" | "Light" | "Accent" | "Good" | "Warning" | "Attention"; + wrap?: boolean; + url?: string; + altText?: string; + columns?: RingCentralAdaptiveCardElement[]; + items?: RingCentralAdaptiveCardElement[]; + width?: string | number; + style?: string; + isVisible?: boolean; + id?: string; + // Input elements + label?: string; + placeholder?: string; + value?: string; + isRequired?: boolean; + // Action elements + title?: string; + data?: Record; + [key: string]: unknown; +}; + +export type RingCentralAdaptiveCardAction = { + type: "Action.Submit" | "Action.OpenUrl" | "Action.ShowCard" | "Action.ToggleVisibility"; + title?: string; + url?: string; + data?: Record; + card?: RingCentralAdaptiveCard; + targetElements?: Array; +}; + +export type RingCentralAdaptiveCard = { + type?: "AdaptiveCard"; + $schema?: string; + version?: string; + body?: RingCentralAdaptiveCardElement[]; + actions?: RingCentralAdaptiveCardAction[]; + fallbackText?: string; + speak?: string; + lang?: string; + verticalContentAlignment?: "Top" | "Center" | "Bottom"; + backgroundImage?: string | { url: string; fillMode?: string }; + minHeight?: string; +}; + export type RingCentralWebhookEvent = { uuid?: string; event?: string; @@ -113,6 +163,7 @@ export type RingCentralAccountConfig = { botExtensionId?: string; replyToMode?: "off" | "all"; selfOnly?: boolean; // JWT mode: only accept messages from the JWT user in Personal chat (default: true) + useAdaptiveCards?: boolean; // Use Adaptive Cards for messages with code blocks (default: false) }; export type RingCentralConfig = RingCentralAccountConfig & {