chore: refresh gramsrv public release

This commit is contained in:
A 2026-06-30 14:37:43 +08:00
parent 75cebe8dbf
commit 70b6820474
1274 changed files with 378751 additions and 59919 deletions

View file

@ -1,6 +1,9 @@
package domain
import "errors"
import (
"errors"
"strings"
)
var (
ErrPasswordHashInvalid = errors.New("password hash invalid")
@ -76,7 +79,10 @@ type PasswordSettings struct {
Hint string
EmailUnconfirmedPattern string
RecoveryEmail string
LoginEmailPattern string
// LoginEmail 是已确认的登录邮箱地址(服务端私有,永不直接下发;下发的是掩码后的
// LoginEmailPattern。它独立于 2FA 恢复邮箱 RecoveryEmail账号可只设登录邮箱而无 2FA。
LoginEmail string
LoginEmailPattern string
NewAlgo PasswordKDFAlgo
NewSecureAlgo SecurePasswordKDFAlgo
SecureRandom []byte
@ -142,3 +148,79 @@ func DefaultAccountReactionSettings() AccountReactionSettings {
PaidPrivacy: PaidReactionPrivacy{Kind: PaidReactionPrivacyDefault},
}
}
// DefaultAccountTTLDays 是账号自毁默认期限(无显式设置时)。与历史固定回显一致。
const DefaultAccountTTLDays = 365
// GlobalPrivacy 是 globalPrivacySettings 的业务层表达(账号级隐私开关)。
// DisallowedGifts 依赖礼物资产模型(当前未实现),故不建模、保持默认。
type GlobalPrivacy struct {
ArchiveAndMuteNewNoncontactPeers bool
KeepArchivedUnmuted bool
KeepArchivedFolders bool
HideReadMarks bool
NewNoncontactPeersRequirePremium bool
DisplayGiftsButton bool
// NoncontactPeersPaidStars非联系人给本人发消息所需 Stars 数。Stars 账本尚未实现,
// 此处仅做忠实持久化(往返不丢值),不参与计费逻辑。
NoncontactPeersPaidStars int64
}
// AccountSettings 聚合账号级单例设置(每用户一行):全局隐私、账号自毁期限、
// 敏感内容开关、联系人注册通知静音。对应 account.get/set GlobalPrivacySettings、
// get/set AccountTTL、get/set ContentSettings、get/set ContactSignUpNotification。
type AccountSettings struct {
GlobalPrivacy GlobalPrivacy
AccountTTLDays int
SensitiveContentEnabled bool
// ContactSignUpSilent 对应 account.setContactSignUpNotification 的 silent 形参:
// true=联系人注册时不通知本人。getContactSignUpNotification 直接返回该值。
ContactSignUpSilent bool
}
// DefaultAccountSettings 是未持久化时的账号设置默认值(与历史回显 stub 行为一致:
// 全局隐私全关、TTL 365 天、敏感内容关、联系人注册通知开启)。
func DefaultAccountSettings() AccountSettings {
return AccountSettings{
AccountTTLDays: DefaultAccountTTLDays,
}
}
// NormalizedTTLDays 返回钳制后的账号自毁期限0/越界回落默认)。
func (s AccountSettings) NormalizedTTLDays() int {
if s.AccountTTLDays <= 0 {
return DefaultAccountTTLDays
}
return s.AccountTTLDays
}
// MaskEmail 把邮箱地址按 Telegram pattern 习惯掩码(首尾各保留一位本地名,如
// a***z@x.com用于 account.password.login_email_pattern / auth.sentCodeTypeEmailCode
// 等只能暴露掩码的下发点。空串返回空串。
func MaskEmail(email string) string {
if email == "" {
return ""
}
at := strings.Index(email, "@")
if at <= 1 {
return email
}
name := email[:at]
return name[:1] + "***" + name[len(name)-1:] + email[at:]
}
// NormalizePhone 仅保留手机号中的数字(与 users.phone 的存储形态一致)。全部被过滤
// 掉时返回原串,便于上层做 validPhone 拒绝。auth/account 两域共用同一规则避免漂移。
func NormalizePhone(phone string) string {
var b strings.Builder
b.Grow(len(phone))
for _, r := range phone {
if r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
if b.Len() == 0 {
return phone
}
return b.String()
}

36
internal/domain/admin.go Normal file
View file

@ -0,0 +1,36 @@
package domain
import "time"
type AdminCommandStatus string
const (
AdminCommandRunning AdminCommandStatus = "running"
AdminCommandCompleted AdminCommandStatus = "completed"
AdminCommandFailed AdminCommandStatus = "failed"
)
type AdminCommand struct {
CommandID string
Actor string
Action string
TargetUserID int64
TargetPeer Peer
DryRun bool
Reason string
RequestJSON []byte
ResultJSON []byte
Status AdminCommandStatus
Error string
CreatedAt time.Time
CompletedAt *time.Time
}
type AccountSendRestriction struct {
UserID int64
Frozen bool
Reason string
Actor string
CommandID string
UpdatedAt time.Time
}

View file

@ -15,6 +15,9 @@ type Authorization struct {
APIID int
AppVersion string
IP string
CreatedAt time.Time
ActiveAt time.Time
// PasswordPending 表示该 auth_key 已通过短信验证码、但账号开启了两步验证且尚未通过
// auth.checkPassword。此状态下业务鉴权须视其为未登录仅允许继续完成两步验证。
PasswordPending bool
CreatedAt time.Time
ActiveAt time.Time
}

291
internal/domain/boost.go Normal file
View file

@ -0,0 +1,291 @@
package domain
import (
"errors"
"fmt"
"sort"
)
const (
// DefaultPremiumBoostSlotID is the single base boost slot every active premium
// user owns in the current small-scale implementation.
DefaultPremiumBoostSlotID = 1
// MaxPremiumBoostSlotsPerApply bounds premium.applyBoost slots before storage.
MaxPremiumBoostSlotsPerApply = 16
// MaxPremiumBoostsListLimit bounds premium.getBoostsList pages.
MaxPremiumBoostsListLimit = 100
// MaxPremiumBoostsOffsetBytes bounds opaque boost pagination cursors.
MaxPremiumBoostsOffsetBytes = 128
// MaxChannelBoostsToUnblockRestrictions matches Layer 225 TDesktop UI bounds.
MaxChannelBoostsToUnblockRestrictions = 8
// MaxDefaultPremiumBoostLevel bounds the built-in level table so status replies
// never expose unbounded next-level values to clients.
MaxDefaultPremiumBoostLevel = 100
// DefaultPremiumBoostReassignCooldownSeconds keeps the current dev behavior of
// allowing immediate moves while preserving a single hook for official cooldowns.
DefaultPremiumBoostReassignCooldownSeconds = 0
)
var (
ErrBoostNotModified = errors.New("boost not modified")
defaultPremiumBoostLevelPolicy = PremiumBoostLevelPolicy{
Thresholds: linearPremiumBoostThresholds(MaxDefaultPremiumBoostLevel),
}
)
// PremiumBoostSlot describes one user-owned boost slot without TL types.
type PremiumBoostSlot struct {
UserID int64
Slot int
Peer Peer
Date int
Expires int
CooldownUntil int
Multiplier int
Gift bool
Giveaway bool
Unclaimed bool
GiveawayMsgID int
UsedGiftSlug string
Stars int64
}
// Active reports whether the slot can currently count for a peer.
func (s PremiumBoostSlot) Active(now int) bool {
if s.UserID == 0 || s.Slot <= 0 {
return false
}
return s.Expires == 0 || s.Expires > now
}
// Assigned reports whether the active slot is currently applied to a peer.
func (s PremiumBoostSlot) Assigned(now int) bool {
return s.Active(now) && s.Peer.ID != 0
}
// Weight returns the boost contribution of one active assigned slot.
func (s PremiumBoostSlot) Weight(now int) int {
if !s.Assigned(now) {
return 0
}
if s.Multiplier <= 0 {
return 1
}
return s.Multiplier
}
// PremiumBoostStatus is the domain projection behind premium.boostsStatus.
type PremiumBoostStatus struct {
Peer Peer
Level int
CurrentLevelBoosts int
Boosts int
GiftBoosts int
NextLevelBoosts int
HasNextLevelBoosts bool
PremiumAudiencePart int
PremiumAudienceTotal int
MyBoostSlots []PremiumBoostSlot
}
// PremiumBoostList is a bounded admin-visible boosts page.
type PremiumBoostList struct {
Count int
Boosts []PremiumBoostSlot
Users []User
NextOffset string
}
// PremiumMyBoosts is the current user's slot inventory.
type PremiumMyBoosts struct {
Slots []PremiumBoostSlot
Users []User
Channels []Channel
}
// PremiumBoostLevelPolicy maps cumulative active boosts to channel boost levels.
// Thresholds[n-1] is the minimum boost count required for level n.
type PremiumBoostLevelPolicy struct {
Thresholds []int
}
// DefaultPremiumBoostLevelPolicy returns the current bounded telesrv level table.
// It is intentionally policy-backed: production deployments can replace the table
// with official thresholds without touching store/RPC state transitions.
func DefaultPremiumBoostLevelPolicy() PremiumBoostLevelPolicy {
return PremiumBoostLevelPolicy{
Thresholds: append([]int(nil), defaultPremiumBoostLevelPolicy.Thresholds...),
}
}
// NewPremiumBoostLevelPolicy creates a monotonic threshold policy from raw values.
func NewPremiumBoostLevelPolicy(thresholds []int) PremiumBoostLevelPolicy {
return PremiumBoostLevelPolicy{Thresholds: normalizePremiumBoostThresholds(thresholds)}
}
// LevelForBoosts returns level bounds for the given active boost count.
func (p PremiumBoostLevelPolicy) LevelForBoosts(boosts int) (level, currentLevelBoosts, nextLevelBoosts int, hasNext bool) {
if boosts < 0 {
boosts = 0
}
thresholds := p.thresholds()
if len(thresholds) == 0 {
return 0, 0, 0, false
}
level = sort.Search(len(thresholds), func(i int) bool { return thresholds[i] > boosts })
if level > 0 {
currentLevelBoosts = thresholds[level-1]
}
if level < len(thresholds) {
return level, currentLevelBoosts, thresholds[level], true
}
return len(thresholds), thresholds[len(thresholds)-1], 0, false
}
func (p PremiumBoostLevelPolicy) thresholds() []int {
if len(p.Thresholds) == 0 {
return defaultPremiumBoostLevelPolicy.Thresholds
}
last := 0
for _, threshold := range p.Thresholds {
if threshold <= 0 || threshold <= last {
return normalizePremiumBoostThresholds(p.Thresholds)
}
last = threshold
}
return p.Thresholds
}
func normalizePremiumBoostThresholds(thresholds []int) []int {
if len(thresholds) == 0 {
return nil
}
sorted := make([]int, 0, len(thresholds))
for _, threshold := range thresholds {
if threshold > 0 {
sorted = append(sorted, threshold)
}
}
sort.Ints(sorted)
out := sorted[:0]
last := 0
for _, threshold := range sorted {
if threshold == last {
continue
}
out = append(out, threshold)
last = threshold
}
return append([]int(nil), out...)
}
func linearPremiumBoostThresholds(maxLevel int) []int {
if maxLevel <= 0 {
return nil
}
out := make([]int, maxLevel)
for i := range out {
out[i] = i + 1
}
return out
}
// PremiumBoostStatusForCount returns the bounded default level projection.
func PremiumBoostStatusForCount(peer Peer, boosts int, my []PremiumBoostSlot) PremiumBoostStatus {
return PremiumBoostStatusForPolicy(peer, boosts, my, defaultPremiumBoostLevelPolicy)
}
// PremiumBoostStatusForPolicy returns a status projection from an explicit level policy.
func PremiumBoostStatusForPolicy(peer Peer, boosts int, my []PremiumBoostSlot, policy PremiumBoostLevelPolicy) PremiumBoostStatus {
if boosts < 0 {
boosts = 0
}
level, current, next, hasNext := policy.LevelForBoosts(boosts)
return PremiumBoostStatus{
Peer: peer,
Level: level,
CurrentLevelBoosts: current,
Boosts: boosts,
NextLevelBoosts: next,
HasNextLevelBoosts: hasNext,
MyBoostSlots: append([]PremiumBoostSlot(nil), my...),
}
}
// ApplyPremiumBoostSlot applies one user-owned slot to a peer and enforces shared
// lifecycle rules for all store implementations.
func ApplyPremiumBoostSlot(slot PremiumBoostSlot, userID int64, slotID int, peer Peer, now, premiumUntil, cooldownSeconds int) (PremiumBoostSlot, bool, error) {
if userID == 0 || slotID <= 0 || peer.Type != PeerTypeChannel || peer.ID == 0 || now < 0 {
return PremiumBoostSlot{}, false, ErrChannelInvalid
}
if premiumUntil <= now {
return PremiumBoostSlot{}, false, ErrPremiumRequired
}
if cooldownSeconds < 0 {
cooldownSeconds = 0
}
if slot.UserID == 0 || slot.Slot == 0 || !slot.Active(now) {
slot = PremiumBoostSlot{
UserID: userID,
Slot: slotID,
Multiplier: 1,
}
}
if slot.UserID != userID || slot.Slot != slotID {
return PremiumBoostSlot{}, false, ErrChannelInvalid
}
if slot.Multiplier <= 0 {
slot.Multiplier = 1
}
if slot.Assigned(now) && slot.Peer == peer {
if slot.Expires != 0 && slot.Expires != premiumUntil {
slot.Expires = premiumUntil
return slot, true, nil
}
return slot, false, ErrBoostNotModified
}
if slot.Assigned(now) && slot.Peer != peer && slot.CooldownUntil > now {
return slot, false, NewPremiumBoostFloodWaitError(slot.CooldownUntil - now)
}
wasAssigned := slot.Assigned(now)
slot.Peer = peer
slot.Date = now
slot.Expires = premiumUntil
if wasAssigned && cooldownSeconds > 0 {
slot.CooldownUntil = now + cooldownSeconds
} else if slot.CooldownUntil <= now {
slot.CooldownUntil = 0
}
return slot, true, nil
}
// PremiumBoostFloodWaitError carries remaining seconds for boost reassign cooldowns.
type PremiumBoostFloodWaitError struct {
Seconds int
}
func (e PremiumBoostFloodWaitError) Error() string {
if e.Seconds <= 0 {
return "premium boost flood wait"
}
return fmt.Sprintf("premium boost flood wait %d seconds", e.Seconds)
}
func NewPremiumBoostFloodWaitError(seconds int) error {
if seconds <= 0 {
seconds = 1
}
return PremiumBoostFloodWaitError{Seconds: seconds}
}
func PremiumBoostFloodWaitSeconds(err error) (int, bool) {
var wait PremiumBoostFloodWaitError
if !errors.As(err, &wait) {
return 0, false
}
if wait.Seconds <= 0 {
return 1, true
}
return wait.Seconds, true
}

View file

@ -0,0 +1,93 @@
package domain
import (
"errors"
"testing"
)
func TestPremiumBoostStatusDefaultPolicyIsBoundedLinear(t *testing.T) {
peer := Peer{Type: PeerTypeChannel, ID: 100}
empty := PremiumBoostStatusForCount(peer, 0, nil)
if empty.Level != 0 || empty.CurrentLevelBoosts != 0 || empty.Boosts != 0 || empty.NextLevelBoosts != 1 || !empty.HasNextLevelBoosts {
t.Fatalf("empty status = %+v, want level 0 next 1", empty)
}
one := PremiumBoostStatusForCount(peer, 1, nil)
if one.Level != 1 || one.CurrentLevelBoosts != 1 || one.Boosts != 1 || one.NextLevelBoosts != 2 || !one.HasNextLevelBoosts {
t.Fatalf("one boost status = %+v, want level 1 next 2", one)
}
max := PremiumBoostStatusForCount(peer, MaxDefaultPremiumBoostLevel, nil)
if max.Level != MaxDefaultPremiumBoostLevel || max.CurrentLevelBoosts != MaxDefaultPremiumBoostLevel || max.HasNextLevelBoosts {
t.Fatalf("max status = %+v, want capped max level without next", max)
}
over := PremiumBoostStatusForCount(peer, MaxDefaultPremiumBoostLevel+50, nil)
if over.Level != MaxDefaultPremiumBoostLevel || over.CurrentLevelBoosts != MaxDefaultPremiumBoostLevel || over.HasNextLevelBoosts {
t.Fatalf("over max status = %+v, want capped max level without next", over)
}
}
func TestPremiumBoostStatusCustomPolicy(t *testing.T) {
policy := NewPremiumBoostLevelPolicy([]int{6, 1, 3, 3, 0})
peer := Peer{Type: PeerTypeChannel, ID: 100}
status := PremiumBoostStatusForPolicy(peer, 4, nil, policy)
if status.Level != 2 || status.CurrentLevelBoosts != 3 || status.NextLevelBoosts != 6 || !status.HasNextLevelBoosts {
t.Fatalf("custom policy status = %+v, want level 2 current 3 next 6", status)
}
status = PremiumBoostStatusForPolicy(peer, 6, nil, policy)
if status.Level != 3 || status.CurrentLevelBoosts != 6 || status.HasNextLevelBoosts {
t.Fatalf("custom max status = %+v, want level 3 without next", status)
}
}
func TestApplyPremiumBoostSlotLifecycle(t *testing.T) {
peer := Peer{Type: PeerTypeChannel, ID: 100}
slot, changed, err := ApplyPremiumBoostSlot(PremiumBoostSlot{}, 10, DefaultPremiumBoostSlotID, peer, 1000, 2000, 0)
if err != nil || !changed {
t.Fatalf("apply free slot changed=%v err=%v", changed, err)
}
if slot.UserID != 10 || slot.Slot != DefaultPremiumBoostSlotID || slot.Peer != peer || slot.Date != 1000 || slot.Expires != 2000 || slot.Multiplier != 1 {
t.Fatalf("applied slot = %+v", slot)
}
_, changed, err = ApplyPremiumBoostSlot(slot, 10, DefaultPremiumBoostSlotID, peer, 1001, 2000, 0)
if !errors.Is(err, ErrBoostNotModified) || changed {
t.Fatalf("reapply same peer changed=%v err=%v, want ErrBoostNotModified", changed, err)
}
extended, changed, err := ApplyPremiumBoostSlot(slot, 10, DefaultPremiumBoostSlotID, peer, 1002, 3000, 0)
if err != nil || !changed || extended.Expires != 3000 || extended.Date != slot.Date {
t.Fatalf("extend same peer slot=%+v changed=%v err=%v", extended, changed, err)
}
shortened, changed, err := ApplyPremiumBoostSlot(extended, 10, DefaultPremiumBoostSlotID, peer, 1003, 2500, 0)
if err != nil || !changed || shortened.Expires != 2500 || shortened.Date != slot.Date {
t.Fatalf("refresh same peer expiry slot=%+v changed=%v err=%v", shortened, changed, err)
}
}
func TestApplyPremiumBoostSlotCooldown(t *testing.T) {
first := Peer{Type: PeerTypeChannel, ID: 100}
second := Peer{Type: PeerTypeChannel, ID: 200}
slot := PremiumBoostSlot{
UserID: 10,
Slot: DefaultPremiumBoostSlotID,
Peer: first,
Date: 1000,
Expires: 3000,
CooldownUntil: 1500,
Multiplier: 1,
}
_, changed, err := ApplyPremiumBoostSlot(slot, 10, DefaultPremiumBoostSlotID, second, 1200, 3000, 0)
if changed || err == nil {
t.Fatalf("cooldown apply changed=%v err=%v, want flood wait", changed, err)
}
if seconds, ok := PremiumBoostFloodWaitSeconds(err); !ok || seconds != 300 {
t.Fatalf("cooldown err = %v seconds=%d ok=%v, want 300", err, seconds, ok)
}
}

313
internal/domain/bot.go Normal file
View file

@ -0,0 +1,313 @@
package domain
import (
"errors"
"strconv"
"strings"
"time"
)
const (
// MaxBotsPerOwner 是每个用户可创建的 bot 上限(对齐官方 appConfig bots_create_limit_default
MaxBotsPerOwner = 20
// BotTokenSecretLength 是 bot token 随机段长度(官方格式 <bot_id>:<35 位>)。
BotTokenSecretLength = 35
// BotUsernameSuffix 是 bot username 的强制后缀(大小写不敏感)。
BotUsernameSuffix = "bot"
// MaxBotNameLength 是 bot 显示名first_name上限。
MaxBotNameLength = 64
// MaxBotInlinePlaceholderLen 是 inline mode placeholder 上限bots.inline_placeholder
MaxBotInlinePlaceholderLen = 128
// MaxBotAppShortNameLen 是 bot app short_name 上限。
MaxBotAppShortNameLen = 64
// MaxBotAppTitleLen 是 bot app title 上限。
MaxBotAppTitleLen = 128
// MaxBotAppDescriptionLen 是 bot app description 上限。
MaxBotAppDescriptionLen = 512
// MaxBotAppURLLen 是 bot app / attach menu webview URL 上限。
MaxBotAppURLLen = 512
// MaxBotAttachMenuPeerTypes 限制 attach menu peer_types 数量,避免无界响应。
MaxBotAttachMenuPeerTypes = 8
// MaxBotAttachMenuIcons 限制 attach menu icon 变体数量。
MaxBotAttachMenuIcons = 8
// MaxBotPreviewMedia 是单 bot main mini app preview media 上限。
MaxBotPreviewMedia = 20
// MaxBotRequestedPeerQuantity 限制 request peer 一次选择数量。
MaxBotRequestedPeerQuantity = 10
// MaxBotCustomMethodLen 限制 WebView custom method 名称长度。
MaxBotCustomMethodLen = 128
// MaxBotCustomMethodPayloadLen 限制 WebView custom method JSON 载荷长度。
MaxBotCustomMethodPayloadLen = 4096
)
// bot 业务错误。
var (
ErrBotTokenInvalid = errors.New("bot token invalid")
ErrBotsTooMany = errors.New("bot create limit exceeded")
ErrBotNameInvalid = errors.New("bot name invalid")
ErrBotUsernameInvalid = errors.New("bot username invalid")
ErrBotNotFound = errors.New("bot not found")
// ErrBotCommandInvalid 表示命令名/描述非法bots.setBotCommands
ErrBotCommandInvalid = errors.New("bot command invalid")
// ErrBotInfoInvalid 表示 setBotInfo 的 name/about/description 越界。
ErrBotInfoInvalid = errors.New("bot info invalid")
// ErrBotMenuButtonInvalid 表示 menu button 文本/URL 非法。
ErrBotMenuButtonInvalid = errors.New("bot menu button invalid")
// ErrBotInlinePlaceholderInvalid 表示 inline placeholder 为空以外的文本越界。
ErrBotInlinePlaceholderInvalid = errors.New("bot inline placeholder invalid")
// ErrBotAppInvalid 表示 bot app 元数据、URL 或 access hash 非法。
ErrBotAppInvalid = errors.New("bot app invalid")
// ErrBotAppShortNameInvalid 表示 bot app short_name 非法。
ErrBotAppShortNameInvalid = errors.New("bot app short name invalid")
// ErrBotAttachMenuInvalid 表示 attach menu catalog 或用户状态非法。
ErrBotAttachMenuInvalid = errors.New("bot attach menu invalid")
// ErrBotRequestedButtonInvalid 表示 request-peer button 上下文非法或已过期。
ErrBotRequestedButtonInvalid = errors.New("bot requested button invalid")
// ErrBotDownloadParamsInvalid 表示 mini app download 参数未通过安全策略。
ErrBotDownloadParamsInvalid = errors.New("bot download params invalid")
// ErrBotCustomMethodUnavailable 表示没有可完成 WebView custom method 的 bot 侧回答。
ErrBotCustomMethodUnavailable = errors.New("bot custom method unavailable")
// ErrBotSessionsNotRevoked 表示 token 已轮换但撤销已登录 session 失败
//token 不可回滚,调用方须告知用户重试以确保旧 session 终止)。
ErrBotSessionsNotRevoked = errors.New("bot sessions not revoked")
)
const (
// MaxBotCommands 是单个 bot 的命令上限default scope
MaxBotCommands = 100
// MaxBotCommandLen 是命令名长度上限(不含前导 /)。
MaxBotCommandLen = 32
// MaxBotCommandDescriptionLen 是命令描述长度上限。
MaxBotCommandDescriptionLen = 256
// MaxBotAboutLen 是 bot aboutusers.about长度上限。
MaxBotAboutLen = 120
// MaxBotDescriptionLen 是 bot description"What can this bot do?")长度上限。
MaxBotDescriptionLen = 512
// MaxBotMenuButtonTextLen / MaxBotMenuButtonURLLen 是菜单按钮文本/URL 上限。
MaxBotMenuButtonTextLen = 64
MaxBotMenuButtonURLLen = 512
)
// BotCommand 是 bot 的一条命令botInfo.commands 元素)。
type BotCommand struct {
Command string `json:"command"`
Description string `json:"description"`
}
// BotMenuButtonType 标识菜单按钮类型。
type BotMenuButtonType int
const (
// BotMenuButtonDefault 是默认(不显示特殊按钮,客户端按 commands 是否非空决定 '/' 圆钮)。
BotMenuButtonDefault BotMenuButtonType = 0
// BotMenuButtonCommands 显式让客户端展示命令菜单按钮。
BotMenuButtonCommands BotMenuButtonType = 1
// BotMenuButtonWebView 是带文本+URL 的 web view 按钮。
BotMenuButtonWebView BotMenuButtonType = 2
)
// BotMenuButton 是 bot 的菜单按钮per-bot 全局per-user 维度 P2 不做)。
type BotMenuButton struct {
Type BotMenuButtonType
Text string
URL string
}
// BotInfoUpdate 是 bots.setBotInfo 的部分更新name→users.first_name、
// about→users.about、description→bots.description。各 SetXxx 为 false 时不动该字段。
type BotInfoUpdate struct {
SetName bool
Name string
SetAbout bool
About string
SetDescription bool
Description string
}
// BotApp 是 mini app catalog 的一项。一个 bot 可拥有多个 appID/access_hash
// 在创建后稳定short_name 在同一 bot 下唯一。
type BotApp struct {
ID int64
BotUserID int64
ShortName string
Title string
Description string
URL string
PhotoID int64
DocumentID int64
AccessHash int64
Hash int64
Inactive bool
RequestWriteAccess bool
HasSettings bool
Main bool
}
// BotAppSettings 是 BotInfo.app_settings 的协议中立表示。
type BotAppSettings struct {
PlaceholderPath []byte
BackgroundColor int
BackgroundDarkColor int
HeaderColor int
HeaderDarkColor int
HasBackgroundColor bool
HasBackgroundDark bool
HasHeaderColor bool
HasHeaderDarkColor bool
}
// BotAppPreviewMedia 是 main mini app preview media 的持久快照。
type BotAppPreviewMedia struct {
ID int64
BotUserID int64
AppID int64
Position int
PhotoID int64
DocumentID int64
}
// BotAttachMenuIconColor 描述 attach menu icon 的主题色覆盖。
type BotAttachMenuIconColor struct {
Name string
Color int
}
// BotAttachMenuIcon 描述 attach menu 的平台 icon 文档引用。
type BotAttachMenuIcon struct {
Name string
DocumentID int64
Colors []BotAttachMenuIconColor
}
// BotAttachMenuBot 是全局 attach/side menu catalog 的一项。
type BotAttachMenuBot struct {
BotUserID int64
AppID int64
ShortName string
Inactive bool
HasSettings bool
RequestWriteAccess bool
ShowInAttachMenu bool
ShowInSideMenu bool
SideMenuDisclaimerNeeded bool
PeerTypes []string
Icons []BotAttachMenuIcon
}
// BotAttachMenuState 是用户对某个 attach menu bot 的启用与写权限状态。
type BotAttachMenuState struct {
UserID int64
BotUserID int64
Enabled bool
WriteAllowed bool
}
// BotRequestedWebViewButton 是 bots.requestWebViewButton 创建的 request-peer 上下文。
type BotRequestedWebViewButton struct {
WebAppReqID string
BotUserID int64
UserID int64
ButtonID int
Text string
PeerType string
MaxQuantity int
CreatedAt time.Time
ExpiresAt time.Time
}
// BotWebViewCustomMethodQuery 是 custom method 的 pending 记录。没有 bot 侧回答
// registry 时只记录查询并显式返回 blocked 错误,避免假成功。
type BotWebViewCustomMethodQuery struct {
ID string
BotUserID int64
UserID int64
CustomMethod string
ParamsJSON string
CreatedAt time.Time
ExpiresAt time.Time
}
// ValidBotCommandName 校验命令名1-32 位小写字母/数字/下划线(不含前导 /)。
func ValidBotCommandName(cmd string) bool {
if len(cmd) == 0 || len(cmd) > MaxBotCommandLen {
return false
}
for _, r := range cmd {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_':
default:
return false
}
}
return true
}
// BotProfile 是 bots 表一行bot 账号的元数据与 token。
// 显示名/username/about 复用 users 行,不在此重复。
type BotProfile struct {
BotUserID int64
OwnerUserID int64
TokenSecret string
Description string
Commands []BotCommand
ChatHistory bool // privacy mode 关闭 = truebot_chat_history flag
Nochats bool // true = 不允许加群bot_nochats flag
InlineGeo bool
InlinePlaceholder string
MenuButton BotMenuButton
HasMainApp bool
HasAttachMenu bool
HasPreviewMedias bool
AppSettings *BotAppSettings
}
// BotChatState 是内置 bot当前仅 BotFather与某用户的对话状态机持久态。
type BotChatState struct {
BotUserID int64
UserID int64
// Command 是进行中的主命令newbot/token/revoke
Command string `json:"command"`
// Step 是主命令内的当前步骤name/username/choose
Step string `json:"step"`
// Draft 暂存跨步骤的中间输入(如 newbot 已收的 name
Draft map[string]string `json:"draft,omitempty"`
}
// FormatBotToken 拼装完整 bot token<bot_user_id>:<secret>)。
func FormatBotToken(botUserID int64, secret string) string {
return strconv.FormatInt(botUserID, 10) + ":" + secret
}
// ParseBotToken 拆解完整 bot token。格式非法时 ok=false不区分具体原因
// 调用方统一回 ACCESS_TOKEN_INVALID避免泄漏存在性
func ParseBotToken(token string) (botUserID int64, secret string, ok bool) {
idPart, secret, found := strings.Cut(token, ":")
if !found || idPart == "" || secret == "" {
return 0, "", false
}
id, err := strconv.ParseInt(idPart, 10, 64)
if err != nil || id <= 0 {
return 0, "", false
}
return id, secret, true
}
// ValidBotUsername 校验 bot username5-32 位、字母开头、[A-Za-z0-9_]、
// 以 bot 结尾大小写不敏感。BotFather 等种子账号不经此校验。
func ValidBotUsername(username string) bool {
if len(username) < 5 || len(username) > 32 {
return false
}
for i, r := range username {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9', r == '_':
if i == 0 {
return false
}
default:
return false
}
}
return strings.HasSuffix(strings.ToLower(username), BotUsernameSuffix)
}

