feat: implement markdown to Slack mrkdwn converter

- Add convertMarkdownToSlack utility function that converts standard markdown to Slack-compatible mrkdwn format
- Handle headers (converted to section blocks), bold/italic text, code blocks, and ordered/unordered lists
- Integrate converter into messaging service for all outgoing messages (sendMessage, replyToMessage, updateMessage)
- Add comprehensive tests covering all conversion scenarios including nested formatting
- Use placeholder approach to avoid conflicts between bold and italic regex patterns
- Leave unsupported markdown unchanged as required

Fixes BUS-1413

Co-Authored-By: Dallin Bentley <dallinbentley98@gmail.com>
This commit is contained in:
Devin AI 2025-07-17 15:59:30 +00:00
parent bc1fbb0d7e
commit fa81ede5f0
3 changed files with 256 additions and 3 deletions

View File

@ -2,6 +2,7 @@ import { WebClient } from '@slack/web-api';
import { z } from 'zod';
import { type SendMessageResult, type SlackMessage, SlackMessageSchema } from '../types';
import { SlackIntegrationError } from '../types/errors';
import { convertMarkdownToSlack } from '../utils/markdownToSlack';
import { validateWithSchema } from '../utils/validation-helpers';
export class SlackMessagingService {
@ -33,10 +34,15 @@ export class SlackMessagingService {
throw new SlackIntegrationError('CHANNEL_NOT_FOUND', 'Channel ID is required');
}
const convertedMessage =
typeof message.text === 'string'
? { ...message, ...convertMarkdownToSlack(message.text) }
: message;
// Validate message
const validatedMessage = validateWithSchema(
SlackMessageSchema,
message,
convertedMessage,
'Invalid message format'
);
@ -151,10 +157,15 @@ export class SlackMessagingService {
);
}
const convertedReplyMessage =
typeof replyMessage.text === 'string'
? { ...replyMessage, ...convertMarkdownToSlack(replyMessage.text) }
: replyMessage;
// Validate message
const validatedMessage = validateWithSchema(
SlackMessageSchema,
replyMessage,
convertedReplyMessage,
'Invalid reply message format'
);
@ -320,10 +331,15 @@ export class SlackMessagingService {
);
}
const convertedUpdatedMessage =
typeof updatedMessage.text === 'string'
? { ...updatedMessage, ...convertMarkdownToSlack(updatedMessage.text) }
: updatedMessage;
// Validate message
const validatedMessage = validateWithSchema(
SlackMessageSchema,
updatedMessage,
convertedUpdatedMessage,
'Invalid message format'
);

View File

@ -0,0 +1,137 @@
import { describe, expect, it } from 'vitest';
import { convertMarkdownToSlack } from './markdownToSlack';
describe('convertMarkdownToSlack', () => {
it('should handle empty or invalid input', () => {
expect(convertMarkdownToSlack('')).toEqual({ text: '' });
expect(convertMarkdownToSlack(null as any)).toEqual({ text: '' });
expect(convertMarkdownToSlack(undefined as any)).toEqual({ text: '' });
});
it('should convert headers to section blocks', () => {
const markdown = '# Main Title\n## Subtitle\nSome content';
const result = convertMarkdownToSlack(markdown);
expect(result.blocks).toHaveLength(3);
expect(result.blocks?.[0]).toEqual({
type: 'section',
text: {
type: 'mrkdwn',
text: '*Main Title*',
},
});
expect(result.blocks?.[1]).toEqual({
type: 'section',
text: {
type: 'mrkdwn',
text: '*Subtitle*',
},
});
expect(result.blocks?.[2]).toEqual({
type: 'section',
text: {
type: 'mrkdwn',
text: 'Some content',
},
});
});
it('should convert bold text', () => {
const markdown = 'This is **bold** and this is __also bold__';
const result = convertMarkdownToSlack(markdown);
expect(result.text).toBe('This is *bold* and this is *also bold*');
});
it('should convert italic text', () => {
const markdown = 'This is *italic* and this is _also italic_';
const result = convertMarkdownToSlack(markdown);
expect(result.text).toBe('This is _italic_ and this is _also italic_');
});
it('should handle inline code', () => {
const markdown = 'Use `console.log()` to debug';
const result = convertMarkdownToSlack(markdown);
expect(result.text).toBe('Use `console.log()` to debug');
});
it('should handle code blocks', () => {
const markdown = '```javascript\nconsole.log("hello");\n```';
const result = convertMarkdownToSlack(markdown);
expect(result.text).toBe('```javascript\nconsole.log("hello");\n```');
});
it('should handle code blocks without language', () => {
const markdown = '```\nsome code\n```';
const result = convertMarkdownToSlack(markdown);
expect(result.text).toBe('```\nsome code\n```');
});
it('should convert unordered lists', () => {
const markdown = '- Item 1\n* Item 2\n+ Item 3';
const result = convertMarkdownToSlack(markdown);
expect(result.text).toBe('• Item 1\n• Item 2\n• Item 3');
});
it('should convert ordered lists', () => {
const markdown = '1. First item\n2. Second item\n3. Third item';
const result = convertMarkdownToSlack(markdown);
expect(result.text).toBe('1. First item\n2. Second item\n3. Third item');
});
it('should handle mixed formatting', () => {
const markdown = '# Title\nThis is **bold** and *italic* with `code`\n- List item';
const result = convertMarkdownToSlack(markdown);
expect(result.blocks).toHaveLength(2);
expect(result.blocks?.[0]).toEqual({
type: 'section',
text: {
type: 'mrkdwn',
text: '*Title*',
},
});
expect(result.blocks?.[1]).toEqual({
type: 'section',
text: {
type: 'mrkdwn',
text: 'This is *bold* and _italic_ with `code`\n• List item',
},
});
});
it('should handle text without complex formatting', () => {
const markdown = 'Simple text with **bold** and *italic*';
const result = convertMarkdownToSlack(markdown);
expect(result.text).toBe('Simple text with *bold* and _italic_');
expect(result.blocks).toBeUndefined();
});
it('should clean up extra whitespace', () => {
const markdown = 'Text with\n\n\n\nmultiple newlines';
const result = convertMarkdownToSlack(markdown);
expect(result.text).toBe('Text with\n\nmultiple newlines');
});
it('should handle nested formatting correctly', () => {
const markdown = '**This is bold with *italic* inside**';
const result = convertMarkdownToSlack(markdown);
expect(result.text).toBe('*This is bold with _italic_ inside*');
});
it('should preserve unsupported markdown unchanged', () => {
const markdown = 'Text with [link](http://example.com) and ![image](image.png)';
const result = convertMarkdownToSlack(markdown);
expect(result.text).toBe('Text with [link](http://example.com) and ![image](image.png)');
});
});

