Ctrl K
ComunidadGuías…

internal/domain/message_entity_autodetect.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package domain

import (
	"strings"
	"unicode"
	"unicode/utf8"
)

// MessageEntitySpan describes an already occupied UTF-16 range while deriving
// automatic entities. It deliberately carries no TL or entity-type semantics.
type MessageEntitySpan struct {
	Offset int
	Length int
}

// DetectAutomaticMessageEntities derives the server-recognized lexical
// entities that do not require user intent: mentions, hashtags, cashtags, bot
// commands and bank card numbers. Offsets and lengths use Telegram's UTF-16 code-unit indexing.
//
// occupied ranges win over derived entities. URL-like tokens are also treated
// as occupied for lexical triggers, so strings such as https://t.me/@name do
// not become misleading mention entities even when URL projection is handled
// by a different boundary.
func DetectAutomaticMessageEntities(message string, occupied []MessageEntitySpan) []MessageEntity {
	if message == "" || (!strings.ContainsAny(message, "@#$/") && !MayContainBankCardNumber(message)) {
		return nil
	}
	type interval struct{ start, end int }
	blocked := make([]interval, 0, len(occupied)+8)
	for _, span := range occupied {
		if span.Offset >= 0 && span.Length > 0 {
			blocked = append(blocked, interval{start: span.Offset, end: span.Offset + span.Length})
		}
	}
	overlaps := func(start, end int) bool {
		for _, span := range blocked {
			if start < span.end && span.start < end {
				return true
			}
		}
		return false
	}
	var out []MessageEntity
	accept := func(entity MessageEntity) {
		if entity.Length <= 0 || len(out) >= MaxMessageEntityCount {
			return
		}
		end := entity.Offset + entity.Length
		if overlaps(entity.Offset, end) {
			return
		}
		out = append(out, entity)
		blocked = append(blocked, interval{start: entity.Offset, end: end})
	}

	for _, entity := range detectMentionMessageEntities(message) {
		accept(entity)
	}
	for _, entity := range detectHashtagMessageEntities(message) {
		accept(entity)
	}
	for _, entity := range detectCashtagMessageEntities(message) {
		accept(entity)
	}
	for _, entity := range detectBotCommandMessageEntities(message) {
		accept(entity)
	}
	for _, entity := range detectBankCardMessageEntities(message) {
		accept(entity)
	}
	return out
}

func detectMentionMessageEntities(message string) []MessageEntity {
	var out []MessageEntity
	for i := 0; i < len(message); i++ {
		if message[i] != '@' || automaticEntityInsideURLLikeToken(message, i) {
			continue
		}
		if r, ok := previousRune(message, i); ok && (automaticEntityWordRune(r) || r == '@') {
			continue
		}
		end := i + 1
		for end < len(message) && automaticEntityUsernameByte(message[end]) {
			end++
		}
		if length := end - i - 1; length < 1 || length > 32 {
			continue
		}
		out = append(out, MessageEntity{
			Type:   MessageEntityMention,
			Offset: automaticEntityUTF16Length(message[:i]),
			Length: automaticEntityUTF16Length(message[i:end]),
		})
		i = end - 1
	}
	return out
}

func detectBotCommandMessageEntities(message string) []MessageEntity {
	var out []MessageEntity
	for i := 0; i < len(message); i++ {
		if message[i] != '/' || automaticEntityInsideURLLikeToken(message, i) {
			continue
		}
		if r, ok := previousRune(message, i); ok && (automaticEntityWordRune(r) || r == '/' || r == '@' || r == '<') {
			continue
		}
		end := i + 1
		for end < len(message) && automaticEntityUsernameByte(message[end]) {
			end++
		}
		if length := end - i - 1; length < 1 || length > 64 {
			continue
		}
		if end < len(message) && message[end] == '@' {
			botEnd := end + 1
			for botEnd < len(message) && automaticEntityUsernameByte(message[botEnd]) {
				botEnd++
			}
			if length := botEnd - end - 1; length >= 1 && length <= 32 {
				end = botEnd
			}
		}
		out = append(out, MessageEntity{
			Type:   MessageEntityBotCommand,
			Offset: automaticEntityUTF16Length(message[:i]),
			Length: automaticEntityUTF16Length(message[i:end]),
		})
		i = end - 1
	}
	return out
}

