Convert standard markdown to Slack mrkdwn for Slack messages (#234)

* Slack: convert Markdown to mrkdwn

* Slack: avoid literal dynamic import for optional dep

* Slack formatter: cache optional dependency load state

* fix: remove slackify-markdown from lockfile dependencies

The lockfile had slackify-markdown in both `dependencies` (pinned) and
`optionalDependencies`, but package.json only lists it in
optionalDependencies. This caused npm ci to treat it as required,
defeating the optional dependency pattern.

Regenerated lockfile with clean npm install to fix.

Written by Cameron ◯ Letta Code

"The lockfile giveth, and the lockfile taketh away." - npm, probably

---------

Co-authored-by: Cameron <cameron@pfiffer.org>
This commit is contained in:
Tom Fehring
2026-02-09 16:59:46 -08:00
committed by GitHub
parent f2ec8f60c2
commit d12633b792
8 changed files with 1323 additions and 14 deletions

1158
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -73,6 +73,7 @@
"googleapis": "^170.1.0",
"grammy": "^1.39.3",
"gray-matter": "^4.0.3",
"keyv": "^5.6.0",
"node-schedule": "^2.1.1",
"open": "^11.0.0",
"openai": "^6.17.0",
@@ -81,13 +82,13 @@
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"update-notifier": "^7.3.1",
"yaml": "^2.8.2",
"keyv": "^5.6.0"
"yaml": "^2.8.2"
},
"optionalDependencies": {
"@slack/bolt": "^4.6.0",
"@whiskeysockets/baileys": "6.7.21",
"discord.js": "^14.25.1"
"discord.js": "^14.25.1",
"slackify-markdown": "^5.0.0"
},
"devDependencies": {
"@types/update-notifier": "^6.0.8",

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { fallbackMarkdownToSlackMrkdwn, markdownToSlackMrkdwn } from './slack-format.js';
describe('markdownToSlackMrkdwn', () => {
it('converts bold', async () => {
const result = await markdownToSlackMrkdwn('**hello**');
expect(result).toContain('*hello*');
});
it('converts italics', async () => {
const result = await markdownToSlackMrkdwn('*hello*');
expect(result).toContain('_hello_');
});
it('converts strikethrough', async () => {
const result = await markdownToSlackMrkdwn('~~bye~~');
expect(result).toContain('~bye~');
});
it('converts links', async () => {
const result = await markdownToSlackMrkdwn('Check out [Google](https://google.com)');
expect(result).toContain('<https://google.com|Google>');
});
it('strips code fence language identifiers', async () => {
const result = await markdownToSlackMrkdwn('```js\nconsole.log(1)\n```');
expect(result).toContain('```');
expect(result).not.toContain('```js');
});
it('returns something for any input (never throws)', async () => {
const weirdInputs = ['', '\\', '[](){}', '****', '```'];
for (const input of weirdInputs) {
const result = await markdownToSlackMrkdwn(input);
expect(typeof result).toBe('string');
}
});
});
describe('fallbackMarkdownToSlackMrkdwn', () => {
it('converts the common Slack mrkdwn differences', () => {
const result = fallbackMarkdownToSlackMrkdwn(
'**bold** *italic* ~~strike~~ [link](https://example.com)\n```js\ncode\n```'
);
expect(result).toContain('*bold*');
expect(result).toContain('_italic_');
expect(result).toContain('~strike~');
expect(result).toContain('<https://example.com|link>');
expect(result).toContain('```\ncode\n```');
expect(result).not.toContain('```js');
});
});

View File

@@ -0,0 +1,89 @@
/**
* Slack Text Formatting
*
* Converts standard Markdown into Slack "mrkdwn" using slackify-markdown.
* slackify-markdown is an optional dependency, so we use a dynamic import and
* provide a conservative fallback if it is missing or fails at runtime.
*/
type SlackifyFn = (markdown: string) => string;
let slackifyFn: SlackifyFn | null = null;
let slackifyLoadFailed = false;
let slackifyLoadPromise: Promise<SlackifyFn | null> | null = null;
async function loadSlackify(): Promise<SlackifyFn | null> {
if (slackifyFn) return slackifyFn;
if (slackifyLoadFailed) return null;
if (slackifyLoadPromise) return slackifyLoadPromise;
slackifyLoadPromise = (async () => {
try {
// Avoid a string-literal specifier so TypeScript doesn't require the module
// to exist at build time when optional deps are omitted.
const moduleId: string = 'slackify-markdown';
const mod = await import(moduleId);
const loaded =
(mod as unknown as { slackifyMarkdown?: SlackifyFn }).slackifyMarkdown
|| (mod as unknown as { default?: SlackifyFn }).default;
if (typeof loaded !== 'function') {
throw new Error('slackify-markdown: missing slackifyMarkdown export');
}
slackifyFn = loaded;
return loaded;
} catch (e) {
slackifyLoadFailed = true;
const reason = e instanceof Error ? e.message : String(e);
console.warn(`[Slack] slackify-markdown unavailable; using fallback formatter (${reason})`);
return null;
}
})();
return slackifyLoadPromise;
}
/**
* Convert Markdown to Slack mrkdwn.
*/
export async function markdownToSlackMrkdwn(markdown: string): Promise<string> {
const converter = await loadSlackify();
if (!converter) {
return fallbackMarkdownToSlackMrkdwn(markdown);
}
try {
return converter(markdown);
} catch (e) {
console.error('[Slack] Markdown conversion failed, using fallback:', e);
return fallbackMarkdownToSlackMrkdwn(markdown);
}
}
/**
* Heuristic conversion fallback that covers the most common Slack mrkdwn
* differences. This is intentionally limited; if you need broader support,
* install slackify-markdown.
*/
export function fallbackMarkdownToSlackMrkdwn(markdown: string): string {
let text = markdown;
// Slack ignores fenced code block language identifiers (```js -> ```).
text = text.replace(/```[a-zA-Z0-9_-]+\n/g, '```\n');
// Links: [label](url) -> <url|label>
text = text.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<$2|$1>');
// Italic: *italic* -> _italic_ (avoid **bold**)
text = text.replace(/(?<!\*)\*([^*\n]+?)\*(?!\*)/g, '_$1_');
// Bold: **bold** / __bold__ -> *bold*
text = text.replace(/\*\*([^*]+?)\*\*/g, '*$1*');
text = text.replace(/__([^_]+?)__/g, '*$1*');
// Strikethrough: ~~strike~~ -> ~strike~
text = text.replace(/~~([^~]+?)~~/g, '~$1~');
return text;
}

View File

@@ -10,6 +10,7 @@ import { createReadStream } from 'node:fs';
import { basename } from 'node:path';
import { buildAttachmentPath, downloadToFile } from './attachments.js';
import { parseCommand, HELP_TEXT } from '../core/commands.js';
import { markdownToSlackMrkdwn } from './slack-format.js';
// Dynamic import to avoid requiring Slack deps if not used
let App: typeof import('@slack/bolt').App;
@@ -112,10 +113,10 @@ export class SlackAdapter implements ChannelAdapter {
const command = parseCommand(text);
if (command) {
if (command === 'help' || command === 'start') {
await say(HELP_TEXT);
await say(await markdownToSlackMrkdwn(HELP_TEXT));
} else if (this.onCommand) {
const result = await this.onCommand(command);
if (result) await say(result);
if (result) await say(await markdownToSlackMrkdwn(result));
}
return; // Don't pass commands to agent
}
@@ -224,9 +225,10 @@ export class SlackAdapter implements ChannelAdapter {
async sendMessage(msg: OutboundMessage): Promise<{ messageId: string }> {
if (!this.app) throw new Error('Slack not started');
const formatted = await markdownToSlackMrkdwn(msg.text);
const result = await this.app.client.chat.postMessage({
channel: msg.chatId,
text: msg.text,
text: formatted,
thread_ts: msg.threadId,
});
@@ -236,11 +238,12 @@ export class SlackAdapter implements ChannelAdapter {
async sendFile(file: OutboundFile): Promise<{ messageId: string }> {
if (!this.app) throw new Error('Slack not started');
const initialComment = file.caption ? await markdownToSlackMrkdwn(file.caption) : undefined;
const basePayload = {
channels: file.chatId,
file: createReadStream(file.filePath),
filename: basename(file.filePath),
initial_comment: file.caption,
initial_comment: initialComment,
};
const result = file.threadId
? await this.app.client.files.upload({ ...basePayload, thread_ts: file.threadId })
@@ -257,10 +260,11 @@ export class SlackAdapter implements ChannelAdapter {
async editMessage(chatId: string, messageId: string, text: string): Promise<void> {
if (!this.app) throw new Error('Slack not started');
const formatted = await markdownToSlackMrkdwn(text);
await this.app.client.chat.update({
channel: chatId,
ts: messageId,
text,
text: formatted,
});
}

View File

@@ -53,6 +53,11 @@ async function sendSlack(chatId: string, text: string): Promise<void> {
throw new Error('SLACK_BOT_TOKEN not set');
}
// Slack uses mrkdwn, which differs slightly from standard Markdown.
// Convert for correct formatting (bold, italics, links, code fences, etc.).
const { markdownToSlackMrkdwn } = await import('../channels/slack-format.js');
const formatted = await markdownToSlackMrkdwn(text);
const response = await fetch('https://slack.com/api/chat.postMessage', {
method: 'POST',
headers: {
@@ -61,7 +66,7 @@ async function sendSlack(chatId: string, text: string): Promise<void> {
},
body: JSON.stringify({
channel: chatId,
text: text,
text: formatted,
}),
});

View File

@@ -202,7 +202,7 @@ describe('formatMessageEnvelope', () => {
it('includes Slack format hint', () => {
const msg = createMessage({ channel: 'slack' });
const result = formatMessageEnvelope(msg);
expect(result).toContain('**Format support**: mrkdwn:');
expect(result).toContain('**Format support**: Markdown (auto-converted to Slack mrkdwn):');
});
it('includes Telegram format hint', () => {

View File

@@ -18,7 +18,7 @@ export const SYSTEM_REMINDER_CLOSE = `</${SYSTEM_REMINDER_TAG}>`;
* Each channel has different markdown support - hints help agent format appropriately.
*/
const CHANNEL_FORMATS: Record<string, string> = {
slack: 'mrkdwn: *bold* _italic_ `code` - NO: headers, tables',
slack: 'Markdown (auto-converted to Slack mrkdwn): **bold** _italic_ `code` [links](url) ```code blocks``` - NO: headers, tables',
discord: '**bold** *italic* `code` [links](url) ```code blocks``` - NO: headers, tables',
telegram: 'MarkdownV2: *bold* _italic_ `code` [links](url) - NO: headers, tables',
whatsapp: '*bold* _italic_ `code` - NO: headers, code fences, links, tables',