View File

@ -0,0 +1,100 @@
import type { SlackBlock } from '../types/blocks';
export interface MarkdownConversionResult {
text: string;
blocks?: SlackBlock[];
}
export function convertMarkdownToSlack(markdown: string): MarkdownConversionResult {
if (!markdown || typeof markdown !== 'string') {
return { text: markdown || '' };
}
let text = markdown;
const blocks: SlackBlock[] = [];
let hasComplexFormatting = false;
const headerRegex = /^(#{1,6})\s+(.+)$/gm;
const headerMatches = [...text.matchAll(headerRegex)];
if (headerMatches.length > 0) {
hasComplexFormatting = true;
for (const match of headerMatches) {
const level = match[1]?.length || 1;
const headerText = match[2] || '';
const slackHeader = level <= 2 ? `*${headerText}*` : `*${headerText}*`;
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: slackHeader,
},
});
}
text = text.replace(headerRegex, '');
}
const codeBlockRegex = /```(\w+)?\n?([\s\S]*?)```/g;
text = text.replace(codeBlockRegex, (match, language, code) => {
return `\`\`\`${language || ''}\n${code.trim()}\n\`\`\``;
});
const inlineCodeRegex = /`([^`]+)`/g;
text = text.replace(inlineCodeRegex, '`$1`');
const boldPlaceholder = '___BOLD_PLACEHOLDER___';
const boldMatches: Array<{ placeholder: string; replacement: string }> = [];
let boldCounter = 0;
const boldRegex = /(\*\*|__)(.*?)\1/g;
text = text.replace(boldRegex, (match, delimiter, content) => {
const placeholder = `${boldPlaceholder}${boldCounter}${boldPlaceholder}`;
const processedContent = content.replace(/\*([^*]+)\*/g, '_$1_').replace(/_([^_]+)_/g, '_$1_');
boldMatches.push({ placeholder, replacement: `*${processedContent}*` });
boldCounter++;
return placeholder;
});
const italicRegex = /(?<!\*)\*([^*]+)\*(?!\*)|(?<!_)_([^_]+)_(?!_)/g;
text = text.replace(italicRegex, (_match, group1, group2) => {
const content = group1 || group2;
return `_${content}_`;
});
for (const { placeholder, replacement } of boldMatches) {
text = text.replace(placeholder, replacement);
}
const unorderedListRegex = /^[\s]*[-*+]\s+(.+)$/gm;
text = text.replace(unorderedListRegex, '• $1');
const orderedListRegex = /^[\s]*\d+\.\s+(.+)$/gm;
let listCounter = 1;
text = text.replace(orderedListRegex, (_match, content) => {
return `${listCounter++}. ${content}`;
});
text = text.replace(/\n{3,}/g, '\n\n').trim();
if (hasComplexFormatting && blocks.length > 0) {
if (text.trim()) {
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: text.trim(),
},
});
}
return {
text: markdown, // Fallback text for notifications
blocks,
};
}
return { text };
}