Merge remote-tracking branch 'upstream/main' into merge-gramsrv-0e2fcdf9
This commit is contained in:
commit
b443ff0c73
277 changed files with 30747 additions and 1551 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
29
internal/domain/botapi_rich_message.go
Normal file
29
internal/domain/botapi_rich_message.go
Normal 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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// PageBlock(Blocks)+ 内嵌已解析的 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 仅支持 inputRichMessage(blocks 形态),不解析
|
||||
// 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
|
|
|||
506
internal/domain/telegram_login.go
Normal file
506
internal/domain/telegram_login.go
Normal 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
|
||||
}
|
||||
112
internal/domain/telegram_login_test.go
Normal file
112
internal/domain/telegram_login_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 必须 ≥1(TDesktop 只认
|
||||
// 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 报告用户在 now(Unix 秒)时刻是否为有效会员。
|
||||
// bot 永不为会员(官方语义;授予路径同样排除 bot,这里是双保险)。
|
||||
func (u User) PremiumActiveAt(now int64) bool {
|
||||
|
|
|
|||
|
|
@ -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 账号不可被授予会员(官方语义)。
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue