Merge remote-tracking branch 'upstream/main' into merge-gramsrv-2965f5d
This commit is contained in:
commit
ebb0be38d9
355 changed files with 44640 additions and 2320 deletions
|
|
@ -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
|
||||
|
|
|
|||
99
internal/domain/account_deletion.go
Normal file
99
internal/domain/account_deletion.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 侧回答
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
41
internal/domain/botapi_update_test.go
Normal file
41
internal/domain/botapi_update_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
21
internal/domain/botapi_webhook.go
Normal file
21
internal/domain/botapi_webhook.go
Normal 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
|
||||
}
|
||||
20
internal/domain/branding_test.go
Normal file
20
internal/domain/branding_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 映射 messageActionStarGiftUnique:collectible
|
||||
// 升级、转赠等所有权变更只进入 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 相册分组 id(sendMultiMedia 同组共享非零值,非相册恒 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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
214
internal/domain/community.go
Normal file
214
internal/domain/community.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
362
internal/domain/ephemeral.go
Normal file
362
internal/domain/ephemeral.go
Normal 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)
|
||||
}
|
||||
64
internal/domain/ephemeral_test.go
Normal file
64
internal/domain/ephemeral_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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.`
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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.getSavedHistory);Peer 必须同时是 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
|
||||
|
|
|
|||
|
|
@ -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-trip(updateBotCallbackQuery.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 keyboard,Kind 会将其解释为 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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 // 收礼 peer(user/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 // 收礼 peer(user/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)))
|
||||
}
|
||||
|
|
|
|||
147
internal/domain/star_gift_collectible_test.go
Normal file
147
internal/domain/star_gift_collectible_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
31
internal/domain/star_gift_cursor_test.go
Normal file
31
internal/domain/star_gift_cursor_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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 id,Bool 为 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,
|
||||
|
|
|
|||
|
|
@ -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 报告用户在 now(Unix 秒)时刻是否为有效会员。
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue