merged from gramsrv upstream
This commit is contained in:
parent
79c64ee916
commit
21a0856587
651 changed files with 54774 additions and 4590 deletions
|
|
@ -3,6 +3,7 @@ package domain
|
|||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -120,6 +121,13 @@ type PasswordSettings struct {
|
|||
SRPBSecret []byte
|
||||
}
|
||||
|
||||
// RevenueWithdrawalPasswordState carries only the durable 2FA facts required
|
||||
// by high-risk payout admission. It intentionally excludes password material.
|
||||
type RevenueWithdrawalPasswordState struct {
|
||||
HasPassword bool
|
||||
PasswordChangedAt time.Time
|
||||
}
|
||||
|
||||
// ReactionNotifyFrom stores one account-level reaction notification scope.
|
||||
type ReactionNotifyFrom string
|
||||
|
||||
|
|
@ -181,8 +189,21 @@ const (
|
|||
MaxAccountTTLDays = 3650
|
||||
)
|
||||
|
||||
// DisallowedGifts stores the Layer 228 global gift-reception switches.
|
||||
type DisallowedGifts struct {
|
||||
UnlimitedStargifts bool
|
||||
LimitedStargifts bool
|
||||
UniqueStargifts bool
|
||||
PremiumGifts bool
|
||||
StargiftsFromChannel bool
|
||||
}
|
||||
|
||||
func (g DisallowedGifts) Zero() bool {
|
||||
return !g.UnlimitedStargifts && !g.LimitedStargifts && !g.UniqueStargifts &&
|
||||
!g.PremiumGifts && !g.StargiftsFromChannel
|
||||
}
|
||||
|
||||
// GlobalPrivacy 是 globalPrivacySettings 的业务层表达(账号级隐私开关)。
|
||||
// DisallowedGifts 依赖礼物资产模型(当前未实现),故不建模、保持默认。
|
||||
type GlobalPrivacy struct {
|
||||
ArchiveAndMuteNewNoncontactPeers bool
|
||||
KeepArchivedUnmuted bool
|
||||
|
|
@ -190,6 +211,7 @@ type GlobalPrivacy struct {
|
|||
HideReadMarks bool
|
||||
NewNoncontactPeersRequirePremium bool
|
||||
DisplayGiftsButton bool
|
||||
DisallowedGifts DisallowedGifts
|
||||
// NoncontactPeersPaidStars:非联系人给本人发消息所需 Stars 数。Stars 账本尚未实现,
|
||||
// 此处仅做忠实持久化(往返不丢值),不参与计费逻辑。
|
||||
NoncontactPeersPaidStars int64
|
||||
|
|
|
|||
|
|
@ -90,10 +90,3 @@ type AccountDeletionCandidate struct {
|
|||
Source AccountDeletionSource
|
||||
DueAt time.Time
|
||||
}
|
||||
|
||||
type AccountDeletionNotification struct {
|
||||
ID int64
|
||||
TargetUserID int64
|
||||
DeletedUserID int64
|
||||
Attempts int
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,11 @@ type Authorization struct {
|
|||
// PasswordPending 表示该 auth_key 已通过短信验证码、但账号开启了两步验证且尚未通过
|
||||
// auth.checkPassword。此状态下业务鉴权须视其为未登录,仅允许继续完成两步验证。
|
||||
PasswordPending bool
|
||||
CreatedAt time.Time
|
||||
ActiveAt time.Time
|
||||
// CreatedAt is the start of the current fully-authorized login session. Bind
|
||||
// refreshes it for every login, and completing password_pending refreshes it
|
||||
// again so time spent waiting for 2FA never satisfies payout freshness.
|
||||
CreatedAt time.Time
|
||||
ActiveAt time.Time
|
||||
}
|
||||
|
||||
// AuthKeyClientInfo 是未登录 auth_key 也需要保留的客户端协商元数据。
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ const (
|
|||
MaxVerifierCompanyLength = 128
|
||||
// MaxCustomVerificationDescriptionLength is the app-configured limit for text
|
||||
// supplied by a verifier (bot_verification_description_length_limit).
|
||||
MaxCustomVerificationDescriptionLength = 70
|
||||
MaxCustomVerificationDescriptionLength = 128
|
||||
// MaxBotVerificationDescriptionLength bounds the final wire description. It
|
||||
// may exceed the custom-input limit because the server-generated fallback
|
||||
// includes the organization name.
|
||||
|
|
|
|||
27
internal/domain/bot_verification_test.go
Normal file
27
internal/domain/bot_verification_test.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestBotVerifierSettingsAcceptsDescriptionLongerThan70Runes(t *testing.T) {
|
||||
description := "This account is verified as official by the representatives of Telegram"
|
||||
if got := utf8.RuneCountInString(description); got != 71 {
|
||||
t.Fatalf("fixture length = %d, want 71", got)
|
||||
}
|
||||
|
||||
settings := BotVerifierSettings{
|
||||
BotID: 1,
|
||||
IconDocumentID: 2,
|
||||
CompanyName: "Example Trust",
|
||||
DefaultDescription: description,
|
||||
CanModifyCustomDescription: true,
|
||||
}
|
||||
if err := settings.Validate(); err != nil {
|
||||
t.Fatalf("Validate() rejected 71-rune default description: %v", err)
|
||||
}
|
||||
if got, err := settings.DescriptionFor(description); err != nil || got != description {
|
||||
t.Fatalf("DescriptionFor() = %q, %v; want fixture, nil", got, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,9 @@ type BotCallbackQuery struct {
|
|||
ChatInstance int64
|
||||
Data []byte
|
||||
InlineMessage *BotInlineMessageID
|
||||
// ClientSession is available only to an in-process service bot. It must not
|
||||
// become part of a durable/public Bot API CallbackQuery payload.
|
||||
ClientSession ClientSessionMetadata `json:"-"`
|
||||
}
|
||||
|
||||
// BotAPIEphemeralPayload is a self-contained 24-hour Bot API queue snapshot.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
// InitialChannelPts is the empty message-box state used by official clients
|
||||
// before the first real channel event. The first event therefore has PTS 2.
|
||||
InitialChannelPts = 1
|
||||
// FirstChannelEventPts is the PTS after the first single-count channel event.
|
||||
FirstChannelEventPts = InitialChannelPts + 1
|
||||
// MaxChannelDifferenceLimit limits a single updates.getChannelDifference page.
|
||||
MaxChannelDifferenceLimit = 100
|
||||
// MaxChannelDifferenceTooLongMessages limits the latest message snapshot returned by channelDifferenceTooLong.
|
||||
|
|
@ -188,23 +193,24 @@ const (
|
|||
|
||||
// ChannelAdminRights is a domain-only representation of Telegram admin rights.
|
||||
type ChannelAdminRights struct {
|
||||
ChangeInfo bool
|
||||
PostMessages bool
|
||||
EditMessages bool
|
||||
DeleteMessages bool
|
||||
PostStories bool
|
||||
EditStories bool
|
||||
DeleteStories bool
|
||||
BanUsers bool
|
||||
InviteUsers bool
|
||||
PinMessages bool
|
||||
AddAdmins bool
|
||||
ManageCall bool
|
||||
ManageChat bool
|
||||
ManageTopics bool
|
||||
Anonymous bool
|
||||
ManageRanks bool
|
||||
ManageLinkedPeers bool
|
||||
ChangeInfo bool
|
||||
PostMessages bool
|
||||
EditMessages bool
|
||||
DeleteMessages bool
|
||||
PostStories bool
|
||||
EditStories bool
|
||||
DeleteStories bool
|
||||
BanUsers bool
|
||||
InviteUsers bool
|
||||
PinMessages bool
|
||||
AddAdmins bool
|
||||
ManageCall bool
|
||||
ManageChat bool
|
||||
ManageTopics bool
|
||||
Anonymous bool
|
||||
ManageRanks bool
|
||||
ManageLinkedPeers bool
|
||||
ManageWelcomeMessages bool
|
||||
// ManageDirectMessages 对应 TL ChatAdminRights.manage_direct_messages(flags.17)。母广播频道的
|
||||
// 管理员据此被客户端授予 monoforum(频道私信)容器的 MonoforumAdmin 身份;creator 走 amCreator 旁路。
|
||||
ManageDirectMessages bool
|
||||
|
|
@ -213,22 +219,23 @@ type ChannelAdminRights struct {
|
|||
// CreatorChannelAdminRights returns the full rights set clients expect on creator projections.
|
||||
func CreatorChannelAdminRights() ChannelAdminRights {
|
||||
return ChannelAdminRights{
|
||||
ChangeInfo: true,
|
||||
PostMessages: true,
|
||||
EditMessages: true,
|
||||
DeleteMessages: true,
|
||||
PostStories: true,
|
||||
EditStories: true,
|
||||
DeleteStories: true,
|
||||
BanUsers: true,
|
||||
InviteUsers: true,
|
||||
PinMessages: true,
|
||||
AddAdmins: true,
|
||||
ManageCall: true,
|
||||
ManageChat: true,
|
||||
ManageTopics: true,
|
||||
ManageRanks: true,
|
||||
ManageLinkedPeers: true,
|
||||
ChangeInfo: true,
|
||||
PostMessages: true,
|
||||
EditMessages: true,
|
||||
DeleteMessages: true,
|
||||
PostStories: true,
|
||||
EditStories: true,
|
||||
DeleteStories: true,
|
||||
BanUsers: true,
|
||||
InviteUsers: true,
|
||||
PinMessages: true,
|
||||
AddAdmins: true,
|
||||
ManageCall: true,
|
||||
ManageChat: true,
|
||||
ManageTopics: true,
|
||||
ManageRanks: true,
|
||||
ManageLinkedPeers: true,
|
||||
ManageWelcomeMessages: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -540,6 +547,14 @@ func (m ChannelMember) CanManageDirectMessages() bool {
|
|||
(m.Role == ChannelRoleAdmin && m.AdminRights.ManageDirectMessages))
|
||||
}
|
||||
|
||||
// CanManageWelcomeMessages is the Layer 229 creator/admin capability. Active
|
||||
// membership is mandatory even if stale admin rights remain in persisted JSON.
|
||||
func (m ChannelMember) CanManageWelcomeMessages() bool {
|
||||
return m.Status == ChannelMemberActive &&
|
||||
(m.Role == ChannelRoleCreator ||
|
||||
(m.Role == ChannelRoleAdmin && m.AdminRights.ManageWelcomeMessages))
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
|
|
|||
28
internal/domain/client_session.go
Normal file
28
internal/domain/client_session.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package domain
|
||||
|
||||
import "strings"
|
||||
|
||||
// ClientSessionMetadata is request-scoped initConnection/session context for
|
||||
// in-process features such as localized service bots. It is deliberately not a
|
||||
// durable message field and must never be exposed through the public Bot API.
|
||||
type ClientSessionMetadata struct {
|
||||
AuthKeyID [8]byte
|
||||
SessionID int64
|
||||
SystemLangCode string
|
||||
LangPack string
|
||||
LangCode string
|
||||
}
|
||||
|
||||
// PreferredLanguage returns a normalized BCP-47 primary language subtag.
|
||||
// Telegram clients normally send lang_code, with system_lang_code as fallback.
|
||||
func (m ClientSessionMetadata) PreferredLanguage() string {
|
||||
value := strings.TrimSpace(m.LangCode)
|
||||
if value == "" {
|
||||
value = strings.TrimSpace(m.SystemLangCode)
|
||||
}
|
||||
value = strings.ToLower(strings.ReplaceAll(value, "_", "-"))
|
||||
if at := strings.IndexByte(value, '-'); at >= 0 {
|
||||
value = value[:at]
|
||||
}
|
||||
return value
|
||||
}
|
||||
21
internal/domain/client_session_test.go
Normal file
21
internal/domain/client_session_test.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestClientSessionMetadataPreferredLanguage(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
in ClientSessionMetadata
|
||||
want string
|
||||
}{
|
||||
{name: "lang code wins", in: ClientSessionMetadata{LangCode: "RU-ru", SystemLangCode: "en-US"}, want: "ru"},
|
||||
{name: "system fallback", in: ClientSessionMetadata{SystemLangCode: "pt_BR"}, want: "pt"},
|
||||
{name: "empty", in: ClientSessionMetadata{}, want: ""},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := test.in.PreferredLanguage(); got != test.want {
|
||||
t.Fatalf("PreferredLanguage() = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,15 @@ type ContactList struct {
|
|||
Hash int64
|
||||
}
|
||||
|
||||
// ContactProjectionBatch is a viewer-owned contact read model for fan-out
|
||||
// projection. Contacts[viewerID][targetUserID] is the same row GetMany(viewer)
|
||||
// would return; PersonalPhotos carries that viewer's personal photo overrides
|
||||
// for the same target set.
|
||||
type ContactProjectionBatch struct {
|
||||
Contacts map[int64]map[int64]Contact
|
||||
PersonalPhotos map[int64]map[int64]ProfilePhotoRef
|
||||
}
|
||||
|
||||
// CloseFriendsEditResult describes a full close-friends list replacement.
|
||||
type CloseFriendsEditResult struct {
|
||||
AddedUserIDs []int64
|
||||
|
|
|
|||
|
|
@ -92,6 +92,21 @@ type Dialog struct {
|
|||
UnreadMark bool
|
||||
ViewForumAsMessages bool
|
||||
PeerSettingsBarHidden bool
|
||||
// TopMessageMentioned/MediaUnread/UnreadProjected are internal owner-view
|
||||
// facts for materialized channel dialog snapshots. They are not TL dialog
|
||||
// fields; response assembly applies them to the shared top-message payload.
|
||||
TopMessageMentioned bool
|
||||
TopMessageMediaUnread bool
|
||||
TopMessageUnreadProjected bool
|
||||
// DefaultSendAs is internal owner-view channel dialog metadata. It is kept
|
||||
// in the materialized owner snapshot so warming the exact viewer/channel
|
||||
// projection cannot erase channels.setDefaultSendAs state.
|
||||
DefaultSendAs *Peer
|
||||
// ChannelMember is the internal exact access/read projection captured with
|
||||
// a materialized channel dialog. It is never a TL dialog field. Keeping it
|
||||
// under the same dialog_owner generation lets startup warm permission reads
|
||||
// before channel difference without a per-channel PostgreSQL lookup.
|
||||
ChannelMember *ChannelMember
|
||||
// Pts 是 channel peer 当前 channel pts;客户端用 dialog.pts 初始化本地
|
||||
// channel 序列并决定 getChannelDifference 起点,channel dialog 必填。
|
||||
Pts int
|
||||
|
|
@ -168,6 +183,10 @@ type DialogArchiveSummary struct {
|
|||
// TopPeer/TopMessage 是归档内最新会话及其 top 消息(dialogFolder.peer/top_message)。
|
||||
TopPeer Peer
|
||||
TopMessage int
|
||||
// TopDialog retains the owner projection needed to hydrate a channel archive
|
||||
// top payload without re-reading channel_members/channel_dialogs. It is an
|
||||
// internal read-model field and is never converted into a second TL dialog.
|
||||
TopDialog *Dialog
|
||||
// UnreadPeersCount 是归档内有未读(或手动标记未读)的会话数;
|
||||
// UnreadMessagesCount 是归档未读消息总数。当前未接 per-peer mute
|
||||
// 状态,全部计入 unmuted 桶。
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ var (
|
|||
// ErrGifCatalogEntryNotFound is returned by an update/delete against an id
|
||||
// that doesn't exist.
|
||||
ErrGifCatalogEntryNotFound = errors.New("gif catalog entry not found")
|
||||
// ErrGifCatalogFull is returned when a create would push the catalog past
|
||||
// MaxGifCatalogEntries.
|
||||
ErrGifCatalogFull = errors.New("gif catalog is full")
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@ import (
|
|||
"telesrv/internal/branding"
|
||||
)
|
||||
|
||||
const officialLoginCodeMessageTemplate = `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `!
|
||||
func officialLoginCodeMessageTemplate() string {
|
||||
return `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `!
|
||||
|
||||
This code can be used to log in to your ` + branding.ProductName + ` account. We never ask it for anything else.
|
||||
|
||||
If you didn't request this code by trying to log in on another device, simply ignore this message.`
|
||||
}
|
||||
|
||||
// LoginCodeDeliveryRequest describes one durable 777000 login-code delivery.
|
||||
// PhoneCodeHash is an opaque idempotency token and must never be persisted in
|
||||
|
|
@ -41,7 +43,7 @@ func OfficialLoginCodeMessage(userID int64, code string, date int) (Message, err
|
|||
if userID <= 0 || IsSystemUserID(userID) || strings.TrimSpace(code) == "" || len(code) > 64 || date < 0 || date > math.MaxInt32 {
|
||||
return Message{}, fmt.Errorf("%w: user=%d code_length=%d date=%d", ErrLoginCodeDeliveryInvalid, userID, len(code), date)
|
||||
}
|
||||
body := fmt.Sprintf(officialLoginCodeMessageTemplate, code)
|
||||
body := fmt.Sprintf(officialLoginCodeMessageTemplate(), code)
|
||||
codeOffset := len("Login code: ")
|
||||
return Message{
|
||||
OwnerUserID: userID,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
// 字段带 json tag 是为了 store 层可直接 json.Marshal 落 JSONB(消息 media 快照、
|
||||
// 文档/照片元数据)。它们是协议无关的纯数据,不是 tg 生成类型。
|
||||
|
||||
// MediaBackend 标识 blob 字节实际存放后端。第一阶段只有本地磁盘。
|
||||
// MediaBackend 标识 blob 字节实际存放的唯一永久后端。
|
||||
type MediaBackend string
|
||||
|
||||
const (
|
||||
|
|
@ -664,15 +664,15 @@ type MessageNoForwardsAction struct {
|
|||
|
||||
// MessageServiceAction 是私聊服务消息动作的协议中立表示。
|
||||
type MessageServiceAction struct {
|
||||
Kind MessageServiceActionKind `json:"kind"`
|
||||
Photo *Photo `json:"photo,omitempty"`
|
||||
Call *MessagePhoneCallAction `json:"call,omitempty"`
|
||||
ConferenceCall *MessageConferenceCallAction `json:"conference_call,omitempty"`
|
||||
BotAllowed *MessageBotAllowedAction `json:"bot_allowed,omitempty"`
|
||||
WebViewData *MessageWebViewDataAction `json:"web_view_data,omitempty"`
|
||||
RequestedPeer *MessageRequestedPeerAction `json:"requested_peer,omitempty"`
|
||||
ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"`
|
||||
NoForwards *MessageNoForwardsAction `json:"no_forwards,omitempty"`
|
||||
Kind MessageServiceActionKind `json:"kind"`
|
||||
Photo *Photo `json:"photo,omitempty"`
|
||||
Call *MessagePhoneCallAction `json:"call,omitempty"`
|
||||
ConferenceCall *MessageConferenceCallAction `json:"conference_call,omitempty"`
|
||||
BotAllowed *MessageBotAllowedAction `json:"bot_allowed,omitempty"`
|
||||
WebViewData *MessageWebViewDataAction `json:"web_view_data,omitempty"`
|
||||
RequestedPeer *MessageRequestedPeerAction `json:"requested_peer,omitempty"`
|
||||
ChatThemeEmoticon string `json:"chat_theme_emoticon,omitempty"`
|
||||
NoForwards *MessageNoForwardsAction `json:"no_forwards,omitempty"`
|
||||
}
|
||||
|
||||
// MessageMedia 是一条消息媒体载荷的业务表示(落库为消息行上的 JSONB 快照)。
|
||||
|
|
|
|||
|
|
@ -51,14 +51,21 @@ func (c MediaCategoryCounts) CountAny(categories []MediaCategory) int {
|
|||
// Categories 是该标签页映射到的基础类别并集(PhotoVideo→[Photo,Video]、RoundVoice→[Voice,RoundVideo])。
|
||||
// 分页对齐历史语义:OffsetID 为游标(返回 id 严格小于它)、AddOffset 为额外偏移、MaxID/MinID 为闭区间。
|
||||
type MediaSearchRequest struct {
|
||||
Categories []MediaCategory
|
||||
OffsetID int
|
||||
AddOffset int
|
||||
Limit int
|
||||
MaxID int
|
||||
MinID int
|
||||
KnownCount int
|
||||
HasKnownCount bool
|
||||
Categories []MediaCategory
|
||||
Query string
|
||||
SenderUserID int64
|
||||
MinDate int
|
||||
MaxDate int
|
||||
TopMsgID int
|
||||
SavedPeer Peer
|
||||
SavedReactions []MessageReaction
|
||||
OffsetID int
|
||||
AddOffset int
|
||||
Limit int
|
||||
MaxID int
|
||||
MinID int
|
||||
KnownCount int
|
||||
HasKnownCount bool
|
||||
}
|
||||
|
||||
// ClassifyMediaCategories 返回一条消息所属的全部共享媒体类别(可为空:无媒体且无链接,
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ var (
|
|||
ErrFilePartsInvalid = errors.New("file parts invalid")
|
||||
ErrFilePartTooBig = errors.New("file part too big")
|
||||
ErrUploadQuotaExceeded = errors.New("upload quota exceeded")
|
||||
ErrPhotoInvalid = errors.New("photo invalid")
|
||||
ErrDocumentInvalid = errors.New("document invalid")
|
||||
ErrPhotoInvalid = errors.New("photo invalid")
|
||||
ErrDocumentInvalid = errors.New("document invalid")
|
||||
// ErrStorageFull is returned when the configured low-space guard rejects a
|
||||
// write: local disk free bytes (or, on the s3 backend, the configured
|
||||
// total-bytes budget) has fallen below the configured threshold.
|
||||
|
|
|
|||
|
|
@ -159,6 +159,9 @@ type Message struct {
|
|||
// Pinned 是 owner 视角的置顶标志(官方私聊多置顶语义:双方各自
|
||||
// 的 box 行独立持有,非 pm_oneside 操作两侧同步翻转)。
|
||||
Pinned bool
|
||||
// Deleted 是 owner 视角的软删除可见性标记。它只用于更新重放/差分恢复
|
||||
// 判断是否还能下发消息快照;普通历史查询应在 store 层直接过滤 deleted box。
|
||||
Deleted bool
|
||||
// SavedPeer 是 Saved Messages 分会话分组键(message.saved_peer_id)。
|
||||
// 仅 self-chat box 行非零:直发笔记 = self;转发进收藏夹 = 源会话 peer;
|
||||
// 存量回填兜底 hidden author 占位 user 2666000。非 self-chat 行恒零值。
|
||||
|
|
@ -196,7 +199,9 @@ func IsHistoryClearServiceMessage(msg Message) bool {
|
|||
msg.Media.ServiceAction.Kind == MessageServiceActionHistoryClear
|
||||
}
|
||||
|
||||
// MessageRichMessage 是 Layer 228 富文本消息(richMessage)的协议中立快照:一组 IV
|
||||
const MessageRichBlocksLegacyLayer = 228
|
||||
|
||||
// MessageRichMessage 是富文本消息(richMessage)的协议中立快照:一组 IV
|
||||
// PageBlock(Blocks)+ 内嵌已解析的 Photos/Documents。
|
||||
//
|
||||
// Blocks 存 gotd TL 序列化后的 []tg.PageBlockClass 不透明字节——PageBlock 体系庞大且
|
||||
|
|
@ -205,20 +210,32 @@ func IsHistoryClearServiceMessage(msg Message) bool {
|
|||
// 与 message media 同理,Photos/Documents 存已解析快照(含 viewer 无关的 access_hash),
|
||||
// 投影复用 tgPhoto/tgDocument。HTML/Markdown 输入也会在 RPC 边界归一为同一组 Blocks。
|
||||
//
|
||||
// 已知局限:Blocks 是 gotd 线格式不透明字节,跨 gotd 版本(PageBlock 构造器变更)可能
|
||||
// 失效——富文本消息为全新实验特性、无存量数据,Phase 1 接受该耦合。
|
||||
// BlocksLayer 是这份持久化字节的 exact TL profile,而不是发送客户端的 Layer。历史记录
|
||||
// 没有该字段;0 是正式的 storage-v1 标记,固定解释为 Layer 228。新写入必须显式保存
|
||||
// 编码时的 profile,读取不得靠 constructor 试解或失败后回退。
|
||||
type MessageRichMessage struct {
|
||||
Rtl bool `json:"rtl,omitempty"`
|
||||
Part bool `json:"part,omitempty"`
|
||||
Blocks []byte `json:"blocks,omitempty"`
|
||||
Photos []Photo `json:"photos,omitempty"`
|
||||
Documents []Document `json:"documents,omitempty"`
|
||||
Rtl bool `json:"rtl,omitempty"`
|
||||
Part bool `json:"part,omitempty"`
|
||||
BlocksLayer int `json:"blocks_layer,omitempty"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// EffectiveBlocksLayer returns the deterministic storage grammar for Blocks.
|
||||
// A missing JSON field is the original storage-v1 format and therefore Layer
|
||||
// 228; it is not an adaptive decode fallback.
|
||||
func (m *MessageRichMessage) EffectiveBlocksLayer() int {
|
||||
if m == nil || m.BlocksLayer == 0 {
|
||||
return MessageRichBlocksLegacyLayer
|
||||
}
|
||||
return m.BlocksLayer
|
||||
}
|
||||
|
||||
// IsZero 表示无富文本载荷(落库时跳过空快照、投影时不下发 rich_message)。
|
||||
func (m *MessageRichMessage) IsZero() bool {
|
||||
return m == nil || (len(m.Blocks) == 0 && len(m.Photos) == 0 && len(m.Documents) == 0)
|
||||
|
|
@ -274,7 +291,11 @@ type MessageFilter struct {
|
|||
// userFull.pinned_msg_id 的查询路径)。
|
||||
PinnedOnly bool
|
||||
MusicOnly bool
|
||||
NeedTotalCount bool
|
||||
PhoneCallsOnly bool
|
||||
// MissedPhoneCallsOnly narrows PhoneCallsOnly to incoming calls that ended
|
||||
// with the protocol-level "missed" reason.
|
||||
MissedPhoneCallsOnly bool
|
||||
NeedTotalCount bool
|
||||
// SavedPeer 非零时仅返回 self-chat 中该 saved 子会话的消息
|
||||
// (messages.getSavedHistory);Peer 必须同时是 self。
|
||||
SavedPeer Peer
|
||||
|
|
@ -302,6 +323,10 @@ type SendPrivateTextRequest struct {
|
|||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
// OriginClientSession carries the exact request's initConnection language
|
||||
// into an in-process bot responder. Stores and idempotency fingerprints
|
||||
// intentionally ignore this ephemeral metadata.
|
||||
OriginClientSession ClientSessionMetadata
|
||||
// OriginUserID identifies the authenticated initiator when a server-generated
|
||||
// service message is authored by another user. Zero preserves the ordinary
|
||||
// send path where the sender is the initiator.
|
||||
|
|
|
|||
250
internal/domain/message_entity_autodetect.go
Normal file
250
internal/domain/message_entity_autodetect.go
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// MessageEntitySpan describes an already occupied UTF-16 range while deriving
|
||||
// automatic entities. It deliberately carries no TL or entity-type semantics.
|
||||
type MessageEntitySpan struct {
|
||||
Offset int
|
||||
Length int
|
||||
}
|
||||
|
||||
// DetectAutomaticMessageEntities derives the server-recognized lexical
|
||||
// entities that do not require user intent: mentions, hashtags, cashtags and
|
||||
// bot commands. Offsets and lengths use Telegram's UTF-16 code-unit indexing.
|
||||
//
|
||||
// occupied ranges win over derived entities. URL-like tokens are also treated
|
||||
// as occupied for lexical triggers, so strings such as https://t.me/@name do
|
||||
// not become misleading mention entities even when URL projection is handled
|
||||
// by a different boundary.
|
||||
func DetectAutomaticMessageEntities(message string, occupied []MessageEntitySpan) []MessageEntity {
|
||||
if message == "" || !strings.ContainsAny(message, "@#$/") {
|
||||
return nil
|
||||
}
|
||||
type interval struct{ start, end int }
|
||||
blocked := make([]interval, 0, len(occupied)+8)
|
||||
for _, span := range occupied {
|
||||
if span.Offset >= 0 && span.Length > 0 {
|
||||
blocked = append(blocked, interval{start: span.Offset, end: span.Offset + span.Length})
|
||||
}
|
||||
}
|
||||
overlaps := func(start, end int) bool {
|
||||
for _, span := range blocked {
|
||||
if start < span.end && span.start < end {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
var out []MessageEntity
|
||||
accept := func(entity MessageEntity) {
|
||||
if entity.Length <= 0 || len(out) >= MaxMessageEntityCount {
|
||||
return
|
||||
}
|
||||
end := entity.Offset + entity.Length
|
||||
if overlaps(entity.Offset, end) {
|
||||
return
|
||||
}
|
||||
out = append(out, entity)
|
||||
blocked = append(blocked, interval{start: entity.Offset, end: end})
|
||||
}
|
||||
|
||||
for _, entity := range detectMentionMessageEntities(message) {
|
||||
accept(entity)
|
||||
}
|
||||
for _, entity := range detectHashtagMessageEntities(message) {
|
||||
accept(entity)
|
||||
}
|
||||
for _, entity := range detectCashtagMessageEntities(message) {
|
||||
accept(entity)
|
||||
}
|
||||
for _, entity := range detectBotCommandMessageEntities(message) {
|
||||
accept(entity)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func detectMentionMessageEntities(message string) []MessageEntity {
|
||||
var out []MessageEntity
|
||||
for i := 0; i < len(message); i++ {
|
||||
if message[i] != '@' || automaticEntityInsideURLLikeToken(message, i) {
|
||||
continue
|
||||
}
|
||||
if r, ok := previousRune(message, i); ok && (automaticEntityWordRune(r) || r == '@') {
|
||||
continue
|
||||
}
|
||||
end := i + 1
|
||||
for end < len(message) && automaticEntityUsernameByte(message[end]) {
|
||||
end++
|
||||
}
|
||||
if length := end - i - 1; length < 1 || length > 32 {
|
||||
continue
|
||||
}
|
||||
out = append(out, MessageEntity{
|
||||
Type: MessageEntityMention,
|
||||
Offset: automaticEntityUTF16Length(message[:i]),
|
||||
Length: automaticEntityUTF16Length(message[i:end]),
|
||||
})
|
||||
i = end - 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func detectBotCommandMessageEntities(message string) []MessageEntity {
|
||||
var out []MessageEntity
|
||||
for i := 0; i < len(message); i++ {
|
||||
if message[i] != '/' || automaticEntityInsideURLLikeToken(message, i) {
|
||||
continue
|
||||
}
|
||||
if r, ok := previousRune(message, i); ok && (automaticEntityWordRune(r) || r == '/' || r == '@' || r == '<') {
|
||||
continue
|
||||
}
|
||||
end := i + 1
|
||||
for end < len(message) && automaticEntityUsernameByte(message[end]) {
|
||||
end++
|
||||
}
|
||||
if length := end - i - 1; length < 1 || length > 64 {
|
||||
continue
|
||||
}
|
||||
if end < len(message) && message[end] == '@' {
|
||||
botEnd := end + 1
|
||||
for botEnd < len(message) && automaticEntityUsernameByte(message[botEnd]) {
|
||||
botEnd++
|
||||
}
|
||||
if length := botEnd - end - 1; length >= 1 && length <= 32 {
|
||||
end = botEnd
|
||||
}
|
||||
}
|
||||
out = append(out, MessageEntity{
|
||||
Type: MessageEntityBotCommand,
|
||||
Offset: automaticEntityUTF16Length(message[:i]),
|
||||
Length: automaticEntityUTF16Length(message[i:end]),
|
||||
})
|
||||
i = end - 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func detectHashtagMessageEntities(message string) []MessageEntity {
|
||||
var out []MessageEntity
|
||||
for i := 0; i < len(message); i++ {
|
||||
if message[i] != '#' || automaticEntityInsideURLLikeToken(message, i) {
|
||||
continue
|
||||
}
|
||||
if r, ok := previousRune(message, i); ok && (automaticEntityWordRune(r) || r == '#' || r == '@') {
|
||||
continue
|
||||
}
|
||||
end := i + 1
|
||||
var first rune
|
||||
count := 0
|
||||
for end < len(message) {
|
||||
r, size := utf8.DecodeRuneInString(message[end:])
|
||||
if size <= 0 || !automaticEntityHashtagRune(r) {
|
||||
break
|
||||
}
|
||||
if count == 0 {
|
||||
first = r
|
||||
}
|
||||
count++
|
||||
end += size
|
||||
}
|
||||
if count >= 1 && count <= 256 && !unicode.IsDigit(first) {
|
||||
out = append(out, MessageEntity{
|
||||
Type: MessageEntityHashtag,
|
||||
Offset: automaticEntityUTF16Length(message[:i]),
|
||||
Length: automaticEntityUTF16Length(message[i:end]),
|
||||
})
|
||||
i = end - 1
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func detectCashtagMessageEntities(message string) []MessageEntity {
|
||||
var out []MessageEntity
|
||||
for i := 0; i < len(message); i++ {
|
||||
if message[i] != '$' || automaticEntityInsideURLLikeToken(message, i) {
|
||||
continue
|
||||
}
|
||||
if r, ok := previousRune(message, i); ok && (automaticEntityWordRune(r) || r == '$') {
|
||||
continue
|
||||
}
|
||||
end := i + 1
|
||||
for end < len(message) && message[end] >= 'A' && message[end] <= 'Z' {
|
||||
end++
|
||||
}
|
||||
if length := end - i - 1; length < 1 || length > 8 {
|
||||
continue
|
||||
}
|
||||
if r, size := utf8.DecodeRuneInString(message[end:]); size > 0 && automaticEntityWordRune(r) {
|
||||
continue
|
||||
}
|
||||
out = append(out, MessageEntity{
|
||||
Type: MessageEntityCashtag,
|
||||
Offset: automaticEntityUTF16Length(message[:i]),
|
||||
Length: automaticEntityUTF16Length(message[i:end]),
|
||||
})
|
||||
i = end - 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func automaticEntityInsideURLLikeToken(message string, byteIndex int) bool {
|
||||
start := byteIndex
|
||||
for start > 0 {
|
||||
r, size := utf8.DecodeLastRuneInString(message[:start])
|
||||
if size <= 0 || automaticEntityURLBoundary(r) {
|
||||
break
|
||||
}
|
||||
start -= size
|
||||
}
|
||||
prefix := strings.TrimLeft(message[start:byteIndex], "([{(【")
|
||||
if strings.Contains(prefix, "://") {
|
||||
return true
|
||||
}
|
||||
separator := strings.IndexAny(prefix, "/?#")
|
||||
if separator <= 0 {
|
||||
return false
|
||||
}
|
||||
host := prefix[:separator]
|
||||
return strings.Contains(host, ".") && !strings.Contains(host, "@")
|
||||
}
|
||||
|
||||
func automaticEntityURLBoundary(r rune) bool {
|
||||
return unicode.IsSpace(r) || strings.ContainsRune("<>\"')】", r)
|
||||
}
|
||||
|
||||
func previousRune(message string, byteIndex int) (rune, bool) {
|
||||
if byteIndex <= 0 || byteIndex > len(message) {
|
||||
return 0, false
|
||||
}
|
||||
r, size := utf8.DecodeLastRuneInString(message[:byteIndex])
|
||||
return r, size > 0
|
||||
}
|
||||
|
||||
func automaticEntityWordRune(r rune) bool {
|
||||
return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r)
|
||||
}
|
||||
|
||||
func automaticEntityHashtagRune(r rune) bool {
|
||||
return automaticEntityWordRune(r)
|
||||
}
|
||||
|
||||
func automaticEntityUsernameByte(b byte) bool {
|
||||
return b == '_' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9'
|
||||
}
|
||||
|
||||
func automaticEntityUTF16Length(text string) int {
|
||||
length := 0
|
||||
for _, r := range text {
|
||||
length++
|
||||
if r > 0xffff {
|
||||
length++
|
||||
}
|
||||
}
|
||||
return length
|
||||
}
|
||||
82
internal/domain/message_entity_autodetect_test.go
Normal file
82
internal/domain/message_entity_autodetect_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectAutomaticMessageEntitiesUsesUTF16AndWhitespaceBoundaries(t *testing.T) {
|
||||
message := "اعلان 🚀\n\n@matrixG"
|
||||
want := []MessageEntity{{Type: MessageEntityMention, Offset: 10, Length: 8}}
|
||||
if got := DetectAutomaticMessageEntities(message, nil); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("entities = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAutomaticMessageEntitiesCoversLexicalTypes(t *testing.T) {
|
||||
message := "@alice #golang $USD /help@matrix_bot"
|
||||
want := []MessageEntity{
|
||||
{Type: MessageEntityMention, Offset: 0, Length: 6},
|
||||
{Type: MessageEntityHashtag, Offset: 7, Length: 7},
|
||||
{Type: MessageEntityCashtag, Offset: 15, Length: 4},
|
||||
{Type: MessageEntityBotCommand, Offset: 20, Length: 16},
|
||||
}
|
||||
if got := DetectAutomaticMessageEntities(message, nil); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("entities = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAutomaticMessageEntitiesSkipsEmailURLsAndOccupiedRanges(t *testing.T) {
|
||||
message := "mail bob@example.com https://t.me/@scam github.com/@other @real"
|
||||
got := DetectAutomaticMessageEntities(message, []MessageEntitySpan{{Offset: 58, Length: 5}})
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("entities = %+v, want no email, URL-path or occupied mention", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAutomaticMessageEntitiesMentionBoundaries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
message string
|
||||
want []MessageEntity
|
||||
}{
|
||||
{
|
||||
name: "maximum username length",
|
||||
message: "\n@" + strings.Repeat("a", 32),
|
||||
want: []MessageEntity{{Type: MessageEntityMention, Offset: 1, Length: 33}},
|
||||
},
|
||||
{
|
||||
name: "username too long",
|
||||
message: "@" + strings.Repeat("a", 33),
|
||||
},
|
||||
{
|
||||
name: "unicode word prefix",
|
||||
message: "نام@matrixG",
|
||||
},
|
||||
{
|
||||
name: "duplicate at prefix",
|
||||
message: "@@matrixG",
|
||||
},
|
||||
{
|
||||
name: "punctuation boundary",
|
||||
message: "(@matrixG)",
|
||||
want: []MessageEntity{{Type: MessageEntityMention, Offset: 1, Length: 8}},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := DetectAutomaticMessageEntities(tt.message, nil); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("entities = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectAutomaticMessageEntitiesCapsDerivedEntities(t *testing.T) {
|
||||
message := strings.Repeat("@a ", MaxMessageEntityCount+1)
|
||||
got := DetectAutomaticMessageEntities(message, nil)
|
||||
if len(got) != MaxMessageEntityCount {
|
||||
t.Fatalf("entity count = %d, want %d", len(got), MaxMessageEntityCount)
|
||||
}
|
||||
}
|
||||
|
|
@ -65,6 +65,9 @@ const (
|
|||
MarkupButtonSimpleWebView MarkupButtonType = "simple_webview"
|
||||
MarkupButtonSwitchInline MarkupButtonType = "switch_inline"
|
||||
MarkupButtonCopy MarkupButtonType = "copy"
|
||||
// MarkupButtonBuy is keyboardButtonBuy. It is valid only on an inline
|
||||
// keyboard attached to invoice media.
|
||||
MarkupButtonBuy MarkupButtonType = "buy"
|
||||
)
|
||||
|
||||
// MarkupButtonStyle is the protocol-neutral semantic button color. Telegram
|
||||
|
|
@ -375,6 +378,11 @@ func validateMarkupButton(b MarkupButton, replyKeyboard bool) error {
|
|||
if b.CopyText == "" || utf8.RuneCountInString(b.CopyText) > 256 {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
case MarkupButtonBuy:
|
||||
if len(b.Data) != 0 || b.URL != "" || b.Query != "" || b.CopyText != "" ||
|
||||
b.RequiresPassword || b.LoginBotUserID != 0 || b.ButtonID != 0 {
|
||||
return ErrButtonInvalid
|
||||
}
|
||||
default:
|
||||
// webview/game/url_auth/request_* 等 P3 未实现类型:拒绝,绝不半实现下发。
|
||||
return ErrButtonTypeInvalid
|
||||
|
|
|
|||
|
|
@ -1,10 +1,32 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMessageRichMessageMissingBlocksLayerIsLegacy228(t *testing.T) {
|
||||
var rich MessageRichMessage
|
||||
if err := json.Unmarshal([]byte(`{"blocks":"FQ=="}`), &rich); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := rich.EffectiveBlocksLayer(); got != MessageRichBlocksLegacyLayer {
|
||||
t.Fatalf("effective blocks layer = %d, want %d", got, MessageRichBlocksLegacyLayer)
|
||||
}
|
||||
if rich.BlocksLayer != 0 {
|
||||
t.Fatalf("legacy JSON mutated stored blocks layer to %d", rich.BlocksLayer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageRichMessageExplicitBlocksLayer(t *testing.T) {
|
||||
const storedLayer = 230
|
||||
rich := MessageRichMessage{BlocksLayer: storedLayer}
|
||||
if got := rich.EffectiveBlocksLayer(); got != storedLayer {
|
||||
t.Fatalf("effective blocks layer = %d, want %d", got, storedLayer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMessageReplyBoundsRejectsQuoteOffsetAsTextOffset(t *testing.T) {
|
||||
reply := &MessageReply{
|
||||
MessageID: 1,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
package domain
|
||||
|
||||
// PhoneChangeRequest 是账号改号的持久化命令。PG 实现必须把 User、Event 与
|
||||
// dispatch outbox 放在同一事务;Exclude* 精确排除发起设备,因为当前设备从
|
||||
// account.changePhone 的 User 返回值更新本地状态。
|
||||
// PhoneChangeRequest 是账号改号的持久化命令。updateUserPhone 不携带 PTS,
|
||||
// 因此该命令只维护号码事实;Exclude* 保留在 DTO 中用于兼容调用边界,在线
|
||||
// 非 PTS 通知由 RPC 层按发起设备排除。
|
||||
type PhoneChangeRequest struct {
|
||||
UserID int64
|
||||
Phone string
|
||||
|
|
@ -18,6 +18,5 @@ type PhoneChangeRequest struct {
|
|||
|
||||
type PhoneChangeResult struct {
|
||||
User User
|
||||
Event UpdateEvent
|
||||
Changed bool
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,15 +25,16 @@ var (
|
|||
ErrSecretChatAlreadyAccepted = errors.New("secretchat: already accepted")
|
||||
// ErrSecretChatAlreadyDeclined:accept 一个已销毁的密聊 → ENCRYPTION_ALREADY_DECLINED。
|
||||
ErrSecretChatAlreadyDeclined = errors.New("secretchat: already declined")
|
||||
// ErrSecretChatIDConflict:chat_id 主键撞键(计数器回退);调用方按 AtLeast 重分配重试。
|
||||
ErrSecretChatIDConflict = errors.New("secretchat: chat id conflict")
|
||||
// ErrSecretChatRandomIDDuplicate:requestEncryption.random_id 已被不同意图或其它
|
||||
// auth key 占用。random_id 同时就是 chat_id,禁止另分配 ID 规避碰撞。
|
||||
ErrSecretChatRandomIDDuplicate = errors.New("secretchat: random id duplicate")
|
||||
)
|
||||
|
||||
// SecretChat 是一通私聊密聊的服务端权威态(durable,跨重启存活)。
|
||||
// 字段命名对齐 TL encryptedChat*;ID 是 int32 量级,access_hash/admin_id/
|
||||
// participant_id/key_fingerprint 是 int64。绑定维度是设备级(perm auth_key 的 int64 值)。
|
||||
type SecretChat struct {
|
||||
// ID 是 chat_id,全局单调 int32 序列;双方共享同一 id。
|
||||
// ID 是 chat_id,必须逐位等于 requestEncryption.random_id(非零 int32);双方共享同一 id。
|
||||
ID int
|
||||
// AdminAccessHash / ParticipantAccessHash 双视角不同(TL "check sum depending on user ID")。
|
||||
AdminAccessHash int64
|
||||
|
|
@ -108,6 +109,19 @@ func (c SecretChat) PeerAuthKeyOf(userID int64) int64 {
|
|||
}
|
||||
}
|
||||
|
||||
// AuthKeyOf 返回 userID 自身绑定的 permanent auth key;非参与者或尚未绑定返回 0。
|
||||
// 已建立密聊的所有读写授权必须同时匹配 user 与该 auth key,不能只依赖账号身份。
|
||||
func (c SecretChat) AuthKeyOf(userID int64) int64 {
|
||||
switch userID {
|
||||
case c.AdminUserID:
|
||||
return c.AdminAuthKeyID
|
||||
case c.ParticipantUserID:
|
||||
return c.ParticipantAuthKeyID
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// AccessHashFor 返回 userID 视角的 access_hash(双方不同);非参与者返回 0。
|
||||
func (c SecretChat) AccessHashFor(userID int64) int64 {
|
||||
switch userID {
|
||||
|
|
@ -177,6 +191,12 @@ type SecretMessageDelivery struct {
|
|||
Date int
|
||||
}
|
||||
|
||||
// MaxSecretMessageDataBytes bounds the opaque encrypted DecryptedMessage payload persisted in
|
||||
// the device queue. Secret-chat media bytes travel through the file service, so a 1 MiB metadata
|
||||
// envelope is already well above the payload emitted by the supported clients while keeping one
|
||||
// request independent from the process-wide MTProto admission budget.
|
||||
const MaxSecretMessageDataBytes = 1 << 20
|
||||
|
||||
// SecretChatRequest 是 requestEncryption 受理入参(隐私/拉黑/self/bot 校验在 rpc 层先行)。
|
||||
type SecretChatRequest struct {
|
||||
AdminUserID int64
|
||||
|
|
|
|||
169
internal/domain/stats.go
Normal file
169
internal/domain/stats.go
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxChannelStatsDays bounds every aggregate query independently of the
|
||||
// client request. The RPC currently requests seven days, while keeping a
|
||||
// small domain ceiling makes future callers safe by construction.
|
||||
MaxChannelStatsDays = 31
|
||||
// MaxChannelStatsTopPosters bounds the user hydration work at the RPC edge.
|
||||
MaxChannelStatsTopPosters = 10
|
||||
// MaxChannelStatsRecentPosts bounds correlated interaction aggregation.
|
||||
MaxChannelStatsRecentPosts = 10
|
||||
// MaxChannelMessagePublicForwards is Telegram's public-forwards page cap.
|
||||
MaxChannelMessagePublicForwards = 100
|
||||
)
|
||||
|
||||
var ErrStatsOffsetInvalid = errors.New("stats offset invalid")
|
||||
|
||||
// StatsPeriod is a half-open Unix-second range [MinDate, MaxDate). Previous
|
||||
// values always use the equally sized range immediately before MinDate.
|
||||
type StatsPeriod struct {
|
||||
MinDate int
|
||||
MaxDate int
|
||||
}
|
||||
|
||||
func (p StatsPeriod) Valid() bool {
|
||||
return p.MinDate > 0 && p.MaxDate > p.MinDate &&
|
||||
p.MaxDate-p.MinDate <= MaxChannelStatsDays*86400
|
||||
}
|
||||
|
||||
func (p StatsPeriod) PreviousMinDate() int {
|
||||
return p.MinDate - (p.MaxDate - p.MinDate)
|
||||
}
|
||||
|
||||
// StatsValueAndPrev is one current-period value and its previous-period peer.
|
||||
type StatsValueAndPrev struct {
|
||||
Current float64
|
||||
Previous float64
|
||||
}
|
||||
|
||||
// StatsReactionCount is one protocol-neutral reaction series value.
|
||||
type StatsReactionCount struct {
|
||||
Reaction MessageReaction
|
||||
Count int
|
||||
}
|
||||
|
||||
// ChannelStatsDay is a UTC-day bucket. Date is the bucket start.
|
||||
type ChannelStatsDay struct {
|
||||
Date int
|
||||
Members int
|
||||
NewMembers int
|
||||
Messages int
|
||||
Viewers int
|
||||
Posters int
|
||||
Views int
|
||||
Shares int
|
||||
Reactions int
|
||||
ByReaction []StatsReactionCount
|
||||
}
|
||||
|
||||
type ChannelStatsTopPoster struct {
|
||||
UserID int64
|
||||
Messages int
|
||||
AvgChars int
|
||||
}
|
||||
|
||||
type ChannelStatsRecentPost struct {
|
||||
MessageID int
|
||||
Views int
|
||||
Forwards int
|
||||
Reactions int
|
||||
}
|
||||
|
||||
// ChannelStats is the durable minimum needed by broadcast/megagroup stats.
|
||||
// Unsupported Telegram dimensions (language, notification mute, IV sources)
|
||||
// are deliberately absent so the RPC edge can return statsGraphError instead
|
||||
// of manufacturing zero-valued facts.
|
||||
type ChannelStats struct {
|
||||
Channel Channel
|
||||
Period StatsPeriod
|
||||
Members StatsValueAndPrev
|
||||
Messages StatsValueAndPrev
|
||||
Viewers StatsValueAndPrev
|
||||
Posters StatsValueAndPrev
|
||||
ViewsPerPost StatsValueAndPrev
|
||||
SharesPerPost StatsValueAndPrev
|
||||
ReactionsPerPost StatsValueAndPrev
|
||||
Days []ChannelStatsDay
|
||||
TopPosters []ChannelStatsTopPoster
|
||||
RecentPosts []ChannelStatsRecentPost
|
||||
}
|
||||
|
||||
type ChannelStatsRequest struct {
|
||||
ViewerUserID int64
|
||||
ChannelID int64
|
||||
Period StatsPeriod
|
||||
}
|
||||
|
||||
// ChannelMessageStats contains event-time view and reaction buckets for one
|
||||
// existing channel message.
|
||||
type ChannelMessageStats struct {
|
||||
Channel Channel
|
||||
Message ChannelMessage
|
||||
Period StatsPeriod
|
||||
Days []ChannelStatsDay
|
||||
}
|
||||
|
||||
type ChannelMessageStatsRequest struct {
|
||||
ViewerUserID int64
|
||||
ChannelID int64
|
||||
MessageID int
|
||||
Period StatsPeriod
|
||||
}
|
||||
|
||||
// ChannelMessagePublicForwardListRequest pages public channel/supergroup
|
||||
// messages whose forward header identifies one exact source channel post.
|
||||
type ChannelMessagePublicForwardListRequest struct {
|
||||
ViewerUserID int64
|
||||
ChannelID int64
|
||||
MessageID int
|
||||
Offset string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type ChannelMessagePublicForwardList struct {
|
||||
Count int
|
||||
Messages []ChannelMessage
|
||||
NextOffset string
|
||||
}
|
||||
|
||||
// ChannelMessagePublicForwardCursor is ordered by date DESC, destination
|
||||
// channel ASC, message id DESC. A versioned textual form is intentionally
|
||||
// opaque to clients while staying easy to validate and log.
|
||||
type ChannelMessagePublicForwardCursor struct {
|
||||
Date int
|
||||
ChannelID int64
|
||||
MessageID int
|
||||
}
|
||||
|
||||
func ParseChannelMessagePublicForwardCursor(offset string) (ChannelMessagePublicForwardCursor, error) {
|
||||
if offset == "" {
|
||||
return ChannelMessagePublicForwardCursor{}, nil
|
||||
}
|
||||
parts := strings.Split(offset, ":")
|
||||
if len(parts) != 4 || parts[0] != "cmf1" {
|
||||
return ChannelMessagePublicForwardCursor{}, ErrStatsOffsetInvalid
|
||||
}
|
||||
date, err1 := strconv.Atoi(parts[1])
|
||||
channelID, err2 := strconv.ParseInt(parts[2], 10, 64)
|
||||
messageID, err3 := strconv.Atoi(parts[3])
|
||||
if err1 != nil || err2 != nil || err3 != nil || date <= 0 || channelID <= 0 ||
|
||||
messageID <= 0 || messageID > MaxMessageBoxID {
|
||||
return ChannelMessagePublicForwardCursor{}, ErrStatsOffsetInvalid
|
||||
}
|
||||
return ChannelMessagePublicForwardCursor{Date: date, ChannelID: channelID, MessageID: messageID}, nil
|
||||
}
|
||||
|
||||
func FormatChannelMessagePublicForwardCursor(message ChannelMessage) string {
|
||||
if message.Date <= 0 || message.ChannelID <= 0 || message.ID <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("cmf1:%d:%d:%d", message.Date, message.ChannelID, message.ID)
|
||||
}
|
||||
|
|
@ -209,6 +209,18 @@ func OfficialSystemUser() User {
|
|||
return u
|
||||
}
|
||||
|
||||
// ChatBotDescription and StickersBotDescription are the shared branded seed
|
||||
// text for both user.about and bots.description. PostgreSQL migrations contain
|
||||
// only the default snapshot; startup reconciliation and the memory backend use
|
||||
// these helpers so custom deployments do not expose stale "telesrv" text.
|
||||
func ChatBotDescription() string {
|
||||
return "Chat with the configured " + branding.ProductName + " AI provider."
|
||||
}
|
||||
|
||||
func StickersBotDescription() string {
|
||||
return "Create custom sticker and emoji packs for " + branding.ProductName + "."
|
||||
}
|
||||
|
||||
// BotFatherUser 返回内置 BotFather 账号。username 不以 bot 结尾属种子例外(与官方一致)。
|
||||
func BotFatherUser() User {
|
||||
u := User{
|
||||
|
|
@ -235,6 +247,7 @@ func StickersBotUser() User {
|
|||
AccessHash: StickersBotAccessHash,
|
||||
FirstName: "Stickers",
|
||||
Username: "Stickers",
|
||||
About: StickersBotDescription(),
|
||||
Verified: true,
|
||||
Bot: true,
|
||||
BotInfoVersion: 2,
|
||||
|
|
@ -254,6 +267,7 @@ func ChatBotUser() User {
|
|||
AccessHash: ChatBotAccessHash,
|
||||
FirstName: "ChatBot",
|
||||
Username: "ChatBot",
|
||||
About: ChatBotDescription(),
|
||||
Verified: true,
|
||||
Bot: true,
|
||||
BotInfoVersion: 1,
|
||||
|
|
|
|||
|
|
@ -9,3 +9,11 @@ type TempAuthKeyBinding struct {
|
|||
ExpiresAt int
|
||||
EncryptedMessage []byte
|
||||
}
|
||||
|
||||
// TempAuthKeyBindingResult is the exact auth-key default committed by the
|
||||
// temp-to-permanent binding transaction. LayerObservationID is the durable
|
||||
// ordering token; zero denotes the legacy unordered default.
|
||||
type TempAuthKeyBindingResult struct {
|
||||
Layer int
|
||||
LayerObservationID int64
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ const (
|
|||
UpdateEventDialogUnreadMark UpdateEventType = "dialog_unread_mark"
|
||||
UpdateEventPeerSettings UpdateEventType = "peer_settings"
|
||||
UpdateEventPeerStoryBlocked UpdateEventType = "peer_story_blocked"
|
||||
// UpdateEventUserPhone 映射 updateUserPhone。它是账号绝对状态更新,TL
|
||||
// 构造器不携 pts;事件仍占账号 pts,以便其它设备在线/离线保持同一水位。
|
||||
// UpdateEventUserPhone 只用于读取历史版本已落库的 updateUserPhone 事件。
|
||||
// TL 构造器不携 pts,当前写路径禁止再产生该 event。
|
||||
UpdateEventUserPhone UpdateEventType = "user_phone"
|
||||
// UpdateEventUserEmojiStatus carries the exact immutable status snapshot.
|
||||
// It consumes account pts even though updateUserEmojiStatus has no pts.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const officialWelcomeMessageTemplate = "👋 Welcome to OwpenGram!\n\nYou just signed in via %s.\n\nIf this wasn't you, revoke this session from \"Settings > Privacy and Security > Active sessions\" immediately."
|
||||
|
|
@ -40,3 +44,202 @@ func SignInMethodLabel(u User) string {
|
|||
}
|
||||
return "phone number"
|
||||
}
|
||||
|
||||
const (
|
||||
MaxWelcomeMessagesPerPeer = 5
|
||||
MaxWelcomeMessageContentBytes = 4 << 20
|
||||
InitialWelcomeRevision = int64(1)
|
||||
WelcomeMessageDeliveryTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWelcomeMessageInvalid = errors.New("welcome message invalid")
|
||||
ErrWelcomeMessagePeerInvalid = errors.New("welcome message peer invalid")
|
||||
ErrWelcomeMessageForbidden = errors.New("welcome message forbidden")
|
||||
ErrWelcomeMessageNotFound = errors.New("welcome message not found")
|
||||
ErrWelcomeMessageNotModified = errors.New("welcome message not modified")
|
||||
ErrWelcomeMessageLimit = errors.New("welcome message limit exceeded")
|
||||
ErrWelcomeMessageRandomIDConflict = errors.New("welcome message random id conflict")
|
||||
ErrWelcomeMessageRevisionOverflow = errors.New("welcome message revision overflow")
|
||||
)
|
||||
|
||||
// WelcomeMessageContent is a durable peer template. It intentionally does not
|
||||
// contain transient receiver, device, callback, report, TTL or reply fields.
|
||||
type WelcomeMessageContent struct {
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
Media *MessageMedia
|
||||
ReplyMarkup *MessageReplyMarkup
|
||||
RichMessage *MessageRichMessage
|
||||
InvertMedia bool
|
||||
NoForwards bool
|
||||
}
|
||||
|
||||
func (c WelcomeMessageContent) Validate() error {
|
||||
if err := ValidateEphemeralContent(EphemeralContent{
|
||||
Message: c.Message, Entities: c.Entities, Media: c.Media,
|
||||
ReplyMarkup: c.ReplyMarkup, RichMessage: c.RichMessage,
|
||||
}); err != nil {
|
||||
return ErrWelcomeMessageInvalid
|
||||
}
|
||||
if c.InvertMedia && c.Media == nil {
|
||||
return ErrWelcomeMessageInvalid
|
||||
}
|
||||
raw, err := json.Marshal(c)
|
||||
if err != nil || len(raw) > MaxWelcomeMessageContentBytes {
|
||||
return ErrWelcomeMessageInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type WelcomeMessage struct {
|
||||
ID int
|
||||
Peer Peer
|
||||
CreatorUserID int64
|
||||
Date int
|
||||
EditDate int
|
||||
RandomID int64
|
||||
Content WelcomeMessageContent
|
||||
CreateFingerprint [32]byte
|
||||
Version uint64
|
||||
}
|
||||
|
||||
func (m WelcomeMessage) ValidateStored() error {
|
||||
if m.ID <= 0 || m.ID > MaxMessageBoxID || m.Peer.Type != PeerTypeChannel || m.Peer.ID <= 0 ||
|
||||
m.CreatorUserID <= 0 || m.Date <= 0 || m.RandomID == 0 || m.Version == 0 ||
|
||||
(m.EditDate != 0 && m.EditDate < m.Date) || m.CreateFingerprint == ([32]byte{}) {
|
||||
return ErrWelcomeMessageInvalid
|
||||
}
|
||||
return m.Content.Validate()
|
||||
}
|
||||
|
||||
type CreateWelcomeMessageRequest struct {
|
||||
Peer Peer
|
||||
CreatorUserID int64
|
||||
Date int
|
||||
RandomID int64
|
||||
Content WelcomeMessageContent
|
||||
CreateFingerprint [32]byte
|
||||
}
|
||||
|
||||
func (r CreateWelcomeMessageRequest) Validate() error {
|
||||
if r.Peer.Type != PeerTypeChannel || r.Peer.ID <= 0 || r.CreatorUserID <= 0 ||
|
||||
r.Date <= 0 || r.RandomID == 0 || r.CreateFingerprint == ([32]byte{}) {
|
||||
return ErrWelcomeMessageInvalid
|
||||
}
|
||||
return r.Content.Validate()
|
||||
}
|
||||
|
||||
type WelcomeMessageEditFields struct {
|
||||
SetMessage bool
|
||||
Message string
|
||||
SetEntities bool
|
||||
Entities []MessageEntity
|
||||
SetMedia bool
|
||||
Media *MessageMedia
|
||||
SetReplyMarkup bool
|
||||
ReplyMarkup *MessageReplyMarkup
|
||||
SetRichMessage bool
|
||||
RichMessage *MessageRichMessage
|
||||
SetInvertMedia bool
|
||||
InvertMedia bool
|
||||
}
|
||||
|
||||
func (f WelcomeMessageEditFields) Empty() bool {
|
||||
return !f.SetMessage && !f.SetEntities && !f.SetMedia && !f.SetReplyMarkup &&
|
||||
!f.SetRichMessage && !f.SetInvertMedia
|
||||
}
|
||||
|
||||
func (f WelcomeMessageEditFields) Apply(current WelcomeMessageContent) (WelcomeMessageContent, error) {
|
||||
if f.Empty() {
|
||||
return WelcomeMessageContent{}, ErrWelcomeMessageInvalid
|
||||
}
|
||||
if f.SetMessage {
|
||||
current.Message = f.Message
|
||||
}
|
||||
if f.SetEntities {
|
||||
current.Entities = f.Entities
|
||||
}
|
||||
if f.SetMedia {
|
||||
current.Media = f.Media
|
||||
}
|
||||
if f.SetReplyMarkup {
|
||||
current.ReplyMarkup = f.ReplyMarkup
|
||||
}
|
||||
if f.SetRichMessage {
|
||||
current.RichMessage = f.RichMessage
|
||||
}
|
||||
if f.SetInvertMedia {
|
||||
current.InvertMedia = f.InvertMedia
|
||||
}
|
||||
if err := current.Validate(); err != nil {
|
||||
return WelcomeMessageContent{}, err
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
type EditWelcomeMessageRequest struct {
|
||||
Peer Peer
|
||||
ID int
|
||||
EditDate int
|
||||
Fields WelcomeMessageEditFields
|
||||
}
|
||||
|
||||
func (r EditWelcomeMessageRequest) Validate() error {
|
||||
if r.Peer.Type != PeerTypeChannel || r.Peer.ID <= 0 || r.ID <= 0 ||
|
||||
r.ID > MaxMessageBoxID || r.EditDate <= 0 || r.Fields.Empty() {
|
||||
return ErrWelcomeMessageInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type WelcomeMessageList struct {
|
||||
Hash int64
|
||||
Messages []WelcomeMessage
|
||||
NotModified bool
|
||||
}
|
||||
|
||||
// WelcomeMessageDelivery is a short-lived, non-PTS snapshot created in the
|
||||
// same transaction as one inactive->active membership transition. It is not a
|
||||
// message-history row and must be physically removed no later than ExpiresAt.
|
||||
type WelcomeMessageDelivery struct {
|
||||
ID int64
|
||||
JoinEventID int64
|
||||
ChannelID int64
|
||||
TargetUserID int64
|
||||
TemplateID int
|
||||
EphemeralID int
|
||||
JoinedAt int
|
||||
Content WelcomeMessageContent
|
||||
AttemptCount int
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func (d WelcomeMessageDelivery) ValidateStored(now time.Time) error {
|
||||
if d.ID <= 0 || d.JoinEventID <= 0 || d.ChannelID <= 0 || d.TargetUserID <= 0 ||
|
||||
d.TemplateID <= 0 || d.EphemeralID <= 0 || d.EphemeralID > MaxMessageBoxID ||
|
||||
d.JoinedAt <= 0 || d.AttemptCount <= 0 || d.ExpiresAt.IsZero() || !d.ExpiresAt.After(now) {
|
||||
return ErrWelcomeMessageInvalid
|
||||
}
|
||||
return d.Content.Validate()
|
||||
}
|
||||
|
||||
func WelcomeCreateFingerprint(peer Peer, creatorUserID, randomID int64, content WelcomeMessageContent) ([32]byte, error) {
|
||||
raw, err := json.Marshal(struct {
|
||||
Peer Peer
|
||||
CreatorUserID int64
|
||||
RandomID int64
|
||||
Content WelcomeMessageContent
|
||||
}{peer, creatorUserID, randomID, content})
|
||||
if err != nil {
|
||||
return [32]byte{}, err
|
||||
}
|
||||
return sha256.Sum256(raw), nil
|
||||
}
|
||||
|
||||
func NextWelcomeRevision(current int64) (int64, error) {
|
||||
if current < InitialWelcomeRevision || current == math.MaxInt64 {
|
||||
return 0, ErrWelcomeMessageRevisionOverflow
|
||||
}
|
||||
return current + 1, nil
|
||||
}
|
||||
|
|
|
|||
54
internal/domain/welcome_message_test.go
Normal file
54
internal/domain/welcome_message_test.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWelcomeMessageContentAndFingerprint(t *testing.T) {
|
||||
peer := Peer{Type: PeerTypeChannel, ID: 42}
|
||||
content := WelcomeMessageContent{Message: "Welcome 👋"}
|
||||
if err := content.Validate(); err != nil {
|
||||
t.Fatalf("valid content: %v", err)
|
||||
}
|
||||
first, err := WelcomeCreateFingerprint(peer, 7, 99, content)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := WelcomeCreateFingerprint(peer, 7, 99, content)
|
||||
if err != nil || first != second || first == ([32]byte{}) {
|
||||
t.Fatalf("deterministic fingerprint = %x/%x err=%v", first, second, err)
|
||||
}
|
||||
changed, err := WelcomeCreateFingerprint(peer, 7, 99, WelcomeMessageContent{Message: "Different"})
|
||||
if err != nil || changed == first {
|
||||
t.Fatalf("changed fingerprint = %x err=%v", changed, err)
|
||||
}
|
||||
if err := (WelcomeMessageContent{Message: "text", InvertMedia: true}).Validate(); !errors.Is(err, ErrWelcomeMessageInvalid) {
|
||||
t.Fatalf("invert without media err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWelcomeMessageEditFields(t *testing.T) {
|
||||
current := WelcomeMessageContent{Message: "before", NoForwards: true}
|
||||
updated, err := (WelcomeMessageEditFields{
|
||||
SetMessage: true, Message: "after", SetEntities: true,
|
||||
}).Apply(current)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Message != "after" || !updated.NoForwards || len(updated.Entities) != 0 {
|
||||
t.Fatalf("updated content = %+v", updated)
|
||||
}
|
||||
if _, err := (WelcomeMessageEditFields{}).Apply(current); !errors.Is(err, ErrWelcomeMessageInvalid) {
|
||||
t.Fatalf("empty edit err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextWelcomeRevision(t *testing.T) {
|
||||
if next, err := NextWelcomeRevision(InitialWelcomeRevision); err != nil || next != 2 {
|
||||
t.Fatalf("next revision = %d,%v", next, err)
|
||||
}
|
||||
if _, err := NextWelcomeRevision(0); !errors.Is(err, ErrWelcomeMessageRevisionOverflow) {
|
||||
t.Fatalf("zero revision err = %v", err)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue