// Package support persists the Telegram bot's support-relay state. For each user // who direct-messages the bot it records the dedicated forum topic the bot opened // in the operators' support chat, whether the user is blocked, and the ids of the // messages relayed into that topic — so an operator's "clear" can delete them while // keeping the topic's info card. The state is a small JSON file on a writable // volume: the bot host has no database. // // The store is concurrency-safe. The go-telegram/bot library dispatches each update // in its own goroutine, so every exported method locks. Mutators update only their // own fields (SetTopic never touches Blocked, SetBlocked never touches the relayed // ids) so a topic recreate cannot clobber a concurrent block toggle. package support import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "sync" "time" ) // User is one user's support-relay state. type User struct { // UserID is the user's Telegram user id (the private chat id the bot relays to). UserID int64 `json:"user_id"` // TopicID is the forum topic's message_thread_id in the support chat; 0 means no // topic has been created yet. TopicID int `json:"topic_id"` // HeaderMsgID is the info-card message that opens the topic and carries the // block/clear buttons; it is never deleted by a clear. HeaderMsgID int `json:"header_msg_id"` // Blocked reports whether the bot drops this user's incoming messages. Blocked bool `json:"blocked"` // FirstName, LastName and Username snapshot the user's Telegram profile at the // time the topic was created or last refreshed (for the topic name and info card). FirstName string `json:"first_name,omitempty"` LastName string `json:"last_name,omitempty"` Username string `json:"username,omitempty"` // RelayedMsgIDs are the ids of the messages posted into the topic after the header // (the user's relayed messages and the operators' replies); a clear deletes them. RelayedMsgIDs []int `json:"relayed_msg_ids,omitempty"` // CreatedAt is when the topic was first opened for this user. CreatedAt time.Time `json:"created_at"` } // Store is the concurrency-safe, JSON-backed support-relay state. The zero value is // not usable; build one with Open or New. type Store struct { mu sync.Mutex path string users map[int64]*User byTopic map[int]int64 // TopicID -> UserID, for resolving operator replies } // file is the on-disk shape: a flat list of users. A JSON object keyed by user id // would force the int64 keys through strings; a list keeps the ids typed. type file struct { Users []*User `json:"users"` } // New returns an empty store that persists to path, creating the parent directory // when missing. Use it as the fallback when Open reports a corrupt file. func New(path string) *Store { _ = os.MkdirAll(filepath.Dir(path), 0o755) return &Store{path: path, users: map[int64]*User{}, byTopic: map[int]int64{}} } // Open loads the store from path. A missing file yields an empty store and no // error; an unreadable or malformed file returns an error so the caller can decide // whether to start empty. func Open(path string) (*Store, error) { s := New(path) b, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return s, nil } return nil, fmt.Errorf("support: read state %s: %w", path, err) } var f file if err := json.Unmarshal(b, &f); err != nil { return nil, fmt.Errorf("support: parse state %s: %w", path, err) } for _, u := range f.Users { if u == nil || u.UserID == 0 { continue } s.users[u.UserID] = u if u.TopicID != 0 { s.byTopic[u.TopicID] = u.UserID } } return s, nil } // Get returns a detached copy of the user's state and whether it exists. func (s *Store) Get(userID int64) (User, bool) { s.mu.Lock() defer s.mu.Unlock() u, ok := s.users[userID] if !ok { return User{}, false } cp := *u cp.RelayedMsgIDs = append([]int(nil), u.RelayedMsgIDs...) return cp, true } // ByTopic returns the user id owning the given forum topic, and whether one is known. func (s *Store) ByTopic(topicID int) (int64, bool) { s.mu.Lock() defer s.mu.Unlock() uid, ok := s.byTopic[topicID] return uid, ok } // Blocked reports whether the user's incoming messages are dropped. func (s *Store) Blocked(userID int64) bool { s.mu.Lock() defer s.mu.Unlock() if u, ok := s.users[userID]; ok { return u.Blocked } return false } // SetTopic records the forum topic and info-card message opened for the user and // refreshes the profile snapshot, creating the record on first contact. It rebuilds // the topic index when the topic changes (a recreate) and preserves the block state // and the relayed-message ids. It persists the result. func (s *Store) SetTopic(userID int64, topicID, headerMsgID int, firstName, lastName, username string) error { s.mu.Lock() defer s.mu.Unlock() u, ok := s.users[userID] if !ok { u = &User{UserID: userID, CreatedAt: time.Now().UTC()} s.users[userID] = u } else if u.TopicID != 0 && u.TopicID != topicID { delete(s.byTopic, u.TopicID) } u.TopicID = topicID u.HeaderMsgID = headerMsgID u.FirstName, u.LastName, u.Username = firstName, lastName, username if topicID != 0 { s.byTopic[topicID] = userID } return s.save() } // SetBlocked sets the user's blocked flag, creating a minimal record when none // exists, and persists the result. func (s *Store) SetBlocked(userID int64, blocked bool) error { s.mu.Lock() defer s.mu.Unlock() u, ok := s.users[userID] if !ok { u = &User{UserID: userID, CreatedAt: time.Now().UTC()} s.users[userID] = u } u.Blocked = blocked return s.save() } // AppendMsg records a message posted into the user's topic (for a later clear) and // persists the result. It is a no-op when the user has no record yet. func (s *Store) AppendMsg(userID int64, msgID int) error { s.mu.Lock() defer s.mu.Unlock() u, ok := s.users[userID] if !ok { return nil } u.RelayedMsgIDs = append(u.RelayedMsgIDs, msgID) return s.save() } // ClearMsgs empties and returns the user's relayed-message ids (the info card is not // among them) and persists the result, so the caller can delete them from the chat. func (s *Store) ClearMsgs(userID int64) ([]int, error) { s.mu.Lock() defer s.mu.Unlock() u, ok := s.users[userID] if !ok || len(u.RelayedMsgIDs) == 0 { return nil, nil } ids := u.RelayedMsgIDs u.RelayedMsgIDs = nil if err := s.save(); err != nil { // Roll back so the ids are not lost if the write fails. u.RelayedMsgIDs = ids return nil, err } return ids, nil } // save writes the state atomically (temp file in the same directory, then rename). // The caller must hold s.mu. func (s *Store) save() error { f := file{Users: make([]*User, 0, len(s.users))} for _, u := range s.users { f.Users = append(f.Users, u) } sort.Slice(f.Users, func(i, j int) bool { return f.Users[i].UserID < f.Users[j].UserID }) b, err := json.MarshalIndent(f, "", " ") if err != nil { return fmt.Errorf("support: marshal state: %w", err) } tmp, err := os.CreateTemp(filepath.Dir(s.path), ".support-*.json") if err != nil { return fmt.Errorf("support: create temp state: %w", err) } tmpName := tmp.Name() defer func() { _ = os.Remove(tmpName) }() // no-op once the rename succeeds if _, err := tmp.Write(b); err != nil { _ = tmp.Close() return fmt.Errorf("support: write temp state: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("support: close temp state: %w", err) } if err := os.Rename(tmpName, s.path); err != nil { return fmt.Errorf("support: replace state %s: %w", s.path, err) } return nil }