mirror of https://github.com/buster-so/buster.git
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:
parent
bc1fbb0d7e
commit
fa81ede5f0
|
@ -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'
|
||||
);
|
||||
|
||||
|
|
|
@ -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 ';
|
||||
const result = convertMarkdownToSlack(markdown);
|
||||
|
||||
expect(result.text).toBe('Text with [link](http://example.com) and ');
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
}
|
Loading…
Reference in New Issue