(new): rich message support
(fix): runtime reliability (tests): regression coverage (doc): v1.1 release notes
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user