FILE / ScuroNeko/Laniakea
tgrich/validate.go
Исходный файл и его история в репозитории.
(fix): harden concurrent lifecycle (tests): add regression coverage (doc): update v1.2 guidance
482 lines
13 KiB
Go
482 lines
13 KiB
Go
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:
|
|
if depth >= maxRichDepth {
|
|
return ErrRichNestingTooDeep
|
|
}
|
|
for _, item := range el {
|
|
if err := v.text(item, depth+1); 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, 0)
|
|
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, depth int) (string, error) {
|
|
if depth > maxRichDepth {
|
|
return "", ErrRichNestingTooDeep
|
|
}
|
|
switch el := text.(type) {
|
|
case nil:
|
|
return "", nil
|
|
case tgapi.RichTextPlain:
|
|
return string(el), nil
|
|
case tgapi.RichTextArray:
|
|
if depth >= maxRichDepth {
|
|
return "", ErrRichNestingTooDeep
|
|
}
|
|
var result strings.Builder
|
|
for _, item := range el {
|
|
part, err := visibleRichText(item, depth+1)
|
|
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, depth+1)
|
|
case tgapi.RichTextURL:
|
|
return visibleRichText(el.Text, depth+1)
|
|
case tgapi.RichTextEmailAddress:
|
|
return visibleRichText(el.Text, depth+1)
|
|
case tgapi.RichTextPhoneNumber:
|
|
return visibleRichText(el.Text, depth+1)
|
|
case tgapi.RichTextBankCardNumber:
|
|
return visibleRichText(el.Text, depth+1)
|
|
case tgapi.RichTextMention:
|
|
return visibleRichText(el.Text, depth+1)
|
|
case tgapi.RichTextHashtag:
|
|
return visibleRichText(el.Text, depth+1)
|
|
case tgapi.RichTextCashtag:
|
|
return visibleRichText(el.Text, depth+1)
|
|
case tgapi.RichTextBotCommand:
|
|
return visibleRichText(el.Text, depth+1)
|
|
case tgapi.RichTextAnchorLink:
|
|
return visibleRichText(el.Text, depth+1)
|
|
case tgapi.RichTextReference:
|
|
return visibleRichText(el.Text, depth+1)
|
|
case tgapi.RichTextReferenceLink:
|
|
return visibleRichText(el.Text, depth+1)
|
|
case tgapi.RichTextDateTime:
|
|
return visibleRichText(el.Text, depth+1)
|
|
case tgapi.RichTextTextMention:
|
|
return visibleRichText(el.Text, depth+1)
|
|
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)
|
|
}
|