View file

@ -0,0 +1,63 @@
package domain
const (
MaxBotInlineResults = 50
MaxBotInlineResultIDLen = 64
MaxBotInlineNextOffsetLen = 64
MaxBotInlineSwitchTextLen = 256
MaxBotInlineWebURLLen = 2048
MaxBotInlineWebMimeLen = 128
MaxBotInlineWebSize = 20 * 1024 * 1024
MaxBotPreparedInlineIDLen = 128
)
type BotInlineResult struct {
ID string
Type string
Title string
Description string
URL string
Thumb *BotInlineWebDocument
Content *BotInlineWebDocument
Message string
Entities []MessageEntity
ReplyMarkup *MessageReplyMarkup
NoWebpage bool
MediaAuto bool
Media *MessageMedia
}
type BotInlineWebDocument struct {
URL string
AccessHash int64
Size int
MimeType string
Attributes []DocumentAttribute
}
type BotInlineResults struct {
QueryID int64
BotUserID int64
UserID int64
Peer Peer
Query string
Geo *MessageGeoPoint
Gallery bool
Private bool
Results []BotInlineResult
CacheTime int
NextOffset string
SwitchPM *BotInlineSwitchPM
SwitchWeb *BotInlineSwitchWebView
PeerTypes []string
}
type BotInlineSwitchPM struct {
Text string
StartParam string
}
type BotInlineSwitchWebView struct {
Text string
URL string
}

302
internal/domain/business.go Normal file
View file

@ -0,0 +1,302 @@
package domain
import (
"errors"
"strings"
"unicode/utf8"
)
const (
MaxBusinessWorkHourIntervals = 28
MaxBusinessRecipientUsers = 100
MaxBusinessChatLinks = 100
MaxBusinessChatLinkMessage = MaxMessageTextLength
MaxBusinessChatLinkTitle = 64
MaxQuickReplies = 100
MaxQuickReplyMessages = 20
MaxQuickReplyShortcutLength = 32
BusinessAwayCooldownSeconds = 24 * 60 * 60
)
var (
ErrBusinessProfileInvalid = errors.New("business profile invalid")
ErrBusinessChatLinkInvalid = errors.New("business chat link invalid")
ErrBusinessChatLinkNotFound = errors.New("business chat link not found")
ErrBusinessChatLinksTooMuch = errors.New("business chat links too much")
ErrBusinessRecipientsEmpty = errors.New("business recipients empty")
ErrBotBusinessMissing = errors.New("bot business missing")
ErrBotNotConnectedYet = errors.New("bot not connected yet")
ErrBotAlreadyDisabled = errors.New("bot already disabled")
ErrShortcutInvalid = errors.New("quick reply shortcut invalid")
ErrShortcutOccupied = errors.New("quick reply shortcut occupied")
ErrQuickRepliesTooMuch = errors.New("quick replies too much")
)
type BusinessProfile struct {
UserID int64
WorkHours *BusinessWorkHours
Location *BusinessLocation
Intro *BusinessIntro
Greeting *BusinessGreetingMessage
Away *BusinessAwayMessage
UpdatedAtUnix int64
}
type BusinessWeeklyOpen struct {
StartMinute int
EndMinute int
}
type BusinessWorkHours struct {
TimezoneID string
WeeklyOpen []BusinessWeeklyOpen
OpenNow bool
}
type BusinessLocation struct {
Address string
Geo *GeoPoint
}
type GeoPoint struct {
Lat float64
Long float64
}
type BusinessIntro struct {
Title string
Description string
StickerDocumentID int64
}
type BusinessRecipients struct {
ExistingChats bool
NewChats bool
Contacts bool
NonContacts bool
ExcludeSelected bool
Users []int64
}
type BusinessBotRights struct {
Reply bool
ReadMessages bool
DeleteSentMessages bool
DeleteReceivedMessages bool
EditName bool
EditBio bool
EditProfilePhoto bool
EditUsername bool
ViewGifts bool
SellGifts bool
ChangeGiftSettings bool
TransferAndUpgradeGifts bool
TransferStars bool
ManageStories bool
}
type BusinessBotRecipients struct {
ExistingChats bool
NewChats bool
Contacts bool
NonContacts bool
ExcludeSelected bool
Users []int64
ExcludeUsers []int64
}
type ConnectedBusinessBot struct {
OwnerUserID int64
BotUserID int64
Recipients BusinessBotRecipients
Rights BusinessBotRights
CreatedAtUnix int64
UpdatedAtUnix int64
}
type ConnectedBusinessBotPeerState struct {
OwnerUserID int64
PeerUserID int64
Paused bool
Disabled bool
UpdatedAtUnix int64
}
type BusinessGreetingMessage struct {
ShortcutID int
Recipients BusinessRecipients
NoActivityDays int
}
type BusinessAwayScheduleKind string
const (
BusinessAwayScheduleAlways BusinessAwayScheduleKind = "always"
BusinessAwayScheduleOutsideWorkHours BusinessAwayScheduleKind = "outside_work_hours"
BusinessAwayScheduleCustom BusinessAwayScheduleKind = "custom"
)
type BusinessAwaySchedule struct {
Kind BusinessAwayScheduleKind
StartDate int
EndDate int
}
type BusinessAwayMessage struct {
ShortcutID int
Schedule BusinessAwaySchedule
Recipients BusinessRecipients
OfflineOnly bool
}
type BusinessAutomationKind string
const (
BusinessAutomationGreeting BusinessAutomationKind = "greeting"
BusinessAutomationAway BusinessAutomationKind = "away"
BusinessAutomationAI BusinessAutomationKind = "ai"
)
type BusinessAutomationDelivery struct {
OwnerUserID int64
PeerUserID int64
Kind BusinessAutomationKind
TriggerMessageID int
ShortcutID int
SentAt int
}
type BusinessChatLinkInput struct {
Message string
Entities []MessageEntity
Title string
}
type BusinessChatLink struct {
OwnerUserID int64
Slug string
Link string
Message string
Entities []MessageEntity
Title string
Views int
CreatedAt int64
UpdatedAt int64
}
type QuickReply struct {
OwnerUserID int64
ID int
Shortcut string
TopMessage int
Count int
SortOrder int
CreatedAt int64
UpdatedAt int64
}
type QuickReplyMessage struct {
OwnerUserID int64
ShortcutID int
ID int
RandomID int64
Date int
Message string
Entities []MessageEntity
}
type QuickReplyList struct {
OwnerUserID int64
QuickReplies []QuickReply
Messages []QuickReplyMessage
Hash int64
}
type QuickReplyMessages struct {
OwnerUserID int64
ShortcutID int
Messages []QuickReplyMessage
Count int
Hash int64
}
type QuickReplyMutationKind string
const (
QuickReplyMutationList QuickReplyMutationKind = "list"
QuickReplyMutationNew QuickReplyMutationKind = "new"
QuickReplyMutationDelete QuickReplyMutationKind = "delete"
QuickReplyMutationMessage QuickReplyMutationKind = "message"
QuickReplyMutationIDs QuickReplyMutationKind = "ids"
)
type QuickReplyMutation struct {
Kind QuickReplyMutationKind
List QuickReplyList
QuickReply QuickReply
ShortcutID int
Message QuickReplyMessage
MessageIDs []int
Date int
Pts int
PtsCount int
}
func NormalizeBusinessChatLinkInput(in BusinessChatLinkInput) (BusinessChatLinkInput, error) {
in.Message = strings.TrimSpace(in.Message)
in.Title = strings.TrimSpace(in.Title)
if in.Message == "" || utf8.RuneCountInString(in.Message) > MaxBusinessChatLinkMessage {
return BusinessChatLinkInput{}, ErrBusinessChatLinkInvalid
}
if utf8.RuneCountInString(in.Title) > MaxBusinessChatLinkTitle {
return BusinessChatLinkInput{}, ErrBusinessChatLinkInvalid
}
if len(in.Entities) > MaxMessageEntityCount {
return BusinessChatLinkInput{}, ErrBusinessChatLinkInvalid
}
in.Entities = append([]MessageEntity(nil), in.Entities...)
return in, nil
}
func NormalizeQuickReplyShortcut(shortcut string) (string, error) {
shortcut = strings.TrimSpace(shortcut)
if shortcut == "" || strings.ContainsAny(shortcut, "\r\n\t") || utf8.RuneCountInString(shortcut) > MaxQuickReplyShortcutLength {
return "", ErrShortcutInvalid
}
return shortcut, nil
}
func BusinessBotRecipientsMatch(recipients BusinessBotRecipients, existingChat, isContact bool, userID int64) bool {
selected := false
if existingChat && recipients.ExistingChats {
selected = true
}
if !existingChat && recipients.NewChats {
selected = true
}
if isContact && recipients.Contacts {
selected = true
}
if !isContact && recipients.NonContacts {
selected = true
}
for _, id := range recipients.Users {
if id == userID {
selected = true
break
}
}
for _, id := range recipients.ExcludeUsers {
if id == userID {
if recipients.ExcludeSelected {
selected = true
break
}
return false
}
}
if recipients.ExcludeSelected {
return !selected
}
return selected
}

View file

