(new): rich message support
Golang lint / lint (pull_request) Successful in 1m20s
Golang lint / lint (push) Successful in 4m8s

(fix): runtime reliability
(tests): regression coverage
(doc): v1.1 release notes
This commit is contained in:
2026-08-12 16:34:44 +03:00
parent 48ddf66540
commit f03a081ed6
83 changed files with 6122 additions and 1925 deletions
+472
View File
@@ -0,0 +1,472 @@
package tgrich
import (
"fmt"
"math"
"strings"
"unicode/utf8"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
type richValidator struct {
chars int
blocks int
media int
allowThinking bool
}
func validateRichBlocks(blocks []tgapi.InputRichBlock, allowThinking bool) error {
v := &richValidator{allowThinking: allowThinking}
for _, block := range blocks {
if err := v.block(block, 0); err != nil {
return err
}
}
return nil
}
func (v *richValidator) addChars(s string) error {
v.chars += utf8.RuneCountInString(s)
if v.chars > maxRichTextChars {
return ErrRichTextTooLong
}
return nil
}
func (v *richValidator) addBlocks(n int) error {
v.blocks += n
if v.blocks > maxRichBlocks {
return ErrRichTooManyBlocks
}
return nil
}
func (v *richValidator) addMedia(media tgapi.InputMedia, want tgapi.InputMediaType) error {
if media.Type != want || media.Media == "" {
return fmt.Errorf("%w: got %q, want %q", ErrRichInvalidMedia, media.Type, want)
}
v.media++
if v.media > maxRichMedia {
return ErrRichTooManyMedia
}
return nil
}
func expectBlockType(got, want tgapi.InputRichType) error {
if got != want {
return fmt.Errorf("%w: got %q, want %q", ErrRichInvalidBlockType, got, want)
}
return nil
}
func validListItemType(t tgapi.RichBlockListItemType) bool {
switch t {
case tgapi.InputRichBlockListItemTypeLower,
tgapi.InputRichBlockListItemTypeUpper,
tgapi.InputRichBlockListItemTypeRomanLow,
tgapi.InputRichBlockListItemTypeRomanUpper,
tgapi.InputRichBlockListItemTypeDecimal:
return true
default:
return false
}
}
func validateTableCell(cell tgapi.RichBlockTableCell) error {
if cell.ColSpan < 0 || cell.RowSpan < 0 {
return ErrRichInvalidTableCell
}
switch cell.Align {
case "", "left", "center", "right":
default:
return ErrRichInvalidTableCell
}
switch cell.VAlign {
case "", "top", "middle", "bottom":
default:
return ErrRichInvalidTableCell
}
return nil
}
func validateMap(block tgapi.InputRichBlockMap) error {
if math.IsNaN(block.Location.Latitude) || math.IsInf(block.Location.Latitude, 0) ||
math.IsNaN(block.Location.Longitude) || math.IsInf(block.Location.Longitude, 0) ||
block.Location.Latitude < -90 || block.Location.Latitude > 90 ||
block.Location.Longitude < -180 || block.Location.Longitude > 180 ||
block.Zoom > 24 || block.Width > 10000 || block.Height > 10000 ||
uint32(block.Width)+uint32(block.Height) > 10000 {
return ErrRichInvalidMap
}
if block.Width != 0 && block.Height != 0 {
longer := float64(block.Width)
shorter := float64(block.Height)
if longer < shorter {
longer, shorter = shorter, longer
}
if longer/shorter > 20 {
return ErrRichInvalidMap
}
}
return nil
}
func (v *richValidator) text(text tgapi.RichText, depth int) error {
switch el := text.(type) {
case nil:
return nil
case tgapi.RichTextPlain:
return v.addChars(string(el))
case tgapi.RichTextArray:
for _, item := range el {
if err := v.text(item, depth); err != nil {
return err
}
}
return nil
case tgapi.RichTextCustomEmoji:
return v.addChars(el.AlternativeText)
case tgapi.RichTextMathematicalExpression:
return v.addChars(el.Expression)
case tgapi.RichTextAnchor:
return nil
}
if depth >= maxRichDepth {
return ErrRichNestingTooDeep
}
var child tgapi.RichText
switch el := text.(type) {
case tgapi.RichTextWrap:
case tgapi.RichTextURL:
case tgapi.RichTextEmailAddress:
case tgapi.RichTextPhoneNumber:
case tgapi.RichTextBankCardNumber:
if err := validateAutomaticEntity(el.Text, el.BankCardNumber); err != nil {
return err
}
case tgapi.RichTextMention:
if err := validateAutomaticEntity(el.Text, "@"+strings.TrimPrefix(el.Username, "@")); err != nil {
return err
}
case tgapi.RichTextHashtag:
if err := validateAutomaticEntity(el.Text, "#"+strings.TrimPrefix(el.Hashtag, "#")); err != nil {
return err
}
case tgapi.RichTextCashtag:
if err := validateAutomaticEntity(el.Text, "$"+strings.TrimPrefix(el.Cashtag, "$")); err != nil {
return err
}
case tgapi.RichTextBotCommand:
if err := validateAutomaticEntity(el.Text, "/"+strings.TrimPrefix(el.BotCommand, "/")); err != nil {
return err
}
case tgapi.RichTextAnchorLink:
case tgapi.RichTextReference:
case tgapi.RichTextReferenceLink:
case tgapi.RichTextDateTime:
case tgapi.RichTextTextMention:
child = el.Text
default:
return ErrRichUnknownTag
}
return v.text(child, depth+1)
}
func validateAutomaticEntity(text tgapi.RichText, semantic string) error {
visible, err := visibleRichText(text)
if err != nil {
return err
}
if visible != semantic {
return fmt.Errorf("%w: visible %q, semantic %q", ErrRichEntityMismatch, visible, semantic)
}
return nil
}
func visibleRichText(text tgapi.RichText) (string, error) {
switch el := text.(type) {
case nil:
return "", nil
case tgapi.RichTextPlain:
return string(el), nil
case tgapi.RichTextArray:
var result strings.Builder
for _, item := range el {
part, err := visibleRichText(item)
if err != nil {
return "", err
}
result.WriteString(part)
}
return result.String(), nil
case tgapi.RichTextCustomEmoji:
return el.AlternativeText, nil
case tgapi.RichTextMathematicalExpression:
return el.Expression, nil
case tgapi.RichTextAnchor:
return "", nil
case tgapi.RichTextWrap:
return visibleRichText(el.Text)
case tgapi.RichTextURL:
return visibleRichText(el.Text)
case tgapi.RichTextEmailAddress:
return visibleRichText(el.Text)
case tgapi.RichTextPhoneNumber:
return visibleRichText(el.Text)
case tgapi.RichTextBankCardNumber:
return visibleRichText(el.Text)
case tgapi.RichTextMention:
return visibleRichText(el.Text)
case tgapi.RichTextHashtag:
return visibleRichText(el.Text)
case tgapi.RichTextCashtag:
return visibleRichText(el.Text)
case tgapi.RichTextBotCommand:
return visibleRichText(el.Text)
case tgapi.RichTextAnchorLink:
return visibleRichText(el.Text)
case tgapi.RichTextReference:
return visibleRichText(el.Text)
case tgapi.RichTextReferenceLink:
return visibleRichText(el.Text)
case tgapi.RichTextDateTime:
return visibleRichText(el.Text)
case tgapi.RichTextTextMention:
return visibleRichText(el.Text)
default:
return "", ErrRichUnknownTag
}
}
func (v *richValidator) caption(caption *tgapi.RichBlockCaption, depth int) error {
if caption == nil {
return nil
}
if err := v.text(caption.Text, depth); err != nil {
return err
}
return v.text(caption.Credit, depth)
}
func (v *richValidator) nested(blocks []tgapi.InputRichBlock, depth int) error {
for _, block := range blocks {
if err := v.block(block, depth); err != nil {
return err
}
}
return nil
}
func (v *richValidator) block(block tgapi.InputRichBlock, depth int) error {
if depth >= maxRichDepth {
return ErrRichNestingTooDeep
}
if err := v.addBlocks(1); err != nil {
return err
}
switch el := block.(type) {
case tgapi.InputRichBlockParagraph:
if err := expectBlockType(el.Type, tgapi.InputRichTypeParagraph); err != nil {
return err
}
return v.text(el.Text, depth+1)
case tgapi.InputRichBlockSectionHeading:
if err := expectBlockType(el.Type, tgapi.InputRichTypeSectionHeading); err != nil {
return err
}
if el.Size < 1 || el.Size > 6 {
return ErrRichInvalidHeading
}
return v.text(el.Text, depth+1)
case tgapi.InputRichBlockPreformatted:
if err := expectBlockType(el.Type, tgapi.InputRichTypePre); err != nil {
return err
}
return v.text(el.Text, depth+1)
case tgapi.InputRichBlockFooter:
if err := expectBlockType(el.Type, tgapi.InputRichTypeFooter); err != nil {
return err
}
return v.text(el.Text, depth+1)
case tgapi.InputRichBlockDivider:
return expectBlockType(el.Type, tgapi.InputRichTypeDivider)
case tgapi.InputRichBlockAnchor:
return expectBlockType(el.Type, tgapi.InputRichTypeAnchor)
case tgapi.InputRichBlockMath:
if err := expectBlockType(el.Type, tgapi.InputRichTypeMathematicalExpression); err != nil {
return err
}
return v.addChars(el.Expression)
case tgapi.InputRichBlockList:
if err := expectBlockType(el.Type, tgapi.InputRichTypeList); err != nil {
return err
}
if err := v.addBlocks(len(el.Items)); err != nil {
return err
}
ordered := false
unordered := false
for _, item := range el.Items {
if item.IsChecked && !item.HasCheckbox {
return ErrRichInvalidCheckbox
}
if item.Type == "" {
unordered = true
if item.Value != 0 {
return ErrRichInvalidListItem
}
} else {
ordered = true
if !validListItemType(item.Type) {
return ErrRichInvalidListItemType
}
}
if err := v.nested(item.Blocks, depth+1); err != nil {
return err
}
}
if ordered && unordered {
return ErrRichListItemMix
}
return nil
case tgapi.InputRichBlockBlockQuotation:
if err := expectBlockType(el.Type, tgapi.InputRichTypeBlockQuotation); err != nil {
return err
}
if err := v.textValue(el.Credit, depth+1); err != nil {
return err
}
return v.nested(el.Blocks, depth+1)
case tgapi.InputRichBlockPullQuotation:
if err := expectBlockType(el.Type, tgapi.InputRichTypePullQuotation); err != nil {
return err
}
if err := v.text(el.Text, depth+1); err != nil {
return err
}
return v.textValue(el.Credit, depth+1)
case tgapi.InputRichBlockCollage:
if err := expectBlockType(el.Type, tgapi.InputRichTypeCollage); err != nil {
return err
}
if err := v.caption(el.Caption, depth+1); err != nil {
return err
}
return v.nested(el.Blocks, depth+1)
case tgapi.InputRichBlockSlideshow:
if err := expectBlockType(el.Type, tgapi.InputRichTypeSlideshow); err != nil {
return err
}
if err := v.caption(el.Caption, depth+1); err != nil {
return err
}
return v.nested(el.Blocks, depth+1)
case tgapi.InputRichBlockTable:
if err := expectBlockType(el.Type, tgapi.InputRichTypeTable); err != nil {
return err
}
if err := v.addBlocks(len(el.Cells)); err != nil {
return err
}
if err := v.textValue(el.Caption, depth+1); err != nil {
return err
}
for _, row := range el.Cells {
columns := 0
for _, cell := range row {
if err := validateTableCell(cell); err != nil {
return err
}
span := cell.ColSpan
if span < 1 {
span = 1
}
columns += span
if err := v.text(cell.Text, depth+1); err != nil {
return err
}
}
if columns > maxTableColumns {
return ErrRichTableTooWide
}
}
return nil
case tgapi.InputRichBlockDetails:
if err := expectBlockType(el.Type, tgapi.InputRichTypeDetails); err != nil {
return err
}
if err := v.text(el.Summary, depth+1); err != nil {
return err
}
return v.nested(el.Blocks, depth+1)
case tgapi.InputRichBlockMap:
if err := expectBlockType(el.Type, tgapi.InputRichTypeMap); err != nil {
return err
}
if err := validateMap(el); err != nil {
return err
}
return v.caption(el.Caption, depth+1)
case tgapi.InputRichBlockAnimation:
if err := expectBlockType(el.Type, tgapi.InputRichTypeAnimation); err != nil {
return err
}
if err := v.addMedia(el.Animation, tgapi.InputMediaTypeAnimation); err != nil {
return err
}
return v.caption(el.Caption, depth+1)
case tgapi.InputRichBlockAudio:
if err := expectBlockType(el.Type, tgapi.InputRichTypeAudio); err != nil {
return err
}
if err := v.addMedia(el.Audio, tgapi.InputMediaTypeAudio); err != nil {
return err
}
return v.caption(el.Caption, depth+1)
case tgapi.InputRichBlockPhoto:
if err := expectBlockType(el.Type, tgapi.InputRichTypePhoto); err != nil {
return err
}
if err := v.addMedia(el.Photo, tgapi.InputMediaTypePhoto); err != nil {
return err
}
return v.caption(el.Caption, depth+1)
case tgapi.InputRichBlockVideo:
if err := expectBlockType(el.Type, tgapi.InputRichTypeVideo); err != nil {
return err
}
if err := v.addMedia(el.Video, tgapi.InputMediaTypeVideo); err != nil {
return err
}
return v.caption(el.Caption, depth+1)
case tgapi.InputRichBlockVoiceNote:
if err := expectBlockType(el.Type, tgapi.InputRichTypeVoiceNote); err != nil {
return err
}
if err := v.addMedia(el.VoiceNote, tgapi.InputMediaTypeVoiceNote); err != nil {
return err
}
return v.caption(el.Caption, depth+1)
case tgapi.InputRichBlockThinking:
if !v.allowThinking {
return ErrRichThinkingDraftOnly
}
if err := expectBlockType(el.Type, tgapi.InputRichTypeThinking); err != nil {
return err
}
return v.text(el.Text, depth+1)
default:
return ErrRichUnknownTag
}
}
func (v *richValidator) textValue(text *tgapi.RichText, depth int) error {
if text == nil {
return nil
}
return v.text(*text, depth)
}