feat: sync monoforum and collectible emoji status

Sync telesrv bd15657 (feat(account): implement collectible emoji status).

Skipped telesrv docs changes per public sync rules.
This commit is contained in:
A 2026-07-19 20:37:10 +08:00
parent edb7057757
commit 0c99ae0a9d
91 changed files with 4061 additions and 693 deletions

View file

@ -603,17 +603,21 @@ type ChannelMessage struct {
From Peer
SendAs *Peer
// SavedPeer 是 monoforum 私信子会话分组键(按订阅者分组);普通频道消息为零值。
SavedPeer Peer
Date int
EditDate int
Post bool
Silent bool
NoForwards bool
Body string
Entities []MessageEntity
ReplyTo *MessageReply
Forward *MessageForward
ViaBotID int64
SavedPeer Peer
// SuggestedPost 是频道私信建议投稿的不可变发送快照;普通频道消息为 nil。
SuggestedPost *SuggestedPost
// PaidMessageStars 是本条频道私信实际扣除的 Stars;管理员免费回复及普通频道消息为 0。
PaidMessageStars int64
Date int
EditDate int
Post bool
Silent bool
NoForwards bool
Body string
Entities []MessageEntity
ReplyTo *MessageReply
Forward *MessageForward
ViaBotID int64
// GroupedID 相册分组 id(sendMultiMedia 同组共享非零值,非相册恒 0)。
GroupedID int64
ReplyMarkup *MessageReplyMarkup
@ -1459,7 +1463,15 @@ type SendMonoforumMessageRequest struct {
IdempotencyPreflighted bool
Message string
Entities []MessageEntity
Date int
Media *MessageMedia
ReplyTo *MessageReply
Silent bool
NoForwards bool
SuggestedPost *SuggestedPost
// AllowPaidStars 是客户端授权的最高可扣金额;实际扣款取频道当前价格,绝不按授权上限扣款。
AllowPaidStars int64
ClearDraft bool
Date int
}
// ChannelSendReplayRequest addresses either a regular channel send (SavedPeer is zero) or one
@ -1579,6 +1591,8 @@ type SendChannelMessageResult struct {
Event ChannelUpdateEvent
Recipients []int64
Duplicate bool
// SenderStarsBalance 仅在实际发生 paid-message 借记时返回;RPC 只向发件人投影余额更新。
SenderStarsBalance *StarsBalance
// ReplayDeleteEvent is the existing durable channel delete event paired
// with a deleted exact-random_id replay. It must be returned only to the
// caller echo and must never be fanned out as a fresh event.

View file

@ -6,37 +6,38 @@ import (
)
var (
ErrChannelInvalid = errors.New("channel invalid")
ErrChannelPrivate = errors.New("channel private")
ErrChannelTitleInvalid = errors.New("channel title invalid")
ErrChannelUserBanned = errors.New("user banned in channel")
ErrChannelWriteForbidden = errors.New("chat write forbidden")
ErrChannelAdminRequired = errors.New("chat admin required")
ErrChannelNotModified = errors.New("chat not modified")
ErrChannelForumMissing = errors.New("channel forum missing")
ErrLinkNotModified = errors.New("discussion link not modified")
ErrChatDiscussionUnallowed = errors.New("chat discussion unallowed")
ErrBroadcastIDInvalid = errors.New("broadcast id invalid")
ErrMegagroupIDInvalid = errors.New("megagroup id invalid")
ErrMegagroupPrehistoryHidden = errors.New("megagroup prehistory hidden")
ErrChatPublicRequired = errors.New("chat public required")
ErrChannelUserCreator = errors.New("channel user creator")
ErrChannelRightForbidden = errors.New("channel right forbidden")
ErrPersistentTimestamp = errors.New("persistent timestamp invalid")
ErrInviteHashEmpty = errors.New("invite hash empty")
ErrInviteHashInvalid = errors.New("invite hash invalid")
ErrInviteHashExpired = errors.New("invite hash expired")
ErrInvitePermanent = errors.New("chat invite permanent")
ErrInviteRevokedMissing = errors.New("invite revoked missing")
ErrInviteRequestSent = errors.New("invite request sent")
ErrHideRequesterMissing = errors.New("hide requester missing")
ErrUsersTooMuch = errors.New("users too much")
ErrUserAlreadyParticipant = errors.New("user already participant")
ErrUserKicked = errors.New("user kicked")
ErrUserNotParticipant = errors.New("user not participant")
ErrBotGroupsBlocked = errors.New("bot groups blocked")
ErrReactionInvalid = errors.New("reaction invalid")
ErrReactionsTooMany = errors.New("reactions too many")
ErrChannelInvalid = errors.New("channel invalid")
ErrChannelPrivate = errors.New("channel private")
ErrChannelTitleInvalid = errors.New("channel title invalid")
ErrChannelUserBanned = errors.New("user banned in channel")
ErrChannelWriteForbidden = errors.New("chat write forbidden")
ErrChannelAdminRequired = errors.New("chat admin required")
ErrChannelNotModified = errors.New("chat not modified")
ErrChannelForumMissing = errors.New("channel forum missing")
ErrChannelMonoforumUnsupported = errors.New("channel monoforum unsupported")
ErrLinkNotModified = errors.New("discussion link not modified")
ErrChatDiscussionUnallowed = errors.New("chat discussion unallowed")
ErrBroadcastIDInvalid = errors.New("broadcast id invalid")
ErrMegagroupIDInvalid = errors.New("megagroup id invalid")
ErrMegagroupPrehistoryHidden = errors.New("megagroup prehistory hidden")
ErrChatPublicRequired = errors.New("chat public required")
ErrChannelUserCreator = errors.New("channel user creator")
ErrChannelRightForbidden = errors.New("channel right forbidden")
ErrPersistentTimestamp = errors.New("persistent timestamp invalid")
ErrInviteHashEmpty = errors.New("invite hash empty")
ErrInviteHashInvalid = errors.New("invite hash invalid")
ErrInviteHashExpired = errors.New("invite hash expired")
ErrInvitePermanent = errors.New("chat invite permanent")
ErrInviteRevokedMissing = errors.New("invite revoked missing")
ErrInviteRequestSent = errors.New("invite request sent")
ErrHideRequesterMissing = errors.New("hide requester missing")
ErrUsersTooMuch = errors.New("users too much")
ErrUserAlreadyParticipant = errors.New("user already participant")
ErrUserKicked = errors.New("user kicked")
ErrUserNotParticipant = errors.New("user not participant")
ErrBotGroupsBlocked = errors.New("bot groups blocked")
ErrReactionInvalid = errors.New("reaction invalid")
ErrReactionsTooMany = errors.New("reactions too many")
)
// SlowModeWaitError carries the remaining wait seconds for a channel slow mode violation.

View file

@ -101,19 +101,42 @@ type DialogDraftWebPage struct {
Optional bool
}
type SuggestedPostPriceKind string
const (
SuggestedPostPriceStars SuggestedPostPriceKind = "stars"
SuggestedPostPriceTON SuggestedPostPriceKind = "ton"
)
// SuggestedPostPrice is either a decimal Stars amount or a nanotons amount.
type SuggestedPostPrice struct {
Kind SuggestedPostPriceKind
Amount int64
Nanos int
}
// SuggestedPost is the domain-only snapshot shared by a monoforum message and its cloud draft.
type SuggestedPost struct {
Accepted bool
Rejected bool
Price *SuggestedPostPrice
ScheduleDate int
}
// DialogDraft is a cloud draft for one peer/topic, expressed only in domain types.
type DialogDraft struct {
Peer Peer
TopMessageID int
Date int
NoWebpage bool
InvertMedia bool
Message string
Entities []MessageEntity
ReplyTo *MessageReply
WebPage *DialogDraftWebPage
Effect int64
RichMessage *MessageRichMessage
Peer Peer
TopMessageID int
Date int
NoWebpage bool
InvertMedia bool
Message string
Entities []MessageEntity
ReplyTo *MessageReply
WebPage *DialogDraftWebPage
Effect int64
SuggestedPost *SuggestedPost
RichMessage *MessageRichMessage
}
// Empty reports whether this draft should clear the cloud draft slot.
@ -126,6 +149,7 @@ func (d DialogDraft) Empty() bool {
(d.ReplyTo == nil || replyOnlyTopic) &&
d.WebPage == nil &&
d.Effect == 0 &&
d.SuggestedPost == nil &&
d.RichMessage.IsZero()
}

View file

@ -238,6 +238,29 @@ type UniqueStarGift struct {
CreatedAt time.Time
}
// CollectibleEmojiStatus projects an immutable unique gift into the complete
// status shape consumed by Telegram clients. Ownership/lifecycle validation
// is intentionally performed by the caller because it depends on the actor;
// this helper validates only the immutable renderable facts.
func CollectibleEmojiStatus(g UniqueStarGift) (EmojiStatusCollectible, bool) {
status := EmojiStatusCollectible{
CollectibleID: g.ID,
Title: g.Title,
Slug: g.Slug,
CenterColor: g.Backdrop.CenterColor,
EdgeColor: g.Backdrop.EdgeColor,
PatternColor: g.Backdrop.PatternColor,
TextColor: g.Backdrop.TextColor,
}
if g.Model.Document != nil {
status.DocumentID = g.Model.Document.ID
}
if g.Pattern.Document != nil {
status.PatternDocumentID = g.Pattern.Document.ID
}
return status, status.Valid()
}
type StarGiftCurrency string
const (

View file

@ -3,6 +3,7 @@ package domain
import (
"encoding/base64"
"errors"
"fmt"
"strconv"
)
@ -31,8 +32,9 @@ const (
StarsReasonGiftAuction StarsTransactionReason = "gift_auction"
StarsReasonGiftPrepaid StarsTransactionReason = "gift_prepaid_upgrade"
StarsReasonGiftDrop StarsTransactionReason = "gift_drop_original_details"
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁
StarsReasonPaidMessage StarsTransactionReason = "paid_message" // 频道 Direct Message 花费
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
)
// StarsTransaction 是一条账本流水。amount 带符号:贷记 > 0(含 refund/收取),借记 < 0。
@ -98,6 +100,17 @@ var (
ErrStarsInvalidAmount = errors.New("stars: invalid amount")
)
// StarsPaymentRequiredError reports the minimum paid-message authorization the
// sender must include in allow_paid_stars. The authorization is a ceiling; the
// ledger debits only the channel's current configured price.
type StarsPaymentRequiredError struct {
Stars int64
}
func (e *StarsPaymentRequiredError) Error() string {
return fmt.Sprintf("stars: allow payment required: %d", e.Stars)
}
// EncodeStarsCursor 把 keyset 游标(最后一条流水 id)编码为客户端不透明字符串。
func EncodeStarsCursor(id int64) string {
return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10)))

View file

@ -29,8 +29,11 @@ const (
UpdateEventPeerStoryBlocked UpdateEventType = "peer_story_blocked"
// UpdateEventUserPhone 映射 updateUserPhone。它是账号绝对状态更新,TL
// 构造器不携 pts;事件仍占账号 pts,以便其它设备在线/离线保持同一水位。
UpdateEventUserPhone UpdateEventType = "user_phone"
UpdateEventDeleteMessages UpdateEventType = "delete_messages"
UpdateEventUserPhone UpdateEventType = "user_phone"
// UpdateEventUserEmojiStatus carries the exact immutable status snapshot.
// It consumes account pts even though updateUserEmojiStatus has no pts.
UpdateEventUserEmojiStatus UpdateEventType = "user_emoji_status"
UpdateEventDeleteMessages UpdateEventType = "delete_messages"
// UpdateEventPinnedMessages 映射 updatePinnedMessages(私聊置顶/取消
// 置顶;MessageIDs 是该 owner 自己视角的 box id,Bool 为 pinned)。
// TL 构造器自带账号 pts/pts_count,不属于 LacksWirePts。
@ -81,6 +84,7 @@ type UpdateEvent struct {
Peers []Peer
Bool bool
Phone string
EmojiStatus UserEmojiStatus
Settings PeerSettings
MessageIDs []int
MaxID int
@ -127,6 +131,7 @@ func (e UpdateEvent) LacksWirePts() bool {
UpdateEventPeerSettings,
UpdateEventPeerStoryBlocked,
UpdateEventUserPhone,
UpdateEventUserEmojiStatus,
UpdateEventDialogFilter,
UpdateEventDialogFilterOrder,
UpdateEventDialogFilters,

View file

@ -16,6 +16,73 @@ type PeerColor struct {
BackgroundEmojiID int64
}
// EmojiStatusCollectible is the immutable projection needed to render a
// collectible gift as an emoji status. The source of truth remains the owned
// UniqueStarGift; users store an immutable snapshot so every user projection,
// online update and offline difference observes the same shape without an
// RPC-layer lookup.
type EmojiStatusCollectible struct {
CollectibleID int64 `json:"collectible_id"`
DocumentID int64 `json:"document_id"`
Title string `json:"title"`
Slug string `json:"slug"`
PatternDocumentID int64 `json:"pattern_document_id"`
CenterColor int `json:"center_color"`
EdgeColor int `json:"edge_color"`
PatternColor int `json:"pattern_color"`
TextColor int `json:"text_color"`
}
// Empty reports whether no collectible status is present.
func (s EmojiStatusCollectible) Empty() bool {
return s == (EmojiStatusCollectible{})
}
// Valid enforces the complete collectible status shape. Partial snapshots
// are forbidden because clients would otherwise render a gradient without its
// model/pattern or be unable to resolve the collectible link.
func (s EmojiStatusCollectible) Valid() bool {
if s.CollectibleID <= 0 || s.DocumentID <= 0 || s.PatternDocumentID <= 0 ||
s.Title == "" || s.Slug == "" {
return false
}
for _, color := range []int{s.CenterColor, s.EdgeColor, s.PatternColor, s.TextColor} {
if color < 0 || color > 0xffffff {
return false
}
}
return true
}
// UserEmojiStatus is the protocol-neutral mutation value accepted by the user
// service/store boundary. Exactly one of a normal document or a complete
// collectible snapshot may be active; the zero value clears the status.
type UserEmojiStatus struct {
DocumentID int64 `json:"document_id"`
Until int `json:"until,omitempty"`
Collectible EmojiStatusCollectible `json:"collectible,omitempty"`
}
func (s UserEmojiStatus) Empty() bool {
return s.DocumentID == 0 && s.Collectible.Empty()
}
func (s UserEmojiStatus) Valid() bool {
if s.Until < 0 {
return false
}
if s.Empty() {
return s.Until == 0
}
if s.DocumentID <= 0 {
return false
}
if s.Collectible.Empty() {
return true
}
return s.Collectible.Valid() && s.DocumentID == s.Collectible.DocumentID
}
// Empty reports whether no explicit color/profile color state is set.
func (c PeerColor) Empty() bool {
return !c.HasColor && c.BackgroundEmojiID == 0
@ -47,9 +114,11 @@ type User struct {
PremiumUntil int
// EmojiStatusDocumentID / EmojiStatusUntil 是用户自定义 emoji status
//(premium 专属,account.updateEmojiStatus)。DocumentID==0 表示未设置;
// Until==0 表示永久。
EmojiStatusDocumentID int64
EmojiStatusUntil int
// Until==0 表示永久。EmojiStatusCollectible 非零时 DocumentID 必须等于
// collectible 的 model document id。
EmojiStatusDocumentID int64
EmojiStatusUntil int
EmojiStatusCollectible EmojiStatusCollectible
// Birthday 是用户公开生日(account.updateBirthday)。零值表示未设置。
Birthday Birthday
// PersonalChannelID 是资料页展示的「个人频道」(account.updatePersonalChannel);
@ -86,12 +155,21 @@ func (u User) PremiumActiveAt(now int64) bool {
// (已设置且未过期;Until==0 表示永久)。emoji status 是 premium 专属,到期
// 降级后即便列仍有残值也不再下发。
func (u User) EmojiStatusActiveAt(now int64) bool {
if !u.PremiumActiveAt(now) || u.EmojiStatusDocumentID == 0 {
if !u.PremiumActiveAt(now) || !u.EmojiStatus().Valid() || u.EmojiStatusDocumentID == 0 {
return false
}
return u.EmojiStatusUntil == 0 || int64(u.EmojiStatusUntil) > now
}
// EmojiStatus returns the complete status snapshot carried by this user.
func (u User) EmojiStatus() UserEmojiStatus {
return UserEmojiStatus{
DocumentID: u.EmojiStatusDocumentID,
Until: u.EmojiStatusUntil,
Collectible: u.EmojiStatusCollectible,
}
}
// DeletedTombstone strips every viewer-dependent or personally identifying
// field while preserving the immutable id and lifecycle audit facts.
func (u User) DeletedTombstone() User {