@ -1,5 +1,11 @@
package domain
import (
"strconv"
"strings"
"unicode/utf8"
)
const (
// MaxChannelDifferenceLimit limits a single updates.getChannelDifference page.
MaxChannelDifferenceLimit = 100
@ -14,9 +20,18 @@ const (
// MaxChannelInviteUsers limits a single inviteToChannel/createChat member batch.
MaxChannelInviteUsers = 200
// MaxChannelRealtimeFanout caps best-effort realtime channel pushes until a presence/subscription index exists.
MaxChannelRealtimeFanout = 500
MaxChannelRealtimeFanout = 2000
// MaxSynchronousChannelDialogFanout bounds per-member channel_dialogs writes in the send transaction.
MaxSynchronousChannelDialogFanout = 1000
// MaxDialogUnreadCount 钳制对话未读消息 COUNT 的上界(设计 fan-out epic Phase 2 / P1-v
// 广播/大群走动态 COUNT(*),积压巨大时既下发天文数字角标、又让 nudge 风暴下每个被 nudge
// 成员的 getChannelDifference/getPeerDialogs 触发 O(积压) 扫描打爆 PG。store 层一律把未读
// COUNT 用 LIMIT 子查询(PG)/提前 break(memory) 钳到本上界——既限定扫描工作量也限定下发数值。
// 只影响角标显示数,不影响 read 水位/pts 真值与 hasUnread(EXISTS) 判定。
// 取 99角标显示上限“99+”惯例):上界即扫描上界,越低单次未读 COUNT 扫描越浅——
// 相比旧值 1000积压 >99 的成员每次未读 COUNT大群读路径 + fan-out 写 + forum 话题)
// 扫描行数约降一个数量级。值可按客户端角标显示习惯调整。
MaxDialogUnreadCount = 99
// MaxChannelTypingFanout caps transient typing fanout.
MaxChannelTypingFanout = MaxChannelRealtimeFanout
// MaxChannelAdminRankLength limits custom admin rank text.
@ -31,6 +46,9 @@ const (
MaxChannelPendingJoinRecentRequesters = 5
// MaxAdminedPublicChannels limits channels.getAdminedPublicChannels payload size.
MaxAdminedPublicChannels = 200
// MaxSendAsChannels bounds the broadcast channels offered as channels.getSendAs candidates
// (the user's own channels they can post groups as).
MaxSendAsChannels = 100
// MaxChannelAdminLogLimit limits one channels.getAdminLog page.
MaxChannelAdminLogLimit = 100
// MaxChannelAdminLogAdmins limits admin filter fan-in.
@ -47,12 +65,36 @@ const (
MaxPublicChannelSearchLimit = 50
// MaxPublicChannelSearchQueryLength bounds public channel peer search strings.
MaxPublicChannelSearchQueryLength = 256
// MaxChannelReactionItems limits one channel reaction policy payload.
MaxChannelReactionItems = 64
// MaxChannelReactionTypes bounds how many distinct reaction types a chat may
// allow via chatReactionsSome. DrKLO sends "enable all standard reactions" on
// a broadcast channel as an explicit chatReactionsSome list (megagroups use
// chatReactionsAll instead), so this MUST stay >= the advertised available
// reactions catalog (messages.getAvailableReactions, currently ~74 entries) or
// that legitimate "select all" payload is wrongly rejected with LIMIT_INVALID.
// 100 leaves headroom for catalog growth while still bounding abusive payloads.
MaxChannelReactionTypes = 100
// MaxChannelReactionsLimit bounds the per-message distinct-reaction cap carried
// by messages.setChatAvailableReactions.reactions_limit. Clients constrain it to
// appConfig reactions_uniq_max (11); this wider server bound only rejects abuse
// and must never be smaller than the reaction type count to keep the cap sane.
MaxChannelReactionsLimit = 100
// MaxChannelReactionEmoticonLength limits one emoji reaction string.
MaxChannelReactionEmoticonLength = 32
// MaxChannelMessageReactionsPerUser limits one user's reactions on one channel message.
// MaxChannelMessageReactionsPerUser bounds the accepted sendReaction vector before trimming.
MaxChannelMessageReactionsPerUser = 16
// MaxMessageReactionsPerUser is the effective per-user cap on one message, matching the
// advertised appConfig reactions_user_max_default.
// Longer vectors keep the newest entries: official clients are told to drop older
// reactions, so the tail of the vector wins.
MaxMessageReactionsPerUser = 1
// MaxMessageReactionsPerUserPremium 是会员档每用户上限,对齐 appConfig
// reactions_user_max_premium官方值 3。客户端按 self premium flag 选档,
// 服务端档位必须 ≥ 客户端宣告档位,否则 premium 用户的多 reaction 会被
// 静默裁剪、双端视图错乱。
MaxMessageReactionsPerUserPremium = 3
// DefaultMessageReactionsUniqMax caps distinct reaction emojis on one message, matching the
// advertised appConfig reactions_uniq_max. ChannelReactionPolicy.Limit overrides it per chat.
DefaultMessageReactionsUniqMax = 11
// MaxChannelMessageReactionRecent limits recent reactors embedded in messageReactions.
MaxChannelMessageReactionRecent = 3
// MaxChannelMessageReactionListLimit limits one messages.getMessageReactionsList page.
@ -99,6 +141,9 @@ const (
MaxChannelForumTopicTitleLength = 128
// DefaultForumTopicIconColor is Telegram Desktop's General-topic fallback blue.
DefaultForumTopicIconColor = 0x6FB9F0
// ForumGeneralTopicID 是 General 话题的固定 idTelegram 协议;客户端 readDiscussion General 用 msg_id=1
// General 内消息 reply_to_top_id=1开 forum 前的历史消息 reply_to_top_id=0二者都归 General。
ForumGeneralTopicID = 1
// MaxChannelReadMentionsBatch limits one messages.readMentions clearing batch.
MaxChannelReadMentionsBatch = 1000
// MaxChannelReadReactionsBatch limits one messages.readReactions clearing batch.
@ -144,12 +189,19 @@ type ChannelAdminRights struct {
PostMessages bool
EditMessages bool
DeleteMessages bool
PostStories bool
EditStories bool
DeleteStories bool
BanUsers bool
InviteUsers bool
PinMessages bool
AddAdmins bool
ManageCall bool
Anonymous bool
ManageRanks bool
// ManageDirectMessages 对应 TL ChatAdminRights.manage_direct_messages(flags.17)。母广播频道的
// 管理员据此被客户端授予 monoforum(频道私信)容器的 MonoforumAdmin 身份;creator 走 amCreator 旁路。
ManageDirectMessages bool
}
// ChannelBannedRights is a domain-only representation of Telegram banned rights.
@ -166,6 +218,7 @@ type ChannelBannedRights struct {
ChangeInfo bool
InviteUsers bool
PinMessages bool
EditRank bool
UntilDate int
}
@ -189,6 +242,83 @@ type ChannelReactionPolicy struct {
PaidEnabled bool
}
// AllowsReaction reports whether the policy accepts one message reaction.
// The zero policy (Default) behaves like chatReactionsAll without allow_custom:
// ordinary emoji reactions are accepted, custom emoji reactions require an
// explicit chatReactionsSome entry or chatReactionsAll.allow_custom.
func (p ChannelReactionPolicy) AllowsReaction(reaction MessageReaction) bool {
if !reaction.Valid() {
return false
}
switch p.Type {
case ChannelReactionPolicyNone:
return false
case ChannelReactionPolicySome:
switch reaction.Type {
case MessageReactionEmoji:
for _, emoticon := range p.Emoticons {
if strings.TrimSpace(emoticon) == reaction.Value() {
return true
}
}
case MessageReactionCustomEmoji:
for _, documentID := range p.CustomEmojiIDs {
if documentID == reaction.DocumentID {
return true
}
}
}
return false
case ChannelReactionPolicyAll:
if reaction.Type == MessageReactionCustomEmoji {
return p.AllowCustom
}
return reaction.Type == MessageReactionEmoji
default:
return reaction.Type == MessageReactionEmoji
}
}
// UniqueReactionsLimit returns the per-message distinct emoji cap for the chat:
// channelFull.reactions_limit when set, otherwise the appConfig reactions_uniq_max default.
func (p ChannelReactionPolicy) UniqueReactionsLimit() int {
if p.Limit > 0 {
return p.Limit
}
return DefaultMessageReactionsUniqMax
}
// MessageReactionsUserMax 返回 viewer 的每用户 reaction 上限档位。
func MessageReactionsUserMax(premium bool) int {
if premium {
return MaxMessageReactionsPerUserPremium
}
return MaxMessageReactionsPerUser
}
// NormalizeReactionsPerUserMax 把请求携带的档位约束到合法区间:
// <=0旧调用方未填回退默认档上限封顶 premium 档。
func NormalizeReactionsPerUserMax(perUserMax int) int {
if perUserMax <= 0 {
return MaxMessageReactionsPerUser
}
if perUserMax > MaxMessageReactionsPerUserPremium {
return MaxMessageReactionsPerUserPremium
}
return perUserMax
}
// TrimMessageReactionsToUserMax enforces the per-user reaction cap by keeping the newest
// entries (clients append new reactions at the end of the vector and drop older ones).
// perUserMax 经 NormalizeReactionsPerUserMax 约束premium viewer 传 premium 档。
func TrimMessageReactionsToUserMax(reactions []MessageReaction, perUserMax int) []MessageReaction {
perUserMax = NormalizeReactionsPerUserMax(perUserMax)
if len(reactions) <= perUserMax {
return reactions
}
return reactions[len(reactions)-perUserMax:]
}
// ChannelPeerColor is a domain-only representation of PeerColor.
type ChannelPeerColor struct {
HasColor bool
@ -220,6 +350,7 @@ type Channel struct {
Title string
About string
Username string
Verified bool
Broadcast bool
Megagroup bool
Forum bool
@ -235,29 +366,52 @@ type Channel struct {
PreHistoryHidden bool
ParticipantsHidden bool
AntiSpam bool
LinkedChatID int64
SlowmodeSeconds int
DefaultBannedRights ChannelBannedRights
ReactionPolicy ChannelReactionPolicy
Color ChannelPeerColor
ProfileColor ChannelPeerColor
EmojiStatus ChannelEmojiStatus
ParticipantsCount int
AdminsCount int
KickedCount int
BannedCount int
TopMessageID int
PinnedMessageID int
Pts int
TTLPeriod int
Date int
Deleted bool
// HasLink 表示频道/群存在未撤销的导出邀请链接。Android send-as 入口会用
// megagroup && (public || has_geo || has_link) 判定是否拉取候选列表。
HasLink bool
LinkedChatID int64
// Monoforum 标记本频道是「频道私信(Direct Messages)」的 monoforum 虚拟频道。
// LinkedMonoforumID:母频道指向其 monoforum;monoforum 反向指向母频道(双向)。
Monoforum bool
LinkedMonoforumID int64
SlowmodeSeconds int
BoostsUnrestrict int
DefaultBannedRights ChannelBannedRights
ReactionPolicy ChannelReactionPolicy
Color ChannelPeerColor
ProfileColor ChannelPeerColor
EmojiStatus ChannelEmojiStatus
Wallpaper *Wallpaper
ParticipantsCount int
AdminsCount int
KickedCount int
BannedCount int
TopMessageID int
PinnedMessageID int
Pts int
TTLPeriod int
Date int
Deleted bool
// 活跃群通话关联channel.call_active/call_not_empty flag 与 channelFull.call
// 的数据源;客户端拉 dialogs/getFullChannel 时凭它重建 banner
ActiveCallID int64
ActiveCallAccessHash int64
ActiveCallNotEmpty bool
// 当前头像(反范式存于 channels 表。PhotoID==0 表示无头像。
PhotoID int64
PhotoDCID int
PhotoStripped []byte
}
// MembersListAdminOnly 表示该频道的成员/订阅者列表仅管理员可查看。广播频道(非
// megagroup的订阅者列表在官方语义里恒为管理员专属——普通订阅者看不到 Subscribers/
// Administrators/Channel Settings 等行,也不能枚举订阅者;此外成员被隐藏
// (ParticipantsHidden) 时对非管理员同样不可见。超级群megagroup默认成员可见。
// 该判定只看频道本身,是否放行还需叠加 viewer 是否管理员。
func (c Channel) MembersListAdminOnly() bool {
return c.ParticipantsHidden || (c.Broadcast && !c.Megagroup)
}
// ChannelMember is one user's channel membership and read state.
type ChannelMember struct {
ChannelID int64
@ -295,6 +449,7 @@ type ChannelDialog struct {
PinnedOrder int
UnreadMark bool
ViewForumAsMessages bool
HasScheduled bool
DefaultSendAs *Peer
}
@ -307,9 +462,32 @@ const (
ChannelActionChatAddUser ChannelMessageActionType = "chat_add_user"
ChannelActionChatDelete ChannelMessageActionType = "chat_delete_user"
ChannelActionChatJoined ChannelMessageActionType = "chat_joined"
ChannelActionEditTitle ChannelMessageActionType = "chat_edit_title"
ChannelActionTopicCreate ChannelMessageActionType = "topic_create"
ChannelActionTopicEdit ChannelMessageActionType = "topic_edit"
// ChannelActionChatJoinedByLink 是经邀请链接加入的服务消息,
// 渲染为 "X joined the group via invite link"。
ChannelActionChatJoinedByLink ChannelMessageActionType = "chat_joined_by_link"
ChannelActionEditTitle ChannelMessageActionType = "chat_edit_title"
ChannelActionTopicCreate ChannelMessageActionType = "topic_create"
ChannelActionTopicEdit ChannelMessageActionType = "topic_edit"
// ChannelActionTodoCompletions / ChannelActionTodoAppendTasks 映射
// messageActionTodoCompletions / messageActionTodoAppendTasks超级群
// checklist 协作生成的服务消息,经 reply_to 指向原 checklist 消息。
ChannelActionTodoCompletions ChannelMessageActionType = "todo_completions"
ChannelActionTodoAppendTasks ChannelMessageActionType = "todo_append_tasks"
// ChannelActionGroupCall 映射 messageActionGroupCallstartedCallDuration=0
// 与 endedCallDuration>0共用同一构造器官方语义即如此。
ChannelActionGroupCall ChannelMessageActionType = "group_call"
// ChannelActionInviteToGroupCall 映射 messageActionInviteToGroupCall被邀请
// 者通过频道消息收到可点击的入会卡片UserIDs 为受邀人)。
ChannelActionInviteToGroupCall ChannelMessageActionType = "invite_to_group_call"
// ChannelActionBoostApply 映射 messageActionBoostApply。
ChannelActionBoostApply ChannelMessageActionType = "boost_apply"
// ChannelActionPaidMessagesPrice 映射 messageActionPaidMessagesPrice
// 广播频道 Direct Messages 开关/价格变更的服务消息。
ChannelActionPaidMessagesPrice ChannelMessageActionType = "paid_messages_price"
// ChannelActionStarGift 映射 messageActionStarGift频道礼物的 admin-log 快照。
ChannelActionStarGift ChannelMessageActionType = "star_gift"
// ChannelActionSetChatWallpaper 映射 messageActionSetChatWallPaper频道外观页设置 wallpaper。
ChannelActionSetChatWallpaper ChannelMessageActionType = "set_chat_wallpaper"
)
// ChannelMessageAction describes a service action without depending on tg.*.
@ -323,6 +501,25 @@ type ChannelMessageAction struct {
Closed *bool
Hidden *bool
UserIDs []int64
// InviterUserID 仅 chat_joined_by_link 使用messageActionChatJoinedByLink.inviter_id
InviterUserID int64
// CallID/CallAccessHash/CallDuration 仅 group_call / invite_to_group_call 使用。
CallID int64
CallAccessHash int64
CallDuration int
// Boosts 仅 boost_apply 服务消息使用。
Boosts int
// BroadcastMessagesAllowed/Stars 仅 paid_messages_price 服务消息使用。
BroadcastMessagesAllowed bool
Stars int64
// Completed/Incompleted/TodoItems 仅 todo_* 服务消息使用。
Completed []int
Incompleted []int
TodoItems []MessageTodoItem
// StarGift 仅 star_gift 服务消息使用。
StarGift *MessageStarGiftAction
// Wallpaper 仅 set_chat_wallpaper 服务消息使用。
Wallpaper *Wallpaper
}
// ChannelMessage is a single stored message in a channel/supergroup.
@ -333,38 +530,107 @@ type ChannelMessage struct {
SenderUserID int64
From Peer
SendAs *Peer
Date int
EditDate int
Post bool
Silent bool
NoForwards bool
Body string
Entities []MessageEntity
ReplyTo *MessageReply
Forward *MessageForward
Discussion *ChannelDiscussionRef
Replies *ChannelMessageReplies
Reactions *ChannelMessageReactions
Action *ChannelMessageAction
Media *MessageMedia
Pinned bool
Mentioned bool
MediaUnread bool
Pts int
Deleted bool
// SavedPeer 是 monoforum 私信子会话分组键(按订阅者分组);普通频道消息为零值。
SavedPeer Peer
Date int
EditDate int
Post bool
Silent bool
NoForwards bool
Body string
Entities []MessageEntity
ReplyTo *MessageReply
Forward *MessageForward
ViaBotID int64
// GroupedID 相册分组 idsendMultiMedia 同组共享非零值,非相册恒 0
GroupedID int64
ReplyMarkup *MessageReplyMarkup
Discussion *ChannelDiscussionRef
Replies *ChannelMessageReplies
Reactions *ChannelMessageReactions
Action *ChannelMessageAction
Media *MessageMedia
// FromBoostsApplied 是发送时的 sender boost 数快照message.from_boosts_applied
FromBoostsApplied int
TTLPeriod int
ExpiresAt int
Pinned bool
Mentioned bool
MediaUnread bool
// Views 是 broadcast post 的浏览数聚合PostAuthor 是 signatures
// 开启时的作者展示名快照。
ViewsCount int
PostAuthor string
Pts int
Deleted bool
}
// MessageReactionType identifies one stored reaction constructor without depending on TL types.
type MessageReactionType string
const (
MessageReactionEmoji MessageReactionType = "emoji"
MessageReactionEmoji MessageReactionType = "emoji"
MessageReactionCustomEmoji MessageReactionType = "custom_emoji"
)
// MessageReaction describes one supported message reaction value.
type MessageReaction struct {
Type MessageReactionType
Emoticon string
Type MessageReactionType
Emoticon string
DocumentID int64
}
// Value returns the canonical persisted value for a reaction constructor.
func (r MessageReaction) Value() string {
switch r.Type {
case MessageReactionEmoji:
return strings.TrimSpace(r.Emoticon)
case MessageReactionCustomEmoji:
if r.DocumentID <= 0 {
return ""
}
return strconv.FormatInt(r.DocumentID, 10)
default:
return ""
}
}
// Key returns a stable identity key for de-duplication and aggregation.
func (r MessageReaction) Key() string {
return string(r.Type) + "\x00" + r.Value()
}
// Valid reports whether a reaction can be persisted as a message reaction.
func (r MessageReaction) Valid() bool {
switch r.Type {
case MessageReactionEmoji:
value := r.Value()
return value != "" && utf8.RuneCountInString(value) <= MaxChannelReactionEmoticonLength
case MessageReactionCustomEmoji:
return r.DocumentID > 0
default:
return false
}
}
// MessageReactionFromValue rebuilds a domain reaction from the persisted
// reaction_type/reaction_value pair used by stores.
func MessageReactionFromValue(reactionType MessageReactionType, value string) (MessageReaction, bool) {
switch reactionType {
case MessageReactionEmoji:
value = strings.TrimSpace(value)
reaction := MessageReaction{Type: MessageReactionEmoji, Emoticon: value}
return reaction, reaction.Valid()
case MessageReactionCustomEmoji:
documentID, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil {
return MessageReaction{}, false
}
reaction := MessageReaction{Type: MessageReactionCustomEmoji, DocumentID: documentID}
return reaction, reaction.Valid()
default:
return MessageReaction{}, false
}
}
// ChannelMessageReactionCount is an aggregated reaction counter for one message.
@ -393,6 +659,9 @@ type ChannelMessageReactions struct {
CanSeeList bool
Results []ChannelMessageReactionCount
Recent []ChannelMessagePeerReaction
// Paid 是付费 reactionStars聚合nil = 无);读路径从 channel_message_paid_reactions
// 填充、tg 转换注入 ReactionPaid 计数 + top reactors。与普通 reaction 分表存储。
Paid *ChannelMessagePaidReactions
}
// SetChannelMessageReactionsRequest replaces the current user's reactions for one message.
@ -404,6 +673,9 @@ type SetChannelMessageReactionsRequest struct {
Big bool
AddToRecent bool
Date int
// ReactionsPerUserMax 是 viewer 的每用户上限档位premium 双档);
// 0 表示未填store 侧按默认档裁剪。
ReactionsPerUserMax int
}
// ChannelMessageReactionsResult describes one reaction update.
@ -516,7 +788,11 @@ const (
ChannelUpdateDeleteMessages ChannelUpdateEventType = "delete_channel_messages"
ChannelUpdateParticipant ChannelUpdateEventType = "channel_participant"
ChannelUpdatePinnedMessages ChannelUpdateEventType = "pinned_channel_messages"
ChannelUpdateNoop ChannelUpdateEventType = "noop"
// ChannelUpdateWebPage 映射 updateChannelWebPage频道消息的 pending 链接预览异步解析后
// 就地替换为已解析卡片(按 webPage id 关联,不标记「已编辑」)。与 edit_channel_message
// 同构(携频道 pts + 消息快照difference/fan-out 复用,仅 tg 投影构造器不同。
ChannelUpdateWebPage ChannelUpdateEventType = "channel_web_page"
ChannelUpdateNoop ChannelUpdateEventType = "noop"
)
// ChannelUpdateEvent is the channel-scoped durable update log entry.
@ -582,6 +858,7 @@ const (
ChannelAdminLogParticipantLeave ChannelAdminLogEventType = "participant_leave"
ChannelAdminLogParticipantPromote ChannelAdminLogEventType = "participant_promote"
ChannelAdminLogParticipantDemote ChannelAdminLogEventType = "participant_demote"
ChannelAdminLogParticipantEditRank ChannelAdminLogEventType = "participant_edit_rank"
ChannelAdminLogParticipantBan ChannelAdminLogEventType = "participant_ban"
ChannelAdminLogParticipantUnban ChannelAdminLogEventType = "participant_unban"
ChannelAdminLogParticipantKick ChannelAdminLogEventType = "participant_kick"
@ -664,9 +941,14 @@ type ChannelAdminLogResult struct {
// ChannelView contains channel data personalized for a viewer.
type ChannelView struct {
Channel Channel
Self ChannelMember
Dialog ChannelDialog
Channel Channel
Self ChannelMember
Dialog ChannelDialog
SelfBoostsApplied int
ExportedInvite *ChannelInvite
// Forbidden 表示当前 viewer 被踢/被禁止查看:查询响应必须呈现
// channelForbidden 形态而不是省略,客户端靠它感知自己已离开会话。
Forbidden bool
}
// ChannelParticipantList is a paged participant response.
@ -730,7 +1012,7 @@ type ChannelRecommendationsRequest struct {
Limit int
}
// ChannelRecommendationsResult contains public broadcast channel recommendations and total count.
// ChannelRecommendationsResult contains public broadcast recommendations and a bounded count hint.
type ChannelRecommendationsResult struct {
Count int
Channels []Channel
@ -876,6 +1158,23 @@ type EditChannelTitleResult struct {
Recipients []int64
}
// SetChannelWallpaperRequest sets or clears a channel wallpaper and may emit a service message.
type SetChannelWallpaperRequest struct {
UserID int64
ChannelID int64
Wallpaper *Wallpaper
Date int
}
// SetChannelWallpaperResult describes a channel wallpaper change.
type SetChannelWallpaperResult struct {
Channel Channel
Message ChannelMessage
Event ChannelUpdateEvent
Recipients []int64
Changed bool
}
// EditChannelAboutRequest modifies a channel/supergroup description.
type EditChannelAboutRequest struct {
UserID int64
@ -904,6 +1203,16 @@ type EditChannelAdminResult struct {
Date int
}
// EditChannelMemberRankRequest sets or clears a participant's member tag (rank)
// without touching their role or admin rights.
type EditChannelMemberRankRequest struct {
UserID int64
ChannelID int64
MemberID int64
Rank string
Date int
}
// EditChannelBannedRequest modifies a participant's banned rights.
type EditChannelBannedRequest struct {
UserID int64
@ -929,6 +1238,10 @@ type EditChannelBannedResult struct {
Event ChannelUpdateEvent
Recipients []int64
Date int
// Message/ServiceEvent 是 megagroup 踢人产生的 "X removed Y" 服务
// 消息及其 channel pts 事件;纯禁言/解禁不生成。
Message ChannelMessage
ServiceEvent ChannelUpdateEvent
}
// DeleteChannelRequest deletes a channel/supergroup. Only the creator may do this.
@ -949,24 +1262,88 @@ type UpdateChannelUsernameRequest struct {
type DeleteChannelResult struct {
Channel Channel
Recipients []int64
// LinkedMonoforum 是随母广播频道一并软删的关联 monoforum(频道私信容器)。删父频道必须连带删它,
// 否则会留下 monoforum=true 但 linked_monoforum_id 指向已删父频道的孤儿(客户端渲染会崩)。
// 仅删带 Direct Messages 的母频道时非 nil;普通频道为 nil。
LinkedMonoforum *Channel
}
// SendChannelMessageRequest sends one channel/supergroup message.
type SendChannelMessageRequest struct {
UserID int64
ChannelID int64
RandomID int64
Message string
Entities []MessageEntity
Media *MessageMedia
MentionUserIDs []int64
Silent bool
NoForwards bool
ReplyTo *MessageReply
Forward *MessageForward
SendAs *Peer
Action *ChannelMessageAction
Date int
UserID int64
ChannelID int64
RandomID int64
Message string
Entities []MessageEntity
Media *MessageMedia
MentionUserIDs []int64
SkipDeliveryUserIDs []int64
// SkipRecipientLookup lets high-level realtime fan-out use the online member
// read model instead of forcing store.SendChannelMessage to synchronously
// return an active-member recipient list after commit.
SkipRecipientLookup bool
// PostAuthor 是 signatures 开启的 broadcast post 上快照的作者展示名。
PostAuthor string
Silent bool
NoForwards bool
ReplyTo *MessageReply
Forward *MessageForward
ViaBotID int64
// GroupedID 相册分组 idsendMultiMedia 同组共享非零值,非相册恒 0
GroupedID int64
ReplyMarkup *MessageReplyMarkup
SendAs *Peer
Action *ChannelMessageAction
Date int
TTLPeriod int
}
// SendMonoforumMessageRequest 向频道私信(monoforum)发送一条消息。MonoforumID 是 monoforum
// 虚拟频道 id;SavedPeer 是订阅者子会话分组键(订阅者发=自己,管理员回复=目标订阅者);
// SenderUserID 是实际发件人。发件权限(订阅者身份/管理员)在 RPC 层校验,store 只校验 monoforum 存在。
type SendMonoforumMessageRequest struct {
MonoforumID int64
SenderUserID int64
SavedPeer Peer
RandomID int64
Message string
Entities []MessageEntity
Date int
}
// MonoforumHistoryFilter 按订阅者子会话拉取 monoforum 私信历史。
type MonoforumHistoryFilter struct {
MonoforumID int64
SavedPeer Peer
Limit int
OffsetID int
}
// MonoforumDialog 是频道私信(monoforum)中一个订阅者子会话的摘要。读水位/未读由后续 P0
// 切片补齐,当前置 0。
type MonoforumDialog struct {
SavedPeer Peer
TopMessageID int
TopMessageDate int
UnreadCount int
ReadInboxMaxID int
ReadOutboxMaxID int
}
// MonoforumDialogList 是 monoforum 订阅者子会话列表(按各子会话 top 消息 id 倒序)。
type MonoforumDialogList struct {
MonoforumID int64
Channel Channel
Dialogs []MonoforumDialog
Messages []ChannelMessage
Count int
}
// MonoforumDialogsFilter 分页拉取 monoforum 订阅者子会话列表(按 top 消息 id 倒序 seek)。
type MonoforumDialogsFilter struct {
MonoforumID int64
Limit int
OffsetID int
}
// SaveChannelDefaultSendAsRequest stores the current user's default send-as peer for a channel dialog.
@ -985,9 +1362,13 @@ type ChannelMessageViewsRequest struct {
Date int
}
// ChannelMessageViewsResult returns current view counters by visible message id.
// ChannelMessageViewsResult returns current view counters and lightweight reply
// counters by visible message id.
type ChannelMessageViewsResult struct {
Views map[int]int
Channel Channel
Views map[int]int
Replies map[int]*ChannelMessageReplies
Peers []Peer
}
// ReadChannelMessageContentsRequest marks channel/supergroup content-read hints for one viewer.
@ -1002,6 +1383,10 @@ type ReadChannelMessageContentsResult struct {
Channel Channel
Messages []ChannelMessage
ClearedUnreadReactionMessageIDs []int
// ClearedUnreadMentionMessageIDs 是本次内容已读翻转为已读的 mention
// 消息;客户端视口看到 @ 消息即发 readMessageContents服务端必须
// 同步清除否则角标在下一次 getDialogs 复活。
ClearedUnreadMentionMessageIDs []int
}
// GetChannelMessageAuthorRequest resolves the original user author of one channel message.
@ -1018,6 +1403,13 @@ type GetChannelMessageAuthorResult struct {
SenderUserID int64
}
// ChannelPaidMessagesPriceResult describes a paid/direct-message price setting change.
type ChannelPaidMessagesPriceResult struct {
Channel Channel
ServiceMessage *SendChannelMessageResult
ServiceMessages []SendChannelMessageResult
}
// SendChannelMessageResult describes a channel message send.
type SendChannelMessageResult struct {
Channel Channel
@ -1026,6 +1418,13 @@ type SendChannelMessageResult struct {
Recipients []int64
Duplicate bool
Discussion *SendChannelDiscussionResult
// MentionUserIDs 是本条消息解析出的被 @ 成员;在线 fanout 按它为
// 每个接收者投影 message.mentioned/media_unread。
MentionUserIDs []int64
// SkipDeliveryUserIDs 是本条消息按 bot privacy 规则被排除投递的成员(隐私 bot 对
// 命令/@/回复以外的消息不可见。Recipients 已扣除它们;在线 fanout 还须据此跳过这些
// 成员的直接推送,否则在线 bot 仍能实时收到群里全部消息(持久 history/difference 已隐藏)。
SkipDeliveryUserIDs []int64
}
// SendChannelDiscussionResult describes the discussion megagroup root created for a broadcast post.
@ -1034,6 +1433,9 @@ type SendChannelDiscussionResult struct {
Message ChannelMessage
Event ChannelUpdateEvent
Recipients []int64
// MentionUserIDs 透传 post 的 @ 目标:讨论组联动消息的实时推送也要
// 按接收者投影 mentioned/media_unread。
MentionUserIDs []int64
}
// CreateChannelForumTopicRequest creates one forum topic root service message.
@ -1121,21 +1523,44 @@ type DeleteChannelForumTopicHistoryRequest struct {
}
// EditChannelMessageRequest edits one text message in a channel/supergroup.
// Media 非 nil 时整体替换消息媒体快照(当前唯一调用方是 live location 续报/停止)。
type EditChannelMessageRequest struct {
UserID int64
ChannelID int64
ID int
Message string
Entities []MessageEntity
EditDate int
Media *MessageMedia
// SetReplyMarkup 置位时替换 reply_markupReplyMarkup 为 nil/空 = 清空键盘)。
SetReplyMarkup bool
ReplyMarkup *MessageReplyMarkup
// ViaBotEditBotID 非零时要求目标消息 via_bot_id 匹配对应 bot。
ViaBotEditBotID int64
// AllowTodoParticipantMutation 允许非作者普通成员在 checklist 的
// others_can_* 授权下仅替换 todo media 快照;不放开普通文本/媒体编辑。
AllowTodoParticipantMutation bool
// TodoServiceAction 非 nil 时,编辑同事务追加一条 reply 到原 checklist
// 的 todo 服务消息,并生成独立 channel pts。
TodoServiceAction *ChannelMessageAction
// MentionUserIDs 是编辑后文本解析出的 @ 目标:新增者补未读提及、
// 被移除者清除reply 隐式提及不受影响)。
MentionUserIDs []int64
EditDate int
// WebPageResolve 置位时为服务端内部「频道链接预览就地替换」:仅换 media不碰 body/
// entities/edit_date生成 ChannelUpdateWebPage 而非 edit_channel_message。幂等守卫
// 仅当目标当前 media 仍是 ID==ExpectedWebPageID 的 pending 链接预览才替换。
WebPageResolve bool
ExpectedWebPageID int64
}
// EditChannelMessageResult describes one channel edit update.
type EditChannelMessageResult struct {
Channel Channel
Message ChannelMessage
Event ChannelUpdateEvent
Recipients []int64
Channel Channel
Message ChannelMessage
Event ChannelUpdateEvent
ServiceMessage ChannelMessage
ServiceEvent ChannelUpdateEvent
Recipients []int64
}
// DeleteChannelMessagesRequest deletes a bounded set of channel/supergroup messages.
@ -1152,6 +1577,16 @@ type DeleteChannelMessagesResult struct {
Event ChannelUpdateEvent
DeletedIDs []int
Recipients []int64
// DiscussionDeletes 是被删 broadcast post 在 linked 讨论组里的转发根
// 级联删除(官方删 post 同时删除讨论根)。
DiscussionDeletes []ChannelCascadeDelete
}
// ChannelCascadeDelete 描述一次跨频道的级联删除(如讨论根随 post 删除)。
type ChannelCascadeDelete struct {
Channel Channel
Event ChannelUpdateEvent
Recipients []int64
}
// DeleteChannelHistoryRequest clears or deletes a bounded channel/supergroup history page.
@ -1192,12 +1627,23 @@ type UpdateChannelPinnedMessageRequest struct {
}
// UpdateChannelPinnedMessageResult describes one pinned-message update.
// 多置顶模型pin/unpin 互不影响其它置顶Event.MessageIDs 仅含本次操作的
// 消息unpinAll 时为本批全部清除的 id
type UpdateChannelPinnedMessageResult struct {
Channel Channel
Event ChannelUpdateEvent
Channel Channel
Event ChannelUpdateEvent
// UnpinEvent 携带单置顶替换时旧置顶的 unpin 事件(无则 Pts=0
UnpinEvent ChannelUpdateEvent
Recipients []int64
}
// UnpinAllChannelMessagesRequest clears every pinned message in one channel.
type UnpinAllChannelMessagesRequest struct {
UserID int64
ChannelID int64
Date int
}
// ChannelInvite is an exported invite link without TL dependencies.
type ChannelInvite struct {
ChannelID int64
@ -1379,6 +1825,7 @@ type ChannelHistoryFilter struct {
Query string
SenderUserID int64
PinnedOnly bool
MusicOnly bool
OffsetID int
OffsetDate int
AddOffset int
@ -1406,6 +1853,7 @@ type ChannelGlobalSearchRequest struct {
Query string
BroadcastsOnly bool
GroupsOnly bool
MusicOnly bool
HasFolderID bool
FolderID int
OffsetRate int
@ -1523,8 +1971,32 @@ type ReadChannelHistoryResult struct {
StillUnreadCount int
Changed bool
Pts int
Dialog ChannelDialog
OutboxUpdates []ChannelReadOutboxUpdate
// Forum 标记该频道是否为话题群。RPC 层据此在频道级 readHistory 后顺带推进
// General(topic 1) 的话题级已读水位General 消息即频道根历史,被频道级已读覆盖)。
Forum bool
Dialog ChannelDialog
OutboxUpdates []ChannelReadOutboxUpdate
}
// ReadChannelTopicHistoryRequest 推进 forum 单个话题的 per-viewer 已读水位messages.readDiscussion
// TopicID = 话题根消息 idGeneral=1。不碰频道级 channel_members.read_inbox_max_id。
type ReadChannelTopicHistoryRequest struct {
UserID int64
ChannelID int64
TopicID int
MaxID int
Date int
}
// ReadChannelTopicHistoryResult 描述一次 per-topic 已读推进结果。OutboxUpdates 每条 UserID 是
// 该话题内待收已读回执的发送者MaxID 为其消息被读到的水位;话题 id 即 Result.TopicID。
type ReadChannelTopicHistoryResult struct {
Channel Channel
TopicID int
MaxID int
Changed bool
Pts int
OutboxUpdates []ChannelReadOutboxUpdate
}
// ChannelReadOutboxUpdate advances one sender's channel outbox read watermark.

View file

@ -33,6 +33,10 @@ var (
ErrUsersTooMuch = errors.New("users too much")
ErrUserAlreadyParticipant = errors.New("user already participant")
ErrUserKicked = errors.New("user kicked")
ErrUserNotParticipant = errors.New("user not participant")
ErrBotGroupsBlocked = errors.New("bot groups blocked")
ErrReactionInvalid = errors.New("reaction invalid")
ErrReactionsTooMany = errors.New("reactions too many")
)
// SlowModeWaitError carries the remaining wait seconds for a channel slow mode violation.

View file

@ -12,6 +12,7 @@ type Contact struct {
Note string
NoteEntities []MessageEntity
Mutual bool
CloseFriend bool
}
// ContactList 是通讯录查询结果。
@ -20,6 +21,12 @@ type ContactList struct {
Hash int64
}
// CloseFriendsEditResult describes a full close-friends list replacement.
type CloseFriendsEditResult struct {
AddedUserIDs []int64
RemovedUserIDs []int64
}
// BlockedContact is one owner-visible blocked peer.
type BlockedContact struct {
User User
@ -74,4 +81,8 @@ type PeerSettings struct {
ShareContact bool
NeedContactsException bool
HiddenPeerSettingsBar bool
BusinessBotID int64
BusinessBotManageURL string
BusinessBotPaused bool
BusinessBotCanReply bool
}

View file

@ -6,6 +6,9 @@ type PeerType string
const (
PeerTypeUser PeerType = "user"
PeerTypeChannel PeerType = "channel"
// PeerTypeFolder 仅用于 dialog 置顶事件中表达 dialogPeerFolder
// archive folder 行本身被置顶/取消置顶ID 为 folder_id。
PeerTypeFolder PeerType = "folder"
)
const (
@ -23,14 +26,44 @@ const (
MaxDialogFolderTitleRunes = 64
// MaxDialogDraftsPerUser bounds messages.getAllDrafts / clearAllDrafts work.
MaxDialogDraftsPerUser = 1000
// MaxPinnedDialogsMainFolder/MaxPinnedDialogsArchiveFolder 对齐 TDesktop
// 内置默认限额appConfig dialogs_pinned_limit_default=5 /
// dialogs_folder_pinned_limit_default=100超限返回
// PINNED_DIALOGS_TOO_MUCH服务端兜底防止客户端截断后两端漂移。
MaxPinnedDialogsMainFolder = 5
MaxPinnedDialogsArchiveFolder = 100
// premium 档对齐 appConfig dialogs_pinned_limit_premium=10 /
// dialogs_folder_pinned_limit_premium=200。服务端档位必须 ≥ 客户端宣告
// 档位,否则 premium 用户 pin 第 6 个会话会被拒、UI 回滚。
MaxPinnedDialogsMainFolderPremium = 10
MaxPinnedDialogsArchiveFolderPremium = 200
)
// PinnedDialogsLimit 返回 folder 维度的置顶上限档位premium 双档)。
func PinnedDialogsLimit(folderID int, premium bool) int {
if folderID == DialogArchiveFolderID {
if premium {
return MaxPinnedDialogsArchiveFolderPremium
}
return MaxPinnedDialogsArchiveFolder
}
if premium {
return MaxPinnedDialogsMainFolderPremium
}
return MaxPinnedDialogsMainFolder
}
// Peer 是业务层 peer 值对象,不依赖 TL 类型。
type Peer struct {
Type PeerType
ID int64
}
// IsSelfUser reports whether this peer is the current user's own user peer.
func (p Peer) IsSelfUser(userID int64) bool {
return userID != 0 && p.Type == PeerTypeUser && p.ID == userID
}
// Dialog 是账号的一条会话摘要。
type Dialog struct {
Peer Peer
@ -43,12 +76,21 @@ type Dialog struct {
UnreadCount int
UnreadMentions int
UnreadReactions int
TTLPeriod int
ThemeEmoticon string
HasScheduled bool
Pinned bool
PinnedOrder int
UnreadMark bool
ViewForumAsMessages bool
PeerSettingsBarHidden bool
Draft *DialogDraft
// Pts 是 channel peer 当前 channel pts客户端用 dialog.pts 初始化本地
// channel 序列并决定 getChannelDifference 起点channel dialog 必填。
Pts int
Draft *DialogDraft
// NotifySettings 是该会话的 per-peer 通知设置nil=未配置,投影时回落默认)。
// 由读路径批量装配(非 dialog store 持久字段),见 rpc.withDialogNotifySettings。
NotifySettings *PeerNotifySettings
}
// DialogDraftWebPage stores a draft link preview without depending on TL input media types.
@ -85,6 +127,21 @@ func (d DialogDraft) Empty() bool {
d.Effect == 0
}
// DialogArchiveSummary 聚合归档folder_id=1状态供主列表 getDialogs
// 第一页输出 dialogFolder 条目TDesktop 依赖该条目发现 archive 的存在
// (新登录设备没有任何 update 可重放,缺少它归档会话将彻底不可见)。
type DialogArchiveSummary struct {
// TopPeer/TopMessage 是归档内最新会话及其 top 消息dialogFolder.peer/top_message
TopPeer Peer
TopMessage int
// UnreadPeersCount 是归档内有未读(或手动标记未读)的会话数;
// UnreadMessagesCount 是归档未读消息总数。当前未接 per-peer mute
// 状态,全部计入 unmuted 桶。
UnreadPeersCount int
UnreadMessagesCount int
Pinned bool
}
// DialogList 是 dialogs 查询结果。
type DialogList struct {
Dialogs []Dialog
@ -95,6 +152,16 @@ type DialogList struct {
State UpdateState
Hash int64
Count int
// ArchiveSummary 非 nil 时,主列表响应头部追加 dialogFolder 条目。
ArchiveSummary *DialogArchiveSummary
}
// DialogHashCheck reports whether a cached stable dialog list hash is known.
type DialogHashCheck struct {
Known bool
Matched bool
Hash int64
Count int
}
// DialogFilter 是会话列表查询条件。

View file

@ -0,0 +1,131 @@
package domain
import "errors"
// 群通话(超级群语音聊天)领域模型。信令真值在 GroupCallStorememory/postgres
// 双实现),版本协议要求 version 必须持久化:客户端忽略 version 小于本地缓存的
// updateGroupCallParticipants重启回卷会让整个房间静默失联。
// GroupCallState 是群通话状态。
type GroupCallState string
const (
GroupCallStateActive GroupCallState = "active"
GroupCallStateDiscarded GroupCallState = "discarded"
)
// 群通话业务错误rpc 层映射为 GROUPCALL_* RPC_ERROR。
var (
ErrGroupCallInvalid = errors.New("group call invalid")
ErrGroupCallDiscarded = errors.New("group call already discarded")
ErrGroupCallAlreadyStarted = errors.New("group call already started")
ErrGroupCallSSRCDuplicate = errors.New("group call ssrc duplicate")
ErrGroupCallNotJoined = errors.New("group call participant missing")
)
// GroupCall 是一场群通话的权威态。
type GroupCall struct {
ID int64
AccessHash int64
ChannelID int64
CreatorUserID int64
State GroupCallState
Title string
JoinMuted bool
// Version 是参与者协议版本:所有参与者变更事务内 +1单调且持久。
Version int
ParticipantsCount int
CreatedAt int
DiscardedAt int
Duration int
// StartedMsgID 是 messageActionGroupCall(started) 的频道消息 iddiscard 时
// 客户端用它定位起始服务消息,当前仅记录)。
StartedMsgID int
}
// Active 报告通话是否仍在进行。
func (c GroupCall) Active() bool {
return c.State == GroupCallStateActive
}
// GroupCallParticipant 是房间内一名参与者。
type GroupCallParticipant struct {
CallID int64
UserID int64
// SSRC 是客户端在 join JSON 里自报的 audio ssrcuint32 值域,存 int64 防符号坑)。
SSRC int64
JoinDate int
ActiveDate int
Muted bool
// MutedByAdmin=true 时 can_self_unmute=false管理员禁言/默认静音策略)。
MutedByAdmin bool
// VolumeByAdmin 是管理员设定的全局音量1..200000=未设。
VolumeByAdmin int
// RaiseHandRating 非零表示举手中,值单调递增用于排序。
RaiseHandRating int64
// VideoJSON / PresentationJSON 是 tg.GroupCallParticipantVideo 的原始 JSON
// 快照M3/M4 启用M0/M1 仅透明保存 self-edit不转发
VideoJSON []byte
PresentationJSON []byte
Left bool
// LastCheckDate 是 checkGroupCall 保活水位。注意:客户端只在 Connecting 态
// 发 checkGroupCall媒体连通后心跳停止掉线判定必须与 SFU 媒体面活性
// 取双过期(见 sweeper绝不能单凭此字段。
LastCheckDate int
}
// CreateGroupCallRequest 创建群通话。
type CreateGroupCallRequest struct {
ChannelID int64
CreatorUserID int64
RandomID int64
Title string
Now int
}
// JoinGroupCallRequest 加入/重进群通话rejoin 同主键换新 ssrc
type JoinGroupCallRequest struct {
CallID int64
UserID int64
SSRC int64
Muted bool
IsAdmin bool
// VideoJSON 是本次 join 铸造的视频内部状态endpoint+源组+activerejoin
// 整体替换并**清空旧 PresentationJSON**(客户端主连接 rejoin 后会重发
// joinGroupCallPresentation旧屏幕登记必须作废
VideoJSON []byte
Now int
}
// GroupCallMutation 是一次参与者维度变更的结果:变更后的 call 行(含新 version
// 与受影响的参与者行(推送 updateGroupCallParticipants 用)。
type GroupCallMutation struct {
Call GroupCall
Participant GroupCallParticipant
}
// GroupCallParticipantUpdate 是 editGroupCallParticipant 的字段级更新nil=不动)。
type GroupCallParticipantUpdate struct {
Muted *bool
MutedByAdmin *bool
VolumeByAdmin *int
RaiseHandRating *int64
VideoJSON *[]byte
PresentationJSON *[]byte
Now int
}
// GroupCallParticipantOverride 是 per-viewer 视图覆盖setter→target
// 本地静音/本地音量,仅 setter 自己可见,不进全房间 version。
type GroupCallParticipantOverride struct {
MutedByYou bool
Volume int // 0=未设
}
// GroupCallParticipantPage 是 getGroupParticipants 的分页结果。
type GroupCallParticipantPage struct {
Count int
Participants []GroupCallParticipant
NextOffset string
Version int
}

View file

@ -29,14 +29,40 @@ type FileBlob struct {
MimeType string `json:"mime_type,omitempty"`
}
// UploadPart 是 upload.saveFilePart/saveBigFilePart 累积的一个分片(落 PG组装后清理
// UploadPart 是 upload.saveFilePart/saveBigFilePart 累积的一个分片元数据。
// 分片字节不进 PGPG 只记录 object_key/size/hash组装后清理 metadata 与临时对象。
type UploadPart struct {
OwnerUserID int64
FileID int64
Part int
TotalParts int // big file 已知总数small file 为 0
Big bool
Bytes []byte
Backend MediaBackend
ObjectKey string
Size int64
SHA256 []byte
}
// UploadPartUsage 描述某用户当前尚未组装的上传分片占用。
type UploadPartUsage struct {
Bytes int64
Parts int
Files int
}
// UploadPartSlot 描述某个 (owner,file_id,part) 槽位的当前状态,用于判断重试覆盖的配额增量。
type UploadPartSlot struct {
ExistingBytes int64
ObjectKey string
FileParts int
Found bool
}
// UploadPartQuota 限制某用户 in-flight 上传分片占用;字段 <=0 表示该维度不限制。
type UploadPartQuota struct {
MaxBytes int64
MaxParts int
MaxFiles int
}
// UploadedFileRef 引用一个客户端已通过 upload.saveFilePart(Big) 上传完毕的文件。
@ -87,22 +113,36 @@ const (
PhotoSizeKindPath PhotoSizeKind = "path"
// PhotoSizeKindProgressive → photoSizeProgressive渐进式 jpeg 多段大小)。
PhotoSizeKindProgressive PhotoSizeKind = "progressive"
// PhotoSizeKindVideo → photo.video_sizes 里的 videoSizeanimated profile video
PhotoSizeKindVideo PhotoSizeKind = "video"
// PhotoSizeKindVideoEmojiMarkup → photo.video_sizes 里的 videoSizeEmojiMarkup。
PhotoSizeKindVideoEmojiMarkup PhotoSizeKind = "video_emoji_markup"
// PhotoSizeKindVideoStickerMarkup → photo.video_sizes 里的 videoSizeStickerMarkup。
PhotoSizeKindVideoStickerMarkup PhotoSizeKind = "video_sticker_markup"
)
// 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
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
VideoStartTs float64 `json:"video_start_ts,omitempty"`
EmojiID int64 `json:"emoji_id,omitempty"`
BackgroundColors []int `json:"background_colors,omitempty"`
StickerSetID int64 `json:"sticker_set_id,omitempty"`
StickerSetAccessHash int64 `json:"sticker_set_access_hash,omitempty"`
StickerSetShortName string `json:"sticker_set_short_name,omitempty"`
StickerSetSystemKey string `json:"sticker_set_system_key,omitempty"`
StickerID int64 `json:"sticker_id,omitempty"`
}
// Downloadable 表示该尺寸需要客户端通过 upload.getFile 拉取(而非内联字节)。
func (s PhotoSize) Downloadable() bool {
return s.Kind == PhotoSizeKindDefault || s.Kind == PhotoSizeKindProgressive
return s.Kind == PhotoSizeKindDefault || s.Kind == PhotoSizeKindProgressive || s.Kind == PhotoSizeKindVideo
}
// DocumentAttributeKind 标识 TL DocumentAttribute 变体。
@ -175,6 +215,39 @@ func (d Document) StickerSetRef() (id, accessHash int64, ok bool) {
return 0, 0, false
}
// IsMusic reports whether the document is a Telegram profile/shared-music song:
// documentAttributeAudio with voice=false. MIME type is intentionally not used
// as the source of truth because clients key their UI off the TL attribute.
func (d Document) IsMusic() bool {
for _, attr := range d.Attributes {
if attr.Kind == DocAttrAudio && !attr.Voice {
return true
}
}
return false
}
// IsSticker reports whether the document carries a sticker attribute
// (static / animated / video stickers all use documentAttributeSticker).
func (d Document) IsSticker() bool {
for _, attr := range d.Attributes {
if attr.Kind == DocAttrSticker {
return true
}
}
return false
}
// IsGif reports whether the document is a savable GIF (documentAttributeAnimated).
func (d Document) IsGif() bool {
for _, attr := range d.Attributes {
if attr.Kind == DocAttrAnimated {
return true
}
}
return false
}
// Photo 是已存储的 Telegram 照片(头像或图片消息)。
type Photo struct {
ID int64 `json:"id"`
@ -193,19 +266,261 @@ const (
MessageMediaKindNone MessageMediaKind = ""
MessageMediaKindPhoto MessageMediaKind = "photo"
MessageMediaKindDocument MessageMediaKind = "document"
MessageMediaKindContact MessageMediaKind = "contact"
MessageMediaKindService MessageMediaKind = "service"
MessageMediaKindGeo MessageMediaKind = "geo"
MessageMediaKindVenue MessageMediaKind = "venue"
MessageMediaKindDice MessageMediaKind = "dice"
MessageMediaKindPoll MessageMediaKind = "poll"
MessageMediaKindGeoLive MessageMediaKind = "geo_live"
MessageMediaKindTodo MessageMediaKind = "todo"
MessageMediaKindStory MessageMediaKind = "story"
MessageMediaKindWebPage MessageMediaKind = "web_page"
)
// MessageTodoItem 是清单中的一项id 为列表内唯一正整数,客户端分配)。
type MessageTodoItem struct {
ID int `json:"id"`
Title string `json:"title"`
Entities []MessageEntity `json:"entities,omitempty"`
}
// MessageTodoCompletion 记录某项被谁在何时勾选完成。
type MessageTodoCompletion struct {
ID int `json:"id"`
CompletedBy int64 `json:"completed_by"`
Date int `json:"date"`
}
// MessageTodo 是待办清单messageMediaToDo。append/toggle 经 editMessage 媒体替换链路
// 整体更新快照(与 live location 同模式)。
type MessageTodo struct {
OthersCanAppend bool `json:"others_can_append,omitempty"`
OthersCanComplete bool `json:"others_can_complete,omitempty"`
Title string `json:"title"`
TitleEntities []MessageEntity `json:"title_entities,omitempty"`
Items []MessageTodoItem `json:"items"`
Completions []MessageTodoCompletion `json:"completions,omitempty"`
}
// MessageGeoPoint 是消息中携带的地理坐标点messageMediaGeo/Venue 共用)。
// AccessHash 在发送时随机生成,客户端会原样带回 upload.getWebFile 地图缩略请求。
type MessageGeoPoint struct {
Lat float64 `json:"lat"`
Long float64 `json:"long"`
AccessHash int64 `json:"access_hash,omitempty"`
AccuracyRadius int `json:"accuracy_radius,omitempty"`
}
// MessageVenue 是带场所信息的位置messageMediaVenue
type MessageVenue struct {
Geo MessageGeoPoint `json:"geo"`
Title string `json:"title"`
Address string `json:"address,omitempty"`
Provider string `json:"provider,omitempty"`
VenueID string `json:"venue_id,omitempty"`
VenueType string `json:"venue_type,omitempty"`
}
// MessageDice 是互动 emojimessageMediaDice。Value 在发送落库时由服务端
// 一次定值,此后对所有 viewer 与转发快照保持不变。
type MessageDice struct {
Emoticon string `json:"emoticon"`
Value int `json:"value"`
}
// MessageGeoLive 是实时位置messageMediaGeoLive。客户端按 message.date + Period
// 自行计算过期;停止共享 = editMessage 把 Period 改为已逝时长(立即过期),服务端无后台任务。
type MessageGeoLive struct {
Geo MessageGeoPoint `json:"geo"`
Heading int `json:"heading,omitempty"`
Period int `json:"period"`
ProximityNotificationRadius int `json:"proximity_notification_radius,omitempty"`
}
// MessageStory is a story shared as messageMediaStory. Peer+ID are the stable
// reference; Story is the viewer-visible snapshot captured when the message was
// sent so history/difference can replay without re-resolving the source story.
type MessageStory struct {
Peer Peer `json:"peer"`
ID int `json:"id"`
ViaMention bool `json:"via_mention,omitempty"`
Story *Story `json:"story,omitempty"`
}
// MessageWebPageState 区分链接预览的三种 TL 形态pendingwebPagePending
// 已知链接但预览尚未异步抓取完成、donewebPage已解析的预览卡片
// emptywebPageEmpty已解析但该链接无可展示内容
type MessageWebPageState string
const (
MessageWebPageStatePending MessageWebPageState = "pending"
MessageWebPageStateDone MessageWebPageState = "done"
MessageWebPageStateEmpty MessageWebPageState = "empty"
)
// MessageWebPage 是以 messageMediaWebPage 形式挂载的链接预览。URL 是稳定引用,
// 其余字段是服务端在解析时捕获的快照,使 history/difference 无需重新抓取即可回放
// (与 MessageStory 同模式。State 决定投影为 webPagePending / webPage / webPageEmpty。
//
// ForceLargeMedia/ForceSmallMedia/Manual/Safe 是 messageMediaWebPage 外层 wrapper
// 的标志位HasLargeMedia 是 webPage 本体的标志位。ID 必须在 pending→done 切换前后
// 保持稳定,客户端按 webPage id 关联占位与解析结果。
type MessageWebPage struct {
State MessageWebPageState `json:"state"`
ID int64 `json:"id"`
URL string `json:"url"`
DisplayURL string `json:"display_url,omitempty"`
Hash int `json:"hash,omitempty"`
Date int `json:"date,omitempty"`
Type string `json:"type,omitempty"`
SiteName string `json:"site_name,omitempty"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Author string `json:"author,omitempty"`
Photo *Photo `json:"photo,omitempty"`
ForceLargeMedia bool `json:"force_large_media,omitempty"`
ForceSmallMedia bool `json:"force_small_media,omitempty"`
Manual bool `json:"manual,omitempty"`
Safe bool `json:"safe,omitempty"`
HasLargeMedia bool `json:"has_large_media,omitempty"`
}
// MessageServiceActionKind 标识私聊服务消息动作。
type MessageServiceActionKind string
const (
MessageServiceActionSuggestProfilePhoto MessageServiceActionKind = "suggest_profile_photo"
// MessageServiceActionPinMessage 映射 messageActionPinMessage
// pm_oneside 私聊置顶生成的服务消息,被置顶消息经 reply_to 指向。
MessageServiceActionPinMessage MessageServiceActionKind = "pin_message"
// MessageServiceActionPhoneCall 映射 messageActionPhoneCall私聊通话
// 结束(含 missed 超时后落历史的通话条目sender 恒为主叫。
MessageServiceActionPhoneCall MessageServiceActionKind = "phone_call"
// MessageServiceActionBotAllowed 映射 messageActionBotAllowed用户授权
// bot 后在 bot 私聊中留下的服务消息。
MessageServiceActionBotAllowed MessageServiceActionKind = "bot_allowed"
// MessageServiceActionWebViewDataSent 映射 messageActionWebViewDataSent*
//simple webview 把 data 回传给 bot 后在私聊中留下的服务消息。
MessageServiceActionWebViewDataSent MessageServiceActionKind = "web_view_data_sent"
// MessageServiceActionRequestedPeer 映射 messageActionRequestedPeer*:用户
// 响应 bot 的 request-peer 按钮后,把所选 peer 作为可恢复服务消息发给 bot。
MessageServiceActionRequestedPeer MessageServiceActionKind = "requested_peer"
// MessageServiceActionSetChatTheme 映射 messageActionSetChatTheme
// 私聊双方共享的 chat theme token 变更。
MessageServiceActionSetChatTheme MessageServiceActionKind = "set_chat_theme"
// MessageServiceActionStarGift 映射 messageActionStarGift收到一份 Star 礼物。
// 礼物快照(贴纸/星价)内嵌在 action 里,收礼人无需额外拉取即可渲染气泡。
MessageServiceActionStarGift MessageServiceActionKind = "star_gift"
)
// MessagePhoneCallAction 是 messageActionPhoneCall 的协议中立载荷。
// Reason 取值同 PhoneCallDiscardReasonDuration 仅通话真正建立后非零。
type MessagePhoneCallAction struct {
CallID int64 `json:"call_id"`
Reason string `json:"reason,omitempty"`
Duration int `json:"duration,omitempty"`
Video bool `json:"video,omitempty"`
}
// MessageBotAllowedAction 是 messageActionBotAllowed 的协议中立载荷。
type MessageBotAllowedAction struct {
AttachMenu bool `json:"attach_menu,omitempty"`
FromRequest bool `json:"from_request,omitempty"`
Domain string `json:"domain,omitempty"`
}
const (
MaxWebViewDataButtonTextLen = MaxBotMenuButtonTextLen
MaxWebViewDataPayloadLen = MaxMessageTextLength
)
// MessageWebViewDataAction 是 messageActionWebViewDataSent* 的协议中立载荷。
type MessageWebViewDataAction struct {
ButtonText string `json:"button_text"`
Data string `json:"data,omitempty"`
}
// MessageRequestedPeerAction 是 messageActionRequestedPeer* 的协议中立载荷。
type MessageRequestedPeerAction struct {
ButtonID int `json:"button_id"`
Peers []Peer `json:"peers"`
}
// MessageServiceAction 是私聊服务消息动作的协议中立表示。
type MessageServiceAction struct {
Kind MessageServiceActionKind `json:"kind"`
Photo *Photo `json:"photo,omitempty"`
Call *MessagePhoneCallAction `json:"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"`
StarGift *MessageStarGiftAction `json:"star_gift,omitempty"`
}
// MessageStarGiftAction 是 messageActionStarGift 的协议中立载荷:内嵌礼物快照(贴纸/星价)
// 使收礼人无需额外拉取即可渲染。PeerUserID/PeerChannelID 为收礼方NameHidden 时下发不暴露 from。
type MessageStarGiftAction struct {
GiftID int64 `json:"gift_id"`
Stars int64 `json:"stars"`
ConvertStars int64 `json:"convert_stars,omitempty"`
Title string `json:"title,omitempty"`
Sticker *Document `json:"sticker,omitempty"`
Message string `json:"message,omitempty"`
FromUserID int64 `json:"from_user_id,omitempty"`
PeerUserID int64 `json:"peer_user_id,omitempty"`
PeerChannelID int64 `json:"peer_channel_id,omitempty"`
SavedID int64 `json:"saved_id,omitempty"`
NameHidden bool `json:"name_hidden,omitempty"`
Saved bool `json:"saved,omitempty"`
Converted bool `json:"converted,omitempty"`
}
// 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"`
Kind MessageMediaKind `json:"kind"`
Photo *Photo `json:"photo,omitempty"`
Document *Document `json:"document,omitempty"`
Contact *MessageContact `json:"contact,omitempty"`
ServiceAction *MessageServiceAction `json:"service_action,omitempty"`
Geo *MessageGeoPoint `json:"geo,omitempty"`
Venue *MessageVenue `json:"venue,omitempty"`
Dice *MessageDice `json:"dice,omitempty"`
Poll *MessagePoll `json:"poll,omitempty"`
GeoLive *MessageGeoLive `json:"geo_live,omitempty"`
Todo *MessageTodo `json:"todo,omitempty"`
Story *MessageStory `json:"story,omitempty"`
WebPage *MessageWebPage `json:"web_page,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"`
// InvertMedia 映射 message.invert_media媒体典型为链接预览渲染在文本上方。
// 存于媒体快照而非消息行,避免新增消息表列;读时投影为 tg.Message.invert_media。
InvertMedia bool `json:"invert_media,omitempty"`
}
// IsMusic reports whether the message media is a music document rather than a
// voice note, video, or generic file.
func (m *MessageMedia) IsMusic() bool {
return m != nil &&
m.Kind == MessageMediaKindDocument &&
m.Document != nil &&
!m.Voice &&
m.Document.IsMusic()
}
// MessageContact is a shared contact card attached to a message.
type MessageContact struct {
PhoneNumber string `json:"phone_number"`
FirstName string `json:"first_name"`
LastName string `json:"last_name,omitempty"`
Vcard string `json:"vcard,omitempty"`
UserID int64 `json:"user_id,omitempty"`
}
// IsZero 表示无媒体(用于落库时跳过空快照、转换时回退 MessageMediaEmpty
@ -213,12 +528,27 @@ func (m *MessageMedia) IsZero() bool {
return m == nil || m.Kind == MessageMediaKindNone
}
// HasUnreadPayload 表示该媒体是否参与 media_unread"未听")状态。
// 只有 voice/round 才有此语义:客户端只为它们渲染未听标记并上报
// readMessageContentsphoto/document 置位只会留下永不清除的脏状态。
func (m *MessageMedia) HasUnreadPayload() bool {
if m.IsZero() || m.Kind != MessageMediaKindDocument {
return false
}
return m.Voice || m.Round
}
// StickerPack 是 emoji→文档 id 的映射条目messages.stickerSet.packs
type StickerPack struct {
Emoticon string `json:"emoticon"`
DocumentIDs []int64 `json:"document_ids"`
}
// StickerSetSystemKeyEmojiDefaultStatuses 是 inputStickerSetEmojiDefaultStatuses
// 对应的系统集标识premium 用户 emoji status 选择器的"默认状态"主体集
// messages.getStickerSet 与 account.getDefaultEmojiStatuses 共用)。
const StickerSetSystemKeyEmojiDefaultStatuses = "emoji_default_statuses"
// StickerSetKind 区分贴纸集用途(影响 getAllStickers / getEmojiStickers 归类)。
type StickerSetKind string
@ -273,6 +603,7 @@ type ProfilePhotoRef struct {
DCID int
Stripped []byte // photoStrippedSize 内联缩略图,可空
Personal bool // true 表示 viewer 私有联系人头像
HasVideo bool // true 表示 photo.video_sizes 非空,需输出 userProfilePhoto.has_video
}
// StrippedFromSizes 从照片尺寸列表里取出 stripped 缩略图字节(用于 UserProfilePhoto/ChatPhoto 占位)。
@ -285,6 +616,17 @@ func StrippedFromSizes(sizes []PhotoSize) []byte {
return nil
}
// PhotoHasVideo 从照片尺寸快照推导 UserProfilePhoto.has_video。
func PhotoHasVideo(sizes []PhotoSize) bool {
for _, s := range sizes {
switch s.Kind {
case PhotoSizeKindVideo, PhotoSizeKindVideoEmojiMarkup, PhotoSizeKindVideoStickerMarkup:
return true
}
}
return false
}
// StickerSetRefKind 标识 InputStickerSet 的解析方式。
type StickerSetRefKind string
@ -319,6 +661,24 @@ type AvailableReaction struct {
Order int `json:"order,omitempty"`
}
// AvailableEffect 是一条消息发送特效messages.getAvailableEffects。引用三个文档:
// 选择器静态图标 / 气泡上的特效贴纸 / 全屏特效动画(后两者可为 0)。属全局静态目录,
// 服务端 seed 后常驻内存。
type AvailableEffect struct {
ID int64 `json:"id"`
Emoticon string `json:"emoticon"`
StaticIconID int64 `json:"static_icon_id,omitempty"`
EffectStickerID int64 `json:"effect_sticker_id"`
EffectAnimationID int64 `json:"effect_animation_id,omitempty"`
PremiumRequired bool `json:"premium_required,omitempty"`
Order int `json:"order,omitempty"`
}
// DocumentIDs 收集该 effect 引用的全部文档 id去零去重
func (e AvailableEffect) DocumentIDs() []int64 {
return dedupNonZeroIDs(e.StaticIconID, e.EffectStickerID, e.EffectAnimationID)
}
// DocumentIDs 收集该 reaction 引用的全部文档 id去零去重便于批量加载
func (r AvailableReaction) DocumentIDs() []int64 {
raw := []int64{
@ -339,3 +699,20 @@ func (r AvailableReaction) DocumentIDs() []int64 {
}
return out
}
// dedupNonZeroIDs 返回去零去重后的 id 列表(保序)。
func dedupNonZeroIDs(ids ...int64) []int64 {
out := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}

View file

@ -0,0 +1,157 @@
package domain
// MediaCategory 是共享媒体标签页的基础类别。一条消息可同时属于多个类别
// (例如带链接的图片 = Photo + URL。落库为媒体索引表的 category 列SMALLINT
//
// 这里只存"基础类别";客户端的复合过滤器PhotoVideo / RoundVoice在查询期映射为
// 多个基础类别的并集(见 MediaCategoriesForFilter 的调用方)。
type MediaCategory int16
const (
MediaCategoryNone MediaCategory = 0
MediaCategoryPhoto MediaCategory = 1 // media.kind=photo
MediaCategoryVideo MediaCategory = 2 // document 含 video 属性且非 round
MediaCategoryGif MediaCategory = 3 // document 含 animated 属性
MediaCategoryFile MediaCategory = 4 // 通用文档(无 video/audio/animated/sticker 属性)
MediaCategoryMusic MediaCategory = 5 // document 含 audio 属性且 voice=false
MediaCategoryVoice MediaCategory = 6 // document 含 audio 属性且 voice=true
MediaCategoryRoundVideo MediaCategory = 7 // document 含 video 属性且 round_message=true视频消息
MediaCategoryURL MediaCategory = 8 // 文本含 url/text_url/email 实体或 messageMediaWebPage
MediaCategoryPoll MediaCategory = 9 // media.kind=poll
)
// MediaCategoryCounts 是共享媒体索引按基础类别聚合出的精确计数。
type MediaCategoryCounts map[MediaCategory]int
// CountAny 返回给定基础类别并集的计数。当前复合 filter 只由互斥类别组成
// (PhotoVideo=Photo+Video, RoundVoice=Voice+RoundVideo),因此可直接相加。
func (c MediaCategoryCounts) CountAny(categories []MediaCategory) int {
if len(categories) == 0 {
return 0
}
seen := make(map[MediaCategory]struct{}, len(categories))
total := 0
for _, category := range categories {
if category == MediaCategoryNone {
continue
}
if _, ok := seen[category]; ok {
continue
}
seen[category] = struct{}{}
total += c[category]
}
return total
}
// MediaSearchRequest 是共享媒体标签页分页查询的入参messages.search 媒体过滤分支)。
// 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
}
// ClassifyMediaCategories 返回一条消息所属的全部共享媒体类别(可为空:无媒体且无链接,
// 或贴纸/地理/联系人等不进任何媒体标签页的载荷)。是媒体索引的唯一分类真值,写路径据此
// 维护索引、迁移回填须与其语义一致。
func ClassifyMediaCategories(media *MessageMedia, entities []MessageEntity) []MediaCategory {
cats := make([]MediaCategory, 0, 2)
add := func(category MediaCategory) {
if category == MediaCategoryNone {
return
}
for _, existing := range cats {
if existing == category {
return
}
}
cats = append(cats, category)
}
if media != nil {
switch media.Kind {
case MessageMediaKindPhoto:
add(MediaCategoryPhoto)
case MessageMediaKindDocument:
if c, ok := classifyDocumentCategory(media.Document); ok {
add(c)
}
case MessageMediaKindPoll:
add(MediaCategoryPoll)
case MessageMediaKindWebPage:
add(MediaCategoryURL)
}
}
if hasURLEntity(entities) {
add(MediaCategoryURL)
}
return cats
}
// classifyDocumentCategory 按 TL DocumentAttribute 判定一个文档落入哪个媒体类别。
// 客户端的标签页 UI 以 TL 属性(而非 MIME为准故这里也只看属性。优先级
// sticker不入任何标签页> animated(GIF) > audio(music/voice) > video(video/round) > 通用文件。
func classifyDocumentCategory(doc *Document) (MediaCategory, bool) {
if doc == nil {
return MediaCategoryNone, false
}
var (
hasSticker bool
hasAnimated bool
hasAudio bool
audioVoice bool
hasVideo bool
videoRound bool
)
for _, a := range doc.Attributes {
switch a.Kind {
case DocAttrSticker, DocAttrCustomEmoji:
hasSticker = true
case DocAttrAnimated:
hasAnimated = true
case DocAttrAudio:
hasAudio = true
if a.Voice {
audioVoice = true
}
case DocAttrVideo:
hasVideo = true
if a.RoundMessage {
videoRound = true
}
}
}
switch {
case hasSticker:
return MediaCategoryNone, false // 贴纸/自定义 emoji 不出现在共享媒体
case hasAnimated:
return MediaCategoryGif, true
case hasAudio:
if audioVoice {
return MediaCategoryVoice, true
}
return MediaCategoryMusic, true
case hasVideo:
if videoRound {
return MediaCategoryRoundVideo, true
}
return MediaCategoryVideo, true
default:
return MediaCategoryFile, true
}
}
func hasURLEntity(entities []MessageEntity) bool {
for _, e := range entities {
if e.Type == MessageEntityURL || e.Type == MessageEntityTextURL || e.Type == MessageEntityEmail {
return true
}
}
return false
}

View file

@ -0,0 +1,51 @@
package domain
import (
"reflect"
"testing"
)
func TestClassifyMediaCategories(t *testing.T) {
doc := func(attrs ...DocumentAttribute) *MessageMedia {
return &MessageMedia{Kind: MessageMediaKindDocument, Document: &Document{Attributes: attrs}}
}
urlEnt := []MessageEntity{{Type: MessageEntityURL}}
textURLEnt := []MessageEntity{{Type: MessageEntityTextURL}}
emailEnt := []MessageEntity{{Type: MessageEntityEmail}}
cases := []struct {
name string
media *MessageMedia
entities []MessageEntity
want []MediaCategory
}{
{"nil media no entities", nil, nil, []MediaCategory{}},
{"photo", &MessageMedia{Kind: MessageMediaKindPhoto, Photo: &Photo{}}, nil, []MediaCategory{MediaCategoryPhoto}},
{"poll", &MessageMedia{Kind: MessageMediaKindPoll}, nil, []MediaCategory{MediaCategoryPoll}},
{"video", doc(DocumentAttribute{Kind: DocAttrVideo}), nil, []MediaCategory{MediaCategoryVideo}},
{"round video note", doc(DocumentAttribute{Kind: DocAttrVideo, RoundMessage: true}), nil, []MediaCategory{MediaCategoryRoundVideo}},
{"gif animation", doc(DocumentAttribute{Kind: DocAttrAnimated}), nil, []MediaCategory{MediaCategoryGif}},
{"music", doc(DocumentAttribute{Kind: DocAttrAudio}), nil, []MediaCategory{MediaCategoryMusic}},
{"voice", doc(DocumentAttribute{Kind: DocAttrAudio, Voice: true}), nil, []MediaCategory{MediaCategoryVoice}},
{"generic file", doc(DocumentAttribute{Kind: DocAttrFilename, FileName: "x.pdf"}), nil, []MediaCategory{MediaCategoryFile}},
{"sticker excluded", doc(DocumentAttribute{Kind: DocAttrSticker}), nil, []MediaCategory{}},
{"animated sticker excluded", doc(DocumentAttribute{Kind: DocAttrSticker}, DocumentAttribute{Kind: DocAttrAnimated}), nil, []MediaCategory{}},
{"photo with url", &MessageMedia{Kind: MessageMediaKindPhoto, Photo: &Photo{}}, urlEnt, []MediaCategory{MediaCategoryPhoto, MediaCategoryURL}},
{"text-only url", nil, urlEnt, []MediaCategory{MediaCategoryURL}},
{"text-only text_url", nil, textURLEnt, []MediaCategory{MediaCategoryURL}},
{"text-only email", nil, emailEnt, []MediaCategory{MediaCategoryURL}},
{"webpage media", &MessageMedia{Kind: MessageMediaKindWebPage, WebPage: &MessageWebPage{State: MessageWebPageStateDone, ID: 1, URL: "https://example.test"}}, nil, []MediaCategory{MediaCategoryURL}},
{"webpage media with url entity deduped", &MessageMedia{Kind: MessageMediaKindWebPage, WebPage: &MessageWebPage{State: MessageWebPageStateDone, ID: 1, URL: "https://example.test"}}, urlEnt, []MediaCategory{MediaCategoryURL}},
{"file with link", doc(DocumentAttribute{Kind: DocAttrFilename}), urlEnt, []MediaCategory{MediaCategoryFile, MediaCategoryURL}},
{"geo not indexed", &MessageMedia{Kind: MessageMediaKindGeo}, nil, []MediaCategory{}},
{"contact not indexed", &MessageMedia{Kind: MessageMediaKindContact}, nil, []MediaCategory{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := ClassifyMediaCategories(tc.media, tc.entities)
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("ClassifyMediaCategories = %v, want %v", got, tc.want)
}
})
}
}

View file

@ -2,15 +2,13 @@ package domain
import "errors"
// 媒体 / 文件相关业务错误。rpc 层据此映射为对应 rpc_error见 internal/rpc/errors.go
// 文件分片上传与媒体相关业务错误。rpc 层据此映射为对应 rpc_error见 internal/rpc/errors.go
// 文件分片part invalid/parts invalid/part too big、上传配额、照片与文档。
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")
ErrFilePartInvalid = errors.New("file part invalid")
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")
)

View file

@ -4,7 +4,25 @@ package domain
type MessageEntityType string
const (
MessageEntityBold MessageEntityType = "bold"
MessageEntityBold MessageEntityType = "bold"
MessageEntityItalic MessageEntityType = "italic"
MessageEntityUnderline MessageEntityType = "underline"
MessageEntityStrike MessageEntityType = "strike"
MessageEntityCode MessageEntityType = "code"
MessageEntityPre MessageEntityType = "pre"
MessageEntityTextURL MessageEntityType = "text_url"
MessageEntityMentionName MessageEntityType = "mention_name"
MessageEntitySpoiler MessageEntityType = "spoiler"
MessageEntityBlockquote MessageEntityType = "blockquote"
MessageEntityCustomEmoji MessageEntityType = "custom_emoji"
MessageEntityMention MessageEntityType = "mention"
MessageEntityHashtag MessageEntityType = "hashtag"
MessageEntityCashtag MessageEntityType = "cashtag"
MessageEntityBotCommand MessageEntityType = "bot_command"
MessageEntityURL MessageEntityType = "url"
MessageEntityEmail MessageEntityType = "email"
MessageEntityPhone MessageEntityType = "phone"
MessageEntityBankCard MessageEntityType = "bank_card"
)
const (
@ -55,7 +73,11 @@ func ValidateMessageReplyBounds(reply *MessageReply) error {
if reply.TopMessageID < 0 || reply.TopMessageID > MaxMessageBoxID {
return ErrReplyMessageIDInvalid
}
if reply.MessageID == 0 && reply.TopMessageID == 0 {
if reply.StoryID < 0 || reply.StoryID > MaxStoryID {
return ErrReplyMessageIDInvalid
}
// story 回复StoryID>0不携带 MessageID/TopMessageID普通回复至少有其一。
if reply.MessageID == 0 && reply.TopMessageID == 0 && reply.StoryID == 0 {
return ErrReplyMessageIDInvalid
}
if reply.QuoteOffset < 0 || reply.QuoteOffset > MaxMessageReplyQuoteOffset {
@ -69,6 +91,16 @@ type MessageEntity struct {
Type MessageEntityType
Offset int
Length int
// URL 仅 text_url 使用。
URL string
// UserID 仅 mention_name 使用。
UserID int64
// Language 仅 pre 使用。
Language string
// DocumentID 仅 custom_emoji 使用。
DocumentID int64
// Collapsed 仅 blockquote 使用。
Collapsed bool
}
// Message 是账号视角下的一条私聊消息。
@ -90,9 +122,56 @@ type Message struct {
Forward *MessageForward
Reactions *ChannelMessageReactions
Pts int
TTLPeriod int
ExpiresAt int
Media *MessageMedia
MediaUnread bool
ReactionUnread bool
ViaBotID int64
// GroupedID 是相册分组 id同一次 sendMultiMedia 的各条消息共享同一非零值,
// 客户端据此把它们渲染成一个相册组。非相册消息恒 0。双盒持同一值。
GroupedID int64
// Effect 是消息特效 idmessage.effectflags2.2?long私聊 1-1 专属动画特效
// (🎉/👍 等),发送方与接收方双盒持同一非零值并各自播放一次;非特效消息恒 0。
// 转发不携带特效(新消息恒 0。仅私聊群/频道不渲染。
Effect int64
// ReplyMarkup 是 bot 消息携带的 inline keyboard 快照P3。仅 bot 出站消息可
// 非空;普通用户消息恒 nil发送侧 is_bot 闸门)。双盒持同一快照(无 per-viewer 差异)。
ReplyMarkup *MessageReplyMarkup
// RichMessage 是 Layer 227 富文本消息richMessage快照可选普通消息恒 nil。
RichMessage *MessageRichMessage
// Pinned 是 owner 视角的置顶标志(官方私聊多置顶语义:双方各自
// 的 box 行独立持有,非 pm_oneside 操作两侧同步翻转)。
Pinned bool
// SavedPeer 是 Saved Messages 分会话分组键message.saved_peer_id
// 仅 self-chat box 行非零:直发笔记 = self转发进收藏夹 = 源会话 peer
// 存量回填兜底 hidden author 占位 user 2666000。非 self-chat 行恒零值。
SavedPeer Peer
}
// MessageRichMessage 是 Layer 227 富文本消息richMessage的协议中立快照一组 IV
// PageBlockBlocks+ 内嵌已解析的 Photos/Documents。
//
// Blocks 存 gotd TL 序列化后的 []tg.PageBlockClass 不透明字节——PageBlock 体系庞大且
// input(inputRichMessage.blocks) 与 output(richMessage.blocks) 同构、原样透传,故不在
// domain 逐类型建模rpc 层负责 tg.PageBlock 向量 ↔ bytes 的序列化domain 不依赖 tg
// 与 message media 同理Photos/Documents 存已解析快照(含 viewer 无关的 access_hash
// 投影复用 tgPhoto/tgDocument。Phase 1 仅支持 inputRichMessageblocks 形态),不解析
// HTML/Markdown 变体。
//
// 已知局限Blocks 是 gotd 线格式不透明字节,跨 gotd 版本PageBlock 构造器变更)可能
// 失效——富文本消息为全新实验特性、无存量数据Phase 1 接受该耦合。
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"`
}
// IsZero 表示无富文本载荷(落库时跳过空快照、投影时不下发 rich_message
func (m *MessageRichMessage) IsZero() bool {
return m == nil || (len(m.Blocks) == 0 && len(m.Photos) == 0 && len(m.Documents) == 0)
}
// MessageReply describes a message reply/thread header without depending on TL types.
@ -104,6 +183,9 @@ type MessageReply struct {
QuoteText string
QuoteEntities []MessageEntity
QuoteOffset int
// StoryID > 0 表示这是一条对 story 的回复评论MessageID 为 0Peer 为 story 作者,
// 投影为 messageReplyStoryHeader 而非普通 messageReplyHeader。
StoryID int
}
// MessageForward 描述一条转发消息的原始作者信息。
@ -126,17 +208,24 @@ type MessageList struct {
// MessageFilter 描述历史/搜索查询条件。
type MessageFilter struct {
HasPeer bool
Peer Peer
Query string
OffsetID int
OffsetDate int
AddOffset int
Limit int
MaxID int
MinID int
Hash int64
HasPeer bool
Peer Peer
Query string
OffsetID int
OffsetDate int
AddOffset int
Limit int
MaxID int
MinID int
Hash int64
// PinnedOnly 仅返回置顶消息messages.search filterPinned 与
// userFull.pinned_msg_id 的查询路径)。
PinnedOnly bool
MusicOnly bool
NeedTotalCount bool
// SavedPeer 非零时仅返回 self-chat 中该 saved 子会话的消息
// messages.getSavedHistoryPeer 必须同时是 self。
SavedPeer Peer
}
// SendPrivateTextRequest 是私聊文本/媒体发送命令。
@ -155,6 +244,19 @@ type SendPrivateTextRequest struct {
OriginAuthKeyID [8]byte
OriginSessionID int64
RecipientBlocked bool
TTLPeriod int
ViaBotID int64
// GroupedID 相册分组 idsendMultiMedia 同组共享非零值,非相册恒 0
GroupedID int64
// Effect 消息特效 id私聊专属0 表无特效;调用方已对 catalog 校验过合法性)。
Effect int64
// BusinessAutomationKind is internal app-layer metadata used to suppress
// recursive greeting/away automation for server-generated replies.
BusinessAutomationKind BusinessAutomationKind
// ReplyMarkup 是 bot 出站消息的 inline keyboard 快照P3普通用户发送恒 nil。
ReplyMarkup *MessageReplyMarkup
// RichMessage 是 Layer 227 富文本消息richMessage快照可选普通消息恒 nil。
RichMessage *MessageRichMessage
}
// SendPrivateTextResult 描述一次私聊文本发送的双端结果。
@ -166,6 +268,26 @@ type SendPrivateTextResult struct {
Duplicate bool
}
// SetPrivateChatThemeRequest changes the shared theme token for a private dialog.
type SetPrivateChatThemeRequest struct {
OwnerUserID int64
Peer Peer
Emoticon string
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
RecipientBlocked bool
}
// SetPrivateChatThemeResult describes the state change and optional service message.
type SetPrivateChatThemeResult struct {
OwnerUserID int64
Peer Peer
Emoticon string
Changed bool
Send SendPrivateTextResult
}
// SetPrivateMessageReactionsRequest replaces the current user's reactions for one private message.
type SetPrivateMessageReactionsRequest struct {
UserID int64
@ -175,6 +297,9 @@ type SetPrivateMessageReactionsRequest struct {
Big bool
AddToRecent bool
Date int
// ReactionsPerUserMax 是 viewer 的每用户上限档位premium 双档);
// 0 表示未填store 侧按默认档裁剪。
ReactionsPerUserMax int
}
// PrivateMessageReactionsRequest fetches reaction summaries for exact private message ids.
@ -205,6 +330,7 @@ type ForwardPrivateMessagesRequest struct {
OriginAuthKeyID [8]byte
OriginSessionID int64
RecipientBlocked bool
TTLPeriod int
}
// ForwardPrivateMessagesResult 描述一次私聊转发的 owner 维度结果。
@ -233,6 +359,7 @@ type ReadHistoryResult struct {
Peer Peer
MaxID int
StillUnreadCount int
ChannelPts int
Changed bool
InboxEvent UpdateEvent
OutboxChanged bool
@ -254,6 +381,10 @@ type ReadMessageContentsResult struct {
OwnerUserID int64
MessageIDs []int
Event UpdateEvent
// SenderEvents 是对端发送者的内容已读回执reader 听完 voice/round 后,
// 每个原 sender 收到一条 updateReadMessagesContentsmessages 用 sender
// 自己视角的 box id。
SenderEvents []UpdateEvent
}
// OutboxReadDateRequest 是 messages.getOutboxReadDate 查询。
@ -263,16 +394,35 @@ type OutboxReadDateRequest struct {
ID int
}
// EditMessageRequest 是账号视角下编辑一条已发送私聊文本消息的命令。
// EditMessageRequest 是账号视角下编辑一条已发送私聊消息的命令。
// Media 非 nil 时整体替换消息媒体快照(当前唯一调用方是 live location 续报/停止,
// rpc 层负责限定媒体种类nil 表示纯文本编辑。
type EditMessageRequest struct {
OwnerUserID int64
Peer Peer
ID int
Message string
Entities []MessageEntity
Media *MessageMedia
EditDate int
OriginAuthKeyID [8]byte
OriginSessionID int64
// SetReplyMarkup 置位时替换 reply_markupReplyMarkup 为 nil/空 = 清空键盘);
// 未置位则保留原 markup。仅 bot 编辑自己消息时由 RPC 层置位P3
SetReplyMarkup bool
ReplyMarkup *MessageReplyMarkup
// ViaBotEditBotID 非零时允许对应 bot 编辑经由它发送的 inline 私聊消息。
ViaBotEditBotID int64
// AllowTodoParticipantMutation 允许 checklist 参与者在 others_can_* 授权下通过
// edit 事件替换 todo 媒体快照。仅 RPC todo handler 设置store 仍会限制为
// todo->todo 且正文/entities/markup 不变,避免普通消息越权编辑。
AllowTodoParticipantMutation bool
// WebPageResolve 置位时为服务端内部「链接预览就地替换」:仅替换 media不碰 body/
// entities/edit_date故不标记「已编辑」生成 UpdateEventWebPage 而非 edit_message。
// 幂等守卫:仅当目标当前 media 仍是 ID==ExpectedWebPageID 的 pending 链接预览才替换,
// 否则返回 ErrMessageNotModified消息已删/已改/已解析)。
WebPageResolve bool
ExpectedWebPageID int64
}
// EditedMessageForUser 描述一次编辑对某个 owner 视角造成的影响。
@ -315,9 +465,13 @@ type DeleteMessagesRequest struct {
// DeleteHistoryRequest 是账号视角下清空某个 peer 历史的命令。
type DeleteHistoryRequest struct {
OwnerUserID int64
Peer Peer
MaxID int
OwnerUserID int64
Peer Peer
MaxID int
// MinDate/MaxDate 是"按日期删除"的闭区间unix 秒0 表示不限);
// 客户端先本地销毁再发请求,服务端静默忽略会造成删除复活。
MinDate int
MaxDate int
JustClear bool
Revoke bool
Date int
@ -358,3 +512,151 @@ func (r DeleteMessagesResult) Changed() bool {
}
return false
}
// PinPrivateMessageRequest 是 messages.updatePinnedMessage 的私聊命令
// (含 Saved Messages = self peer
type PinPrivateMessageRequest struct {
OwnerUserID int64
Peer Peer
MessageID int
Pinned bool
// PmOneside 仅置顶在本侧(官方私聊置顶框"同时为对方置顶"未勾选时),
// 不向对端翻转、不生成服务消息。unpin 无此语义,恒双侧清除。
PmOneside bool
Silent bool
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
}
// PinnedMessagesForUser 描述置顶状态变化对某个 owner 视角的影响。
type PinnedMessagesForUser struct {
UserID int64
Peer Peer
MessageIDs []int
Pinned bool
Event UpdateEvent
}
// MaxUnpinAllBatch 限制单次 unpinAllMessages 实际清除的置顶数量;
// 超出部分由客户端按 affectedHistory.Offset>0 续发清除,单条
// updatePinnedMessages 的 messages 向量随之有界。
const MaxUnpinAllBatch = 1000
// PinPrivateMessageResult 描述私聊置顶/取消置顶的 owner 维度结果。
// Updated 为空表示状态未变化(幂等 no-op不烧 pts
type PinPrivateMessageResult struct {
OwnerUserID int64
Updated []PinnedMessagesForUser
// Offset 非 0 表示 unpinAll 还有剩余批次待清。
Offset int
}
// Self 返回当前请求账号的置顶结果。
func (r PinPrivateMessageResult) Self() PinnedMessagesForUser {
for _, item := range r.Updated {
if item.UserID == r.OwnerUserID {
return item
}
}
return PinnedMessagesForUser{UserID: r.OwnerUserID}
}
// Changed 表示本次操作是否实际翻转了任何 owner 视角的置顶状态。
func (r PinPrivateMessageResult) Changed() bool {
for _, item := range r.Updated {
if len(item.MessageIDs) > 0 {
return true
}
}
return false
}
// UnpinAllPrivateMessagesRequest 是 messages.unpinAllMessages 的私聊命令。
type UnpinAllPrivateMessagesRequest struct {
OwnerUserID int64
Peer Peer
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
}
// ScheduledMessage is a pending account-owned message that has not entered
// normal peer history yet.
type ScheduledMessage struct {
OwnerUserID int64
ID int
Peer Peer
RandomID int64
Message string
Entities []MessageEntity
Media *MessageMedia
Silent bool
NoForwards bool
ReplyTo *MessageReply
Forward *MessageForward
SendAs *Peer
ScheduleDate int
ScheduleRepeatPeriod int
CreatedAt int
UpdatedAt int
State string
SentMessageID int
}
// ScheduledMessageList is a bounded scheduled queue page.
type ScheduledMessageList struct {
Messages []ScheduledMessage
Count int
Hash int64
}
// ScheduleMessageRequest creates one scheduled message.
type ScheduleMessageRequest struct {
OwnerUserID int64
Peer Peer
RandomID int64
Message string
Entities []MessageEntity
Media *MessageMedia
Silent bool
NoForwards bool
ReplyTo *MessageReply
Forward *MessageForward
SendAs *Peer
ScheduleDate int
ScheduleRepeatPeriod int
Date int
}
// EditScheduledMessageRequest updates one pending scheduled message before it
// enters normal history.
type EditScheduledMessageRequest struct {
OwnerUserID int64
Peer Peer
ID int
SetMessage bool
Message string
Entities []MessageEntity
ScheduleDate int
Date int
}
// ScheduledMessageFilter selects scheduled messages for one owner/peer.
type ScheduledMessageFilter struct {
OwnerUserID int64
Peer Peer
IDs []int
Limit int
Hash int64
}
// ScheduledMessageClaim describes a due/manual scheduled dispatch claim.
type ScheduledMessageClaim struct {
OwnerUserID int64
Peer Peer
IDs []int
Now int
Limit int
LeaseUntil int
}

View file

@ -4,9 +4,16 @@ import "errors"
var (
ErrMessageIDInvalid = errors.New("message id invalid")
ErrMessageEmpty = errors.New("message empty")
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")
// ErrPinnedSavedDialogsTooMuch 映射 PINNED_TOO_MUCH收藏夹子会话置顶
// 数量达到 MaxPinnedSavedDialogs 上限。
ErrPinnedSavedDialogsTooMuch = errors.New("pinned saved dialogs too much")
// ErrPinnedDialogsTooMuch 映射 PINNED_DIALOGS_TOO_MUCH目标 folder 内
// 置顶会话数量达到上限。
ErrPinnedDialogsTooMuch = errors.New("pinned dialogs too much")
)

View file

@ -0,0 +1,150 @@
package domain
import (
"errors"
"net/url"
"strings"
"unicode/utf8"
)
const (
// MaxMarkupRows 限制 inline keyboard 行数。
MaxMarkupRows = 100
// MaxMarkupButtonsPerRow 限制单行按钮数。
MaxMarkupButtonsPerRow = 8
// MaxMarkupButtonsTotal 限制 inline keyboard 总按钮数(对齐官方约 100
MaxMarkupButtonsTotal = 100
// MaxCallbackDataLen 是 callback 按钮 data 的字节上限(对齐 Bot API 1-64
MaxCallbackDataLen = 64
// MaxMarkupButtonTextLen 是按钮文本长度上限rune 计数)。
MaxMarkupButtonTextLen = 256
// MaxBotCallbackAnswerLen 是 callback answer 弹窗/toast 文本上限。
MaxBotCallbackAnswerLen = 200
// MaxStartParamLen 是 messages.startBot 深链 payload 上限(对齐官方 64
MaxStartParamLen = 64
)
// markup / callback 业务错误。
var (
// ErrButtonDataInvalid 表示 callback data 越界(>64 字节)。
ErrButtonDataInvalid = errors.New("button data invalid")
// ErrButtonInvalid 表示键盘结构非法(行/按钮数超限、文本空/过长)。
ErrButtonInvalid = errors.New("button invalid")
// ErrButtonURLInvalid 表示 url 按钮的链接非法(非 https
ErrButtonURLInvalid = errors.New("button url invalid")
// ErrButtonTypeInvalid 表示按钮类型 P3 不支持webview/game/url_auth/request_* 等)。
ErrButtonTypeInvalid = errors.New("button type invalid")
// ErrStartParamInvalid 表示 startBot 的 start_param 越界。
ErrStartParamInvalid = errors.New("start param invalid")
)
// MarkupButtonType 标识 P3 支持的 inline 按钮类型。
type MarkupButtonType string
const (
// MarkupButtonCallback 是 keyboardButtonCallback点击触发 getBotCallbackAnswer
MarkupButtonCallback MarkupButtonType = "callback"
// MarkupButtonURL 是 keyboardButtonUrl点击打开链接
MarkupButtonURL MarkupButtonType = "url"
)
// MarkupButton 是一颗 inline keyboard 按钮P3 仅 callback/url
type MarkupButton struct {
Type MarkupButtonType `json:"type"`
Text string `json:"text"`
// Data 仅 callback 使用:原始字节(含 0x00/非 UTF-8/高位。json 自动 base64
// 编解码,保证经 JSONB 列字节级 round-tripupdateBotCallbackQuery.data 须原样)。
Data []byte `json:"data,omitempty"`
// URL 仅 url 使用。
URL string `json:"url,omitempty"`
// RequiresPassword 仅 callback 使用keyboardButtonCallback.requires_password
// 2FA SRP 校验 P3 stub
RequiresPassword bool `json:"requires_password,omitempty"`
}
// MessageReplyMarkup 是消息携带的 inline keyboard 快照P3 仅 ReplyInlineMarkup
type MessageReplyMarkup struct {
Inline [][]MarkupButton `json:"inline,omitempty"`
}
// IsZero 报告 markup 是否为空(无任何按钮)。空 markup 不写 wire flag、不入库。
func (m *MessageReplyMarkup) IsZero() bool {
if m == nil {
return true
}
for _, row := range m.Inline {
if len(row) > 0 {
return false
}
}
return true
}
// ValidateReplyMarkup 校验 inline keyboard 结构与各按钮校验须先于落库I9
// 空 markup 合法(视为清空/无键盘)。
func ValidateReplyMarkup(m *MessageReplyMarkup) error {
if m == nil {
return nil
}
if len(m.Inline) > MaxMarkupRows {
return ErrButtonInvalid
}
total := 0
for _, row := range m.Inline {
if len(row) > MaxMarkupButtonsPerRow {
return ErrButtonInvalid
}
total += len(row)
if total > MaxMarkupButtonsTotal {
return ErrButtonInvalid
}
for i := range row {
if err := validateMarkupButton(row[i]); err != nil {
return err
}
}
}
return nil
}
func validateMarkupButton(b MarkupButton) error {
text := strings.TrimSpace(b.Text)
if text == "" || utf8.RuneCountInString(b.Text) > MaxMarkupButtonTextLen {
return ErrButtonInvalid
}
switch b.Type {
case MarkupButtonCallback:
if len(b.Data) > MaxCallbackDataLen {
return ErrButtonDataInvalid
}
case MarkupButtonURL:
if err := validateButtonURL(b.URL); err != nil {
return err
}
default:
// webview/game/url_auth/request_* 等 P3 未实现类型:拒绝,绝不半实现下发。
return ErrButtonTypeInvalid
}
return nil
}
func validateButtonURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" || len(raw) > MaxBotMenuButtonURLLen {
return ErrButtonURLInvalid
}
u, err := url.Parse(raw)
if err != nil || u.Scheme != "https" || u.Host == "" {
return ErrButtonURLInvalid
}
return nil
}
// BotCallbackAnswer 是 bot 对一次 callback query 的应答setBotCallbackAnswer →
// 解挂等待中的 getBotCallbackAnswer
type BotCallbackAnswer struct {
Alert bool
Message string
URL string
CacheTime int
}

View file

@ -0,0 +1,77 @@
package domain
import (
"errors"
"strings"
"testing"
)
func cb(text string, data []byte) MarkupButton {
return MarkupButton{Type: MarkupButtonCallback, Text: text, Data: data}
}
func TestValidateReplyMarkup(t *testing.T) {
tests := []struct {
name string
m *MessageReplyMarkup
want error
}{
{"nil ok", nil, nil},
{"empty ok", &MessageReplyMarkup{}, nil},
{"callback ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{cb("ok", []byte("d"))}}}, nil},
{"callback 64B ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{cb("ok", make([]byte, 64))}}}, nil},
{"callback 65B bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{cb("ok", make([]byte, 65))}}}, ErrButtonDataInvalid},
{"empty text bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{cb("", []byte("d"))}}}, ErrButtonInvalid},
{"url https ok", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "https://example.com/x"}}}}, nil},
{"url http bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "http://example.com"}}}}, ErrButtonURLInvalid},
{"url javascript bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: "javascript:alert(1)"}}}}, ErrButtonURLInvalid},
{"url empty bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: MarkupButtonURL, Text: "go", URL: ""}}}}, ErrButtonURLInvalid},
{"unknown type bad", &MessageReplyMarkup{Inline: [][]MarkupButton{{{Type: "webview", Text: "x"}}}}, ErrButtonTypeInvalid},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := ValidateReplyMarkup(tt.m); !errors.Is(err, tt.want) {
t.Fatalf("ValidateReplyMarkup = %v, want %v", err, tt.want)
}
})
}
}
func TestValidateReplyMarkupLimits(t *testing.T) {
// 行数上限。
tooManyRows := &MessageReplyMarkup{Inline: make([][]MarkupButton, MaxMarkupRows+1)}
for i := range tooManyRows.Inline {
tooManyRows.Inline[i] = []MarkupButton{cb("x", []byte("d"))}
}
if err := ValidateReplyMarkup(tooManyRows); !errors.Is(err, ErrButtonInvalid) {
t.Fatalf("rows over limit = %v, want ErrButtonInvalid", err)
}
// 单行按钮数上限。
wideRow := make([]MarkupButton, MaxMarkupButtonsPerRow+1)
for i := range wideRow {
wideRow[i] = cb("x", []byte("d"))
}
if err := ValidateReplyMarkup(&MessageReplyMarkup{Inline: [][]MarkupButton{wideRow}}); !errors.Is(err, ErrButtonInvalid) {
t.Fatalf("row width over limit = %v, want ErrButtonInvalid", err)
}
// 文本长度上限。
longText := strings.Repeat("a", MaxMarkupButtonTextLen+1)
if err := ValidateReplyMarkup(&MessageReplyMarkup{Inline: [][]MarkupButton{{cb(longText, []byte("d"))}}}); !errors.Is(err, ErrButtonInvalid) {
t.Fatalf("text over limit = %v, want ErrButtonInvalid", err)
}
}
func TestMessageReplyMarkupIsZero(t *testing.T) {
if !(*MessageReplyMarkup)(nil).IsZero() {
t.Fatal("nil markup must be zero")
}
if !(&MessageReplyMarkup{}).IsZero() {
t.Fatal("empty markup must be zero")
}
if !(&MessageReplyMarkup{Inline: [][]MarkupButton{{}}}).IsZero() {
t.Fatal("markup with only empty rows must be zero")
}
if (&MessageReplyMarkup{Inline: [][]MarkupButton{{cb("x", nil)}}}).IsZero() {
t.Fatal("markup with a button must not be zero")
}
}

70
internal/domain/notify.go Normal file
View file

@ -0,0 +1,70 @@
package domain
// NotifyScopeKind 区分通知设置的作用域:具体 peer / 三类全局默认(私聊/群/频道)。
type NotifyScopeKind string
const (
NotifyScopePeer NotifyScopeKind = "peer"
NotifyScopeUsers NotifyScopeKind = "users"
NotifyScopeChats NotifyScopeKind = "chats"
NotifyScopeBroadcasts NotifyScopeKind = "broadcasts"
)
// NotifyScope 唯一标识一条通知设置peer 作用域用 Peer(+TopicID 区分 forum 话题,
// 0=整 peer),三类默认作用域 Peer/TopicID 为空。
type NotifyScope struct {
Kind NotifyScopeKind
Peer Peer
TopicID int
}
// PeerNotifySettings 是 peerNotifySettings 的业务层表达。各字段为指针=可选:
// nil 表示该项未设置客户端按所属类别默认继承。声音字段恒为默认v1 不建模
// 自定义铃声custom sound 罕见,留后续)。
type PeerNotifySettings struct {
ShowPreviews *bool
Silent *bool
MuteUntil *int
StoriesMuted *bool
StoriesHideSender *bool
}
// IsZero 报告该设置是否全部未设置(无需持久化/可视为继承默认)。
func (s PeerNotifySettings) IsZero() bool {
return s.ShowPreviews == nil && s.Silent == nil && s.MuteUntil == nil &&
s.StoriesMuted == nil && s.StoriesHideSender == nil
}
// NotifyException 是一个 peer可含 forum 话题)的非默认通知设置,供
// account.getNotifyExceptions 列出"有自定义通知设置的会话"全局索引。
type NotifyException struct {
Peer Peer
TopicID int
Settings PeerNotifySettings
}
// Clone 深拷贝指针字段,供内存 store 隔离。
func (s PeerNotifySettings) Clone() PeerNotifySettings {
out := PeerNotifySettings{}
if s.ShowPreviews != nil {
v := *s.ShowPreviews
out.ShowPreviews = &v
}
if s.Silent != nil {
v := *s.Silent
out.Silent = &v
}
if s.MuteUntil != nil {
v := *s.MuteUntil
out.MuteUntil = &v
}
if s.StoriesMuted != nil {
v := *s.StoriesMuted
out.StoriesMuted = &v
}
if s.StoriesHideSender != nil {
v := *s.StoriesHideSender
out.StoriesHideSender = &v
}
return out
}

View file

@ -0,0 +1,46 @@
package domain
// 频道帖子付费 reactionmessages.sendPaidReaction用户花 Stars 为一条频道消息「点赞」,
// 星数在 (channel,message,user) 上累计;消息展示 ReactionPaid 总星数 + top reactors 排行。
// 与 Stars 账本stars.go配合rpc 先 Debit 再在此累计。
const (
// MaxPaidReactionStarsPerRequest 是单次 sendPaidReaction 的星数上限(对齐官方 stars_paid_reaction_amount_max
MaxPaidReactionStarsPerRequest = 10000
// MaxPaidReactionTopReactors 是 top reactors 排行展示条数。
MaxPaidReactionTopReactors = 3
)
// PaidReactor 是某 reactor 对一条消息累计投入的付费 reaction 星数。
type PaidReactor struct {
UserID int64
Stars int64
Anonymous bool
My bool // 是否为当前 viewer投影时按视角置位
}
// ChannelMessagePaidReactions 是一条频道消息的付费 reaction 聚合(携带在消息上 / reaction 更新里)。
type ChannelMessagePaidReactions struct {
TotalStars int64 // 全体 reactor 投入星数之和
MyStars int64 // 当前 viewer 投入的星数0 = 未投)
MyAnonymous bool // 当前 viewer 是否匿名投入
TopReactors []PaidReactor // 按 Stars DESC含当前 viewer
}
// SendChannelPaidReactionRequest 为一条频道消息增投付费 reaction 星数。
type SendChannelPaidReactionRequest struct {
UserID int64
ChannelID int64
MessageID int
Stars int64
Anonymous bool // 隐私:是否匿名投入
Date int
}
// ChannelMessagePaidReactionResult 是增投后的结果,供 rpc 投影与扇出。
type ChannelMessagePaidReactionResult struct {
Channel Channel
Message ChannelMessage
Paid ChannelMessagePaidReactions
Recipients []int64
}

View file

@ -0,0 +1,50 @@
package domain
import "errors"
// Passkey 相关错误。
var (
ErrPasskeyChallengeInvalid = errors.New("passkey challenge invalid")
ErrPasskeyNotFound = errors.New("passkey not found")
ErrPasskeyInvalid = errors.New("passkey invalid")
ErrPasskeyUserHandleInvalid = errors.New("passkey user handle invalid")
)
// PasskeyChallengePurpose 区分注册挑战与登录挑战。
type PasskeyChallengePurpose string
const (
PasskeyChallengeLogin PasskeyChallengePurpose = "login"
PasskeyChallengeRegister PasskeyChallengePurpose = "register"
)
// PasskeyChallenge 是一次性 WebAuthn 挑战的元数据(短 TTL、用后即焚)。
// 注册挑战绑定发起用户;登录挑战(discoverable)UserID=0,用户由 user_handle 反查。
type PasskeyChallenge struct {
Purpose PasskeyChallengePurpose
UserID int64
ExpiresAt int64 // unix 秒
}
// PasskeyCredential 是一条已注册的 passkey 凭据(WebAuthn 公钥)。
type PasskeyCredential struct {
CredentialID []byte // 原始 credential id 字节(对外以 base64url 暴露)
UserID int64
PublicKey []byte // COSE 公钥原始字节
SignCount uint32
AAGUID []byte
Name string
Transports []string
CreatedAt int64 // unix 秒
LastUsedAt int64 // unix 秒;0 表示从未用于登录
}
// Clone 深拷贝(切片字段),避免内存 store 暴露内部引用。
func (c PasskeyCredential) Clone() PasskeyCredential {
out := c
out.CredentialID = append([]byte(nil), c.CredentialID...)
out.PublicKey = append([]byte(nil), c.PublicKey...)
out.AAGUID = append([]byte(nil), c.AAGUID...)
out.Transports = append([]string(nil), c.Transports...)
return out
}

141
internal/domain/phone.go Normal file
View file

@ -0,0 +1,141 @@
package domain
// PhoneCallState 是私聊 1:1 通话信令状态机的服务端状态。
// 状态迁移、TL 视角映射与推送策略见 docs 与 internal/app/phone。
type PhoneCallState string
const (
// PhoneCallStateRequestedrequestCall 已受理,已向被叫推 phoneCallRequested。
PhoneCallStateRequested PhoneCallState = "requested"
// PhoneCallStateRinging至少一台被叫设备上报 receivedCall主叫据 receive_date 切振铃 UI
PhoneCallStateRinging PhoneCallState = "ringing"
// PhoneCallStateAccepted被叫 acceptCall 已落 g_b等主叫 confirmCall。
PhoneCallStateAccepted PhoneCallState = "accepted"
// PhoneCallStateConfirmed主叫 confirmCall 完成密钥交换,通话进行中。
PhoneCallStateConfirmed PhoneCallState = "confirmed"
// PhoneCallStateDiscarded终态。作为 tombstone 保留一段时间吸收晚到 RPC 后回收。
PhoneCallStateDiscarded PhoneCallState = "discarded"
)
// PhoneCallDiscardReason 是挂断原因,与 TL phoneCallDiscardReason* 一一对应。
type PhoneCallDiscardReason string
const (
PhoneCallDiscardReasonMissed PhoneCallDiscardReason = "missed"
PhoneCallDiscardReasonDisconnect PhoneCallDiscardReason = "disconnect"
PhoneCallDiscardReasonHangup PhoneCallDiscardReason = "hangup"
PhoneCallDiscardReasonBusy PhoneCallDiscardReason = "busy"
// PhoneCallDiscardReasonMigrateConference 是升级为 conference call 的迁移挂断;
// 当前不支持 conference仅原样回显 reason。
PhoneCallDiscardReasonMigrateConference PhoneCallDiscardReason = "migrate_conference"
)
// PhoneCallProtocol 是 tgcalls 协议协商参数TL phoneCallProtocol
type PhoneCallProtocol struct {
UDPP2P bool
UDPReflector bool
MinLayer int
MaxLayer int
LibraryVersions []string
}
// SessionRef 标识一台具体设备连接(信令定向推送 fast-path 的锚点,允许失效)。
type SessionRef struct {
RawAuthKeyID [8]byte
SessionID int64
}
// Zero 报告锚点是否未记录。
func (s SessionRef) Zero() bool {
return s == SessionRef{}
}
// PhoneCallConnection 是下发给双方的 WebRTC STUN/TURN 服务条目
// TL phoneConnectionWebrtc。requestCall 受理时签发,全生命周期稳定。
type PhoneCallConnection struct {
ID int64
IP string
Port int
Username string
Password string
Stun bool
Turn bool
}
// PhoneCallRequest 是 phone.requestCall 受理入参(隐私/拉黑等校验在 rpc 层先行完成)。
type PhoneCallRequest struct {
CalleeID int64
RandomID int64
GAHash []byte
Video bool
Protocol PhoneCallProtocol
CallerDevice SessionRef
// PrivacyP2P 是 phone_p2p 隐私的双向 ANDrpc 层算定;强制 relay 时置 false
PrivacyP2P bool
// Connections 是为本通话签发的 STUN/TURN 列表可空TURN 未启用时退回
// 纯信令交换 host candidates 的 LAN 直连)。
Connections []PhoneCallConnection
}
// PhoneCall 是一通私聊通话的服务端权威态。密钥材料GAHash/GB/GA只存活于
// 进程内 registry随 tombstone 回收销毁,绝不落任何持久化存储。
type PhoneCall struct {
ID int64
AccessHash int64
// AdminID 是主叫ParticipantID 是被叫;与 TL phoneCall 字段同名,全生命周期不变。
AdminID int64
ParticipantID int64
Video bool
State PhoneCallState
Date int // requestCall 受理时刻unix 秒)
ReceiveDate int // 首台被叫设备 receivedCall 时刻0 表示尚未触达
StartDate int // confirmCall 完成时刻
DiscardedAt int // 进入终态时刻tombstone GC 依据)
GAHash []byte // 32BrequestCall 携带的承诺
GB []byte // 256BacceptCall 携带
GA []byte // 256BconfirmCall 揭示(服务端核验 SHA256(GA)==GAHash
KeyFingerprint int64 // E2E 指纹,服务端无法验证,仅转发
// Protocol 是协商结果accept 时计算CallerProtocol/CalleeProtocol 保留原始两份。
Protocol PhoneCallProtocol
CallerProtocol PhoneCallProtocol
CalleeProtocol PhoneCallProtocol
P2PAllowed bool
DiscardReason PhoneCallDiscardReason
Duration int
// PrivacyP2P 与 Connections 自 PhoneCallRequest 原样保留(见其注释)。
PrivacyP2P bool
Connections []PhoneCallConnection
RandomID int64 // 主叫侧幂等去重键的一半callerID+RandomID
// 设备锚点requestCall / acceptCall 的来源设备,仅作定向推送提示。
CallerDevice SessionRef
CalleeDevice SessionRef
}
// Terminal 报告通话是否已进入终态。
func (c PhoneCall) Terminal() bool {
return c.State == PhoneCallStateDiscarded
}
// PeerOf 返回 userID 在本通话中的对端userID 不是参与者时返回 0。
func (c PhoneCall) PeerOf(userID int64) int64 {
switch userID {
case c.AdminID:
return c.ParticipantID
case c.ParticipantID:
return c.AdminID
default:
return 0
}
}
// HasParticipant 报告 userID 是否为本通话参与者。
func (c PhoneCall) HasParticipant(userID int64) bool {
return userID != 0 && (userID == c.AdminID || userID == c.ParticipantID)
}

325
internal/domain/poll.go Normal file
View file

@ -0,0 +1,325 @@
package domain
import (
"bytes"
"errors"
)
// 本文件定义投票poll的业务对象与共享语义
//
// - MessagePoll 是随消息 media JSONB 落库的「渲染安全定义快照」——不含 quiz 正确答案与
// solution 等服务端机密Closed 仅是发送时刻快照,读路径必须用 polls 权威态覆盖。
// - PollDefinition 是 polls 权威表行(含机密与可变 closed投票校验与结果门控只信它。
// - 投票校验ValidatePollVote与结果门控ResolvePollResults是纯函数由 memory 与
// postgres 两个 store 共用,杜绝双实现行为漂移。
//
// 转发不复制 poll转发快照携带同一 poll id投票/关闭全局聚合(与官方一致)。
const (
// MaxPollQuestionLength 与官方 poll question 上限一致。
MaxPollQuestionLength = 255
// MinPollAnswers / MaxPollAnswers 是答案个数边界(官方 2..10)。
MinPollAnswers = 2
MaxPollAnswers = 10
// MaxPollAnswerTextLength 是单个答案文本上限(官方 100
MaxPollAnswerTextLength = 100
// MaxPollSolutionLength 是 quiz 解释文本上限(官方 200
MaxPollSolutionLength = 200
// MinPollClosePeriod / MaxPollClosePeriod 是自动关闭倒计时边界(官方 5..600 秒)。
MinPollClosePeriod = 5
MaxPollClosePeriod = 600
// MaxPollRecentVoters 是 recent_voters 截断(与 reaction recent 同款,官方 3
MaxPollRecentVoters = 3
// MaxPollVotesPageLimit 是 messages.getPollVotes 单页上限。
MaxPollVotesPageLimit = 50
)
// 投票链路业务错误rpc 层映射为对应 RPC error 文本。
var (
ErrPollInvalid = errors.New("poll invalid")
ErrPollNotFound = errors.New("poll not found")
ErrPollClosed = errors.New("poll closed")
ErrPollOptionInvalid = errors.New("poll option invalid")
ErrPollRevoteNotAllowed = errors.New("poll revote not allowed")
ErrPollNotCreator = errors.New("poll can only be closed by creator")
ErrPollNotPublic = errors.New("poll voters are not public")
)
// MessagePollAnswer 是一个候选答案(文本 + option 字节键 + 可选配图)。
// Media 仅允许 photo/document 快照rpc 层限制输入类型)。
type MessagePollAnswer struct {
Text string `json:"text"`
Entities []MessageEntity `json:"entities,omitempty"`
Option []byte `json:"option"`
Media *MessageMedia `json:"media,omitempty"`
}
// MessagePoll 是消息 media 上的 poll 定义快照(渲染安全,不含机密)。
type MessagePoll struct {
ID int64 `json:"id"`
Question string `json:"question"`
QuestionEntities []MessageEntity `json:"question_entities,omitempty"`
Answers []MessagePollAnswer `json:"answers"`
// Closed 是发送时刻快照;读路径以 polls 权威态覆盖(转发副本/关闭后历史一致性靠它)。
Closed bool `json:"closed,omitempty"`
PublicVoters bool `json:"public_voters,omitempty"`
MultipleChoice bool `json:"multiple_choice,omitempty"`
Quiz bool `json:"quiz,omitempty"`
RevotingDisabled bool `json:"revoting_disabled,omitempty"`
ShuffleAnswers bool `json:"shuffle_answers,omitempty"`
HideResultsUntilClose bool `json:"hide_results_until_close,omitempty"`
ClosePeriod int `json:"close_period,omitempty"`
CloseDate int `json:"close_date,omitempty"`
// AttachedMedia 是 poll 题干配图messageMediaPoll.attached_mediaphoto/document 快照。
AttachedMedia *MessageMedia `json:"attached_media,omitempty"`
// Results 在读路径按 viewer 填充(含 chosen/correct/solution 门控),不落库。
Results *MessagePollResults `json:"-"`
}
// MessagePollAnswerVoters 是一个答案的聚合结果(已按 viewer 门控)。
type MessagePollAnswerVoters struct {
Option []byte
Voters int
Chosen bool
Correct bool
}
// MessagePollResults 是按 viewer 解析后的聚合结果。
type MessagePollResults struct {
TotalVoters int
Voters []MessagePollAnswerVoters // 与 MessagePoll.Answers 同序
RecentVoters []int64 // 仅 public_voters 填充,截断 MaxPollRecentVoters
ViewerVoted bool
Solution string // 仅 quiz 且 (viewer 已投 || 已关闭) 下发
SolutionEntities []MessageEntity
}
// PollDefinition 是 polls 权威表行可变状态closed+ 服务端机密 + 校验所需选项集。
type PollDefinition struct {
ID int64
CreatorUserID int64
Options [][]byte // 合法选项集合(与答案顺序一致)
PublicVoters bool
MultipleChoice bool
Quiz bool
RevotingDisabled bool
HideResultsUntilClose bool
Closed bool
ClosePeriod int
CloseDate int
CorrectOptions [][]byte
Solution string
SolutionEntities []MessageEntity
}
// ClosedAt 返回 now 时刻 poll 是否应视为已关闭(显式关闭或 close_date 已过)。
func (d PollDefinition) ClosedAt(now int) bool {
return d.Closed || (d.CloseDate > 0 && now >= d.CloseDate)
}
// HasOption 判断 option 是否在合法选项集内。
func (d PollDefinition) HasOption(option []byte) bool {
for _, candidate := range d.Options {
if bytes.Equal(candidate, option) {
return true
}
}
return false
}
// PollVote 是一个用户的投票行。Options 保留客户端提交顺序。
type PollVote struct {
PollID int64
UserID int64
Options [][]byte
Date int
}
// PollAggregates 是 store 层产出的原始聚合,交由 ResolvePollResults 做 viewer 门控。
// Counts 的 key 是 string(option)。
type PollAggregates struct {
Counts map[string]int
TotalVoters int
RecentVoters []int64 // 按 vote_date DESC 截断 MaxPollRecentVoters仅 public_voters 需要
ViewerOptions [][]byte // viewer 自己的投票nil = 未投)
}
// ValidatePollVote 校验一次投票提交existing 是 viewer 当前投票nil=未投options 为空表示撤票。
// memory 与 postgres store 必须共用本函数,保持双实现一致。
func ValidatePollVote(def PollDefinition, existing [][]byte, options [][]byte, now int) error {
if def.ID == 0 {
return ErrPollNotFound
}
if def.ClosedAt(now) {
return ErrPollClosed
}
if def.Quiz || def.RevotingDisabled {
// quiz / revoting_disabled不可撤票、不可改票。
if len(existing) > 0 || len(options) == 0 {
return ErrPollRevoteNotAllowed
}
}
if def.Quiz {
if len(options) != 1 {
return ErrPollOptionInvalid
}
} else if len(options) > 1 && !def.MultipleChoice {
return ErrPollOptionInvalid
}
if len(options) > len(def.Options) {
return ErrPollOptionInvalid
}
seen := make(map[string]struct{}, len(options))
for _, option := range options {
if !def.HasOption(option) {
return ErrPollOptionInvalid
}
key := string(option)
if _, dup := seen[key]; dup {
return ErrPollOptionInvalid
}
seen[key] = struct{}{}
}
return nil
}
// ResolvePollResults 把原始聚合解析成 viewer 视角结果chosen 标 viewer 自己的选项;
// correct/solution 仅 quiz 且 (viewer 已投 || 已关闭) 揭示;
// hide_results_until_close 在关闭前对非创建者隐藏计数(防恶意客户端绕过 UI
func ResolvePollResults(def PollDefinition, agg PollAggregates, viewerUserID int64, now int) MessagePollResults {
closed := def.ClosedAt(now)
viewerVoted := len(agg.ViewerOptions) > 0
reveal := def.Quiz && (viewerVoted || closed)
hideCounts := def.HideResultsUntilClose && !closed && viewerUserID != def.CreatorUserID
chosen := make(map[string]struct{}, len(agg.ViewerOptions))
for _, option := range agg.ViewerOptions {
chosen[string(option)] = struct{}{}
}
correct := make(map[string]struct{}, len(def.CorrectOptions))
if reveal {
for _, option := range def.CorrectOptions {
correct[string(option)] = struct{}{}
}
}
out := MessagePollResults{
TotalVoters: agg.TotalVoters,
Voters: make([]MessagePollAnswerVoters, 0, len(def.Options)),
ViewerVoted: viewerVoted,
}
for _, option := range def.Options {
key := string(option)
_, isChosen := chosen[key]
_, isCorrect := correct[key]
voters := agg.Counts[key]
if hideCounts {
voters = 0
}
out.Voters = append(out.Voters, MessagePollAnswerVoters{
Option: option,
Voters: voters,
Chosen: isChosen,
Correct: isCorrect,
})
}
if def.PublicVoters && !hideCounts && len(agg.RecentVoters) > 0 {
recent := agg.RecentVoters
if len(recent) > MaxPollRecentVoters {
recent = recent[:MaxPollRecentVoters]
}
out.RecentVoters = append([]int64(nil), recent...)
}
if reveal {
out.Solution = def.Solution
out.SolutionEntities = append([]MessageEntity(nil), def.SolutionEntities...)
}
return out
}
// ApplyPollState 把权威态与解析结果写回 media 上的定义快照(读路径 enrichment 终点)。
func ApplyPollState(poll *MessagePoll, def PollDefinition, results MessagePollResults, now int) {
if poll == nil {
return
}
poll.Closed = def.ClosedAt(now)
poll.Results = &results
}
// VotePrivateMessagePollRequest 是私聊消息投票请求msg id 为 viewer box id
type VotePrivateMessagePollRequest struct {
UserID int64
Peer Peer
MessageID int
Options [][]byte // 空 = 撤票
Date int
}
// PrivateMessagePollResult 返回两个 owner 视角的消息media 已按各自 owner enrich
type PrivateMessagePollResult struct {
PollID int64
Messages []Message
}
// ClosePrivateMessagePollRequest 关闭私聊消息上的 poll仅 poll 创建者)。
type ClosePrivateMessagePollRequest struct {
UserID int64
Peer Peer
MessageID int
Date int
}
// VoteChannelMessagePollRequest 是频道/超级群消息投票请求。
type VoteChannelMessagePollRequest struct {
UserID int64
ChannelID int64
MessageID int
Options [][]byte
Date int
}
// ChannelMessagePollResult 返回投票者视角消息与建议推送收件人。
type ChannelMessagePollResult struct {
PollID int64
Channel Channel
Message ChannelMessage
Recipients []int64
}
// ChannelPollFanoutViews 是频道 poll fan-out 的批量 per-viewer enrich 结果(消除逐 viewer GetMessages
// 的 N+1viewer-invariant 聚合counts/total/recent只算一次 + 批量 viewerOptions + 批量成员可见性,
// 每 viewer 用同一 ResolvePollResults 合成,与逐 viewer 路径字节同源。
// - Foundpoll 消息存在且确为 poll否则视为无可投影
// - Message基础消息快照未 per-viewer enrich供 app 层叠加 bot 历史可见性过滤。
// - Pollskey 存在=已评估该 viewer值为 nil=该 viewer 不可见(非成员/pre-history 隐藏);
// 非 nil=该 viewer 视角已 enrich 的 poll。bot 历史过滤由 app 层在此基础上叠加(置 nil
type ChannelPollFanoutViews struct {
Found bool
Message ChannelMessage
Polls map[int64]*MessagePoll
}
// CloseChannelMessagePollRequest 关闭频道消息上的 poll仅 poll 创建者)。
type CloseChannelMessagePollRequest struct {
UserID int64
ChannelID int64
MessageID int
Date int
}
// PollVotesListRequest 是 messages.getPollVotes 的分页请求。
type PollVotesListRequest struct {
PollID int64
Option []byte // 可选:仅列出投了该选项的人
OffsetDate int // 0 = 第一页;翻页用上页末行 (date,user_id)
OffsetUserID int64
Limit int
}
// PollVotesList 是投票人分页结果。
type PollVotesList struct {
Count int // 满足过滤条件的总数
Votes []PollVote
// HasMore 为 true 时 rpc 层用末行 (Date,UserID) 编码 next_offset。
HasMore bool
}

View file

@ -0,0 +1,93 @@
package domain
import (
"testing"
"time"
)
func TestPremiumActiveAt(t *testing.T) {
now := time.Now().Unix()
cases := []struct {
name string
user User
want bool
}{
{"zero until = 非会员", User{}, false},
{"future until = 会员", User{PremiumUntil: int(now + 3600)}, true},
{"past until = 已到期", User{PremiumUntil: int(now - 1)}, false},
{"恰好 now = 已到期(边界闭区间外)", User{PremiumUntil: int(now)}, false},
{"bot 永不会员(双保险)", User{Bot: true, BotInfoVersion: 1, PremiumUntil: int(now + 3600)}, false},
}
for _, tc := range cases {
if got := tc.user.PremiumActiveAt(now); got != tc.want {
t.Errorf("%s: PremiumActiveAt = %v, want %v", tc.name, got, tc.want)
}
}
}
func TestEmojiStatusActiveAt(t *testing.T) {
now := time.Now().Unix()
premium := int(now + 3600)
cases := []struct {
name string
user User
want bool
}{
{"未设置", User{PremiumUntil: premium}, false},
{"永久状态", User{PremiumUntil: premium, EmojiStatusDocumentID: 7}, true},
{"未过期状态", User{PremiumUntil: premium, EmojiStatusDocumentID: 7, EmojiStatusUntil: int(now + 60)}, true},
{"已过期状态", User{PremiumUntil: premium, EmojiStatusDocumentID: 7, EmojiStatusUntil: int(now - 60)}, false},
{"会员到期后残值不下发", User{PremiumUntil: int(now - 1), EmojiStatusDocumentID: 7}, false},
}
for _, tc := range cases {
if got := tc.user.EmojiStatusActiveAt(now); got != tc.want {
t.Errorf("%s: EmojiStatusActiveAt = %v, want %v", tc.name, got, tc.want)
}
}
}
func TestTrimMessageReactionsToUserMaxTiers(t *testing.T) {
reactions := []MessageReaction{
{Type: MessageReactionEmoji, Emoticon: "👍"},
{Type: MessageReactionEmoji, Emoticon: "❤"},
{Type: MessageReactionEmoji, Emoticon: "🔥"},
{Type: MessageReactionEmoji, Emoticon: "🎉"},
}
// 默认档(含 0 = 旧调用方未填)裁到 1保尾部最新。
for _, max := range []int{0, -1, MaxMessageReactionsPerUser} {
got := TrimMessageReactionsToUserMax(reactions, max)
if len(got) != 1 || got[0].Emoticon != "🎉" {
t.Fatalf("default tier (max=%d) = %+v, want [🎉]", max, got)
}
}
// premium 档裁到 3保尾部最新。
got := TrimMessageReactionsToUserMax(reactions, MessageReactionsUserMax(true))
if len(got) != 3 || got[0].Emoticon != "❤" || got[2].Emoticon != "🎉" {
t.Fatalf("premium tier = %+v, want [❤ 🔥 🎉]", got)
}
// 超出 premium 档的请求值被封顶。
if got := TrimMessageReactionsToUserMax(reactions, 99); len(got) != MaxMessageReactionsPerUserPremium {
t.Fatalf("over-cap tier = %d entries, want %d", len(got), MaxMessageReactionsPerUserPremium)
}
if MessageReactionsUserMax(false) != MaxMessageReactionsPerUser {
t.Fatalf("MessageReactionsUserMax(false) = %d", MessageReactionsUserMax(false))
}
}
func TestPinnedDialogsLimitTiers(t *testing.T) {
cases := []struct {
folderID int
premium bool
want int
}{
{DialogMainFolderID, false, MaxPinnedDialogsMainFolder},
{DialogMainFolderID, true, MaxPinnedDialogsMainFolderPremium},
{DialogArchiveFolderID, false, MaxPinnedDialogsArchiveFolder},
{DialogArchiveFolderID, true, MaxPinnedDialogsArchiveFolderPremium},
}
for _, tc := range cases {
if got := PinnedDialogsLimit(tc.folderID, tc.premium); got != tc.want {
t.Errorf("PinnedDialogsLimit(%d, %v) = %d, want %d", tc.folderID, tc.premium, got, tc.want)
}
}
}

View file

@ -0,0 +1,87 @@
package domain
// SavedHiddenAuthorUserID 是官方 hidden-author 收藏夹子会话的占位 user id
// TDesktop kSavedHiddenAuthorId / TDLib HIDDEN_AUTHOR_DIALOG_ID
// 运行时新写入永远能确定源会话,不产生该值;仅存量回填中
// 「fwd 头只有 from_name、源会话已不可知」的消息归入此子会话。
const SavedHiddenAuthorUserID int64 = 2666000
// MaxPinnedSavedDialogs 是收藏夹子会话置顶上限(官方 premium 上限同级)。
const MaxPinnedSavedDialogs = 100
// MaxSavedDialogsLimit 是 messages.getSavedDialogs 单页上限。
const MaxSavedDialogsLimit = 100
// SavedPeerForSelfChat 计算 self-chat 新消息的 saved 子会话分组键,与
// TDLib SavedMessagesTopicId 语义对齐转发带源会话saved_from_peer
// 源会话fwd 头仅 from_name 且源会话不可知 → hidden author 占位(防御
// 分支运行时转发总是带源会话其余直发、drop_author→ self。
func SavedPeerForSelfChat(selfUserID int64, forward *MessageForward) Peer {
if forward != nil {
if forward.SavedFrom.ID != 0 {
return forward.SavedFrom
}
if forward.From.ID == 0 && forward.FromName != "" {
return Peer{Type: PeerTypeUser, ID: SavedHiddenAuthorUserID}
}
}
return Peer{Type: PeerTypeUser, ID: selfUserID}
}
// SavedDialog 是收藏夹的一个子会话savedDialog TL 构造器的业务形态)。
type SavedDialog struct {
Peer Peer
// TopMessage 是该子会话当前最新可见消息的 owner 视角 box id。
TopMessage int
Pinned bool
}
// SavedDialogList 是 getSavedDialogs/getPinnedSavedDialogs/getSavedDialogsByID
// 的业务层结果。Messages 与 Dialogs 一一对应top message 全量行)。
type SavedDialogList struct {
Dialogs []SavedDialog
Messages []Message
Users []User
Channels []Channel
// Count 是过滤口径下(含/不含 pinned的子会话总数。
Count int
// Full 为 true 表示本页已含过滤口径下的全部剩余子会话
// (映射 messages.savedDialogsfalse 映射 savedDialogsSlice
Full bool
}
// SavedDialogsFilter 描述 getSavedDialogs 分页条件。
type SavedDialogsFilter struct {
ExcludePinned bool
// OffsetID 是上一页最后一个子会话的 top message box id0 或 >= MaxMessageBoxID
// 视为从最新开始TDesktop 首页传 0DrKLO Android 首页传 int32 max
OffsetID int
// OffsetDate/OffsetPeer 仅作合法性校验box id 单调于时间,分页统一按
// top box id 严格降序推进。
OffsetDate int
OffsetPeer Peer
Limit int
}
// DeleteSavedHistoryRequest 是 messages.deleteSavedHistory 的业务命令:
// 删除 self-chat 中一个 saved 子会话的消息。self-chat 单侧,无 revoke 语义。
type DeleteSavedHistoryRequest struct {
OwnerUserID int64
// SavedPeer 是要清空的子会话分组键。
SavedPeer Peer
MaxID int
MinDate int
MaxDate int
Date int
OriginAuthKeyID [8]byte
OriginSessionID int64
}
// DeleteSavedHistoryResult 描述一次 saved 子会话删除批次。
type DeleteSavedHistoryResult struct {
// MessageIDs 是本批被删除的 owner 视角 box id。
MessageIDs []int
Event UpdateEvent
// More 为 true 表示仍有剩余批次,客户端按 affectedHistory.offset 续发。
More bool
}

View file

@ -0,0 +1,23 @@
package domain
const (
// MaxSavedMusicItems bounds one account's profile music list in the current
// small-scale compatibility phase.
MaxSavedMusicItems = 1000
)
// SaveMusicRequest mutates the current user's ordered profile music list.
type SaveMusicRequest struct {
UserID int64
Document Document
Unsave bool
AfterDocumentID int64
Date int
}
// SavedMusicList is an ordered page of music documents pinned to a user profile.
type SavedMusicList struct {
UserID int64
Documents []Document
Count int
}

View file

@ -0,0 +1,188 @@
package domain
import "errors"
// SecretChatState 是私聊端对端加密Secret Chat / EncryptedChat握手状态机的
// 服务端权威态。服务端是盲中继g_a/g_b/key_fingerprint/加密 bytes 全部不透明
// 存储与原样转发,永不接触 DH 共享密钥与明文。设计见 docs/secret-chat-module.md。
type SecretChatState string
const (
// SecretChatStateRequestedrequestEncryption 已受理、等接受方 acceptEncryption。
// 同一服务端态对发起方投影为 encryptedChatWaiting、对接受方投影为 encryptedChatRequested。
SecretChatStateRequested SecretChatState = "requested"
// SecretChatStateNormalacceptEncryption 完成 g_b/key_fingerprint 落库,握手成型。
SecretChatStateNormal SecretChatState = "normal"
// SecretChatStateDiscarded终态任一方 discardEncryption 触发。
SecretChatStateDiscarded SecretChatState = "discarded"
)
// Secret chat 存储层错误memory/postgres 双实现共用,行为契约由 storetest 钉死)。
var (
// ErrSecretChatNotFoundchat_id 不存在 → CHAT_ID_INVALID / ENCRYPTION_ID_INVALID。
ErrSecretChatNotFound = errors.New("secretchat: not found")
// ErrSecretChatAlreadyAcceptedaccept 一个已成型的密聊 → ENCRYPTION_ALREADY_ACCEPTED。
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")
)
// 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 int
// AdminAccessHash / ParticipantAccessHash 双视角不同TL "check sum depending on user ID")。
AdminAccessHash int64
ParticipantAccessHash int64
// AdminUserID 是发起方ParticipantUserID 是接受方;据此判角色。
AdminUserID int64
ParticipantUserID int64
// AdminAuthKeyID / ParticipantAuthKeyID 是绑定设备的 perm auth_keyauthKeyIDToInt64 小端值)。
// ParticipantAuthKeyID 在 accept 前为 0建链前邀请对 participant 账号级可见)。
AdminAuthKeyID int64
ParticipantAuthKeyID int64
State SecretChatState
// GA 是 requestEncryption 携带的发起方公钥(盲存,左补零 256B
// GB 是 acceptEncryption 携带的接受方公钥accept 后落库)。
// 投影 GAOrB对 admin 视角是 GB对 participant 视角是 GATL 注释钉死)。
GA []byte
GB []byte
// KeyFingerprint 是接受方算出的共享密钥指纹,服务端原样 int64 中继、绝不重算。
KeyFingerprint int64
// Layer 是密聊内层 layer 快照不解析FolderID 透传 requested 归档意图。
Layer int
FolderID int
// HistoryDeleted 是 discard 时是否要求对端删整个会话历史。
HistoryDeleted bool
// RandomID 是 requestEncryption 的 int32 幂等键(同发起设备同 random_id 返同 chat
RandomID int32
// Date 是受理时刻unix 秒)。
Date int
}
// Terminal 报告密聊是否已销毁。
func (c SecretChat) Terminal() bool {
return c.State == SecretChatStateDiscarded
}
// HasParticipant 报告 userID 是否为本密聊的发起方或接受方。
func (c SecretChat) HasParticipant(userID int64) bool {
return userID != 0 && (userID == c.AdminUserID || userID == c.ParticipantUserID)
}
// IsAdmin 报告 userID 是否为发起方。
func (c SecretChat) IsAdmin(userID int64) bool {
return userID != 0 && userID == c.AdminUserID
}
// PeerOf 返回 userID 在本密聊中的对端userID 不是参与者时返回 0。
func (c SecretChat) PeerOf(userID int64) int64 {
switch userID {
case c.AdminUserID:
return c.ParticipantUserID
case c.ParticipantUserID:
return c.AdminUserID
default:
return 0
}
}
// PeerAuthKeyOf 返回 userID 的对端绑定设备 perm auth_keyint64非参与者或对端
// 未绑定返回 0。admin 的对端是 participant 设备,反之亦然。
func (c SecretChat) PeerAuthKeyOf(userID int64) int64 {
switch userID {
case c.AdminUserID:
return c.ParticipantAuthKeyID
case c.ParticipantUserID:
return c.AdminAuthKeyID
default:
return 0
}
}
// AccessHashFor 返回 userID 视角的 access_hash双方不同非参与者返回 0。
func (c SecretChat) AccessHashFor(userID int64) int64 {
switch userID {
case c.AdminUserID:
return c.AdminAccessHash
case c.ParticipantUserID:
return c.ParticipantAccessHash
default:
return 0
}
}
// EncryptedFileRef 是密聊文件的服务端快照P2盲中继内容是加密 bytes不解密
type EncryptedFileRef struct {
ID int64
AccessHash int64
Size int64
DCID int
KeyFingerprint int // int32 量级
}
// SecretChatMessage 是密聊 qts 投递队列里的一条不透明加密消息updateNewEncryptedMessage
// 的载荷。Bytes 是客户端加密的 DecryptedMessage服务端盲存盲转、永不解密。
// 按接收方设备ReceiverAuthKeyID的 qts 序列投递。
type SecretChatMessage struct {
ReceiverAuthKeyID int64
Qts int
ReceiverUserID int64
ChatID int
RandomID int64
Date int
IsService bool
Bytes []byte
File *EncryptedFileRef
}
// EncryptedStateEventType 区分无 qts 的密聊状态事件种类。
type EncryptedStateEventType int
const (
// EncryptedStateEventEncryptionupdateEncryption握手态变化投递时按 secret_chats
// 权威态重建(不固化快照)。
EncryptedStateEventEncryption EncryptedStateEventType = 1
// EncryptedStateEventReadupdateEncryptedMessagesRead已读回执携 MaxDate。
EncryptedStateEventRead EncryptedStateEventType = 2
)
// EncryptedStateEvent 是无 qts 的 durable 密聊状态事件(离线 getDifference 补偿)。
// TargetAuthKeyID=0 表示账号级(所有设备可见),非 0 表示绑定设备定向。
type EncryptedStateEvent struct {
ID int64
TargetUserID int64
TargetAuthKeyID int64
ChatID int
Type EncryptedStateEventType
MaxDate int
Date int
}
// SecretMessageDelivery 是 sendEncrypted* 要投递给对端设备的一条加密消息rpc 层组装,
// service 分配接收设备 qts 并落库)。
type SecretMessageDelivery struct {
RandomID int64
Bytes []byte
IsService bool
File *EncryptedFileRef
Date int
}
// SecretChatRequest 是 requestEncryption 受理入参(隐私/拉黑/self/bot 校验在 rpc 层先行)。
type SecretChatRequest struct {
AdminUserID int64
AdminAuthKeyID int64
ParticipantUserID int64
RandomID int32
GA []byte
Date int
}

View file

@ -0,0 +1,115 @@
package domain
import (
"encoding/base64"
"errors"
"strconv"
)
// Star giftpayments.sendStarsForm + inputInvoiceStarGift领域模型。目录是从已 seed 的
// animated_emoji 合成的静态集合StarGiftpeer 收到的礼物实例落 peer_star_giftsSavedStarGift
// 与 Stars 账本配合:发礼 Debit、转换回 Stars 时 Credit。
// StarGift 是一个可购买礼物目录项(合成、非用户持有)。
type StarGift struct {
ID int64
Stars int64 // 购买价Stars
ConvertStars int64 // 收礼人可转换回的 Starsv1 = Stars全额
Title string // 可选标题
Sticker Document // 礼物贴纸快照tg 投影必须是带 sticker 属性的有效 Document否则客户端丢弃
}
// SavedStarGift 是一条已收到的礼物实例peer_star_gifts 一行)。
type SavedStarGift struct {
ID int64
Owner Peer // 收礼 peeruser/channel
FromUserID int64 // 送礼人(匿名也保留真实值供账本,下发时按 NameHidden 决定是否暴露)
GiftID int64 // → StarGift.ID
MsgID int // 用户礼物的私聊 msg_id频道礼物不进历史固定为 0
SavedID int64 // 频道礼物 inputSavedStarGiftChat.saved_id用户礼物为 0
Date int // 收到时刻 Unix 秒
NameHidden bool // 送礼人请求隐藏姓名
Unsaved bool // 未展示在个人资料saveStarGift 切换)
Converted bool // 已转换回 Stars终态从列表排除
ConvertStars int64 // 转换可退回的 Stars
Message string // 附言(可选)
}
// SavedStarGiftRef 是 payments.getSavedStarGift/saveStarGift/convertStarGift 的协议中立引用。
// 用户礼物使用 inputSavedStarGiftUser.msg_id频道礼物使用 inputSavedStarGiftChat.peer + saved_id。
type SavedStarGiftRef struct {
Owner Peer
MsgID int
SavedID int64
}
// Valid reports whether the reference has the identity required by its owner kind.
func (r SavedStarGiftRef) Valid() bool {
switch r.Owner.Type {
case PeerTypeUser:
return r.Owner.ID != 0 && r.MsgID > 0
case PeerTypeChannel:
return r.Owner.ID != 0 && r.SavedID > 0
default:
return false
}
}
// SavedStarGiftPage 是一页已收到礼物 + keyset 分页游标。
type SavedStarGiftPage struct {
Gifts []SavedStarGift
NextOffset string // 空 = 无更多页(末页必须省略,客户端据此停止翻页)
Count int // 总数(未转换、按 excludeUnsaved 过滤后)
}
// Star gift 边界常量。
const (
// MaxSavedStarGiftsLimit 是 getSavedStarGifts 单页上限。
MaxSavedStarGiftsLimit = 100
// MaxStarGiftMessageRunes 限制附言长度(对齐 stargifts_message_length_max 量级)。
MaxStarGiftMessageRunes = 255
// MaxStarGiftsOffsetBytes 是 keyset 游标字符串长度上限。
MaxStarGiftsOffsetBytes = 64
)
// Star gift 哨兵错误rpc 层 errors.Is 映射为 tgerr
var (
// ErrStarGiftInvalid 表示礼物 id 不在目录里。
ErrStarGiftInvalid = errors.New("stargift: invalid gift id")
// ErrStarGiftNotFound 表示找不到该已收到礼物实例。
ErrStarGiftNotFound = errors.New("stargift: saved gift not found")
// ErrStarGiftAlreadyConverted 表示礼物已转换回 Stars不可重复转换
ErrStarGiftAlreadyConverted = errors.New("stargift: already converted")
)
// StarGiftCatalogHash 由目录的 (gift_id, stars) 折叠出稳定 hash供 getStarGifts NotModified。
func StarGiftCatalogHash(catalog []StarGift) int {
var h uint64
for _, g := range catalog {
h ^= uint64(g.ID)
h = h*0x4f25 + uint64(g.ID)
h = h*0x4f25 + uint64(g.Stars)
}
return int(h & 0x7fffffff)
}
// EncodeStarGiftCursor / DecodeStarGiftCursor 是 saved gifts keyset 游标(最后一条实例 id
func EncodeStarGiftCursor(id int64) string {
return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10)))
}
// DecodeStarGiftCursor 反解游标;无法解析(含空串)返回 ok=false调用方从首页开始
func DecodeStarGiftCursor(s string) (int64, bool) {
if s == "" {
return 0, false
}
raw, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return 0, false
}
id, err := strconv.ParseInt(string(raw), 10, 64)
if err != nil || id <= 0 {
return 0, false
}
return id, true
}

92
internal/domain/stars.go Normal file
View file

@ -0,0 +1,92 @@
package domain
import (
"encoding/base64"
"errors"
"strconv"
)
// Stars 本地账本领域模型(无 TL 类型,镜像 boost.go 风格)。本实现是本地账本、
// 非真实支付:余额为整数 Stars线上 Nanos 恒 0借记原子、永不为负。
// StarsBalance 是一个账号的当前可用 Stars 余额。
type StarsBalance struct {
UserID int64
Balance int64 // 当前可花费 Stars恒 >= 0
Granted bool // 起始授予是否已应用(惰性首读授予的幂等守卫)
}
// StarsTransactionReason 标记一条流水的语义(投影到 tg.StarsTransaction 的标志位/标题)。
type StarsTransactionReason string
const (
StarsReasonGrant StarsTransactionReason = "grant" // 起始余额自动授予
StarsReasonTopup StarsTransactionReason = "topup" // 充值(本地铸造)
StarsReasonReaction StarsTransactionReason = "reaction" // 付费 reaction 花费
StarsReasonGift StarsTransactionReason = "gift" // 星礼花费/收取
StarsReasonPaidMedia StarsTransactionReason = "paid_media" // 付费媒体解锁
StarsReasonAdjust StarsTransactionReason = "adjust" // 兜底/人工调整
)
// StarsTransaction 是一条账本流水。amount 带符号:贷记 > 0含 refund/收取),借记 < 0。
type StarsTransaction struct {
ID int64 // 单调递增账本 idkeyset 游标)
UserID int64 // 账本归属
Peer Peer // 对手方grant/topup 等无对手时为零 Peer
Amount int64 // 带符号金额
Date int // Unix 秒
Reason StarsTransactionReason
Title string // 可选,投影到 tg.StarsTransaction.Title
Description string // 可选,投影到 tg.StarsTransaction.Description
}
// IsCredit 报告该流水是否为入账(贷记),投影到 tg.StarsTransaction.Refund。
func (t StarsTransaction) IsCredit() bool { return t.Amount > 0 }
// StarsTransactionPage 是一页账本流水 + 当前余额 + 分页游标 + 对手方用户富化集合。
type StarsTransactionPage struct {
Balance int64
Transactions []StarsTransaction
NextOffset string // 空表示无更多页DrKLO 据此停止翻页,勿在末页给非空值)
Users []User // History 中提到的对手方用户,供 tg Users 富化
}
// Stars 账本边界常量。
const (
// DefaultStarsStartingGrant 是惰性首读授予的起始 Stars 余额(本地测试用)。
DefaultStarsStartingGrant = 1000
// MaxStarsTransactionsLimit 是 getStarsTransactions 单页上限。
MaxStarsTransactionsLimit = 100
// MaxStarsTransactionsOffsetBytes 是 keyset 游标字符串长度上限。
MaxStarsTransactionsOffsetBytes = 64
)
// Stars 账本哨兵错误rpc 层 errors.Is 匹配后映射为 tgerr仿 ErrPremiumRequired
var (
// ErrStarsInsufficient 表示余额不足以完成借记(映射 BALANCE_TOO_LOW
ErrStarsInsufficient = errors.New("stars: insufficient balance")
// ErrStarsInvalidAmount 表示金额非法(<=0
ErrStarsInvalidAmount = errors.New("stars: invalid amount")
)
// EncodeStarsCursor 把 keyset 游标(最后一条流水 id编码为客户端不透明字符串。
func EncodeStarsCursor(id int64) string {
return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10)))
}
// DecodeStarsCursor 反解 EncodeStarsCursor无法解析含空串时返回 ok=false
// 调用方应据此从首页开始(客户端只会回传我们给过的游标,畸形仅作兜底)。
func DecodeStarsCursor(s string) (int64, bool) {
if s == "" {
return 0, false
}
raw, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return 0, false
}
id, err := strconv.ParseInt(string(raw), 10, 64)
if err != nil || id <= 0 {
return 0, false
}
return id, true
}

View file

@ -0,0 +1,46 @@
package domain
import "errors"
// ErrStickerInvalid 表示输入文档不是合法的贴纸/GIFfaveSticker/saveGif 等)。
var ErrStickerInvalid = errors.New("sticker document invalid")
// StickerCollectionKind 区分一个用户的几类个人贴纸集合。
type StickerCollectionKind string
const (
// StickerCollectionFaved 收藏的贴纸messages.faveSticker / getFavedStickers
StickerCollectionFaved StickerCollectionKind = "faved"
// StickerCollectionRecent 最近使用的贴纸saveRecentSticker / getRecentStickers
StickerCollectionRecent StickerCollectionKind = "recent"
// StickerCollectionRecentAttached attach 菜单里最近用于媒体的贴纸attached=true
StickerCollectionRecentAttached StickerCollectionKind = "recent_attached"
// StickerCollectionGif 保存的 GIFmessages.saveGif / getSavedGifs
StickerCollectionGif StickerCollectionKind = "gif"
)
// 各集合容量上界(最新在前,超出截断最旧)。仅为限制存储增长,非严格 Telegram 配额;
// faved 不做 premium 分档(用户决策范围外),统一一个上界。
const (
MaxFavedStickers = 100
MaxRecentStickers = 30
MaxSavedGifs = 200
)
// MaxStickerCollectionItems 返回某类集合的容量上界。
func MaxStickerCollectionItems(kind StickerCollectionKind) int {
switch kind {
case StickerCollectionFaved:
return MaxFavedStickers
case StickerCollectionGif:
return MaxSavedGifs
default:
return MaxRecentStickers
}
}
// StickerCollectionItem 是集合内一项:文档 id + 入列时间recent 的 used_at / faved 的收藏时刻)。
type StickerCollectionItem struct {
DocumentID int64
Date int
}

557
internal/domain/story.go Normal file
View file

@ -0,0 +1,557 @@
package domain
import (
"errors"
"fmt"
"hash/fnv"
"strconv"
"strings"
)
const (
// MaxStoryID keeps story ids inside TL int / PostgreSQL int4 bounds.
MaxStoryID = MaxMessageBoxID
// MaxStoryIDs limits exact-id story RPCs and view increments.
MaxStoryIDs = 200
// MaxStoryListLimit bounds one active/archive/pinned story page.
MaxStoryListLimit = 100
// MaxStoryInteractionListLimit bounds one story viewer/reaction page.
MaxStoryInteractionListLimit = 100
// MaxStoryInteractionOffsetLength bounds server-generated owner interaction
// cursors before they reach store-side parsing or query predicates.
MaxStoryInteractionOffsetLength = 64
// MaxStoryAlbumOffset bounds stories.getAlbumStories count-offset paging
// before a future album store can turn malformed requests into deep OFFSET
// work. DrKLO sends loadedObjects.size(), so normal UI paging stays small.
MaxStoryAlbumOffset = 10000
// MaxStoryPinnedToTop bounds stories.togglePinnedToTop. It matches the
// client default stories_pinned_to_top_count_max used by TDesktop/DrKLO.
MaxStoryPinnedToTop = 3
// MaxStoryPrivacyFanoutTargets bounds one privacy-change fanout candidate set.
MaxStoryPrivacyFanoutTargets = 5000
// MaxStorySendAsChannels bounds stories.getChatsToSend channel candidates.
MaxStorySendAsChannels = 200
// MaxStoryMediaAreas bounds one story's overlay/click target vector.
MaxStoryMediaAreas = 16
// MaxStoryMediaAreaURLLength bounds mediaAreaUrl click targets stored in
// one story snapshot.
MaxStoryMediaAreaURLLength = 2048
// MaxStoryStarGiftSlugLength bounds collectible gift slugs stored in one
// story snapshot. TDesktop/DrKLO treat the slug as a deep-link path token.
MaxStoryStarGiftSlugLength = 255
// MaxStoryGeoAddressPartLength bounds optional mediaAreaGeoPoint address
// labels stored in one story snapshot.
MaxStoryGeoAddressPartLength = 256
// MaxStoryWeatherEmojiLength bounds mediaAreaWeather emoji labels stored in
// one story snapshot.
MaxStoryWeatherEmojiLength = 32
// DefaultStoryPeriod is the Layer 225 sendStory period when the optional
// period flag is absent.
DefaultStoryPeriod = 86400
// MaxStoryViewQueryLength bounds q in stories.getStoryViewsList before it
// reaches store-side LIKE/search predicates.
MaxStoryViewQueryLength = 128
// DefaultStoryCanSendRemaining is the development-stage story quota returned
// by canSendStory until production limits are modeled.
DefaultStoryCanSendRemaining = 100
)
var (
ErrStoryIDInvalid = errors.New("story id invalid")
ErrStoryNotFound = errors.New("story not found")
ErrStoryPeerInvalid = errors.New("story peer invalid")
ErrStoryNotModified = errors.New("story not modified")
ErrStoryOffsetInvalid = errors.New("story offset invalid")
ErrStoryPeriodInvalid = errors.New("story period invalid")
)
// ValidateStoryInteractionOffset validates stories.getStoryViewsList and
// stories.getStoryReactionsList keyset cursors without depending on TL types.
func ValidateStoryInteractionOffset(offset string, reactionsOnly bool) error {
return validateStoryInteractionOffset(offset, reactionsOnly, false)
}
// ValidateStoryReactionInteractionOffset validates stories.getStoryReactionsList
// cursors. When forwardsFirst is set, reaction-list pages may legitimately use
// group 1 for ordinary reaction rows after public repost rows.
func ValidateStoryReactionInteractionOffset(offset string, forwardsFirst bool) error {
return validateStoryInteractionOffset(offset, true, forwardsFirst)
}
func validateStoryInteractionOffset(offset string, reactionsOnly, forwardsFirst bool) error {
if offset == "" {
return nil
}
if len(offset) > MaxStoryInteractionOffsetLength {
return ErrStoryOffsetInvalid
}
parts := strings.Split(offset, ":")
if len(parts) != 3 && len(parts) != 4 {
return ErrStoryOffsetInvalid
}
group, err1 := strconv.Atoi(parts[0])
date, err2 := strconv.Atoi(parts[1])
viewerID, err3 := strconv.ParseInt(parts[2], 10, 64)
var messageID int
var err4 error
if len(parts) == 4 {
messageID, err4 = strconv.Atoi(parts[3])
}
reactionGroupInvalid := reactionsOnly && group != 0 && !(forwardsFirst && group == 1)
if err1 != nil || err2 != nil || err3 != nil || err4 != nil ||
group < 0 || group > 1 || reactionGroupInvalid ||
date <= 0 || viewerID == 0 || messageID < 0 || messageID > MaxMessageBoxID {
return ErrStoryOffsetInvalid
}
return nil
}
// Story is a protocol-neutral Telegram story snapshot owned by one peer.
type Story struct {
Owner Peer
ID int
RandomID int64
Date int
ExpireDate int
Deleted bool
Pinned bool
PinnedToTopOrder int
Public bool
CloseFriends bool
Contacts bool
SelectedContacts bool
NoForwards bool
Edited bool
Out bool
PrivacyRules []PrivacyRule
AllowUserIDs []int64
DisallowUserIDs []int64
Caption string
Entities []MessageEntity
Media *MessageMedia
MediaAreas []StoryMediaArea
Forward *StoryForward
Views StoryViews
SentReaction *MessageReaction
}
// Active reports whether the story belongs in the active strip at now.
func (s Story) Active(now int) bool {
return !s.Deleted && s.ExpireDate > now
}
// Interactable reports whether a viewer may create new view/reaction state.
// Expired profile-pinned stories remain visible from profiles, but ordinary
// expired stories must not accept stale interaction writes.
func (s Story) Interactable(now int) bool {
return !s.Deleted && (s.ExpireDate > now || s.Pinned)
}
// VisibleTo reports story visibility with no relationship facts.
func (s Story) VisibleTo(viewerUserID int64) bool {
return s.VisibleToWithFacts(viewerUserID, false, false)
}
// VisibleToWithFacts reports story visibility using already-loaded viewer facts.
func (s Story) VisibleToWithFacts(viewerUserID int64, viewerIsContact, viewerCloseFriend bool) bool {
return s.VisibleToWithStoryFacts(viewerUserID, viewerIsContact, viewerCloseFriend, false)
}
// VisibleToWithStoryFacts reports story visibility using already-loaded viewer
// facts, including owner-side story blocklist membership.
func (s Story) VisibleToWithStoryFacts(viewerUserID int64, viewerIsContact, viewerCloseFriend, viewerStoryBlocked bool) bool {
if viewerUserID == 0 {
return false
}
if s.Owner.Type == PeerTypeUser && s.Owner.ID == viewerUserID {
return true
}
if viewerStoryBlocked {
return false
}
if int64InSet(s.DisallowUserIDs, viewerUserID) {
return false
}
if int64InSet(s.AllowUserIDs, viewerUserID) {
return true
}
if s.Public {
return true
}
if s.Contacts && viewerIsContact {
return true
}
if s.CloseFriends && viewerCloseFriend {
return true
}
return false
}
func int64InSet(ids []int64, needle int64) bool {
for _, id := range ids {
if id == needle {
return true
}
}
return false
}
// StoryForward contains the protocol-neutral source of a reposted story.
type StoryForward struct {
// Source is the durable server-side source story owner used for counters
// and owner interaction lists. From is the client-visible clickable peer
// and may be empty when forwards privacy requires a from_name-only header.
Source Peer
From Peer
FromName string
StoryID int
Modified bool
}
// StoryViews is the owner-visible aggregate of story views and reactions.
type StoryViews struct {
ViewsCount int
ForwardsCount int
ReactionsCount int
Reactions []ChannelMessageReactionCount
RecentViewers []int64
HasViewers bool
}
// StoryView is one viewer's durable view/reaction row.
type StoryView struct {
Owner Peer
StoryID int
ViewerID int64
Date int
Reaction *MessageReaction
// Repost is set for public repost interactions. It is protocol-neutral and
// converted by rpc to storyViewPublicRepost.
Repost *Story
PublicForward *StoryPublicForward
Blocked bool
BlockedMyStoriesFrom bool
}
// StoryPublicForward is a public channel/supergroup message that shared a story
// via messageMediaStory. It is converted by rpc to storyViewPublicForward or
// storyReactionPublicForward.
type StoryPublicForward struct {
Message ChannelMessage
}
// StoryViewListRequest pages owner-visible story viewers.
type StoryViewListRequest struct {
ViewerUserID int64
Owner Peer
StoryID int
Offset string
Limit int
Query string
JustContacts bool
ReactionsFirst bool
ForwardsFirst bool
}
// StoryViewList is a bounded page for stories.getStoryViewsList.
type StoryViewList struct {
Count int
ViewsCount int
ForwardsCount int
ReactionsCount int
Views []StoryView
NextOffset string
}
// StoryReactionListRequest pages peers that reacted to one story.
type StoryReactionListRequest struct {
ViewerUserID int64
Owner Peer
StoryID int
Reaction *MessageReaction
Offset string
Limit int
ForwardsFirst bool
CanViewOwnerInteractions bool
}
// StoryReactionList is a bounded page for stories.getStoryReactionsList.
type StoryReactionList struct {
Count int
Reactions []StoryView
NextOffset string
}
// StoryMessageForwardListRequest pages public channel/supergroup messages that
// shared one source story as messageMediaStory.
type StoryMessageForwardListRequest struct {
ViewerUserID int64
Owner Peer
StoryID int
Offset string
Limit int
ReactionsFirst bool
ForwardsFirst bool
}
// StoryMessageForwardList is a bounded page of public message forwards.
type StoryMessageForwardList struct {
Count int
Forwards []StoryView
NextOffset string
}
// StoryPublicForwardListRequest pages public repost stories for one source story.
type StoryPublicForwardListRequest struct {
ViewerUserID int64
Owner Peer
StoryID int
Offset string
Limit int
}
// StoryPublicForwardList is a bounded page of public repost story forwards.
type StoryPublicForwardList struct {
Count int
Forwards []StoryView
NextOffset string
}
// PeerStories is the story list for one owner peer from one viewer's perspective.
type PeerStories struct {
Peer Peer
MaxReadID int
Stories []Story
Users []User
Channels []Channel
}
// StoryList is a bounded story page.
type StoryList struct {
Count int
State string
HasMore bool
// Hidden reports that this list is the viewer's hidden story source list.
Hidden bool
Stories []Story
// PinnedToTop contains the owner-visible top-pinned story IDs in display
// order for stories.stories.pinned_to_top.
PinnedToTop []int
Peers []PeerStories
Users []User
Channels []Channel
}
// StoryListCursor is the opaque getAllStories seek position after one peer.
type StoryListCursor struct {
Set bool
Date int
Peer Peer
}
// StoryListDigest summarizes a complete viewer-scoped active story source list.
type StoryListDigest struct {
Count int
Hash uint64
}
// DigestStoryPeerList returns a deterministic digest for an already ordered
// complete active/hidden story peer list.
func DigestStoryPeerList(peers []PeerStories) StoryListDigest {
h := fnv.New64a()
fmt.Fprintf(h, "story-list|count=%d|", len(peers))
for _, peer := range peers {
fmt.Fprintf(h, "p=%s:%d:%d:%d|", peer.Peer.Type, peer.Peer.ID, peer.MaxReadID, len(peer.Stories))
for _, story := range peer.Stories {
fmt.Fprintf(
h,
"s=%d:%d:%d:%t:%t:%d:%t:%t:%t:%t:%t:%t:%d:%d:%#v:%#v:%#v:%#v:%#v:%#v:%#v:%#v|",
story.ID,
story.Date,
story.ExpireDate,
story.Deleted,
story.Pinned,
story.PinnedToTopOrder,
story.Public,
story.CloseFriends,
story.Contacts,
story.SelectedContacts,
story.Edited,
story.Out,
story.Views.ViewsCount,
story.Views.ReactionsCount,
story.SentReaction,
story.Caption,
story.Entities,
story.Media,
story.MediaAreas,
story.Forward,
story.PrivacyRules,
story.AllowUserIDs,
)
fmt.Fprintf(h, "disallow=%#v|noforwards=%t|", story.DisallowUserIDs, story.NoForwards)
}
}
return StoryListDigest{Count: len(peers), Hash: h.Sum64()}
}
// StoryReadState is the per viewer+owner read boundary.
type StoryReadState struct {
ViewerID int64
Peer Peer
MaxReadID int
Date int
}
// RecentStory is the domain counterpart of TL recentStory.
type RecentStory struct {
Peer Peer
MaxID int
Live bool
}
// PeerStoryProjection is the per-viewer story summary embedded into peer objects.
type PeerStoryProjection struct {
Peer Peer
Recent RecentStory
Hidden bool
}
// StoryReadResult describes a readStories mutation.
type StoryReadResult struct {
ViewerID int64
Peer Peer
MaxReadID int
Advanced bool
Date int
}
// StoryReactionResult describes one story reaction mutation.
type StoryReactionResult struct {
ViewerID int64
Peer Peer
StoryID int
Reaction *MessageReaction
Story Story
Changed bool
Date int
}
// StoryMediaAreaKind identifies a protocol-neutral story overlay area.
type StoryMediaAreaKind string
const (
StoryMediaAreaSuggestedReaction StoryMediaAreaKind = "suggested_reaction"
StoryMediaAreaURL StoryMediaAreaKind = "url"
StoryMediaAreaGeoPoint StoryMediaAreaKind = "geo_point"
StoryMediaAreaVenue StoryMediaAreaKind = "venue"
StoryMediaAreaWeather StoryMediaAreaKind = "weather"
StoryMediaAreaChannelPost StoryMediaAreaKind = "channel_post"
StoryMediaAreaStarGift StoryMediaAreaKind = "star_gift"
)
// StoryMediaAreaCoordinates is a percentage-based story media overlay box.
type StoryMediaAreaCoordinates struct {
X float64
Y float64
W float64
H float64
Rotation float64
Radius float64
HasRadius bool
}
// StoryGeoPointAddress is the protocol-neutral counterpart of geoPointAddress.
type StoryGeoPointAddress struct {
CountryISO2 string
State string
City string
Street string
}
// StoryMediaArea is a protocol-neutral media area stored with a story snapshot.
type StoryMediaArea struct {
Kind StoryMediaAreaKind
Coordinates StoryMediaAreaCoordinates
Dark bool
Flipped bool
Reaction *MessageReaction
URL string
Geo *MessageGeoPoint
GeoAddress *StoryGeoPointAddress
Venue *MessageVenue
WeatherEmoji string
TemperatureC float64
Color int
ChannelID int64
MsgID int
StarGiftSlug string
}
// StoryCreateRequest creates one owner story with a store-assigned monotonic ID.
type StoryCreateRequest struct {
Owner Peer
RandomID int64
Date int
Period int
Pinned bool
Public bool
CloseFriends bool
Contacts bool
SelectedContacts bool
NoForwards bool
PrivacyRules []PrivacyRule
AllowUserIDs []int64
DisallowUserIDs []int64
Caption string
Entities []MessageEntity
Media *MessageMedia
MediaAreas []StoryMediaArea
Forward *StoryForward
}
// StoryCreateResult describes a sendStory mutation.
type StoryCreateResult struct {
Story Story
Duplicate bool
}
// StoryEditRequest applies a partial story edit.
type StoryEditRequest struct {
Owner Peer
ID int
Media *MessageMedia
UpdateMedia bool
Caption string
Entities []MessageEntity
UpdateCaption bool
Public bool
CloseFriends bool
Contacts bool
SelectedContacts bool
PrivacyRules []PrivacyRule
AllowUserIDs []int64
DisallowUserIDs []int64
UpdatePrivacy bool
MediaAreas []StoryMediaArea
UpdateMediaAreas bool
}
// StoryEditResult describes a story edit and its previous visible facts.
type StoryEditResult struct {
Story Story
Previous Story
}
// StoryMutationResult describes story mutations that may emit updateStory.
type StoryMutationResult struct {
Peer Peer
IDs []int
Stories []Story
Previous []Story
}
// UpsertStoryRequest inserts or replaces a story snapshot.
type UpsertStoryRequest struct {
Story Story
}

View file

@ -3,6 +3,11 @@ package domain
const (
// OfficialSystemUserID 是 Telegram 兼容客户端识别的官方系统账号。
OfficialSystemUserID int64 = 777000
// BotFatherUserID 是内置 BotFather 账号,与官方 @BotFather 同 ID。
BotFatherUserID int64 = 93372553
// BotFatherAccessHash 固定不变;与迁移 0090 的种子行双写,必须保持一致。
BotFatherAccessHash int64 = 7421896403922962293
)
// OfficialSystemUser 返回第一阶段内置的官方系统账号。
@ -17,3 +22,47 @@ func OfficialSystemUser() User {
Support: true,
}
}
// BotFatherUser 返回内置 BotFather 账号。username 不以 bot 结尾属种子例外(与官方一致)。
func BotFatherUser() User {
return User{
ID: BotFatherUserID,
AccessHash: BotFatherAccessHash,
FirstName: "BotFather",
Username: "BotFather",
Verified: true,
Bot: true,
BotInfoVersion: 1,
}
}
// SystemUserByID 返回内置系统账号;非系统账号返回 ok=false。
// 所有对 777000 的硬编码注入点统一经此函数,新增内置账号只改这里。
func SystemUserByID(id int64) (User, bool) {
switch id {
case OfficialSystemUserID:
return OfficialSystemUser(), true
case BotFatherUserID:
return BotFatherUser(), true
}
return User{}, false
}
func IsSystemUserID(id int64) bool {
_, ok := SystemUserByID(id)
return ok
}
func SystemUserByPhone(phone string) (User, bool) {
phone = NormalizePhone(phone)
for _, id := range []int64{OfficialSystemUserID, BotFatherUserID} {
u, ok := SystemUserByID(id)
if !ok || u.Phone == "" {
continue
}
if NormalizePhone(u.Phone) == phone {
return u, true
}
}
return User{}, false
}

185
internal/domain/themes.go Normal file
View file

@ -0,0 +1,185 @@
package domain
import "errors"
// 自定义云主题(account.createTheme 等)相关错误。
var (
ErrThemeNotFound = errors.New("theme not found")
ErrThemeInvalid = errors.New("theme invalid")
ErrThemeSlugTaken = errors.New("theme slug taken")
ErrThemeFormatInvalid = errors.New("theme format invalid")
)
// ThemeBaseKind 对应 TL baseTheme 的五个变体(classic/day/night/tinted/arctic)。
// 客户端按当前 base theme 选取匹配的 ThemeSettings。
type ThemeBaseKind string
const (
ThemeBaseClassic ThemeBaseKind = "classic"
ThemeBaseDay ThemeBaseKind = "day"
ThemeBaseNight ThemeBaseKind = "night"
ThemeBaseTinted ThemeBaseKind = "tinted"
ThemeBaseArctic ThemeBaseKind = "arctic"
)
// ThemeWallpaperSpec 是一份主题设置里的纯渐变墙纸(对应 wallPaperNoFile + wallPaperSettings)。
// 自定义云主题只用纯色渐变(无下载),与静态目录一致。
type ThemeWallpaperSpec struct {
BackgroundColors []int `json:"background_colors,omitempty"` // 最多 4 个
Intensity int `json:"intensity,omitempty"`
Rotation int `json:"rotation,omitempty"`
Blur bool `json:"blur,omitempty"`
Motion bool `json:"motion,omitempty"`
Emoticon string `json:"emoticon,omitempty"`
Dark bool `json:"dark,omitempty"`
}
// WallpaperSettings 是频道/私聊 wallpaper 的 domain-only 设置。Has* 字段保留 TL
// conditional flag 语义,避免黑色(0)这类合法取值在持久化时丢失。
type WallpaperSettings struct {
Blur bool `json:"blur,omitempty"`
Motion bool `json:"motion,omitempty"`
HasBackgroundColor bool `json:"has_background_color,omitempty"`
BackgroundColor int `json:"background_color,omitempty"`
HasSecondBackgroundColor bool `json:"has_second_background_color,omitempty"`
SecondBackgroundColor int `json:"second_background_color,omitempty"`
HasThirdBackgroundColor bool `json:"has_third_background_color,omitempty"`
ThirdBackgroundColor int `json:"third_background_color,omitempty"`
HasFourthBackgroundColor bool `json:"has_fourth_background_color,omitempty"`
FourthBackgroundColor int `json:"fourth_background_color,omitempty"`
HasIntensity bool `json:"has_intensity,omitempty"`
Intensity int `json:"intensity,omitempty"`
HasRotation bool `json:"has_rotation,omitempty"`
Rotation int `json:"rotation,omitempty"`
HasEmoticon bool `json:"has_emoticon,omitempty"`
Emoticon string `json:"emoticon,omitempty"`
}
// Empty reports whether no explicit wallpaper rendering settings are present.
func (s WallpaperSettings) Empty() bool {
return !s.Blur &&
!s.Motion &&
!s.HasBackgroundColor &&
!s.HasSecondBackgroundColor &&
!s.HasThirdBackgroundColor &&
!s.HasFourthBackgroundColor &&
!s.HasIntensity &&
!s.HasRotation &&
!s.HasEmoticon
}
// Wallpaper 是频道/私聊当前 wallpaper 的 domain-only 描述。NoFile=true 对应
// wallPaperNoFile否则 ID+AccessHash/Slug 引用 catalog/document wallpaper。
type Wallpaper struct {
ID int64 `json:"id,omitempty"`
AccessHash int64 `json:"access_hash,omitempty"`
Slug string `json:"slug,omitempty"`
NoFile bool `json:"no_file,omitempty"`
Default bool `json:"default,omitempty"`
Pattern bool `json:"pattern,omitempty"`
Dark bool `json:"dark,omitempty"`
Settings WallpaperSettings `json:"settings,omitempty"`
}
// CloneWallpaperPtr returns a detached wallpaper pointer.
func CloneWallpaperPtr(in *Wallpaper) *Wallpaper {
if in == nil {
return nil
}
out := *in
return &out
}
// WallpaperEqual compares optional wallpapers by value.
func WallpaperEqual(a, b *Wallpaper) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}
// ThemeSettingsSpec 是一个 base theme 下的配色(对应 themeSettings/inputThemeSettings)。
// BaseTheme 与 AccentColor 必填(TL 编码器要求 base_theme 非空)。
type ThemeSettingsSpec struct {
BaseTheme ThemeBaseKind `json:"base_theme"`
AccentColor int `json:"accent_color"`
OutboxAccentColor int `json:"outbox_accent_color,omitempty"`
HasOutboxAccent bool `json:"has_outbox_accent,omitempty"`
MessageColors []int `json:"message_colors,omitempty"`
MessageColorsAnimated bool `json:"message_colors_animated,omitempty"`
Wallpaper *ThemeWallpaperSpec `json:"wallpaper,omitempty"`
}
// Theme 是一份持久化的自定义云主题。document_id 软引用 documents(无硬外键),
// settings 仅 accent 主题非空;完整 .attheme 主题靠 DocumentID 下载。
type Theme struct {
ID int64
AccessHash int64
CreatorUserID int64
Slug string
Title string
Emoticon string
ForChat bool
DocumentID int64
Settings []ThemeSettingsSpec
InstallsCount int
CreatedAt int64 // unix 秒
}
// Clone 深拷贝(切片字段),避免内存 store 暴露内部引用。
func (t Theme) Clone() Theme {
out := t
out.Settings = cloneThemeSettings(t.Settings)
return out
}
func cloneThemeSettings(in []ThemeSettingsSpec) []ThemeSettingsSpec {
if in == nil {
return nil
}
out := make([]ThemeSettingsSpec, len(in))
for i, s := range in {
out[i] = s
out[i].MessageColors = append([]int(nil), s.MessageColors...)
if s.Wallpaper != nil {
wp := *s.Wallpaper
wp.BackgroundColors = append([]int(nil), s.Wallpaper.BackgroundColors...)
out[i].Wallpaper = &wp
}
}
return out
}
// IsCreator 报告 userID 是否为该主题的创建者(决定客户端再次上传走 create 还是 update)。
func (t Theme) IsCreator(userID int64) bool {
return t.CreatorUserID != 0 && t.CreatorUserID == userID
}
// ThemeRef 引用一份主题:按 id+access_hash(inputTheme)或 slug(inputThemeSlug,深链)。
type ThemeRef struct {
ID int64
AccessHash int64
Slug string
}
// IsZero 报告该引用是否为空(既无 id 也无 slug)。
func (r ThemeRef) IsZero() bool { return r.ID == 0 && r.Slug == "" }
// ThemeSpec 是 createTheme 的输入。Slug 为空表示由服务端自动分配。
type ThemeSpec struct {
CreatorUserID int64
Slug string
Title string
Emoticon string
ForChat bool
DocumentID int64
Settings []ThemeSettingsSpec
}
// ThemeUpdate 是 updateTheme 的部分更新;nil 字段表示不改。
type ThemeUpdate struct {
Slug *string
Title *string
DocumentID *int64
Settings *[]ThemeSettingsSpec
}

View file

@ -4,25 +4,65 @@ package domain
type UpdateEventType string
const (
UpdateEventNewMessage UpdateEventType = "new_message"
UpdateEventReadHistoryInbox UpdateEventType = "read_history_inbox"
UpdateEventReadHistoryOutbox UpdateEventType = "read_history_outbox"
UpdateEventReadMessageContents UpdateEventType = "read_message_contents"
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"
UpdateEventNewMessage UpdateEventType = "new_message"
UpdateEventReadHistoryInbox UpdateEventType = "read_history_inbox"
UpdateEventReadHistoryOutbox UpdateEventType = "read_history_outbox"
// forum 话题级已读messages.readDiscussion 推进 per-topic 水位后下发。
UpdateEventReadChannelDiscussionInbox UpdateEventType = "read_channel_discussion_inbox"
UpdateEventReadChannelDiscussionOutbox UpdateEventType = "read_channel_discussion_outbox"
UpdateEventReadMessageContents UpdateEventType = "read_message_contents"
UpdateEventEditMessage UpdateEventType = "edit_message"
// UpdateEventWebPage 映射 updateWebPage异步解析完成后把消息里的 pending 链接预览
// 占位就地替换为已解析卡片。携带账号 pts非 LacksWirePts消息快照经 box JOIN 重建,
// 故 difference/dispatch 与 edit_message 同走通用消息事件路径,仅 tg 投影构造器不同。
UpdateEventWebPage UpdateEventType = "web_page"
UpdateEventMessageReactions UpdateEventType = "message_reactions"
// UpdateEventMessagePoll 映射 updateMessagePoll投票/关闭后 poll 状态变化;
// Message 为该 owner 视角消息media 在 difference 重放时按 viewer 重新 enrich
// 与 reaction 同款:占账号 pts 但 TL 构造器无 pts见 LacksWirePts。
UpdateEventMessagePoll UpdateEventType = "message_poll"
UpdateEventContactsReset UpdateEventType = "contacts_reset"
UpdateEventDialogPinned UpdateEventType = "dialog_pinned"
UpdateEventPinnedDialogs UpdateEventType = "pinned_dialogs"
UpdateEventDialogUnreadMark UpdateEventType = "dialog_unread_mark"
UpdateEventPeerSettings UpdateEventType = "peer_settings"
UpdateEventPeerStoryBlocked UpdateEventType = "peer_story_blocked"
UpdateEventDeleteMessages UpdateEventType = "delete_messages"
// UpdateEventPinnedMessages 映射 updatePinnedMessages私聊置顶/取消
// 置顶MessageIDs 是该 owner 自己视角的 box idBool 为 pinned
// TL 构造器自带账号 pts/pts_count不属于 LacksWirePts。
UpdateEventPinnedMessages UpdateEventType = "pinned_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"
UpdateEventStory UpdateEventType = "story"
UpdateEventReadStories UpdateEventType = "read_stories"
UpdateEventSentStoryReaction UpdateEventType = "sent_story_reaction"
UpdateEventNewStoryReaction UpdateEventType = "new_story_reaction"
UpdateEventQuickReplies UpdateEventType = "quick_replies"
UpdateEventNewQuickReply UpdateEventType = "new_quick_reply"
UpdateEventDeleteQuickReply UpdateEventType = "delete_quick_reply"
UpdateEventQuickReplyMessage UpdateEventType = "quick_reply_message"
UpdateEventDeleteQuickReplyMessages UpdateEventType = "delete_quick_reply_messages"
// UpdateEventChannelState 表示当前账号与某频道的成员关系发生变化
// leave/kick离线设备经 difference 收到 updateChannel 后重新拉取
// channel 状态并移除会话。
UpdateEventChannelState UpdateEventType = "channel_state"
// UpdateEventDraftMessage 映射 updateDraftMessage云草稿变更Peer 为会话,
// MaxID 复用为 forum top_msg_id。事件只是"该会话草稿变过"的标记——草稿是
// 绝对状态而非增量difference/outbox 重放时按 Peer 重载当前草稿填充 Draft
// 字段(已删则下发 draftMessageEmpty不在事件行里固化内容快照。
UpdateEventDraftMessage UpdateEventType = "draft_message"
// UpdateEventSavedDialogPinned 映射 updateSavedDialogPinned收藏夹
// 子会话置顶翻转Peer 为子会话分组键Bool 为 pinned
UpdateEventSavedDialogPinned UpdateEventType = "saved_dialog_pinned"
// UpdateEventPinnedSavedDialogs 映射 updatePinnedSavedDialogs
// 收藏夹置顶顺序整表Peers 为新顺序)。
UpdateEventPinnedSavedDialogs UpdateEventType = "pinned_saved_dialogs"
UpdateEventNoop UpdateEventType = "noop"
)
// UpdateEvent 是账号视角的增量事件,按 user_id + pts 顺序持久化。
@ -33,6 +73,7 @@ type UpdateEvent struct {
PtsCount int
Date int
Message Message
Story Story
Peer Peer
Peers []Peer
Bool bool
@ -40,13 +81,73 @@ type UpdateEvent struct {
MessageIDs []int
MaxID int
StillUnreadCount int
Users []User
Channels []Channel
FilterID int
DialogFilter *DialogFolder
FilterOrder []int
FolderPeers []FolderPeerUpdate
TagsEnabled bool
ChannelPts int
// TopMsgID 仅 forum per-topic 已读事件read_channel_discussion_*)使用:承载话题 id
// General=1与 MaxID(=read_max_id) 一起映射 updateReadChannelDiscussionInbox/Outbox。
TopMsgID int
Users []User
Channels []Channel
FilterID int
// FolderID 是 read 类事件发生时该会话所在的物理 folder0 主列表/1 归档),
// 填入 updateReadChannelInbox.folder_id。
FolderID int
DialogFilter *DialogFolder
// Draft 仅 draft_message 事件使用不持久化difference/outbox 重放时按
// Peer(+MaxID=top_msg_id) 重载当前草稿填充nil 表示草稿已删(下发 empty
Draft *DialogDraft
FilterOrder []int
FolderPeers []FolderPeerUpdate
TagsEnabled bool
Reaction *MessageReaction
QuickReplies []QuickReply
QuickReply QuickReply
QuickReplyMessage QuickReplyMessage
}
// LacksWirePts 表示该事件占用了账号 pts但它对应的 TL update 构造器没有
// 账号 pts 字段reaction、channel 已读、dialog/folder/settings 状态类)。
// 在线投递这类事件必须附带显式 pts 簿记,否则客户端水位与服务端错位,
// 下一条真正带 pts 的更新会被判为空洞。
func (e UpdateEvent) LacksWirePts() bool {
switch e.Type {
case UpdateEventMessageReactions,
UpdateEventMessagePoll,
UpdateEventDraftMessage,
UpdateEventChannelState,
UpdateEventContactsReset,
UpdateEventDialogPinned,
UpdateEventPinnedDialogs,
UpdateEventSavedDialogPinned,
UpdateEventPinnedSavedDialogs,
UpdateEventDialogUnreadMark,
UpdateEventPeerSettings,
UpdateEventPeerStoryBlocked,
UpdateEventDialogFilter,
UpdateEventDialogFilterOrder,
UpdateEventDialogFilters,
UpdateEventChannelAvailable,
UpdateEventChannelViewForum,
UpdateEventStory,
UpdateEventReadStories,
UpdateEventSentStoryReaction,
UpdateEventNewStoryReaction,
UpdateEventQuickReplies,
UpdateEventNewQuickReply,
UpdateEventDeleteQuickReply,
UpdateEventQuickReplyMessage,
UpdateEventDeleteQuickReplyMessages:
// updateFolderPeers 自带 pts/pts_count不在此列。
return true
case UpdateEventReadChannelDiscussionInbox, UpdateEventReadChannelDiscussionOutbox:
// forum 话题已读映射 updateReadChannelDiscussionInbox/Outbox无账号 pts 字段。
return true
case UpdateEventReadHistoryInbox, UpdateEventReadHistoryOutbox:
// channel peer 映射 updateReadChannelInbox/Outbox无账号 pts 字段;
// 私聊形态自带 pts/pts_count。
return e.Peer.Type == PeerTypeChannel
default:
return false
}
}
// UpdateDifference 是 updates.getDifference 的业务层结果。
@ -63,4 +164,5 @@ type UpdateDifference struct {
type ChannelDifferenceNudge struct {
ChannelID int64
Pts int
Channel *ChannelView
}

View file

@ -6,6 +6,19 @@ package domain
// 777000 等兼容系统账号低于该区间,业务注册用户从这里开始递增。
const UserIDSequenceBase int64 = 1780243200
// PeerColor is a domain-only representation of Telegram peerColor.
// HasColor preserves explicit color=0, which is distinct from "color unset".
type PeerColor struct {
HasColor bool
Color int
BackgroundEmojiID int64
}
// Empty reports whether no explicit color/profile color state is set.
func (c PeerColor) Empty() bool {
return !c.HasColor && c.BackgroundEmojiID == 0
}
// User 是一个账号。第一阶段仅保留登录链路必须字段;
// access_hash 为任何 InputUser 校验所必须,不可省。
type User struct {
@ -21,15 +34,53 @@ type User struct {
Support bool
Contact bool
Mutual bool
CloseFriend bool
// Bot 标识 bot 账号;置位时 BotInfoVersion 必须 ≥1TDesktop 只认
// user TL 是否携带 bot_info_version 字段,且与 bot flag 共用 bit14
Bot bool
BotInfoVersion int
// PremiumUntil 是会员到期 Unix 秒0 表示非会员。premium 状态的唯一权威
// 来源是该字段,由读取路径经 PremiumActiveAt 即时派生——到期后无需等
// 后台 sweeper 翻转即停止下发 premiumsweeper 只负责清理与通知推送)。
PremiumUntil int
// EmojiStatusDocumentID / EmojiStatusUntil 是用户自定义 emoji status
//premium 专属account.updateEmojiStatus。DocumentID==0 表示未设置;
// Until==0 表示永久。
EmojiStatusDocumentID int64
EmojiStatusUntil int
// Birthday 是用户公开生日account.updateBirthday。零值表示未设置。
Birthday Birthday
// PersonalChannelID 是资料页展示的「个人频道」account.updatePersonalChannel
// 0 表示未设置。资料投影时按它取频道对象与最新一帖。
PersonalChannelID int64
Color PeerColor
ProfileColor PeerColor
// Profile photo fields are filled by app-layer user projection. PhotoID==0 表示无头像。
PhotoID int64
PhotoDCID int
PhotoStripped []byte
PhotoPersonal bool
PhotoHasVideo bool
LastSeenAt int
Status UserStatus
}
// PremiumActiveAt 报告用户在 nowUnix 秒)时刻是否为有效会员。
// bot 永不为会员(官方语义;授予路径同样排除 bot这里是双保险
func (u User) PremiumActiveAt(now int64) bool {
return !u.Bot && u.PremiumUntil > 0 && int64(u.PremiumUntil) > now
}
// EmojiStatusActiveAt 报告用户在 nowUnix 秒)时刻是否有生效的 emoji status
// 已设置且未过期Until==0 表示永久。emoji status 是 premium 专属,到期
// 降级后即便列仍有残值也不再下发。
func (u User) EmojiStatusActiveAt(now int64) bool {
if !u.PremiumActiveAt(now) || u.EmojiStatusDocumentID == 0 {
return false
}
return u.EmojiStatusUntil == 0 || int64(u.EmojiStatusUntil) > now
}
// UserStatusKind is a protocol-neutral account presence state.
type UserStatusKind int
@ -53,6 +104,29 @@ type UserStatus struct {
WasOnline int
}
// Birthday 是用户公开生日。Day/Month 为 0 表示未设置Year 为 0 表示只填了月日不含年份。
type Birthday struct {
Day int
Month int
Year int
}
// IsSet 报告生日是否已设置(必须有合法月日)。
func (b Birthday) IsSet() bool {
return b.Day != 0 && b.Month != 0
}
// ValidBirthday 校验月/日年份可选是否在合法范围内。清除生日传零值即可IsSet 为 false
func ValidBirthday(b Birthday) bool {
if b.Month < 1 || b.Month > 12 || b.Day < 1 || b.Day > 31 {
return false
}
if b.Year != 0 && (b.Year < 1900 || b.Year > 2100) {
return false
}
return true
}
// UserProfileUpdate 描述 account.updateProfile 的可选字段更新。
type UserProfileUpdate struct {
FirstName string

View file

@ -9,4 +9,12 @@ var (
ErrPhoneNotOccupied = errors.New("phone not occupied")
ErrFirstNameInvalid = errors.New("first name invalid")
ErrAboutTooLong = errors.New("about too long")
ErrUserNotFound = errors.New("user not found")
ErrUserSendRestricted = errors.New("user send restricted")
// ErrPremiumRequired 表示该操作仅限有效会员PREMIUM_ACCOUNT_REQUIRED
ErrPremiumRequired = errors.New("premium account required")
// ErrPremiumBotUnsupported 表示 bot 账号不可被授予会员(官方语义)。
ErrPremiumBotUnsupported = errors.New("bot accounts cannot be premium")
// ErrBirthdayInvalid 表示生日的月/日/年不在合法范围BIRTHDAY_INVALID
ErrBirthdayInvalid = errors.New("birthday invalid")
)

View file

@ -0,0 +1,75 @@
package domain
import (
"hash/fnv"
"net/url"
"strings"
)
// NormalizeWebPageURL 把用户消息里的链接规范化为去重用的稳定形态:仅接受 http/https、
// 拒绝带 userinfo 的 URLSSRF/伪装防御)、小写 scheme 与 host、去掉默认端口与 fragment
// 保留 path 与 query 原样。返回规范化 URL 与是否可预览(不可预览返回 false
func NormalizeWebPageURL(raw string) (string, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", false
}
u, err := url.Parse(raw)
if err != nil {
return "", false
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return "", false
}
if u.User != nil {
return "", false
}
host := strings.ToLower(u.Host)
if host == "" {
return "", false
}
// 去掉与 scheme 对应的默认端口,避免 example.com 与 example.com:80 哈希不同。
if h, port, ok := splitHostPort(host); ok {
if (scheme == "http" && port == "80") || (scheme == "https" && port == "443") {
host = h
}
}
out := scheme + "://" + host + u.EscapedPath()
if u.RawQuery != "" {
out += "?" + u.RawQuery
}
return out, true
}
// splitHostPort 拆分 host:port无端口时 ok=false。不用 net.SplitHostPort 以免 IPv6
// 字面量裸 host无端口被当作错误。
func splitHostPort(host string) (string, string, bool) {
// IPv6 字面量形如 [::1]:443只在末尾 ] 之后找端口。
idx := strings.LastIndexByte(host, ':')
if idx < 0 {
return host, "", false
}
if close := strings.LastIndexByte(host, ']'); close >= 0 && idx < close {
return host, "", false
}
return host[:idx], host[idx+1:], true
}
// IsPendingWebPageMedia 报告 media 是否为 ID==id 的 pending 链接预览占位(异步解析的
// 幂等守卫:仅这种状态才允许被解析结果就地替换)。
func IsPendingWebPageMedia(m *MessageMedia, id int64) bool {
return m != nil &&
m.Kind == MessageMediaKindWebPage &&
m.WebPage != nil &&
m.WebPage.State == MessageWebPageStatePending &&
m.WebPage.ID == id
}
// WebPageURLHash 计算规范化 URL 的稳定 63-bit 哈希,同时用作 web_pages 行的主键与
// webPage id保证 pending 占位与 done 解析携带同一 id。FNV-1a/64 取正。
func WebPageURLHash(normalized string) int64 {
h := fnv.New64a()
_, _ = h.Write([]byte(normalized))
return int64(h.Sum64() & 0x7fffffffffffffff)
}

View file

@ -0,0 +1,58 @@
package domain
import "testing"
func TestNormalizeWebPageURL(t *testing.T) {
cases := []struct {
name string
in string
want string
ok bool
}{
{"lowercase-host-drop-fragment", "https://Example.COM/Path?q=1#frag", "https://example.com/Path?q=1", true},
{"strip-default-http-port", "http://example.com:80/x", "http://example.com/x", true},
{"strip-default-https-port", "https://example.com:443", "https://example.com", true},
{"keep-nondefault-port", "http://example.com:8080/x", "http://example.com:8080/x", true},
{"keep-query-case", "https://e.com/a?B=C", "https://e.com/a?B=C", true},
{"reject-scheme", "ftp://example.com/x", "", false},
{"reject-userinfo", "https://user:pass@example.com/x", "", false},
{"reject-no-host", "https:///path", "", false},
{"reject-plain-text", "not a url", "", false},
{"reject-empty", " ", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := NormalizeWebPageURL(tc.in)
if ok != tc.ok || got != tc.want {
t.Fatalf("NormalizeWebPageURL(%q) = (%q,%v), want (%q,%v)", tc.in, got, ok, tc.want, tc.ok)
}
})
}
}
func TestWebPageURLHashStableAndPositive(t *testing.T) {
const u = "https://example.com/article"
h1 := WebPageURLHash(u)
h2 := WebPageURLHash(u)
if h1 != h2 {
t.Fatalf("hash not deterministic: %d != %d", h1, h2)
}
if h1 < 0 {
t.Fatalf("hash must be non-negative (used as TL id), got %d", h1)
}
if WebPageURLHash("https://other.example/x") == h1 {
t.Fatalf("distinct URLs collided")
}
}
// TestNormalizeThenHashDedupes 验证规范化后等价的 URL 哈希一致(去重键稳定)。
func TestNormalizeThenHashDedupes(t *testing.T) {
a, _ := NormalizeWebPageURL("https://Example.com:443/x")
b, _ := NormalizeWebPageURL("https://example.com/x")
if a != b {
t.Fatalf("normalized forms differ: %q vs %q", a, b)
}
if WebPageURLHash(a) != WebPageURLHash(b) {
t.Fatalf("equivalent URLs hashed differently")
}
}