diff --git a/.gitignore b/.gitignore index 02ef18d1f..0381c84df 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ dist/ # Windows Application Icon/Resource *.syso + +# Test channels +cmd/telegram/ diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index a11cf53b8..b008d57ca 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -25,18 +25,7 @@ import ( "github.com/sipeed/picoclaw/pkg/utils" ) -var ( - reHeading = regexp.MustCompile(`^#{1,6}\s+(.+)$`) - reBlockquote = regexp.MustCompile(`^>\s*(.*)$`) - reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) - reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`) - reBoldUnder = regexp.MustCompile(`__(.+?)__`) - reItalic = regexp.MustCompile(`_([^_]+)_`) - reStrike = regexp.MustCompile(`~~(.+?)~~`) - reListItem = regexp.MustCompile(`^[-*]\s+`) - reCodeBlock = regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```") - reInlineCode = regexp.MustCompile("`([^`]+)`") -) +var reHeading = regexp.MustCompile(`(?m)^#{1,6}\s+([^\n]+)`) type TelegramChannel struct { *channels.BaseChannel @@ -173,18 +162,16 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } - htmlContent := markdownToTelegramHTML(msg.Content) + markdownV2Content := markdownToTelegramMarkdownV2(msg.Content) - // Typing/placeholder handled by Manager.preSend — just send the message - tgMsg := tu.Message(tu.ID(chatID), htmlContent) - tgMsg.ParseMode = telego.ModeHTML + tgMsg := tu.Message(tu.ID(chatID), markdownV2Content). + WithParseMode(telego.ModeMarkdownV2) if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { - logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{ + logger.ErrorCF("telegram", "MarkdownV2 parse failed, falling back to plain text", map[string]any{ "error": err.Error(), }) - tgMsg.ParseMode = "" - if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { + if _, err = c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), msg.Content)); err != nil { return fmt.Errorf("telegram send: %w", channels.ErrTemporary) } } @@ -232,9 +219,9 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag if err != nil { return err } - htmlContent := markdownToTelegramHTML(content) - editMsg := tu.EditMessageText(tu.ID(cid), mid, htmlContent) - editMsg.ParseMode = telego.ModeHTML + md2Content := markdownToTelegramMarkdownV2(content) + editMsg := tu.EditMessageText(tu.ID(cid), mid, md2Content). + WithParseMode(telego.ModeMarkdownV2) _, err = c.bot.EditMessageText(ctx, editMsg) return err } @@ -554,109 +541,154 @@ func parseChatID(chatIDStr string) (int64, error) { return id, err } -func markdownToTelegramHTML(text string) string { - if text == "" { - return "" - } +// markdownToTelegramMarkdownV2 takes a standardized markdown string and +// strictly escapes or transforms it to fit Telegram's MarkdownV2 requirements. +// https://core.telegram.org/bots/api#formatting-options +func markdownToTelegramMarkdownV2(text string) string { + // replace Heading to bolding + text = reHeading.ReplaceAllString(text, "*$1*") - codeBlocks := extractCodeBlocks(text) - text = codeBlocks.text + var result strings.Builder + runes := []rune(text) + length := len(runes) - inlineCodes := extractInlineCodes(text) - text = inlineCodes.text - - text = reHeading.ReplaceAllString(text, "$1") - - text = reBlockquote.ReplaceAllString(text, "$1") - - text = escapeHTML(text) - - text = reLink.ReplaceAllString(text, `$1`) - - text = reBoldStar.ReplaceAllString(text, "$1") - - text = reBoldUnder.ReplaceAllString(text, "$1") - - text = reItalic.ReplaceAllStringFunc(text, func(s string) string { - match := reItalic.FindStringSubmatch(s) - if len(match) < 2 { - return s + // List of characters that must be escaped in standard text contexts + needsNormalEscape := func(r rune) bool { + switch r { + case '_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!': + return true } - return "" + match[1] + "" - }) - - text = reStrike.ReplaceAllString(text, "$1") - - text = reListItem.ReplaceAllString(text, "• ") - - for i, code := range inlineCodes.codes { - escaped := escapeHTML(code) - text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("%s", escaped)) - } - - for i, code := range codeBlocks.codes { - escaped := escapeHTML(code) - text = strings.ReplaceAll( - text, - fmt.Sprintf("\x00CB%d\x00", i), - fmt.Sprintf("
%s
", escaped), - ) - } - - return text -} - -type codeBlockMatch struct { - text string - codes []string -} - -func extractCodeBlocks(text string) codeBlockMatch { - matches := reCodeBlock.FindAllStringSubmatch(text, -1) - - codes := make([]string, 0, len(matches)) - for _, match := range matches { - codes = append(codes, match[1]) + return false } i := 0 - text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string { - placeholder := fmt.Sprintf("\x00CB%d\x00", i) + for i < length { + // 1. Check for Pre-formatted Code Block (```...```) + if i+2 < length && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' { + result.WriteString("```") + i += 3 + // Find closing ``` + for i < length { + if i+2 < length && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' { + result.WriteString("```") + i += 3 + break + } + // Inside code blocks, escape `\` and `\` + if runes[i] == '\\' || runes[i] == '`' { + result.WriteRune('\\') + } + result.WriteRune(runes[i]) + i++ + } + continue + } + + // 2. Check for Inline Code (`...`) + if runes[i] == '`' { + result.WriteRune('`') + i++ + for i < length { + if runes[i] == '`' { + result.WriteRune('`') + i++ + break + } + if runes[i] == '\\' || runes[i] == '`' { + result.WriteRune('\\') + } + result.WriteRune(runes[i]) + i++ + } + continue + } + + // 3. Link or Custom Emoji definition: URL part (...) + // We detect this by checking if the previous non-space character closed a bracket ']', + // and we are currently on '('. To keep logic linear, we handle it as we traverse. + // NOTE: A true deep-parser would link `[` to `](...)`. For safety, whenever we see `(`, + // if it looks like a URL part, we escape it via URL rules. Let's do a basic lookbehind. + if runes[i] == '(' && i > 0 && runes[i-1] == ']' { + result.WriteRune('(') + i++ + for i < length { + if runes[i] == ')' { + // Unescaped closing bracket ends the URL + result.WriteRune(')') + i++ + break + } + // In URL part, escape `\` and `)` + if runes[i] == '\\' || runes[i] == ')' { + result.WriteRune('\\') + } + result.WriteRune(runes[i]) + i++ + } + continue + } + + // 4. Handle blockquotes starts + if runes[i] == '>' && (i == 0 || runes[i-1] == '\n') { + result.WriteRune('>') + i++ + continue + } + + // 5. Handle standard Markdown Entities Boundaries + // If they are part of valid markdown boundaries, we write them as-is. + // We trust the syntax rules: * _ ~ || [ ] + // (Assuming the text is a valid markdown, we don't escape these if formatting is intended) + + // Note on Ambiguity (__ vs _): + // Telegram parses `__` from left to right greedily. + if i+1 < length && runes[i] == '_' && runes[i+1] == '_' { + result.WriteString("__") + i += 2 + continue + } + + if i+1 < length && runes[i] == '|' && runes[i+1] == '|' { + result.WriteString("||") + i += 2 + continue + } + + // Standard single-char boundaries + if runes[i] == '*' || runes[i] == '_' || runes[i] == '~' || runes[i] == '[' || runes[i] == ']' { + result.WriteRune(runes[i]) + i++ + continue + } + + // Custom emoji boundary check `![` + if i+1 < length && runes[i] == '!' && runes[i+1] == '[' { + result.WriteString("![") + i += 2 + continue + } + + // 6. Handle plain text characters + // Escape remaining special characters if they aren't forming intended valid markup + if needsNormalEscape(runes[i]) { + // Check if it's already escaped; if an escape character exists, consume it legitimately + if runes[i] == '\\' && i+1 < length && needsNormalEscape(runes[i+1]) { + // Keep the backslash and the escaped char as is, avoiding double escaping + result.WriteRune('\\') + result.WriteRune(runes[i+1]) + i += 2 + continue + } + + // Auto-escape the character + result.WriteRune('\\') + } + + result.WriteRune(runes[i]) i++ - return placeholder - }) - - return codeBlockMatch{text: text, codes: codes} -} - -type inlineCodeMatch struct { - text string - codes []string -} - -func extractInlineCodes(text string) inlineCodeMatch { - matches := reInlineCode.FindAllStringSubmatch(text, -1) - - codes := make([]string, 0, len(matches)) - for _, match := range matches { - codes = append(codes, match[1]) } - i := 0 - text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string { - placeholder := fmt.Sprintf("\x00IC%d\x00", i) - i++ - return placeholder - }) - - return inlineCodeMatch{text: text, codes: codes} -} - -func escapeHTML(text string) string { - text = strings.ReplaceAll(text, "&", "&") - text = strings.ReplaceAll(text, "<", "<") - text = strings.ReplaceAll(text, ">", ">") - return text + return result.String() } // isBotMentioned checks if the bot is mentioned in the message via entities. diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go new file mode 100644 index 000000000..0de803fd8 --- /dev/null +++ b/pkg/channels/telegram/telegram_test.go @@ -0,0 +1,36 @@ +package telegram + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_markdownToTelegramMarkdownV2(t *testing.T) { + cases := []struct { + input string + expected string + }{ + { + input: `## HeadingH2 #`, + expected: "*HeadingH2 \\#*", + }, + { + input: "~strikethroughMD~", + expected: "~strikethroughMD~", + }, + { + input: "[inline URL](http://www.example.com/)", + expected: "[inline URL](http://www.example.com/)", + }, + } + + for _, tc := range cases { + t.Run(fmt.Sprintf("formating %s -> %s", tc.input, tc.expected), func(t *testing.T) { + actual := markdownToTelegramMarkdownV2(tc.input) + + require.EqualValues(t, tc.expected, actual) + }) + } +}