feat: sync AI compose and ChatBot features

This commit is contained in:
A 2026-07-03 19:43:20 +08:00
parent 35e5d38f4d
commit b7269b135f
75 changed files with 5426 additions and 123 deletions

208
internal/store/memory/ai.go Normal file
View file

@ -0,0 +1,208 @@
package memory
import (
"context"
"sort"
"sync"
"telesrv/internal/domain"
)
// AIComposeStore 是 store.AIComposeStore 的内存实现。
type AIComposeStore struct {
mu sync.RWMutex
byID map[int64]domain.AIComposeTone
bySlug map[string]int64
saves map[int64]map[int64]int64 // userID -> toneID -> order
seq int64
}
func NewAIComposeStore() *AIComposeStore {
return &AIComposeStore{
byID: make(map[int64]domain.AIComposeTone),
bySlug: make(map[string]int64),
saves: make(map[int64]map[int64]int64),
}
}
func (s *AIComposeStore) CreateAIComposeTone(_ context.Context, tone domain.AIComposeTone) error {
if tone.ID == 0 || tone.AccessHash == 0 || tone.OwnerUserID == 0 || tone.Slug == "" {
return domain.ErrAIComposeToneInvalid
}
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.byID[tone.ID]; ok {
return domain.ErrAIComposeToneInvalid
}
if _, ok := s.bySlug[tone.Slug]; ok {
return domain.ErrAIComposeToneInvalid
}
s.byID[tone.ID] = tone.Clone()
s.bySlug[tone.Slug] = tone.ID
return nil
}
func (s *AIComposeStore) UpdateAIComposeTone(_ context.Context, tone domain.AIComposeTone) error {
s.mu.Lock()
defer s.mu.Unlock()
prev, ok := s.byID[tone.ID]
if !ok {
return domain.ErrAIComposeToneNotFound
}
if prev.OwnerUserID != tone.OwnerUserID {
return domain.ErrAIComposeToneInvalid
}
if tone.Slug != prev.Slug {
if _, taken := s.bySlug[tone.Slug]; taken {
return domain.ErrAIComposeToneInvalid
}
delete(s.bySlug, prev.Slug)
s.bySlug[tone.Slug] = tone.ID
}
s.byID[tone.ID] = tone.Clone()
return nil
}
func (s *AIComposeStore) DeleteAIComposeTone(_ context.Context, ownerUserID, toneID int64) error {
s.mu.Lock()
defer s.mu.Unlock()
tone, ok := s.byID[toneID]
if !ok {
return domain.ErrAIComposeToneNotFound
}
if tone.OwnerUserID != ownerUserID {
return domain.ErrAIComposeToneInvalid
}
delete(s.byID, toneID)
delete(s.bySlug, tone.Slug)
for userID := range s.saves {
delete(s.saves[userID], toneID)
}
return nil
}
func (s *AIComposeStore) GetAIComposeToneByID(_ context.Context, id, accessHash int64) (domain.AIComposeTone, bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
tone, ok := s.byID[id]
if !ok || tone.AccessHash != accessHash {
return domain.AIComposeTone{}, false, nil
}
return tone.Clone(), true, nil
}
func (s *AIComposeStore) GetAIComposeToneBySlug(_ context.Context, slug string) (domain.AIComposeTone, bool, error) {
s.mu.RLock()
defer s.mu.RUnlock()
id, ok := s.bySlug[slug]
if !ok {
return domain.AIComposeTone{}, false, nil
}
tone, ok := s.byID[id]
if !ok {
return domain.AIComposeTone{}, false, nil
}
return tone.Clone(), true, nil
}
func (s *AIComposeStore) ListAIComposeTonesForUser(_ context.Context, userID int64) ([]domain.AIComposeTone, error) {
s.mu.RLock()
defer s.mu.RUnlock()
seen := make(map[int64]bool)
out := make([]domain.AIComposeTone, 0)
for _, tone := range s.byID {
if tone.OwnerUserID != userID {
continue
}
item := tone.Clone()
item.Creator = true
item.Saved = true
out = append(out, item)
seen[item.ID] = true
}
if saved := s.saves[userID]; len(saved) > 0 {
type row struct {
tone domain.AIComposeTone
order int64
}
rows := make([]row, 0, len(saved))
for id, order := range saved {
if seen[id] {
continue
}
tone, ok := s.byID[id]
if !ok {
continue
}
tone = tone.Clone()
tone.Creator = false
tone.Saved = true
rows = append(rows, row{tone: tone, order: order})
}
sort.Slice(rows, func(i, j int) bool { return rows[i].order < rows[j].order })
for _, row := range rows {
out = append(out, row.tone)
}
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Creator != out[j].Creator {
return out[i].Creator
}
if out[i].UpdatedAt != out[j].UpdatedAt {
return out[i].UpdatedAt > out[j].UpdatedAt
}
return out[i].ID < out[j].ID
})
return out, nil
}
func (s *AIComposeStore) SaveAIComposeTone(_ context.Context, userID, toneID int64) error {
s.mu.Lock()
defer s.mu.Unlock()
tone, ok := s.byID[toneID]
if !ok {
return domain.ErrAIComposeToneNotFound
}
if tone.OwnerUserID == userID {
return nil
}
byUser := s.saves[userID]
if byUser == nil {
byUser = make(map[int64]int64)
s.saves[userID] = byUser
}
if _, ok := byUser[toneID]; ok {
return nil
}
s.seq++
byUser[toneID] = s.seq
tone.InstallsCount++
s.byID[toneID] = tone
return nil
}
func (s *AIComposeStore) UnsaveAIComposeTone(_ context.Context, userID, toneID int64) error {
s.mu.Lock()
defer s.mu.Unlock()
if byUser := s.saves[userID]; byUser != nil {
delete(byUser, toneID)
}
return nil
}
func (s *AIComposeStore) SavedAIComposeToneCount(_ context.Context, userID int64) (int, error) {
s.mu.RLock()
defer s.mu.RUnlock()
seen := make(map[int64]bool)
for _, tone := range s.byID {
if tone.OwnerUserID == userID {
seen[tone.ID] = true
}
}
for toneID := range s.saves[userID] {
if _, ok := s.byID[toneID]; ok {
seen[toneID] = true
}
}
return len(seen), nil
}