func detectHashtagMessageEntities(message string) []MessageEntity {
	var out []MessageEntity
	for i := 0; i < len(message); i++ {
		if message[i] != '#' || automaticEntityInsideURLLikeToken(message, i) {
			continue
		}
		if r, ok := previousRune(message, i); ok && (automaticEntityWordRune(r) || r == '#' || r == '@') {
			continue
		}
		end := i + 1
		var first rune
		count := 0
		for end < len(message) {
			r, size := utf8.DecodeRuneInString(message[end:])
			if size <= 0 || !automaticEntityHashtagRune(r) {
				break
			}
			if count == 0 {
				first = r
			}
			count++
			end += size
		}
		if count >= 1 && count <= 256 && !unicode.IsDigit(first) {
			out = append(out, MessageEntity{
				Type:   MessageEntityHashtag,
				Offset: automaticEntityUTF16Length(message[:i]),
				Length: automaticEntityUTF16Length(message[i:end]),
			})
			i = end - 1
		}
	}
	return out
}

func detectCashtagMessageEntities(message string) []MessageEntity {
	var out []MessageEntity
	for i := 0; i < len(message); i++ {
		if message[i] != '$' || automaticEntityInsideURLLikeToken(message, i) {
			continue
		}
		if r, ok := previousRune(message, i); ok && (automaticEntityWordRune(r) || r == '$') {
			continue
		}
		end := i + 1
		for end < len(message) && message[end] >= 'A' && message[end] <= 'Z' {
			end++
		}
		if length := end - i - 1; length < 1 || length > 8 {
			continue
		}
		if r, size := utf8.DecodeRuneInString(message[end:]); size > 0 && automaticEntityWordRune(r) {
			continue
		}
		out = append(out, MessageEntity{
			Type:   MessageEntityCashtag,
			Offset: automaticEntityUTF16Length(message[:i]),
			Length: automaticEntityUTF16Length(message[i:end]),
		})
		i = end - 1
	}
	return out
}

func automaticEntityInsideURLLikeToken(message string, byteIndex int) bool {
	start := byteIndex
	for start > 0 {
		r, size := utf8.DecodeLastRuneInString(message[:start])
		if size <= 0 || automaticEntityURLBoundary(r) {
			break
		}
		start -= size
	}
	prefix := strings.TrimLeft(message[start:byteIndex], "([{(【")
	if strings.Contains(prefix, "://") {
		return true
	}
	separator := strings.IndexAny(prefix, "/?#")
	if separator <= 0 {
		return false
	}
	host := prefix[:separator]
	return strings.Contains(host, ".") && !strings.Contains(host, "@")
}

func automaticEntityURLBoundary(r rune) bool {
	return unicode.IsSpace(r) || strings.ContainsRune("<>\"')】", r)
}

func previousRune(message string, byteIndex int) (rune, bool) {
	if byteIndex <= 0 || byteIndex > len(message) {
		return 0, false
	}
	r, size := utf8.DecodeLastRuneInString(message[:byteIndex])
	return r, size > 0
}

func automaticEntityWordRune(r rune) bool {
	return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)
}

func automaticEntityHashtagRune(r rune) bool {
	return automaticEntityWordRune(r)
}

func automaticEntityUsernameByte(b byte) bool {
	return b == '_' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9'
}

func automaticEntityUTF16Length(text string) int {
	length := 0
	for _, r := range text {
		length++
		if r > 0xffff {
			length++
		}
	}
	return length
}