Initial open source release
This commit is contained in:
commit
74992e893f
377 changed files with 118084 additions and 0 deletions
65
internal/domain/account.go
Normal file
65
internal/domain/account.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package domain
|
||||
|
||||
// PasswordSettings 是账号 2FA/SRP 配置。第一阶段默认 HasPassword=false。
|
||||
type PasswordSettings struct {
|
||||
HasRecovery bool
|
||||
HasSecureValues bool
|
||||
HasPassword bool
|
||||
Hint string
|
||||
EmailUnconfirmedPattern string
|
||||
LoginEmailPattern string
|
||||
SecureRandom []byte
|
||||
}
|
||||
|
||||
// ReactionNotifyFrom stores one account-level reaction notification scope.
|
||||
type ReactionNotifyFrom string
|
||||
|
||||
const (
|
||||
ReactionNotifyFromNone ReactionNotifyFrom = "none"
|
||||
ReactionNotifyFromContacts ReactionNotifyFrom = "contacts"
|
||||
ReactionNotifyFromAll ReactionNotifyFrom = "all"
|
||||
)
|
||||
|
||||
// ReactionsNotifySettings stores the account reaction notification settings
|
||||
// consumed by account.get/setReactionsNotifySettings.
|
||||
type ReactionsNotifySettings struct {
|
||||
MessagesFrom ReactionNotifyFrom
|
||||
StoriesFrom ReactionNotifyFrom
|
||||
PollVotesFrom ReactionNotifyFrom
|
||||
ShowPreviews bool
|
||||
}
|
||||
|
||||
// PaidReactionPrivacyKind stores the account default paid reaction privacy.
|
||||
type PaidReactionPrivacyKind string
|
||||
|
||||
const (
|
||||
PaidReactionPrivacyDefault PaidReactionPrivacyKind = "default"
|
||||
PaidReactionPrivacyAnonymous PaidReactionPrivacyKind = "anonymous"
|
||||
PaidReactionPrivacyPeer PaidReactionPrivacyKind = "peer"
|
||||
)
|
||||
|
||||
// PaidReactionPrivacy is the domain representation of tg.PaidReactionPrivacy.
|
||||
type PaidReactionPrivacy struct {
|
||||
Kind PaidReactionPrivacyKind
|
||||
Peer *Peer
|
||||
}
|
||||
|
||||
// AccountReactionSettings groups account-level reaction preferences.
|
||||
type AccountReactionSettings struct {
|
||||
Notify ReactionsNotifySettings
|
||||
DefaultReaction MessageReaction
|
||||
PaidPrivacy PaidReactionPrivacy
|
||||
}
|
||||
|
||||
func DefaultAccountReactionSettings() AccountReactionSettings {
|
||||
return AccountReactionSettings{
|
||||
Notify: ReactionsNotifySettings{
|
||||
MessagesFrom: ReactionNotifyFromContacts,
|
||||
StoriesFrom: ReactionNotifyFromContacts,
|
||||
PollVotesFrom: ReactionNotifyFromContacts,
|
||||
ShowPreviews: true,
|
||||
},
|
||||
DefaultReaction: MessageReaction{Type: MessageReactionEmoji, Emoticon: "👍"},
|
||||
PaidPrivacy: PaidReactionPrivacy{Kind: PaidReactionPrivacyDefault},
|
||||
}
|
||||
}
|
||||
15
internal/domain/authorization.go
Normal file
15
internal/domain/authorization.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package domain
|
||||
|
||||
// Authorization 是一条设备授权:auth_key 与 user 的绑定 + initConnection 设备信息。
|
||||
// auth_key 是协议产物、授权是业务产物,故独立于 store.AuthKeyData。
|
||||
type Authorization struct {
|
||||
AuthKeyID [8]byte // 协议原生 auth_key_id;store 边界按小端转 int64
|
||||
UserID int64
|
||||
Layer int
|
||||
DeviceModel string
|
||||
Platform string
|
||||
SystemVersion string
|
||||
APIID int
|
||||
AppVersion string
|
||||
IP string
|
||||
}
|
||||
1546
internal/domain/channel.go
Normal file
1546
internal/domain/channel.go
Normal file
File diff suppressed because it is too large
Load diff
68
internal/domain/channel_errors.go
Normal file
68
internal/domain/channel_errors.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrChannelInvalid = errors.New("channel invalid")
|
||||
ErrChannelPrivate = errors.New("channel private")
|
||||
ErrChannelTitleInvalid = errors.New("channel title invalid")
|
||||
ErrChannelUserBanned = errors.New("user banned in channel")
|
||||
ErrChannelWriteForbidden = errors.New("chat write forbidden")
|
||||
ErrChannelAdminRequired = errors.New("chat admin required")
|
||||
ErrChannelNotModified = errors.New("chat not modified")
|
||||
ErrChannelForumMissing = errors.New("channel forum missing")
|
||||
ErrLinkNotModified = errors.New("discussion link not modified")
|
||||
ErrChatDiscussionUnallowed = errors.New("chat discussion unallowed")
|
||||
ErrBroadcastIDInvalid = errors.New("broadcast id invalid")
|
||||
ErrMegagroupIDInvalid = errors.New("megagroup id invalid")
|
||||
ErrMegagroupPrehistoryHidden = errors.New("megagroup prehistory hidden")
|
||||
ErrChatPublicRequired = errors.New("chat public required")
|
||||
ErrChannelUserCreator = errors.New("channel user creator")
|
||||
ErrChannelRightForbidden = errors.New("channel right forbidden")
|
||||
ErrPersistentTimestamp = errors.New("persistent timestamp invalid")
|
||||
ErrInviteHashEmpty = errors.New("invite hash empty")
|
||||
ErrInviteHashInvalid = errors.New("invite hash invalid")
|
||||
ErrInviteHashExpired = errors.New("invite hash expired")
|
||||
ErrInvitePermanent = errors.New("chat invite permanent")
|
||||
ErrInviteRevokedMissing = errors.New("invite revoked missing")
|
||||
ErrInviteRequestSent = errors.New("invite request sent")
|
||||
ErrHideRequesterMissing = errors.New("hide requester missing")
|
||||
ErrUsersTooMuch = errors.New("users too much")
|
||||
ErrUserAlreadyParticipant = errors.New("user already participant")
|
||||
ErrUserKicked = errors.New("user kicked")
|
||||
)
|
||||
|
||||
// SlowModeWaitError carries the remaining wait seconds for a channel slow mode violation.
|
||||
type SlowModeWaitError struct {
|
||||
Seconds int
|
||||
}
|
||||
|
||||
func (e SlowModeWaitError) Error() string {
|
||||
if e.Seconds <= 0 {
|
||||
return "slowmode wait"
|
||||
}
|
||||
return fmt.Sprintf("slowmode wait %d seconds", e.Seconds)
|
||||
}
|
||||
|
||||
// NewSlowModeWaitError creates a bounded slow mode wait error.
|
||||
func NewSlowModeWaitError(seconds int) error {
|
||||
if seconds <= 0 {
|
||||
seconds = 1
|
||||
}
|
||||
return SlowModeWaitError{Seconds: seconds}
|
||||
}
|
||||
|
||||
// SlowModeWaitSeconds extracts the wait duration from err.
|
||||
func SlowModeWaitSeconds(err error) (int, bool) {
|
||||
var wait SlowModeWaitError
|
||||
if !errors.As(err, &wait) {
|
||||
return 0, false
|
||||
}
|
||||
if wait.Seconds <= 0 {
|
||||
return 1, true
|
||||
}
|
||||
return wait.Seconds, true
|
||||
}
|
||||
65
internal/domain/contact.go
Normal file
65
internal/domain/contact.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package domain
|
||||
|
||||
// Contact 是当前账号通讯录中的一个已注册联系人。
|
||||
//
|
||||
// FirstName/LastName/Phone/Note 是 owner 视角数据:同一个 target user 在不同
|
||||
// owner 的通讯录里可以有不同备注,这些字段不回写 users 全局资料。
|
||||
type Contact struct {
|
||||
User User
|
||||
FirstName string
|
||||
LastName string
|
||||
Phone string
|
||||
Note string
|
||||
NoteEntities []MessageEntity
|
||||
Mutual bool
|
||||
}
|
||||
|
||||
// ContactList 是通讯录查询结果。
|
||||
type ContactList struct {
|
||||
Contacts []Contact
|
||||
Hash int64
|
||||
}
|
||||
|
||||
// ContactInput 描述一次 owner 视角联系人写入。
|
||||
type ContactInput struct {
|
||||
ContactUserID int64
|
||||
ClientID int64
|
||||
Phone string
|
||||
FirstName string
|
||||
LastName string
|
||||
Note string
|
||||
NoteEntities []MessageEntity
|
||||
AddPhonePrivacyException bool
|
||||
}
|
||||
|
||||
// ImportedContact 是 contacts.importContacts 成功导入项。
|
||||
type ImportedContact struct {
|
||||
UserID int64
|
||||
ClientID int64
|
||||
}
|
||||
|
||||
// ImportContactsResult 是 contacts.importContacts 的业务结果。
|
||||
type ImportContactsResult struct {
|
||||
Imported []ImportedContact
|
||||
Contacts []Contact
|
||||
RetryContacts []int64
|
||||
}
|
||||
|
||||
// UserSearchResult 是 contacts.search 的业务结果。
|
||||
// MyResults 放当前账号通讯录内命中的用户;Results 放其他全局命中用户。
|
||||
// MyChannelResults 放当前账号已加入的公开 channel/supergroup;ChannelResults 放其他公开命中。
|
||||
type UserSearchResult struct {
|
||||
MyResults []User
|
||||
Results []User
|
||||
MyChannelResults []Channel
|
||||
ChannelResults []Channel
|
||||
}
|
||||
|
||||
// PeerSettings 是当前 owner 看某个 peer 的可操作状态。
|
||||
type PeerSettings struct {
|
||||
AddContact bool
|
||||
BlockContact bool
|
||||
ShareContact bool
|
||||
NeedContactsException bool
|
||||
HiddenPeerSettingsBar bool
|
||||
}
|
||||
155
internal/domain/dialog.go
Normal file
155
internal/domain/dialog.go
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
package domain
|
||||
|
||||
// PeerType 标识 dialog 所属 peer 类型。
|
||||
type PeerType string
|
||||
|
||||
const (
|
||||
PeerTypeUser PeerType = "user"
|
||||
PeerTypeChannel PeerType = "channel"
|
||||
)
|
||||
|
||||
const (
|
||||
// DialogMainFolderID 是 TDesktop 主会话列表 folder_id。
|
||||
DialogMainFolderID = 0
|
||||
// DialogArchiveFolderID 是 Telegram 约定的归档会话 folder_id。
|
||||
DialogArchiveFolderID = 1
|
||||
// DialogCustomFolderMinID 起才允许用户自定义 filter。
|
||||
DialogCustomFolderMinID = 2
|
||||
// MaxDialogFolders 限制单用户自定义 filter 数量,避免无界配置拖垮启动同步。
|
||||
MaxDialogFolders = 100
|
||||
// MaxDialogFolderPeers 限制单 filter 中 include/exclude/pinned peer 数。
|
||||
MaxDialogFolderPeers = 100
|
||||
// MaxDialogFolderTitleRunes 对齐 Telegram folder title 的短标题语义。
|
||||
MaxDialogFolderTitleRunes = 64
|
||||
// MaxDialogDraftsPerUser bounds messages.getAllDrafts / clearAllDrafts work.
|
||||
MaxDialogDraftsPerUser = 1000
|
||||
)
|
||||
|
||||
// Peer 是业务层 peer 值对象,不依赖 TL 类型。
|
||||
type Peer struct {
|
||||
Type PeerType
|
||||
ID int64
|
||||
}
|
||||
|
||||
// Dialog 是账号的一条会话摘要。
|
||||
type Dialog struct {
|
||||
Peer Peer
|
||||
ChannelLeft bool
|
||||
FolderID int
|
||||
TopMessage int
|
||||
TopMessageDate int
|
||||
ReadInboxMaxID int
|
||||
ReadOutboxMaxID int
|
||||
UnreadCount int
|
||||
UnreadMentions int
|
||||
UnreadReactions int
|
||||
Pinned bool
|
||||
PinnedOrder int
|
||||
UnreadMark bool
|
||||
ViewForumAsMessages bool
|
||||
PeerSettingsBarHidden bool
|
||||
Draft *DialogDraft
|
||||
}
|
||||
|
||||
// DialogDraftWebPage stores a draft link preview without depending on TL input media types.
|
||||
type DialogDraftWebPage struct {
|
||||
URL string
|
||||
ForceLargeMedia bool
|
||||
ForceSmallMedia bool
|
||||
Optional bool
|
||||
}
|
||||
|
||||
// DialogDraft is a cloud draft for one peer/topic, expressed only in domain types.
|
||||
type DialogDraft struct {
|
||||
Peer Peer
|
||||
TopMessageID int
|
||||
Date int
|
||||
NoWebpage bool
|
||||
InvertMedia bool
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
ReplyTo *MessageReply
|
||||
WebPage *DialogDraftWebPage
|
||||
Effect int64
|
||||
}
|
||||
|
||||
// Empty reports whether this draft should clear the cloud draft slot.
|
||||
func (d DialogDraft) Empty() bool {
|
||||
replyOnlyTopic := d.ReplyTo != nil && d.ReplyTo.MessageID == 0 && d.ReplyTo.TopMessageID > 0
|
||||
return !d.NoWebpage &&
|
||||
!d.InvertMedia &&
|
||||
d.Message == "" &&
|
||||
len(d.Entities) == 0 &&
|
||||
(d.ReplyTo == nil || replyOnlyTopic) &&
|
||||
d.WebPage == nil &&
|
||||
d.Effect == 0
|
||||
}
|
||||
|
||||
// DialogList 是 dialogs 查询结果。
|
||||
type DialogList struct {
|
||||
Dialogs []Dialog
|
||||
Messages []Message
|
||||
ChannelMessages []ChannelMessage
|
||||
Users []User
|
||||
Channels []Channel
|
||||
State UpdateState
|
||||
Hash int64
|
||||
Count int
|
||||
}
|
||||
|
||||
// DialogFilter 是会话列表查询条件。
|
||||
type DialogFilter struct {
|
||||
PinnedOnly bool
|
||||
ExcludePinned bool
|
||||
HasFolderID bool
|
||||
FolderID int
|
||||
Folder *DialogFolder
|
||||
OffsetDate int
|
||||
OffsetID int
|
||||
HasOffsetPeer bool
|
||||
OffsetPeer Peer
|
||||
Limit int
|
||||
Hash int64
|
||||
}
|
||||
|
||||
// DialogFolderPeer 是 folder/filter 规则中的 peer,保留 access_hash 供 RPC 层回写 InputPeer。
|
||||
type DialogFolderPeer struct {
|
||||
Peer Peer
|
||||
AccessHash int64
|
||||
}
|
||||
|
||||
// DialogFolder 是用户自定义会话分组规则。它只表达业务含义,不依赖 TL 生成类型。
|
||||
type DialogFolder struct {
|
||||
ID int
|
||||
Contacts bool
|
||||
NonContacts bool
|
||||
Groups bool
|
||||
Broadcasts bool
|
||||
Bots bool
|
||||
ExcludeMuted bool
|
||||
ExcludeRead bool
|
||||
ExcludeArchived bool
|
||||
TitleNoanimate bool
|
||||
Title string
|
||||
TitleEntities []MessageEntity
|
||||
Emoticon string
|
||||
HasEmoticon bool
|
||||
Color int
|
||||
HasColor bool
|
||||
PinnedPeers []DialogFolderPeer
|
||||
IncludePeers []DialogFolderPeer
|
||||
ExcludePeers []DialogFolderPeer
|
||||
IsChatlist bool
|
||||
}
|
||||
|
||||
// DialogFolderList 是 messages.getDialogFilters 的业务响应。
|
||||
type DialogFolderList struct {
|
||||
TagsEnabled bool
|
||||
Folders []DialogFolder
|
||||
}
|
||||
|
||||
// FolderPeerUpdate 描述 folders.editPeerFolders 的单个归档/还原变更。
|
||||
type FolderPeerUpdate struct {
|
||||
Peer Peer
|
||||
FolderID int
|
||||
}
|
||||
4
internal/domain/doc.go
Normal file
4
internal/domain/doc.go
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Package domain 存放业务实体与值对象(User、Peer、Dialog、Message、MessageID 等)。
|
||||
//
|
||||
// 铁律:本包禁止依赖 gotd/td/tg 等协议层类型;TL 类型只允许出现在 RPC/MTProto 边界。
|
||||
package domain
|
||||
30
internal/domain/help.go
Normal file
30
internal/domain/help.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package domain
|
||||
|
||||
// AppConfig 是客户端应用配置。
|
||||
type AppConfig struct {
|
||||
Client string
|
||||
Hash int
|
||||
JSON []byte
|
||||
}
|
||||
|
||||
// CountryCode 是一个国家的电话区号规则。
|
||||
type CountryCode struct {
|
||||
CountryCode string
|
||||
Prefixes []string
|
||||
Patterns []string
|
||||
}
|
||||
|
||||
// Country 是登录页国家/区号选择项。
|
||||
type Country struct {
|
||||
ISO2 string
|
||||
DefaultName string
|
||||
Name string
|
||||
Hidden bool
|
||||
CountryCodes []CountryCode
|
||||
}
|
||||
|
||||
// CountriesList 是 help.getCountriesList 查询结果。
|
||||
type CountriesList struct {
|
||||
Hash int
|
||||
Countries []Country
|
||||
}
|
||||
24
internal/domain/langpack.go
Normal file
24
internal/domain/langpack.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package domain
|
||||
|
||||
// LangPack 是一份客户端语言包的查询结果。
|
||||
type LangPack struct {
|
||||
LangPack string
|
||||
LangCode string
|
||||
FromVersion int
|
||||
Version int
|
||||
Strings []LangPackString
|
||||
}
|
||||
|
||||
// LangPackString 是语言包中的一个普通或复数形式字符串。
|
||||
type LangPackString struct {
|
||||
Key string
|
||||
Value string
|
||||
Pluralized bool
|
||||
ZeroValue string
|
||||
OneValue string
|
||||
TwoValue string
|
||||
FewValue string
|
||||
ManyValue string
|
||||
OtherValue string
|
||||
Deleted bool
|
||||
}
|
||||
331
internal/domain/media.go
Normal file
331
internal/domain/media.go
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
package domain
|
||||
|
||||
// 本文件定义媒体相关的业务值对象(文档、照片、贴纸集、可用 reaction、消息媒体)。
|
||||
// 这些类型完全不依赖 tg.*;rpc 层负责 domain↔tg 转换。
|
||||
//
|
||||
// 字段带 json tag 是为了 store 层可直接 json.Marshal 落 JSONB(消息 media 快照、
|
||||
// 文档/照片元数据)。它们是协议无关的纯数据,不是 tg 生成类型。
|
||||
|
||||
// MediaBackend 标识 blob 字节实际存放后端。第一阶段只有本地磁盘。
|
||||
type MediaBackend string
|
||||
|
||||
const (
|
||||
// MediaBackendLocalFS 表示 blob 字节存在本地磁盘(object_key 为相对路径)。
|
||||
MediaBackendLocalFS MediaBackend = "localfs"
|
||||
)
|
||||
|
||||
// FileBlob 是一个可下载的二进制对象的索引项:location_key → 后端/对象键/大小/mime。
|
||||
// 真正的字节由 blob backend 按 ObjectKey 读写;本结构只描述定位与元数据。
|
||||
type FileBlob struct {
|
||||
// LocationKey 是稳定的逻辑定位键,由 getFile 的 InputFileLocation 推导:
|
||||
// doc:<id> 文档主体
|
||||
// doc:<id>:<type> 文档缩略图(PhotoSize type)
|
||||
// photo:<id>:<type> 照片某尺寸
|
||||
LocationKey string `json:"location_key"`
|
||||
Backend MediaBackend `json:"backend"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
Size int64 `json:"size"`
|
||||
SHA256 []byte `json:"sha256,omitempty"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
}
|
||||
|
||||
// UploadPart 是 upload.saveFilePart/saveBigFilePart 累积的一个分片(落 PG,组装后清理)。
|
||||
type UploadPart struct {
|
||||
OwnerUserID int64
|
||||
FileID int64
|
||||
Part int
|
||||
TotalParts int // big file 已知总数;small file 为 0
|
||||
Big bool
|
||||
Bytes []byte
|
||||
}
|
||||
|
||||
// UploadedFileRef 引用一个客户端已通过 upload.saveFilePart(Big) 上传完毕的文件。
|
||||
// rpc 层从 tg.InputFile/InputFileBig 转换得到;files 服务据此组装 blob。
|
||||
type UploadedFileRef struct {
|
||||
OwnerUserID int64
|
||||
FileID int64
|
||||
Parts int
|
||||
Name string
|
||||
Big bool
|
||||
MD5 string // small file 客户端 md5_checksum(hex),可校验;big file 为空
|
||||
}
|
||||
|
||||
// DocumentSpec 描述从上传文件创建 Document 的元数据(来自 InputMediaUploadedDocument)。
|
||||
type DocumentSpec struct {
|
||||
MimeType string
|
||||
Attributes []DocumentAttribute
|
||||
Thumb *UploadedFileRef // 可选缩略图上传,生成 doc:<id>:m
|
||||
ForceFile bool
|
||||
}
|
||||
|
||||
// FileDownloadRequest 是 upload.getFile 解析后的下载请求;
|
||||
// LocationKey 由 rpc 层从 tg.InputFileLocation 推导(doc:<id> / photo:<id>:<type> 等)。
|
||||
type FileDownloadRequest struct {
|
||||
LocationKey string
|
||||
Offset int64
|
||||
Limit int
|
||||
}
|
||||
|
||||
// FileChunk 是 upload.getFile 返回的一段内容。
|
||||
type FileChunk struct {
|
||||
Bytes []byte
|
||||
MimeType string
|
||||
Total int64
|
||||
}
|
||||
|
||||
// PhotoSizeKind 标识 PhotoSize 的 TL 变体。
|
||||
type PhotoSizeKind string
|
||||
|
||||
const (
|
||||
// PhotoSizeKindDefault → photoSize(可下载,type/w/h/size)。
|
||||
PhotoSizeKindDefault PhotoSizeKind = "size"
|
||||
// PhotoSizeKindStripped → photoStrippedSize(内联字节,秒开模糊图)。
|
||||
PhotoSizeKindStripped PhotoSizeKind = "stripped"
|
||||
// PhotoSizeKindCached → photoCachedSize(内联字节 + w/h)。
|
||||
PhotoSizeKindCached PhotoSizeKind = "cached"
|
||||
// PhotoSizeKindPath → photoPathSize(内联 svg path 字节,矢量占位)。
|
||||
PhotoSizeKindPath PhotoSizeKind = "path"
|
||||
// PhotoSizeKindProgressive → photoSizeProgressive(渐进式 jpeg 多段大小)。
|
||||
PhotoSizeKindProgressive PhotoSizeKind = "progressive"
|
||||
)
|
||||
|
||||
// PhotoSize 描述照片/缩略图的一种渲染尺寸。
|
||||
type PhotoSize struct {
|
||||
Kind PhotoSizeKind `json:"kind"`
|
||||
Type string `json:"type"`
|
||||
W int `json:"w,omitempty"`
|
||||
H int `json:"h,omitempty"`
|
||||
Size int `json:"size,omitempty"`
|
||||
Bytes []byte `json:"bytes,omitempty"` // stripped/cached/path 内联内容
|
||||
Sizes []int `json:"sizes,omitempty"` // progressive
|
||||
}
|
||||
|
||||
// Downloadable 表示该尺寸需要客户端通过 upload.getFile 拉取(而非内联字节)。
|
||||
func (s PhotoSize) Downloadable() bool {
|
||||
return s.Kind == PhotoSizeKindDefault || s.Kind == PhotoSizeKindProgressive
|
||||
}
|
||||
|
||||
// DocumentAttributeKind 标识 TL DocumentAttribute 变体。
|
||||
type DocumentAttributeKind string
|
||||
|
||||
const (
|
||||
DocAttrImageSize DocumentAttributeKind = "image_size"
|
||||
DocAttrAnimated DocumentAttributeKind = "animated"
|
||||
DocAttrSticker DocumentAttributeKind = "sticker"
|
||||
DocAttrVideo DocumentAttributeKind = "video"
|
||||
DocAttrAudio DocumentAttributeKind = "audio"
|
||||
DocAttrFilename DocumentAttributeKind = "filename"
|
||||
DocAttrCustomEmoji DocumentAttributeKind = "custom_emoji"
|
||||
)
|
||||
|
||||
// DocumentAttribute 是主路径用到的 TL DocumentAttribute 变体的并集。
|
||||
type DocumentAttribute struct {
|
||||
Kind DocumentAttributeKind `json:"kind"`
|
||||
|
||||
// image_size / video / sticker box
|
||||
W int `json:"w,omitempty"`
|
||||
H int `json:"h,omitempty"`
|
||||
|
||||
// sticker / custom_emoji
|
||||
Alt string `json:"alt,omitempty"`
|
||||
Mask bool `json:"mask,omitempty"`
|
||||
StickerSetID int64 `json:"sticker_set_id,omitempty"`
|
||||
StickerSetAccessHash int64 `json:"sticker_set_access_hash,omitempty"`
|
||||
Free bool `json:"free,omitempty"` // custom_emoji
|
||||
TextColor bool `json:"text_color,omitempty"` // custom_emoji
|
||||
|
||||
// video
|
||||
Duration float64 `json:"duration,omitempty"`
|
||||
RoundMessage bool `json:"round_message,omitempty"`
|
||||
SupportsStreaming bool `json:"supports_streaming,omitempty"`
|
||||
|
||||
// audio
|
||||
AudioDuration int `json:"audio_duration,omitempty"`
|
||||
Voice bool `json:"voice,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Performer string `json:"performer,omitempty"`
|
||||
Waveform []byte `json:"waveform,omitempty"`
|
||||
|
||||
// filename
|
||||
FileName string `json:"file_name,omitempty"`
|
||||
}
|
||||
|
||||
// Document 是已存储的 Telegram 文档(贴纸、gif、文件、视频、音频、自定义 emoji……)。
|
||||
type Document struct {
|
||||
ID int64 `json:"id"`
|
||||
AccessHash int64 `json:"access_hash"`
|
||||
FileReference []byte `json:"file_reference,omitempty"`
|
||||
Date int `json:"date,omitempty"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
DCID int `json:"dc_id,omitempty"`
|
||||
Attributes []DocumentAttribute `json:"attributes,omitempty"`
|
||||
Thumbs []PhotoSize `json:"thumbs,omitempty"`
|
||||
}
|
||||
|
||||
// StickerSetRef 返回该文档归属的贴纸集引用(若有 sticker/custom_emoji 属性)。
|
||||
func (d Document) StickerSetRef() (id, accessHash int64, ok bool) {
|
||||
for _, attr := range d.Attributes {
|
||||
if attr.Kind == DocAttrSticker || attr.Kind == DocAttrCustomEmoji {
|
||||
if attr.StickerSetID != 0 {
|
||||
return attr.StickerSetID, attr.StickerSetAccessHash, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// Photo 是已存储的 Telegram 照片(头像或图片消息)。
|
||||
type Photo struct {
|
||||
ID int64 `json:"id"`
|
||||
AccessHash int64 `json:"access_hash"`
|
||||
FileReference []byte `json:"file_reference,omitempty"`
|
||||
Date int `json:"date,omitempty"`
|
||||
DCID int `json:"dc_id,omitempty"`
|
||||
HasStickers bool `json:"has_stickers,omitempty"`
|
||||
Sizes []PhotoSize `json:"sizes,omitempty"`
|
||||
}
|
||||
|
||||
// MessageMediaKind 枚举消息可挂载的媒体载荷。
|
||||
type MessageMediaKind string
|
||||
|
||||
const (
|
||||
MessageMediaKindNone MessageMediaKind = ""
|
||||
MessageMediaKindPhoto MessageMediaKind = "photo"
|
||||
MessageMediaKindDocument MessageMediaKind = "document"
|
||||
)
|
||||
|
||||
// MessageMedia 是一条消息媒体载荷的业务表示(落库为消息行上的 JSONB 快照)。
|
||||
type MessageMedia struct {
|
||||
Kind MessageMediaKind `json:"kind"`
|
||||
Photo *Photo `json:"photo,omitempty"`
|
||||
Document *Document `json:"document,omitempty"`
|
||||
Spoiler bool `json:"spoiler,omitempty"`
|
||||
TTLSeconds int `json:"ttl_seconds,omitempty"`
|
||||
Nopremium bool `json:"nopremium,omitempty"`
|
||||
Voice bool `json:"voice,omitempty"`
|
||||
Round bool `json:"round,omitempty"`
|
||||
Video bool `json:"video,omitempty"`
|
||||
}
|
||||
|
||||
// IsZero 表示无媒体(用于落库时跳过空快照、转换时回退 MessageMediaEmpty)。
|
||||
func (m *MessageMedia) IsZero() bool {
|
||||
return m == nil || m.Kind == MessageMediaKindNone
|
||||
}
|
||||
|
||||
// StickerPack 是 emoji→文档 id 的映射条目(messages.stickerSet.packs)。
|
||||
type StickerPack struct {
|
||||
Emoticon string `json:"emoticon"`
|
||||
DocumentIDs []int64 `json:"document_ids"`
|
||||
}
|
||||
|
||||
// StickerSetKind 区分贴纸集用途(影响 getAllStickers / getEmojiStickers 归类)。
|
||||
type StickerSetKind string
|
||||
|
||||
const (
|
||||
StickerSetKindStickers StickerSetKind = "stickers"
|
||||
StickerSetKindEmoji StickerSetKind = "emoji"
|
||||
StickerSetKindMasks StickerSetKind = "masks"
|
||||
// StickerSetKindSystem 是 TDesktop 通过 InputStickerSetDice/AnimatedEmoji 等系统集请求的内置集。
|
||||
StickerSetKindSystem StickerSetKind = "system"
|
||||
)
|
||||
|
||||
// StickerSet 是贴纸/自定义 emoji 集的元数据 + 有序文档 id。
|
||||
type StickerSet struct {
|
||||
ID int64 `json:"id"`
|
||||
AccessHash int64 `json:"access_hash"`
|
||||
ShortName string `json:"short_name"`
|
||||
Title string `json:"title"`
|
||||
Count int `json:"count"`
|
||||
Hash int `json:"hash"`
|
||||
Kind StickerSetKind `json:"set_kind"`
|
||||
Official bool `json:"official,omitempty"`
|
||||
Animated bool `json:"animated,omitempty"`
|
||||
Videos bool `json:"videos,omitempty"`
|
||||
Emojis bool `json:"emojis,omitempty"`
|
||||
Masks bool `json:"masks,omitempty"`
|
||||
Installed bool `json:"installed,omitempty"`
|
||||
Archived bool `json:"archived,omitempty"`
|
||||
InstalledDate int `json:"installed_date,omitempty"`
|
||||
ThumbDocumentID int64 `json:"thumb_document_id,omitempty"`
|
||||
Thumbs []PhotoSize `json:"thumbs,omitempty"`
|
||||
ThumbDCID int `json:"thumb_dc_id,omitempty"`
|
||||
ThumbVersion int `json:"thumb_version,omitempty"`
|
||||
DocumentIDs []int64 `json:"document_ids,omitempty"`
|
||||
Packs []StickerPack `json:"packs,omitempty"`
|
||||
SortOrder int `json:"sort_order,omitempty"`
|
||||
// SystemKey 是 TDesktop 系统集的稳定标识(如 "animated_emoji"、"dice:🎲"),用于 InputStickerSet* 路由。
|
||||
SystemKey string `json:"system_key,omitempty"`
|
||||
}
|
||||
|
||||
// ProfilePhotoRef 是渲染头像所需的最小信息(当前 profile photo)。
|
||||
type ProfilePhotoRef struct {
|
||||
PhotoID int64
|
||||
DCID int
|
||||
Stripped []byte // photoStrippedSize 内联缩略图,可空
|
||||
}
|
||||
|
||||
// StrippedFromSizes 从照片尺寸列表里取出 stripped 缩略图字节(用于 UserProfilePhoto/ChatPhoto 占位)。
|
||||
func StrippedFromSizes(sizes []PhotoSize) []byte {
|
||||
for _, s := range sizes {
|
||||
if s.Kind == PhotoSizeKindStripped {
|
||||
return s.Bytes
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StickerSetRefKind 标识 InputStickerSet 的解析方式。
|
||||
type StickerSetRefKind string
|
||||
|
||||
const (
|
||||
StickerSetRefByID StickerSetRefKind = "id"
|
||||
StickerSetRefByShortName StickerSetRefKind = "short_name"
|
||||
StickerSetRefBySystem StickerSetRefKind = "system"
|
||||
)
|
||||
|
||||
// StickerSetRef 是 rpc 层从 tg.InputStickerSet 转换得到的贴纸集引用。
|
||||
type StickerSetRef struct {
|
||||
Kind StickerSetRefKind
|
||||
ID int64
|
||||
AccessHash int64
|
||||
ShortName string
|
||||
SystemKey string
|
||||
}
|
||||
|
||||
// AvailableReaction 描述 messages.getAvailableReactions 的一项(真实资源由文档 id 引用)。
|
||||
type AvailableReaction struct {
|
||||
Reaction string `json:"reaction"`
|
||||
Title string `json:"title"`
|
||||
Inactive bool `json:"inactive,omitempty"`
|
||||
Premium bool `json:"premium,omitempty"`
|
||||
StaticIconID int64 `json:"static_icon_id,omitempty"`
|
||||
AppearAnimationID int64 `json:"appear_animation_id,omitempty"`
|
||||
SelectAnimationID int64 `json:"select_animation_id,omitempty"`
|
||||
ActivateAnimationID int64 `json:"activate_animation_id,omitempty"`
|
||||
EffectAnimationID int64 `json:"effect_animation_id,omitempty"`
|
||||
AroundAnimationID int64 `json:"around_animation_id,omitempty"`
|
||||
CenterIconID int64 `json:"center_icon_id,omitempty"`
|
||||
Order int `json:"order,omitempty"`
|
||||
}
|
||||
|
||||
// DocumentIDs 收集该 reaction 引用的全部文档 id(去零去重,便于批量加载)。
|
||||
func (r AvailableReaction) DocumentIDs() []int64 {
|
||||
raw := []int64{
|
||||
r.StaticIconID, r.AppearAnimationID, r.SelectAnimationID,
|
||||
r.ActivateAnimationID, r.EffectAnimationID, r.AroundAnimationID, r.CenterIconID,
|
||||
}
|
||||
out := make([]int64, 0, len(raw))
|
||||
seen := make(map[int64]struct{}, len(raw))
|
||||
for _, id := range raw {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
16
internal/domain/media_errors.go
Normal file
16
internal/domain/media_errors.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
// 媒体 / 文件相关业务错误。rpc 层据此映射为对应 rpc_error(见 internal/rpc/errors.go)。
|
||||
var (
|
||||
ErrFilePartInvalid = errors.New("file part invalid")
|
||||
ErrFilePartsInvalid = errors.New("file parts invalid")
|
||||
ErrFilePartTooBig = errors.New("file part too big")
|
||||
ErrFileReference = errors.New("file reference invalid")
|
||||
ErrMediaInvalid = errors.New("media invalid")
|
||||
ErrMediaEmpty = errors.New("media empty")
|
||||
ErrPhotoInvalid = errors.New("photo invalid")
|
||||
ErrStickersetInvalid = errors.New("stickerset invalid")
|
||||
ErrDocumentInvalid = errors.New("document invalid")
|
||||
)
|
||||
352
internal/domain/message.go
Normal file
352
internal/domain/message.go
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
package domain
|
||||
|
||||
// MessageEntityType 标识消息实体类型。
|
||||
type MessageEntityType string
|
||||
|
||||
const (
|
||||
MessageEntityBold MessageEntityType = "bold"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxMessageTextLength matches the first-stage text message limit exposed to Telegram clients.
|
||||
MaxMessageTextLength = 4096
|
||||
// MaxMessageReplyQuoteLength matches TDesktop's quote_length_max app config default.
|
||||
MaxMessageReplyQuoteLength = 1024
|
||||
// MaxMessageReplyQuoteOffset bounds quote_offset, which is an offset inside message text, not a message id.
|
||||
MaxMessageReplyQuoteOffset = MaxMessageTextLength
|
||||
// MaxMessageEntityCount limits styled text entity vectors in message text and quotes.
|
||||
MaxMessageEntityCount = 256
|
||||
// MaxMessageBoxID 是 TL int / PostgreSQL int4 可安全表达的最大 message id。
|
||||
MaxMessageBoxID = 1<<31 - 1
|
||||
// MaxDeleteMessageIDs 限制单次 deleteMessages/updateDeleteMessages 的 owner 视角 id 数量。
|
||||
// 大批量历史清理走 deleteHistory 分批推进,避免单个 RPC 构造超大数组或 durable payload。
|
||||
MaxDeleteMessageIDs = 1000
|
||||
// MaxGetMessageIDs 限制 getMessages / channels.getMessages 精确 ID 批量。
|
||||
MaxGetMessageIDs = 100
|
||||
// MaxDeleteHistoryBatch 限制单次 deleteHistory 实际清理的 message box 数量。
|
||||
// affectedHistory.Offset > 0 时客户端可继续调用,服务端不一次性 RETURNING 全历史。
|
||||
MaxDeleteHistoryBatch = 1000
|
||||
// MaxForwardMessageIDs 限制单次 forwardMessages 的 owner 视角 id 数量。
|
||||
MaxForwardMessageIDs = 100
|
||||
// MaxMessageHistoryAddOffset 限制 history/search 的 add_offset 绝对值。
|
||||
// TDesktop 正常只使用小窗口偏移;服务端必须拒绝把客户端传入的超大值变成 SQL OFFSET 或 slice capacity。
|
||||
MaxMessageHistoryAddOffset = 100
|
||||
)
|
||||
|
||||
// ClampMessageHistoryAddOffset bounds Telegram history/search add_offset to a small local window.
|
||||
func ClampMessageHistoryAddOffset(v int) int {
|
||||
if v > MaxMessageHistoryAddOffset {
|
||||
return MaxMessageHistoryAddOffset
|
||||
}
|
||||
if v < -MaxMessageHistoryAddOffset {
|
||||
return -MaxMessageHistoryAddOffset
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ValidateMessageReplyBounds validates reply fields that are independent of peer visibility.
|
||||
func ValidateMessageReplyBounds(reply *MessageReply) error {
|
||||
if reply == nil {
|
||||
return nil
|
||||
}
|
||||
if reply.MessageID < 0 || reply.MessageID > MaxMessageBoxID {
|
||||
return ErrReplyMessageIDInvalid
|
||||
}
|
||||
if reply.TopMessageID < 0 || reply.TopMessageID > MaxMessageBoxID {
|
||||
return ErrReplyMessageIDInvalid
|
||||
}
|
||||
if reply.MessageID == 0 && reply.TopMessageID == 0 {
|
||||
return ErrReplyMessageIDInvalid
|
||||
}
|
||||
if reply.QuoteOffset < 0 || reply.QuoteOffset > MaxMessageReplyQuoteOffset {
|
||||
return ErrReplyMessageIDInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MessageEntity 是业务层消息实体,不依赖 TL 类型。
|
||||
type MessageEntity struct {
|
||||
Type MessageEntityType
|
||||
Offset int
|
||||
Length int
|
||||
}
|
||||
|
||||
// Message 是账号视角下的一条私聊消息。
|
||||
type Message struct {
|
||||
ID int // 当前 owner 视角下的 message box id,暴露给 Telegram 客户端。
|
||||
UID int64 // 共享私聊消息主体 id,不暴露给客户端。
|
||||
RandomID int64
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
From Peer
|
||||
Date int
|
||||
EditDate int
|
||||
Out bool
|
||||
Silent bool
|
||||
NoForwards bool
|
||||
Body string
|
||||
Entities []MessageEntity
|
||||
ReplyTo *MessageReply
|
||||
Forward *MessageForward
|
||||
Reactions *ChannelMessageReactions
|
||||
Pts int
|
||||
Media *MessageMedia
|
||||
}
|
||||
|
||||
// MessageReply describes a message reply/thread header without depending on TL types.
|
||||
type MessageReply struct {
|
||||
MessageID int
|
||||
Peer Peer
|
||||
TopMessageID int
|
||||
ForumTopic bool
|
||||
QuoteText string
|
||||
QuoteEntities []MessageEntity
|
||||
QuoteOffset int
|
||||
}
|
||||
|
||||
// MessageForward 描述一条转发消息的原始作者信息。
|
||||
type MessageForward struct {
|
||||
From Peer
|
||||
FromName string
|
||||
Date int
|
||||
ChannelPost int
|
||||
SavedFrom Peer
|
||||
SavedFromMsgID int
|
||||
}
|
||||
|
||||
// MessageList 是账号视角下的消息查询结果。
|
||||
type MessageList struct {
|
||||
Messages []Message
|
||||
Users []User
|
||||
Count int
|
||||
Hash int64
|
||||
}
|
||||
|
||||
// MessageFilter 描述历史/搜索查询条件。
|
||||
type MessageFilter struct {
|
||||
HasPeer bool
|
||||
Peer Peer
|
||||
Query string
|
||||
OffsetID int
|
||||
OffsetDate int
|
||||
AddOffset int
|
||||
Limit int
|
||||
MaxID int
|
||||
MinID int
|
||||
Hash int64
|
||||
NeedTotalCount bool
|
||||
}
|
||||
|
||||
// SendPrivateTextRequest 是私聊文本/媒体发送命令。
|
||||
type SendPrivateTextRequest struct {
|
||||
SenderUserID int64
|
||||
RecipientUserID int64
|
||||
RandomID int64
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
Media *MessageMedia
|
||||
Silent bool
|
||||
NoForwards bool
|
||||
ReplyTo *MessageReply
|
||||
Forward *MessageForward
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
// SendPrivateTextResult 描述一次私聊文本发送的双端结果。
|
||||
type SendPrivateTextResult struct {
|
||||
SenderMessage Message
|
||||
RecipientMessage Message
|
||||
SenderEvent UpdateEvent
|
||||
RecipientEvent UpdateEvent
|
||||
Duplicate bool
|
||||
}
|
||||
|
||||
// SetPrivateMessageReactionsRequest replaces the current user's reactions for one private message.
|
||||
type SetPrivateMessageReactionsRequest struct {
|
||||
UserID int64
|
||||
Peer Peer
|
||||
MessageID int
|
||||
Reactions []MessageReaction
|
||||
Big bool
|
||||
AddToRecent bool
|
||||
Date int
|
||||
}
|
||||
|
||||
// PrivateMessageReactionsRequest fetches reaction summaries for exact private message ids.
|
||||
type PrivateMessageReactionsRequest struct {
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
IDs []int
|
||||
}
|
||||
|
||||
// PrivateMessageReactionsResult describes private reaction updates in owner-visible boxes.
|
||||
type PrivateMessageReactionsResult struct {
|
||||
Messages []Message
|
||||
Reactions ChannelMessageReactions
|
||||
}
|
||||
|
||||
// ForwardPrivateMessagesRequest 是私聊文本消息转发命令。
|
||||
type ForwardPrivateMessagesRequest struct {
|
||||
OwnerUserID int64
|
||||
FromPeer Peer
|
||||
ToUserID int64
|
||||
MessageIDs []int
|
||||
RandomIDs []int64
|
||||
Silent bool
|
||||
NoForwards bool
|
||||
DropAuthor bool
|
||||
ReplyTo *MessageReply
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
// ForwardPrivateMessagesResult 描述一次私聊转发的 owner 维度结果。
|
||||
type ForwardPrivateMessagesResult struct {
|
||||
OwnerUserID int64
|
||||
SenderMessages []Message
|
||||
RecipientMessages []Message
|
||||
SenderEvents []UpdateEvent
|
||||
RecipientEvents []UpdateEvent
|
||||
Duplicates []bool
|
||||
}
|
||||
|
||||
// ReadHistoryRequest 是账号视角的 messages.readHistory 命令。
|
||||
type ReadHistoryRequest struct {
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
MaxID int
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
// ReadHistoryResult 描述一次会话已读操作的业务结果。
|
||||
type ReadHistoryResult struct {
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
MaxID int
|
||||
StillUnreadCount int
|
||||
Changed bool
|
||||
InboxEvent UpdateEvent
|
||||
OutboxChanged bool
|
||||
OutboxUserID int64
|
||||
OutboxEvent UpdateEvent
|
||||
}
|
||||
|
||||
// ReadMessageContentsRequest marks media/mention contents as read for exact owner-visible messages.
|
||||
type ReadMessageContentsRequest struct {
|
||||
OwnerUserID int64
|
||||
IDs []int
|
||||
}
|
||||
|
||||
// ReadMessageContentsResult contains owner-visible message IDs that existed and can be synced.
|
||||
type ReadMessageContentsResult struct {
|
||||
OwnerUserID int64
|
||||
MessageIDs []int
|
||||
}
|
||||
|
||||
// OutboxReadDateRequest 是 messages.getOutboxReadDate 查询。
|
||||
type OutboxReadDateRequest struct {
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
ID int
|
||||
}
|
||||
|
||||
// EditMessageRequest 是账号视角下编辑一条已发送私聊文本消息的命令。
|
||||
type EditMessageRequest struct {
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
ID int
|
||||
Message string
|
||||
Entities []MessageEntity
|
||||
EditDate int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
// EditedMessageForUser 描述一次编辑对某个 owner 视角造成的影响。
|
||||
type EditedMessageForUser struct {
|
||||
UserID int64
|
||||
Message Message
|
||||
Event UpdateEvent
|
||||
}
|
||||
|
||||
// EditMessageResult 描述消息编辑后的 owner 维度结果。
|
||||
type EditMessageResult struct {
|
||||
OwnerUserID int64
|
||||
Edited []EditedMessageForUser
|
||||
}
|
||||
|
||||
// Self 返回当前请求账号的编辑结果。
|
||||
func (r EditMessageResult) Self() EditedMessageForUser {
|
||||
for _, item := range r.Edited {
|
||||
if item.UserID == r.OwnerUserID {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return EditedMessageForUser{UserID: r.OwnerUserID}
|
||||
}
|
||||
|
||||
// Changed 表示本次编辑是否实际影响了任何 owner 视角。
|
||||
func (r EditMessageResult) Changed() bool {
|
||||
return len(r.Edited) > 0
|
||||
}
|
||||
|
||||
// DeleteMessagesRequest 是账号视角下按消息 ID 删除消息的命令。
|
||||
type DeleteMessagesRequest struct {
|
||||
OwnerUserID int64
|
||||
IDs []int
|
||||
Revoke bool
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
// DeleteHistoryRequest 是账号视角下清空某个 peer 历史的命令。
|
||||
type DeleteHistoryRequest struct {
|
||||
OwnerUserID int64
|
||||
Peer Peer
|
||||
MaxID int
|
||||
JustClear bool
|
||||
Revoke bool
|
||||
Date int
|
||||
OriginAuthKeyID [8]byte
|
||||
OriginSessionID int64
|
||||
}
|
||||
|
||||
// DeletedMessagesForUser 描述一次删除对某个 owner 视角造成的影响。
|
||||
type DeletedMessagesForUser struct {
|
||||
UserID int64
|
||||
MessageIDs []int
|
||||
Event UpdateEvent
|
||||
}
|
||||
|
||||
// DeleteMessagesResult 描述消息删除后的 owner 维度结果。
|
||||
type DeleteMessagesResult struct {
|
||||
OwnerUserID int64
|
||||
Deleted []DeletedMessagesForUser
|
||||
Offset int
|
||||
}
|
||||
|
||||
// Self 返回当前请求账号的删除结果。
|
||||
func (r DeleteMessagesResult) Self() DeletedMessagesForUser {
|
||||
for _, item := range r.Deleted {
|
||||
if item.UserID == r.OwnerUserID {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return DeletedMessagesForUser{UserID: r.OwnerUserID}
|
||||
}
|
||||
|
||||
// Changed 表示本次删除是否实际影响了任何 owner 视角。
|
||||
func (r DeleteMessagesResult) Changed() bool {
|
||||
for _, item := range r.Deleted {
|
||||
if len(item.MessageIDs) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
12
internal/domain/message_errors.go
Normal file
12
internal/domain/message_errors.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrMessageIDInvalid = errors.New("message id invalid")
|
||||
ErrMessageAuthorRequired = errors.New("message author required")
|
||||
ErrMessageNotModified = errors.New("message not modified")
|
||||
ErrMessageNotReadYet = errors.New("message not read yet")
|
||||
ErrReplyMessageIDInvalid = errors.New("reply message id invalid")
|
||||
ErrChatForwardsRestricted = errors.New("chat forwards restricted")
|
||||
)
|
||||
27
internal/domain/message_test.go
Normal file
27
internal/domain/message_test.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateMessageReplyBoundsRejectsQuoteOffsetAsTextOffset(t *testing.T) {
|
||||
reply := &MessageReply{
|
||||
MessageID: 1,
|
||||
QuoteText: "hello",
|
||||
QuoteOffset: MaxMessageReplyQuoteOffset + 1,
|
||||
}
|
||||
if err := ValidateMessageReplyBounds(reply); !errors.Is(err, ErrReplyMessageIDInvalid) {
|
||||
t.Fatalf("ValidateMessageReplyBounds err = %v, want ErrReplyMessageIDInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMessageReplyBoundsAllowsForumTopicOnlyHeader(t *testing.T) {
|
||||
reply := &MessageReply{
|
||||
TopMessageID: 10,
|
||||
ForumTopic: true,
|
||||
}
|
||||
if err := ValidateMessageReplyBounds(reply); err != nil {
|
||||
t.Fatalf("ValidateMessageReplyBounds err = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
19
internal/domain/system.go
Normal file
19
internal/domain/system.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package domain
|
||||
|
||||
const (
|
||||
// OfficialSystemUserID 是 Telegram 兼容客户端识别的官方系统账号。
|
||||
OfficialSystemUserID int64 = 777000
|
||||
)
|
||||
|
||||
// OfficialSystemUser 返回第一阶段内置的官方系统账号。
|
||||
func OfficialSystemUser() User {
|
||||
return User{
|
||||
ID: OfficialSystemUserID,
|
||||
AccessHash: 6599886787491911851,
|
||||
Phone: "42777",
|
||||
FirstName: "Telegram",
|
||||
Username: "telegram",
|
||||
Verified: true,
|
||||
Support: true,
|
||||
}
|
||||
}
|
||||
11
internal/domain/temp_auth_key.go
Normal file
11
internal/domain/temp_auth_key.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package domain
|
||||
|
||||
// TempAuthKeyBinding 是 auth.bindTempAuthKey 的持久化记录。
|
||||
type TempAuthKeyBinding struct {
|
||||
TempAuthKeyID [8]byte
|
||||
PermAuthKeyID int64
|
||||
Nonce int64
|
||||
TempSessionID int64
|
||||
ExpiresAt int
|
||||
EncryptedMessage []byte
|
||||
}
|
||||
10
internal/domain/update.go
Normal file
10
internal/domain/update.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package domain
|
||||
|
||||
// UpdateState 是账号的 update 状态(pts/qts/seq/date)。
|
||||
// 第一阶段空账号为零值;真实状态机属第二阶段。
|
||||
type UpdateState struct {
|
||||
Pts int
|
||||
Qts int
|
||||
Date int
|
||||
Seq int
|
||||
}
|
||||
58
internal/domain/update_event.go
Normal file
58
internal/domain/update_event.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
package domain
|
||||
|
||||
// UpdateEventType 标识 update 队列事件类型。
|
||||
type UpdateEventType string
|
||||
|
||||
const (
|
||||
UpdateEventNewMessage UpdateEventType = "new_message"
|
||||
UpdateEventReadHistoryInbox UpdateEventType = "read_history_inbox"
|
||||
UpdateEventReadHistoryOutbox UpdateEventType = "read_history_outbox"
|
||||
UpdateEventEditMessage UpdateEventType = "edit_message"
|
||||
UpdateEventMessageReactions UpdateEventType = "message_reactions"
|
||||
UpdateEventContactsReset UpdateEventType = "contacts_reset"
|
||||
UpdateEventDialogPinned UpdateEventType = "dialog_pinned"
|
||||
UpdateEventPinnedDialogs UpdateEventType = "pinned_dialogs"
|
||||
UpdateEventDialogUnreadMark UpdateEventType = "dialog_unread_mark"
|
||||
UpdateEventPeerSettings UpdateEventType = "peer_settings"
|
||||
UpdateEventDeleteMessages UpdateEventType = "delete_messages"
|
||||
UpdateEventDialogFilter UpdateEventType = "dialog_filter"
|
||||
UpdateEventDialogFilterOrder UpdateEventType = "dialog_filter_order"
|
||||
UpdateEventDialogFilters UpdateEventType = "dialog_filters"
|
||||
UpdateEventFolderPeers UpdateEventType = "folder_peers"
|
||||
UpdateEventChannelAvailable UpdateEventType = "channel_available_messages"
|
||||
UpdateEventChannelViewForum UpdateEventType = "channel_view_forum_as_messages"
|
||||
UpdateEventNoop UpdateEventType = "noop"
|
||||
)
|
||||
|
||||
// UpdateEvent 是账号视角的增量事件,按 user_id + pts 顺序持久化。
|
||||
type UpdateEvent struct {
|
||||
UserID int64
|
||||
Type UpdateEventType
|
||||
Pts int
|
||||
PtsCount int
|
||||
Date int
|
||||
Message Message
|
||||
Peer Peer
|
||||
Peers []Peer
|
||||
Bool bool
|
||||
Settings PeerSettings
|
||||
MessageIDs []int
|
||||
MaxID int
|
||||
StillUnreadCount int
|
||||
Users []User
|
||||
Channels []Channel
|
||||
FilterID int
|
||||
DialogFilter *DialogFolder
|
||||
FilterOrder []int
|
||||
FolderPeers []FolderPeerUpdate
|
||||
TagsEnabled bool
|
||||
}
|
||||
|
||||
// UpdateDifference 是 updates.getDifference 的业务层结果。
|
||||
type UpdateDifference struct {
|
||||
State UpdateState
|
||||
Events []UpdateEvent
|
||||
// Partial 为 true 表示连续事件被 limit 截断、后面还有(映射 updates.differenceSlice,
|
||||
// 客户端据 State 继续翻页);false 表示已到当前连续末尾(updates.difference)。
|
||||
Partial bool
|
||||
}
|
||||
63
internal/domain/user.go
Normal file
63
internal/domain/user.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package domain
|
||||
|
||||
// UserIDSequenceBase 是普通用户 ID 的起始值。
|
||||
//
|
||||
// 取 2026-06-01 00:00:00 Asia/Shanghai 的 Unix 秒级时间戳。
|
||||
// 777000 等兼容系统账号低于该区间,业务注册用户从这里开始递增。
|
||||
const UserIDSequenceBase int64 = 1780243200
|
||||
|
||||
// User 是一个账号。第一阶段仅保留登录链路必须字段;
|
||||
// access_hash 为任何 InputUser 校验所必须,不可省。
|
||||
type User struct {
|
||||
ID int64
|
||||
AccessHash int64
|
||||
Phone string
|
||||
FirstName string
|
||||
LastName string
|
||||
About string
|
||||
Username string
|
||||
CountryCode string
|
||||
Verified bool
|
||||
Support bool
|
||||
Contact bool
|
||||
Mutual bool
|
||||
// Profile photo:反范式存于 users 表,便于无 join 渲染头像。PhotoID==0 表示无头像。
|
||||
PhotoID int64
|
||||
PhotoDCID int
|
||||
PhotoStripped []byte
|
||||
LastSeenAt int
|
||||
Status UserStatus
|
||||
}
|
||||
|
||||
// UserStatusKind is a protocol-neutral account presence state.
|
||||
type UserStatusKind int
|
||||
|
||||
const (
|
||||
UserStatusUnknown UserStatusKind = iota
|
||||
UserStatusOnline
|
||||
UserStatusOffline
|
||||
UserStatusRecently
|
||||
UserStatusLastWeek
|
||||
UserStatusLastMonth
|
||||
UserStatusEmpty
|
||||
)
|
||||
|
||||
// UserStatus describes the currently visible presence state for a user.
|
||||
//
|
||||
// Expires and WasOnline are absolute Unix timestamps in seconds, matching
|
||||
// Telegram's UserStatus semantics without leaking tg.* into domain.
|
||||
type UserStatus struct {
|
||||
Kind UserStatusKind
|
||||
Expires int
|
||||
WasOnline int
|
||||
}
|
||||
|
||||
// UserProfileUpdate 描述 account.updateProfile 的可选字段更新。
|
||||
type UserProfileUpdate struct {
|
||||
FirstName string
|
||||
HasFirstName bool
|
||||
LastName string
|
||||
HasLastName bool
|
||||
About string
|
||||
HasAbout bool
|
||||
}
|
||||
12
internal/domain/user_errors.go
Normal file
12
internal/domain/user_errors.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrUsernameInvalid = errors.New("username invalid")
|
||||
ErrUsernameOccupied = errors.New("username occupied")
|
||||
ErrUsernameNotOccupied = errors.New("username not occupied")
|
||||
ErrPhoneNotOccupied = errors.New("phone not occupied")
|
||||
ErrFirstNameInvalid = errors.New("first name invalid")
|
||||
ErrAboutTooLong = errors.New("about too long")
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue