package entity import ( "bytes" "encoding/json" "fmt" "strings" "unicode/utf8" ) // Content is the in-memory message body. Persist only Marshal() bytes into messages.content. type Content struct { Type Type `json:"t"` ReplyTo int64 `json:"reply_to,omitempty"` AtAll bool `json:"at_all,omitempty"` AtUIDs []int64 `json:"at_uids,omitempty"` Text *Text `json:"text,omitempty"` Image *Image `json:"image,omitempty"` Voice *Voice `json:"voice,omitempty"` Video *Video `json:"video,omitempty"` Emoji *Emoji `json:"emoji,omitempty"` GIF *GIF `json:"gif,omitempty"` File *File `json:"file,omitempty"` Location *Location `json:"location,omitempty"` System *System `json:"system,omitempty"` } func NewText(body string) *Content { return &Content{Type: TypeText, Text: &Text{Body: body}} } func NewImage(img *Image) *Content { return &Content{Type: TypeImage, Image: img} } func NewVoice(v *Voice) *Content { return &Content{Type: TypeVoice, Voice: v} } func NewVideo(v *Video) *Content { return &Content{Type: TypeVideo, Video: v} } func NewEmoji(e *Emoji) *Content { return &Content{Type: TypeEmoji, Emoji: e} } func NewGIF(g *GIF) *Content { return &Content{Type: TypeGIF, GIF: g} } func NewFile(f *File) *Content { return &Content{Type: TypeFile, File: f} } func NewLocation(loc *Location) *Content { return &Content{Type: TypeLocation, Location: loc} } func NewSystem(s *System) *Content { return &Content{Type: TypeSystem, System: s} } // Marshal serializes content to JSON bytes for the BLOB column. It never returns a Go string. func (c *Content) Marshal() ([]byte, error) { if err := c.Validate(); err != nil { return nil, err } var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) if err := enc.Encode(c); err != nil { return nil, fmt.Errorf("content: %w", err) } b := bytes.TrimSuffix(buf.Bytes(), []byte{'\n'}) if len(b) > MaxContentBytes { return nil, fmt.Errorf("content: exceeds %d bytes", MaxContentBytes) } return b, nil } // UnmarshalContent decodes stored BLOB bytes. Rejects a plain UTF-8 string that is not JSON. func UnmarshalContent(raw []byte) (*Content, error) { if len(raw) == 0 { return nil, fmt.Errorf("content: empty") } if len(raw) > MaxContentBytes { return nil, fmt.Errorf("content: exceeds %d bytes", MaxContentBytes) } var c Content if err := json.Unmarshal(raw, &c); err != nil { return nil, fmt.Errorf("content: %w", err) } if err := c.Validate(); err != nil { return nil, err } return &c, nil } func (c *Content) Validate() error { if c == nil { return fmt.Errorf("content: empty") } if !c.Type.Valid() { return fmt.Errorf("content: unknown type %d", c.Type) } if c.ReplyTo < 0 { return fmt.Errorf("content: reply_to") } if err := validateMentions(c); err != nil { return err } got, n := c.populated() if n != 1 { return fmt.Errorf("content: type %d wants exactly one payload, got %d", c.Type, n) } if got != c.Type { return fmt.Errorf("content: type %d does not match payload %d", c.Type, got) } switch c.Type { case TypeText: return validateText(c.Text) case TypeImage: return validateMediaKey("image", c.Image.Key) case TypeVoice: return validateVoice(c.Voice) case TypeVideo: return validateMediaKey("video", c.Video.Key) case TypeEmoji: return validateEmoji(c.Emoji) case TypeGIF: return validateMediaKey("gif", c.GIF.Key) case TypeFile: return validateFile(c.File) case TypeLocation: return validateLocation(c.Location) case TypeSystem: return validateSystem(c.System) default: return fmt.Errorf("content: unknown type %d", c.Type) } } func (c *Content) populated() (Type, int) { n := 0 var t Type if c.Text != nil { n++ t = TypeText } if c.Image != nil { n++ t = TypeImage } if c.Voice != nil { n++ t = TypeVoice } if c.Video != nil { n++ t = TypeVideo } if c.Emoji != nil { n++ t = TypeEmoji } if c.GIF != nil { n++ t = TypeGIF } if c.File != nil { n++ t = TypeFile } if c.Location != nil { n++ t = TypeLocation } if c.System != nil { n++ t = TypeSystem } return t, n } func validateText(t *Text) error { body := strings.TrimSpace(t.Body) if body == "" { return fmt.Errorf("content: text empty") } if !utf8.ValidString(t.Body) { return fmt.Errorf("content: text not utf-8") } return nil } func validateMentions(c *Content) error { if c.Type == TypeSystem && (c.AtAll || len(c.AtUIDs) > 0) { return fmt.Errorf("content: mentions") } if len(c.AtUIDs) == 0 { return nil } seen := make(map[int64]struct{}, len(c.AtUIDs)) out := make([]int64, 0, len(c.AtUIDs)) for _, id := range c.AtUIDs { if id <= 0 { return fmt.Errorf("content: at_uids") } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} out = append(out, id) } if len(out) > MaxAtUIDs { return fmt.Errorf("content: at_uids") } c.AtUIDs = out return nil } func validateMediaKey(kind, key string) error { if strings.TrimSpace(key) == "" { return fmt.Errorf("content: %s key empty", kind) } return nil } func validateVoice(v *Voice) error { if err := validateMediaKey("voice", v.Key); err != nil { return err } if v.DurationMS < 0 { return fmt.Errorf("content: voice duration") } return nil } func validateEmoji(e *Emoji) error { if strings.TrimSpace(e.Pack) == "" || strings.TrimSpace(e.ID) == "" { return fmt.Errorf("content: emoji pack/id empty") } return nil } func validateFile(f *File) error { if err := validateMediaKey("file", f.Key); err != nil { return err } if strings.TrimSpace(f.Name) == "" { return fmt.Errorf("content: file name empty") } if f.Size < 0 { return fmt.Errorf("content: file size") } return nil } func validateLocation(l *Location) error { if l.Lat < -90 || l.Lat > 90 || l.Lng < -180 || l.Lng > 180 { return fmt.Errorf("content: location range") } return nil } func validateSystem(s *System) error { if strings.TrimSpace(s.Kind) == "" { return fmt.Errorf("content: system kind empty") } return nil } // ObjectKeys returns media object keys in this payload (image/voice/video/gif/file). func (c *Content) ObjectKeys() []string { if c == nil { return nil } var keys []string add := func(k string) { k = strings.TrimSpace(k) if k != "" { keys = append(keys, k) } } switch { case c.Image != nil: add(c.Image.Key) add(c.Image.ThumbKey) case c.Voice != nil: add(c.Voice.Key) case c.Video != nil: add(c.Video.Key) add(c.Video.CoverKey) case c.GIF != nil: add(c.GIF.Key) case c.File != nil: add(c.File.Key) } return keys } // ReplaceObjectKeys rewrites keys after CopyObject into perm/fav/. func (c *Content) ReplaceObjectKeys(mapping map[string]string) { if c == nil || len(mapping) == 0 { return } repl := func(s *string) { if s == nil { return } if n, ok := mapping[*s]; ok { *s = n } } if c.Image != nil { repl(&c.Image.Key) repl(&c.Image.ThumbKey) } if c.Voice != nil { repl(&c.Voice.Key) } if c.Video != nil { repl(&c.Video.Key) repl(&c.Video.CoverKey) } if c.GIF != nil { repl(&c.GIF.Key) } if c.File != nil { repl(&c.File.Key) } }