View file

@ -61,6 +61,7 @@ func NewBotStore(users *UserStore) *BotStore {
}
s.byID[domain.BotFatherUserID] = botFatherSeedProfile()
s.byID[domain.StickersBotUserID] = stickersSeedProfile()
s.byID[domain.ChatBotUserID] = chatBotSeedProfile()
return s
}
@ -99,6 +100,19 @@ func stickersSeedProfile() domain.BotProfile {
}
}
func chatBotSeedProfile() domain.BotProfile {
return domain.BotProfile{
BotUserID: domain.ChatBotUserID,
OwnerUserID: domain.ChatBotUserID,
Description: "Chat with the configured telesrv AI provider.",
Commands: []domain.BotCommand{
{Command: "start", Description: "start chatting"},
{Command: "help", Description: "show help"},
{Command: "reset", Description: "clear local chat context"},
},
}
}
func (s *BotStore) CreateBotAccount(ctx context.Context, user domain.User, profile domain.BotProfile) (domain.User, domain.BotProfile, error) {
user.Phone = ""
user.Username = strings.TrimSpace(strings.TrimPrefix(user.Username, "@"))

View file

@ -44,7 +44,7 @@ func (s *MessageStore) EditMessage(_ context.Context, req domain.EditMessageRequ
if req.Message == "" && req.Media == nil && target.Media.IsZero() {
return res, domain.ErrMessageEmpty
}
if req.Media == nil && !req.SetReplyMarkup && target.Body == req.Message && equalMessageEntities(target.Entities, req.Entities) {
if req.Media == nil && !req.SetReplyMarkup && target.Body == req.Message && target.HideEdited == req.HideEdited && equalMessageEntities(target.Entities, req.Entities) {
return res, domain.ErrMessageNotModified
}
messageSenderID := target.From.ID
@ -74,6 +74,7 @@ func (s *MessageStore) EditMessage(_ context.Context, req domain.EditMessageRequ
msg.ReplyMarkup = cloneReplyMarkup(req.ReplyMarkup)
}
msg.EditDate = req.EditDate
msg.HideEdited = req.HideEdited
msg.Pts = s.nextPtsLocked(userID)
s.m[userID][i] = msg
event := editMessageEvent(msg)

View file

@ -15,11 +15,11 @@ type UserStore struct {
nextID int64
}
// NewUserStore 创建内存 UserStore。内置系统账号(777000 / BotFather / Stickers)
// NewUserStore 创建内存 UserStore。内置系统账号(777000 / BotFather / Stickers / ChatBot)
// 预置进表,与 postgres 的迁移种子保持双 store 行为一致。
func NewUserStore() *UserStore {
s := &UserStore{byID: make(map[int64]domain.User), nextID: domain.UserIDSequenceBase}
for _, id := range []int64{domain.OfficialSystemUserID, domain.BotFatherUserID, domain.StickersBotUserID} {
for _, id := range []int64{domain.OfficialSystemUserID, domain.BotFatherUserID, domain.StickersBotUserID, domain.ChatBotUserID} {
if u, ok := domain.SystemUserByID(id); ok {
s.byID[u.ID] = u
}