owpengram-server/internal/botapi/formatted_text.go
A 2965f5d47d feat: sync bot api formatted text parse modes
Sync telesrv 00630bc (feat(botapi): support formatted text parse modes).

Skipped telesrv docs changes per public sync rules.
2026-07-20 20:23:47 +08:00

1029 lines
30 KiB
Go

package botapi
import (
"bytes"
"errors"
"fmt"
"net/url"
"sort"
"strconv"
"strings"
"unicode/utf8"
"telesrv/internal/domain"
)
// The official Bot API rejects the raw formatted-text input above 32 KiB before
// parsing; the post-parse message/caption character limit is checked separately.
const maxBotAPIFormattedTextBytes = 1 << 15
// botAPIFormattedTextRaw applies the Bot API parse_mode/entities precedence and
// returns the plain text plus UTF-16 based entities that can cross the domain
// boundary. A non-empty parse_mode (except "none") deliberately wins over an
// entities payload, matching the official Bot API server.
func botAPIFormattedTextRaw(text, parseMode, rawEntities string, maxLength int, requireText bool) (string, []domain.MessageEntity, error) {
mode, enabled, err := botAPIParseMode(parseMode)
if err != nil {
return "", nil, err
}
if enabled && text != "" {
return botAPIFormattedText(text, mode, nil, maxLength, requireText)
}
entities, err := botAPIMessageEntities(rawEntities)
if err != nil {
return "", nil, err
}
return validateBotAPIFormattedText(text, entities, maxLength, requireText)
}
func botAPIFormattedText(text, parseMode string, inputEntities []apiMessageEntity, maxLength int, requireText bool) (string, []domain.MessageEntity, error) {
mode, enabled, err := botAPIParseMode(parseMode)
if err != nil {
return "", nil, err
}
if !utf8.ValidString(text) {
return "", nil, errors.New("ENTITY_INVALID")
}
if len(text) > maxBotAPIFormattedTextBytes {
return "", nil, errors.New("MESSAGE_TOO_LONG")
}
if enabled {
var parsed string
var entities []domain.MessageEntity
switch mode {
case "html":
parsed, entities, err = parseBotAPIHTML(text)
case "markdown":
parsed, entities, err = parseBotAPIMarkdown(text)
case "markdownv2":
parsed, entities, err = parseBotAPIMarkdownV2(text)
default:
panic("normalized Bot API parse mode is not handled")
}
if err != nil {
return "", nil, err
}
return validateBotAPIFormattedText(parsed, entities, maxLength, requireText)
}
entities, err := messageEntitiesFromAPI(inputEntities)
if err != nil {
return "", nil, err
}
return validateBotAPIFormattedText(text, entities, maxLength, requireText)
}
func botAPIParseMode(raw string) (mode string, enabled bool, err error) {
mode = strings.ToLower(strings.TrimSpace(raw))
switch mode {
case "", "none", "null":
return "", false, nil
case "html", "markdown", "markdownv2":
return mode, true, nil
default:
return "", false, errors.New("Unsupported parse_mode")
}
}
func validateBotAPIFormattedText(text string, entities []domain.MessageEntity, maxLength int, requireText bool) (string, []domain.MessageEntity, error) {
if !utf8.ValidString(text) {
return "", nil, errors.New("ENTITY_INVALID")
}
if requireText && text == "" {
return "", nil, errors.New("MESSAGE_EMPTY")
}
if maxLength > 0 && utf8.RuneCountInString(text) > maxLength {
return "", nil, errors.New("MESSAGE_TOO_LONG")
}
if len(entities) > domain.MaxMessageEntityCount {
return "", nil, errors.New("ENTITIES_TOO_LONG")
}
textLength := utf16StringLength(text)
boundaries := make(map[int]struct{}, utf8.RuneCountInString(text)+1)
boundaries[0] = struct{}{}
position := 0
for _, r := range text {
position++
if r > 0xffff {
position++
}
boundaries[position] = struct{}{}
}
for _, entity := range entities {
if entity.Type == "" || entity.Offset < 0 || entity.Length <= 0 || entity.Offset > textLength || entity.Length > textLength-entity.Offset {
return "", nil, errors.New("ENTITY_BOUNDS_INVALID")
}
if _, ok := boundaries[entity.Offset]; !ok {
return "", nil, errors.New("ENTITY_BOUNDS_INVALID")
}
if _, ok := boundaries[entity.Offset+entity.Length]; !ok {
return "", nil, errors.New("ENTITY_BOUNDS_INVALID")
}
}
sortBotAPIEntities(entities)
ends := make([]int, 0, len(entities))
for _, entity := range entities {
for len(ends) > 0 && entity.Offset >= ends[len(ends)-1] {
ends = ends[:len(ends)-1]
}
end := entity.Offset + entity.Length
if len(ends) > 0 && end > ends[len(ends)-1] {
return "", nil, errors.New("ENTITY_BOUNDS_INVALID")
}
ends = append(ends, end)
}
return text, entities, nil
}
func utf16StringLength(text string) int {
length := 0
for _, r := range text {
length++
if r > 0xffff {
length++
}
}
return length
}
type formattedTextBuilder struct {
text bytes.Buffer
utf16 int
}
func (b *formattedTextBuilder) appendString(value string) {
b.text.WriteString(value)
b.utf16 += utf16StringLength(value)
}
func (b *formattedTextBuilder) appendRune(r rune) {
b.text.WriteRune(r)
b.utf16++
if r > 0xffff {
b.utf16++
}
}
func (b *formattedTextBuilder) string() string { return b.text.String() }
func (b *formattedTextBuilder) byteLen() int { return b.text.Len() }
func parseEntityError(format string, args ...any) error {
return fmt.Errorf("Can't parse entities: "+format, args...)
}
type htmlEntityFrame struct {
tag string
typ domain.MessageEntityType
offset int
outputByte int
argument string
language string
documentID int64
date int
collapsed bool
relative bool
shortTime bool
longTime bool
shortDate bool
longDate bool
dayOfWeek bool
}
func parseBotAPIHTML(input string) (string, []domain.MessageEntity, error) {
var out formattedTextBuilder
entities := make([]domain.MessageEntity, 0)
stack := make([]htmlEntityFrame, 0)
for i := 0; i < len(input); {
switch input[i] {
case '&':
decoded, next, err := decodeBotAPIHTMLEntity(input, i)
if err != nil {
return "", nil, err
}
out.appendString(decoded)
i = next
case '<':
closing, tag, attrs, booleans, next, err := scanBotAPIHTMLTag(input, i)
if err != nil {
return "", nil, err
}
if !closing {
frame, frameErr := botAPIHTMLFrame(tag, attrs, booleans, out.utf16, out.byteLen())
if frameErr != nil {
return "", nil, frameErr
}
stack = append(stack, frame)
} else {
if len(stack) == 0 {
return "", nil, parseEntityError("unexpected end tag at byte offset %d", i)
}
frame := stack[len(stack)-1]
if tag != "" && tag != frame.tag {
return "", nil, parseEntityError("unmatched end tag at byte offset %d, expected </%s>, found </%s>", i, frame.tag, tag)
}
stack = stack[:len(stack)-1]
length := out.utf16 - frame.offset
if length > 0 {
if frame.tag == "tg-time" && frame.date <= 0 {
i = next
continue
}
entity := domain.MessageEntity{
Type: frame.typ, Offset: frame.offset, Length: length, Language: frame.language,
DocumentID: frame.documentID, Date: frame.date, Collapsed: frame.collapsed,
Relative: frame.relative, ShortTime: frame.shortTime, LongTime: frame.longTime,
ShortDate: frame.shortDate, LongDate: frame.longDate, DayOfWeek: frame.dayOfWeek,
}
switch frame.tag {
case "a":
link := frame.argument
if link == "" {
link = out.string()[frame.outputByte:]
}
resolved, ok := botAPITextLinkEntity(link, frame.offset, length)
if ok {
entities = append(entities, resolved)
}
case "pre":
if len(entities) > 0 {
last := &entities[len(entities)-1]
if last.Type == domain.MessageEntityCode && last.Offset == frame.offset && last.Length == length && last.Language != "" {
last.Type = domain.MessageEntityPre
break
}
}
entities = append(entities, entity)
default:
entities = append(entities, entity)
}
}
}
i = next
default:
j := i
for j < len(input) && input[j] != '<' && input[j] != '&' {
j++
}
out.appendString(input[i:j])
i = j
}
}
if len(stack) > 0 {
return "", nil, parseEntityError("can't find end tag corresponding to start tag <%s>", stack[len(stack)-1].tag)
}
for i := range entities {
if entities[i].Type == domain.MessageEntityCode {
entities[i].Language = ""
}
}
sortBotAPIEntities(entities)
return out.string(), entities, nil
}
// scanBotAPIHTMLTag parses only the deliberately small HTML dialect accepted by
// the Bot API. It does not apply browser error recovery: malformed and unmatched
// tags must fail before a message state transition.
func scanBotAPIHTMLTag(input string, start int) (closing bool, tag string, attrs map[string]string, booleans map[string]bool, next int, err error) {
if start < 0 || start >= len(input) || input[start] != '<' {
return false, "", nil, nil, start, parseEntityError("invalid tag at byte offset %d", start)
}
i := start + 1
if i < len(input) && input[i] == '/' {
closing = true
i++
}
nameStart := i
for i < len(input) && !isHTMLSpace(input[i]) && input[i] != '>' {
i++
}
if i >= len(input) {
return false, "", nil, nil, start, parseEntityError("unclosed tag at byte offset %d", start)
}
if i == nameStart && !closing {
return false, "", nil, nil, start, parseEntityError("empty tag at byte offset %d", start)
}
tag = strings.ToLower(input[nameStart:i])
if tag != "" && !supportedBotAPIHTMLTag(tag) {
return false, "", nil, nil, start, parseEntityError("unsupported tag %q at byte offset %d", tag, start)
}
attrs = make(map[string]string)
booleans = make(map[string]bool)
if closing {
for i < len(input) && isHTMLSpace(input[i]) {
i++
}
if i >= len(input) || input[i] != '>' {
return false, "", nil, nil, start, parseEntityError("unclosed end tag at byte offset %d", start)
}
return true, tag, attrs, booleans, i + 1, nil
}
for {
for i < len(input) && isHTMLSpace(input[i]) {
i++
}
if i >= len(input) {
return false, "", nil, nil, start, parseEntityError("unclosed start tag <%s>", tag)
}
if input[i] == '>' {
return false, tag, attrs, booleans, i + 1, nil
}
if input[i] == '/' {
return false, "", nil, nil, start, parseEntityError("self-closing tag <%s/> is unsupported", tag)
}
attributeStart := i
for i < len(input) && !isHTMLSpace(input[i]) && !strings.ContainsRune("=>/\"'", rune(input[i])) {
i++
}
if i == attributeStart {
return false, "", nil, nil, start, parseEntityError("empty attribute name in tag <%s>", tag)
}
name := strings.ToLower(input[attributeStart:i])
for i < len(input) && isHTMLSpace(input[i]) {
i++
}
if i >= len(input) {
return false, "", nil, nil, start, parseEntityError("unclosed start tag <%s>", tag)
}
if input[i] != '=' {
booleans[name] = true
continue
}
i++
for i < len(input) && isHTMLSpace(input[i]) {
i++
}
if i >= len(input) {
return false, "", nil, nil, start, parseEntityError("unclosed attribute %q", name)
}
var raw string
if input[i] == '\'' || input[i] == '"' {
quote := input[i]
i++
valueStart := i
for i < len(input) && input[i] != quote {
i++
}
if i >= len(input) {
return false, "", nil, nil, start, parseEntityError("unclosed attribute %q", name)
}
raw = input[valueStart:i]
i++
} else {
valueStart := i
for i < len(input) && (isASCIIAlphaNumeric(input[i]) || input[i] == '.' || input[i] == '-') {
i++
}
if i == valueStart || (i < len(input) && !isHTMLSpace(input[i]) && input[i] != '>') {
return false, "", nil, nil, start, parseEntityError("invalid unquoted attribute %q", name)
}
raw = strings.ToLower(input[valueStart:i])
}
value, decodeErr := decodeBotAPIHTMLString(raw)
if decodeErr != nil {
return false, "", nil, nil, start, decodeErr
}
attrs[name] = value
}
}
func botAPIHTMLFrame(tag string, attrs map[string]string, booleans map[string]bool, offset, outputByte int) (htmlEntityFrame, error) {
frame := htmlEntityFrame{tag: tag, offset: offset, outputByte: outputByte}
switch tag {
case "b", "strong":
frame.typ = domain.MessageEntityBold
case "i", "em":
frame.typ = domain.MessageEntityItalic
case "u", "ins":
frame.typ = domain.MessageEntityUnderline
case "s", "strike", "del":
frame.typ = domain.MessageEntityStrike
case "tg-spoiler":
frame.typ = domain.MessageEntitySpoiler
case "span":
if attrs["class"] != "tg-spoiler" {
return htmlEntityFrame{}, parseEntityError("tag <span> must have class \"tg-spoiler\"")
}
frame.typ = domain.MessageEntitySpoiler
case "a":
frame.typ, frame.argument = domain.MessageEntityTextURL, attrs["href"]
case "code":
frame.typ = domain.MessageEntityCode
if class := attrs["class"]; strings.HasPrefix(class, "language-") {
frame.language = strings.TrimPrefix(class, "language-")
}
case "pre":
frame.typ = domain.MessageEntityPre
case "blockquote":
frame.typ = domain.MessageEntityBlockquote
_, hasExpandable := attrs["expandable"]
frame.collapsed = booleans["expandable"] || hasExpandable
case "tg-emoji":
frame.typ = domain.MessageEntityCustomEmoji
id, err := strconv.ParseInt(attrs["emoji-id"], 10, 64)
if err != nil || id <= 0 {
return htmlEntityFrame{}, parseEntityError("invalid custom emoji identifier")
}
frame.documentID = id
case "tg-time":
frame.typ = domain.MessageEntityFormattedDate
date, err := strconv.ParseInt(attrs["unix"], 10, 32)
if err != nil {
date = 0
}
formatted, err := botAPIFormattedDate(1, attrs["format"])
if err != nil {
return htmlEntityFrame{}, err
}
frame.date = int(date)
frame.relative = formatted.Relative
frame.shortTime = formatted.ShortTime
frame.longTime = formatted.LongTime
frame.shortDate = formatted.ShortDate
frame.longDate = formatted.LongDate
frame.dayOfWeek = formatted.DayOfWeek
default:
return htmlEntityFrame{}, parseEntityError("unsupported tag <%s>", tag)
}
return frame, nil
}
func supportedBotAPIHTMLTag(tag string) bool {
switch tag {
case "a", "b", "strong", "i", "em", "s", "strike", "del", "u", "ins", "tg-spoiler", "tg-emoji", "tg-time", "span", "pre", "code", "blockquote":
return true
default:
return false
}
}
func isHTMLSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\f' }
func isASCIIAlphaNumeric(c byte) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9'
}
func decodeBotAPIHTMLString(input string) (string, error) {
if !strings.Contains(input, "&") {
return input, nil
}
var out strings.Builder
for i := 0; i < len(input); {
if input[i] != '&' {
j := strings.IndexByte(input[i:], '&')
if j < 0 {
out.WriteString(input[i:])
break
}
out.WriteString(input[i : i+j])
i += j
continue
}
decoded, next, err := decodeBotAPIHTMLEntity(input, i)
if err != nil {
return "", err
}
out.WriteString(decoded)
i = next
}
return out.String(), nil
}
func decodeBotAPIHTMLEntity(input string, start int) (string, int, error) {
endRelative := strings.IndexByte(input[start:], ';')
if endRelative < 0 {
return "&", start + 1, nil
}
end := start + endRelative
name := input[start+1 : end]
switch name {
case "lt":
return "<", end + 1, nil
case "gt":
return ">", end + 1, nil
case "amp":
return "&", end + 1, nil
case "quot":
return "\"", end + 1, nil
}
if !strings.HasPrefix(name, "#") {
return "&", start + 1, nil
}
base, digits := 10, name[1:]
if strings.HasPrefix(digits, "x") || strings.HasPrefix(digits, "X") {
base, digits = 16, digits[1:]
}
value, err := strconv.ParseInt(digits, base, 32)
if err != nil || value <= 0 || value > utf8.MaxRune || value >= 0xd800 && value <= 0xdfff {
return "&", start + 1, nil
}
return string(rune(value)), end + 1, nil
}
func parseBotAPIMarkdown(input string) (string, []domain.MessageEntity, error) {
var out formattedTextBuilder
entities := make([]domain.MessageEntity, 0)
for i := 0; i < len(input); {
if input[i] == '\\' && i+1 < len(input) && strings.ContainsRune("_*`[", rune(input[i+1])) {
out.appendRune(rune(input[i+1]))
i += 2
continue
}
marker := input[i]
if marker != '_' && marker != '*' && marker != '`' && marker != '[' {
r, size := utf8.DecodeRuneInString(input[i:])
out.appendRune(r)
i += size
continue
}
begin := i
typ := domain.MessageEntityItalic
delimiter := string(marker)
language := ""
switch marker {
case '*':
typ = domain.MessageEntityBold
i++
case '[':
typ, delimiter = domain.MessageEntityTextURL, "]"
i++
case '`':
typ = domain.MessageEntityCode
if strings.HasPrefix(input[i:], "```") {
typ, delimiter = domain.MessageEntityPre, "```"
i += 3
languageEnd := i
for languageEnd < len(input) && !isHTMLSpace(input[languageEnd]) && input[languageEnd] != '`' {
languageEnd++
}
if languageEnd > i && languageEnd < len(input) && input[languageEnd] != '`' {
language = input[i:languageEnd]
i = languageEnd
}
i = skipSingleLeadingNewline(input, i)
} else {
i++
}
default:
i++
}
offset := out.utf16
for i < len(input) && !strings.HasPrefix(input[i:], delimiter) {
r, size := utf8.DecodeRuneInString(input[i:])
out.appendRune(r)
i += size
}
if i >= len(input) {
return "", nil, parseEntityError("can't find end of entity starting at byte offset %d", begin)
}
length := out.utf16 - offset
i += len(delimiter)
if length <= 0 {
continue
}
if typ == domain.MessageEntityTextURL {
// Derive the visible slice by walking back over exactly the entity's
// UTF-16 range; legacy Markdown doesn't allow nested entities.
visibleStart := outputByteOffsetForUTF16Suffix(out.string(), length)
link := out.string()[visibleStart:]
if i < len(input) && input[i] == '(' {
urlStart := i + 1
urlEnd := strings.IndexByte(input[urlStart:], ')')
if urlEnd < 0 {
link = input[urlStart:]
i = len(input)
} else {
link = input[urlStart : urlStart+urlEnd]
i = urlStart + urlEnd + 1
}
}
if entity, ok := botAPITextLinkEntity(link, offset, length); ok {
entities = append(entities, entity)
}
continue
}
entities = append(entities, domain.MessageEntity{Type: typ, Offset: offset, Length: length, Language: language})
}
sortBotAPIEntities(entities)
return out.string(), entities, nil
}
func skipSingleLeadingNewline(input string, i int) int {
if i >= len(input) || input[i] != '\n' && input[i] != '\r' {
return i
}
first := input[i]
i++
if i < len(input) && (input[i] == '\n' || input[i] == '\r') && input[i] != first {
i++
}
return i
}
func outputByteOffsetForUTF16Suffix(text string, suffixLength int) int {
need := suffixLength
for i := len(text); i > 0; {
r, size := utf8.DecodeLastRuneInString(text[:i])
need--
if r > 0xffff {
need--
}
i -= size
if need == 0 {
return i
}
}
return 0
}
type markdownV2Frame struct {
typ domain.MessageEntityType
offset int
inputByte int
outputByte int
language string
}
func parseBotAPIMarkdownV2(input string) (string, []domain.MessageEntity, error) {
var out formattedTextBuilder
entities := make([]domain.MessageEntity, 0)
stack := make([]markdownV2Frame, 0)
haveBlockquote, canStartBlockquote := false, true
for i := 0; i < len(input); {
if input[i] == '\\' && i+1 < len(input) && input[i+1] > 0 && input[i+1] <= 126 {
literal := input[i+1]
out.appendRune(rune(literal))
if literal != '\r' {
canStartBlockquote = literal == '\n'
}
i += 2
continue
}
reserved := "_*[]()~`>#+-=|{}.!\n"
if len(stack) > 0 && (stack[len(stack)-1].typ == domain.MessageEntityCode || stack[len(stack)-1].typ == domain.MessageEntityPre) {
reserved = "`"
}
if !strings.ContainsRune(reserved, rune(input[i])) {
r, size := utf8.DecodeRuneInString(input[i:])
out.appendRune(r)
if r != '\r' {
canStartBlockquote = false
}
i += size
continue
}
c := input[i]
endQuote := haveBlockquote && c == '\n' && (i+1 == len(input) || input[i+1] != '>')
isEnd := endQuote || markdownV2ClosesTop(input, i, stack)
if !isEnd {
frame := markdownV2Frame{offset: out.utf16, inputByte: i, outputByte: out.byteLen()}
switch c {
case '_':
frame.typ = domain.MessageEntityItalic
i++
if i < len(input) && input[i] == '_' {
frame.typ = domain.MessageEntityUnderline
i++
}
case '*':
frame.typ = domain.MessageEntityBold
i++
case '~':
frame.typ = domain.MessageEntityStrike
i++
case '|':
if i+1 >= len(input) || input[i+1] != '|' {
return "", nil, markdownV2ReservedError(c)
}
frame.typ = domain.MessageEntitySpoiler
i += 2
case '[':
frame.typ = domain.MessageEntityTextURL
i++
case '!':
if i+1 >= len(input) || input[i+1] != '[' {
return "", nil, markdownV2ReservedError(c)
}
frame.typ = domain.MessageEntityCustomEmoji
i += 2
case '`':
frame.typ = domain.MessageEntityCode
if strings.HasPrefix(input[i:], "```") {
frame.typ = domain.MessageEntityPre
i += 3
languageEnd := i
for languageEnd < len(input) && !isHTMLSpace(input[languageEnd]) && input[languageEnd] != '`' {
languageEnd++
}
if languageEnd > i && languageEnd < len(input) && input[languageEnd] != '`' {
frame.language = input[i:languageEnd]
i = languageEnd
}
i = skipSingleLeadingNewline(input, i)
} else {
i++
}
case '\n':
out.appendRune('\n')
canStartBlockquote = true
i++
continue
case '>':
if !canStartBlockquote {
return "", nil, markdownV2ReservedError(c)
}
if haveBlockquote {
i++
continue
}
frame.typ = domain.MessageEntityBlockquote
haveBlockquote = true
i++
default:
return "", nil, markdownV2ReservedError(c)
}
stack = append(stack, frame)
continue
}
if len(stack) == 0 {
return "", nil, markdownV2ReservedError(c)
}
collapsed := false
if endQuote {
quoteStart := i
if len(stack) > 0 {
quoteStart = stack[len(stack)-1].inputByte
}
if stack[len(stack)-1].typ == domain.MessageEntitySpoiler && out.utf16 == stack[len(stack)-1].offset {
stack = stack[:len(stack)-1]
collapsed = true
}
if len(stack) == 0 || stack[len(stack)-1].typ != domain.MessageEntityBlockquote {
return "", nil, parseEntityError("can't find end of entity starting at byte offset %d", quoteStart)
}
frame := stack[len(stack)-1]
stack = stack[:len(stack)-1]
out.appendRune('\n')
length := out.utf16 - frame.offset
if length > 0 {
entities = append(entities, domain.MessageEntity{Type: domain.MessageEntityBlockquote, Offset: frame.offset, Length: length, Collapsed: collapsed})
}
haveBlockquote, canStartBlockquote = false, true
i++
continue
}
frame := stack[len(stack)-1]
stack = stack[:len(stack)-1]
length := out.utf16 - frame.offset
switch frame.typ {
case domain.MessageEntityBold, domain.MessageEntityItalic, domain.MessageEntityStrike, domain.MessageEntityCode:
i++
case domain.MessageEntityUnderline, domain.MessageEntitySpoiler:
i += 2
case domain.MessageEntityPre:
i += 3
case domain.MessageEntityTextURL, domain.MessageEntityCustomEmoji:
i++ // closing ]
link := out.string()[frame.outputByte:]
if i < len(input) && input[i] == '(' {
parsedURL, next, parseErr := parseMarkdownV2URL(input, i+1)
if parseErr != nil {
return "", nil, parseErr
}
link, i = parsedURL, next
} else if frame.typ == domain.MessageEntityCustomEmoji {
return "", nil, parseEntityError("custom emoji entity must contain a tg://emoji or tg://time URL")
}
if length > 0 {
if frame.typ == domain.MessageEntityTextURL {
if entity, ok := botAPITextLinkEntity(link, frame.offset, length); ok {
entities = append(entities, entity)
}
} else {
entity, resolveErr := botAPICustomLinkEntity(link, frame.offset, length)
if resolveErr != nil {
return "", nil, resolveErr
}
entities = append(entities, entity)
}
}
continue
default:
return "", nil, parseEntityError("invalid MarkdownV2 entity")
}
if length > 0 {
entities = append(entities, domain.MessageEntity{Type: frame.typ, Offset: frame.offset, Length: length, Language: frame.language})
}
}
if haveBlockquote {
collapsed := false
if len(stack) > 0 && stack[len(stack)-1].typ == domain.MessageEntitySpoiler && out.utf16 == stack[len(stack)-1].offset {
stack = stack[:len(stack)-1]
collapsed = true
}
if len(stack) > 0 && stack[len(stack)-1].typ == domain.MessageEntityBlockquote {
frame := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if length := out.utf16 - frame.offset; length > 0 {
entities = append(entities, domain.MessageEntity{Type: domain.MessageEntityBlockquote, Offset: frame.offset, Length: length, Collapsed: collapsed})
}
haveBlockquote = false
}
}
if len(stack) > 0 {
frame := stack[len(stack)-1]
return "", nil, parseEntityError("can't find end of entity starting at byte offset %d", frame.inputByte)
}
sortBotAPIEntities(entities)
return out.string(), entities, nil
}
func markdownV2ClosesTop(input string, i int, stack []markdownV2Frame) bool {
if len(stack) == 0 {
return false
}
c := input[i]
switch stack[len(stack)-1].typ {
case domain.MessageEntityBold:
return c == '*'
case domain.MessageEntityItalic:
return c == '_' && (i+1 >= len(input) || input[i+1] != '_')
case domain.MessageEntityUnderline:
return c == '_' && i+1 < len(input) && input[i+1] == '_'
case domain.MessageEntityStrike:
return c == '~'
case domain.MessageEntitySpoiler:
return c == '|' && i+1 < len(input) && input[i+1] == '|'
case domain.MessageEntityCode:
return c == '`'
case domain.MessageEntityPre:
return strings.HasPrefix(input[i:], "```")
case domain.MessageEntityTextURL, domain.MessageEntityCustomEmoji:
return c == ']'
case domain.MessageEntityBlockquote:
return false
default:
return false
}
}
func markdownV2ReservedError(c byte) error {
return parseEntityError("character %q is reserved and must be escaped with a preceding backslash", c)
}
func parseMarkdownV2URL(input string, start int) (string, int, error) {
var out strings.Builder
for i := start; i < len(input); {
if input[i] == ')' {
return out.String(), i + 1, nil
}
if input[i] == '\\' && i+1 < len(input) && input[i+1] > 0 && input[i+1] <= 126 {
out.WriteByte(input[i+1])
i += 2
continue
}
r, size := utf8.DecodeRuneInString(input[i:])
out.WriteRune(r)
i += size
}
return "", start, parseEntityError("can't find end of URL at byte offset %d", start)
}
func botAPITextLinkEntity(raw string, offset, length int) (domain.MessageEntity, bool) {
if userID, ok := botAPITGUserID(raw); ok {
return domain.MessageEntity{Type: domain.MessageEntityMentionName, Offset: offset, Length: length, UserID: userID}, true
}
if parsed, err := url.Parse(raw); err == nil && strings.EqualFold(parsed.Scheme, "tg") && strings.EqualFold(parsed.Host, "user") {
return domain.MessageEntity{}, false
}
if !validBotAPITextURL(raw) {
return domain.MessageEntity{}, false
}
return domain.MessageEntity{Type: domain.MessageEntityTextURL, Offset: offset, Length: length, URL: raw}, true
}
func botAPITGUserID(raw string) (int64, bool) {
parsed, err := url.Parse(raw)
if err != nil || !strings.EqualFold(parsed.Scheme, "tg") || !strings.EqualFold(parsed.Host, "user") {
return 0, false
}
id, err := strconv.ParseInt(parsed.Query().Get("id"), 10, 64)
return id, err == nil && id > 0
}
func validBotAPITextURL(raw string) bool {
if raw == "" || strings.ContainsAny(raw, " \t\r\n") {
return false
}
parsed, err := url.Parse(raw)
if err != nil {
return false
}
switch strings.ToLower(parsed.Scheme) {
case "http", "https":
return parsed.Host != ""
case "tg":
return parsed.Host != ""
case "mailto", "tel":
return parsed.Opaque != "" || parsed.Path != ""
case "":
return strings.Contains(parsed.Path, ".")
default:
return false
}
}
func botAPICustomLinkEntity(raw string, offset, length int) (domain.MessageEntity, error) {
parsed, err := url.Parse(raw)
if err != nil || !strings.EqualFold(parsed.Scheme, "tg") {
return domain.MessageEntity{}, parseEntityError("invalid tg://emoji or tg://time URL")
}
switch strings.ToLower(parsed.Host) {
case "emoji":
id, parseErr := strconv.ParseInt(parsed.Query().Get("id"), 10, 64)
if parseErr != nil || id <= 0 {
return domain.MessageEntity{}, parseEntityError("invalid custom emoji identifier")
}
return domain.MessageEntity{Type: domain.MessageEntityCustomEmoji, Offset: offset, Length: length, DocumentID: id}, nil
case "time":
date, parseErr := strconv.ParseInt(parsed.Query().Get("unix"), 10, 32)
if parseErr != nil || date <= 0 {
return domain.MessageEntity{}, parseEntityError("invalid date-time unix value")
}
entity, formatErr := botAPIFormattedDate(int(date), parsed.Query().Get("format"))
if formatErr != nil {
return domain.MessageEntity{}, formatErr
}
entity.Offset, entity.Length = offset, length
return entity, nil
default:
return domain.MessageEntity{}, parseEntityError("invalid tg://emoji or tg://time URL")
}
}
func botAPIFormattedDate(date int, format string) (domain.MessageEntity, error) {
if date <= 0 || int64(date) > 1<<31-1 {
return domain.MessageEntity{}, parseEntityError("invalid date-time unix value")
}
entity := domain.MessageEntity{Type: domain.MessageEntityFormattedDate, Date: date}
if format == "" {
return entity, nil
}
if format == "r" || format == "R" {
entity.Relative = true
return entity, nil
}
for _, part := range format {
switch part {
case 't':
entity.ShortTime = true
case 'T':
entity.LongTime = true
case 'd':
entity.ShortDate = true
case 'D':
entity.LongDate = true
case 'w', 'W':
entity.DayOfWeek = true
default:
return domain.MessageEntity{}, parseEntityError("invalid date-time format %q", format)
}
}
return entity, nil
}
func botAPIFormattedDateFormat(entity domain.MessageEntity) string {
if entity.Relative {
return "r"
}
var out strings.Builder
if entity.DayOfWeek {
out.WriteByte('w')
}
if entity.ShortDate {
out.WriteByte('d')
} else if entity.LongDate {
out.WriteByte('D')
}
if entity.ShortTime {
out.WriteByte('t')
} else if entity.LongTime {
out.WriteByte('T')
}
return out.String()
}
func sortBotAPIEntities(entities []domain.MessageEntity) {
sort.SliceStable(entities, func(i, j int) bool {
if entities[i].Offset != entities[j].Offset {
return entities[i].Offset < entities[j].Offset
}
if entities[i].Length != entities[j].Length {
return entities[i].Length > entities[j].Length
}
return entities[i].Type < entities[j].Type
})
}