Skip to content

Text Formatting

The Signal Bot supports Markdown-style text formatting in all outgoing messages. This allows bot responses to include bold, italic, monospace, strikethrough, and spoiler text that renders natively in the Signal app.

StyleMarkdown SyntaxExampleResult
Bold**text****important**important
Italic*text**emphasis*emphasis
Monospace`text``code`code
Strikethrough~text~~removed~removed
Spoiler||text||||hidden||(tap to reveal)

Signal doesn’t parse Markdown syntax like Discord or Slack. If you send **bold** to Signal, users see the raw asterisks. Signal requires text styling to be specified as byte-indexed ranges in a separate textStyles parameter.

The bot includes a Markdown-to-Signal parser that:

  1. Parses Markdown syntax in outgoing messages
  2. Calculates byte positions for each styled segment (important for UTF-8/emoji support)
  3. Strips Markdown markers from the visible text
  4. Sends the cleaned text with a textStyles array to signal-cli

Signal-cli’s JSON-RPC API expects styles as an array of strings in the format "start:length:STYLE":

{
"method": "send",
"params": {
"message": "This is bold text",
"textStyles": ["8:4:BOLD"]
}
}

This tells Signal: starting at byte 8, for 4 bytes, apply BOLD style.

File: src/src/bot/signal-bot-v2.ts - parseMarkdownToSignalStyles()

function parseMarkdownToSignalStyles(text: string): {
text: string; // Cleaned text with markers removed
textStyles: TextStyle[]; // Array of style ranges
}
interface TextStyle {
start: number; // Byte offset (not character index)
length: number; // Length in bytes
style: 'BOLD' | 'ITALIC' | 'STRIKETHROUGH' | 'SPOILER' | 'MONOSPACE';
}

Styles are processed in this order to avoid conflicts:

  1. Bold (**text**) - Most specific, processed first
  2. Italic (*text*) - Uses negative lookbehind to avoid matching inside **
  3. Monospace (`text`)
  4. Strikethrough (~text~)
  5. Spoiler (||text||)

The parser calculates byte offsets, not character indices. This is critical for:

  • Emoji (most are 4 bytes)
  • Non-ASCII characters (2-4 bytes in UTF-8)
  • Mixed content
const getByteOffset = (str: string, charIndex: number): number => {
return Buffer.byteLength(str.substring(0, charIndex), 'utf8');
};

Run the formatting test to send a test message to the Bot Development room:

Terminal window
./deploy.sh test-formatting

This will:

  1. Show the original message with Markdown markers
  2. Show the cleaned message
  3. List all detected styles with byte positions
  4. Send the message to verify rendering

File: src/src/utils/formatting-test.ts

Contains a standalone test that can be run to verify the parser is working correctly.

All messages sent via bot.sendMessage() are automatically parsed:

// In command-handler.ts or any bot code
await this.bot.sendMessage({
groupId: context.groupId,
message: '**Success!** Your question has been recorded.\n\nUse `!answer` to respond.'
});

The parser handles the conversion automatically - developers write Markdown, users see formatted text.

  1. Nested styles partially supported - **Bold with code inside** works, but deeply nested combinations may have edge cases
  2. No block quotes - Signal doesn’t support block quote styling
  3. No headers - Signal doesn’t support header sizes
  4. No links - Signal auto-links URLs but doesn’t support [text](url) syntax

Cause: The textStyles parameter wasn’t being sent correctly.

History: Fixed on 2025-12-21 - the parameter name was textStyle (singular) but signal-cli expects textStyles (plural). See LESSONS_LEARNED.md.

Cause: Character index used instead of byte offset.

Fix: Ensure Buffer.byteLength() is used for position calculation, not string.length.

Cause: Emoji byte lengths vary (most are 4 bytes).

Fix: The parser handles this correctly by using byte offsets. If you see issues, check that the parser is processing the text before any emoji manipulation.