Initial open source release

This commit is contained in:
A 2026-06-04 01:37:39 +08:00
commit 74992e893f
377 changed files with 118084 additions and 0 deletions

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

View file

@ -0,0 +1,15 @@
package domain
// Authorization 是一条设备授权auth_key 与 user 的绑定 + initConnection 设备信息。
// auth_key 是协议产物、授权是业务产物,故独立于 store.AuthKeyData。
type Authorization struct {
AuthKeyID [8]byte // 协议原生 auth_key_idstore 边界按小端转 int64
UserID int64
Layer int
DeviceModel string
Platform string
SystemVersion string
APIID int
AppVersion string
IP string
}

1546
internal/domain/channel.go Normal file

File diff suppressed because it is too large Load diff

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

View 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/supergroupChannelResults 放其他公开命中。
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
View 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
View 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
View 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
}

View 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
View 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_checksumhex可校验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
}

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

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

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

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

@ -0,0 +1,10 @@
package domain
// UpdateState 是账号的 update 状态pts/qts/seq/date
// 第一阶段空账号为零值;真实状态机属第二阶段。
type UpdateState struct {
Pts int
Qts int
Date int
Seq int
}

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

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