From 05fbeabcd091eb8ccb2bc2e9fbaa649f016dea98 Mon Sep 17 00:00:00 2001 From: zuimao_kaf Date: Tue, 17 Mar 2026 13:58:16 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9B=AE=E5=BD=95=E4=BF=9D=E7=95=99=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E4=B8=8A=E4=B8=8B=E6=96=87=E5=B9=B6=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E7=9B=B4=E8=BE=BE=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/storage/entry.go | 292 ++++++++++++------------ internal/storage/storage.go | 291 ++++++++++++------------ internal/telegram/handlers_entry.go | 338 ++++++++++++++-------------- internal/telegram/handlers_post.go | 113 +++++++--- internal/toc/renderer.go | 94 ++++---- 5 files changed, 601 insertions(+), 527 deletions(-) diff --git a/internal/storage/entry.go b/internal/storage/entry.go index 1140e7a..de0ea54 100644 --- a/internal/storage/entry.go +++ b/internal/storage/entry.go @@ -1,147 +1,149 @@ -package storage - -import ( - "fmt" - "sort" - "time" - - "github.com/teris-io/shortid" - bolt "go.etcd.io/bbolt" -) - -func (s *Storage) CreateEntry(category, title, link string) (*Entry, error) { +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(), - Category: category, - Title: title, - Link: link, - Timestamp: time.Now(), + ID: shortid.MustGenerate(), + Category: category, + Title: title, + Link: channelLink, + ChannelLink: channelLink, + 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 0142a48..e46c4f7 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -1,152 +1,167 @@ -package storage - +package storage + import ( "encoding/binary" "encoding/json" "fmt" "os" "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"` - Title string `json:"title"` - Link string `json:"link"` - Timestamp time.Time `json:"timestamp"` + ID string `json:"id"` + Category string `json:"category"` + Title string `json:"title"` + Link string `json:"link,omitempty"` // 兼容旧数据 + ChannelLink string `json:"channel_link,omitempty"` + DirectLink string `json:"direct_link,omitempty"` + Timestamp time.Time `json:"timestamp"` } -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) +func (e Entry) MessageLink() string { + if e.ChannelLink != "" { + return e.ChannelLink } - - 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 + return e.Link } -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 +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 +} diff --git a/internal/telegram/handlers_entry.go b/internal/telegram/handlers_entry.go index 7fdbda7..3f1d501 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 { - if msgID := parseMessageIDFromLink(entry.Link); msgID != 0 { +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 4090856..37c5d44 100644 --- a/internal/telegram/handlers_post.go +++ b/internal/telegram/handlers_post.go @@ -1,10 +1,11 @@ package telegram -import ( - "fmt" - "strings" - "sync" - "time" +import ( + "fmt" + "regexp" + "strings" + "sync" + "time" "tgchanbot/internal/storage" @@ -21,10 +22,12 @@ const ( ) // 相册收集定时器 -var ( - albumTimers = make(map[int64]*time.Timer) - albumTimersMu sync.Mutex -) +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() @@ -69,20 +72,20 @@ func (b *Bot) handleQuickPost(c tele.Context, replyMsg *tele.Message, payload st title = extractTitle(replyMsg) } - // 构建链接 - link := buildMessageLinkFromReply(c.Message(), replyMsg) - - // 创建条目 - entry, err := b.storage.CreateEntry(category, title, link) - if err != nil { - return c.Reply(fmt.Sprintf("❌ 保存失败: %v", err)) - } + channelLink := buildMessageLinkFromReply(c.Message(), replyMsg) + directLink := extractDirectLink(replyMsg) + + // 创建条目 + entry, err := b.storage.CreateEntry(category, title, channelLink, directLink) + if err != nil { + return c.Reply(fmt.Sprintf("❌ 保存失败: %v", err)) + } b.toc.TriggerUpdate() - return c.Reply(fmt.Sprintf("✅ 已添加\n\nID: `%s`\n分类: %s\n标题: %s\n链接: %s", - entry.ID, entry.Category, entry.Title, entry.Link), tele.ModeMarkdown) -} + 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 @@ -437,13 +440,13 @@ func (b *Bot) handleConfirmCallback(c tele.Context, userID int64, action string) return c.Respond(&tele.CallbackResponse{Text: "发送失败"}) } - // 使用频道消息的链接 - link := b.buildChannelLink(channelMsg.ID) - - entry, err := b.storage.CreateEntry(state.SelectedCat, title, link) - if err != nil { - c.Edit(fmt.Sprintf("❌ 保存失败: %v", err)) - return c.Respond(&tele.CallbackResponse{Text: "保存失败"}) + channelLink := b.buildChannelLink(channelMsg.ID) + directLink := extractDirectLinkFromState(state) + + entry, err := b.storage.CreateEntry(state.SelectedCat, title, channelLink, directLink) + if err != nil { + c.Edit(fmt.Sprintf("❌ 保存失败: %v", err)) + return c.Respond(&tele.CallbackResponse{Text: "保存失败"}) } b.toc.TriggerUpdate() @@ -499,7 +502,7 @@ func (b *Bot) buildChannelLink(msgID int) string { return fmt.Sprintf("https://t.me/c/%d/%d", chatID, msgID) } -func extractTitle(msg *tele.Message) string { +func extractTitle(msg *tele.Message) string { text := msg.Text if text == "" { text = msg.Caption @@ -516,8 +519,58 @@ func extractTitle(msg *tele.Message) string { title = "无标题" } - return title -} + return title +} + +func extractDirectLinkFromMessages(msgs []*tele.Message) string { + for _, msg := range msgs { + if msg == nil { + continue + } + if link := extractDirectLink(msg); link != "" { + return link + } + } + return "" +} + +func extractDirectLinkFromState(state *PostState) string { + if state == nil { + return "" + } + if link := extractDirectLink(state.ForwardedMsg); link != "" { + return link + } + return extractDirectLinkFromMessages(state.ForwardedMsgs) +} + +func extractDirectLink(msg *tele.Message) string { + if msg == nil { + return "" + } + + for _, entity := range msg.Entities { + if entity.Type == tele.EntityTextLink && entity.URL != "" { + return entity.URL + } + } + for _, entity := range msg.CaptionEntities { + if entity.Type == tele.EntityTextLink && entity.URL != "" { + return entity.URL + } + } + + for _, text := range []string{msg.Text, msg.Caption} { + if text == "" { + continue + } + if match := directURLPattern.FindString(text); match != "" { + return match + } + } + + return "" +} func buildMessageLink(msg *tele.Message) string { if msg.OriginalChat == nil { diff --git a/internal/toc/renderer.go b/internal/toc/renderer.go index 62a374f..de7fb0d 100644 --- a/internal/toc/renderer.go +++ b/internal/toc/renderer.go @@ -1,51 +1,55 @@ -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 { for _, entry := range entries { - sb.WriteString(fmt.Sprintf(" • %s\n", entry.Link, html.EscapeString(entry.Title))) + sb.WriteString(fmt.Sprintf(" • %s", html.EscapeString(entry.MessageLink()), html.EscapeString(entry.Title))) + if entry.HasDirectLink() { + sb.WriteString(fmt.Sprintf(" [直达]", html.EscapeString(entry.DirectLink))) + } + sb.WriteString("\n") } } - sb.WriteString("\n") - } - - sb.WriteString("━━━━━━━━━━━━━━━\n") - - return sb.String(), nil -} + sb.WriteString("\n") + } + + sb.WriteString("━━━━━━━━━━━━━━━\n") + + return sb.String(), nil +}