package main import ( "slices" "strings" ) var ( // telegramTagEscaper writes the characters that rich Markdown reads as HTML // as HTML entities. telegramTagEscaper = strings.NewReplacer("&", "&", "<", "<") // telegramTagUnescaper reverses telegramTagEscaper. telegramTagUnescaper = strings.NewReplacer("&", "&", "<", "<") ) // telegramMarkdownEntities converts the Markdown of a reply to plain text and // Telegram message entities. It knows bold (** and __), italic (* and _), // strikethrough (~~), inline code, fenced code blocks with an optional // language, links [text](url), and ATX headings, which become bold. Lists, // quotes, and tables stay as their text. A mark that does not close stays as // text with no entity, and code holds no entity. // // Inline marks do not cross a line break, so a mark that does not close cannot // change the rest of the reply. func telegramMarkdownEntities(markdown string) (string, []TelegramEntity) { var out TelegramMarkdownText lines := strings.Split(markdown, "\n") for index := 0; index < len(lines); index++ { if index > 0 { out.add("\n") } line := lines[index] language, fence := markdownCodeFence(line) end := markdownClosingFence(lines, index, fence) heading := markdownHeadingText(line) switch { case end > index: start := out.units out.add(strings.Join(lines[index+1:end], "\n")) out.entity(TelegramEntity{ Type: "pre", Offset: start, Length: out.units - start, Language: language, }) index = end case heading != "": start := out.units out.inline(heading) out.entity(TelegramEntity{ Type: "bold", Offset: start, Length: out.units - start, }) default: out.inline(line) } } slices.SortStableFunc(out.entities, func(a, b TelegramEntity) int { if a.Offset != b.Offset { return a.Offset - b.Offset } return b.Length - a.Length }) return out.text.String(), out.entities } // telegramEscapeTags writes each & of markdown outside inline code and fenced // code blocks as & and each < as <. Rich Markdown reads raw HTML and // HTML entities in prose, and it does not read a backslash before < as an // escape. With the entities, it shows a tag name and an entity that the model // wrote in prose as text. func telegramEscapeTags(markdown string) string { return telegramRewriteProse(markdown, telegramTagEscaper.Replace) } // telegramUnescapeTags gives back the Markdown that telegramEscapeTags escaped. // It is exact, because after the escape each & outside code starts & or // <. It also works on a part of telegramSplitRich, because a part has the // same code as the text that it comes from. func telegramUnescapeTags(markdown string) string { return telegramRewriteProse(markdown, telegramTagUnescaper.Replace) } // telegramUTF16Units returns the number of UTF-16 code units of text. func telegramUTF16Units(text string) int { units := 0 for _, char := range text { units++ if char > 0xFFFF { units++ } } return units } // TelegramMarkdownText is the plain text of a Markdown reply and its Telegram // entities, while the conversion builds it. Units counts the UTF-16 code units // of text, the unit of the entity offsets and lengths. type TelegramMarkdownText struct { text strings.Builder units int entities []TelegramEntity } // add appends text. func (m *TelegramMarkdownText) add(text string) { m.text.WriteString(text) m.units += telegramUTF16Units(text) } // entity keeps entity, unless it is empty, because Telegram refuses an entity // of length 0. func (m *TelegramMarkdownText) entity(entity TelegramEntity) { if entity.Length > 0 { m.entities = append(m.entities, entity) } } // inline appends one line of Markdown with its inline marks as entities. A // backslash before an ASCII punctuation character makes the character plain // text. func (m *TelegramMarkdownText) inline(line string) { plain := 0 index := 0 for index < len(line) { if line[index] == '\\' && index+1 < len(line) && strings.IndexByte(markdownPunctuation, line[index+1]) >= 0 { m.add(line[plain:index]) plain = index + 1 index += 2 continue } span, found := markdownMarkSpan(line, index) if !found { index++ continue } m.add(line[plain:index]) start := m.units if span.kind == "code" { m.add(span.content) } else { m.inline(span.content) } m.entity(TelegramEntity{ Type: span.kind, Offset: start, Length: m.units - start, URL: span.url, }) index = span.end plain = span.end } m.add(line[plain:]) } // telegramRewriteProse returns markdown with rewrite applied to each part of a // line outside inline code and fenced code blocks. A code block that does not // close runs to the end, as in Markdown. func telegramRewriteProse(markdown string, rewrite func(string) string) string { var out strings.Builder lines := strings.Split(markdown, "\n") for index := 0; index < len(lines); index++ { if index > 0 { out.WriteString("\n") } line := lines[index] _, fence := markdownCodeFence(line) if fence == "" { telegramRewriteLineProse(&out, line, rewrite) continue } end := markdownClosingFence(lines, index, fence) if end < 0 { end = len(lines) - 1 } out.WriteString(strings.Join(lines[index:end+1], "\n")) index = end } return out.String() } // telegramRewriteLineProse writes line to out with rewrite applied to each part // outside inline code. A backtick after a backslash opens no code. A run of // backticks that opens no inline code stays as text whole, as in Markdown. func telegramRewriteLineProse(out *strings.Builder, line string, rewrite func(string) string) { prose := 0 for index := 0; index < len(line); { switch line[index] { case '\\': if index+1 < len(line) && strings.IndexByte(markdownPunctuation, line[index+1]) >= 0 { index += 2 continue } case '`': end := index + markdownBackticks(line[index:]) span, found := markdownCodeSpan(line, index) if found { end = span.end } out.WriteString(rewrite(line[prose:index])) out.WriteString(line[index:end]) index = end prose = end continue } index++ } out.WriteString(rewrite(line[prose:])) }