owpengram-server/internal/domain/media.go
A 599453a3c4 fix: harden channel rights and sticker compatibility
Co-authored-by: HSgram <3013954224@qq.com>
2026-07-01 22:38:13 +08:00

868 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package domain
import (
"path/filepath"
"strings"
)
// 本文件定义媒体相关的业务值对象(文档、照片、贴纸集、可用 reaction、消息媒体
// 这些类型完全不依赖 tg.*rpc 层负责 domain↔tg 转换。
//
// 字段带 json tag 是为了 store 层可直接 json.Marshal 落 JSONB消息 media 快照、
// 文档/照片元数据)。它们是协议无关的纯数据,不是 tg 生成类型。
// MediaBackend 标识 blob 字节实际存放后端。第一阶段只有本地磁盘。
type MediaBackend string
const (
// MediaBackendLocalFS 表示 blob 字节存在本地磁盘object_key 为相对路径)。
MediaBackendLocalFS MediaBackend = "localfs"
)
// FileBlob 是一个可下载的二进制对象的索引项location_key → 后端/对象键/大小/mime。
// 真正的字节由 blob backend 按 ObjectKey 读写;本结构只描述定位与元数据。
type FileBlob struct {
// LocationKey 是稳定的逻辑定位键,由 getFile 的 InputFileLocation 推导:
// doc:<id> 文档主体
// doc:<id>:<type> 文档缩略图PhotoSize type
// photo:<id>:<type> 照片某尺寸
LocationKey string `json:"location_key"`
Backend MediaBackend `json:"backend"`
ObjectKey string `json:"object_key"`
Size int64 `json:"size"`
SHA256 []byte `json:"sha256,omitempty"`
MimeType string `json:"mime_type,omitempty"`
}
// UploadPart 是 upload.saveFilePart/saveBigFilePart 累积的一个分片元数据。
// 分片字节不进 PGPG 只记录 object_key/size/hash组装后清理 metadata 与临时对象。
type UploadPart struct {
OwnerUserID int64
FileID int64
Part int
TotalParts int // big file 已知总数small file 为 0
Big bool
Backend MediaBackend
ObjectKey string
Size int64
SHA256 []byte
}
// UploadPartUsage 描述某用户当前尚未组装的上传分片占用。
type UploadPartUsage struct {
Bytes int64
Parts int
Files int
}
// UploadPartSlot 描述某个 (owner,file_id,part) 槽位的当前状态,用于判断重试覆盖的配额增量。
type UploadPartSlot struct {
ExistingBytes int64
ObjectKey string
FileParts int
Found bool
}
// UploadPartQuota 限制某用户 in-flight 上传分片占用;字段 <=0 表示该维度不限制。
type UploadPartQuota struct {
MaxBytes int64
MaxParts int
MaxFiles int
}
// UploadedFileRef 引用一个客户端已通过 upload.saveFilePart(Big) 上传完毕的文件。
// rpc 层从 tg.InputFile/InputFileBig 转换得到files 服务据此组装 blob。
type UploadedFileRef struct {
OwnerUserID int64
FileID int64
Parts int
Name string
Big bool
MD5 string // small file 客户端 md5_checksumhex可校验big file 为空
}
// DocumentSpec 描述从上传文件创建 Document 的元数据(来自 InputMediaUploadedDocument
type DocumentSpec struct {
MimeType string
Attributes []DocumentAttribute
Thumb *UploadedFileRef // 可选缩略图上传,生成 doc:<id>:m
ForceFile bool
}
// FileDownloadRequest 是 upload.getFile 解析后的下载请求;
// LocationKey 由 rpc 层从 tg.InputFileLocation 推导doc:<id> / photo:<id>:<type> 等)。
type FileDownloadRequest struct {
LocationKey string
Offset int64
Limit int
}
// FileChunk 是 upload.getFile 返回的一段内容。
type FileChunk struct {
Bytes []byte
MimeType string
Total int64
}
// PhotoSizeKind 标识 PhotoSize 的 TL 变体。
type PhotoSizeKind string
const (
// PhotoSizeKindDefault → photoSize可下载type/w/h/size
PhotoSizeKindDefault PhotoSizeKind = "size"
// PhotoSizeKindStripped → photoStrippedSize内联字节秒开模糊图
PhotoSizeKindStripped PhotoSizeKind = "stripped"
// PhotoSizeKindCached → photoCachedSize内联字节 + w/h
PhotoSizeKindCached PhotoSizeKind = "cached"
// PhotoSizeKindPath → photoPathSize内联 svg path 字节,矢量占位)。
PhotoSizeKindPath PhotoSizeKind = "path"
// PhotoSizeKindProgressive → photoSizeProgressive渐进式 jpeg 多段大小)。
PhotoSizeKindProgressive PhotoSizeKind = "progressive"
// PhotoSizeKindVideo → photo.video_sizes 里的 videoSizeanimated profile video
PhotoSizeKindVideo PhotoSizeKind = "video"
// PhotoSizeKindVideoEmojiMarkup → photo.video_sizes 里的 videoSizeEmojiMarkup。
PhotoSizeKindVideoEmojiMarkup PhotoSizeKind = "video_emoji_markup"
// PhotoSizeKindVideoStickerMarkup → photo.video_sizes 里的 videoSizeStickerMarkup。
PhotoSizeKindVideoStickerMarkup PhotoSizeKind = "video_sticker_markup"
)
// PhotoSize 描述照片/缩略图的一种渲染尺寸。
type PhotoSize struct {
Kind PhotoSizeKind `json:"kind"`
Type string `json:"type"`
W int `json:"w,omitempty"`
H int `json:"h,omitempty"`
Size int `json:"size,omitempty"`
Bytes []byte `json:"bytes,omitempty"` // stripped/cached/path 内联内容
Sizes []int `json:"sizes,omitempty"` // progressive
VideoStartTs float64 `json:"video_start_ts,omitempty"`
EmojiID int64 `json:"emoji_id,omitempty"`
BackgroundColors []int `json:"background_colors,omitempty"`
StickerSetID int64 `json:"sticker_set_id,omitempty"`
StickerSetAccessHash int64 `json:"sticker_set_access_hash,omitempty"`
StickerSetShortName string `json:"sticker_set_short_name,omitempty"`
StickerSetSystemKey string `json:"sticker_set_system_key,omitempty"`
StickerID int64 `json:"sticker_id,omitempty"`
}
// Downloadable 表示该尺寸需要客户端通过 upload.getFile 拉取(而非内联字节)。
func (s PhotoSize) Downloadable() bool {
return s.Kind == PhotoSizeKindDefault || s.Kind == PhotoSizeKindProgressive || s.Kind == PhotoSizeKindVideo
}
// DocumentAttributeKind 标识 TL DocumentAttribute 变体。
type DocumentAttributeKind string
const (
DocAttrImageSize DocumentAttributeKind = "image_size"
DocAttrAnimated DocumentAttributeKind = "animated"
DocAttrSticker DocumentAttributeKind = "sticker"
DocAttrVideo DocumentAttributeKind = "video"
DocAttrAudio DocumentAttributeKind = "audio"
DocAttrFilename DocumentAttributeKind = "filename"
DocAttrCustomEmoji DocumentAttributeKind = "custom_emoji"
)
// DocumentAttribute 是主路径用到的 TL DocumentAttribute 变体的并集。
type DocumentAttribute struct {
Kind DocumentAttributeKind `json:"kind"`
// image_size / video / sticker box
W int `json:"w,omitempty"`
H int `json:"h,omitempty"`
// sticker / custom_emoji
Alt string `json:"alt,omitempty"`
Mask bool `json:"mask,omitempty"`
StickerSetID int64 `json:"sticker_set_id,omitempty"`
StickerSetAccessHash int64 `json:"sticker_set_access_hash,omitempty"`
Free bool `json:"free,omitempty"` // custom_emoji
TextColor bool `json:"text_color,omitempty"` // custom_emoji
// video
Duration float64 `json:"duration,omitempty"`
RoundMessage bool `json:"round_message,omitempty"`
SupportsStreaming bool `json:"supports_streaming,omitempty"`
// audio
AudioDuration int `json:"audio_duration,omitempty"`
Voice bool `json:"voice,omitempty"`
Title string `json:"title,omitempty"`
Performer string `json:"performer,omitempty"`
Waveform []byte `json:"waveform,omitempty"`
// filename
FileName string `json:"file_name,omitempty"`
}
// Document 是已存储的 Telegram 文档贴纸、gif、文件、视频、音频、自定义 emoji……
type Document struct {
ID int64 `json:"id"`
AccessHash int64 `json:"access_hash"`
FileReference []byte `json:"file_reference,omitempty"`
Date int `json:"date,omitempty"`
MimeType string `json:"mime_type,omitempty"`
Size int64 `json:"size,omitempty"`
DCID int `json:"dc_id,omitempty"`
Attributes []DocumentAttribute `json:"attributes,omitempty"`
Thumbs []PhotoSize `json:"thumbs,omitempty"`
}
// StickerSetRef 返回该文档归属的贴纸集引用(若有 sticker/custom_emoji 属性)。
func (d Document) StickerSetRef() (id, accessHash int64, ok bool) {
for _, attr := range d.Attributes {
if attr.Kind == DocAttrSticker || attr.Kind == DocAttrCustomEmoji {
if attr.StickerSetID != 0 {
return attr.StickerSetID, attr.StickerSetAccessHash, true
}
}
}
return 0, 0, false
}
// IsMusic reports whether the document is a Telegram profile/shared-music song:
// documentAttributeAudio with voice=false. MIME type is intentionally not used
// as the source of truth because clients key their UI off the TL attribute.
func (d Document) IsMusic() bool {
for _, attr := range d.Attributes {
if attr.Kind == DocAttrAudio && !attr.Voice {
return true
}
}
return false
}
// IsSticker reports whether the document carries a sticker attribute
// (static / animated / video stickers all use documentAttributeSticker).
func (d Document) IsSticker() bool {
for _, attr := range d.Attributes {
if attr.Kind == DocAttrSticker {
return true
}
}
return false
}
func (d Document) IsCustomEmoji() bool {
for _, attr := range d.Attributes {
if attr.Kind == DocAttrCustomEmoji {
return true
}
}
return false
}
func (d Document) IsStickerLike() bool {
return d.IsSticker() || d.IsCustomEmoji()
}
// MaxStickerMaterialDocumentSize bounds uploaded documents that can be promoted
// into user-created sticker/custom emoji sets without server-side transcoding.
const MaxStickerMaterialDocumentSize int64 = 20 << 20
// IsStickerSetMaterial reports whether an existing Document can be used as an
// input item for stickers.createStickerSet/stickers.addStickerToSet. Already
// tagged sticker/custom emoji documents always pass. Untagged TGS/WebP/Lottie
// JSON uploads can be shaped later from their MIME/body; video uploads must
// already carry a video attribute because this layer does not transcode or
// probe containers.
func (d Document) IsStickerSetMaterial() bool {
if d.IsStickerLike() {
return true
}
if d.ID == 0 || d.AccessHash == 0 || d.Size > MaxStickerMaterialDocumentSize {
return false
}
switch d.stickerMaterialMimeOrExt() {
case "application/x-tgsticker", "image/webp", "application/json":
return true
case "video/webm", "video/mp4":
return d.hasDocumentAttribute(DocAttrVideo)
default:
return false
}
}
func (d Document) hasDocumentAttribute(kind DocumentAttributeKind) bool {
for _, attr := range d.Attributes {
if attr.Kind == kind {
return true
}
}
return false
}
// StickerSetMaterialMime returns the normalized sticker material MIME inferred
// from the document MIME type or a filename attribute.
func (d Document) StickerSetMaterialMime() string {
return d.stickerMaterialMimeOrExt()
}
func (d Document) stickerMaterialMimeOrExt() string {
mimeType := strings.ToLower(strings.TrimSpace(d.MimeType))
switch mimeType {
case "application/x-tgsticker", "image/webp", "video/webm", "video/mp4",
"application/json", "application/lottie+json", "text/json":
if mimeType == "application/lottie+json" || mimeType == "text/json" {
return "application/json"
}
return mimeType
}
for _, attr := range d.Attributes {
if attr.Kind != DocAttrFilename {
continue
}
switch strings.ToLower(filepath.Ext(strings.TrimSpace(attr.FileName))) {
case ".tgs":
return "application/x-tgsticker"
case ".webp":
return "image/webp"
case ".json":
return "application/json"
case ".webm":
return "video/webm"
case ".mp4":
return "video/mp4"
}
}
return mimeType
}
// IsGif reports whether the document is a savable GIF (documentAttributeAnimated).
func (d Document) IsGif() bool {
for _, attr := range d.Attributes {
if attr.Kind == DocAttrAnimated {
return true
}
}
return false
}
// Photo 是已存储的 Telegram 照片(头像或图片消息)。
type Photo struct {
ID int64 `json:"id"`
AccessHash int64 `json:"access_hash"`
FileReference []byte `json:"file_reference,omitempty"`
Date int `json:"date,omitempty"`
DCID int `json:"dc_id,omitempty"`
HasStickers bool `json:"has_stickers,omitempty"`
Sizes []PhotoSize `json:"sizes,omitempty"`
}
// MessageMediaKind 枚举消息可挂载的媒体载荷。
type MessageMediaKind string
const (
MessageMediaKindNone MessageMediaKind = ""
MessageMediaKindPhoto MessageMediaKind = "photo"
MessageMediaKindDocument MessageMediaKind = "document"
MessageMediaKindContact MessageMediaKind = "contact"
MessageMediaKindService MessageMediaKind = "service"
MessageMediaKindGeo MessageMediaKind = "geo"
MessageMediaKindVenue MessageMediaKind = "venue"
MessageMediaKindDice MessageMediaKind = "dice"
MessageMediaKindPoll MessageMediaKind = "poll"
MessageMediaKindGeoLive MessageMediaKind = "geo_live"
MessageMediaKindTodo MessageMediaKind = "todo"
MessageMediaKindStory MessageMediaKind = "story"
MessageMediaKindWebPage MessageMediaKind = "web_page"
)
// MessageTodoItem 是清单中的一项id 为列表内唯一正整数,客户端分配)。
type MessageTodoItem struct {
ID int `json:"id"`
Title string `json:"title"`
Entities []MessageEntity `json:"entities,omitempty"`
}
// MessageTodoCompletion 记录某项被谁在何时勾选完成。
type MessageTodoCompletion struct {
ID int `json:"id"`
CompletedBy int64 `json:"completed_by"`
Date int `json:"date"`
}
// MessageTodo 是待办清单messageMediaToDo。append/toggle 经 editMessage 媒体替换链路
// 整体更新快照(与 live location 同模式)。
type MessageTodo struct {
OthersCanAppend bool `json:"others_can_append,omitempty"`
OthersCanComplete bool `json:"others_can_complete,omitempty"`
Title string `json:"title"`
TitleEntities []MessageEntity `json:"title_entities,omitempty"`
Items []MessageTodoItem `json:"items"`
Completions []MessageTodoCompletion `json:"completions,omitempty"`
}
// MessageGeoPoint 是消息中携带的地理坐标点messageMediaGeo/Venue 共用)。
// AccessHash 在发送时随机生成,客户端会原样带回 upload.getWebFile 地图缩略请求。
type MessageGeoPoint struct {
Lat float64 `json:"lat"`
Long float64 `json:"long"`
AccessHash int64 `json:"access_hash,omitempty"`
AccuracyRadius int `json:"accuracy_radius,omitempty"`
}
// MessageVenue 是带场所信息的位置messageMediaVenue
type MessageVenue struct {
Geo MessageGeoPoint `json:"geo"`
Title string `json:"title"`
Address string `json:"address,omitempty"`
Provider string `json:"provider,omitempty"`
VenueID string `json:"venue_id,omitempty"`
VenueType string `json:"venue_type,omitempty"`
}
// MessageDice 是互动 emojimessageMediaDice。Value 在发送落库时由服务端
// 一次定值,此后对所有 viewer 与转发快照保持不变。
type MessageDice struct {
Emoticon string `json:"emoticon"`
Value int `json:"value"`
}
// MessageGeoLive 是实时位置messageMediaGeoLive。客户端按 message.date + Period
// 自行计算过期;停止共享 = editMessage 把 Period 改为已逝时长(立即过期),服务端无后台任务。
type MessageGeoLive struct {
Geo MessageGeoPoint `json:"geo"`
Heading int `json:"heading,omitempty"`
Period int `json:"period"`
ProximityNotificationRadius int `json:"proximity_notification_radius,omitempty"`
}
// MessageStory is a story shared as messageMediaStory. Peer+ID are the stable
// reference; Story is the viewer-visible snapshot captured when the message was
// sent so history/difference can replay without re-resolving the source story.
type MessageStory struct {
Peer Peer `json:"peer"`
ID int `json:"id"`
ViaMention bool `json:"via_mention,omitempty"`
Story *Story `json:"story,omitempty"`
}
// MessageWebPageState 区分链接预览的三种 TL 形态pendingwebPagePending
// 已知链接但预览尚未异步抓取完成、donewebPage已解析的预览卡片
// emptywebPageEmpty已解析但该链接无可展示内容
type MessageWebPageState string
const (
MessageWebPageStatePending MessageWebPageState = "pending"
MessageWebPageStateDone MessageWebPageState = "done"
MessageWebPageStateEmpty MessageWebPageState = "empty"
)
// MessageWebPage 是以 messageMediaWebPage 形式挂载的链接预览。URL 是稳定引用,
// 其余字段是服务端在解析时捕获的快照,使 history/difference 无需重新抓取即可回放
// (与 MessageStory 同模式。State 决定投影为 webPagePending / webPage / webPageEmpty。
//
// ForceLargeMedia/ForceSmallMedia/Manual/Safe 是 messageMediaWebPage 外层 wrapper
// 的标志位HasLargeMedia 是 webPage 本体的标志位。ID 必须在 pending→done 切换前后
// 保持稳定,客户端按 webPage id 关联占位与解析结果。
type MessageWebPage struct {
State MessageWebPageState `json:"state"`
ID int64 `json:"id"`
URL string `json:"url"`
DisplayURL string `json:"display_url,omitempty"`
Hash int `json:"hash,omitempty"`
Date int `json:"date,omitempty"`
Type string `json:"type,omitempty"`
SiteName string `json:"site_name,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Author string `json:"author,omitempty"`
Photo *Photo `json:"photo,omitempty"`
ForceLargeMedia bool `json:"force_large_media,omitempty"`
ForceSmallMedia bool `json:"force_small_media,omitempty"`
Manual bool `json:"manual,omitempty"`
Safe bool `json:"safe,omitempty"`
HasLargeMedia bool `json:"has_large_media,omitempty"`
}
// MessageServiceActionKind 标识私聊服务消息动作。
type MessageServiceActionKind string
const (
MessageServiceActionSuggestProfilePhoto MessageServiceActionKind = "suggest_profile_photo"
// MessageServiceActionPinMessage 映射 messageActionPinMessage
// pm_oneside 私聊置顶生成的服务消息,被置顶消息经 reply_to 指向。
MessageServiceActionPinMessage MessageServiceActionKind = "pin_message"
// MessageServiceActionPhoneCall 映射 messageActionPhoneCall私聊通话
// 结束(含 missed 超时后落历史的通话条目sender 恒为主叫。
MessageServiceActionPhoneCall MessageServiceActionKind = "phone_call"
// MessageServiceActionConferenceCall 映射 messageActionConferenceCall
// ad-hoc conference call 的私聊邀请/状态服务消息。
MessageServiceActionConferenceCall MessageServiceActionKind = "conference_call"
// MessageServiceActionBotAllowed 映射 messageActionBotAllowed用户授权
// bot 后在 bot 私聊中留下的服务消息。
MessageServiceActionBotAllowed MessageServiceActionKind = "bot_allowed"
// MessageServiceActionWebViewDataSent 映射 messageActionWebViewDataSent*
//simple webview 把 data 回传给 bot 后在私聊中留下的服务消息。
MessageServiceActionWebViewDataSent MessageServiceActionKind = "web_view_data_sent"
// MessageServiceActionRequestedPeer 映射 messageActionRequestedPeer*:用户
// 响应 bot 的 request-peer 按钮后,把所选 peer 作为可恢复服务消息发给 bot。
MessageServiceActionRequestedPeer MessageServiceActionKind = "requested_peer"
// MessageServiceActionSetChatTheme 映射 messageActionSetChatTheme
// 私聊双方共享的 chat theme token 变更。
MessageServiceActionSetChatTheme MessageServiceActionKind = "set_chat_theme"
// MessageServiceActionStarGift 映射 messageActionStarGift收到一份 Star 礼物。
// 礼物快照(贴纸/星价)内嵌在 action 里,收礼人无需额外拉取即可渲染气泡。
MessageServiceActionStarGift MessageServiceActionKind = "star_gift"
)
// MessagePhoneCallAction 是 messageActionPhoneCall 的协议中立载荷。
// Reason 取值同 PhoneCallDiscardReasonDuration 仅通话真正建立后非零。
type MessagePhoneCallAction struct {
CallID int64 `json:"call_id"`
Reason string `json:"reason,omitempty"`
Duration int `json:"duration,omitempty"`
Video bool `json:"video,omitempty"`
}
// MessageConferenceCallAction 是 messageActionConferenceCall 的协议中立载荷。
type MessageConferenceCallAction struct {
CallID int64 `json:"call_id"`
Missed bool `json:"missed,omitempty"`
Active bool `json:"active,omitempty"`
Video bool `json:"video,omitempty"`
Duration int `json:"duration,omitempty"`
OtherParticipants []Peer `json:"other_participants,omitempty"`
}
// MessageBotAllowedAction 是 messageActionBotAllowed 的协议中立载荷。
type MessageBotAllowedAction struct {
AttachMenu bool `json:"attach_menu,omitempty"`
FromRequest bool `json:"from_request,omitempty"`
Domain string `json:"domain,omitempty"`
}
const (
MaxWebViewDataButtonTextLen = MaxBotMenuButtonTextLen
MaxWebViewDataPayloadLen = MaxMessageTextLength
)
// MessageWebViewDataAction 是 messageActionWebViewDataSent* 的协议中立载荷。
type MessageWebViewDataAction struct {
ButtonText string `json:"button_text"`
Data string `json:"data,omitempty"`
}
// MessageRequestedPeerAction 是 messageActionRequestedPeer* 的协议中立载荷。
type MessageRequestedPeerAction struct {
ButtonID int `json:"button_id"`
Peers []Peer `json:"peers"`
}
// MessageServiceAction 是私聊服务消息动作的协议中立表示。
type MessageServiceAction struct {
Kind MessageServiceActionKind `json:"kind"`
Photo *Photo `json:"photo,omitempty"`
Call *MessagePhoneCallAction `json:"call,omitempty"`
ConferenceCall *MessageConferenceCallAction `json:"conference_call,omitempty"`
BotAllowed *MessageBotAllowedAction `json:"bot_allowed,omitempty"`
WebViewData *MessageWebViewDataAction `json:"web_view_data,omitempty"`
RequestedPeer *MessageRequestedPeerAction `json:"requested_peer,omitempty"`
ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"`
StarGift *MessageStarGiftAction `json:"star_gift,omitempty"`
}
// MessageStarGiftAction 是 messageActionStarGift 的协议中立载荷:内嵌礼物快照(贴纸/星价)
// 使收礼人无需额外拉取即可渲染。PeerUserID/PeerChannelID 为收礼方NameHidden 时下发不暴露 from。
type MessageStarGiftAction struct {
GiftID int64 `json:"gift_id"`
Stars int64 `json:"stars"`
ConvertStars int64 `json:"convert_stars,omitempty"`
Title string `json:"title,omitempty"`
Sticker *Document `json:"sticker,omitempty"`
Message string `json:"message,omitempty"`
FromUserID int64 `json:"from_user_id,omitempty"`
PeerUserID int64 `json:"peer_user_id,omitempty"`
PeerChannelID int64 `json:"peer_channel_id,omitempty"`
SavedID int64 `json:"saved_id,omitempty"`
NameHidden bool `json:"name_hidden,omitempty"`
Saved bool `json:"saved,omitempty"`
Converted bool `json:"converted,omitempty"`
}
// MessageMedia 是一条消息媒体载荷的业务表示(落库为消息行上的 JSONB 快照)。
type MessageMedia struct {
Kind MessageMediaKind `json:"kind"`
Photo *Photo `json:"photo,omitempty"`
Document *Document `json:"document,omitempty"`
Contact *MessageContact `json:"contact,omitempty"`
ServiceAction *MessageServiceAction `json:"service_action,omitempty"`
Geo *MessageGeoPoint `json:"geo,omitempty"`
Venue *MessageVenue `json:"venue,omitempty"`
Dice *MessageDice `json:"dice,omitempty"`
Poll *MessagePoll `json:"poll,omitempty"`
GeoLive *MessageGeoLive `json:"geo_live,omitempty"`
Todo *MessageTodo `json:"todo,omitempty"`
Story *MessageStory `json:"story,omitempty"`
WebPage *MessageWebPage `json:"web_page,omitempty"`
Spoiler bool `json:"spoiler,omitempty"`
TTLSeconds int `json:"ttl_seconds,omitempty"`
Nopremium bool `json:"nopremium,omitempty"`
Voice bool `json:"voice,omitempty"`
Round bool `json:"round,omitempty"`
Video bool `json:"video,omitempty"`
// InvertMedia 映射 message.invert_media媒体典型为链接预览渲染在文本上方。
// 存于媒体快照而非消息行,避免新增消息表列;读时投影为 tg.Message.invert_media。
InvertMedia bool `json:"invert_media,omitempty"`
}
// IsMusic reports whether the message media is a music document rather than a
// voice note, video, or generic file.
func (m *MessageMedia) IsMusic() bool {
return m != nil &&
m.Kind == MessageMediaKindDocument &&
m.Document != nil &&
!m.Voice &&
m.Document.IsMusic()
}
// MessageContact is a shared contact card attached to a message.
type MessageContact struct {
PhoneNumber string `json:"phone_number"`
FirstName string `json:"first_name"`
LastName string `json:"last_name,omitempty"`
Vcard string `json:"vcard,omitempty"`
UserID int64 `json:"user_id,omitempty"`
}
// IsZero 表示无媒体(用于落库时跳过空快照、转换时回退 MessageMediaEmpty
func (m *MessageMedia) IsZero() bool {
return m == nil || m.Kind == MessageMediaKindNone
}
// HasUnreadPayload 表示该媒体是否参与 media_unread"未听")状态。
// 只有 voice/round 才有此语义:客户端只为它们渲染未听标记并上报
// readMessageContentsphoto/document 置位只会留下永不清除的脏状态。
func (m *MessageMedia) HasUnreadPayload() bool {
if m.IsZero() || m.Kind != MessageMediaKindDocument {
return false
}
return m.Voice || m.Round
}
// StickerPack 是 emoji→文档 id 的映射条目messages.stickerSet.packs
type StickerPack struct {
Emoticon string `json:"emoticon"`
DocumentIDs []int64 `json:"document_ids"`
}
type StickerKeyword struct {
DocumentID int64 `json:"document_id"`
Keywords []string `json:"keywords"`
}
const (
// StickerSetSystemKeyEmojiDefaultStatuses 是 inputStickerSetEmojiDefaultStatuses
// 对应的系统集标识premium 用户 emoji status 选择器的"默认状态"主体集
// messages.getStickerSet 与 account.getDefaultEmojiStatuses 共用)。
StickerSetSystemKeyEmojiDefaultStatuses = "emoji_default_statuses"
// StickerSetSystemKeyEmojiDefaultTopicIcons 对应论坛 topic 默认图标系统集。
StickerSetSystemKeyEmojiDefaultTopicIcons = "emoji_default_topic_icons"
// StickerSetSystemKeyPremiumGifts 对应 premium gifts 系统集。
StickerSetSystemKeyPremiumGifts = "premium_gifts"
)
// StickerSetKind 区分贴纸集用途(影响 getAllStickers / getEmojiStickers 归类)。
type StickerSetKind string
const (
StickerSetKindStickers StickerSetKind = "stickers"
StickerSetKindEmoji StickerSetKind = "emoji"
StickerSetKindMasks StickerSetKind = "masks"
// StickerSetKindSystem 是 TDesktop 通过 InputStickerSetDice/AnimatedEmoji 等系统集请求的内置集。
StickerSetKindSystem StickerSetKind = "system"
)
// StickerSet 是贴纸/自定义 emoji 集的元数据 + 有序文档 id。
type StickerSet struct {
ID int64 `json:"id"`
AccessHash int64 `json:"access_hash"`
ShortName string `json:"short_name"`
Title string `json:"title"`
Count int `json:"count"`
Hash int `json:"hash"`
Kind StickerSetKind `json:"set_kind"`
Official bool `json:"official,omitempty"`
Animated bool `json:"animated,omitempty"`
Videos bool `json:"videos,omitempty"`
Emojis bool `json:"emojis,omitempty"`
Masks bool `json:"masks,omitempty"`
TextColor bool `json:"text_color,omitempty"`
Creator bool `json:"creator,omitempty"`
CreatorUserID int64 `json:"creator_user_id,omitempty"`
Installed bool `json:"installed,omitempty"`
Archived bool `json:"archived,omitempty"`
Deleted bool `json:"deleted,omitempty"`
InstalledDate int `json:"installed_date,omitempty"`
ThumbDocumentID int64 `json:"thumb_document_id,omitempty"`
Thumbs []PhotoSize `json:"thumbs,omitempty"`
ThumbDCID int `json:"thumb_dc_id,omitempty"`
ThumbVersion int `json:"thumb_version,omitempty"`
DocumentIDs []int64 `json:"document_ids,omitempty"`
Packs []StickerPack `json:"packs,omitempty"`
Keywords []StickerKeyword `json:"keywords,omitempty"`
SortOrder int `json:"sort_order,omitempty"`
Software string `json:"software,omitempty"`
// SystemKey 是 TDesktop 系统集的稳定标识(如 "animated_emoji"、"dice:🎲"),用于 InputStickerSet* 路由。
SystemKey string `json:"system_key,omitempty"`
}
const (
MinStickerSetShortNameLen = 5
MaxStickerSetShortNameLen = 32
MaxStickerSetTitleLen = 64
MaxStickerSetItems = 120
MaxStickerSetKeywords = 20
MaxStickerSetKeywordLen = 32
)
type StickerSetItemInput struct {
DocumentID int64
DocumentAccessHash int64
Emoji string
Keywords string
}
type CreateStickerSetRequest struct {
CreatorUserID int64
Title string
ShortName string
Kind StickerSetKind
TextColor bool
ThumbDocumentID int64
ThumbAccessHash int64
Items []StickerSetItemInput
Software string
Date int
}
// ProfilePhotoKind distinguishes a user's real profile photo from the fallback
// public photo shown when privacy hides the real one.
type ProfilePhotoKind string
const (
ProfilePhotoKindProfile ProfilePhotoKind = "profile"
ProfilePhotoKindFallback ProfilePhotoKind = "fallback"
)
// ProfilePhotoRef 是渲染头像所需的最小信息(当前 profile/fallback/personal photo
type ProfilePhotoRef struct {
PhotoID int64
DCID int
Stripped []byte // photoStrippedSize 内联缩略图,可空
Personal bool // true 表示 viewer 私有联系人头像
HasVideo bool // true 表示 photo.video_sizes 非空,需输出 userProfilePhoto.has_video
}
// StrippedFromSizes 从照片尺寸列表里取出 stripped 缩略图字节(用于 UserProfilePhoto/ChatPhoto 占位)。
func StrippedFromSizes(sizes []PhotoSize) []byte {
for _, s := range sizes {
if s.Kind == PhotoSizeKindStripped {
return s.Bytes
}
}
return nil
}
// PhotoHasVideo 从照片尺寸快照推导 UserProfilePhoto.has_video。
func PhotoHasVideo(sizes []PhotoSize) bool {
for _, s := range sizes {
switch s.Kind {
case PhotoSizeKindVideo, PhotoSizeKindVideoEmojiMarkup, PhotoSizeKindVideoStickerMarkup:
return true
}
}
return false
}
// StickerSetRefKind 标识 InputStickerSet 的解析方式。
type StickerSetRefKind string
const (
StickerSetRefByID StickerSetRefKind = "id"
StickerSetRefByShortName StickerSetRefKind = "short_name"
StickerSetRefBySystem StickerSetRefKind = "system"
)
// StickerSetRef 是 rpc 层从 tg.InputStickerSet 转换得到的贴纸集引用。
type StickerSetRef struct {
Kind StickerSetRefKind
ID int64
AccessHash int64
ShortName string
SystemKey string
}
// AvailableReaction 描述 messages.getAvailableReactions 的一项(真实资源由文档 id 引用)。
type AvailableReaction struct {
Reaction string `json:"reaction"`
Title string `json:"title"`
Inactive bool `json:"inactive,omitempty"`
Premium bool `json:"premium,omitempty"`
StaticIconID int64 `json:"static_icon_id,omitempty"`
AppearAnimationID int64 `json:"appear_animation_id,omitempty"`
SelectAnimationID int64 `json:"select_animation_id,omitempty"`
ActivateAnimationID int64 `json:"activate_animation_id,omitempty"`
EffectAnimationID int64 `json:"effect_animation_id,omitempty"`
AroundAnimationID int64 `json:"around_animation_id,omitempty"`
CenterIconID int64 `json:"center_icon_id,omitempty"`
Order int `json:"order,omitempty"`
}
// AvailableEffect 是一条消息发送特效messages.getAvailableEffects。引用三个文档:
// 选择器静态图标 / 气泡上的特效贴纸 / 全屏特效动画(后两者可为 0)。属全局静态目录,
// 服务端 seed 后常驻内存。
type AvailableEffect struct {
ID int64 `json:"id"`
Emoticon string `json:"emoticon"`
StaticIconID int64 `json:"static_icon_id,omitempty"`
EffectStickerID int64 `json:"effect_sticker_id"`
EffectAnimationID int64 `json:"effect_animation_id,omitempty"`
PremiumRequired bool `json:"premium_required,omitempty"`
Order int `json:"order,omitempty"`
}
// DocumentIDs 收集该 effect 引用的全部文档 id去零去重
func (e AvailableEffect) DocumentIDs() []int64 {
return dedupNonZeroIDs(e.StaticIconID, e.EffectStickerID, e.EffectAnimationID)
}
// DocumentIDs 收集该 reaction 引用的全部文档 id去零去重便于批量加载
func (r AvailableReaction) DocumentIDs() []int64 {
raw := []int64{
r.StaticIconID, r.AppearAnimationID, r.SelectAnimationID,
r.ActivateAnimationID, r.EffectAnimationID, r.AroundAnimationID, r.CenterIconID,
}
out := make([]int64, 0, len(raw))
seen := make(map[int64]struct{}, len(raw))
for _, id := range raw {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}
// dedupNonZeroIDs 返回去零去重后的 id 列表(保序)。
func dedupNonZeroIDs(ids ...int64) []int64 {
out := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}