Merge remote-tracking branch 'upstream/main' into merge-gramsrv-2965f5d

This commit is contained in:
onysd 2026-07-20 23:43:51 +03:00
commit ebb0be38d9
355 changed files with 44640 additions and 2320 deletions

View file

@ -174,7 +174,12 @@ func DefaultAccountReactionSettings() AccountReactionSettings {
}
// DefaultAccountTTLDays 是账号自毁默认期限(无显式设置时)。与历史固定回显一致。
const DefaultAccountTTLDays = 365
const (
DefaultAccountTTLDays = 365
// MaxAccountTTLDays prevents an untrusted int32 TL value from producing an
// out-of-range PostgreSQL interval/timestamp during deadline maintenance.
MaxAccountTTLDays = 3650
)
// GlobalPrivacy 是 globalPrivacySettings 的业务层表达(账号级隐私开关)。
// DisallowedGifts 依赖礼物资产模型(当前未实现),故不建模、保持默认。
@ -212,7 +217,7 @@ func DefaultAccountSettings() AccountSettings {
// NormalizedTTLDays 返回钳制后的账号自毁期限0/越界回落默认)。
func (s AccountSettings) NormalizedTTLDays() int {
if s.AccountTTLDays <= 0 {
if s.AccountTTLDays <= 0 || s.AccountTTLDays > MaxAccountTTLDays {
return DefaultAccountTTLDays
}
return s.AccountTTLDays

View file

@ -0,0 +1,99 @@
package domain
import (
"errors"
"time"
)
var (
ErrAccountDeleted = errors.New("account deleted")
ErrAccountDeletionForbidden = errors.New("account deletion forbidden")
ErrAccountDeletionHashInvalid = errors.New("account deletion hash invalid")
ErrAccountDeletionNotPending = errors.New("account deletion not pending")
)
// AccountDeletionSource is the single audited reason attached to a user
// tombstone. Different entry points share one execution and cleanup path.
type AccountDeletionSource string
const (
AccountDeletionManual AccountDeletionSource = "manual"
AccountDeletionForgotPassword AccountDeletionSource = "forgot_password"
AccountDeletionTOSDecline AccountDeletionSource = "tos_decline"
AccountDeletionPasswordResetExpiry AccountDeletionSource = "password_reset_expiry"
AccountDeletionAccountTTL AccountDeletionSource = "account_ttl"
AccountDeletionFreezeExpiry AccountDeletionSource = "freeze_expiry"
)
type AccountDeletionRequestState string
const (
AccountDeletionPending AccountDeletionRequestState = "pending"
AccountDeletionCancelled AccountDeletionRequestState = "cancelled"
AccountDeletionExecuted AccountDeletionRequestState = "executed"
)
// AccountDeletionRequest represents the seven-day 2FA confirmation window.
// ConfirmHashDigest is SHA-256(raw link token); the raw token is only included
// in the durable service message and is never persisted as a credential.
type AccountDeletionRequest struct {
ID int64
UserID int64
RequesterAuthKeyID [8]byte
State AccountDeletionRequestState
Reason string
ConfirmHashDigest [32]byte
RequestedAt time.Time
ExecuteAt time.Time
CompletedAt time.Time
}
type AccountDeletionSnapshot struct {
User User
HasPassword bool
PasswordUpdatedAt time.Time
Pending *AccountDeletionRequest
}
type ScheduleAccountDeletion struct {
UserID int64
RequesterAuthKeyID [8]byte
Reason string
ConfirmHashDigest [32]byte
ServiceMessage string
RequestedAt time.Time
ExecuteAt time.Time
}
type AccountDeletionResult struct {
User User
Changed bool
RevokedAuthorizations []Authorization
}
type AccountDeleteKind string
const (
AccountDeleteImmediate AccountDeleteKind = "immediate"
AccountDeleteDelayed AccountDeleteKind = "delayed"
)
type AccountDeleteOutcome struct {
Kind AccountDeleteKind
WaitSeconds int
ExecuteAt time.Time
Deletion AccountDeletionResult
}
type AccountDeletionCandidate struct {
UserID int64
Source AccountDeletionSource
DueAt time.Time
}
type AccountDeletionNotification struct {
ID int64
TargetUserID int64
DeletedUserID int64
Attempts int
}

View file

@ -92,6 +92,7 @@ const (
type BotCommand struct {
Command string `json:"command"`
Description string `json:"description"`
Ephemeral bool `json:"ephemeral,omitempty"`
}
// BotMenuButtonType 标识菜单按钮类型。
@ -204,15 +205,19 @@ type BotAttachMenuState struct {
// BotRequestedWebViewButton 是 bots.requestWebViewButton 创建的 request-peer 上下文。
type BotRequestedWebViewButton struct {
WebAppReqID string
BotUserID int64
UserID int64
ButtonID int
Text string
PeerType string
MaxQuantity int
CreatedAt time.Time
ExpiresAt time.Time
WebAppReqID string
BotUserID int64
UserID int64
ButtonID int
Text string
PeerType string
MaxQuantity int
PeerFilter *BotRequestPeerFilter
NameRequested bool
UsernameRequested bool
PhotoRequested bool
CreatedAt time.Time
ExpiresAt time.Time
}
// BotWebViewCustomMethodQuery 是 custom method 的 pending 记录。没有 bot 侧回答

View file

@ -1,13 +1,142 @@
package domain
import "time"
// BotAPIUpdateKind is the Bot API delivery shape for a queued update.
type BotAPIUpdateKind string
const (
BotAPIUpdateMessage BotAPIUpdateKind = "message"
BotAPIUpdateEditedMessage BotAPIUpdateKind = "edited_message"
BotAPIUpdateCallbackQuery BotAPIUpdateKind = "callback_query"
)
// BotCallbackQuery is the protocol-neutral payload shared by MTProto
// updateBotCallbackQuery and the HTTP Bot API CallbackQuery projection.
type BotCallbackQuery struct {
ID int64
BotUserID int64
UserID int64
Peer Peer
MessageID int
ChatInstance int64
Data []byte
InlineMessage *BotInlineMessageID
}
// BotAPIEphemeralPayload is a self-contained 24-hour Bot API queue snapshot.
// Ordinary queued messages are reloaded from their durable message tables;
// ephemeral messages have no such table and therefore travel in this explicit
// envelope instead of overloading SourcePts or an ordinary message id. The
// public shape deliberately cannot represent random IDs, payload hashes,
// auth-key/session identifiers, or the originating device.
type BotAPIEphemeralPayload struct {
Message BotAPIEphemeralMessage
ReplyTo *BotAPIEphemeralMessage `json:",omitempty"`
}
type BotAPIEphemeralMessage struct {
ID int
Peer Peer
SenderUserID int64
ReceiverUserID int64
Date int
EditDate int
TopMessageID int
ReplyToEphemeralID int
Content EphemeralContent
Version uint64
ExpiresAt time.Time
}
func NewBotAPIEphemeralPayload(message EphemeralMessage) *BotAPIEphemeralPayload {
payload := &BotAPIEphemeralPayload{Message: publicBotAPIEphemeralMessage(message)}
if message.BotAPIReply != nil {
reply := publicBotAPIEphemeralMessage(*message.BotAPIReply)
payload.ReplyTo = &reply
}
return payload
}
func publicBotAPIEphemeralMessage(message EphemeralMessage) BotAPIEphemeralMessage {
return BotAPIEphemeralMessage{
ID: message.ID, Peer: message.Peer,
SenderUserID: message.SenderUserID, ReceiverUserID: message.ReceiverUserID,
Date: message.Date, EditDate: message.EditDate,
TopMessageID: message.TopMessageID, ReplyToEphemeralID: message.ReplyToEphemeralID,
Content: message.Content, Version: message.Version, ExpiresAt: message.ExpiresAt,
}
}
func (m BotAPIEphemeralMessage) EphemeralMessage() EphemeralMessage {
return EphemeralMessage{
ID: m.ID, Peer: m.Peer,
SenderUserID: m.SenderUserID, ReceiverUserID: m.ReceiverUserID,
Date: m.Date, EditDate: m.EditDate,
TopMessageID: m.TopMessageID, ReplyToEphemeralID: m.ReplyToEphemeralID,
Content: m.Content, Version: m.Version, ExpiresAt: m.ExpiresAt,
}
}
func (p BotAPIEphemeralPayload) EphemeralMessage() EphemeralMessage {
message := p.Message.EphemeralMessage()
if p.ReplyTo != nil {
reply := p.ReplyTo.EphemeralMessage()
message.BotAPIReply = &reply
}
return message
}
func (p BotAPIEphemeralPayload) Validate() error {
if err := p.Message.Validate(); err != nil {
return err
}
if p.Message.ReplyToEphemeralID == 0 {
if p.ReplyTo != nil {
return ErrEphemeralInvalid
}
return nil
}
if p.ReplyTo == nil || p.ReplyTo.Validate() != nil || p.ReplyTo.ID != p.Message.ReplyToEphemeralID ||
p.ReplyTo.Peer != p.Message.Peer || p.ReplyTo.Date > p.Message.Date ||
!sameEphemeralParticipantPair(p.Message.SenderUserID, p.Message.ReceiverUserID, p.ReplyTo.SenderUserID, p.ReplyTo.ReceiverUserID) {
return ErrEphemeralInvalid
}
return nil
}
func sameEphemeralParticipantPair(firstSender, firstReceiver, secondSender, secondReceiver int64) bool {
return (firstSender == secondSender && firstReceiver == secondReceiver) ||
(firstSender == secondReceiver && firstReceiver == secondSender)
}
func (m BotAPIEphemeralMessage) Expired(now time.Time) bool {
return !m.ExpiresAt.IsZero() && !now.Before(m.ExpiresAt)
}
func (m BotAPIEphemeralMessage) Validate() error {
date := time.Unix(int64(m.Date), 0)
if m.ID <= 0 || m.ID > MaxMessageBoxID || m.Peer.Type != PeerTypeChannel || m.Peer.ID <= 0 ||
m.SenderUserID <= 0 || m.ReceiverUserID <= 0 || m.SenderUserID == m.ReceiverUserID ||
m.Date <= 0 || m.Version == 0 || m.ExpiresAt.IsZero() || !m.ExpiresAt.After(date) ||
m.ExpiresAt.Sub(date) > EphemeralMessageRetention+time.Second ||
(m.EditDate != 0 && m.EditDate < m.Date) || m.TopMessageID < 0 || m.TopMessageID > MaxMessageBoxID ||
m.ReplyToEphemeralID < 0 || m.ReplyToEphemeralID > MaxMessageBoxID || m.ReplyToEphemeralID == m.ID {
return ErrEphemeralInvalid
}
return ValidateEphemeralContent(m.Content)
}
// BotInlineMessageID is the domain-only shape of inputBotInlineMessageID64.
// It can be projected both to MTProto and to Bot API's opaque
// inline_message_id without leaking tg types into the store boundary.
type BotInlineMessageID struct {
DCID int
OwnerID int64
ID int
AccessHash int64
}
// BotAPIUpdate is a durable Bot API update cursor. ID is the Bot API update_id
// and is global across all bots, matching Telegram Bot API's monotonic offset
// contract without reusing MTProto pts from user/channel logs.
@ -19,6 +148,8 @@ type BotAPIUpdate struct {
MessageID int
SourcePts int
Date int
Callback *BotCallbackQuery
Ephemeral *BotAPIEphemeralPayload
}
// EnqueueBotAPIUpdateRequest describes a message-like update that should be
@ -30,4 +161,6 @@ type EnqueueBotAPIUpdateRequest struct {
MessageID int
SourcePts int
Date int
Callback *BotCallbackQuery
Ephemeral *BotAPIEphemeralPayload
}

View file

@ -0,0 +1,41 @@
package domain
import (
"bytes"
"encoding/json"
"testing"
"time"
)
func TestBotAPIEphemeralPayloadCannotSerializePrivateRoutingState(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
reply := EphemeralMessage{
ID: 16, Peer: Peer{Type: PeerTypeChannel, ID: 1001},
SenderUserID: 3001, ReceiverUserID: 2001, Date: int(now.Unix()) - 1,
Content: EphemeralContent{Message: "prompt"}, Version: 1, ExpiresAt: now.Add(EphemeralMessageRetention),
}
payload := NewBotAPIEphemeralPayload(EphemeralMessage{
ID: 17, Peer: Peer{Type: PeerTypeChannel, ID: 1001},
SenderUserID: 2001, ReceiverUserID: 3001, Date: int(now.Unix()),
RandomID: 99, ReplyToEphemeralID: reply.ID, Content: EphemeralContent{Message: "private"},
OriginDevice: EphemeralDevice{UserID: 2001, BusinessAuthKeyID: [8]byte{1, 2, 3}, SessionID: 44},
PayloadHash: [32]byte{5, 6, 7}, Version: 1,
CreatedAt: now, ExpiresAt: now.Add(EphemeralMessageRetention), BotAPIReply: &reply,
})
raw, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
for _, privateField := range [][]byte{
[]byte("RandomID"), []byte("OriginDevice"), []byte("BusinessAuthKeyID"),
[]byte("SessionID"), []byte("PayloadHash"), []byte("CreatedAt"),
} {
if bytes.Contains(raw, privateField) {
t.Fatalf("durable Bot API envelope leaked %s: %s", privateField, raw)
}
}
if payload.Validate() != nil || payload.Message.ID != 17 || payload.Message.Content.Message != "private" || payload.Message.ExpiresAt.IsZero() ||
payload.ReplyTo == nil || payload.ReplyTo.ID != reply.ID {
t.Fatalf("public payload=%+v", payload)
}
}

View file

@ -0,0 +1,21 @@
package domain
import "time"
// BotAPIWebhook is durable delivery configuration and observable retry state.
// The token secret is never stored here: authentication remains owned by BotProfile.
type BotAPIWebhook struct {
BotUserID int64
URL string
SecretToken string
MaxConnections int
AllowedUpdates []BotAPIUpdateKind
// AllowedUpdatesSet distinguishes an explicitly supplied (possibly empty)
// setWebhook parameter from omission, which must preserve the previous
// getUpdates/setWebhook policy atomically at the store boundary.
AllowedUpdatesSet bool
FailureCount int
LastErrorDate int
LastErrorMessage string
NextAttemptAt time.Time
}

View file

@ -0,0 +1,20 @@
package domain
import (
"strings"
"testing"
)
func TestServiceIdentityAndLoginMessageUseTelesrvBrand(t *testing.T) {
serviceUser := OfficialSystemUser()
if serviceUser.FirstName != "Telesrv" || serviceUser.Username != "telesrv" {
t.Fatalf("service user = %+v, want Telesrv identity", serviceUser)
}
message, err := OfficialLoginCodeMessage(42, "12345", 1)
if err != nil {
t.Fatalf("build login message: %v", err)
}
if !strings.Contains(message.Body, "Telesrv") || strings.Contains(strings.ToLower(message.Body), "telegram") {
t.Fatalf("login message exposes wrong brand: %q", message.Body)
}
}

View file

@ -188,20 +188,23 @@ const (
// ChannelAdminRights is a domain-only representation of Telegram admin rights.
type ChannelAdminRights struct {
ChangeInfo bool
PostMessages bool
EditMessages bool
DeleteMessages bool
PostStories bool
EditStories bool
DeleteStories bool
BanUsers bool
InviteUsers bool
PinMessages bool
AddAdmins bool
ManageCall bool
Anonymous bool
ManageRanks bool
ChangeInfo bool
PostMessages bool
EditMessages bool
DeleteMessages bool
PostStories bool
EditStories bool
DeleteStories bool
BanUsers bool
InviteUsers bool
PinMessages bool
AddAdmins bool
ManageCall bool
ManageChat bool
ManageTopics bool
Anonymous bool
ManageRanks bool
ManageLinkedPeers bool
// ManageDirectMessages 对应 TL ChatAdminRights.manage_direct_messages(flags.17)。母广播频道的
// 管理员据此被客户端授予 monoforum(频道私信)容器的 MonoforumAdmin 身份;creator 走 amCreator 旁路。
ManageDirectMessages bool
@ -210,19 +213,22 @@ type ChannelAdminRights struct {
// CreatorChannelAdminRights returns the full rights set clients expect on creator projections.
func CreatorChannelAdminRights() ChannelAdminRights {
return ChannelAdminRights{
ChangeInfo: true,
PostMessages: true,
EditMessages: true,
DeleteMessages: true,
PostStories: true,
EditStories: true,
DeleteStories: true,
BanUsers: true,
InviteUsers: true,
PinMessages: true,
AddAdmins: true,
ManageCall: true,
ManageRanks: true,
ChangeInfo: true,
PostMessages: true,
EditMessages: true,
DeleteMessages: true,
PostStories: true,
EditStories: true,
DeleteStories: true,
BanUsers: true,
InviteUsers: true,
PinMessages: true,
AddAdmins: true,
ManageCall: true,
ManageChat: true,
ManageTopics: true,
ManageRanks: true,
ManageLinkedPeers: true,
}
}
@ -274,7 +280,10 @@ type ChannelBannedRights struct {
SendPlain bool
EditRank bool
SendReactions bool
UntilDate int
// ManageLinkedPeers is the Layer 228 default restriction used by Communities:
// true means only admins may add peers; false lets members submit requests.
ManageLinkedPeers bool
UntilDate int
}
// ChannelReactionPolicyType describes which reactions are allowed in a channel.
@ -425,6 +434,10 @@ type Channel struct {
// megagroup && (public || has_geo || has_link) 判定是否拉取候选列表。
HasLink bool
LinkedChatID int64
// LinkedCommunityID is the unique Community containing this group/channel.
// A channel can belong to at most one Community and Communities themselves
// are stored in a separate aggregate, never in channels.
LinkedCommunityID int64
// Monoforum 标记本频道是「频道私信(Direct Messages)」的 monoforum 虚拟频道。
// LinkedMonoforumID:母频道指向其 monoforum;monoforum 反向指向母频道(双向)。
Monoforum bool
@ -550,13 +563,20 @@ const (
ChannelActionPaidMessagesPrice ChannelMessageActionType = "paid_messages_price"
// ChannelActionStarGift 映射 messageActionStarGift频道礼物的 admin-log 快照。
ChannelActionStarGift ChannelMessageActionType = "star_gift"
// ChannelActionStarGiftUnique 映射 messageActionStarGiftUniquecollectible
// 升级、转赠等所有权变更只进入 Recent Actions不伪造频道历史/pts。
ChannelActionStarGiftUnique ChannelMessageActionType = "star_gift_unique"
// ChannelActionSetChatWallpaper 映射 messageActionSetChatWallPaper频道外观页设置 wallpaper。
ChannelActionSetChatWallpaper ChannelMessageActionType = "set_chat_wallpaper"
// ChannelActionChangeCommunity maps messageActionChangeCommunity. A non-zero
// CommunityID means linked; zero means unlinked.
ChannelActionChangeCommunity ChannelMessageActionType = "change_community"
)
// ChannelMessageAction describes a service action without depending on tg.*.
type ChannelMessageAction struct {
Type ChannelMessageActionType
CommunityID int64
Title string
IconColor int
IconEmojiID int64
@ -583,7 +603,8 @@ type ChannelMessageAction struct {
Incompleted []int
TodoItems []MessageTodoItem
// StarGift 仅 star_gift 服务消息使用。
StarGift *MessageStarGiftAction
StarGift *MessageStarGiftAction
StarGiftUnique *MessageStarGiftUniqueAction
// Wallpaper 仅 set_chat_wallpaper 服务消息使用。
Wallpaper *Wallpaper
// Photo 仅 chat_edit_photo 服务消息使用。
@ -599,17 +620,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 相册分组 idsendMultiMedia 同组共享非零值,非相册恒 0
GroupedID int64
ReplyMarkup *MessageReplyMarkup
@ -1455,7 +1480,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
@ -1575,6 +1608,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.
@ -2015,18 +2050,24 @@ type ChannelSearchPostsRequest struct {
// ChannelGlobalSearchRequest describes a bounded messages.searchGlobal page
// over channel/supergroup messages visible to the current account.
type ChannelGlobalSearchRequest struct {
Query string
BroadcastsOnly bool
GroupsOnly bool
MusicOnly bool
HasFolderID bool
FolderID int
OffsetRate int
OffsetChannelID int64
OffsetID int
MinDate int
MaxDate int
Limit int
Query string
ChannelIDs []int64
RestrictChannelIDs bool
// AllowPublicPreview includes linked public channels that the account can
// preview without joining. It is enabled only by Layer 228 Community-scoped
// search; ordinary global search remains joined-dialog-only.
AllowPublicPreview bool
BroadcastsOnly bool
GroupsOnly bool
MusicOnly bool
HasFolderID bool
FolderID int
OffsetRate int
OffsetChannelID int64
OffsetID int
MinDate int
MaxDate int
Limit int
}
// ChannelRepliesFilter describes messages.getReplies query conditions.

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

@ -0,0 +1,214 @@
package domain
import "errors"
const (
MaxCommunityPeers = 100
MaxCommunityBotPeers = 100
MaxCommunityLinkRequests = 100
MaxCommunityTitleRunes = 128
MaxCommunityAboutRunes = 255
MaxCommunityParticipants = 200
)
var (
ErrCommunityInvalid = errors.New("community invalid")
ErrCommunityPrivate = errors.New("community private")
ErrCommunityAdminRequired = errors.New("community admin required")
ErrCommunityCreatorRequired = errors.New("community creator required")
ErrCommunityPeerInvalid = errors.New("community peer invalid")
ErrCommunityPeerLinked = errors.New("community peer already linked")
ErrCommunityPeersTooMuch = errors.New("community peers too much")
ErrCommunityRequestCreated = errors.New("community request created")
ErrCommunityRequestMissing = errors.New("community request missing")
ErrCommunityParticipantInvalid = errors.New("community participant invalid")
)
// Community is the Layer 228 aggregation container. It intentionally has no
// message/read/pts fields: linked dialogs remain the only message truth.
type Community struct {
ID int64
AccessHash int64
CreatorUserID int64
Title string
About string
Date int
Deleted bool
DefaultBannedRights ChannelBannedRights
PhotoID int64
PhotoDCID int
PhotoStripped []byte
}
type CommunityMemberRole string
const (
CommunityRoleCreator CommunityMemberRole = "creator"
CommunityRoleAdmin CommunityMemberRole = "admin"
CommunityRoleMember CommunityMemberRole = "member"
)
type CommunityMemberStatus string
const (
CommunityMemberActive CommunityMemberStatus = "active"
CommunityMemberKicked CommunityMemberStatus = "kicked"
)
type CommunityMember struct {
CommunityID int64
UserID int64
Role CommunityMemberRole
Status CommunityMemberStatus
AdminRights ChannelAdminRights
Rank string
Date int
}
func (m CommunityMember) Active() bool { return m.Status == CommunityMemberActive }
func (m CommunityMember) CanManageLinkedPeers() bool {
return m.Active() && (m.Role == CommunityRoleCreator ||
(m.Role == CommunityRoleAdmin && m.AdminRights.ManageLinkedPeers))
}
func (m CommunityMember) CanChangeInfo() bool {
return m.Active() && (m.Role == CommunityRoleCreator ||
(m.Role == CommunityRoleAdmin && m.AdminRights.ChangeInfo))
}
func (m CommunityMember) CanAddAdmins() bool {
return m.Active() && (m.Role == CommunityRoleCreator ||
(m.Role == CommunityRoleAdmin && m.AdminRights.AddAdmins))
}
func (m CommunityMember) CanBanUsers() bool {
return m.Active() && (m.Role == CommunityRoleCreator ||
(m.Role == CommunityRoleAdmin && m.AdminRights.BanUsers))
}
type CommunityPeerVisibility string
const (
CommunityPeerVisible CommunityPeerVisibility = "visible"
CommunityPeerHidden CommunityPeerVisibility = "hidden"
)
type CommunityPeerLink struct {
CommunityID int64
Peer Peer
Visibility CommunityPeerVisibility
CanViewHistory bool
CreatedBy int64
Date int
}
func (l CommunityPeerLink) Visible() bool { return l.Visibility == CommunityPeerVisible }
type CommunityPeerLinkRequest struct {
CommunityID int64
Peer Peer
RequestedBy int64
Visibility CommunityPeerVisibility
Date int
}
type CommunityUserState struct {
CommunityID int64
UserID int64
Collapsed bool
Pinned bool
PinnedOrder int
NotifySettings *PeerNotifySettings
}
type CommunityView struct {
Community Community
Self CommunityMember
State CommunityUserState
Links []CommunityPeerLink
Channels []Channel
Users []User
ServiceMessages []SendChannelMessageResult
AdminsCount int
KickedCount int
PendingRequests int
Forbidden bool
}
func (v CommunityView) Creator() bool {
return v.Self.Active() && v.Self.Role == CommunityRoleCreator
}
type CreateCommunityRequest struct {
CreatorUserID int64
Title string
About string
InitialPeer Peer
Visibility CommunityPeerVisibility
Date int
}
type CommunityTogglePeerLinkRequest struct {
ActorUserID int64
CommunityID int64
Peer Peer
Visibility CommunityPeerVisibility
Deleted bool
RequestOnly bool
Date int
}
type CommunityTogglePeerLinkResult struct {
Community Community
Peer Peer
RequestedBy int64
Link *CommunityPeerLink
ServiceMessage *SendChannelMessageResult
Removed bool
RequestCreated bool
}
type CommunityPeerLinkRequestPage struct {
TotalCount int
Requests []CommunityPeerLinkRequest
NextOffset string
Channels []Channel
Users []User
}
type CommunityParticipantJoinedChats struct {
CreatorChatIDs []int64
JoinedChatIDs []int64
Channels []Channel
Users []User
}
type CommunityParticipantList struct {
Community Community
Count int
Participants []CommunityMember
Users []User
Hash int64
}
type CommunityParticipantBanResult struct {
Changed bool
ChannelBans []EditChannelBannedResult
RemovedLinks []CommunityTogglePeerLinkResult
}
type CommunityEditAdminRequest struct {
ActorUserID int64
CommunityID int64
UserID int64
Rights ChannelAdminRights
Rank string
Date int
}
type CommunitySearchScope struct {
CommunityID int64
ChannelIDs []int64
BotUserIDs []int64
}

View file

@ -6,6 +6,9 @@ type PeerType string
const (
PeerTypeUser PeerType = "user"
PeerTypeChannel PeerType = "channel"
// PeerTypeCommunity identifies a Layer 228 Community container. Communities
// have dialog pin/notify state but never own messages, read boundaries or pts.
PeerTypeCommunity PeerType = "community"
// PeerTypeFolder 仅用于 dialog 置顶事件中表达 dialogPeerFolder
// archive folder 行本身被置顶/取消置顶ID 为 folder_id。
PeerTypeFolder PeerType = "folder"
@ -101,19 +104,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 +152,7 @@ func (d DialogDraft) Empty() bool {
(d.ReplyTo == nil || replyOnlyTopic) &&
d.WebPage == nil &&
d.Effect == 0 &&
d.SuggestedPost == nil &&
d.RichMessage.IsZero()
}
@ -151,6 +178,7 @@ type DialogList struct {
ChannelMessages []ChannelMessage
Users []User
Channels []Channel
Communities []CommunityView
State UpdateState
Hash int64
Count int

View file

@ -0,0 +1,362 @@
package domain
import (
"crypto/sha256"
"errors"
"time"
"unicode/utf8"
)
const (
// EphemeralMessageRetention matches TDesktop's in-memory upper bound. The
// server never replays these records; the retention only keeps callback,
// edit, delete and abuse-report lookups coherent across instances.
EphemeralMessageRetention = 48 * time.Hour
// EphemeralReplyWindow is the official Bot API eligible-action window.
EphemeralReplyWindow = 15 * time.Second
// MaxEphemeralCreateAttempts bounds random int32 ID collision retries.
MaxEphemeralCreateAttempts = 8
// MaxEphemeralCallbackDataBytes is the Bot API callback_data wire limit.
MaxEphemeralCallbackDataBytes = 64
// MaxEphemeralCaptionLength follows the Bot API media-caption contract.
MaxEphemeralCaptionLength = 1024
// Rich messages are accepted at the domain boundary only within a bounded
// wire-sized snapshot. The current official client does not send this flag,
// but malformed callers must not be able to retain unbounded block vectors.
MaxEphemeralRichBlocksBytes = 1 << 20
MaxEphemeralRichMediaRefs = 100
)
var (
ErrEphemeralInvalid = errors.New("ephemeral message invalid")
ErrEphemeralNotFound = errors.New("ephemeral message not found")
ErrEphemeralExpired = errors.New("ephemeral message expired")
ErrEphemeralDeleted = errors.New("ephemeral message deleted")
ErrEphemeralIDCollision = errors.New("ephemeral message id collision")
ErrEphemeralRandomIDConflict = errors.New("ephemeral random id conflict")
ErrEphemeralVersionConflict = errors.New("ephemeral message version conflict")
ErrEphemeralReplyExpired = errors.New("ephemeral reply expired")
ErrEphemeralQueryInvalid = errors.New("ephemeral query invalid")
ErrEphemeralPeerInvalid = errors.New("ephemeral peer invalid")
ErrEphemeralSenderInvalid = errors.New("ephemeral sender invalid")
ErrEphemeralReceiverInvalid = errors.New("ephemeral receiver invalid")
ErrEphemeralCommandInvalid = errors.New("ephemeral command invalid")
ErrEphemeralForbidden = errors.New("ephemeral action forbidden")
ErrEphemeralDeviceMismatch = errors.New("ephemeral device mismatch")
ErrEphemeralCallbackInvalid = errors.New("ephemeral callback invalid")
)
// EphemeralDevice identifies the exact client application that originated an
// eligible action. BusinessAuthKeyID is the durable device identity; SessionID
// is retained for binding checks and diagnostics, not used as a global key.
type EphemeralDevice struct {
UserID int64
BusinessAuthKeyID [8]byte
SessionID int64
}
// EphemeralContent is the mutable presentation payload. Identity, routing and
// reply ancestry live on EphemeralMessage and never change during edits.
type EphemeralContent struct {
Message string
Entities []MessageEntity
Media *MessageMedia
ReplyMarkup *MessageReplyMarkup
RichMessage *MessageRichMessage
}
// EphemeralMessage is a short-lived bot/member interaction. It deliberately
// has no ordinary message box ID, pts, qts, seq, unread or dialog fields.
type EphemeralMessage struct {
ID int
Peer Peer
SenderUserID int64
ReceiverUserID int64
Date int
EditDate int
RandomID int64
TopMessageID int
ReplyToEphemeralID int
Content EphemeralContent
OriginDevice EphemeralDevice
PayloadHash [32]byte
Version uint64
Deleted bool
CreatedAt time.Time
ExpiresAt time.Time
// BotAPIReply is a one-level, runtime-only reply snapshot. It is attached
// after the authoritative message has been written, excluded from Redis and
// broker JSON, and used only to project a valid Bot API reply_to_message.
BotAPIReply *EphemeralMessage `json:"-"`
}
type SendClientEphemeralRequest struct {
SenderUserID int64
ReceiverBotID int64
Peer Peer
QueryID int64
RandomID int64
TopMessageID int
ReplyToEphemeralID int
Content EphemeralContent
OriginDevice EphemeralDevice
}
type SendBotEphemeralRequest struct {
BotUserID int64
ReceiverUserID int64
Peer Peer
RandomID int64
TopMessageID int
ReplyToEphemeralID int
Content EphemeralContent
// ActionMessageID authorizes the ordinary 15-second response path. When it
// is zero the bot must be an administrator and delivery targets every ready
// Layer 228 device of ReceiverUserID.
ActionMessageID int
// CallbackQueryID authorizes a response to a callback originating from a
// bot→user ephemeral message. The shared action record owns the target device.
CallbackQueryID int64
}
type EphemeralCallback struct {
Message EphemeralMessage
BotUserID int64
UserID int64
Peer Peer
Data []byte
Device EphemeralDevice
OccurredAt time.Time
}
type EphemeralCallbackAction struct {
QueryID int64
BotUserID int64
UserID int64
Peer Peer
MessageID int
TopMessageID int
Device EphemeralDevice
CreatedAt time.Time
ExpiresAt time.Time
}
// EphemeralReportEvidence is the durable, device-identity-free snapshot kept
// for abuse review after the transient Redis record expires. It intentionally
// excludes OriginDevice, random IDs and session/auth-key identifiers.
type EphemeralReportEvidence struct {
MessageID int
Peer Peer
SenderUserID int64
ReceiverUserID int64
Date int
EditDate int
TopMessageID int
ReplyToEphemeralID int
Content EphemeralContent
PayloadHash [32]byte
Version uint64
}
// EphemeralAbuseReport is written only for a final report option. CommentHash
// makes retries idempotent without indexing potentially large user text.
type EphemeralAbuseReport struct {
ReporterUserID int64
Option string
Comment string
CommentHash [32]byte
Evidence EphemeralReportEvidence
CreatedAt time.Time
}
func NewEphemeralAbuseReport(reporterUserID int64, option, comment string, message EphemeralMessage, createdAt time.Time) EphemeralAbuseReport {
return EphemeralAbuseReport{
ReporterUserID: reporterUserID,
Option: option,
Comment: comment,
CommentHash: sha256.Sum256([]byte(comment)),
Evidence: EphemeralReportEvidence{
MessageID: message.ID, Peer: message.Peer,
SenderUserID: message.SenderUserID, ReceiverUserID: message.ReceiverUserID,
Date: message.Date, EditDate: message.EditDate,
TopMessageID: message.TopMessageID, ReplyToEphemeralID: message.ReplyToEphemeralID,
Content: message.Content, PayloadHash: message.PayloadHash, Version: message.Version,
},
CreatedAt: createdAt,
}
}
func (r EphemeralAbuseReport) Validate() error {
if r.ReporterUserID <= 0 || r.Option == "" || len(r.Option) > 64 || utf8.RuneCountInString(r.Comment) > 4096 ||
r.Evidence.MessageID <= 0 || r.Evidence.MessageID > MaxMessageBoxID ||
r.Evidence.Peer.Type != PeerTypeChannel || r.Evidence.Peer.ID <= 0 ||
r.Evidence.SenderUserID <= 0 || r.Evidence.ReceiverUserID != r.ReporterUserID ||
r.Evidence.SenderUserID == r.Evidence.ReceiverUserID || r.CreatedAt.IsZero() ||
r.CommentHash != sha256.Sum256([]byte(r.Comment)) {
return ErrEphemeralInvalid
}
return nil
}
type EditEphemeralFields struct {
SetMessage bool
Message string
Entities []MessageEntity
SetMedia bool
Media *MessageMedia
SetReplyMarkup bool
ReplyMarkup *MessageReplyMarkup
}
type BotAPIFileInput struct {
LocationKey string
RemoteURL string
FileName string
MimeType string
Bytes []byte
Width int
Height int
Duration int
Title string
Performer string
Emoji string
}
type BotAPIEphemeralSendInput struct {
BotUserID int64
ChatID int64
ReceiverUserID int64
CallbackQueryID int64
ReplyToEphemeralID int
TopMessageID int
Kind string
Text string
Entities []MessageEntity
ReplyMarkup *MessageReplyMarkup
File BotAPIFileInput
SecondaryFile BotAPIFileInput
DirectMedia *MessageMedia
}
type BotAPIEphemeralEditInput struct {
BotUserID int64
ChatID int64
ReceiverUserID int64
MessageID int
Mode EphemeralEditMode
Fields EditEphemeralFields
MediaKind string
File BotAPIFileInput
SecondaryFile BotAPIFileInput
}
type EphemeralEditMode string
const (
EphemeralEditText EphemeralEditMode = "text"
EphemeralEditMedia EphemeralEditMode = "media"
EphemeralEditCaption EphemeralEditMode = "caption"
EphemeralEditReplyMarkup EphemeralEditMode = "reply_markup"
)
func (m EphemeralMessage) ValidateForCreate(now time.Time) error {
if err := m.ValidateStored(); err != nil || m.Version != 1 || m.Deleted || !m.ExpiresAt.After(now) {
return ErrEphemeralInvalid
}
return nil
}
func (m EphemeralMessage) ValidateStored() error {
if m.ID <= 0 || m.ID > MaxMessageBoxID || m.Peer.Type != PeerTypeChannel || m.Peer.ID <= 0 ||
m.SenderUserID <= 0 || m.ReceiverUserID <= 0 || m.SenderUserID == m.ReceiverUserID ||
m.RandomID == 0 || m.Date <= 0 || m.Version == 0 || m.CreatedAt.IsZero() || m.ExpiresAt.IsZero() ||
!m.ExpiresAt.After(m.CreatedAt) || m.ExpiresAt.Sub(m.CreatedAt) > EphemeralMessageRetention ||
m.Date != int(m.CreatedAt.Unix()) || (m.EditDate != 0 && m.EditDate < m.Date) ||
m.TopMessageID < 0 || m.TopMessageID > MaxMessageBoxID ||
m.ReplyToEphemeralID < 0 || m.ReplyToEphemeralID > MaxMessageBoxID || m.ReplyToEphemeralID == m.ID ||
m.PayloadHash == ([32]byte{}) {
return ErrEphemeralInvalid
}
zeroDevice := m.OriginDevice == (EphemeralDevice{})
if !zeroDevice && (m.OriginDevice.UserID <= 0 || m.OriginDevice.BusinessAuthKeyID == ([8]byte{}) ||
m.OriginDevice.SessionID == 0 ||
(m.OriginDevice.UserID != m.SenderUserID && m.OriginDevice.UserID != m.ReceiverUserID)) {
return ErrEphemeralInvalid
}
if m.Deleted {
if m.Version < 2 || m.Content.Message != "" || len(m.Content.Entities) != 0 || m.Content.Media != nil ||
m.Content.ReplyMarkup != nil || !m.Content.RichMessage.IsZero() {
return ErrEphemeralInvalid
}
return nil
}
return ValidateEphemeralContent(m.Content)
}
func ValidateEphemeralContent(content EphemeralContent) error {
if !utf8.ValidString(content.Message) || utf8.RuneCountInString(content.Message) > MaxMessageTextLength ||
len(content.Entities) > MaxMessageEntityCount || !validEphemeralEntityBounds(content.Message, content.Entities) {
return ErrEphemeralInvalid
}
if err := ValidateReplyMarkup(content.ReplyMarkup); err != nil {
return ErrEphemeralInvalid
}
if content.ReplyMarkup != nil && !content.ReplyMarkup.IsZero() && content.ReplyMarkup.Kind() != MessageReplyMarkupInline {
return ErrEphemeralInvalid
}
if content.Media != nil && !validEphemeralMedia(content.Media) {
return ErrEphemeralInvalid
}
if rich := content.RichMessage; !rich.IsZero() {
if len(rich.Blocks) == 0 || len(rich.Blocks) > MaxEphemeralRichBlocksBytes ||
len(rich.Photos) > MaxEphemeralRichMediaRefs || len(rich.Documents) > MaxEphemeralRichMediaRefs {
return ErrEphemeralInvalid
}
}
if content.Message == "" && content.Media == nil && content.RichMessage.IsZero() {
return ErrEphemeralInvalid
}
return nil
}
func validEphemeralEntityBounds(message string, entities []MessageEntity) bool {
utf16Length := 0
for _, value := range message {
utf16Length++
if value > 0xffff {
utf16Length++
}
}
for _, entity := range entities {
if entity.Type == "" || entity.Offset < 0 || entity.Length <= 0 || entity.Offset > utf16Length ||
entity.Length > utf16Length-entity.Offset {
return false
}
}
return true
}
func validEphemeralMedia(media *MessageMedia) bool {
if media == nil || media.IsZero() || media.ServiceAction != nil || media.Dice != nil || media.Poll != nil ||
media.GeoLive != nil || media.Todo != nil || media.Story != nil || media.WebPage != nil {
return false
}
switch media.Kind {
case MessageMediaKindPhoto:
return media.Photo != nil && media.Document == nil && media.Contact == nil && media.Geo == nil && media.Venue == nil
case MessageMediaKindDocument:
return media.Document != nil && media.Photo == nil && media.LivePhotoVideo == nil && media.Contact == nil && media.Geo == nil && media.Venue == nil
case MessageMediaKindContact:
return media.Contact != nil && media.Photo == nil && media.LivePhotoVideo == nil && media.Document == nil && media.Geo == nil && media.Venue == nil
case MessageMediaKindGeo:
return media.Geo != nil && media.Photo == nil && media.LivePhotoVideo == nil && media.Document == nil && media.Contact == nil && media.Venue == nil
case MessageMediaKindVenue:
return media.Venue != nil && media.Photo == nil && media.LivePhotoVideo == nil && media.Document == nil && media.Contact == nil && media.Geo == nil
default:
return false
}
}
func (m EphemeralMessage) Expired(now time.Time) bool {
return !m.ExpiresAt.IsZero() && !now.Before(m.ExpiresAt)
}

View file

@ -0,0 +1,64 @@
package domain
import (
"errors"
"testing"
"time"
)
func TestValidateEphemeralContentBoundsAllRetainedVectors(t *testing.T) {
valid := EphemeralContent{
Message: "hi 👋",
Entities: []MessageEntity{{Type: MessageEntityBold, Offset: 0, Length: 2}},
ReplyMarkup: &MessageReplyMarkup{Type: MessageReplyMarkupInline, Inline: [][]MarkupButton{{{
Type: MarkupButtonCallback, Text: "OK", Data: []byte("ok"),
}}}},
}
if err := ValidateEphemeralContent(valid); err != nil {
t.Fatalf("valid content: %v", err)
}
badBounds := valid
badBounds.Entities = []MessageEntity{{Type: MessageEntityBold, Offset: 5, Length: 2}}
if err := ValidateEphemeralContent(badBounds); !errors.Is(err, ErrEphemeralInvalid) {
t.Fatalf("entity bounds err=%v", err)
}
badKeyboard := valid
badKeyboard.ReplyMarkup = &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{
Type: MarkupButtonText, Text: "public keyboard",
}}}}
if err := ValidateEphemeralContent(badKeyboard); !errors.Is(err, ErrEphemeralInvalid) {
t.Fatalf("reply keyboard err=%v", err)
}
badRich := EphemeralContent{RichMessage: &MessageRichMessage{Blocks: make([]byte, MaxEphemeralRichBlocksBytes+1)}}
if err := ValidateEphemeralContent(badRich); !errors.Is(err, ErrEphemeralInvalid) {
t.Fatalf("rich bound err=%v", err)
}
badMedia := EphemeralContent{Media: &MessageMedia{Kind: MessageMediaKindPhoto}}
if err := ValidateEphemeralContent(badMedia); !errors.Is(err, ErrEphemeralInvalid) {
t.Fatalf("media shape err=%v", err)
}
}
func TestEphemeralStoredStateRejectsPartialDeviceAndInvalidTombstone(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
message := EphemeralMessage{
ID: 17, Peer: Peer{Type: PeerTypeChannel, ID: 1001},
SenderUserID: 2001, ReceiverUserID: 3001, Date: int(now.Unix()), RandomID: 9,
Content: EphemeralContent{Message: "private"}, OriginDevice: EphemeralDevice{UserID: 3001},
PayloadHash: [32]byte{1}, Version: 1, CreatedAt: now, ExpiresAt: now.Add(EphemeralMessageRetention),
}
if err := message.ValidateStored(); !errors.Is(err, ErrEphemeralInvalid) {
t.Fatalf("partial device err=%v", err)
}
message.OriginDevice = EphemeralDevice{}
message.Deleted = true
message.Content = EphemeralContent{}
if err := message.ValidateStored(); !errors.Is(err, ErrEphemeralInvalid) {
t.Fatalf("version-one tombstone err=%v", err)
}
message.Version = 2
if err := message.ValidateStored(); err != nil {
t.Fatalf("valid tombstone err=%v", err)
}
}

View file

@ -4,11 +4,13 @@ import (
"fmt"
"math"
"strings"
"telesrv/internal/branding"
)
const officialLoginCodeMessageTemplate = `Login code: %s. Do not give this code to anyone, even if they say they are from Telegram!
const officialLoginCodeMessageTemplate = `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `!
This code can be used to log in to your Telegram account. We never ask it for anything else.
This code can be used to log in to your ` + branding.ProductName + ` account. We never ask it for anything else.
If you didn't request this code by trying to log in on another device, simply ignore this message.`

View file

@ -564,7 +564,9 @@ const (
// MessageServiceActionStarGiftUnique maps messageActionStarGiftUnique. The
// immutable collectible snapshot is carried by the service message so an
// exact replay/difference never depends on mutable catalog state.
MessageServiceActionStarGiftUnique MessageServiceActionKind = "star_gift_unique"
MessageServiceActionStarGiftUnique MessageServiceActionKind = "star_gift_unique"
MessageServiceActionStarGiftOffer MessageServiceActionKind = "star_gift_offer"
MessageServiceActionStarGiftOfferDeclined MessageServiceActionKind = "star_gift_offer_declined"
)
// MessagePhoneCallAction 是 messageActionPhoneCall 的协议中立载荷。
@ -608,78 +610,132 @@ type MessageWebViewDataAction struct {
type MessageRequestedPeerAction struct {
ButtonID int `json:"button_id"`
Peers []Peer `json:"peers"`
// Details is the immutable, permission-gated snapshot delivered to the bot.
// It is kept separate from Peers because the sender-side MTProto action only
// exposes peer identities, while the bot-side/Bot API view may additionally
// expose the requested name, username, and profile photo.
Details []MessageRequestedPeerDetails `json:"details,omitempty"`
NameRequested bool `json:"name_requested,omitempty"`
UsernameRequested bool `json:"username_requested,omitempty"`
PhotoRequested bool `json:"photo_requested,omitempty"`
}
type MessageRequestedPeerDetails struct {
Peer Peer `json:"peer"`
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
Title string `json:"title,omitempty"`
Username string `json:"username,omitempty"`
Photo *Photo `json:"photo,omitempty"`
}
// 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"`
StarGiftUnique *MessageStarGiftUniqueAction `json:"star_gift_unique,omitempty"`
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"`
StarGiftUnique *MessageStarGiftUniqueAction `json:"star_gift_unique,omitempty"`
StarGiftOffer *MessageStarGiftOfferAction `json:"star_gift_offer,omitempty"`
StarGiftOfferDeclined *MessageStarGiftOfferDeclinedAction `json:"star_gift_offer_declined,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"`
CanUpgrade bool `json:"can_upgrade,omitempty"`
PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"`
UpgradeStars int64 `json:"upgrade_stars,omitempty"`
UpgradeMsgID int `json:"upgrade_msg_id,omitempty"`
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"`
CanUpgrade bool `json:"can_upgrade,omitempty"`
PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"`
PrepaidUpgradeHash string `json:"prepaid_upgrade_hash,omitempty"`
UpgradeSeparate bool `json:"upgrade_separate,omitempty"`
// UpgradePriceStars belongs to the inner StarGift.upgrade_stars field and
// is the price of a normal paid upgrade. UpgradeStars below belongs to the
// outer messageActionStarGift and is only the amount already prepaid by the
// sender. TDesktop uses these two fields to choose the paid vs free flow.
UpgradePriceStars int64 `json:"upgrade_price_stars,omitempty"`
UpgradeStars int64 `json:"upgrade_stars,omitempty"`
UpgradeMsgID int `json:"upgrade_msg_id,omitempty"`
GiftMsgID int `json:"gift_msg_id,omitempty"`
GiftNum int `json:"gift_num,omitempty"`
AuctionAcquired bool `json:"auction_acquired,omitempty"`
To Peer `json:"to,omitempty"`
}
// MessageStarGiftUniqueAction is the protocol-neutral payload of an upgrade
// service message. Commercial transfer/resale/export fields are intentionally
// absent from the collectibles mainline.
type MessageStarGiftUniqueAction struct {
Gift UniqueStarGift `json:"gift"`
FromUserID int64 `json:"from_user_id,omitempty"`
Peer Peer `json:"peer"`
SavedID int64 `json:"saved_id,omitempty"`
Upgrade bool `json:"upgrade,omitempty"`
Saved bool `json:"saved,omitempty"`
PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"`
Gift UniqueStarGift `json:"gift"`
FromUserID int64 `json:"from_user_id,omitempty"`
Peer Peer `json:"peer"`
SavedID int64 `json:"saved_id,omitempty"`
Upgrade bool `json:"upgrade,omitempty"`
Saved bool `json:"saved,omitempty"`
PrepaidUpgrade bool `json:"prepaid_upgrade,omitempty"`
Transferred bool `json:"transferred,omitempty"`
Refunded bool `json:"refunded,omitempty"`
Assigned bool `json:"assigned,omitempty"`
FromOffer bool `json:"from_offer,omitempty"`
Craft bool `json:"craft,omitempty"`
CanExportAt int `json:"can_export_at,omitempty"`
TransferStars int64 `json:"transfer_stars,omitempty"`
ResaleAmount *StarGiftAmount `json:"resale_amount,omitempty"`
CanTransferAt int `json:"can_transfer_at,omitempty"`
CanResellAt int `json:"can_resell_at,omitempty"`
DropOriginalDetailsStars int64 `json:"drop_original_details_stars,omitempty"`
CanCraftAt int `json:"can_craft_at,omitempty"`
}
type MessageStarGiftOfferAction struct {
Gift UniqueStarGift `json:"gift"`
Price StarGiftAmount `json:"price"`
ExpiresAt int `json:"expires_at"`
Accepted bool `json:"accepted,omitempty"`
Declined bool `json:"declined,omitempty"`
}
type MessageStarGiftOfferDeclinedAction struct {
Gift UniqueStarGift `json:"gift"`
Price StarGiftAmount `json:"price"`
Expired bool `json:"expired,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"`
Kind MessageMediaKind `json:"kind"`
Photo *Photo `json:"photo,omitempty"`
LivePhotoVideo *Document `json:"live_photo_video,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"`

View file

@ -151,7 +151,7 @@ type Message struct {
// (🎉/👍 等),发送方与接收方双盒持同一非零值并各自播放一次;非特效消息恒 0。
// 转发不携带特效(新消息恒 0。仅私聊群/频道不渲染。
Effect int64
// ReplyMarkup 是 bot 消息携带的 inline keyboard 快照P3。仅 bot 出站消息可
// ReplyMarkup 是 bot 消息携带的 reply/inline keyboard 快照。仅 bot 出站消息可
// 非空;普通用户消息恒 nil发送侧 is_bot 闸门)。双盒持同一快照(无 per-viewer 差异)。
ReplyMarkup *MessageReplyMarkup
// RichMessage 是 Layer 227 富文本消息richMessage快照可选普通消息恒 nil。
@ -242,6 +242,10 @@ type MessageFilter struct {
// SavedPeer 非零时仅返回 self-chat 中该 saved 子会话的消息
// messages.getSavedHistoryPeer 必须同时是 self。
SavedPeer Peer
// PeerIDs restricts a global private search to these user peers. Empty is a
// valid restricted set, so RestrictPeerIDs carries presence separately.
PeerIDs []int64
RestrictPeerIDs bool
}
// SendPrivateTextRequest 是私聊文本/媒体发送命令。
@ -283,7 +287,7 @@ type SendPrivateTextRequest struct {
// BusinessAutomationKind is internal app-layer metadata used to suppress
// recursive greeting/away automation for server-generated replies.
BusinessAutomationKind BusinessAutomationKind
// ReplyMarkup 是 bot 出站消息的 inline keyboard 快照P3;普通用户发送恒 nil。
// ReplyMarkup 是 bot 出站消息的 reply/inline keyboard 快照;普通用户发送恒 nil。
ReplyMarkup *MessageReplyMarkup
// RichMessage 是 Layer 227 富文本消息richMessage快照可选普通消息恒 nil。
RichMessage *MessageRichMessage

View file

@ -18,6 +18,10 @@ const (
MaxCallbackDataLen = 64
// MaxMarkupButtonTextLen 是按钮文本长度上限rune 计数)。
MaxMarkupButtonTextLen = 256
// MaxReplyKeyboardButtonTextLen 对齐 Bot API KeyboardButton 的 1-64 字符约束。
MaxReplyKeyboardButtonTextLen = 64
// MaxReplyKeyboardPlaceholderLen 是 reply keyboard / force reply 输入框占位符上限。
MaxReplyKeyboardPlaceholderLen = 64
// MaxBotCallbackAnswerLen 是 callback answer 弹窗/toast 文本上限。
MaxBotCallbackAnswerLen = 200
// MaxStartParamLen 是 messages.startBot 深链 payload 上限(对齐官方 64
@ -38,20 +42,81 @@ var (
ErrStartParamInvalid = errors.New("start param invalid")
)
// MarkupButtonType 标识 P3 支持的 inline 按钮类型。
// MarkupButtonType 标识消息键盘按钮类型。
type MarkupButtonType string
const (
// MarkupButtonText 是 reply keyboard 的普通文本按钮;点击后客户端发送标准文本消息。
MarkupButtonText MarkupButtonType = "text"
// MarkupButtonCallback 是 keyboardButtonCallback点击触发 getBotCallbackAnswer
MarkupButtonCallback MarkupButtonType = "callback"
// MarkupButtonURL 是 keyboardButtonUrl点击打开链接
MarkupButtonURL MarkupButtonType = "url"
MarkupButtonURL MarkupButtonType = "url"
MarkupButtonRequestPhone MarkupButtonType = "request_phone"
MarkupButtonRequestLocation MarkupButtonType = "request_location"
MarkupButtonRequestPoll MarkupButtonType = "request_poll"
MarkupButtonRequestPeer MarkupButtonType = "request_peer"
MarkupButtonWebView MarkupButtonType = "webview"
MarkupButtonSimpleWebView MarkupButtonType = "simple_webview"
MarkupButtonSwitchInline MarkupButtonType = "switch_inline"
MarkupButtonCopy MarkupButtonType = "copy"
)
// MarkupButton 是一颗 inline keyboard 按钮P3 仅 callback/url
// MarkupButtonStyle is the protocol-neutral semantic button color. Telegram
// intentionally exposes semantic colors instead of arbitrary RGB values.
type MarkupButtonStyle string
const (
MarkupButtonStylePrimary MarkupButtonStyle = "primary"
MarkupButtonStyleDanger MarkupButtonStyle = "danger"
MarkupButtonStyleSuccess MarkupButtonStyle = "success"
)
// BotRequestAdminRights mirrors Bot API ChatAdministratorRights without
// importing protocol types into persisted message state.
type BotRequestAdminRights struct {
Anonymous bool `json:"anonymous,omitempty"`
ManageChat bool `json:"manage_chat,omitempty"`
DeleteMessages bool `json:"delete_messages,omitempty"`
ManageVideoChats bool `json:"manage_video_chats,omitempty"`
RestrictMembers bool `json:"restrict_members,omitempty"`
PromoteMembers bool `json:"promote_members,omitempty"`
ChangeInfo bool `json:"change_info,omitempty"`
InviteUsers bool `json:"invite_users,omitempty"`
PostStories bool `json:"post_stories,omitempty"`
EditStories bool `json:"edit_stories,omitempty"`
DeleteStories bool `json:"delete_stories,omitempty"`
PostMessages bool `json:"post_messages,omitempty"`
EditMessages bool `json:"edit_messages,omitempty"`
PinMessages bool `json:"pin_messages,omitempty"`
ManageTopics bool `json:"manage_topics,omitempty"`
ManageDirectMessages bool `json:"manage_direct_messages,omitempty"`
}
type BotRequestPeerFilter struct {
UserIsBotSet bool `json:"user_is_bot_set,omitempty"`
UserIsBot bool `json:"user_is_bot,omitempty"`
UserIsPremiumSet bool `json:"user_is_premium_set,omitempty"`
UserIsPremium bool `json:"user_is_premium,omitempty"`
ChatHasUsernameSet bool `json:"chat_has_username_set,omitempty"`
ChatHasUsername bool `json:"chat_has_username,omitempty"`
ChatIsForumSet bool `json:"chat_is_forum_set,omitempty"`
ChatIsForum bool `json:"chat_is_forum,omitempty"`
ChatIsCreated bool `json:"chat_is_created,omitempty"`
BotIsMember bool `json:"bot_is_member,omitempty"`
UserAdminRights *BotRequestAdminRights `json:"user_admin_rights,omitempty"`
BotAdminRights *BotRequestAdminRights `json:"bot_admin_rights,omitempty"`
}
// MarkupButton 是一颗消息键盘按钮。reply keyboard 当前只接受普通文本按钮;
// inline keyboard 当前接受 callback/url。
type MarkupButton struct {
Type MarkupButtonType `json:"type"`
Text string `json:"text"`
// Style is one of primary/danger/success. Empty means the client default.
Style MarkupButtonStyle `json:"style,omitempty"`
// IconCustomEmojiID is the optional custom emoji rendered before Text.
IconCustomEmojiID int64 `json:"icon_custom_emoji_id,omitempty"`
// Data 仅 callback 使用:原始字节(含 0x00/非 UTF-8/高位。json 自动 base64
// 编解码,保证经 JSONB 列字节级 round-tripupdateBotCallbackQuery.data 须原样)。
Data []byte `json:"data,omitempty"`
@ -60,11 +125,68 @@ type MarkupButton struct {
// RequiresPassword 仅 callback 使用keyboardButtonCallback.requires_password
// 2FA SRP 校验 P3 stub
RequiresPassword bool `json:"requires_password,omitempty"`
// PollType is empty, "regular", or "quiz" for request_poll.
PollType string `json:"poll_type,omitempty"`
// ButtonID and request-peer fields preserve Bot API request_id and the
// client-side chooser shape. RequestPeerType is user/chat/broadcast.
ButtonID int `json:"button_id,omitempty"`
RequestPeerType string `json:"request_peer_type,omitempty"`
MaxQuantity int `json:"max_quantity,omitempty"`
NameRequested bool `json:"name_requested,omitempty"`
UsernameRequested bool `json:"username_requested,omitempty"`
PhotoRequested bool `json:"photo_requested,omitempty"`
RequestPeerFilter *BotRequestPeerFilter `json:"request_peer_filter,omitempty"`
Query string `json:"query,omitempty"`
SamePeer bool `json:"same_peer,omitempty"`
PeerTypes []string `json:"peer_types,omitempty"`
CopyText string `json:"copy_text,omitempty"`
}
// MessageReplyMarkup 是消息携带的 inline keyboard 快照P3 仅 ReplyInlineMarkup
// MessageReplyMarkupType 标识互斥的 ReplyMarkup constructor。
type MessageReplyMarkupType string
const (
MessageReplyMarkupInline MessageReplyMarkupType = "inline"
MessageReplyMarkupKeyboard MessageReplyMarkupType = "keyboard"
MessageReplyMarkupHide MessageReplyMarkupType = "hide"
MessageReplyMarkupForceReply MessageReplyMarkupType = "force_reply"
)
// MessageReplyMarkup 是消息携带的协议中立 reply markup 快照。Type 为空且 Inline
// 非空表示 0110 之前已经持久化的合法 inline keyboardKind 会将其解释为 inline。
type MessageReplyMarkup struct {
Inline [][]MarkupButton `json:"inline,omitempty"`
Type MessageReplyMarkupType `json:"type,omitempty"`
Inline [][]MarkupButton `json:"inline,omitempty"`
Keyboard [][]MarkupButton `json:"keyboard,omitempty"`
Resize bool `json:"resize,omitempty"`
SingleUse bool `json:"single_use,omitempty"`
Selective bool `json:"selective,omitempty"`
Persistent bool `json:"persistent,omitempty"`
Placeholder string `json:"placeholder,omitempty"`
}
// Kind 返回 markup constructor兼容已落库的无 Type inline 快照。
func (m *MessageReplyMarkup) Kind() MessageReplyMarkupType {
if m == nil {
return ""
}
if m.Type != "" {
return m.Type
}
if len(m.Inline) > 0 {
return MessageReplyMarkupInline
}
return ""
}
// IsReplyKeyboardFamily 报告 markup 是否会控制输入框下方的 reply keyboard。
func (m *MessageReplyMarkup) IsReplyKeyboardFamily() bool {
switch m.Kind() {
case MessageReplyMarkupKeyboard, MessageReplyMarkupHide, MessageReplyMarkupForceReply:
return true
default:
return false
}
}
// IsZero 报告 markup 是否为空(无任何按钮)。空 markup 不写 wire flag、不入库。
@ -72,26 +194,77 @@ func (m *MessageReplyMarkup) IsZero() bool {
if m == nil {
return true
}
for _, row := range m.Inline {
if len(row) > 0 {
return false
switch m.Kind() {
case MessageReplyMarkupInline:
for _, row := range m.Inline {
if len(row) > 0 {
return false
}
}
return true
case MessageReplyMarkupKeyboard:
for _, row := range m.Keyboard {
if len(row) > 0 {
return false
}
}
return true
case MessageReplyMarkupHide, MessageReplyMarkupForceReply:
return false
default:
return true
}
return true
}
// ValidateReplyMarkup 校验 inline keyboard 结构与各按钮校验须先于落库I9
// 空 markup 合法(视为清空/无键盘)。
// ValidateReplyMarkup 校验 markup constructor、结构与按钮校验须先于落库I9
// 空 inline markup 合法(视为清空/无键盘)。
func ValidateReplyMarkup(m *MessageReplyMarkup) error {
if m == nil {
return nil
}
if len(m.Inline) > MaxMarkupRows {
kind := m.Kind()
if kind == "" {
if len(m.Inline) != 0 || len(m.Keyboard) != 0 || m.Resize || m.SingleUse || m.Selective || m.Persistent || m.Placeholder != "" {
return ErrButtonInvalid
}
return nil
}
switch kind {
case MessageReplyMarkupInline:
if len(m.Keyboard) != 0 || m.Resize || m.SingleUse || m.Selective || m.Persistent || m.Placeholder != "" {
return ErrButtonInvalid
}
return validateMarkupRows(m.Inline, false)
case MessageReplyMarkupKeyboard:
if len(m.Inline) != 0 || utf8.RuneCountInString(m.Placeholder) > MaxReplyKeyboardPlaceholderLen {
return ErrButtonInvalid
}
if len(m.Keyboard) == 0 {
return ErrButtonInvalid
}
return validateMarkupRows(m.Keyboard, true)
case MessageReplyMarkupHide:
if len(m.Inline) != 0 || len(m.Keyboard) != 0 || m.Resize || m.SingleUse || m.Persistent || m.Placeholder != "" {
return ErrButtonInvalid
}
return nil
case MessageReplyMarkupForceReply:
if len(m.Inline) != 0 || len(m.Keyboard) != 0 || m.Resize || m.Persistent || utf8.RuneCountInString(m.Placeholder) > MaxReplyKeyboardPlaceholderLen {
return ErrButtonInvalid
}
return nil
default:
return ErrButtonInvalid
}
}
func validateMarkupRows(rows [][]MarkupButton, replyKeyboard bool) error {
if len(rows) > MaxMarkupRows {
return ErrButtonInvalid
}
total := 0
for _, row := range m.Inline {
if len(row) > MaxMarkupButtonsPerRow {
for _, row := range rows {
if len(row) == 0 || len(row) > MaxMarkupButtonsPerRow {
return ErrButtonInvalid
}
total += len(row)
@ -99,7 +272,7 @@ func ValidateReplyMarkup(m *MessageReplyMarkup) error {
return ErrButtonInvalid
}
for i := range row {
if err := validateMarkupButton(row[i]); err != nil {
if err := validateMarkupButton(row[i], replyKeyboard); err != nil {
return err
}
}
@ -107,11 +280,60 @@ func ValidateReplyMarkup(m *MessageReplyMarkup) error {
return nil
}
func validateMarkupButton(b MarkupButton) error {
func validateMarkupButton(b MarkupButton, replyKeyboard bool) error {
text := strings.TrimSpace(b.Text)
if text == "" || utf8.RuneCountInString(b.Text) > MaxMarkupButtonTextLen {
return ErrButtonInvalid
}
switch b.Style {
case "", MarkupButtonStylePrimary, MarkupButtonStyleDanger, MarkupButtonStyleSuccess:
default:
return ErrButtonInvalid
}
if b.IconCustomEmojiID < 0 {
return ErrButtonInvalid
}
if replyKeyboard {
if utf8.RuneCountInString(b.Text) > MaxReplyKeyboardButtonTextLen {
return ErrButtonInvalid
}
switch b.Type {
case MarkupButtonText, MarkupButtonRequestPhone, MarkupButtonRequestLocation:
case MarkupButtonRequestPoll:
if b.PollType != "" && b.PollType != "regular" && b.PollType != "quiz" {
return ErrButtonInvalid
}
case MarkupButtonRequestPeer:
if b.ButtonID == 0 || b.MaxQuantity < 1 || b.MaxQuantity > 10 ||
(b.RequestPeerType != "user" && b.RequestPeerType != "chat" && b.RequestPeerType != "broadcast") {
return ErrButtonInvalid
}
if b.RequestPeerFilter != nil {
filter := b.RequestPeerFilter
if b.RequestPeerType == "user" {
if filter.ChatHasUsernameSet || filter.ChatIsForumSet || filter.ChatIsCreated || filter.BotIsMember ||
filter.UserAdminRights != nil || filter.BotAdminRights != nil {
return ErrButtonInvalid
}
} else {
if filter.UserIsBotSet || filter.UserIsPremiumSet ||
(b.RequestPeerType == "broadcast" && (filter.ChatIsForumSet || filter.BotIsMember)) {
return ErrButtonInvalid
}
}
}
case MarkupButtonSimpleWebView:
if err := validateButtonURL(b.URL); err != nil {
return err
}
default:
return ErrButtonTypeInvalid
}
if len(b.Data) != 0 || b.RequiresPassword || b.Query != "" || b.SamePeer || len(b.PeerTypes) != 0 || b.CopyText != "" {
return ErrButtonInvalid
}
return nil
}
switch b.Type {
case MarkupButtonCallback:
if len(b.Data) > MaxCallbackDataLen {
@ -121,6 +343,18 @@ func validateMarkupButton(b MarkupButton) error {
if err := validateButtonURL(b.URL); err != nil {
return err
}
case MarkupButtonWebView:
if err := validateButtonURL(b.URL); err != nil {
return err
}
case MarkupButtonSwitchInline:
if utf8.RuneCountInString(b.Query) > 256 {
return ErrButtonInvalid
}
case MarkupButtonCopy:
if b.CopyText == "" || utf8.RuneCountInString(b.CopyText) > 256 {
return ErrButtonInvalid
}
default:
// webview/game/url_auth/request_* 等 P3 未实现类型:拒绝,绝不半实现下发。
return ErrButtonTypeInvalid

View file

@ -26,7 +26,20 @@ func TestValidateReplyMarkup(t *testing.T) {
{"url http bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "http://example.com"}}}}, ErrButtonURLInvalid},
{"url javascript bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "javascript:alert(1)"}}}}, ErrButtonURLInvalid},
{"url empty bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: ""}}}}, ErrButtonURLInvalid},
{"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "webview", Text: "x"}}}}, ErrButtonTypeInvalid},
{"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "rainbow", Text: "x"}}}}, ErrButtonTypeInvalid},
{"reply keyboard ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}, Resize: true, Persistent: true, Placeholder: "Choose"}, nil},
{"reply keyboard semantic style ok", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Delete", Style: MarkupButtonStyleDanger, IconCustomEmojiID: 123}}}}, nil},
{"inline semantic style ok", &MessageReplyMarkup{Type: MessageReplyMarkupInline, Inline: [][]MarkupButton{{{Type: MarkupButtonCallback, Text: "Confirm", Data: []byte("yes"), Style: MarkupButtonStyleSuccess}}}}, nil},
{"unknown semantic style bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Odd", Style: "rainbow"}}}}, ErrButtonInvalid},
{"negative custom emoji bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Odd", IconCustomEmojiID: -1}}}}, ErrButtonInvalid},
{"reply keyboard callback bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{cb("wrong", []byte("d"))}}}, ErrButtonTypeInvalid},
{"reply keyboard empty bad", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard}, ErrButtonInvalid},
{"reply keyboard placeholder too long", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}, Placeholder: strings.Repeat("p", MaxReplyKeyboardPlaceholderLen+1)}, ErrButtonInvalid},
{"reply keyboard text too long", &MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: strings.Repeat("x", MaxReplyKeyboardButtonTextLen+1)}}}}, ErrButtonInvalid},
{"hide keyboard ok", &MessageReplyMarkup{Type: MessageReplyMarkupHide, Selective: true}, nil},
{"force reply ok", &MessageReplyMarkup{Type: MessageReplyMarkupForceReply, SingleUse: true, Placeholder: "Answer"}, nil},
{"missing keyboard constructor", &MessageReplyMarkup{Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}}, ErrButtonInvalid},
{"inline constructor with keyboard payload", &MessageReplyMarkup{Type: MessageReplyMarkupInline, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "Help"}}}}, ErrButtonInvalid},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -74,4 +87,10 @@ func TestMessageReplyMarkupIsZero(t *testing.T) {
if (&MessageReplyMarkup{Inline: [][]MarkupButton{{cb("x", nil)}}}).IsZero() {
t.Fatal("markup with a button must not be zero")
}
if (&MessageReplyMarkup{Type: MessageReplyMarkupKeyboard, Keyboard: [][]MarkupButton{{{Type: MarkupButtonText, Text: "x"}}}}).IsZero() {
t.Fatal("reply keyboard with a button must not be zero")
}
if (&MessageReplyMarkup{Type: MessageReplyMarkupHide}).IsZero() {
t.Fatal("hide keyboard constructor must not be zero")
}
}

View file

@ -24,29 +24,85 @@ type StarGift struct {
UpgradeIssued int // 当前已发行数量
Title string // 可选标题
Sticker Document // 礼物贴纸快照tg 投影必须是带 sticker 属性的有效 Document否则客户端丢弃
// Layer 228 regular-gift shape. Static release facts live in the immutable
// catalog revision; AvailabilityRemains/AvailabilityResale are the current
// inventory projection maintained on the catalog aggregate.
Limited bool
SoldOut bool
Birthday bool
RequirePremium bool
LimitedPerUser bool
PeerColorAvailable bool
Auction bool
AvailabilityRemains int
AvailabilityTotal int
AvailabilityResale int64
FirstSaleDate int
LastSaleDate int
ResellMinStars int64
ReleasedBy Peer
PerUserTotal int
PerUserRemains int
LockedUntilDate int
AuctionSlug string
GiftsPerRound int
AuctionStartDate int
UpgradeVariants int
Background *StarGiftBackground
}
// StarGiftBackground is the release-level palette used by auction cards and
// gift previews before a collectible backdrop is selected.
type StarGiftBackground struct {
CenterColor int
EdgeColor int
TextColor int
}
// SavedStarGift 是一条已收到的礼物实例peer_star_gifts 一行)。
type SavedStarGift struct {
ID int64
Owner Peer // 收礼 peeruser/channel
FromUserID int64 // 送礼人(匿名也保留真实值供账本,下发时按 NameHidden 决定是否暴露)
GiftID int64 // → StarGift.ID
RevisionID int64 // → star_gift_catalog_revisions.id历史查询必须按此版本投影
MsgID int // 用户礼物的私聊 msg_id频道礼物不进历史固定为 0
SavedID int64 // 频道礼物 inputSavedStarGiftChat.saved_id用户礼物为 0
Date int // 收到时刻 Unix 秒
NameHidden bool // 送礼人请求隐藏姓名
Unsaved bool // 未展示在个人资料saveStarGift 切换)
Converted bool // 已转换回 Stars终态从列表排除
ConvertStars int64 // 转换可退回的 Stars
PrepaidUpgradeStars int64 // 送礼人随礼物预付的唯一礼物升级额
Message string // 附言(可选)
UniqueGiftID int64 // 非 0 表示已升级为唯一礼物;与 Converted 互斥
UpgradeMsgID int // messageActionStarGiftUnique 的 owner 侧消息 id
PinnedOrder int // >0 表示资料页置顶顺序
CollectionIDs []int // 当前所属集合;按集合顺序稳定返回
Unique *UniqueStarGift
ID int64
Owner Peer // 收礼 peeruser/channel
FromUserID int64 // 送礼人(匿名也保留真实值供账本,下发时按 NameHidden 决定是否暴露)
GiftID int64 // → StarGift.ID
RevisionID int64 // → star_gift_catalog_revisions.id历史查询必须按此版本投影
MsgID int // 用户礼物的私聊 msg_id频道礼物不进历史固定为 0
SavedID int64 // 频道礼物 inputSavedStarGiftChat.saved_id用户礼物为 0
Date int // 收到时刻 Unix 秒
NameHidden bool // 送礼人请求隐藏姓名
Unsaved bool // 未展示在个人资料saveStarGift 切换)
Converted bool // 已转换回 Stars终态从列表排除
LifecycleStatus StarGiftLifecycleStatus
ConvertStars int64 // 转换可退回的 Stars
PrepaidUpgradeStars int64 // 送礼人随礼物预付的唯一礼物升级额
PrepaidUpgradeHash string // 第三方单独代付升级的一次性 entitlement
GiftNum int // auction-acquired release number for regular gifts
Message string // 附言(可选)
UniqueGiftID int64 // 非 0 表示已升级为唯一礼物;与 Converted 互斥
TransferStars int64
CanExportAt int
CanTransferAt int
CanResellAt int
DropOriginalDetailsStars int64
CanCraftAt int
UpgradeMsgID int // 当前 owner 侧承载 messageActionStarGiftUnique 的消息 id所有权转移时随新消息更新
PinnedOrder int // >0 表示资料页置顶顺序
CollectionIDs []int // 当前所属集合;按集合顺序稳定返回
Unique *UniqueStarGift
}
type StarGiftLifecycleStatus string
const (
StarGiftLifecycleActive StarGiftLifecycleStatus = "active"
StarGiftLifecycleConverted StarGiftLifecycleStatus = "converted"
StarGiftLifecycleBurned StarGiftLifecycleStatus = "burned"
StarGiftLifecycleExported StarGiftLifecycleStatus = "exported"
)
func (s StarGiftLifecycleStatus) Live() bool {
return s == StarGiftLifecycleActive
}
// StarGiftCollectibleAttributeKind 是唯一礼物三个必选属性槽位。
@ -58,8 +114,31 @@ const (
StarGiftCollectibleBackdrop StarGiftCollectibleAttributeKind = "backdrop"
)
// StarGiftCollectibleAttribute 是已发布属性池的一项。RarityPermille 同时是客户端展示的
// 精确稀有度和升级抽取概率;同一 revision、同一 kind 的总和必须恰好为 1000。
// StarGiftAttributeRarityKind mirrors the Layer 228 rarity union. Permille is the only
// kind eligible for a regular upgrade draw; named rarities are currently used by
// craft-only models and must still be preserved in the published attribute directory.
type StarGiftAttributeRarityKind string
const (
StarGiftRarityPermille StarGiftAttributeRarityKind = "permille"
StarGiftRarityUncommon StarGiftAttributeRarityKind = "uncommon"
StarGiftRarityRare StarGiftAttributeRarityKind = "rare"
StarGiftRarityEpic StarGiftAttributeRarityKind = "epic"
StarGiftRarityLegendary StarGiftAttributeRarityKind = "legendary"
)
func (k StarGiftAttributeRarityKind) Valid() bool {
switch k {
case StarGiftRarityPermille, StarGiftRarityUncommon, StarGiftRarityRare,
StarGiftRarityEpic, StarGiftRarityLegendary:
return true
default:
return false
}
}
// StarGiftCollectibleAttribute 是已发布属性池的一项。RarityKind/RarityPermille
// 是客户端展示事实;普通升级把非 crafted 的 permille 值当相对权重,不要求合计为 1000。
type StarGiftCollectibleAttribute struct {
ID int64
CollectibleRevisionID int64
@ -71,7 +150,10 @@ type StarGiftCollectibleAttribute struct {
EdgeColor int
PatternColor int
TextColor int
RarityKind StarGiftAttributeRarityKind
RarityPermille int
Crafted bool
OfficialDocumentID int64
SortOrder int
Animation *StarGiftAnimation
Blob *FileBlob
@ -79,33 +161,37 @@ type StarGiftCollectibleAttribute struct {
// StarGiftCollectibleRevision 是某普通礼物的一份不可变、可发布属性池。
type StarGiftCollectibleRevision struct {
ID int64
GiftID int64
Revision int
UpgradeStars int64
SupplyTotal int
Issued int
SlugPrefix string
Published bool
Models []StarGiftCollectibleAttribute
Patterns []StarGiftCollectibleAttribute
Backdrops []StarGiftCollectibleAttribute
CreatedBy string
CreatedAt time.Time
PublishedAt time.Time
ID int64
GiftID int64
Revision int
UpgradeStars int64
SupplyTotal int
Issued int
SlugPrefix string
Published bool
Models []StarGiftCollectibleAttribute
Patterns []StarGiftCollectibleAttribute
Backdrops []StarGiftCollectibleAttribute
CreatedBy string
CreatedAt time.Time
PublishedAt time.Time
OfficialGiftID int64
SourceManifestSHA256 []byte
}
// StarGiftCollectibleWrite 是后台创建/发布属性池的协议无关输入。
type StarGiftCollectibleWrite struct {
GiftID int64
UpgradeStars int64
SupplyTotal int
SlugPrefix string
Models []StarGiftCollectibleAttribute
Patterns []StarGiftCollectibleAttribute
Backdrops []StarGiftCollectibleAttribute
Actor string
CommandID string
GiftID int64
UpgradeStars int64
SupplyTotal int
SlugPrefix string
Models []StarGiftCollectibleAttribute
Patterns []StarGiftCollectibleAttribute
Backdrops []StarGiftCollectibleAttribute
Actor string
CommandID string
OfficialGiftID int64
SourceManifestSHA256 []byte
}
// UniqueStarGift 是一份已经发行的唯一礼物。属性、编号与 slug 一经创建永久不变。
@ -118,6 +204,26 @@ type UniqueStarGift struct {
Slug string
Num int
Owner Peer
RequirePremium bool
ResaleTonOnly bool
ThemeAvailable bool
Burned bool
Crafted bool
OwnerName string
OwnerAddress string
GiftAddress string
ResellAmount *StarGiftAmount
ResellVersion int64
ReleasedBy Peer
ValueAmount int64
ValueCurrency string
ValueUSD int64
ThemePeer Peer
Host Peer
OfferMinStars int
CraftChancePermille int
LastSaleDate int
LastSaleAmount *StarGiftAmount
Model StarGiftCollectibleAttribute
Pattern StarGiftCollectibleAttribute
Backdrop StarGiftCollectibleAttribute
@ -132,6 +238,56 @@ 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 (
StarGiftCurrencyStars StarGiftCurrency = "XTR"
StarGiftCurrencyTON StarGiftCurrency = "TON"
)
type StarGiftAmount struct {
Currency StarGiftCurrency
Amount int64
Nanos int
}
func (a StarGiftAmount) Valid() bool {
if a.Amount <= 0 {
return false
}
switch a.Currency {
case StarGiftCurrencyStars:
return a.Nanos >= -999999999 && a.Nanos <= 999999999
case StarGiftCurrencyTON:
return a.Nanos == 0
default:
return false
}
}
// StarGiftUpgradePreview 是客户端升级弹窗所需的当前价格和属性样例。
type StarGiftUpgradePreview struct {
GiftID int64
@ -171,7 +327,205 @@ type StarGiftUpgradeRequest struct {
OriginSessionID int64
}
type StarGiftPurchaseRequest struct {
BuyerUserID int64
BuyerPremium bool
To Peer
GiftID int64
RevisionID int64
IncludeUpgrade bool
HideName bool
Message string
ChargeStars int64
FormID int64
CommandKey string
Date int
RecipientBlocked bool
OriginAuthKeyID [8]byte
OriginSessionID int64
}
// StarGiftPurchaseForm is the server-issued, short-lived payment intent that
// binds payments.getPaymentForm to one later payments.sendStarsForm call. A
// fresh form represents a fresh purchase even when every invoice field is the
// same; retrying one form represents the same purchase command.
type StarGiftPurchaseForm struct {
FormID int64
BuyerUserID int64
To Peer
GiftID int64
RevisionID int64
IncludeUpgrade bool
HideName bool
Message string
ChargeStars int64
IssuedAt int
ExpiresAt int
}
type StarGiftPurchaseResult struct {
Gift StarGift
Saved SavedStarGift
Balance StarsBalance
Send SendPrivateTextResult
Duplicate bool
}
// StarGiftConvertRequest identifies one owner-scoped regular gift conversion.
// ActorUserID is the authenticated user who owns the user gift or administers
// the channel gift; authorization is checked again at the RPC boundary.
type StarGiftConvertRequest struct {
ActorUserID int64
Ref SavedStarGiftRef
Date int
}
// StarGiftConvertResult exposes the committed aggregate state. OwnerBalance is
// the post-credit balance of either the user or the channel internal Stars
// ledger selected by Saved.Owner.
type StarGiftConvertResult struct {
Saved SavedStarGift
OwnerBalance int64
}
type StarGiftUpgradeResult struct {
Saved SavedStarGift
Unique UniqueStarGift
Balance StarsBalance
Send SendPrivateTextResult
SourceEdits []EditedMessageForUser
Duplicate bool
}
// StarGiftUpgradeReceipt is the immutable command envelope needed to replay a
// committed upgrade after the saved gift has entered its unique terminal state.
// In particular, a paid replay must not be rebound to a later catalog price.
type StarGiftUpgradeReceipt struct {
UserID int64
SourceSavedGiftID int64
FormID int64
UniqueGiftID int64
ChargeStars int64
BalanceAfter int64
SourceEditPts int
RequirePrepaid bool
KeepOriginalDetails bool
}
type StarGiftPrepaidUpgradeRequest struct {
PayerUserID int64
Owner Peer
Hash string
ChargeStars int64
FormID int64
CommandKey string
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
}
type StarGiftPrepaidUpgradeResult struct {
Saved SavedStarGift
Balance StarsBalance
Send SendPrivateTextResult
Duplicate bool
}
type StarGiftDropOriginalDetailsRequest struct {
UserID int64
Ref SavedStarGiftRef
ChargeStars int64
FormID int64
CommandKey string
Date int
}
type StarGiftDropOriginalDetailsResult struct {
Saved SavedStarGift
Unique UniqueStarGift
Balance StarsBalance
Duplicate bool
}
// StarGiftLifecyclePolicy is the server-owned policy snapshotted when a regular
// gift becomes collectible. It deliberately contains no wallet/node/provider
// configuration: TON remains only a currency unit in the local ledger.
type StarGiftLifecyclePolicy struct {
TransferStars int64
DropOriginalDetailsStars int64
OfferMinStars int
ExportDelaySeconds int
TransferDelaySeconds int
ResellDelaySeconds int
CraftDelaySeconds int
CraftChancePermille int
}
func (p StarGiftLifecyclePolicy) Valid() bool {
return p.TransferStars >= 0 && p.DropOriginalDetailsStars >= 0 && p.OfferMinStars >= 0 &&
p.ExportDelaySeconds >= 0 && p.TransferDelaySeconds >= 0 && p.ResellDelaySeconds >= 0 &&
p.CraftDelaySeconds >= 0 && p.CraftChancePermille >= 0 && p.CraftChancePermille <= 1000
}
// StarGiftMarketPolicy is snapshotted in the running aggregate coordinator.
// Proceeds permille is the seller share; the remainder is recorded as platform
// commission. TON is still only a unit in the local ledger.
type StarGiftMarketPolicy struct {
StarsProceedsPermille int
TONProceedsPermille int
}
func (p StarGiftMarketPolicy) Valid() bool {
return p.StarsProceedsPermille >= 0 && p.StarsProceedsPermille <= 1000 &&
p.TONProceedsPermille >= 0 && p.TONProceedsPermille <= 1000
}
type StarGiftResaleFilter struct {
GiftID int64
SortByPrice bool
SortByNum bool
ForCraft bool
StarsOnly bool
ModelIDs []int64
PatternIDs []int64
BackdropIDs []int64
Offset string
Limit int
}
type StarGiftResalePage struct {
Gifts []UniqueStarGift
Count int
NextOffset string
}
type StarGiftValueInfo struct {
Currency string
Value int64
ValueIsAverage bool
InitialSaleDate int
InitialSaleStars int64
InitialSalePrice int64
LastSaleDate int
LastSalePrice int64
FloorPrice int64
AveragePrice int64
ListedCount int
}
type StarGiftTransferRequest struct {
ActorUserID int64
Ref SavedStarGiftRef
To Peer
ChargeStars int64
FormID int64
CommandKey string
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
}
type StarGiftTransferResult struct {
Saved SavedStarGift
Unique UniqueStarGift
Balance StarsBalance
@ -179,6 +533,161 @@ type StarGiftUpgradeResult struct {
Duplicate bool
}
type StarGiftListingRequest struct {
ActorUserID int64
Ref SavedStarGiftRef
Amount *StarGiftAmount
Date int
}
type StarGiftResalePurchaseRequest struct {
BuyerUserID int64
Slug string
To Peer
Amount StarGiftAmount
FormID int64
CommandKey string
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
}
type StarGiftOfferRequest struct {
BuyerUserID int64
Owner Peer
Slug string
Price StarGiftAmount
Duration int
RandomID int64
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
}
type StarGiftOffer struct {
ID int64
BuyerUserID int64
Owner Peer
UniqueGiftID int64
Price StarGiftAmount
RandomID int64
OfferMsgID int
BuyerMsgID int
Status string
CreatedAt int
ExpiresAt int
ResolvedAt int
Gift UniqueStarGift
}
type StarGiftOfferResult struct {
Offer StarGiftOffer
Saved SavedStarGift
Unique UniqueStarGift
Balance StarsBalance
Send SendPrivateTextResult
Duplicate bool
}
type StarGiftResolveOfferRequest struct {
OwnerUserID int64
OfferMsgID int
Decline bool
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
}
type StarGiftCraftRequest struct {
UserID int64
Refs []SavedStarGiftRef
CommandKey string
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
}
type StarGiftCraftResult struct {
Success bool
Chance int
Gift *UniqueStarGift
Send SendPrivateTextResult
SourceEdits []EditedMessageForUser
Duplicate bool
}
type StarGiftAuction struct {
Gift StarGift
Version int
StartDate int
EndDate int
MinBidAmount int64
NextRoundAt int
LastGiftNum int
GiftsLeft int
CurrentRound int
TotalRounds int
RoundDuration int
BidLevels []StarGiftAuctionBidLevel
TopBidders []int64
UserState StarGiftAuctionUserState
Finished bool
AveragePrice int64
ListedCount int
}
type StarGiftAuctionBidLevel struct {
Pos int
Amount int64
Date int
}
type StarGiftAuctionUserState struct {
Returned bool
BidAmount int64
BidDate int
MinBidAmount int64
BidPeer Peer
AcquiredCount int
}
type StarGiftAuctionBidRequest struct {
UserID int64
GiftID int64
Peer Peer
BidAmount int64
HideName bool
Message string
UpdateBid bool
FormID int64
Date int
}
type StarGiftAuctionAcquired struct {
Peer Peer
Date int
BidAmount int64
Round int
Pos int
Message string
GiftNum int
NameHidden bool
}
type StarGiftWithdrawalRequest struct {
UserID int64
Ref SavedStarGiftRef
Date int
}
type StarGiftWithdrawal struct {
ProviderRequestID string
URL string
ExpiresAt int
Status string
Gift UniqueStarGift
}
// StarGiftCollection 是 peer 资料页中的礼物集合;一份礼物可属于多个集合。
type StarGiftCollection struct {
Owner Peer
@ -223,17 +732,53 @@ type StarGiftAnimation struct {
// StarGiftCatalogWrite 是 store 原子创建目录版本所需的协议无关数据。
type StarGiftCatalogWrite struct {
GiftID int64 // 0 创建新礼物;非 0 为该礼物创建新 revision
Title string
Stars int64
ConvertStars int64
Enabled bool
SortOrder int
Document Document
Blob FileBlob
Animation StarGiftAnimation
Actor string
CommandID string
GiftID int64 // 0 创建新礼物;非 0 为该礼物创建新 revision
Title string
Stars int64
ConvertStars int64
Enabled bool
SortOrder int
Document Document
Blob FileBlob
Animation StarGiftAnimation
Actor string
CommandID string
OfficialGiftID int64
SourceManifestSHA256 []byte
OfficialSourceJSON []byte
Limited bool
SoldOut bool
Birthday bool
RequirePremium bool
LimitedPerUser bool
PeerColorAvailable bool
Auction bool
AvailabilityRemains int
AvailabilityTotal int
AvailabilityResale int64
FirstSaleDate int
LastSaleDate int
ResellMinStars int64
ReleasedBy Peer
PerUserTotal int
LockedUntilDate int
AuctionSlug string
GiftsPerRound int
AuctionStartDate int
UpgradeVariants int
Background *StarGiftBackground
}
// StarGiftCatalogBundleWrite atomically publishes one catalog revision and its optional
// complete collectible pool. Collectible.GiftID is filled with the allocated local gift ID.
type StarGiftCatalogBundleWrite struct {
Catalog StarGiftCatalogWrite
Collectible *StarGiftCollectibleWrite
}
type StarGiftCatalogBundleResult struct {
Catalog StarGiftCatalogEntry
Collectible *StarGiftCollectibleRevision
}
// StarGiftCatalogEntry 是管理后台目录视图。
@ -255,20 +800,24 @@ type StarGiftCatalogEntry struct {
}
// SavedStarGiftRef 是 payments.getSavedStarGift/saveStarGift/convertStarGift 的协议中立引用。
// 用户礼物使用 inputSavedStarGiftUser.msg_id频道礼物使用 inputSavedStarGiftChat.peer + saved_id。
// 用户礼物使用 inputSavedStarGiftUser.msg_id频道礼物使用 inputSavedStarGiftChat.peer + saved_id
// 已升级的唯一礼物也可使用官方 inputSavedStarGiftSlug.slug。三种身份必须互斥。
type SavedStarGiftRef struct {
Owner Peer
MsgID int
SavedID int64
Slug string
}
// Valid reports whether the reference has the identity required by its owner kind.
func (r SavedStarGiftRef) Valid() bool {
slug := strings.TrimSpace(r.Slug)
validSlug := slug != "" && slug == r.Slug && len(slug) <= MaxStarGiftSlugBytes && r.MsgID == 0 && r.SavedID == 0
switch r.Owner.Type {
case PeerTypeUser:
return r.Owner.ID != 0 && r.MsgID > 0
return r.Owner.ID != 0 && (validSlug || r.MsgID > 0 && r.SavedID == 0 && slug == "")
case PeerTypeChannel:
return r.Owner.ID != 0 && r.SavedID > 0
return r.Owner.ID != 0 && (validSlug || r.SavedID > 0 && r.MsgID == 0 && slug == "")
default:
return false
}
@ -281,6 +830,14 @@ type SavedStarGiftPage struct {
Count int // 总数(未转换、按 excludeUnsaved 过滤后)
}
// SavedStarGiftListCursor is the composite keyset cursor for the profile gift
// order: pinned gifts first by PinnedOrder, then unpinned gifts by ID DESC.
// PinnedOrder == 0 identifies the unpinned segment.
type SavedStarGiftListCursor struct {
PinnedOrder int
ID int64
}
// SavedStarGiftFilter describes the client-visible filters supported by
// payments.getSavedStarGifts. CollectionID is the collection membership filter;
// zero means all collections. The current catalog is used only to decide whether
@ -317,7 +874,8 @@ const (
// MaxStarGiftCatalogSize 是当前普通礼物目录的有界上限。
MaxStarGiftCatalogSize = 500
MaxStarGiftTitleRunes = 128
MaxStarGiftCollectibleAttributesPerKind = 256
MaxStarGiftSlugBytes = 255
MaxStarGiftCollectibleAttributesPerKind = 512
MaxStarGiftCollectionTitleRunes = 12
MaxStarGiftCollectionsPerPeer = 100
MaxStarGiftCollectionItems = 1000
@ -339,6 +897,18 @@ var (
ErrStarGiftCollectibleInvalid = errors.New("stargift: invalid collectible definition")
ErrStarGiftCollectionNotFound = errors.New("stargift: collection not found")
ErrStarGiftCollectionsFull = errors.New("stargift: collections full")
ErrStarGiftUnavailable = errors.New("stargift: unavailable")
ErrStarGiftOwnerInvalid = errors.New("stargift: owner invalid")
ErrStarGiftTransferUnavailable = errors.New("stargift: transfer unavailable")
ErrStarGiftResaleUnavailable = errors.New("stargift: resale unavailable")
ErrStarGiftOfferInvalid = errors.New("stargift: offer invalid")
ErrStarGiftOfferExpired = errors.New("stargift: offer expired")
ErrStarGiftCraftUnavailable = errors.New("stargift: craft unavailable")
ErrStarGiftAuctionUnavailable = errors.New("stargift: auction unavailable")
ErrStarGiftWithdrawalUnavailable = errors.New("stargift: withdrawal provider unavailable")
ErrStarGiftFormExpired = errors.New("stargift: payment form expired")
ErrStarGiftFormPurposeInvalid = errors.New("stargift: payment form purpose invalid")
ErrStarGiftFormAmountMismatch = errors.New("stargift: payment form amount mismatch")
)
var starGiftCollectibleSlugPrefix = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,47}$`)
@ -351,6 +921,11 @@ func ValidateStarGiftCollectibleDraft(write StarGiftCollectibleWrite) error {
!starGiftCollectibleSlugPrefix.MatchString(write.SlugPrefix) || strings.TrimSpace(write.CommandID) == "" {
return ErrStarGiftCollectibleInvalid
}
if write.OfficialGiftID < 0 ||
(write.OfficialGiftID == 0 && len(write.SourceManifestSHA256) != 0) ||
(write.OfficialGiftID > 0 && len(write.SourceManifestSHA256) != 32) {
return ErrStarGiftCollectibleInvalid
}
if err := validateStarGiftAttributes(write.Models, StarGiftCollectibleModel, false); err != nil {
return err
}
@ -380,11 +955,19 @@ func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind
return ErrStarGiftCollectibleInvalid
}
seen := make(map[string]struct{}, len(attributes))
total := 0
selectable := 0
for _, attribute := range attributes {
name := strings.TrimSpace(attribute.Name)
if attribute.Kind != kind || name == "" || len([]rune(name)) > MaxStarGiftTitleRunes ||
attribute.RarityPermille <= 0 || attribute.RarityPermille > 1000 {
rarityKind := attribute.RarityKind
if attribute.Kind != kind || name == "" || len([]rune(name)) > MaxStarGiftTitleRunes || !rarityKind.Valid() {
return ErrStarGiftCollectibleInvalid
}
if rarityKind == StarGiftRarityPermille {
if attribute.RarityPermille <= 0 || attribute.RarityPermille > 1000 || attribute.Crafted {
return ErrStarGiftCollectibleInvalid
}
selectable++
} else if attribute.RarityPermille != 0 || !attribute.Crafted || kind != StarGiftCollectibleModel {
return ErrStarGiftCollectibleInvalid
}
key := strings.ToLower(name)
@ -392,19 +975,19 @@ func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind
return ErrStarGiftCollectibleInvalid
}
seen[key] = struct{}{}
total += attribute.RarityPermille
switch kind {
case StarGiftCollectibleModel, StarGiftCollectiblePattern:
if attribute.Animation == nil || len(attribute.Animation.JSON) == 0 ||
len(attribute.Animation.TGS) == 0 || len(attribute.Animation.SHA256) != 32 {
return ErrStarGiftCollectibleInvalid
}
if requireStoredAsset && (attribute.Document == nil || !attribute.Document.IsSticker() ||
if requireStoredAsset && (attribute.Document == nil ||
!validStarGiftCollectibleDocument(*attribute.Document, kind) ||
attribute.Document.MimeType != "application/x-tgsticker" || attribute.Blob == nil) {
return ErrStarGiftCollectibleInvalid
}
case StarGiftCollectibleBackdrop:
if attribute.BackdropID <= 0 || attribute.Document != nil ||
if attribute.BackdropID < 0 || attribute.Document != nil ||
attribute.CenterColor < 0 || attribute.CenterColor > 0xffffff ||
attribute.EdgeColor < 0 || attribute.EdgeColor > 0xffffff ||
attribute.PatternColor < 0 || attribute.PatternColor > 0xffffff ||
@ -415,12 +998,44 @@ func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind
return ErrStarGiftCollectibleInvalid
}
}
if total != 1000 {
if selectable == 0 {
return ErrStarGiftCollectibleInvalid
}
return nil
}
// validStarGiftCollectibleDocument enforces the client-visible document roles
// materialized by the Star Gift write boundary. Models are ordinary stickers.
// Patterns are text-color custom emoji with an inline PhotoPathSize so Android
// can classify and tint the TGS before its full first frame is downloaded.
func validStarGiftCollectibleDocument(document Document, kind StarGiftCollectibleAttributeKind) bool {
renderAttributes := 0
validRenderAttribute := false
for _, attribute := range document.Attributes {
switch attribute.Kind {
case DocAttrSticker:
renderAttributes++
validRenderAttribute = validRenderAttribute || kind == StarGiftCollectibleModel
case DocAttrCustomEmoji:
renderAttributes++
validRenderAttribute = validRenderAttribute ||
(kind == StarGiftCollectiblePattern && attribute.TextColor)
}
}
if renderAttributes != 1 || !validRenderAttribute {
return false
}
if kind == StarGiftCollectibleModel {
return true
}
for _, thumb := range document.Thumbs {
if thumb.Kind == PhotoSizeKindPath && strings.TrimSpace(thumb.Type) != "" && len(thumb.Bytes) > 0 {
return true
}
}
return false
}
// StarGiftCatalogHash 由客户端可见目录字段折叠出稳定 hash供 getStarGifts NotModified。
func StarGiftCatalogHash(catalog []StarGift) int {
var h uint64
@ -462,7 +1077,44 @@ func StarGiftCollectionHash(title string, giftIDs []int64) int64 {
return int64(h & 0x7fffffffffffffff)
}
// EncodeStarGiftCursor / DecodeStarGiftCursor 是 saved gifts keyset 游标(最后一条实例 id
// EncodeSavedStarGiftListCursor encodes the exact profile-order key of the last
// visible gift. The version prefix keeps this cursor distinct from other star
// gift lists that are ordered only by instance ID.
func EncodeSavedStarGiftListCursor(pinnedOrder int, id int64) string {
if pinnedOrder < 0 || id <= 0 {
return ""
}
raw := "v1:" + strconv.Itoa(pinnedOrder) + ":" + strconv.FormatInt(id, 10)
return base64.RawURLEncoding.EncodeToString([]byte(raw))
}
// DecodeSavedStarGiftListCursor decodes a profile gift list cursor. Invalid or
// obsolete cursor shapes are rejected instead of being normalized on read.
func DecodeSavedStarGiftListCursor(s string) (SavedStarGiftListCursor, bool) {
if s == "" {
return SavedStarGiftListCursor{}, false
}
raw, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return SavedStarGiftListCursor{}, false
}
parts := strings.Split(string(raw), ":")
if len(parts) != 3 || parts[0] != "v1" {
return SavedStarGiftListCursor{}, false
}
order, err := strconv.ParseInt(parts[1], 10, 32)
if err != nil || order < 0 {
return SavedStarGiftListCursor{}, false
}
id, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil || id <= 0 {
return SavedStarGiftListCursor{}, false
}
return SavedStarGiftListCursor{PinnedOrder: int(order), ID: id}, true
}
// EncodeStarGiftCursor / DecodeStarGiftCursor are simple instance-ID cursors
// used by star gift lists whose order is strictly ID DESC (for example craft).
func EncodeStarGiftCursor(id int64) string {
return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10)))
}

View file

@ -0,0 +1,147 @@
package domain
import (
"crypto/sha256"
"errors"
"strings"
"testing"
)
func TestSavedStarGiftRefRequiresOneOfficialIdentity(t *testing.T) {
user := Peer{Type: PeerTypeUser, ID: 42}
channel := Peer{Type: PeerTypeChannel, ID: 84}
tests := []struct {
name string
ref SavedStarGiftRef
want bool
}{
{name: "user message", ref: SavedStarGiftRef{Owner: user, MsgID: 10}, want: true},
{name: "channel saved id", ref: SavedStarGiftRef{Owner: channel, SavedID: 20}, want: true},
{name: "user collectible slug", ref: SavedStarGiftRef{Owner: user, Slug: "official-42-1"}, want: true},
{name: "channel collectible slug", ref: SavedStarGiftRef{Owner: channel, Slug: "official-84-1"}, want: true},
{name: "message and slug", ref: SavedStarGiftRef{Owner: user, MsgID: 10, Slug: "official-42-1"}},
{name: "saved id and slug", ref: SavedStarGiftRef{Owner: channel, SavedID: 20, Slug: "official-84-1"}},
{name: "whitespace slug", ref: SavedStarGiftRef{Owner: user, Slug: " official-42-1"}},
{name: "oversized slug", ref: SavedStarGiftRef{Owner: user, Slug: strings.Repeat("x", MaxStarGiftSlugBytes+1)}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.ref.Valid(); got != tt.want {
t.Fatalf("Valid() = %v, want %v", got, tt.want)
}
})
}
}
func TestStarGiftLifecycleStatusRequiresExplicitActive(t *testing.T) {
if StarGiftLifecycleStatus("").Live() {
t.Fatal("empty lifecycle status must not be treated as active")
}
if !StarGiftLifecycleActive.Live() {
t.Fatal("active lifecycle status must be live")
}
}
func validCollectibleDraft() StarGiftCollectibleWrite {
animation := &StarGiftAnimation{JSON: []byte(`{}`), TGS: []byte{1}, SHA256: make([]byte, sha256.Size)}
return StarGiftCollectibleWrite{
GiftID: 1, UpgradeStars: 25, SupplyTotal: 100, SlugPrefix: "official-1", CommandID: "test",
Models: []StarGiftCollectibleAttribute{
{Kind: StarGiftCollectibleModel, Name: "Regular", RarityKind: StarGiftRarityPermille, RarityPermille: 922, Animation: animation},
{Kind: StarGiftCollectibleModel, Name: "Crafted", RarityKind: StarGiftRarityLegendary, Crafted: true, Animation: animation},
},
Patterns: []StarGiftCollectibleAttribute{
{Kind: StarGiftCollectiblePattern, Name: "Pattern", RarityKind: StarGiftRarityPermille, RarityPermille: 989, Animation: animation},
},
Backdrops: []StarGiftCollectibleAttribute{
{Kind: StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 0, RarityKind: StarGiftRarityPermille, RarityPermille: 999},
},
}
}
func TestValidateStarGiftCollectibleDraftOfficialProvenance(t *testing.T) {
write := validCollectibleDraft()
write.OfficialGiftID = 10
write.SourceManifestSHA256 = make([]byte, sha256.Size)
if err := ValidateStarGiftCollectibleDraft(write); err != nil {
t.Fatalf("valid official draft: %v", err)
}
tests := map[string]StarGiftCollectibleWrite{}
withoutHash := write
withoutHash.SourceManifestSHA256 = nil
tests["official ID without hash"] = withoutHash
withoutID := write
withoutID.OfficialGiftID = 0
tests["hash without official ID"] = withoutID
negativeID := write
negativeID.OfficialGiftID = -1
tests["negative official ID"] = negativeID
for name, invalid := range tests {
t.Run(name, func(t *testing.T) {
if err := ValidateStarGiftCollectibleDraft(invalid); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
}
})
}
}
func TestValidateStarGiftCollectibleDraftRejectsImplicitRarity(t *testing.T) {
write := validCollectibleDraft()
write.Models[0].RarityKind = ""
if err := ValidateStarGiftCollectibleDraft(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
}
}
func storedCollectibleWrite() StarGiftCollectibleWrite {
write := validCollectibleDraft()
for i := range write.Models {
write.Models[i].Document = &Document{
ID: int64(100 + i), MimeType: "application/x-tgsticker",
Attributes: []DocumentAttribute{{Kind: DocAttrSticker, Alt: "🎁"}},
}
write.Models[i].Blob = &FileBlob{LocationKey: "model"}
}
write.Patterns[0].Document = &Document{
ID: 200, MimeType: "application/x-tgsticker",
Attributes: []DocumentAttribute{{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true}},
Thumbs: []PhotoSize{{Kind: PhotoSizeKindPath, Type: "j", Bytes: []byte{1}}},
}
write.Patterns[0].Blob = &FileBlob{LocationKey: "pattern"}
return write
}
func TestValidateStarGiftCollectibleWriteRequiresExactDocumentRoles(t *testing.T) {
if err := ValidateStarGiftCollectibleWrite(storedCollectibleWrite()); err != nil {
t.Fatalf("valid stored collectible: %v", err)
}
tests := map[string]func(*StarGiftCollectibleWrite){
"pattern stored as sticker": func(write *StarGiftCollectibleWrite) {
write.Patterns[0].Document.Attributes = []DocumentAttribute{{Kind: DocAttrSticker, Alt: "🎁"}}
},
"pattern custom emoji without text color": func(write *StarGiftCollectibleWrite) {
write.Patterns[0].Document.Attributes[0].TextColor = false
},
"pattern without inline path thumb": func(write *StarGiftCollectibleWrite) {
write.Patterns[0].Document.Thumbs = nil
},
"model stored as custom emoji": func(write *StarGiftCollectibleWrite) {
write.Models[0].Document.Attributes = []DocumentAttribute{{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true}}
},
"ambiguous model render attributes": func(write *StarGiftCollectibleWrite) {
write.Models[0].Document.Attributes = append(write.Models[0].Document.Attributes,
DocumentAttribute{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true})
},
}
for name, mutate := range tests {
t.Run(name, func(t *testing.T) {
write := storedCollectibleWrite()
mutate(&write)
if err := ValidateStarGiftCollectibleWrite(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
}
})
}
}

View file

@ -0,0 +1,31 @@
package domain
import "testing"
func TestSavedStarGiftListCursorRoundTrip(t *testing.T) {
want := SavedStarGiftListCursor{PinnedOrder: 7, ID: 9223372036854770000}
encoded := EncodeSavedStarGiftListCursor(want.PinnedOrder, want.ID)
got, ok := DecodeSavedStarGiftListCursor(encoded)
if !ok || got != want {
t.Fatalf("cursor round trip = %+v ok=%v, want %+v", got, ok, want)
}
unpinned := SavedStarGiftListCursor{ID: 42}
got, ok = DecodeSavedStarGiftListCursor(EncodeSavedStarGiftListCursor(0, unpinned.ID))
if !ok || got != unpinned {
t.Fatalf("unpinned cursor round trip = %+v ok=%v, want %+v", got, ok, unpinned)
}
}
func TestSavedStarGiftListCursorRejectsInvalidAndSimpleIDShapes(t *testing.T) {
for _, cursor := range []string{
"not-base64!",
EncodeStarGiftCursor(42),
EncodeSavedStarGiftListCursor(-1, 42),
EncodeSavedStarGiftListCursor(1, 0),
} {
if got, ok := DecodeSavedStarGiftListCursor(cursor); ok {
t.Fatalf("cursor %q decoded as %+v, want rejected", cursor, got)
}
}
}

View file

@ -3,6 +3,7 @@ package domain
import (
"encoding/base64"
"errors"
"fmt"
"strconv"
)
@ -20,13 +21,20 @@ type StarsBalance struct {
type StarsTransactionReason string
const (
StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予
StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造)
StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费
StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取
StarsReasonGiftUpgrade StarsTransactionReason = "gift_upgrade" // 普通礼物升级为唯一礼物
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予
StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造)
StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费
StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取
StarsReasonGiftUpgrade StarsTransactionReason = "gift_upgrade" // 普通礼物升级为唯一礼物
StarsReasonGiftTransfer StarsTransactionReason = "gift_transfer"
StarsReasonGiftResale StarsTransactionReason = "gift_resale"
StarsReasonGiftOffer StarsTransactionReason = "gift_offer"
StarsReasonGiftAuction StarsTransactionReason = "gift_auction"
StarsReasonGiftPrepaid StarsTransactionReason = "gift_prepaid_upgrade"
StarsReasonGiftDrop StarsTransactionReason = "gift_drop_original_details"
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁
StarsReasonPaidMessage StarsTransactionReason = "paid_message" // 频道 Direct Message 花费
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
)
// StarsTransaction 是一条账本流水。amount 带符号:贷记 > 0含 refund/收取),借记 < 0。
@ -52,6 +60,28 @@ type StarsTransactionPage struct {
Users []User // History 中提到的对手方用户,供 tg Users 富化
}
// TonTransaction is an entry in telesrv's internal nanoton ledger. It models
// the Telegram TON-denominated gift UI without contacting a wallet, Fragment,
// a TON node, or any blockchain service.
type TonTransaction struct {
ID int64
UserID int64
Peer Peer
GiftID int64
Amount int64 // signed nanoton amount
Date int
Reason StarsTransactionReason
Title string
Description string
}
type TonTransactionPage struct {
Balance int64
Transactions []TonTransaction
NextOffset string
Users []User
}
// Stars 账本边界常量。
const (
// DefaultStarsStartingGrant 是惰性首读授予的起始 Stars 余额(本地测试用)。
@ -70,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

@ -12,6 +12,9 @@ const (
UpdateEventReadChannelDiscussionOutbox UpdateEventType = "read_channel_discussion_outbox"
UpdateEventReadMessageContents UpdateEventType = "read_message_contents"
UpdateEventEditMessage UpdateEventType = "edit_message"
// UpdateEventBotCallbackQuery 仅用于 Bot API 专用 update_id 队列投影;不写账号
// pts/difference/outbox。
UpdateEventBotCallbackQuery UpdateEventType = "bot_callback_query"
// UpdateEventWebPage 映射 updateWebPage异步解析完成后把消息里的 pending 链接预览
// 占位就地替换为已解析卡片。携带账号 pts非 LacksWirePts消息快照经 box JOIN 重建,
// 故 difference/dispatch 与 edit_message 同走通用消息事件路径,仅 tg 投影构造器不同。
@ -29,8 +32,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 idBool 为 pinned
// TL 构造器自带账号 pts/pts_count不属于 LacksWirePts。
@ -81,6 +87,7 @@ type UpdateEvent struct {
Peers []Peer
Bool bool
Phone string
EmojiStatus UserEmojiStatus
Settings PeerSettings
MessageIDs []int
MaxID int
@ -106,6 +113,11 @@ type UpdateEvent struct {
QuickReplies []QuickReply
QuickReply QuickReply
QuickReplyMessage QuickReplyMessage
BotCallbackQuery *BotCallbackQuery
// BotAPIUpdateID is the HTTP Bot API update_id. It is intentionally separate
// from MTProto Pts: Bot API ephemeral envelopes never advance account state.
BotAPIUpdateID int64
EphemeralMessage *EphemeralMessage
}
// LacksWirePts 表示该事件占用了账号 pts但它对应的 TL update 构造器没有
@ -127,6 +139,7 @@ func (e UpdateEvent) LacksWirePts() bool {
UpdateEventPeerSettings,
UpdateEventPeerStoryBlocked,
UpdateEventUserPhone,
UpdateEventUserEmojiStatus,
UpdateEventDialogFilter,
UpdateEventDialogFilterOrder,
UpdateEventDialogFilters,

View file

@ -1,5 +1,7 @@
package domain
import "time"
// UserIDSequenceBase 是普通用户 ID 的起始值。
//
// 取 2026-06-01 00:00:00 Asia/Shanghai 的 Unix 秒级时间戳。
@ -14,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
@ -50,14 +119,19 @@ 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
// 0 表示未设置。资料投影时按它取频道对象与最新一帖。
PersonalChannelID int64
// LinkedCommunityID is the single Community containing this bot. Ordinary
// users must keep it zero; the community aggregate enforces that invariant.
LinkedCommunityID int64
Color PeerColor
ProfileColor PeerColor
// Profile photo fields are filled by app-layer user projection. PhotoID==0 表示无头像。
@ -68,6 +142,15 @@ type User struct {
PhotoHasVideo bool
LastSeenAt int
Status UserStatus
// Deleted is the durable tombstone state. Deleted users remain addressable by
// ID so historical messages can render "Deleted Account", but all profile
// and reusable identity fields are cleared at the store boundary.
Deleted bool
DeletedAt int64
DeletionSource AccountDeletionSource
DeletionReason string
CreatedAt time.Time
AccountDeleteAt time.Time
}
// PremiumActiveAt 报告用户在 nowUnix 秒)时刻是否为有效会员。
@ -80,12 +163,40 @@ 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 {
if !u.Deleted {
return u
}
return User{
ID: u.ID,
AccessHash: u.AccessHash,
Deleted: true,
DeletedAt: u.DeletedAt,
DeletionSource: u.DeletionSource,
DeletionReason: u.DeletionReason,
CreatedAt: u.CreatedAt,
AccountDeleteAt: u.AccountDeleteAt,
Status: UserStatus{Kind: UserStatusEmpty},
}
}
// UserStatusKind is a protocol-neutral account presence state.
type UserStatusKind int