merged from gramsrv upstream

This commit is contained in:
onysd 2026-09-01 12:06:31 +03:00
parent 79c64ee916
commit 21a0856587
651 changed files with 54774 additions and 4590 deletions

View file

@ -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

View file

@ -90,10 +90,3 @@ type AccountDeletionCandidate struct {
Source AccountDeletionSource
DueAt time.Time
}
type AccountDeletionNotification struct {
ID int64
TargetUserID int64
DeletedUserID int64
Attempts int
}

View file

@ -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 也需要保留的客户端协商元数据。

View file

@ -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.

View 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)
}
}

View file

@ -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.

View file

@ -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.

View 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
}

View 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)
}
})
}
}

View file

@ -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

View file

@ -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 桶。

View file

@ -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 (

View file

@ -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,

View file

@ -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 快照)。

View file

@ -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 返回一条消息所属的全部共享媒体类别(可为空:无媒体且无链接,

View file

@ -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.

View file

@ -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
// PageBlockBlocks+ 内嵌已解析的 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.getSavedHistoryPeer 必须同时是 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.

View 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
}

View 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)
}
}

View file

@ -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

View file

@ -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,

View file

@ -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
}

View file

@ -25,15 +25,16 @@ var (
ErrSecretChatAlreadyAccepted = errors.New("secretchat: already accepted")
// ErrSecretChatAlreadyDeclinedaccept 一个已销毁的密聊 → ENCRYPTION_ALREADY_DECLINED。
ErrSecretChatAlreadyDeclined = errors.New("secretchat: already declined")
// ErrSecretChatIDConflictchat_id 主键撞键(计数器回退);调用方按 AtLeast 重分配重试。
ErrSecretChatIDConflict = errors.New("secretchat: chat id conflict")
// ErrSecretChatRandomIDDuplicaterequestEncryption.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
View 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)
}

View file

@ -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,

View file

@ -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
}

View file

@ -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.

View file

@ -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
}

View 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)
}
}