Merge remote-tracking branch 'upstream/main' into merge-gramsrv-0e2fcdf9

This commit is contained in:
onysd 2026-07-24 17:15:53 +03:00
commit b443ff0c73
277 changed files with 30747 additions and 1551 deletions

View file

@ -32,6 +32,7 @@ type AdminCommand struct {
type AccountFreeze struct {
UserID int64
Frozen bool
Version int64
Since time.Time
Until time.Time
AppealURL string
@ -40,3 +41,15 @@ type AccountFreeze struct {
CommandID string
UpdatedAt time.Time
}
// AccountFreezeNotification is a durable, coalesced online refresh for one
// viewer. UpdateUser itself has no pts; offline clients always recover from the
// authoritative viewer-scoped user projection instead of replaying this row.
type AccountFreezeNotification struct {
ID int64
TargetUserID int64
FrozenUserID int64
Version int64
Frozen bool
Attempts int
}

View file

@ -0,0 +1,29 @@
package domain
// BotAPIRichMessageInput is the protocol-neutral HTTP Bot API input passed to
// the RPC edge. Exactly one of HTML, Markdown, or BlocksJSON must be present.
// BlocksJSON and MediaJSON are retained in the DTO so unsupported Bot API 10.2
// shapes are rejected explicitly at the conversion boundary instead of being
// flattened or silently dropped.
type BotAPIRichMessageInput struct {
HTML string
Markdown string
BlocksJSON []byte
MediaJSON []byte
RTL bool
SkipEntityDetection bool
}
func (m BotAPIRichMessageInput) SourceCount() int {
n := 0
if m.HTML != "" {
n++
}
if m.Markdown != "" {
n++
}
if len(m.BlocksJSON) != 0 {
n++
}
return n
}

View file

@ -415,6 +415,9 @@ type Channel struct {
About string
Username string
Verified bool
Scam bool
Fake bool
Gigagroup bool
Broadcast bool
Megagroup bool
Forum bool
@ -505,6 +508,26 @@ type ChannelMember struct {
Guest bool
}
// CanManageDirectMessages reports whether this active parent-channel member may
// see and address every subscriber topic in the linked direct-messages
// monoforum. Telegram deliberately does not grant this capability to an
// ordinary channel administrator: the explicit manage_direct_messages right is
// required (creators have the capability implicitly).
func (m ChannelMember) CanManageDirectMessages() bool {
return m.Status == ChannelMemberActive &&
(m.Role == ChannelRoleCreator ||
(m.Role == ChannelRoleAdmin && m.AdminRights.ManageDirectMessages))
}
// CanPostChannelMessages reports whether this active member may publish a post
// to a broadcast channel. Suggested-post managers need this in addition to
// CanManageDirectMessages when approving a subscriber-authored suggestion.
func (m ChannelMember) CanPostChannelMessages() bool {
return m.Status == ChannelMemberActive &&
(m.Role == ChannelRoleCreator ||
(m.Role == ChannelRoleAdmin && m.AdminRights.PostMessages))
}
// ChannelDialog is the current user's owner-view dialog state for a channel.
type ChannelDialog struct {
UserID int64
@ -570,7 +593,10 @@ const (
ChannelActionSetChatWallpaper ChannelMessageActionType = "set_chat_wallpaper"
// ChannelActionChangeCommunity maps messageActionChangeCommunity. A non-zero
// CommunityID means linked; zero means unlinked.
ChannelActionChangeCommunity ChannelMessageActionType = "change_community"
ChannelActionChangeCommunity ChannelMessageActionType = "change_community"
ChannelActionSuggestedPostApproval ChannelMessageActionType = "suggested_post_approval"
ChannelActionSuggestedPostSuccess ChannelMessageActionType = "suggested_post_success"
ChannelActionSuggestedPostRefund ChannelMessageActionType = "suggested_post_refund"
)
// ChannelMessageAction describes a service action without depending on tg.*.
@ -609,6 +635,15 @@ type ChannelMessageAction struct {
Wallpaper *Wallpaper
// Photo 仅 chat_edit_photo 服务消息使用。
Photo *Photo
// Suggested-post lifecycle actions share the immutable price snapshot. The
// approval action additionally uses the reject/balance/schedule fields;
// refund uses PayerInitiated.
SuggestedPostRejected bool
SuggestedPostBalanceTooLow bool
SuggestedPostRejectComment string
SuggestedPostScheduleDate int
SuggestedPostPrice *SuggestedPostPrice
SuggestedPostPayerInitiated bool
}
// ChannelMessage is a single stored message in a channel/supergroup.
@ -643,7 +678,7 @@ type ChannelMessage struct {
Reactions *ChannelMessageReactions
Action *ChannelMessageAction
Media *MessageMedia
// RichMessage 是 Layer 227 富文本消息richMessage快照可选普通消息恒 nil。
// RichMessage 是 Layer 228 富文本消息richMessage快照可选普通消息恒 nil。
RichMessage *MessageRichMessage
// FromBoostsApplied 是发送时的 sender boost 数快照message.from_boosts_applied
FromBoostsApplied int
@ -1411,6 +1446,25 @@ type UpdateChannelUsernameRequest struct {
Username string
}
// ChannelAdminSettings is an admin-direct patch of channel moderation settings.
// nil fields are left unchanged; set fields are applied verbatim (no membership
// or permission checks — this is the operator/admin path).
type ChannelAdminSettings struct {
Gigagroup *bool
AntiSpam *bool
ParticipantsHidden *bool
NoForwards *bool
JoinToSend *bool
JoinRequest *bool
SlowmodeSeconds *int
}
// Empty reports whether the patch changes nothing.
func (p ChannelAdminSettings) Empty() bool {
return p.Gigagroup == nil && p.AntiSpam == nil && p.ParticipantsHidden == nil &&
p.NoForwards == nil && p.JoinToSend == nil && p.JoinRequest == nil && p.SlowmodeSeconds == nil
}
// SetChannelPhotoResult describes a channel avatar mutation and its durable
// service message.
type SetChannelPhotoResult struct {
@ -1491,6 +1545,59 @@ type SendMonoforumMessageRequest struct {
Date int
}
// ToggleSuggestedPostApprovalRequest is the domain command behind
// messages.toggleSuggestedPostApproval. MessageID addresses the immutable
// suggestion in one monoforum subscriber sub-dialog.
type ToggleSuggestedPostApprovalRequest struct {
UserID int64
MonoforumID int64
MessageID int
Reject bool
RejectComment string
ScheduleDate int
Date int
}
// SuggestedPostLifecycleState is persisted so approval, scheduled publication,
// settlement and refund remain idempotent across restarts.
type SuggestedPostLifecycleState string
const (
SuggestedPostStateBalanceLow SuggestedPostLifecycleState = "balance_low"
SuggestedPostStateRejected SuggestedPostLifecycleState = "rejected"
SuggestedPostStateScheduled SuggestedPostLifecycleState = "scheduled"
SuggestedPostStatePublished SuggestedPostLifecycleState = "published"
SuggestedPostStateCompleted SuggestedPostLifecycleState = "completed"
SuggestedPostStateRefunded SuggestedPostLifecycleState = "refunded"
)
// ToggleSuggestedPostApprovalResult contains every durable update produced by
// one command or lifecycle transition. OriginalEvent is an edit in the
// monoforum; ServiceEvent is the approval/success/refund service message; an
// optional Published result is the broadcast post.
type ToggleSuggestedPostApprovalResult struct {
Monoforum Channel
Parent Channel
SavedPeer Peer
State SuggestedPostLifecycleState
OriginalMessage ChannelMessage
OriginalEvent ChannelUpdateEvent
ServiceMessage ChannelMessage
ServiceEvent ChannelUpdateEvent
Published *SendChannelMessageResult
Recipients []int64
PayerStarsBalance *StarsBalance
PayerTONBalance *int64
Duplicate bool
}
// SuggestedPostLifecycleRequest bounds one worker pass; stores must use an
// indexed seek and row locks rather than scanning every approval.
type SuggestedPostLifecycleRequest struct {
Now int
Limit int
}
// ChannelSendReplayRequest addresses either a regular channel send (SavedPeer is zero) or one
// monoforum sub-dialog send (SavedPeer is the subscriber scope). Lookup is read-only and must
// never re-run membership/permission checks or allocate pts/message ids.

View file

@ -6,38 +6,41 @@ 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")
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")
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")
ErrSuggestedPostInvalid = errors.New("suggested post invalid")
ErrSuggestedPostAlreadyHandled = errors.New("suggested post already handled")
ErrSuggestedPostApprovalForbidden = errors.New("suggested post approval forbidden")
)
// SlowModeWaitError carries the remaining wait seconds for a channel slow mode violation.

View file

@ -154,7 +154,7 @@ type Message struct {
// ReplyMarkup 是 bot 消息携带的 reply/inline keyboard 快照。仅 bot 出站消息可
// 非空;普通用户消息恒 nil发送侧 is_bot 闸门)。双盒持同一快照(无 per-viewer 差异)。
ReplyMarkup *MessageReplyMarkup
// RichMessage 是 Layer 227 富文本消息richMessage快照可选普通消息恒 nil。
// RichMessage 是 Layer 228 富文本消息richMessage快照可选普通消息恒 nil。
RichMessage *MessageRichMessage
// Pinned 是 owner 视角的置顶标志(官方私聊多置顶语义:双方各自
// 的 box 行独立持有,非 pm_oneside 操作两侧同步翻转)。
@ -165,15 +165,14 @@ type Message struct {
SavedPeer Peer
}
// MessageRichMessage 是 Layer 227 富文本消息richMessage的协议中立快照一组 IV
// MessageRichMessage 是 Layer 228 富文本消息richMessage的协议中立快照一组 IV
// PageBlockBlocks+ 内嵌已解析的 Photos/Documents。
//
// Blocks 存 gotd TL 序列化后的 []tg.PageBlockClass 不透明字节——PageBlock 体系庞大且
// input(inputRichMessage.blocks) 与 output(richMessage.blocks) 同构、原样透传,故不在
// domain 逐类型建模rpc 层负责 tg.PageBlock 向量 ↔ bytes 的序列化domain 不依赖 tg
// 与 message media 同理Photos/Documents 存已解析快照(含 viewer 无关的 access_hash
// 投影复用 tgPhoto/tgDocument。Phase 1 仅支持 inputRichMessageblocks 形态),不解析
// HTML/Markdown 变体。
// 投影复用 tgPhoto/tgDocument。HTML/Markdown 输入也会在 RPC 边界归一为同一组 Blocks。
//
// 已知局限Blocks 是 gotd 线格式不透明字节,跨 gotd 版本PageBlock 构造器变更)可能
// 失效——富文本消息为全新实验特性、无存量数据Phase 1 接受该耦合。
@ -183,6 +182,10 @@ type MessageRichMessage struct {
Blocks []byte `json:"blocks,omitempty"`
Photos []Photo `json:"photos,omitempty"`
Documents []Document `json:"documents,omitempty"`
// BotAPIProjection 是由 RPC 边界从同一组已校验 PageBlock 派生出的
// Bot API RichMessage JSON。它不是第二事实源写入边界只允许从 Blocks
// 生成HTTP Bot API 投影只读,避免 botapi 包反向依赖 tg 类型。
BotAPIProjection []byte `json:"bot_api_projection,omitempty"`
}
// IsZero 表示无富文本载荷(落库时跳过空快照、投影时不下发 rich_message
@ -289,7 +292,7 @@ type SendPrivateTextRequest struct {
BusinessAutomationKind BusinessAutomationKind
// ReplyMarkup 是 bot 出站消息的 reply/inline keyboard 快照;普通用户发送恒 nil。
ReplyMarkup *MessageReplyMarkup
// RichMessage 是 Layer 227 富文本消息richMessage快照可选普通消息恒 nil。
// RichMessage 是 Layer 228 富文本消息richMessage快照可选普通消息恒 nil。
RichMessage *MessageRichMessage
}

View file

@ -51,7 +51,12 @@ const (
// MarkupButtonCallback 是 keyboardButtonCallback点击触发 getBotCallbackAnswer
MarkupButtonCallback MarkupButtonType = "callback"
// MarkupButtonURL 是 keyboardButtonUrl点击打开链接
MarkupButtonURL MarkupButtonType = "url"
MarkupButtonURL MarkupButtonType = "url"
// MarkupButtonLoginURL is Bot API login_url / inputKeyboardButtonUrlAuth.
// The target bot is resolved and the linked origin is verified before the
// message is persisted; ButtonID is the stable flattened keyboard index
// returned to clients as keyboardButtonUrlAuth.button_id.
MarkupButtonLoginURL MarkupButtonType = "login_url"
MarkupButtonRequestPhone MarkupButtonType = "request_phone"
MarkupButtonRequestLocation MarkupButtonType = "request_location"
MarkupButtonRequestPoll MarkupButtonType = "request_poll"
@ -122,6 +127,13 @@ type MarkupButton struct {
Data []byte `json:"data,omitempty"`
// URL 仅 url 使用。
URL string `json:"url,omitempty"`
// Login URL-only fields. LoginBotUserID=0 means the sending bot until the
// RPC/Bot API edge resolves it. LoginBotUsername is input-only and must be
// cleared before persistence.
ForwardText string `json:"forward_text,omitempty"`
LoginBotUserID int64 `json:"login_bot_user_id,omitempty"`
LoginBotUsername string `json:"login_bot_username,omitempty"`
RequestWriteAccess bool `json:"request_write_access,omitempty"`
// RequiresPassword 仅 callback 使用keyboardButtonCallback.requires_password
// 2FA SRP 校验 P3 stub
RequiresPassword bool `json:"requires_password,omitempty"`
@ -343,6 +355,14 @@ func validateMarkupButton(b MarkupButton, replyKeyboard bool) error {
if err := validateButtonURL(b.URL); err != nil {
return err
}
case MarkupButtonLoginURL:
if err := validateLoginButtonURL(b.URL); err != nil {
return err
}
if b.ButtonID < 0 || b.LoginBotUserID < 0 || utf8.RuneCountInString(b.ForwardText) > MaxReplyKeyboardButtonTextLen ||
utf8.RuneCountInString(b.LoginBotUsername) > 64 {
return ErrButtonInvalid
}
case MarkupButtonWebView:
if err := validateButtonURL(b.URL); err != nil {
return err
@ -374,6 +394,27 @@ func validateButtonURL(raw string) error {
return nil
}
// validateLoginButtonURL performs only the protocol-shape validation shared by
// Bot API and MTProto input buttons. The Telegram Login service remains the
// authority for the deployment policy: HTTP is accepted here as a protocol
// shape, then allowed only when the Login HTTP switch is enabled and the exact
// origin is registered.
func validateLoginButtonURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" || len(raw) > MaxBotMenuButtonURLLen {
return ErrButtonURLInvalid
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" || u.User != nil {
return ErrButtonURLInvalid
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return ErrButtonURLInvalid
}
return nil
}
// BotCallbackAnswer 是 bot 对一次 callback query 的应答setBotCallbackAnswer →
// 解挂等待中的 getBotCallbackAnswer
type BotCallbackAnswer struct {

View file

@ -26,6 +26,11 @@ 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},
{"login url loopback http ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://127.0.0.1:8080/login"}}}}, nil},
{"login url localhost http ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://localhost:8080/login"}}}}, nil},
{"login url public http host ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://example.com:3000/login"}}}}, nil},
{"login url public http ip ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "http://192.0.2.25:18080/login"}}}}, nil},
{"login url credentials bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonLoginURL, Text: "login", URL: "https://user@example.com/login"}}}}, ErrButtonURLInvalid},
{"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},

View file

@ -3,6 +3,7 @@ package domain
import (
"encoding/base64"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
@ -139,6 +140,7 @@ func (k StarGiftAttributeRarityKind) Valid() bool {
// StarGiftCollectibleAttribute 是已发布属性池的一项。RarityKind/RarityPermille
// 是客户端展示事实;普通升级把非 crafted 的 permille 值当相对权重,不要求合计为 1000。
// 每类仍必须提供至少两个客户端可区分的普通升级属性,否则 TDesktop 的升级滚动无法结束。
type StarGiftCollectibleAttribute struct {
ID int64
CollectibleRevisionID int64
@ -325,6 +327,45 @@ type StarGiftUpgradeRequest struct {
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
// Admin-controlled attribute overrides. When non-zero these pin the specific
// collectible model/pattern/backdrop instead of the random pool draw. They
// are only honoured on the admin grant path; the DB FK (attribute must belong
// to the revision) remains the source of truth. The collectible number is
// always assigned automatically (sequential).
ModelAttributeID int64
PatternAttributeID int64
BackdropAttributeID int64
}
// AdminStarGiftGrant is one admin "give gift" command: deliver GiftID to
// Recipient from the official system account 777000 at no charge.
// When Upgrade is set the gift is minted as a collectible; the optional
// attribute IDs pin specific model/pattern/backdrop (0 => random). The
// collectible number is always assigned automatically.
type AdminStarGiftGrant struct {
SenderID int64
Recipient Peer
GiftID int64
HideName bool
Message string
Upgrade bool
CommandKey string
Date int
RecipientBlocked bool
ModelAttributeID int64
PatternAttributeID int64
BackdropAttributeID int64
}
// AdminStarGiftGrantResult is the committed direct collectible assignment.
// The saved gift, unique issuance, private message and replay receipt are one
// aggregate transaction.
type AdminStarGiftGrantResult struct {
Saved SavedStarGift
Unique UniqueStarGift
Send SendPrivateTextResult
Duplicate bool
}
type StarGiftPurchaseRequest struct {
@ -879,6 +920,9 @@ const (
MaxStarGiftCollectionTitleRunes = 12
MaxStarGiftCollectionsPerPeer = 100
MaxStarGiftCollectionItems = 1000
// MaxPinnedStarGifts matches stargifts_pinned_to_top_limit advertised to
// official clients. Pin requests are complete replacement vectors.
MaxPinnedStarGifts = 6
)
// Star gift 哨兵错误rpc 层 errors.Is 映射为 tgerr
@ -932,7 +976,10 @@ func ValidateStarGiftCollectibleDraft(write StarGiftCollectibleWrite) error {
if err := validateStarGiftAttributes(write.Patterns, StarGiftCollectiblePattern, false); err != nil {
return err
}
return validateStarGiftAttributes(write.Backdrops, StarGiftCollectibleBackdrop, false)
if err := validateStarGiftAttributes(write.Backdrops, StarGiftCollectibleBackdrop, false); err != nil {
return err
}
return validateStarGiftUpgradePreviewPool(write, false)
}
// ValidateStarGiftCollectibleWrite validates a complete publish command. Published pools are
@ -947,7 +994,62 @@ func ValidateStarGiftCollectibleWrite(write StarGiftCollectibleWrite) error {
if err := validateStarGiftAttributes(write.Patterns, StarGiftCollectiblePattern, true); err != nil {
return err
}
return validateStarGiftAttributes(write.Backdrops, StarGiftCollectibleBackdrop, true)
if err := validateStarGiftAttributes(write.Backdrops, StarGiftCollectibleBackdrop, true); err != nil {
return err
}
return validateStarGiftUpgradePreviewPool(write, true)
}
// validateStarGiftUpgradePreviewPool protects the official-client animation contract. The
// preview response includes the target attribute plus the published selectable pool; TDesktop
// deduplicates models and patterns by document identity and needs a non-target item in every
// category before its spinner can transition to the finished state.
func validateStarGiftUpgradePreviewPool(write StarGiftCollectibleWrite, requireStoredAsset bool) error {
validateAnimated := func(kind StarGiftCollectibleAttributeKind, attributes []StarGiftCollectibleAttribute) error {
selectable := 0
documents := make(map[int64]struct{}, len(attributes))
for _, attribute := range attributes {
if attribute.RarityKind != StarGiftRarityPermille || attribute.Crafted {
continue
}
selectable++
if requireStoredAsset {
if attribute.Document == nil {
return fmt.Errorf("%w: %s preview attribute has no document", ErrStarGiftCollectibleInvalid, kind)
}
documents[attribute.Document.ID] = struct{}{}
}
}
if selectable < 2 {
return fmt.Errorf("%w: %s preview requires at least two selectable attributes", ErrStarGiftCollectibleInvalid, kind)
}
if requireStoredAsset && len(documents) < 2 {
return fmt.Errorf("%w: %s preview requires at least two distinct documents", ErrStarGiftCollectibleInvalid, kind)
}
return nil
}
if err := validateAnimated(StarGiftCollectibleModel, write.Models); err != nil {
return err
}
if err := validateAnimated(StarGiftCollectiblePattern, write.Patterns); err != nil {
return err
}
seenBackdropIDs := make(map[int]struct{}, len(write.Backdrops))
selectableBackdrops := 0
for _, attribute := range write.Backdrops {
if attribute.RarityKind != StarGiftRarityPermille || attribute.Crafted {
continue
}
selectableBackdrops++
if _, exists := seenBackdropIDs[attribute.BackdropID]; exists {
return fmt.Errorf("%w: duplicate backdrop_id %d", ErrStarGiftCollectibleInvalid, attribute.BackdropID)
}
seenBackdropIDs[attribute.BackdropID] = struct{}{}
}
if selectableBackdrops < 2 {
return fmt.Errorf("%w: backdrop preview requires at least two selectable attributes", ErrStarGiftCollectibleInvalid)
}
return nil
}
func validateStarGiftAttributes(attributes []StarGiftCollectibleAttribute, kind StarGiftCollectibleAttributeKind, requireStoredAsset bool) error {

View file

@ -48,13 +48,16 @@ func validCollectibleDraft() 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: "Regular Two", RarityKind: StarGiftRarityPermille, RarityPermille: 78, Animation: animation},
{Kind: StarGiftCollectibleModel, Name: "Crafted", RarityKind: StarGiftRarityLegendary, Crafted: true, Animation: animation},
},
Patterns: []StarGiftCollectibleAttribute{
{Kind: StarGiftCollectiblePattern, Name: "Pattern", RarityKind: StarGiftRarityPermille, RarityPermille: 989, Animation: animation},
{Kind: StarGiftCollectiblePattern, Name: "Pattern Two", RarityKind: StarGiftRarityPermille, RarityPermille: 11, Animation: animation},
},
Backdrops: []StarGiftCollectibleAttribute{
{Kind: StarGiftCollectibleBackdrop, Name: "Backdrop", BackdropID: 0, RarityKind: StarGiftRarityPermille, RarityPermille: 999},
{Kind: StarGiftCollectibleBackdrop, Name: "Backdrop Two", BackdropID: 1, RarityKind: StarGiftRarityPermille, RarityPermille: 1},
},
}
}
@ -103,15 +106,59 @@ func storedCollectibleWrite() StarGiftCollectibleWrite {
}
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}}},
for i := range write.Patterns {
write.Patterns[i].Document = &Document{
ID: int64(200 + i), MimeType: "application/x-tgsticker",
Attributes: []DocumentAttribute{{Kind: DocAttrCustomEmoji, Alt: "🎁", TextColor: true}},
Thumbs: []PhotoSize{{Kind: PhotoSizeKindPath, Type: "j", Bytes: []byte{1}}},
}
write.Patterns[i].Blob = &FileBlob{LocationKey: "pattern"}
}
write.Patterns[0].Blob = &FileBlob{LocationKey: "pattern"}
return write
}
func TestValidateStarGiftCollectibleDraftRequiresClientSafePreviewPool(t *testing.T) {
tests := map[string]func(*StarGiftCollectibleWrite){
"one selectable model": func(write *StarGiftCollectibleWrite) {
write.Models = append(write.Models[:1], write.Models[2:]...)
},
"one selectable pattern": func(write *StarGiftCollectibleWrite) {
write.Patterns = write.Patterns[:1]
},
"one selectable backdrop": func(write *StarGiftCollectibleWrite) {
write.Backdrops = write.Backdrops[:1]
},
"duplicate backdrop id": func(write *StarGiftCollectibleWrite) {
write.Backdrops[1].BackdropID = write.Backdrops[0].BackdropID
},
}
for name, mutate := range tests {
t.Run(name, func(t *testing.T) {
write := validCollectibleDraft()
mutate(&write)
if err := ValidateStarGiftCollectibleDraft(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
}
})
}
}
func TestValidateStarGiftCollectibleWriteRequiresDistinctPreviewDocuments(t *testing.T) {
for _, kind := range []StarGiftCollectibleAttributeKind{StarGiftCollectibleModel, StarGiftCollectiblePattern} {
t.Run(string(kind), func(t *testing.T) {
write := storedCollectibleWrite()
if kind == StarGiftCollectibleModel {
write.Models[1].Document = write.Models[0].Document
} else {
write.Patterns[1].Document = write.Patterns[0].Document
}
if err := ValidateStarGiftCollectibleWrite(write); !errors.Is(err, ErrStarGiftCollectibleInvalid) {
t.Fatalf("err=%v, want ErrStarGiftCollectibleInvalid", err)
}
})
}
}
func TestValidateStarGiftCollectibleWriteRequiresExactDocumentRoles(t *testing.T) {
if err := ValidateStarGiftCollectibleWrite(storedCollectibleWrite()); err != nil {
t.Fatalf("valid stored collectible: %v", err)

View file

@ -21,20 +21,21 @@ type StarsBalance struct {
type StarsTransactionReason string
const (
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" // 兜底/人工调整
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 花费
StarsReasonSuggestedPost StarsTransactionReason = "suggested_post"
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
)
// StarsTransaction 是一条账本流水。amount 带符号:贷记 > 0含 refund/收取),借记 < 0。

View file

@ -0,0 +1,506 @@
package domain
import (
"errors"
"slices"
"strings"
"time"
"unicode/utf8"
)
var (
ErrTelegramLoginClientInvalid = errors.New("telegram login client invalid")
ErrTelegramLoginClientDisabled = errors.New("telegram login client disabled")
ErrTelegramLoginURLInvalid = errors.New("telegram login url invalid")
ErrTelegramLoginRequestInvalid = errors.New("telegram login request invalid")
ErrTelegramLoginRequestExpired = errors.New("telegram login request expired")
ErrTelegramLoginRequestConflict = errors.New("telegram login request conflict")
ErrTelegramLoginMatchCodeInvalid = errors.New("telegram login match code invalid")
ErrTelegramLoginScopeInvalid = errors.New("telegram login scope invalid")
ErrTelegramLoginCodeInvalid = errors.New("telegram login code invalid")
ErrTelegramLoginCodeConsumed = errors.New("telegram login code consumed")
ErrTelegramLoginWebAuthHashInvalid = errors.New("telegram login web authorization hash invalid")
ErrTelegramLoginRedirectNotAllowed = errors.New("telegram login redirect not allowed")
ErrTelegramLoginOriginNotAllowed = errors.New("telegram login origin not allowed")
ErrTelegramLoginSecretInvalid = errors.New("telegram login client secret invalid")
ErrTelegramLoginPKCEInvalid = errors.New("telegram login pkce invalid")
ErrTelegramLoginAuthorizationsTooMany = errors.New("telegram login authorizations too many")
)
const MaxTelegramLoginWebAuthorizations = 1000
type TelegramLoginSigningAlgorithm string
const (
TelegramLoginSigningRS256 TelegramLoginSigningAlgorithm = "RS256"
TelegramLoginSigningES256 TelegramLoginSigningAlgorithm = "ES256"
TelegramLoginSigningEdDSA TelegramLoginSigningAlgorithm = "EdDSA"
TelegramLoginSigningES256K TelegramLoginSigningAlgorithm = "ES256K"
)
func (a TelegramLoginSigningAlgorithm) Valid() bool {
switch a {
case TelegramLoginSigningRS256, TelegramLoginSigningES256, TelegramLoginSigningEdDSA, TelegramLoginSigningES256K:
return true
default:
return false
}
}
type TelegramLoginScope string
const (
TelegramLoginScopeOpenID TelegramLoginScope = "openid"
TelegramLoginScopeProfile TelegramLoginScope = "profile"
TelegramLoginScopePhone TelegramLoginScope = "phone"
TelegramLoginScopeBotAccess TelegramLoginScope = "telegram:bot_access"
)
func (s TelegramLoginScope) Valid() bool {
switch s {
case TelegramLoginScopeOpenID, TelegramLoginScopeProfile, TelegramLoginScopePhone, TelegramLoginScopeBotAccess:
return true
default:
return false
}
}
type TelegramLoginClient struct {
BotUserID int64
ClientID string
SecretHash []byte
SecretVersion int64
SigningAlgorithm TelegramLoginSigningAlgorithm
Enabled bool
CreatedAt time.Time
UpdatedAt time.Time
}
func (c TelegramLoginClient) Clone() TelegramLoginClient {
out := c
out.SecretHash = append([]byte(nil), c.SecretHash...)
return out
}
func (c TelegramLoginClient) Validate() error {
if c.BotUserID <= 0 || c.ClientID == "" || len(c.SecretHash) != 32 || c.SecretVersion <= 0 || !c.SigningAlgorithm.Valid() {
return ErrTelegramLoginClientInvalid
}
return nil
}
type TelegramLoginAllowedURLKind string
const (
TelegramLoginAllowedWebOrigin TelegramLoginAllowedURLKind = "web_origin"
TelegramLoginAllowedRedirectURI TelegramLoginAllowedURLKind = "redirect_uri"
)
type TelegramLoginAllowedURL struct {
ID int64
BotUserID int64
Kind TelegramLoginAllowedURLKind
NormalizedURL string
CreatedAt time.Time
}
type TelegramLoginNativePlatform string
const (
TelegramLoginNativeIOS TelegramLoginNativePlatform = "ios"
TelegramLoginNativeAndroid TelegramLoginNativePlatform = "android"
)
type TelegramLoginNativeApp struct {
ID int64
BotUserID int64
Platform TelegramLoginNativePlatform
ApplicationID string
// VerificationID is the 10-character Apple Team ID on iOS and the
// normalized 64-hex SHA-256 signing-certificate fingerprint on Android.
VerificationID string
CallbackURI string
VerifiedDisplayName string
Enabled bool
CreatedAt time.Time
UpdatedAt time.Time
}
const MaxTelegramLoginNativeApps = 20
func (p TelegramLoginNativePlatform) Valid() bool {
return p == TelegramLoginNativeIOS || p == TelegramLoginNativeAndroid
}
func (a TelegramLoginNativeApp) Validate() error {
if a.BotUserID <= 0 || !a.Platform.Valid() || a.ApplicationID == "" || len(a.ApplicationID) > 255 ||
a.VerificationID == "" || a.CallbackURI == "" || len(a.CallbackURI) > 4096 ||
a.VerifiedDisplayName == "" || len(a.VerifiedDisplayName) > 128 ||
a.CreatedAt.IsZero() || a.UpdatedAt.IsZero() {
return ErrTelegramLoginClientInvalid
}
return nil
}
type TelegramLoginRequestSource string
const (
TelegramLoginRequestWeb TelegramLoginRequestSource = "web"
TelegramLoginRequestJavaScript TelegramLoginRequestSource = "javascript"
TelegramLoginRequestNative TelegramLoginRequestSource = "native"
TelegramLoginRequestMiniApp TelegramLoginRequestSource = "mini_app"
TelegramLoginRequestMessageButton TelegramLoginRequestSource = "message_button"
)
type TelegramLoginRequestState string
const (
TelegramLoginRequestPending TelegramLoginRequestState = "pending"
TelegramLoginRequestApproved TelegramLoginRequestState = "approved"
TelegramLoginRequestDeclined TelegramLoginRequestState = "declined"
TelegramLoginRequestExpired TelegramLoginRequestState = "expired"
)
func (s TelegramLoginRequestState) Terminal() bool {
return s == TelegramLoginRequestApproved || s == TelegramLoginRequestDeclined || s == TelegramLoginRequestExpired
}
func CanTransitionTelegramLoginRequest(from, to TelegramLoginRequestState) bool {
if from != TelegramLoginRequestPending {
return false
}
return to == TelegramLoginRequestApproved || to == TelegramLoginRequestDeclined || to == TelegramLoginRequestExpired
}
type TelegramLoginRequest struct {
ID int64
RequestTokenHash []byte
BrowserTokenHash []byte
BotUserID int64
ClientID string
SigningAlgorithm TelegramLoginSigningAlgorithm
Source TelegramLoginRequestSource
ResponseType string
RedirectURI string
Origin string
Domain string
Scopes []TelegramLoginScope
State string
Nonce string
CodeChallenge string
CodeChallengeMethod string
Browser string
Platform string
IP string
Region string
InAppOrigin string
IsApp bool
VerifiedAppName string
MatchCodes []string
MatchCode string
MatchCodesFirst bool
UserIDHint int64
PeerType PeerType
PeerID int64
MessageID int
ButtonID int
Status TelegramLoginRequestState
AuthorizedUserID int64
ProfileName string
GivenName string
FamilyName string
PreferredUsername string
Picture string
PhoneNumber string
WriteAllowed bool
PhoneShared bool
CreatedAt time.Time
ExpiresAt time.Time
ApprovedAt time.Time
DeclinedAt time.Time
}
func (r TelegramLoginRequest) Clone() TelegramLoginRequest {
out := r
out.RequestTokenHash = append([]byte(nil), r.RequestTokenHash...)
out.BrowserTokenHash = append([]byte(nil), r.BrowserTokenHash...)
out.Scopes = append([]TelegramLoginScope(nil), r.Scopes...)
out.MatchCodes = append([]string(nil), r.MatchCodes...)
return out
}
func (r TelegramLoginRequest) Requests(scope TelegramLoginScope) bool {
return slices.Contains(r.Scopes, scope)
}
func (r TelegramLoginRequest) Validate() error {
if len(r.RequestTokenHash) != 32 || len(r.BrowserTokenHash) != 32 || r.BotUserID <= 0 || r.ClientID == "" || r.ClientID != strings.TrimSpace(r.ClientID) || r.RedirectURI == "" || r.Domain == "" {
return ErrTelegramLoginRequestInvalid
}
if !r.SigningAlgorithm.Valid() || !r.Source.Valid() || (r.ResponseType != "code" && r.ResponseType != "post_message" && r.ResponseType != "legacy_url") ||
r.Status != TelegramLoginRequestPending || r.CreatedAt.IsZero() || !r.ExpiresAt.After(r.CreatedAt) {
return ErrTelegramLoginRequestInvalid
}
switch r.Source {
case TelegramLoginRequestWeb:
if r.ResponseType != "code" {
return ErrTelegramLoginRequestInvalid
}
case TelegramLoginRequestJavaScript:
if r.ResponseType != "post_message" {
return ErrTelegramLoginRequestInvalid
}
case TelegramLoginRequestNative:
if r.ResponseType != "code" || !r.IsApp || r.VerifiedAppName == "" || r.Origin != "" {
return ErrTelegramLoginRequestInvalid
}
case TelegramLoginRequestMiniApp:
if r.ResponseType != "post_message" {
return ErrTelegramLoginRequestInvalid
}
case TelegramLoginRequestMessageButton:
if r.ResponseType != "legacy_url" {
return ErrTelegramLoginRequestInvalid
}
default:
return ErrTelegramLoginRequestInvalid
}
if r.Source != TelegramLoginRequestNative && (r.IsApp || r.VerifiedAppName != "" || r.Origin == "") {
return ErrTelegramLoginRequestInvalid
}
if r.AuthorizedUserID != 0 || r.ProfileName != "" || r.GivenName != "" || r.FamilyName != "" ||
r.PreferredUsername != "" || r.Picture != "" || r.PhoneNumber != "" || r.WriteAllowed || r.PhoneShared ||
!r.ApprovedAt.IsZero() || !r.DeclinedAt.IsZero() {
return ErrTelegramLoginRequestInvalid
}
if len(r.RedirectURI) > 4096 || len(r.Origin) > 4096 || len(r.Domain) > 255 || len(r.InAppOrigin) > 4096 ||
len(r.State) > 2048 || len(r.Nonce) > 1024 || len(r.Browser) == 0 || len(r.Browser) > 255 ||
len(r.Platform) == 0 || len(r.Platform) > 255 || len(r.IP) == 0 || len(r.IP) > 128 ||
len(r.Region) == 0 || len(r.Region) > 255 || len(r.VerifiedAppName) > 128 || r.UserIDHint < 0 ||
r.PeerID < 0 || r.MessageID < 0 || r.ButtonID < 0 || len(r.MatchCodes) > 8 {
return ErrTelegramLoginRequestInvalid
}
if r.ResponseType == "legacy_url" {
if r.Source != TelegramLoginRequestMessageButton || r.PeerID <= 0 || r.MessageID <= 0 ||
(r.PeerType != PeerTypeUser && r.PeerType != PeerTypeChannel) || r.CodeChallenge != "" || r.CodeChallengeMethod != "" ||
len(r.MatchCodes) != 0 || r.MatchCode != "" || r.MatchCodesFirst {
return ErrTelegramLoginRequestInvalid
}
if !slices.Contains(r.Scopes, TelegramLoginScopeOpenID) || !slices.Contains(r.Scopes, TelegramLoginScopeProfile) {
return ErrTelegramLoginScopeInvalid
}
seen := make(map[TelegramLoginScope]struct{}, len(r.Scopes))
for _, scope := range r.Scopes {
if !scope.Valid() || scope == TelegramLoginScopePhone {
return ErrTelegramLoginScopeInvalid
}
if _, duplicate := seen[scope]; duplicate {
return ErrTelegramLoginScopeInvalid
}
seen[scope] = struct{}{}
}
} else if r.ResponseType == "code" {
if err := ValidateTelegramLoginScopes(r.Scopes, r.SigningAlgorithm); err != nil {
return err
}
if r.CodeChallengeMethod != "S256" || r.CodeChallenge == "" {
return ErrTelegramLoginPKCEInvalid
}
} else {
if err := ValidateTelegramLoginScopes(r.Scopes, r.SigningAlgorithm); err != nil {
return err
}
// Telegram's official JavaScript SDK returns an ID token directly and
// therefore sends no authorization-code PKCE parameters. Accept a PKCE
// pair for generic callers, but never a partial pair.
if r.CodeChallenge == "" && r.CodeChallengeMethod == "" {
// Official post_message/Mini App shape.
} else if r.CodeChallengeMethod != "S256" || r.CodeChallenge == "" {
return ErrTelegramLoginPKCEInvalid
}
}
if r.Source == TelegramLoginRequestMiniApp {
if r.ResponseType != "post_message" || r.InAppOrigin == "" || r.Origin != r.InAppOrigin {
return ErrTelegramLoginRequestInvalid
}
} else if r.InAppOrigin != "" {
return ErrTelegramLoginRequestInvalid
}
if r.MatchCodesFirst && len(r.MatchCodes) == 0 {
return ErrTelegramLoginRequestInvalid
}
if len(r.MatchCodes) > 0 && (r.MatchCode == "" || !slices.Contains(r.MatchCodes, r.MatchCode)) {
return ErrTelegramLoginRequestInvalid
}
return nil
}
// TelegramLoginMessageButtonAuthorization is the domain-only input for the
// legacy login_url consent path. BotToken is used transiently to produce the
// official HMAC response and is never persisted in the login aggregate.
type TelegramLoginMessageButtonAuthorization struct {
UserID int64
BotUserID int64
BotToken string
URL string
RequestWriteAccess bool
WriteAllowed bool
Peer Peer
MessageID int
ButtonID int
Browser string
Platform string
IP string
Region string
Identity TelegramLoginIdentitySnapshot
}
type TelegramLoginMessageButtonResult struct {
URL string
Request TelegramLoginRequest
WebAuthorization TelegramLoginWebAuthorization
}
func (s TelegramLoginRequestSource) Valid() bool {
switch s {
case TelegramLoginRequestWeb, TelegramLoginRequestJavaScript, TelegramLoginRequestNative,
TelegramLoginRequestMiniApp, TelegramLoginRequestMessageButton:
return true
default:
return false
}
}
// TelegramLoginIdentitySnapshot is the immutable identity presented on the
// approval screen and later signed into the ID token. It is written together
// with the pending->approved transition so a profile/phone mutation between
// approval and code exchange cannot change what the relying party receives.
type TelegramLoginIdentitySnapshot struct {
UserID int64
Name string
GivenName string
FamilyName string
PreferredUsername string
Picture string
PhoneNumber string
}
func (s TelegramLoginIdentitySnapshot) Sanitized(includeProfile, includePhone bool) (TelegramLoginIdentitySnapshot, error) {
if s.UserID <= 0 {
return TelegramLoginIdentitySnapshot{}, ErrTelegramLoginRequestInvalid
}
out := TelegramLoginIdentitySnapshot{UserID: s.UserID}
if includeProfile {
out.Name = strings.TrimSpace(s.Name)
out.GivenName = strings.TrimSpace(s.GivenName)
out.FamilyName = strings.TrimSpace(s.FamilyName)
out.PreferredUsername = strings.TrimSpace(s.PreferredUsername)
out.Picture = strings.TrimSpace(s.Picture)
if out.Name == "" || out.GivenName == "" {
return TelegramLoginIdentitySnapshot{}, ErrTelegramLoginRequestInvalid
}
}
if includePhone {
out.PhoneNumber = NormalizePhone(s.PhoneNumber)
if !ValidPhone(out.PhoneNumber) {
return TelegramLoginIdentitySnapshot{}, ErrPhoneNumberInvalid
}
}
if !boundedUTF8(out.Name, 255) || !boundedUTF8(out.GivenName, 255) || !boundedUTF8(out.FamilyName, 255) ||
!boundedUTF8(out.PreferredUsername, 64) || !boundedUTF8(out.Picture, 4096) || len(out.PhoneNumber) > 32 {
return TelegramLoginIdentitySnapshot{}, ErrTelegramLoginRequestInvalid
}
return out, nil
}
func boundedUTF8(value string, maxBytes int) bool {
return utf8.ValidString(value) && len(value) <= maxBytes
}
func ValidateTelegramLoginScopes(scopes []TelegramLoginScope, alg TelegramLoginSigningAlgorithm) error {
if !alg.Valid() || len(scopes) == 0 || !slices.Contains(scopes, TelegramLoginScopeOpenID) {
return ErrTelegramLoginScopeInvalid
}
seen := make(map[TelegramLoginScope]struct{}, len(scopes))
for _, scope := range scopes {
if !scope.Valid() {
return ErrTelegramLoginScopeInvalid
}
if _, duplicate := seen[scope]; duplicate {
return ErrTelegramLoginScopeInvalid
}
seen[scope] = struct{}{}
}
if alg == TelegramLoginSigningEdDSA || alg == TelegramLoginSigningES256K {
if len(scopes) != 1 || scopes[0] != TelegramLoginScopeOpenID {
return ErrTelegramLoginScopeInvalid
}
}
return nil
}
type TelegramLoginApproval struct {
RequestID int64
Identity TelegramLoginIdentitySnapshot
WriteAllowed bool
PhoneShared bool
MatchCode string
ApprovedAt time.Time
}
type TelegramLoginAuthorizationCode struct {
ID int64
RequestID int64
CodeHash []byte
SealedCode []byte
SealNonce []byte
SealKeyID string
IssuedAt time.Time
ExpiresAt time.Time
ConsumedAt time.Time
}
// TelegramLoginCodeExchange carries the values already normalized/hashed by
// the application service. The durable store compares them again while the
// code/request/client rows are locked, closing redirect, PKCE and secret-
// rotation TOCTOU gaps between HTTP validation and one-time consumption.
type TelegramLoginCodeExchange struct {
CodeHash []byte
ClientID string
ClientSecretVersion int64
RedirectURI string
CodeChallenge string
Now time.Time
}
func (c TelegramLoginAuthorizationCode) Clone() TelegramLoginAuthorizationCode {
out := c
out.CodeHash = append([]byte(nil), c.CodeHash...)
out.SealedCode = append([]byte(nil), c.SealedCode...)
out.SealNonce = append([]byte(nil), c.SealNonce...)
return out
}
type TelegramLoginWebAuthorization struct {
Hash int64
RequestID int64
UserID int64
BotUserID int64
Domain string
Browser string
Platform string
IP string
Region string
Scopes []TelegramLoginScope
PhoneShared bool
BotAccessGranted bool
CreatedAt time.Time
LastActiveAt time.Time
RevokedAt time.Time
}
func (a TelegramLoginWebAuthorization) Clone() TelegramLoginWebAuthorization {
out := a
out.Scopes = append([]TelegramLoginScope(nil), a.Scopes...)
return out
}

View file

@ -0,0 +1,112 @@
package domain
import (
"strings"
"testing"
"time"
)
func TestValidateTelegramLoginScopes(t *testing.T) {
tests := []struct {
name string
scopes []TelegramLoginScope
alg TelegramLoginSigningAlgorithm
valid bool
}{
{name: "rs profile phone", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, TelegramLoginScopeProfile, TelegramLoginScopePhone}, alg: TelegramLoginSigningRS256, valid: true},
{name: "missing openid", scopes: []TelegramLoginScope{TelegramLoginScopeProfile}, alg: TelegramLoginSigningRS256},
{name: "duplicate", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, TelegramLoginScopeOpenID}, alg: TelegramLoginSigningRS256},
{name: "unknown", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, "admin"}, alg: TelegramLoginSigningRS256},
{name: "eddsa openid", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID}, alg: TelegramLoginSigningEdDSA, valid: true},
{name: "eddsa profile forbidden", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, TelegramLoginScopeProfile}, alg: TelegramLoginSigningEdDSA},
{name: "es256k phone forbidden", scopes: []TelegramLoginScope{TelegramLoginScopeOpenID, TelegramLoginScopePhone}, alg: TelegramLoginSigningES256K},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := ValidateTelegramLoginScopes(test.scopes, test.alg)
if (err == nil) != test.valid {
t.Fatalf("ValidateTelegramLoginScopes() error = %v, valid = %v", err, test.valid)
}
})
}
}
func TestTelegramLoginRequestTransitions(t *testing.T) {
for _, terminal := range []TelegramLoginRequestState{
TelegramLoginRequestApproved,
TelegramLoginRequestDeclined,
TelegramLoginRequestExpired,
} {
if !CanTransitionTelegramLoginRequest(TelegramLoginRequestPending, terminal) {
t.Fatalf("pending -> %s must be valid", terminal)
}
if CanTransitionTelegramLoginRequest(terminal, TelegramLoginRequestPending) {
t.Fatalf("%s -> pending must be forbidden", terminal)
}
}
if CanTransitionTelegramLoginRequest(TelegramLoginRequestApproved, TelegramLoginRequestDeclined) {
t.Fatal("approved -> declined must be forbidden")
}
}
func TestTelegramLoginRequestSourceShapeMatrix(t *testing.T) {
now := time.Unix(1_780_000_000, 0).UTC()
base := TelegramLoginRequest{
RequestTokenHash: make([]byte, 32), BrowserTokenHash: make([]byte, 32),
BotUserID: 9001, ClientID: "9001", SigningAlgorithm: TelegramLoginSigningRS256,
Source: TelegramLoginRequestWeb, ResponseType: "code", RedirectURI: "https://rp.example/callback",
Origin: "https://rp.example", Domain: "rp.example", Scopes: []TelegramLoginScope{TelegramLoginScopeOpenID},
CodeChallenge: strings.Repeat("A", 43), CodeChallengeMethod: "S256",
Browser: "Firefox", Platform: "Windows", IP: "192.0.2.1", Region: "Test",
Status: TelegramLoginRequestPending, CreatedAt: now, ExpiresAt: now.Add(5 * time.Minute),
}
if err := base.Validate(); err != nil {
t.Fatalf("valid web request: %v", err)
}
invalid := []struct {
name string
mutate func(*TelegramLoginRequest)
}{
{name: "web post message", mutate: func(r *TelegramLoginRequest) {
r.ResponseType = "post_message"
r.CodeChallenge = ""
r.CodeChallengeMethod = ""
}},
{name: "javascript code", mutate: func(r *TelegramLoginRequest) { r.Source = TelegramLoginRequestJavaScript }},
{name: "message button code", mutate: func(r *TelegramLoginRequest) { r.Source = TelegramLoginRequestMessageButton }},
{name: "web app flag", mutate: func(r *TelegramLoginRequest) { r.IsApp = true; r.VerifiedAppName = "Forged" }},
{name: "web missing origin", mutate: func(r *TelegramLoginRequest) { r.Origin = "" }},
}
for _, tc := range invalid {
t.Run(tc.name, func(t *testing.T) {
request := base.Clone()
tc.mutate(&request)
if err := request.Validate(); err == nil {
t.Fatal("forbidden source shape was accepted")
}
})
}
native := base.Clone()
native.Source, native.Origin, native.Domain = TelegramLoginRequestNative, "", "dev.bedolaga.demo"
native.IsApp, native.VerifiedAppName = true, "Bedolaga"
if err := native.Validate(); err != nil {
t.Fatalf("valid native request: %v", err)
}
native.IsApp = false
if err := native.Validate(); err == nil {
t.Fatal("native request without verified app state was accepted")
}
mini := base.Clone()
mini.Source, mini.ResponseType = TelegramLoginRequestMiniApp, "post_message"
mini.CodeChallenge, mini.CodeChallengeMethod = "", ""
mini.RedirectURI, mini.InAppOrigin = "https://rp.example/", mini.Origin
if err := mini.Validate(); err != nil {
t.Fatalf("valid Mini App request: %v", err)
}
mini.InAppOrigin = "https://other.example"
if err := mini.Validate(); err == nil {
t.Fatal("Mini App origin mismatch was accepted")
}
}

View file

@ -105,10 +105,21 @@ type User struct {
Username string
CountryCode string
Verified bool
Scam bool
Fake bool
Support bool
Contact bool
Mutual bool
CloseFriend bool
// RestrictionReasons are transient, viewer-scoped unavailability reasons.
// They are produced after loading the viewer-independent base user and must
// never be persisted in users or the base-user cache.
RestrictionReasons []UserRestrictionReason
// ContactNote/ContactNoteEntities are transient viewer-scoped contact
// projection fields. They must never be persisted into users or a
// viewer-independent base-user cache.
ContactNote string
ContactNoteEntities []MessageEntity
// Bot 标识 bot 账号;置位时 BotInfoVersion 必须 ≥1TDesktop 只认
// user TL 是否携带 bot_info_version 字段,且与 bot flag 共用 bit14
Bot bool
@ -153,6 +164,23 @@ type User struct {
AccountDeleteAt time.Time
}
// UserRestrictionReason is the protocol-neutral form of Telegram's
// restrictionReason. Platform "all" applies to TDesktop and official mobile
// clients; Text is intentionally server supplied and directly user-visible.
type UserRestrictionReason struct {
Platform string
Reason string
Text string
}
func AccountFrozenRestrictionReasons() []UserRestrictionReason {
return []UserRestrictionReason{{
Platform: "all",
Reason: "frozen",
Text: "This account is frozen.",
}}
}
// PremiumActiveAt 报告用户在 nowUnix 秒)时刻是否为有效会员。
// bot 永不为会员(官方语义;授予路径同样排除 bot这里是双保险
func (u User) PremiumActiveAt(now int64) bool {

View file

@ -12,6 +12,9 @@ var (
ErrUserNotFound = errors.New("user not found")
ErrUserFrozen = errors.New("user account frozen")
ErrAuthenticatedScopeInvalid = errors.New("authenticated user scope invalid")
// ErrPeerModerationFlagsInvalid rejects the impossible scam+fake state at
// every write boundary shared by user, bot and channel projections.
ErrPeerModerationFlagsInvalid = errors.New("peer moderation flags invalid")
// ErrPremiumRequired 表示该操作仅限有效会员PREMIUM_ACCOUNT_REQUIRED
ErrPremiumRequired = errors.New("premium account required")
// ErrPremiumBotUnsupported 表示 bot 账号不可被授予会员(官方语义)。