owpengram-server/internal/domain/message_entity_autodetect.go
2026-09-01 12:06:31 +03:00

250 lines
6.7 KiB
Go

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 and
// bot commands. 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, "@#$/") {
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)
}
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
}