owpengram-server/internal/loadharness/snapshot.go
2026-09-01 12:06:31 +03:00

723 lines
25 KiB
Go

package loadharness
import (
"context"
"crypto/rsa"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/iamxvbaba/td/tg"
)
const ClientStateVersion = 1
type ClientUpdateState struct {
Pts int `json:"pts"`
Qts int `json:"qts"`
Date int `json:"date"`
Seq int `json:"seq"`
UnreadCount int `json:"unread_count"`
}
type ClientDialogState struct {
PeerType string `json:"peer_type"`
PeerID int64 `json:"peer_id"`
AccessHash int64 `json:"access_hash"`
TopMessage int `json:"top_message"`
TopMessageDate int `json:"top_message_date"`
Pts int `json:"pts,omitempty"`
HasPts bool `json:"has_pts,omitempty"`
ReadInboxMaxID int `json:"read_inbox_max_id"`
ReadOutboxMaxID int `json:"read_outbox_max_id"`
UnreadCount int `json:"unread_count"`
UnreadMentions int `json:"unread_mentions"`
UnreadReactions int `json:"unread_reactions"`
Pinned bool `json:"pinned,omitempty"`
HasDraft bool `json:"has_draft,omitempty"`
DraftText string `json:"draft_text,omitempty"`
DatasetExpected bool `json:"dataset_expected,omitempty"`
}
type ClientAccountState struct {
AccountIndex int `json:"account_index"`
UserID int64 `json:"user_id"`
State ClientUpdateState `json:"state"`
Dialogs []ClientDialogState `json:"dialogs"`
}
type ClientState struct {
Version int `json:"version"`
DatasetSHA256 string `json:"dataset_sha256"`
SeedIdentitySHA string `json:"seed_identity_sha256"`
CreatedAt time.Time `json:"created_at"`
Accounts []ClientAccountState `json:"accounts"`
}
type SnapshotConfig struct {
ManifestPath string
SessionKeyPath string
RSAKeyOverride string
DatasetPath string
SeedStatePath string
ClientStatePath string
Concurrency int
OperationTimeout time.Duration
}
type SnapshotEvent struct {
Completed int
Total int
Account int
Resumed bool
Err error
}
type SnapshotResult struct {
Accounts int
Dialogs int
Channels int
}
func (c SnapshotConfig) validate() error {
if c.ManifestPath == "" || c.SessionKeyPath == "" || c.DatasetPath == "" || c.SeedStatePath == "" || c.ClientStatePath == "" {
return errors.New("manifest, session-key, dataset, seed-state and client-state paths are required")
}
if c.Concurrency <= 0 || c.Concurrency > 64 {
return errors.New("snapshot concurrency must be between 1 and 64")
}
if c.OperationTimeout <= 0 {
return errors.New("snapshot operation timeout must be positive")
}
return nil
}
// SnapshotClientState establishes the old client cursors that later offline
// mutations must advance from. Dialogs are read through public paginated RPCs;
// per-account part files make a 1,000-account snapshot safely resumable without
// rewriting the growing aggregate after every account.
func SnapshotClientState(ctx context.Context, cfg SnapshotConfig, progress func(SnapshotEvent)) (*SnapshotResult, error) {
if err := cfg.validate(); err != nil {
return nil, err
}
manifest, err := LoadManifest(cfg.ManifestPath)
if err != nil {
return nil, err
}
dataset, err := LoadDataset(cfg.DatasetPath)
if err != nil {
return nil, err
}
targets, err := seedPrimaryTargets(manifest, dataset.Config.Accounts)
if err != nil {
return nil, err
}
seedState, err := LoadDatasetSeedState(cfg.SeedStatePath, dataset)
if err != nil {
return nil, err
}
seedJournal := &seedJournal{dataset: dataset, state: seedState}
if err := seedJournal.assertComplete(); err != nil {
return nil, fmt.Errorf("snapshot requires a complete seed: %w", err)
}
seedIdentity, err := seedIdentitySHA256(seedState)
if err != nil {
return nil, err
}
if existing, loadErr := LoadClientState(cfg.ClientStatePath); loadErr == nil {
if err := existing.Validate(dataset, seedState, targets); err != nil {
return nil, fmt.Errorf("existing client state does not match requested dataset: %w", err)
}
return clientStateResult(existing), nil
} else if !os.IsNotExist(loadErr) {
return nil, loadErr
}
key, err := LoadSessionKey(cfg.SessionKeyPath)
if err != nil {
return nil, err
}
publicKey, err := loadManifestPublicKey(cfg.ManifestPath, manifest.Endpoint, cfg.RSAKeyOverride)
if err != nil {
return nil, err
}
accounts := make([]int, dataset.Config.Accounts)
for account := range accounts {
accounts[account] = account
}
resumed := make([]bool, len(accounts))
for _, account := range accounts {
if _, err := loadClientStatePart(clientStatePartPath(cfg.ClientStatePath, account), dataset, seedState, targets, account); err == nil {
resumed[account] = true
} else if !os.IsNotExist(err) {
return nil, err
}
}
completed := 0
if err := runSeedAccountPhase(ctx, "snapshot", accounts, cfg.Concurrency, func(event SeedEvent) {
if event.Err == nil {
completed++
}
if progress != nil {
progress(SnapshotEvent{Completed: completed, Total: len(accounts), Account: event.Account, Resumed: resumed[event.Account], Err: event.Err})
}
}, func(ctx context.Context, account int) error {
if resumed[account] {
return nil
}
state, err := snapshotAccount(ctx, cfg, manifest, dataset, seedState, targets, key, publicKey, account)
if err != nil {
return err
}
return writeClientStatePart(clientStatePartPath(cfg.ClientStatePath, account), dataset.PlanSHA256, seedIdentity, state)
}); err != nil {
return nil, err
}
clientState := &ClientState{
Version: ClientStateVersion, DatasetSHA256: dataset.PlanSHA256, SeedIdentitySHA: seedIdentity, CreatedAt: time.Now().UTC(),
Accounts: make([]ClientAccountState, 0, len(accounts)),
}
for _, account := range accounts {
state, err := loadClientStatePart(clientStatePartPath(cfg.ClientStatePath, account), dataset, seedState, targets, account)
if err != nil {
return nil, err
}
clientState.Accounts = append(clientState.Accounts, *state)
}
if err := clientState.Validate(dataset, seedState, targets); err != nil {
return nil, err
}
if err := WriteClientState(cfg.ClientStatePath, clientState); err != nil {
return nil, err
}
return clientStateResult(clientState), nil
}
func snapshotAccount(
ctx context.Context,
cfg SnapshotConfig,
manifest *Manifest,
dataset *Dataset,
seedState *DatasetSeedState,
targets []SessionRecord,
key [32]byte,
publicKey *rsa.PublicKey,
account int,
) (*ClientAccountState, error) {
var result *ClientAccountState
err := withAuthorizedSeedSession(ctx, SeedConfig{
ManifestPath: cfg.ManifestPath, OperationTimeout: cfg.OperationTimeout,
}, manifest, targets[account], key, publicKey, func(ctx context.Context, raw *tg.Client) error {
dialogs, err := snapshotDialogs(ctx, cfg.OperationTimeout, raw)
if err != nil {
return err
}
expected := expectedDatasetPeers(dataset, seedState, targets, account)
for i := range dialogs {
_, dialogs[i].DatasetExpected = expected[clientPeerKey{typ: dialogs[i].PeerType, id: dialogs[i].PeerID}]
if dialogs[i].DatasetExpected {
delete(expected, clientPeerKey{typ: dialogs[i].PeerType, id: dialogs[i].PeerID})
}
}
if len(expected) != 0 {
missing := make([]string, 0, min(len(expected), 5))
for peer := range expected {
missing = append(missing, fmt.Sprintf("%s:%d", peer.typ, peer.id))
if len(missing) == 5 {
break
}
}
sort.Strings(missing)
return fmt.Errorf("messages.getDialogs omitted %d expected dataset peers (sample %s)", len(expected), strings.Join(missing, ","))
}
if err := validateSeededRichDialogs(dataset, seedState, targets, account, dialogs, true); err != nil {
return fmt.Errorf("rich dialog state: %w", err)
}
stateCtx, cancel := context.WithTimeout(ctx, cfg.OperationTimeout)
state, err := raw.UpdatesGetState(stateCtx)
cancel()
if err != nil {
return fmt.Errorf("updates.getState: %w", err)
}
result = &ClientAccountState{
AccountIndex: account, UserID: targets[account].UserID,
State: ClientUpdateState{Pts: state.Pts, Qts: state.Qts, Date: state.Date, Seq: state.Seq, UnreadCount: state.UnreadCount},
Dialogs: dialogs,
}
return nil
})
if err == nil && result == nil {
return nil, errors.New("snapshot session ended without producing account state")
}
return result, err
}
type clientPeerKey struct {
typ string
id int64
}
func snapshotDialogs(ctx context.Context, timeout time.Duration, raw *tg.Client) ([]ClientDialogState, error) {
dialogs, _, err := snapshotDialogsObserved(ctx, timeout, raw, snapshotPaginationProfile, nil)
return dialogs, err
}
func snapshotDialogsObserved(
ctx context.Context,
timeout time.Duration,
raw *tg.Client,
profile dialogPaginationProfile,
observe func(string, time.Time, error),
) ([]ClientDialogState, StartupDialogsCounts, error) {
if profile.FirstLimit <= 0 || profile.SubsequentLimit <= 0 {
return nil, StartupDialogsCounts{}, errors.New("invalid dialogs pagination profile")
}
dialogsByPeer := make(map[clientPeerKey]ClientDialogState)
counts := StartupDialogsCounts{}
start := time.Now()
pinnedCtx, cancel := context.WithTimeout(ctx, timeout)
pinned, err := raw.MessagesGetPinnedDialogs(pinnedCtx, 0)
cancel()
if observe != nil {
observe("messages.getPinnedDialogs", start, err)
}
counts.PinnedCalls++
if err != nil {
return nil, counts, fmt.Errorf("messages.getPinnedDialogs: %w", err)
}
if _, err := mergeDialogPage(dialogsByPeer, pinned.Dialogs, pinned.Messages, pinned.Chats, pinned.Users, true); err != nil {
return nil, counts, err
}
pinnedPeers := make(map[clientPeerKey]struct{}, len(dialogsByPeer))
for peer := range dialogsByPeer {
pinnedPeers[peer] = struct{}{}
}
request := &tg.MessagesGetDialogsRequest{ExcludePinned: true, OffsetPeer: &tg.InputPeerEmpty{}, Limit: profile.limit(0)}
for page := 0; page < 100; page++ {
request.Limit = profile.limit(page)
start := time.Now()
rpcCtx, cancel := context.WithTimeout(ctx, timeout)
response, err := raw.MessagesGetDialogs(rpcCtx, request)
cancel()
if observe != nil {
observe("messages.getDialogs", start, err)
if page == 0 {
observe("messages.getDialogs.first", start, err)
} else {
observe("messages.getDialogs.next", start, err)
}
}
counts.Calls++
if err != nil {
return nil, counts, fmt.Errorf("messages.getDialogs page %d: %w", page+1, err)
}
var pageDialogs []tg.DialogClass
var messages []tg.MessageClass
var chats []tg.ChatClass
var users []tg.UserClass
complete := false
switch value := response.(type) {
case *tg.MessagesDialogs:
if observe != nil {
observe("messages.getDialogs.full", start, nil)
}
counts.Full++
pageDialogs, messages, chats, users, complete = value.Dialogs, value.Messages, value.Chats, value.Users, true
case *tg.MessagesDialogsSlice:
if observe != nil {
observe("messages.getDialogs.slice", start, nil)
}
counts.Slice++
pageDialogs, messages, chats, users = value.Dialogs, value.Messages, value.Chats, value.Users
case *tg.MessagesDialogsNotModified:
return nil, counts, errors.New("messages.getDialogs with hash=0 returned dialogsNotModified")
default:
return nil, counts, fmt.Errorf("messages.getDialogs returned %T", response)
}
last, overlap, err := mergeDialogPageKnownOverlap(dialogsByPeer, pageDialogs, messages, chats, users, false, pinnedPeers)
counts.PinnedOverlap += overlap
if err != nil {
return nil, counts, fmt.Errorf("messages.getDialogs page %d: %w", page+1, err)
}
// A dialogsSlice is explicitly non-final. The server may enforce a
// smaller per-page cap than the client-requested limit (TDesktop asks
// for 500 after its first page while telesrv currently returns at most
// 100). Only the full constructor or an empty slice proves completion;
// treating a short slice as EOF truncates the real TDesktop workload.
if dialogsPaginationDone(complete, len(pageDialogs)) {
break
}
if last.TopMessage <= 0 || last.TopMessageDate <= 0 {
return nil, counts, fmt.Errorf("messages.getDialogs page %d has no usable offset", page+1)
}
request.OffsetDate = last.TopMessageDate
request.OffsetID = last.TopMessage
request.OffsetPeer = clientDialogInputPeer(last)
if request.OffsetPeer == nil {
return nil, counts, fmt.Errorf("messages.getDialogs page %d has invalid offset peer", page+1)
}
if page == 99 {
return nil, counts, errors.New("messages.getDialogs exceeded 100 pages")
}
}
result := make([]ClientDialogState, 0, len(dialogsByPeer))
for _, dialog := range dialogsByPeer {
result = append(result, dialog)
}
sort.Slice(result, func(i, j int) bool {
if result[i].PeerType != result[j].PeerType {
return result[i].PeerType < result[j].PeerType
}
return result[i].PeerID < result[j].PeerID
})
counts.Dialogs = len(result)
return result, counts, nil
}
func dialogsPaginationDone(complete bool, pageSize int) bool {
return complete || pageSize == 0
}
func mergeDialogPage(
destination map[clientPeerKey]ClientDialogState,
dialogClasses []tg.DialogClass,
messages []tg.MessageClass,
chats []tg.ChatClass,
users []tg.UserClass,
pinnedPage bool,
) (ClientDialogState, error) {
last, _, err := mergeDialogPageKnownOverlap(destination, dialogClasses, messages, chats, users, pinnedPage, nil)
return last, err
}
func mergeDialogPageKnownOverlap(
destination map[clientPeerKey]ClientDialogState,
dialogClasses []tg.DialogClass,
messages []tg.MessageClass,
chats []tg.ChatClass,
users []tg.UserClass,
pinnedPage bool,
allowedOverlap map[clientPeerKey]struct{},
) (ClientDialogState, int, error) {
accessHashes := make(map[clientPeerKey]int64, len(chats)+len(users))
for _, chat := range chats {
channel, ok := chat.(*tg.Channel)
if !ok {
continue
}
hash, ok := channel.GetAccessHash()
if ok {
accessHashes[clientPeerKey{typ: "channel", id: channel.ID}] = hash
}
}
for _, userClass := range users {
user, ok := userClass.(*tg.User)
if !ok {
continue
}
hash, ok := user.GetAccessHash()
if ok {
accessHashes[clientPeerKey{typ: "user", id: user.ID}] = hash
}
}
messageDates := make(map[string]int, len(messages))
for _, messageClass := range messages {
message, ok := messageClass.AsNotEmpty()
if !ok {
continue
}
peer, ok := clientPeerFromTG(message.GetPeerID())
if !ok {
continue
}
messageDates[clientMessageKey(peer, message.GetID())] = message.GetDate()
}
var last ClientDialogState
overlaps := 0
for _, dialogClass := range dialogClasses {
dialog, ok := dialogClass.(*tg.Dialog)
if !ok {
continue
}
peer, ok := clientPeerFromTG(dialog.Peer)
if !ok {
continue
}
hash := accessHashes[peer]
if hash == 0 {
return ClientDialogState{}, overlaps, fmt.Errorf("dialog %s:%d omitted access hash", peer.typ, peer.id)
}
pts, hasPts := dialog.GetPts()
if peer.typ == "channel" && (!hasPts || pts <= 0) {
return ClientDialogState{}, overlaps, fmt.Errorf("channel dialog %d omitted pts", peer.id)
}
date := messageDates[clientMessageKey(peer, dialog.TopMessage)]
if dialog.TopMessage <= 0 || date <= 0 {
return ClientDialogState{}, overlaps, fmt.Errorf("dialog %s:%d omitted top message payload", peer.typ, peer.id)
}
state := ClientDialogState{
PeerType: peer.typ, PeerID: peer.id, AccessHash: hash,
TopMessage: dialog.TopMessage, TopMessageDate: date, Pts: pts, HasPts: hasPts,
ReadInboxMaxID: dialog.ReadInboxMaxID, ReadOutboxMaxID: dialog.ReadOutboxMaxID,
UnreadCount: dialog.UnreadCount, UnreadMentions: dialog.UnreadMentionsCount,
UnreadReactions: dialog.UnreadReactionsCount, Pinned: pinnedPage || dialog.Pinned,
}
if draftClass, ok := dialog.GetDraft(); ok {
if draft, ok := draftClass.(*tg.DraftMessage); ok && draft.Message != "" {
state.HasDraft, state.DraftText = true, draft.Message
}
}
if existing, exists := destination[peer]; exists {
if _, allowed := allowedOverlap[peer]; !allowed || !existing.Pinned || !state.Pinned || existing.TopMessage != state.TopMessage || existing.AccessHash != state.AccessHash {
return ClientDialogState{}, overlaps, fmt.Errorf("duplicate dialog %s:%d", peer.typ, peer.id)
}
delete(allowedOverlap, peer)
if !state.HasDraft && existing.HasDraft {
state.HasDraft, state.DraftText = existing.HasDraft, existing.DraftText
}
overlaps++
}
destination[peer] = state
last = state
}
return last, overlaps, nil
}
func clientPeerFromTG(peer tg.PeerClass) (clientPeerKey, bool) {
switch value := peer.(type) {
case *tg.PeerUser:
return clientPeerKey{typ: "user", id: value.UserID}, value.UserID > 0
case *tg.PeerChannel:
return clientPeerKey{typ: "channel", id: value.ChannelID}, value.ChannelID > 0
default:
return clientPeerKey{}, false
}
}
func clientMessageKey(peer clientPeerKey, messageID int) string {
return fmt.Sprintf("%s:%d:%d", peer.typ, peer.id, messageID)
}
func clientDialogInputPeer(dialog ClientDialogState) tg.InputPeerClass {
switch dialog.PeerType {
case "user":
return &tg.InputPeerUser{UserID: dialog.PeerID, AccessHash: dialog.AccessHash}
case "channel":
return &tg.InputPeerChannel{ChannelID: dialog.PeerID, AccessHash: dialog.AccessHash}
default:
return nil
}
}
func expectedDatasetPeers(dataset *Dataset, seedState *DatasetSeedState, targets []SessionRecord, account int) map[clientPeerKey]struct{} {
expected := make(map[clientPeerKey]struct{})
for _, edge := range dataset.PrivateEdges {
switch account {
case edge.SenderAccount:
expected[clientPeerKey{typ: "user", id: targets[edge.RecipientAccount].UserID}] = struct{}{}
case edge.RecipientAccount:
expected[clientPeerKey{typ: "user", id: targets[edge.SenderAccount].UserID}] = struct{}{}
}
}
for position, group := range dataset.Groups {
memberIndex := sort.SearchInts(group.MemberAccounts, account)
if memberIndex < len(group.MemberAccounts) && group.MemberAccounts[memberIndex] == account {
expected[clientPeerKey{typ: "channel", id: seedState.Groups[position].ChannelID}] = struct{}{}
}
}
return expected
}
func clientStatePartPath(clientStatePath string, account int) string {
return filepath.Join(clientStatePath+".parts", fmt.Sprintf("account-%04d.json", account))
}
type clientStatePart struct {
Version int `json:"version"`
DatasetSHA256 string `json:"dataset_sha256"`
SeedIdentitySHA string `json:"seed_identity_sha256"`
Account ClientAccountState `json:"account"`
}
func writeClientStatePart(path, datasetSHA, seedIdentity string, account *ClientAccountState) error {
if account == nil {
return errors.New("cannot write nil client account state")
}
part := clientStatePart{Version: ClientStateVersion, DatasetSHA256: datasetSHA, SeedIdentitySHA: seedIdentity, Account: *account}
data, err := json.MarshalIndent(part, "", " ")
if err != nil {
return err
}
return writeFileAtomic(path, append(data, '\n'), 0o600)
}
func loadClientStatePart(path string, dataset *Dataset, seedState *DatasetSeedState, targets []SessionRecord, account int) (*ClientAccountState, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var part clientStatePart
decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&part); err != nil {
return nil, fmt.Errorf("decode client state part: %w", err)
}
seedIdentity, err := seedIdentitySHA256(seedState)
if err != nil {
return nil, err
}
target := targets[account]
if part.Version != ClientStateVersion || part.DatasetSHA256 != dataset.PlanSHA256 || part.SeedIdentitySHA != seedIdentity || part.Account.AccountIndex != target.AccountIndex || part.Account.UserID != target.UserID {
return nil, errors.New("client state part does not match dataset or account")
}
if err := validateClientAccountState(&part.Account); err != nil {
return nil, err
}
if err := validateExpectedDatasetPeers(dataset, seedState, targets, &part.Account); err != nil {
return nil, err
}
return &part.Account, nil
}
func (s *ClientState) Validate(dataset *Dataset, seedState *DatasetSeedState, targets []SessionRecord) error {
if s == nil || s.Version != ClientStateVersion || dataset == nil || s.DatasetSHA256 != dataset.PlanSHA256 {
return errors.New("client state does not match dataset")
}
seedIdentity, err := seedIdentitySHA256(seedState)
if err != nil {
return err
}
if s.SeedIdentitySHA != seedIdentity {
return errors.New("client state does not match seeded channel identities")
}
if len(s.Accounts) != dataset.Config.Accounts || len(targets) != dataset.Config.Accounts {
return errors.New("client state account count does not match dataset")
}
for account := range s.Accounts {
if s.Accounts[account].AccountIndex != account || s.Accounts[account].UserID != targets[account].UserID {
return fmt.Errorf("client state account %d has wrong identity", account)
}
if err := validateClientAccountState(&s.Accounts[account]); err != nil {
return fmt.Errorf("client state account %d: %w", account, err)
}
if err := validateExpectedDatasetPeers(dataset, seedState, targets, &s.Accounts[account]); err != nil {
return fmt.Errorf("client state account %d: %w", account, err)
}
}
return nil
}
func validateExpectedDatasetPeers(dataset *Dataset, seedState *DatasetSeedState, targets []SessionRecord, account *ClientAccountState) error {
expected := expectedDatasetPeers(dataset, seedState, targets, account.AccountIndex)
for _, dialog := range account.Dialogs {
peer := clientPeerKey{typ: dialog.PeerType, id: dialog.PeerID}
_, shouldBeExpected := expected[peer]
if dialog.DatasetExpected != shouldBeExpected {
return fmt.Errorf("dialog %s:%d has incorrect dataset_expected marker", peer.typ, peer.id)
}
if shouldBeExpected {
delete(expected, peer)
}
}
if len(expected) != 0 {
return fmt.Errorf("client state omits %d expected dataset peers", len(expected))
}
return nil
}
func seedIdentitySHA256(state *DatasetSeedState) (string, error) {
if state == nil {
return "", errors.New("nil seed state")
}
type identity struct {
GroupIndex int `json:"group_index"`
ChannelID int64 `json:"channel_id"`
AccessHash int64 `json:"access_hash"`
}
identities := make([]identity, len(state.Groups))
for i, group := range state.Groups {
if group.ChannelID <= 0 || group.AccessHash == 0 {
return "", fmt.Errorf("group %d has incomplete identity", group.GroupIndex)
}
identities[i] = identity{GroupIndex: group.GroupIndex, ChannelID: group.ChannelID, AccessHash: group.AccessHash}
}
data, err := json.Marshal(identities)
if err != nil {
return "", err
}
sum := sha256.Sum256(data)
return fmt.Sprintf("%x", sum[:]), nil
}
func validateClientAccountState(account *ClientAccountState) error {
if account == nil || account.AccountIndex < 0 || account.UserID <= 0 || account.State.Pts < 0 || account.State.Qts < 0 || account.State.Date <= 0 || account.State.Seq < 0 {
return errors.New("invalid account state")
}
seen := make(map[clientPeerKey]struct{}, len(account.Dialogs))
for _, dialog := range account.Dialogs {
peer := clientPeerKey{typ: dialog.PeerType, id: dialog.PeerID}
if (peer.typ != "user" && peer.typ != "channel") || peer.id <= 0 || dialog.AccessHash == 0 || dialog.TopMessage <= 0 || dialog.TopMessageDate <= 0 {
return fmt.Errorf("invalid dialog %s:%d", peer.typ, peer.id)
}
if peer.typ == "channel" && (!dialog.HasPts || dialog.Pts <= 0) {
return fmt.Errorf("invalid channel pts for %d", peer.id)
}
if dialog.HasDraft != (dialog.DraftText != "") {
return fmt.Errorf("invalid draft state for %s:%d", peer.typ, peer.id)
}
if _, exists := seen[peer]; exists {
return fmt.Errorf("duplicate dialog %s:%d", peer.typ, peer.id)
}
seen[peer] = struct{}{}
}
return nil
}
func WriteClientState(path string, state *ClientState) error {
if state == nil || state.Version != ClientStateVersion || state.DatasetSHA256 == "" || state.SeedIdentitySHA == "" {
return errors.New("invalid client state")
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
return writeFileAtomic(path, append(data, '\n'), 0o600)
}
func LoadClientState(path string) (*ClientState, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var state ClientState
decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&state); err != nil {
return nil, fmt.Errorf("decode client state: %w", err)
}
if state.Version != ClientStateVersion || state.DatasetSHA256 == "" || state.SeedIdentitySHA == "" {
return nil, errors.New("invalid client state")
}
return &state, nil
}
func clientStateResult(state *ClientState) *SnapshotResult {
result := &SnapshotResult{Accounts: len(state.Accounts)}
for _, account := range state.Accounts {
result.Dialogs += len(account.Dialogs)
for _, dialog := range account.Dialogs {
if dialog.PeerType == "channel" {
result.Channels++
}
}
}
return result
}