diff --git a/internal/storage/entry.go b/internal/storage/entry.go index de0ea54..e1bdd8d 100644 --- a/internal/storage/entry.go +++ b/internal/storage/entry.go @@ -1,14 +1,14 @@ -package storage - -import ( - "fmt" - "sort" - "time" - - "github.com/teris-io/shortid" - bolt "go.etcd.io/bbolt" -) - +package storage + +import ( + "fmt" + "sort" + "time" + + "github.com/teris-io/shortid" + bolt "go.etcd.io/bbolt" +) + func (s *Storage) CreateEntry(category, title, channelLink, directLink string) (*Entry, error) { entry := &Entry{ ID: shortid.MustGenerate(), @@ -19,131 +19,131 @@ func (s *Storage) CreateEntry(category, title, channelLink, directLink string) ( DirectLink: directLink, Timestamp: time.Now(), } - - err := s.db.Update(func(tx *bolt.Tx) error { - b := tx.Bucket(bucketEntries) - data, err := encodeJSON(entry) - if err != nil { - return err - } - return b.Put([]byte(entry.ID), data) - }) - - if err != nil { - return nil, err - } - return entry, nil -} - -func (s *Storage) GetEntry(id string) (*Entry, error) { - var entry *Entry - err := s.db.View(func(tx *bolt.Tx) error { - b := tx.Bucket(bucketEntries) - data := b.Get([]byte(id)) - if data == nil { - return fmt.Errorf("entry %q not found", id) - } - entry = &Entry{} - return decodeJSON(data, entry) - }) - return entry, err -} - -func (s *Storage) DeleteEntry(id string) error { - return s.db.Update(func(tx *bolt.Tx) error { - b := tx.Bucket(bucketEntries) - if b.Get([]byte(id)) == nil { - return fmt.Errorf("entry %q not found", id) - } - return b.Delete([]byte(id)) - }) -} - -func (s *Storage) UpdateEntryTitle(id, title string) error { - return s.db.Update(func(tx *bolt.Tx) error { - b := tx.Bucket(bucketEntries) - data := b.Get([]byte(id)) - if data == nil { - return fmt.Errorf("entry %q not found", id) - } - - var entry Entry - if err := decodeJSON(data, &entry); err != nil { - return err - } - - entry.Title = title - newData, err := encodeJSON(entry) - if err != nil { - return err - } - - return b.Put([]byte(id), newData) - }) -} - -func (s *Storage) UpdateEntryCategory(id, category string) error { - return s.db.Update(func(tx *bolt.Tx) error { - b := tx.Bucket(bucketEntries) - data := b.Get([]byte(id)) - if data == nil { - return fmt.Errorf("entry %q not found", id) - } - - var entry Entry - if err := decodeJSON(data, &entry); err != nil { - return err - } - - entry.Category = category - newData, err := encodeJSON(entry) - if err != nil { - return err - } - - return b.Put([]byte(id), newData) - }) -} - -func (s *Storage) ListEntries(category string) ([]Entry, error) { - var entries []Entry - err := s.db.View(func(tx *bolt.Tx) error { - b := tx.Bucket(bucketEntries) - return b.ForEach(func(k, v []byte) error { - var entry Entry - if err := decodeJSON(v, &entry); err != nil { - return err - } - if category == "" || entry.Category == category { - entries = append(entries, entry) - } - return nil - }) - }) - if err != nil { - return nil, err - } - - sort.Slice(entries, func(i, j int) bool { - return entries[i].Timestamp.Before(entries[j].Timestamp) - }) - - return entries, nil -} - -func (s *Storage) ListAllEntries() ([]Entry, error) { - return s.ListEntries("") -} - -func (s *Storage) GetEntriesByCategory() (map[string][]Entry, error) { - entries, err := s.ListAllEntries() - if err != nil { - return nil, err - } - - result := make(map[string][]Entry) - for _, e := range entries { - result[e.Category] = append(result[e.Category], e) - } - return result, nil -} + + err := s.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(bucketEntries) + data, err := encodeJSON(entry) + if err != nil { + return err + } + return b.Put([]byte(entry.ID), data) + }) + + if err != nil { + return nil, err + } + return entry, nil +} + +func (s *Storage) GetEntry(id string) (*Entry, error) { + var entry *Entry + err := s.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket(bucketEntries) + data := b.Get([]byte(id)) + if data == nil { + return fmt.Errorf("entry %q not found", id) + } + entry = &Entry{} + return decodeJSON(data, entry) + }) + return entry, err +} + +func (s *Storage) DeleteEntry(id string) error { + return s.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(bucketEntries) + if b.Get([]byte(id)) == nil { + return fmt.Errorf("entry %q not found", id) + } + return b.Delete([]byte(id)) + }) +} + +func (s *Storage) UpdateEntryTitle(id, title string) error { + return s.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(bucketEntries) + data := b.Get([]byte(id)) + if data == nil { + return fmt.Errorf("entry %q not found", id) + } + + var entry Entry + if err := decodeJSON(data, &entry); err != nil { + return err + } + + entry.Title = title + newData, err := encodeJSON(entry) + if err != nil { + return err + } + + return b.Put([]byte(id), newData) + }) +} + +func (s *Storage) UpdateEntryCategory(id, category string) error { + return s.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(bucketEntries) + data := b.Get([]byte(id)) + if data == nil { + return fmt.Errorf("entry %q not found", id) + } + + var entry Entry + if err := decodeJSON(data, &entry); err != nil { + return err + } + + entry.Category = category + newData, err := encodeJSON(entry) + if err != nil { + return err + } + + return b.Put([]byte(id), newData) + }) +} + +func (s *Storage) ListEntries(category string) ([]Entry, error) { + var entries []Entry + err := s.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket(bucketEntries) + return b.ForEach(func(k, v []byte) error { + var entry Entry + if err := decodeJSON(v, &entry); err != nil { + return err + } + if category == "" || entry.Category == category { + entries = append(entries, entry) + } + return nil + }) + }) + if err != nil { + return nil, err + } + + sort.Slice(entries, func(i, j int) bool { + return entries[i].Timestamp.Before(entries[j].Timestamp) + }) + + return entries, nil +} + +func (s *Storage) ListAllEntries() ([]Entry, error) { + return s.ListEntries("") +} + +func (s *Storage) GetEntriesByCategory() (map[string][]Entry, error) { + entries, err := s.ListAllEntries() + if err != nil { + return nil, err + } + + result := make(map[string][]Entry) + for _, e := range entries { + result[e.Category] = append(result[e.Category], e) + } + return result, nil +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index e46c4f7..0e1b8ae 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -1,5 +1,5 @@ -package storage - +package storage + import ( "encoding/binary" "encoding/json" @@ -8,25 +8,25 @@ import ( "path/filepath" "strings" "time" - - bolt "go.etcd.io/bbolt" -) - -var ( - bucketAppConfig = []byte("AppConfig") - bucketCategories = []byte("Categories") - bucketEntries = []byte("Entries") - bucketAdmins = []byte("Admins") - - keyTocMsgID = []byte("toc_msg_id") -) - -type Category struct { - Name string `json:"name"` - Order int `json:"order"` - CreatedAt time.Time `json:"created_at"` -} - + + bolt "go.etcd.io/bbolt" +) + +var ( + bucketAppConfig = []byte("AppConfig") + bucketCategories = []byte("Categories") + bucketEntries = []byte("Entries") + bucketAdmins = []byte("Admins") + + keyTocMsgID = []byte("toc_msg_id") +) + +type Category struct { + Name string `json:"name"` + Order int `json:"order"` + CreatedAt time.Time `json:"created_at"` +} + type Entry struct { ID string `json:"id"` Category string `json:"category"` @@ -48,120 +48,120 @@ func (e Entry) HasDirectLink() bool { direct := strings.TrimSpace(e.DirectLink) return direct != "" && direct != e.MessageLink() } - -type Storage struct { - db *bolt.DB -} - -func New(path string) (*Storage, error) { - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return nil, fmt.Errorf("failed to create data directory: %w", err) - } - - db, err := bolt.Open(path, 0600, &bolt.Options{Timeout: 1 * time.Second}) - if err != nil { - return nil, fmt.Errorf("failed to open database: %w", err) - } - - s := &Storage{db: db} - if err := s.initBuckets(); err != nil { - db.Close() - return nil, err - } - - return s, nil -} - -func (s *Storage) initBuckets() error { - return s.db.Update(func(tx *bolt.Tx) error { - buckets := [][]byte{bucketAppConfig, bucketCategories, bucketEntries, bucketAdmins} - for _, b := range buckets { - if _, err := tx.CreateBucketIfNotExists(b); err != nil { - return fmt.Errorf("failed to create bucket %s: %w", b, err) - } - } - return nil - }) -} - -func (s *Storage) Close() error { - return s.db.Close() -} - -func (s *Storage) GetTocMsgID() (int, error) { - var msgID int - err := s.db.View(func(tx *bolt.Tx) error { - b := tx.Bucket(bucketAppConfig) - v := b.Get(keyTocMsgID) - if v != nil && len(v) >= 4 { - msgID = int(binary.BigEndian.Uint32(v)) - } - return nil - }) - return msgID, err -} - -func (s *Storage) SetTocMsgID(msgID int) error { - return s.db.Update(func(tx *bolt.Tx) error { - b := tx.Bucket(bucketAppConfig) - buf := make([]byte, 4) - binary.BigEndian.PutUint32(buf, uint32(msgID)) - return b.Put(keyTocMsgID, buf) - }) -} - -func encodeJSON(v any) ([]byte, error) { - return json.Marshal(v) -} - -func decodeJSON(data []byte, v any) error { - return json.Unmarshal(data, v) -} - -// Admin management - -func (s *Storage) AddAdmin(userID int64) error { - return s.db.Update(func(tx *bolt.Tx) error { - b := tx.Bucket(bucketAdmins) - key := make([]byte, 8) - binary.BigEndian.PutUint64(key, uint64(userID)) - return b.Put(key, []byte("1")) - }) -} - -func (s *Storage) RemoveAdmin(userID int64) error { - return s.db.Update(func(tx *bolt.Tx) error { - b := tx.Bucket(bucketAdmins) - key := make([]byte, 8) - binary.BigEndian.PutUint64(key, uint64(userID)) - return b.Delete(key) - }) -} - -func (s *Storage) IsAdmin(userID int64) bool { - var exists bool - s.db.View(func(tx *bolt.Tx) error { - b := tx.Bucket(bucketAdmins) - key := make([]byte, 8) - binary.BigEndian.PutUint64(key, uint64(userID)) - exists = b.Get(key) != nil - return nil - }) - return exists -} - -func (s *Storage) ListAdmins() ([]int64, error) { - var admins []int64 - err := s.db.View(func(tx *bolt.Tx) error { - b := tx.Bucket(bucketAdmins) - return b.ForEach(func(k, v []byte) error { - if len(k) == 8 { - userID := int64(binary.BigEndian.Uint64(k)) - admins = append(admins, userID) - } - return nil - }) - }) - return admins, err -} + +type Storage struct { + db *bolt.DB +} + +func New(path string) (*Storage, error) { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("failed to create data directory: %w", err) + } + + db, err := bolt.Open(path, 0600, &bolt.Options{Timeout: 1 * time.Second}) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + s := &Storage{db: db} + if err := s.initBuckets(); err != nil { + db.Close() + return nil, err + } + + return s, nil +} + +func (s *Storage) initBuckets() error { + return s.db.Update(func(tx *bolt.Tx) error { + buckets := [][]byte{bucketAppConfig, bucketCategories, bucketEntries, bucketAdmins} + for _, b := range buckets { + if _, err := tx.CreateBucketIfNotExists(b); err != nil { + return fmt.Errorf("failed to create bucket %s: %w", b, err) + } + } + return nil + }) +} + +func (s *Storage) Close() error { + return s.db.Close() +} + +func (s *Storage) GetTocMsgID() (int, error) { + var msgID int + err := s.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket(bucketAppConfig) + v := b.Get(keyTocMsgID) + if v != nil && len(v) >= 4 { + msgID = int(binary.BigEndian.Uint32(v)) + } + return nil + }) + return msgID, err +} + +func (s *Storage) SetTocMsgID(msgID int) error { + return s.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(bucketAppConfig) + buf := make([]byte, 4) + binary.BigEndian.PutUint32(buf, uint32(msgID)) + return b.Put(keyTocMsgID, buf) + }) +} + +func encodeJSON(v any) ([]byte, error) { + return json.Marshal(v) +} + +func decodeJSON(data []byte, v any) error { + return json.Unmarshal(data, v) +} + +// Admin management + +func (s *Storage) AddAdmin(userID int64) error { + return s.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(bucketAdmins) + key := make([]byte, 8) + binary.BigEndian.PutUint64(key, uint64(userID)) + return b.Put(key, []byte("1")) + }) +} + +func (s *Storage) RemoveAdmin(userID int64) error { + return s.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(bucketAdmins) + key := make([]byte, 8) + binary.BigEndian.PutUint64(key, uint64(userID)) + return b.Delete(key) + }) +} + +func (s *Storage) IsAdmin(userID int64) bool { + var exists bool + s.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket(bucketAdmins) + key := make([]byte, 8) + binary.BigEndian.PutUint64(key, uint64(userID)) + exists = b.Get(key) != nil + return nil + }) + return exists +} + +func (s *Storage) ListAdmins() ([]int64, error) { + var admins []int64 + err := s.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket(bucketAdmins) + return b.ForEach(func(k, v []byte) error { + if len(k) == 8 { + userID := int64(binary.BigEndian.Uint64(k)) + admins = append(admins, userID) + } + return nil + }) + }) + return admins, err +} diff --git a/internal/telegram/handlers_entry.go b/internal/telegram/handlers_entry.go index 3f1d501..518107c 100644 --- a/internal/telegram/handlers_entry.go +++ b/internal/telegram/handlers_entry.go @@ -1,172 +1,172 @@ -package telegram - -import ( - "fmt" - "html" - "strconv" - "strings" - - tele "gopkg.in/telebot.v3" -) - -const ( - cbPrefixDelEntry = "del:" - cbPrefixDelWithMsg = "delwithmsg:" - cbPrefixDelOnlyToc = "delonlytoc:" -) - -func (b *Bot) handleEntryDel(c tele.Context) error { - id := strings.TrimSpace(c.Message().Payload) - if id == "" { - return c.Reply("用法: /del ") - } - - entry, err := b.storage.GetEntry(id) - if err != nil { - return c.Reply(fmt.Sprintf("❌ %v", err)) - } - - menu := &tele.ReplyMarkup{} - delWithMsgBtn := menu.Data("🗑 删除条目+频道消息", cbPrefixDelWithMsg+id) - delOnlyTocBtn := menu.Data("📝 仅删除目录条目", cbPrefixDelOnlyToc+id) - cancelBtn := menu.Data("❌ 取消", cbCancel) - menu.Inline( - menu.Row(delWithMsgBtn), - menu.Row(delOnlyTocBtn), - menu.Row(cancelBtn), - ) - - text := fmt.Sprintf("🗑 确认删除?\n\n分类: %s\n标题: %s\n\n请选择删除方式:", entry.Category, entry.Title) - return c.Reply(text, menu) -} - -func (b *Bot) handleEntryEdit(c tele.Context) error { - args := strings.SplitN(c.Message().Payload, " ", 2) - if len(args) < 2 { - return c.Reply("用法: /edit <新标题>") - } - - id := args[0] - newTitle := strings.TrimSpace(args[1]) - - if err := b.storage.UpdateEntryTitle(id, newTitle); err != nil { - return c.Reply(fmt.Sprintf("❌ %v", err)) - } - - b.toc.TriggerUpdate() - return c.Reply(fmt.Sprintf("✅ 标题已更新为: %s", newTitle)) -} - -func (b *Bot) handleEntryMove(c tele.Context) error { - args := strings.Fields(c.Message().Payload) - if len(args) < 2 { - return c.Reply("用法: /move <新分类>") - } - - id := args[0] - newCategory := args[1] - - if !b.storage.CategoryExists(newCategory) { - return c.Reply(fmt.Sprintf("❌ 分类 [%s] 不存在", newCategory)) - } - - if err := b.storage.UpdateEntryCategory(id, newCategory); err != nil { - return c.Reply(fmt.Sprintf("❌ %v", err)) - } - - b.toc.TriggerUpdate() - return c.Reply(fmt.Sprintf("✅ 已移动到分类: %s", newCategory)) -} - -func (b *Bot) handleEntryList(c tele.Context) error { - category := strings.TrimSpace(c.Message().Payload) - - entries, err := b.storage.ListEntries(category) - if err != nil { - return c.Reply(fmt.Sprintf("❌ %v", err)) - } - - if len(entries) == 0 { - if category != "" { - return c.Reply(fmt.Sprintf("📭 分类 [%s] 暂无条目", category)) - } - return c.Reply("📭 暂无条目") - } - - var sb strings.Builder - if category != "" { - sb.WriteString(fmt.Sprintf("📋 分类 [%s] 的条目:\n\n", html.EscapeString(category))) - } else { - sb.WriteString("📋 所有条目:\n\n") - } - - currentCat := "" - for _, e := range entries { - if category == "" && e.Category != currentCat { - currentCat = e.Category - sb.WriteString(fmt.Sprintf("\n【%s】\n", html.EscapeString(currentCat))) - } - sb.WriteString(fmt.Sprintf("• %s %s\n", e.ID, html.EscapeString(e.Title))) - } - - return c.Reply(sb.String(), tele.ModeHTML) -} - -func (b *Bot) handleRefresh(c tele.Context) error { - b.toc.TriggerUpdate() - return c.Reply("🔄 目录刷新已触发") -} - -// handleDeleteEntryCallback 处理删除条目的回调 -func (b *Bot) handleDeleteEntryCallback(c tele.Context, entryID string, deleteChannelMsg bool) error { - entry, err := b.storage.GetEntry(entryID) - if err != nil { - c.Edit(fmt.Sprintf("❌ %v", err)) - return c.Respond(&tele.CallbackResponse{Text: "条目不存在"}) - } - - // 根据用户选择决定是否删除频道消息 - msgDeleted := false - if deleteChannelMsg { +package telegram + +import ( + "fmt" + "html" + "strconv" + "strings" + + tele "gopkg.in/telebot.v3" +) + +const ( + cbPrefixDelEntry = "del:" + cbPrefixDelWithMsg = "delwithmsg:" + cbPrefixDelOnlyToc = "delonlytoc:" +) + +func (b *Bot) handleEntryDel(c tele.Context) error { + id := strings.TrimSpace(c.Message().Payload) + if id == "" { + return c.Reply("用法: /del ") + } + + entry, err := b.storage.GetEntry(id) + if err != nil { + return c.Reply(fmt.Sprintf("❌ %v", err)) + } + + menu := &tele.ReplyMarkup{} + delWithMsgBtn := menu.Data("🗑 删除条目+频道消息", cbPrefixDelWithMsg+id) + delOnlyTocBtn := menu.Data("📝 仅删除目录条目", cbPrefixDelOnlyToc+id) + cancelBtn := menu.Data("❌ 取消", cbCancel) + menu.Inline( + menu.Row(delWithMsgBtn), + menu.Row(delOnlyTocBtn), + menu.Row(cancelBtn), + ) + + text := fmt.Sprintf("🗑 确认删除?\n\n分类: %s\n标题: %s\n\n请选择删除方式:", entry.Category, entry.Title) + return c.Reply(text, menu) +} + +func (b *Bot) handleEntryEdit(c tele.Context) error { + args := strings.SplitN(c.Message().Payload, " ", 2) + if len(args) < 2 { + return c.Reply("用法: /edit <新标题>") + } + + id := args[0] + newTitle := strings.TrimSpace(args[1]) + + if err := b.storage.UpdateEntryTitle(id, newTitle); err != nil { + return c.Reply(fmt.Sprintf("❌ %v", err)) + } + + b.toc.TriggerUpdate() + return c.Reply(fmt.Sprintf("✅ 标题已更新为: %s", newTitle)) +} + +func (b *Bot) handleEntryMove(c tele.Context) error { + args := strings.Fields(c.Message().Payload) + if len(args) < 2 { + return c.Reply("用法: /move <新分类>") + } + + id := args[0] + newCategory := args[1] + + if !b.storage.CategoryExists(newCategory) { + return c.Reply(fmt.Sprintf("❌ 分类 [%s] 不存在", newCategory)) + } + + if err := b.storage.UpdateEntryCategory(id, newCategory); err != nil { + return c.Reply(fmt.Sprintf("❌ %v", err)) + } + + b.toc.TriggerUpdate() + return c.Reply(fmt.Sprintf("✅ 已移动到分类: %s", newCategory)) +} + +func (b *Bot) handleEntryList(c tele.Context) error { + category := strings.TrimSpace(c.Message().Payload) + + entries, err := b.storage.ListEntries(category) + if err != nil { + return c.Reply(fmt.Sprintf("❌ %v", err)) + } + + if len(entries) == 0 { + if category != "" { + return c.Reply(fmt.Sprintf("📭 分类 [%s] 暂无条目", category)) + } + return c.Reply("📭 暂无条目") + } + + var sb strings.Builder + if category != "" { + sb.WriteString(fmt.Sprintf("📋 分类 [%s] 的条目:\n\n", html.EscapeString(category))) + } else { + sb.WriteString("📋 所有条目:\n\n") + } + + currentCat := "" + for _, e := range entries { + if category == "" && e.Category != currentCat { + currentCat = e.Category + sb.WriteString(fmt.Sprintf("\n【%s】\n", html.EscapeString(currentCat))) + } + sb.WriteString(fmt.Sprintf("• %s %s\n", e.ID, html.EscapeString(e.Title))) + } + + return c.Reply(sb.String(), tele.ModeHTML) +} + +func (b *Bot) handleRefresh(c tele.Context) error { + b.toc.TriggerUpdate() + return c.Reply("🔄 目录刷新已触发") +} + +// handleDeleteEntryCallback 处理删除条目的回调 +func (b *Bot) handleDeleteEntryCallback(c tele.Context, entryID string, deleteChannelMsg bool) error { + entry, err := b.storage.GetEntry(entryID) + if err != nil { + c.Edit(fmt.Sprintf("❌ %v", err)) + return c.Respond(&tele.CallbackResponse{Text: "条目不存在"}) + } + + // 根据用户选择决定是否删除频道消息 + msgDeleted := false + if deleteChannelMsg { if msgID := parseMessageIDFromLink(entry.MessageLink()); msgID != 0 { channel := &tele.Chat{ID: b.cfg.Channel.ID} msg := &tele.Message{ID: msgID, Chat: channel} if err := b.bot.Delete(msg); err == nil { - msgDeleted = true - } - } - } - - if err := b.storage.DeleteEntry(entryID); err != nil { - c.Edit(fmt.Sprintf("❌ 删除失败: %v", err)) - return c.Respond(&tele.CallbackResponse{Text: "删除失败"}) - } - - b.toc.TriggerUpdate() - - var text string - if deleteChannelMsg && msgDeleted { - text = fmt.Sprintf("✅ 已删除: [%s] %s\n📨 频道消息已同步删除", entry.Category, entry.Title) - } else if deleteChannelMsg && !msgDeleted { - text = fmt.Sprintf("✅ 已删除: [%s] %s\n⚠️ 频道消息删除失败(可能已被删除)", entry.Category, entry.Title) - } else { - text = fmt.Sprintf("✅ 已删除: [%s] %s\n📝 频道消息已保留", entry.Category, entry.Title) - } - - c.Edit(text) - return c.Respond(&tele.CallbackResponse{Text: "删除成功"}) -} - -// parseMessageIDFromLink 从链接中解析消息ID -// 支持格式: https://t.me/c/123456/789 或 https://t.me/username/789 -func parseMessageIDFromLink(link string) int { - parts := strings.Split(link, "/") - if len(parts) < 1 { - return 0 - } - msgID, err := strconv.Atoi(parts[len(parts)-1]) - if err != nil { - return 0 - } - return msgID -} + msgDeleted = true + } + } + } + + if err := b.storage.DeleteEntry(entryID); err != nil { + c.Edit(fmt.Sprintf("❌ 删除失败: %v", err)) + return c.Respond(&tele.CallbackResponse{Text: "删除失败"}) + } + + b.toc.TriggerUpdate() + + var text string + if deleteChannelMsg && msgDeleted { + text = fmt.Sprintf("✅ 已删除: [%s] %s\n📨 频道消息已同步删除", entry.Category, entry.Title) + } else if deleteChannelMsg && !msgDeleted { + text = fmt.Sprintf("✅ 已删除: [%s] %s\n⚠️ 频道消息删除失败(可能已被删除)", entry.Category, entry.Title) + } else { + text = fmt.Sprintf("✅ 已删除: [%s] %s\n📝 频道消息已保留", entry.Category, entry.Title) + } + + c.Edit(text) + return c.Respond(&tele.CallbackResponse{Text: "删除成功"}) +} + +// parseMessageIDFromLink 从链接中解析消息ID +// 支持格式: https://t.me/c/123456/789 或 https://t.me/username/789 +func parseMessageIDFromLink(link string) int { + parts := strings.Split(link, "/") + if len(parts) < 1 { + return 0 + } + msgID, err := strconv.Atoi(parts[len(parts)-1]) + if err != nil { + return 0 + } + return msgID +} diff --git a/internal/telegram/handlers_post.go b/internal/telegram/handlers_post.go index 37c5d44..a337417 100644 --- a/internal/telegram/handlers_post.go +++ b/internal/telegram/handlers_post.go @@ -1,77 +1,77 @@ -package telegram - +package telegram + import ( "fmt" "regexp" "strings" "sync" "time" - - "tgchanbot/internal/storage" - - tele "gopkg.in/telebot.v3" -) - -const ( - cbPrefixCat = "cat:" - cbPrefixConfirm = "confirm:" - cbPrefixTitle = "title:" - cbCancel = "cancel" - - albumCollectDelay = 500 * time.Millisecond // 等待相册其他消息的时间 -) - -// 相册收集定时器 + + "tgchanbot/internal/storage" + + tele "gopkg.in/telebot.v3" +) + +const ( + cbPrefixCat = "cat:" + cbPrefixConfirm = "confirm:" + cbPrefixTitle = "title:" + cbCancel = "cancel" + + albumCollectDelay = 500 * time.Millisecond // 等待相册其他消息的时间 +) + +// 相册收集定时器 var ( albumTimers = make(map[int64]*time.Timer) albumTimersMu sync.Mutex ) var directURLPattern = regexp.MustCompile(`https?://[^\s<>"']+`) - -func (b *Bot) handlePost(c tele.Context) error { - msg := c.Message() - payload := strings.TrimSpace(c.Message().Payload) - - // 快捷方式: 回复消息 + /post <分类> /tt <标题> - if msg.ReplyTo != nil && payload != "" { - return b.handleQuickPost(c, msg.ReplyTo, payload) - } - - // 常规交互流程 - b.states.StartPost(c.Sender().ID) - return c.Reply("📨 请转发需要归档到目录的消息\n\n消息将被同步发送到频道并添加到目录\n\n💡 快捷方式: 回复消息并发送\n`/post <分类> /tt <标题>`", tele.ModeMarkdown) -} - -func (b *Bot) handleQuickPost(c tele.Context, replyMsg *tele.Message, payload string) error { - // 解析: <分类> /tt <标题> - parts := strings.SplitN(payload, "/tt", 2) - category := strings.TrimSpace(parts[0]) - - var title string - if len(parts) > 1 { - title = strings.TrimSpace(parts[1]) - } - - if category == "" { - return c.Reply("❌ 用法: /post <分类> /tt <标题>\n例如: /post iOS /tt 某个APP推荐") - } - - // 验证分类存在 - if !b.storage.CategoryExists(category) { - categories, _ := b.storage.ListCategories() - var names []string - for _, cat := range categories { - names = append(names, cat.Name) - } - return c.Reply(fmt.Sprintf("❌ 分类 [%s] 不存在\n\n可用分类: %s", category, strings.Join(names, ", "))) - } - - // 标题: 优先使用指定标题,否则从回复消息提取 - if title == "" { - title = extractTitle(replyMsg) - } - + +func (b *Bot) handlePost(c tele.Context) error { + msg := c.Message() + payload := strings.TrimSpace(c.Message().Payload) + + // 快捷方式: 回复消息 + /post <分类> /tt <标题> + if msg.ReplyTo != nil && payload != "" { + return b.handleQuickPost(c, msg.ReplyTo, payload) + } + + // 常规交互流程 + b.states.StartPost(c.Sender().ID) + return c.Reply("📨 请转发需要归档到目录的消息\n\n消息将被同步发送到频道并添加到目录\n\n💡 快捷方式: 回复消息并发送\n`/post <分类> /tt <标题>`", tele.ModeMarkdown) +} + +func (b *Bot) handleQuickPost(c tele.Context, replyMsg *tele.Message, payload string) error { + // 解析: <分类> /tt <标题> + parts := strings.SplitN(payload, "/tt", 2) + category := strings.TrimSpace(parts[0]) + + var title string + if len(parts) > 1 { + title = strings.TrimSpace(parts[1]) + } + + if category == "" { + return c.Reply("❌ 用法: /post <分类> /tt <标题>\n例如: /post iOS /tt 某个APP推荐") + } + + // 验证分类存在 + if !b.storage.CategoryExists(category) { + categories, _ := b.storage.ListCategories() + var names []string + for _, cat := range categories { + names = append(names, cat.Name) + } + return c.Reply(fmt.Sprintf("❌ 分类 [%s] 不存在\n\n可用分类: %s", category, strings.Join(names, ", "))) + } + + // 标题: 优先使用指定标题,否则从回复消息提取 + if title == "" { + title = extractTitle(replyMsg) + } + channelLink := buildMessageLinkFromReply(c.Message(), replyMsg) directLink := extractDirectLink(replyMsg) @@ -80,366 +80,366 @@ func (b *Bot) handleQuickPost(c tele.Context, replyMsg *tele.Message, payload st if err != nil { return c.Reply(fmt.Sprintf("❌ 保存失败: %v", err)) } - - b.toc.TriggerUpdate() - + + b.toc.TriggerUpdate() + return c.Reply(fmt.Sprintf("✅ 已添加\n\nID: `%s`\n分类: %s\n标题: %s\n链接: %s", entry.ID, entry.Category, entry.Title, entry.MessageLink()), tele.ModeMarkdown) } - -func buildMessageLinkFromReply(currentMsg, replyMsg *tele.Message) string { - chat := currentMsg.Chat - msgID := replyMsg.ID - - if chat.Username != "" { - return fmt.Sprintf("https://t.me/%s/%d", chat.Username, msgID) - } - - chatID := chat.ID - if chatID < 0 { - chatID = -chatID - 1000000000000 - } - return fmt.Sprintf("https://t.me/c/%d/%d", chatID, msgID) -} - -func (b *Bot) handleTextInput(c tele.Context) error { - msg := c.Message() - if msg == nil { - return nil - } - - // 只处理私聊消息,忽略群组对话 - if c.Chat().Type != tele.ChatPrivate { - return nil - } - - // 检查是否为转发消息(OriginalUnixtime 在隐私设置隐藏来源时仍存在) - isForwarded := msg.OriginalChat != nil || msg.OriginalSender != nil || msg.OriginalUnixtime > 0 - - state := b.states.Get(c.Sender().ID) - - // 私聊收到转发消息,直接启动投稿流程(无需先 /post) + +func buildMessageLinkFromReply(currentMsg, replyMsg *tele.Message) string { + chat := currentMsg.Chat + msgID := replyMsg.ID + + if chat.Username != "" { + return fmt.Sprintf("https://t.me/%s/%d", chat.Username, msgID) + } + + chatID := chat.ID + if chatID < 0 { + chatID = -chatID - 1000000000000 + } + return fmt.Sprintf("https://t.me/c/%d/%d", chatID, msgID) +} + +func (b *Bot) handleTextInput(c tele.Context) error { + msg := c.Message() + if msg == nil { + return nil + } + + // 只处理私聊消息,忽略群组对话 + if c.Chat().Type != tele.ChatPrivate { + return nil + } + + // 检查是否为转发消息(OriginalUnixtime 在隐私设置隐藏来源时仍存在) + isForwarded := msg.OriginalChat != nil || msg.OriginalSender != nil || msg.OriginalUnixtime > 0 + + state := b.states.Get(c.Sender().ID) + + // 私聊收到转发消息,直接启动投稿流程(无需先 /post) if isForwarded && b.isAdmin(c.Sender().ID) { - if state == nil { - b.states.StartPost(c.Sender().ID) - } - if state == nil || state.Step == StepAwaitForward { - return b.handleForwarded(c) - } - } - - if state == nil { - return nil - } - - // 自定义标题输入 (StepAwaitTitle) - if state.Step == StepAwaitTitle { - return b.handleTitleInput(c) - } - - return nil -} - -func (b *Bot) handleForwarded(c tele.Context) error { + if state == nil { + b.states.StartPost(c.Sender().ID) + } + if state == nil || state.Step == StepAwaitForward { + return b.handleForwarded(c) + } + } + + if state == nil { + return nil + } + + // 自定义标题输入 (StepAwaitTitle) + if state.Step == StepAwaitTitle { + return b.handleTitleInput(c) + } + + return nil +} + +func (b *Bot) handleForwarded(c tele.Context) error { if !b.isAdmin(c.Sender().ID) { - return nil - } - - state := b.states.Get(c.Sender().ID) - if state == nil || state.Step != StepAwaitForward { - return nil - } - - msg := c.Message() - // 检查是否为转发消息(OriginalUnixtime 在隐私设置隐藏来源时仍存在) - if msg.OriginalChat == nil && msg.OriginalSender == nil && msg.OriginalUnixtime == 0 { - return c.Reply("❌ 这不是一条转发消息,请转发一条消息给我") - } - - userID := c.Sender().ID - - // 相册消息处理 - if msg.AlbumID != "" { - b.states.AddAlbumMessage(userID, msg) - b.scheduleAlbumFinalize(c, userID) - return nil // 等待收集完成 - } - - // 单条消息 - b.states.SetForwarded(userID, msg) - return b.showCategorySelection(c) -} - -// scheduleAlbumFinalize 延迟完成相册收集 -func (b *Bot) scheduleAlbumFinalize(c tele.Context, userID int64) { - albumTimersMu.Lock() - defer albumTimersMu.Unlock() - - // 取消之前的定时器 - if timer, exists := albumTimers[userID]; exists { - timer.Stop() - } - - // 设置新定时器 - albumTimers[userID] = time.AfterFunc(albumCollectDelay, func() { - albumTimersMu.Lock() - delete(albumTimers, userID) - albumTimersMu.Unlock() - - b.states.FinalizeAlbum(userID) - b.showCategorySelectionAsync(c, userID) - }) -} - -// showCategorySelection 显示分类选择 -func (b *Bot) showCategorySelection(c tele.Context) error { - categories, err := b.storage.ListCategories() - if err != nil { - b.states.Delete(c.Sender().ID) - return c.Reply(fmt.Sprintf("❌ 获取分类失败: %v", err)) - } - - if len(categories) == 0 { - b.states.Delete(c.Sender().ID) - return c.Reply("❌ 暂无分类,请先使用 /cat_add 创建分类") - } - - state := b.states.Get(c.Sender().ID) - msgCount := 1 - if state != nil && len(state.ForwardedMsgs) > 0 { - msgCount = len(state.ForwardedMsgs) - } - - keyboard := b.buildCategoryKeyboard(categories) - if msgCount > 1 { - return c.Reply(fmt.Sprintf("📁 已收到 %d 条消息(相册),请选择分类:", msgCount), keyboard) - } - return c.Reply("📁 请选择分类:", keyboard) -} - -// showCategorySelectionAsync 异步显示分类选择(用于定时器回调) -func (b *Bot) showCategorySelectionAsync(c tele.Context, userID int64) { - categories, err := b.storage.ListCategories() - if err != nil { - b.states.Delete(userID) - c.Bot().Send(c.Chat(), fmt.Sprintf("❌ 获取分类失败: %v", err)) - return - } - - if len(categories) == 0 { - b.states.Delete(userID) - c.Bot().Send(c.Chat(), "❌ 暂无分类,请先使用 /cat_add 创建分类") - return - } - - state := b.states.Get(userID) - msgCount := 1 - if state != nil && len(state.ForwardedMsgs) > 0 { - msgCount = len(state.ForwardedMsgs) - } - - keyboard := b.buildCategoryKeyboard(categories) - if msgCount > 1 { - c.Bot().Send(c.Chat(), fmt.Sprintf("📁 已收到 %d 条消息(相册),请选择分类:", msgCount), keyboard) - } else { - c.Bot().Send(c.Chat(), "📁 请选择分类:", keyboard) - } -} - -func (b *Bot) buildCategoryKeyboard(categories []storage.Category) *tele.ReplyMarkup { - menu := &tele.ReplyMarkup{} - var rows []tele.Row - - var currentRow []tele.Btn - for _, cat := range categories { - btn := menu.Data(cat.Name, cbPrefixCat+cat.Name) - currentRow = append(currentRow, btn) - - if len(currentRow) == 3 { - rows = append(rows, menu.Row(currentRow...)) - currentRow = nil - } - } - - if len(currentRow) > 0 { - rows = append(rows, menu.Row(currentRow...)) - } - - cancelBtn := menu.Data("❌ 取消", cbCancel) - rows = append(rows, menu.Row(cancelBtn)) - - menu.Inline(rows...) - return menu -} - -func (b *Bot) handleCallback(c tele.Context) error { + return nil + } + + state := b.states.Get(c.Sender().ID) + if state == nil || state.Step != StepAwaitForward { + return nil + } + + msg := c.Message() + // 检查是否为转发消息(OriginalUnixtime 在隐私设置隐藏来源时仍存在) + if msg.OriginalChat == nil && msg.OriginalSender == nil && msg.OriginalUnixtime == 0 { + return c.Reply("❌ 这不是一条转发消息,请转发一条消息给我") + } + + userID := c.Sender().ID + + // 相册消息处理 + if msg.AlbumID != "" { + b.states.AddAlbumMessage(userID, msg) + b.scheduleAlbumFinalize(c, userID) + return nil // 等待收集完成 + } + + // 单条消息 + b.states.SetForwarded(userID, msg) + return b.showCategorySelection(c) +} + +// scheduleAlbumFinalize 延迟完成相册收集 +func (b *Bot) scheduleAlbumFinalize(c tele.Context, userID int64) { + albumTimersMu.Lock() + defer albumTimersMu.Unlock() + + // 取消之前的定时器 + if timer, exists := albumTimers[userID]; exists { + timer.Stop() + } + + // 设置新定时器 + albumTimers[userID] = time.AfterFunc(albumCollectDelay, func() { + albumTimersMu.Lock() + delete(albumTimers, userID) + albumTimersMu.Unlock() + + b.states.FinalizeAlbum(userID) + b.showCategorySelectionAsync(c, userID) + }) +} + +// showCategorySelection 显示分类选择 +func (b *Bot) showCategorySelection(c tele.Context) error { + categories, err := b.storage.ListCategories() + if err != nil { + b.states.Delete(c.Sender().ID) + return c.Reply(fmt.Sprintf("❌ 获取分类失败: %v", err)) + } + + if len(categories) == 0 { + b.states.Delete(c.Sender().ID) + return c.Reply("❌ 暂无分类,请先使用 /cat_add 创建分类") + } + + state := b.states.Get(c.Sender().ID) + msgCount := 1 + if state != nil && len(state.ForwardedMsgs) > 0 { + msgCount = len(state.ForwardedMsgs) + } + + keyboard := b.buildCategoryKeyboard(categories) + if msgCount > 1 { + return c.Reply(fmt.Sprintf("📁 已收到 %d 条消息(相册),请选择分类:", msgCount), keyboard) + } + return c.Reply("📁 请选择分类:", keyboard) +} + +// showCategorySelectionAsync 异步显示分类选择(用于定时器回调) +func (b *Bot) showCategorySelectionAsync(c tele.Context, userID int64) { + categories, err := b.storage.ListCategories() + if err != nil { + b.states.Delete(userID) + c.Bot().Send(c.Chat(), fmt.Sprintf("❌ 获取分类失败: %v", err)) + return + } + + if len(categories) == 0 { + b.states.Delete(userID) + c.Bot().Send(c.Chat(), "❌ 暂无分类,请先使用 /cat_add 创建分类") + return + } + + state := b.states.Get(userID) + msgCount := 1 + if state != nil && len(state.ForwardedMsgs) > 0 { + msgCount = len(state.ForwardedMsgs) + } + + keyboard := b.buildCategoryKeyboard(categories) + if msgCount > 1 { + c.Bot().Send(c.Chat(), fmt.Sprintf("📁 已收到 %d 条消息(相册),请选择分类:", msgCount), keyboard) + } else { + c.Bot().Send(c.Chat(), "📁 请选择分类:", keyboard) + } +} + +func (b *Bot) buildCategoryKeyboard(categories []storage.Category) *tele.ReplyMarkup { + menu := &tele.ReplyMarkup{} + var rows []tele.Row + + var currentRow []tele.Btn + for _, cat := range categories { + btn := menu.Data(cat.Name, cbPrefixCat+cat.Name) + currentRow = append(currentRow, btn) + + if len(currentRow) == 3 { + rows = append(rows, menu.Row(currentRow...)) + currentRow = nil + } + } + + if len(currentRow) > 0 { + rows = append(rows, menu.Row(currentRow...)) + } + + cancelBtn := menu.Data("❌ 取消", cbCancel) + rows = append(rows, menu.Row(cancelBtn)) + + menu.Inline(rows...) + return menu +} + +func (b *Bot) handleCallback(c tele.Context) error { if !b.isAdmin(c.Sender().ID) { - return c.Respond(&tele.CallbackResponse{Text: "无权限"}) - } - - // telebot v3 会在 data 前加 \f 前缀,需要去掉 - data := strings.TrimPrefix(c.Callback().Data, "\f") - userID := c.Sender().ID - - switch { - case data == cbCancel: - return b.handleCancelCallback(c, userID) - - case strings.HasPrefix(data, cbPrefixCat): - category := strings.TrimPrefix(data, cbPrefixCat) - return b.handleCategoryCallback(c, userID, category) - - case strings.HasPrefix(data, cbPrefixTitle): - action := strings.TrimPrefix(data, cbPrefixTitle) - return b.handleTitleCallback(c, userID, action) - - case strings.HasPrefix(data, cbPrefixConfirm): - action := strings.TrimPrefix(data, cbPrefixConfirm) - return b.handleConfirmCallback(c, userID, action) - - case strings.HasPrefix(data, cbPrefixDelWithMsg): - entryID := strings.TrimPrefix(data, cbPrefixDelWithMsg) - return b.handleDeleteEntryCallback(c, entryID, true) - - case strings.HasPrefix(data, cbPrefixDelOnlyToc): - entryID := strings.TrimPrefix(data, cbPrefixDelOnlyToc) - return b.handleDeleteEntryCallback(c, entryID, false) - } - - return c.Respond() -} - -func (b *Bot) handleCancelCallback(c tele.Context, userID int64) error { - b.states.Delete(userID) - c.Edit("❌ 已取消") - return c.Respond(&tele.CallbackResponse{Text: "已取消"}) -} - -func (b *Bot) handleTitleCallback(c tele.Context, userID int64, action string) error { - state := b.states.Get(userID) - if state == nil || state.Step != StepAwaitTitle { - return c.Respond(&tele.CallbackResponse{Text: "会话已过期"}) - } - - if action == "default" { - // 使用默认标题 - title := extractTitle(state.ForwardedMsg) - b.states.SetTitle(userID, title) - return b.showConfirmation(c, state, title) - } - - // 自定义标题 - 提示用户输入 - c.Edit("✏️ 请发送新标题:") - return c.Respond() -} - -func (b *Bot) handleTitleInput(c tele.Context) error { - state := b.states.Get(c.Sender().ID) - if state == nil || state.Step != StepAwaitTitle { - return nil - } - - title := strings.TrimSpace(c.Message().Text) - if title == "" { - return c.Reply("❌ 标题不能为空,请重新输入:") - } - - if len(title) > 50 { - title = title[:47] + "..." - } - - b.states.SetTitle(c.Sender().ID, title) - return b.showConfirmationMsg(c, state, title) -} - -func (b *Bot) showConfirmation(c tele.Context, state *PostState, title string) error { - channelName := "未知频道" - if state.ForwardedMsg.OriginalChat != nil { - channelName = state.ForwardedMsg.OriginalChat.Title - } - - menu := &tele.ReplyMarkup{} - confirmBtn := menu.Data("✅ 确认添加", cbPrefixConfirm+"yes") - cancelBtn := menu.Data("❌ 取消", cbCancel) - menu.Inline(menu.Row(confirmBtn, cancelBtn)) - - text := fmt.Sprintf("📋 确认添加?\n\n频道: %s\n分类: %s\n标题: %s", channelName, state.SelectedCat, title) - c.Edit(text, menu) - return c.Respond() -} - -func (b *Bot) showConfirmationMsg(c tele.Context, state *PostState, title string) error { - channelName := "未知频道" - if state.ForwardedMsg.OriginalChat != nil { - channelName = state.ForwardedMsg.OriginalChat.Title - } - - menu := &tele.ReplyMarkup{} - confirmBtn := menu.Data("✅ 确认添加", cbPrefixConfirm+"yes") - cancelBtn := menu.Data("❌ 取消", cbCancel) - menu.Inline(menu.Row(confirmBtn, cancelBtn)) - - text := fmt.Sprintf("📋 确认添加?\n\n频道: %s\n分类: %s\n标题: %s", channelName, state.SelectedCat, title) - return c.Reply(text, menu) -} - -func (b *Bot) handleCategoryCallback(c tele.Context, userID int64, category string) error { - state := b.states.Get(userID) - if state == nil || state.Step != StepAwaitCategory { - return c.Respond(&tele.CallbackResponse{Text: "会话已过期"}) - } - - b.states.SetCategory(userID, category) - - // 提取默认标题 - defaultTitle := extractTitle(state.ForwardedMsg) - - menu := &tele.ReplyMarkup{} - useDefaultBtn := menu.Data("✅ 使用此标题", cbPrefixTitle+"default") - customBtn := menu.Data("✏️ 自定义标题", cbPrefixTitle+"custom") - cancelBtn := menu.Data("❌ 取消", cbCancel) - menu.Inline( - menu.Row(useDefaultBtn), - menu.Row(customBtn), - menu.Row(cancelBtn), - ) - - text := fmt.Sprintf("📝 确认标题\n\n分类: %s\n标题: %s\n\n使用此标题或自定义?", category, defaultTitle) - c.Edit(text, menu) - return c.Respond() -} - -func (b *Bot) handleConfirmCallback(c tele.Context, userID int64, action string) error { - if action != "yes" { - return b.handleCancelCallback(c, userID) - } - - state := b.states.Get(userID) - if state == nil || state.Step != StepAwaitConfirm { - return c.Respond(&tele.CallbackResponse{Text: "会话已过期"}) - } - - defer b.states.Delete(userID) - - title := state.Title - channel := &tele.Chat{ID: b.cfg.Channel.ID} - - var channelMsg *tele.Message - var err error - - // 相册消息 - if len(state.ForwardedMsgs) > 1 { - channelMsg, err = b.sendAlbumCopy(channel, state.ForwardedMsgs) - } else { - // 单条消息 - channelMsg, err = b.sendMessageCopy(channel, state.ForwardedMsg) - } - - if err != nil { - c.Edit(fmt.Sprintf("❌ 发送到频道失败: %v", err)) - return c.Respond(&tele.CallbackResponse{Text: "发送失败"}) - } - + return c.Respond(&tele.CallbackResponse{Text: "无权限"}) + } + + // telebot v3 会在 data 前加 \f 前缀,需要去掉 + data := strings.TrimPrefix(c.Callback().Data, "\f") + userID := c.Sender().ID + + switch { + case data == cbCancel: + return b.handleCancelCallback(c, userID) + + case strings.HasPrefix(data, cbPrefixCat): + category := strings.TrimPrefix(data, cbPrefixCat) + return b.handleCategoryCallback(c, userID, category) + + case strings.HasPrefix(data, cbPrefixTitle): + action := strings.TrimPrefix(data, cbPrefixTitle) + return b.handleTitleCallback(c, userID, action) + + case strings.HasPrefix(data, cbPrefixConfirm): + action := strings.TrimPrefix(data, cbPrefixConfirm) + return b.handleConfirmCallback(c, userID, action) + + case strings.HasPrefix(data, cbPrefixDelWithMsg): + entryID := strings.TrimPrefix(data, cbPrefixDelWithMsg) + return b.handleDeleteEntryCallback(c, entryID, true) + + case strings.HasPrefix(data, cbPrefixDelOnlyToc): + entryID := strings.TrimPrefix(data, cbPrefixDelOnlyToc) + return b.handleDeleteEntryCallback(c, entryID, false) + } + + return c.Respond() +} + +func (b *Bot) handleCancelCallback(c tele.Context, userID int64) error { + b.states.Delete(userID) + c.Edit("❌ 已取消") + return c.Respond(&tele.CallbackResponse{Text: "已取消"}) +} + +func (b *Bot) handleTitleCallback(c tele.Context, userID int64, action string) error { + state := b.states.Get(userID) + if state == nil || state.Step != StepAwaitTitle { + return c.Respond(&tele.CallbackResponse{Text: "会话已过期"}) + } + + if action == "default" { + // 使用默认标题 + title := extractTitle(state.ForwardedMsg) + b.states.SetTitle(userID, title) + return b.showConfirmation(c, state, title) + } + + // 自定义标题 - 提示用户输入 + c.Edit("✏️ 请发送新标题:") + return c.Respond() +} + +func (b *Bot) handleTitleInput(c tele.Context) error { + state := b.states.Get(c.Sender().ID) + if state == nil || state.Step != StepAwaitTitle { + return nil + } + + title := strings.TrimSpace(c.Message().Text) + if title == "" { + return c.Reply("❌ 标题不能为空,请重新输入:") + } + + if len(title) > 50 { + title = title[:47] + "..." + } + + b.states.SetTitle(c.Sender().ID, title) + return b.showConfirmationMsg(c, state, title) +} + +func (b *Bot) showConfirmation(c tele.Context, state *PostState, title string) error { + channelName := "未知频道" + if state.ForwardedMsg.OriginalChat != nil { + channelName = state.ForwardedMsg.OriginalChat.Title + } + + menu := &tele.ReplyMarkup{} + confirmBtn := menu.Data("✅ 确认添加", cbPrefixConfirm+"yes") + cancelBtn := menu.Data("❌ 取消", cbCancel) + menu.Inline(menu.Row(confirmBtn, cancelBtn)) + + text := fmt.Sprintf("📋 确认添加?\n\n频道: %s\n分类: %s\n标题: %s", channelName, state.SelectedCat, title) + c.Edit(text, menu) + return c.Respond() +} + +func (b *Bot) showConfirmationMsg(c tele.Context, state *PostState, title string) error { + channelName := "未知频道" + if state.ForwardedMsg.OriginalChat != nil { + channelName = state.ForwardedMsg.OriginalChat.Title + } + + menu := &tele.ReplyMarkup{} + confirmBtn := menu.Data("✅ 确认添加", cbPrefixConfirm+"yes") + cancelBtn := menu.Data("❌ 取消", cbCancel) + menu.Inline(menu.Row(confirmBtn, cancelBtn)) + + text := fmt.Sprintf("📋 确认添加?\n\n频道: %s\n分类: %s\n标题: %s", channelName, state.SelectedCat, title) + return c.Reply(text, menu) +} + +func (b *Bot) handleCategoryCallback(c tele.Context, userID int64, category string) error { + state := b.states.Get(userID) + if state == nil || state.Step != StepAwaitCategory { + return c.Respond(&tele.CallbackResponse{Text: "会话已过期"}) + } + + b.states.SetCategory(userID, category) + + // 提取默认标题 + defaultTitle := extractTitle(state.ForwardedMsg) + + menu := &tele.ReplyMarkup{} + useDefaultBtn := menu.Data("✅ 使用此标题", cbPrefixTitle+"default") + customBtn := menu.Data("✏️ 自定义标题", cbPrefixTitle+"custom") + cancelBtn := menu.Data("❌ 取消", cbCancel) + menu.Inline( + menu.Row(useDefaultBtn), + menu.Row(customBtn), + menu.Row(cancelBtn), + ) + + text := fmt.Sprintf("📝 确认标题\n\n分类: %s\n标题: %s\n\n使用此标题或自定义?", category, defaultTitle) + c.Edit(text, menu) + return c.Respond() +} + +func (b *Bot) handleConfirmCallback(c tele.Context, userID int64, action string) error { + if action != "yes" { + return b.handleCancelCallback(c, userID) + } + + state := b.states.Get(userID) + if state == nil || state.Step != StepAwaitConfirm { + return c.Respond(&tele.CallbackResponse{Text: "会话已过期"}) + } + + defer b.states.Delete(userID) + + title := state.Title + channel := &tele.Chat{ID: b.cfg.Channel.ID} + + var channelMsg *tele.Message + var err error + + // 相册消息 + if len(state.ForwardedMsgs) > 1 { + channelMsg, err = b.sendAlbumCopy(channel, state.ForwardedMsgs) + } else { + // 单条消息 + channelMsg, err = b.sendMessageCopy(channel, state.ForwardedMsg) + } + + if err != nil { + c.Edit(fmt.Sprintf("❌ 发送到频道失败: %v", err)) + return c.Respond(&tele.CallbackResponse{Text: "发送失败"}) + } + channelLink := b.buildChannelLink(channelMsg.ID) directLink := extractDirectLinkFromState(state) @@ -447,78 +447,78 @@ func (b *Bot) handleConfirmCallback(c tele.Context, userID int64, action string) if err != nil { c.Edit(fmt.Sprintf("❌ 保存失败: %v", err)) return c.Respond(&tele.CallbackResponse{Text: "保存失败"}) - } - - b.toc.TriggerUpdate() - - text := fmt.Sprintf("✅ 已添加\n\nID: %s\n分类: %s\n标题: %s", entry.ID, entry.Category, entry.Title) - c.Edit(text) - return c.Respond(&tele.CallbackResponse{Text: "添加成功"}) -} - -// sendMessageCopy 复制消息内容发送(使用 copyMessage API 保留原格式) -func (b *Bot) sendMessageCopy(to *tele.Chat, msg *tele.Message) (*tele.Message, error) { - // 使用 Copy 方法,完整保留原消息的 entities(代码块、粗体、斜体等格式) - return b.bot.Copy(to, msg) -} - -// sendAlbumCopy 复制相册发送到频道,返回第一条消息 -func (b *Bot) sendAlbumCopy(to *tele.Chat, msgs []*tele.Message) (*tele.Message, error) { - if len(msgs) == 0 { - return nil, fmt.Errorf("相册为空") - } - - // 转换为 Editable 接口切片 - editables := make([]tele.Editable, len(msgs)) - for i, msg := range msgs { - editables[i] = msg - } - - // 使用 CopyMany 保留原格式(代码块、粗体等 entities) - sentMsgs, err := b.bot.CopyMany(to, editables) - if err != nil { - return nil, err - } - - if len(sentMsgs) == 0 { - return nil, fmt.Errorf("发送相册失败") - } - - return &sentMsgs[0], nil -} - -func (b *Bot) buildChannelLink(msgID int) string { - // 尝试获取频道信息,检查是否为公开频道 - chat, err := b.bot.ChatByID(b.cfg.Channel.ID) - if err == nil && chat.Username != "" { - return fmt.Sprintf("https://t.me/%s/%d", chat.Username, msgID) - } - - // 私有频道 fallback - chatID := b.cfg.Channel.ID - if chatID < 0 { - chatID = -chatID - 1000000000000 - } - return fmt.Sprintf("https://t.me/c/%d/%d", chatID, msgID) -} - + } + + b.toc.TriggerUpdate() + + text := fmt.Sprintf("✅ 已添加\n\nID: %s\n分类: %s\n标题: %s", entry.ID, entry.Category, entry.Title) + c.Edit(text) + return c.Respond(&tele.CallbackResponse{Text: "添加成功"}) +} + +// sendMessageCopy 复制消息内容发送(使用 copyMessage API 保留原格式) +func (b *Bot) sendMessageCopy(to *tele.Chat, msg *tele.Message) (*tele.Message, error) { + // 使用 Copy 方法,完整保留原消息的 entities(代码块、粗体、斜体等格式) + return b.bot.Copy(to, msg) +} + +// sendAlbumCopy 复制相册发送到频道,返回第一条消息 +func (b *Bot) sendAlbumCopy(to *tele.Chat, msgs []*tele.Message) (*tele.Message, error) { + if len(msgs) == 0 { + return nil, fmt.Errorf("相册为空") + } + + // 转换为 Editable 接口切片 + editables := make([]tele.Editable, len(msgs)) + for i, msg := range msgs { + editables[i] = msg + } + + // 使用 CopyMany 保留原格式(代码块、粗体等 entities) + sentMsgs, err := b.bot.CopyMany(to, editables) + if err != nil { + return nil, err + } + + if len(sentMsgs) == 0 { + return nil, fmt.Errorf("发送相册失败") + } + + return &sentMsgs[0], nil +} + +func (b *Bot) buildChannelLink(msgID int) string { + // 尝试获取频道信息,检查是否为公开频道 + chat, err := b.bot.ChatByID(b.cfg.Channel.ID) + if err == nil && chat.Username != "" { + return fmt.Sprintf("https://t.me/%s/%d", chat.Username, msgID) + } + + // 私有频道 fallback + chatID := b.cfg.Channel.ID + if chatID < 0 { + chatID = -chatID - 1000000000000 + } + return fmt.Sprintf("https://t.me/c/%d/%d", chatID, msgID) +} + func extractTitle(msg *tele.Message) string { - text := msg.Text - if text == "" { - text = msg.Caption - } - - lines := strings.Split(text, "\n") - title := strings.TrimSpace(lines[0]) - - if len(title) > 50 { - title = title[:47] + "..." - } - - if title == "" { - title = "无标题" - } - + text := msg.Text + if text == "" { + text = msg.Caption + } + + lines := strings.Split(text, "\n") + title := strings.TrimSpace(lines[0]) + + if len(title) > 50 { + title = title[:47] + "..." + } + + if title == "" { + title = "无标题" + } + return title } @@ -571,25 +571,25 @@ func extractDirectLink(msg *tele.Message) string { return "" } - -func buildMessageLink(msg *tele.Message) string { - if msg.OriginalChat == nil { - return "" - } - - chat := msg.OriginalChat - msgID := msg.OriginalMessageID - if msgID == 0 { - msgID = msg.ID - } - - if chat.Username != "" { - return fmt.Sprintf("https://t.me/%s/%d", chat.Username, msgID) - } - - chatID := chat.ID - if chatID < 0 { - chatID = -chatID - 1000000000000 - } - return fmt.Sprintf("https://t.me/c/%d/%d", chatID, msgID) -} + +func buildMessageLink(msg *tele.Message) string { + if msg.OriginalChat == nil { + return "" + } + + chat := msg.OriginalChat + msgID := msg.OriginalMessageID + if msgID == 0 { + msgID = msg.ID + } + + if chat.Username != "" { + return fmt.Sprintf("https://t.me/%s/%d", chat.Username, msgID) + } + + chatID := chat.ID + if chatID < 0 { + chatID = -chatID - 1000000000000 + } + return fmt.Sprintf("https://t.me/c/%d/%d", chatID, msgID) +} diff --git a/internal/toc/renderer.go b/internal/toc/renderer.go index de7fb0d..204f8e7 100644 --- a/internal/toc/renderer.go +++ b/internal/toc/renderer.go @@ -1,40 +1,40 @@ -package toc - -import ( - "fmt" - "html" - "strings" -) - -func (m *Manager) Render() (string, error) { - categories, err := m.storage.ListCategories() - if err != nil { - return "", err - } - - entriesByCategory, err := m.storage.GetEntriesByCategory() - if err != nil { - return "", err - } - - var sb strings.Builder - sb.WriteString("📚 频道目录\n") - sb.WriteString("━━━━━━━━━━━━━━━\n\n") - - if len(categories) == 0 { - sb.WriteString("暂无分类") - return sb.String(), nil - } - - for _, cat := range categories { - entries := entriesByCategory[cat.Name] - - sb.WriteString(fmt.Sprintf("📁 %s", html.EscapeString(cat.Name))) - if len(entries) > 0 { - sb.WriteString(fmt.Sprintf(" (%d)", len(entries))) - } - sb.WriteString("\n") - +package toc + +import ( + "fmt" + "html" + "strings" +) + +func (m *Manager) Render() (string, error) { + categories, err := m.storage.ListCategories() + if err != nil { + return "", err + } + + entriesByCategory, err := m.storage.GetEntriesByCategory() + if err != nil { + return "", err + } + + var sb strings.Builder + sb.WriteString("📚 频道目录\n") + sb.WriteString("━━━━━━━━━━━━━━━\n\n") + + if len(categories) == 0 { + sb.WriteString("暂无分类") + return sb.String(), nil + } + + for _, cat := range categories { + entries := entriesByCategory[cat.Name] + + sb.WriteString(fmt.Sprintf("📁 %s", html.EscapeString(cat.Name))) + if len(entries) > 0 { + sb.WriteString(fmt.Sprintf(" (%d)", len(entries))) + } + sb.WriteString("\n") + if len(entries) == 0 { sb.WriteString(" 暂无内容\n") } else { @@ -46,10 +46,10 @@ func (m *Manager) Render() (string, error) { sb.WriteString("\n") } } - sb.WriteString("\n") - } - - sb.WriteString("━━━━━━━━━━━━━━━\n") - - return sb.String(), nil -} + sb.WriteString("\n") + } + + sb.WriteString("━━━━━━━━━━━━━━━\n") + + return sb.String(), nil +}