目录保留消息上下文并增加直达链接

This commit is contained in:
2026-03-17 13:58:16 +08:00
parent 6686a43a35
commit 05fbeabcd0
5 changed files with 601 additions and 527 deletions

View File

@@ -1,147 +1,149 @@
package storage package storage
import ( import (
"fmt" "fmt"
"sort" "sort"
"time" "time"
"github.com/teris-io/shortid" "github.com/teris-io/shortid"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
) )
func (s *Storage) CreateEntry(category, title, link string) (*Entry, error) { func (s *Storage) CreateEntry(category, title, channelLink, directLink string) (*Entry, error) {
entry := &Entry{ entry := &Entry{
ID: shortid.MustGenerate(), ID: shortid.MustGenerate(),
Category: category, Category: category,
Title: title, Title: title,
Link: link, Link: channelLink,
Timestamp: time.Now(), ChannelLink: channelLink,
DirectLink: directLink,
Timestamp: time.Now(),
} }
err := s.db.Update(func(tx *bolt.Tx) error { err := s.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketEntries) b := tx.Bucket(bucketEntries)
data, err := encodeJSON(entry) data, err := encodeJSON(entry)
if err != nil { if err != nil {
return err return err
} }
return b.Put([]byte(entry.ID), data) return b.Put([]byte(entry.ID), data)
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
return entry, nil return entry, nil
} }
func (s *Storage) GetEntry(id string) (*Entry, error) { func (s *Storage) GetEntry(id string) (*Entry, error) {
var entry *Entry var entry *Entry
err := s.db.View(func(tx *bolt.Tx) error { err := s.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketEntries) b := tx.Bucket(bucketEntries)
data := b.Get([]byte(id)) data := b.Get([]byte(id))
if data == nil { if data == nil {
return fmt.Errorf("entry %q not found", id) return fmt.Errorf("entry %q not found", id)
} }
entry = &Entry{} entry = &Entry{}
return decodeJSON(data, entry) return decodeJSON(data, entry)
}) })
return entry, err return entry, err
} }
func (s *Storage) DeleteEntry(id string) error { func (s *Storage) DeleteEntry(id string) error {
return s.db.Update(func(tx *bolt.Tx) error { return s.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketEntries) b := tx.Bucket(bucketEntries)
if b.Get([]byte(id)) == nil { if b.Get([]byte(id)) == nil {
return fmt.Errorf("entry %q not found", id) return fmt.Errorf("entry %q not found", id)
} }
return b.Delete([]byte(id)) return b.Delete([]byte(id))
}) })
} }
func (s *Storage) UpdateEntryTitle(id, title string) error { func (s *Storage) UpdateEntryTitle(id, title string) error {
return s.db.Update(func(tx *bolt.Tx) error { return s.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketEntries) b := tx.Bucket(bucketEntries)
data := b.Get([]byte(id)) data := b.Get([]byte(id))
if data == nil { if data == nil {
return fmt.Errorf("entry %q not found", id) return fmt.Errorf("entry %q not found", id)
} }
var entry Entry var entry Entry
if err := decodeJSON(data, &entry); err != nil { if err := decodeJSON(data, &entry); err != nil {
return err return err
} }
entry.Title = title entry.Title = title
newData, err := encodeJSON(entry) newData, err := encodeJSON(entry)
if err != nil { if err != nil {
return err return err
} }
return b.Put([]byte(id), newData) return b.Put([]byte(id), newData)
}) })
} }
func (s *Storage) UpdateEntryCategory(id, category string) error { func (s *Storage) UpdateEntryCategory(id, category string) error {
return s.db.Update(func(tx *bolt.Tx) error { return s.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketEntries) b := tx.Bucket(bucketEntries)
data := b.Get([]byte(id)) data := b.Get([]byte(id))
if data == nil { if data == nil {
return fmt.Errorf("entry %q not found", id) return fmt.Errorf("entry %q not found", id)
} }
var entry Entry var entry Entry
if err := decodeJSON(data, &entry); err != nil { if err := decodeJSON(data, &entry); err != nil {
return err return err
} }
entry.Category = category entry.Category = category
newData, err := encodeJSON(entry) newData, err := encodeJSON(entry)
if err != nil { if err != nil {
return err return err
} }
return b.Put([]byte(id), newData) return b.Put([]byte(id), newData)
}) })
} }
func (s *Storage) ListEntries(category string) ([]Entry, error) { func (s *Storage) ListEntries(category string) ([]Entry, error) {
var entries []Entry var entries []Entry
err := s.db.View(func(tx *bolt.Tx) error { err := s.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketEntries) b := tx.Bucket(bucketEntries)
return b.ForEach(func(k, v []byte) error { return b.ForEach(func(k, v []byte) error {
var entry Entry var entry Entry
if err := decodeJSON(v, &entry); err != nil { if err := decodeJSON(v, &entry); err != nil {
return err return err
} }
if category == "" || entry.Category == category { if category == "" || entry.Category == category {
entries = append(entries, entry) entries = append(entries, entry)
} }
return nil return nil
}) })
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
sort.Slice(entries, func(i, j int) bool { sort.Slice(entries, func(i, j int) bool {
return entries[i].Timestamp.Before(entries[j].Timestamp) return entries[i].Timestamp.Before(entries[j].Timestamp)
}) })
return entries, nil return entries, nil
} }
func (s *Storage) ListAllEntries() ([]Entry, error) { func (s *Storage) ListAllEntries() ([]Entry, error) {
return s.ListEntries("") return s.ListEntries("")
} }
func (s *Storage) GetEntriesByCategory() (map[string][]Entry, error) { func (s *Storage) GetEntriesByCategory() (map[string][]Entry, error) {
entries, err := s.ListAllEntries() entries, err := s.ListAllEntries()
if err != nil { if err != nil {
return nil, err return nil, err
} }
result := make(map[string][]Entry) result := make(map[string][]Entry)
for _, e := range entries { for _, e := range entries {
result[e.Category] = append(result[e.Category], e) result[e.Category] = append(result[e.Category], e)
} }
return result, nil return result, nil
} }

View File

@@ -1,152 +1,167 @@
package storage package storage
import ( import (
"encoding/binary" "encoding/binary"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
) )
var ( var (
bucketAppConfig = []byte("AppConfig") bucketAppConfig = []byte("AppConfig")
bucketCategories = []byte("Categories") bucketCategories = []byte("Categories")
bucketEntries = []byte("Entries") bucketEntries = []byte("Entries")
bucketAdmins = []byte("Admins") bucketAdmins = []byte("Admins")
keyTocMsgID = []byte("toc_msg_id") keyTocMsgID = []byte("toc_msg_id")
) )
type Category struct { type Category struct {
Name string `json:"name"` Name string `json:"name"`
Order int `json:"order"` Order int `json:"order"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
type Entry struct { type Entry struct {
ID string `json:"id"` ID string `json:"id"`
Category string `json:"category"` Category string `json:"category"`
Title string `json:"title"` Title string `json:"title"`
Link string `json:"link"` Link string `json:"link,omitempty"` // 兼容旧数据
Timestamp time.Time `json:"timestamp"` ChannelLink string `json:"channel_link,omitempty"`
DirectLink string `json:"direct_link,omitempty"`
Timestamp time.Time `json:"timestamp"`
} }
type Storage struct { func (e Entry) MessageLink() string {
db *bolt.DB if e.ChannelLink != "" {
} return e.ChannelLink
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)
} }
return e.Link
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 { func (e Entry) HasDirectLink() bool {
return s.db.Update(func(tx *bolt.Tx) error { direct := strings.TrimSpace(e.DirectLink)
buckets := [][]byte{bucketAppConfig, bucketCategories, bucketEntries, bucketAdmins} return direct != "" && direct != e.MessageLink()
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
}

View File

@@ -1,172 +1,172 @@
package telegram package telegram
import ( import (
"fmt" "fmt"
"html" "html"
"strconv" "strconv"
"strings" "strings"
tele "gopkg.in/telebot.v3" tele "gopkg.in/telebot.v3"
) )
const ( const (
cbPrefixDelEntry = "del:" cbPrefixDelEntry = "del:"
cbPrefixDelWithMsg = "delwithmsg:" cbPrefixDelWithMsg = "delwithmsg:"
cbPrefixDelOnlyToc = "delonlytoc:" cbPrefixDelOnlyToc = "delonlytoc:"
) )
func (b *Bot) handleEntryDel(c tele.Context) error { func (b *Bot) handleEntryDel(c tele.Context) error {
id := strings.TrimSpace(c.Message().Payload) id := strings.TrimSpace(c.Message().Payload)
if id == "" { if id == "" {
return c.Reply("用法: /del <ID>") return c.Reply("用法: /del <ID>")
} }
entry, err := b.storage.GetEntry(id) entry, err := b.storage.GetEntry(id)
if err != nil { if err != nil {
return c.Reply(fmt.Sprintf("❌ %v", err)) return c.Reply(fmt.Sprintf("❌ %v", err))
} }
menu := &tele.ReplyMarkup{} menu := &tele.ReplyMarkup{}
delWithMsgBtn := menu.Data("🗑 删除条目+频道消息", cbPrefixDelWithMsg+id) delWithMsgBtn := menu.Data("🗑 删除条目+频道消息", cbPrefixDelWithMsg+id)
delOnlyTocBtn := menu.Data("📝 仅删除目录条目", cbPrefixDelOnlyToc+id) delOnlyTocBtn := menu.Data("📝 仅删除目录条目", cbPrefixDelOnlyToc+id)
cancelBtn := menu.Data("❌ 取消", cbCancel) cancelBtn := menu.Data("❌ 取消", cbCancel)
menu.Inline( menu.Inline(
menu.Row(delWithMsgBtn), menu.Row(delWithMsgBtn),
menu.Row(delOnlyTocBtn), menu.Row(delOnlyTocBtn),
menu.Row(cancelBtn), menu.Row(cancelBtn),
) )
text := fmt.Sprintf("🗑 确认删除?\n\n分类: %s\n标题: %s\n\n请选择删除方式:", entry.Category, entry.Title) text := fmt.Sprintf("🗑 确认删除?\n\n分类: %s\n标题: %s\n\n请选择删除方式:", entry.Category, entry.Title)
return c.Reply(text, menu) return c.Reply(text, menu)
} }
func (b *Bot) handleEntryEdit(c tele.Context) error { func (b *Bot) handleEntryEdit(c tele.Context) error {
args := strings.SplitN(c.Message().Payload, " ", 2) args := strings.SplitN(c.Message().Payload, " ", 2)
if len(args) < 2 { if len(args) < 2 {
return c.Reply("用法: /edit <ID> <新标题>") return c.Reply("用法: /edit <ID> <新标题>")
} }
id := args[0] id := args[0]
newTitle := strings.TrimSpace(args[1]) newTitle := strings.TrimSpace(args[1])
if err := b.storage.UpdateEntryTitle(id, newTitle); err != nil { if err := b.storage.UpdateEntryTitle(id, newTitle); err != nil {
return c.Reply(fmt.Sprintf("❌ %v", err)) return c.Reply(fmt.Sprintf("❌ %v", err))
} }
b.toc.TriggerUpdate() b.toc.TriggerUpdate()
return c.Reply(fmt.Sprintf("✅ 标题已更新为: %s", newTitle)) return c.Reply(fmt.Sprintf("✅ 标题已更新为: %s", newTitle))
} }
func (b *Bot) handleEntryMove(c tele.Context) error { func (b *Bot) handleEntryMove(c tele.Context) error {
args := strings.Fields(c.Message().Payload) args := strings.Fields(c.Message().Payload)
if len(args) < 2 { if len(args) < 2 {
return c.Reply("用法: /move <ID> <新分类>") return c.Reply("用法: /move <ID> <新分类>")
} }
id := args[0] id := args[0]
newCategory := args[1] newCategory := args[1]
if !b.storage.CategoryExists(newCategory) { if !b.storage.CategoryExists(newCategory) {
return c.Reply(fmt.Sprintf("❌ 分类 [%s] 不存在", newCategory)) return c.Reply(fmt.Sprintf("❌ 分类 [%s] 不存在", newCategory))
} }
if err := b.storage.UpdateEntryCategory(id, newCategory); err != nil { if err := b.storage.UpdateEntryCategory(id, newCategory); err != nil {
return c.Reply(fmt.Sprintf("❌ %v", err)) return c.Reply(fmt.Sprintf("❌ %v", err))
} }
b.toc.TriggerUpdate() b.toc.TriggerUpdate()
return c.Reply(fmt.Sprintf("✅ 已移动到分类: %s", newCategory)) return c.Reply(fmt.Sprintf("✅ 已移动到分类: %s", newCategory))
} }
func (b *Bot) handleEntryList(c tele.Context) error { func (b *Bot) handleEntryList(c tele.Context) error {
category := strings.TrimSpace(c.Message().Payload) category := strings.TrimSpace(c.Message().Payload)
entries, err := b.storage.ListEntries(category) entries, err := b.storage.ListEntries(category)
if err != nil { if err != nil {
return c.Reply(fmt.Sprintf("❌ %v", err)) return c.Reply(fmt.Sprintf("❌ %v", err))
} }
if len(entries) == 0 { if len(entries) == 0 {
if category != "" { if category != "" {
return c.Reply(fmt.Sprintf("📭 分类 [%s] 暂无条目", category)) return c.Reply(fmt.Sprintf("📭 分类 [%s] 暂无条目", category))
} }
return c.Reply("📭 暂无条目") return c.Reply("📭 暂无条目")
} }
var sb strings.Builder var sb strings.Builder
if category != "" { if category != "" {
sb.WriteString(fmt.Sprintf("📋 分类 [%s] 的条目:\n\n", html.EscapeString(category))) sb.WriteString(fmt.Sprintf("📋 分类 [%s] 的条目:\n\n", html.EscapeString(category)))
} else { } else {
sb.WriteString("📋 所有条目:\n\n") sb.WriteString("📋 所有条目:\n\n")
} }
currentCat := "" currentCat := ""
for _, e := range entries { for _, e := range entries {
if category == "" && e.Category != currentCat { if category == "" && e.Category != currentCat {
currentCat = e.Category currentCat = e.Category
sb.WriteString(fmt.Sprintf("\n【%s】\n", html.EscapeString(currentCat))) sb.WriteString(fmt.Sprintf("\n【%s】\n", html.EscapeString(currentCat)))
} }
sb.WriteString(fmt.Sprintf("• <code>%s</code> %s\n", e.ID, html.EscapeString(e.Title))) sb.WriteString(fmt.Sprintf("• <code>%s</code> %s\n", e.ID, html.EscapeString(e.Title)))
} }
return c.Reply(sb.String(), tele.ModeHTML) return c.Reply(sb.String(), tele.ModeHTML)
} }
func (b *Bot) handleRefresh(c tele.Context) error { func (b *Bot) handleRefresh(c tele.Context) error {
b.toc.TriggerUpdate() b.toc.TriggerUpdate()
return c.Reply("🔄 目录刷新已触发") return c.Reply("🔄 目录刷新已触发")
} }
// handleDeleteEntryCallback 处理删除条目的回调 // handleDeleteEntryCallback 处理删除条目的回调
func (b *Bot) handleDeleteEntryCallback(c tele.Context, entryID string, deleteChannelMsg bool) error { func (b *Bot) handleDeleteEntryCallback(c tele.Context, entryID string, deleteChannelMsg bool) error {
entry, err := b.storage.GetEntry(entryID) entry, err := b.storage.GetEntry(entryID)
if err != nil { if err != nil {
c.Edit(fmt.Sprintf("❌ %v", err)) c.Edit(fmt.Sprintf("❌ %v", err))
return c.Respond(&tele.CallbackResponse{Text: "条目不存在"}) return c.Respond(&tele.CallbackResponse{Text: "条目不存在"})
} }
// 根据用户选择决定是否删除频道消息 // 根据用户选择决定是否删除频道消息
msgDeleted := false msgDeleted := false
if deleteChannelMsg { if deleteChannelMsg {
if msgID := parseMessageIDFromLink(entry.Link); msgID != 0 { if msgID := parseMessageIDFromLink(entry.MessageLink()); msgID != 0 {
channel := &tele.Chat{ID: b.cfg.Channel.ID} channel := &tele.Chat{ID: b.cfg.Channel.ID}
msg := &tele.Message{ID: msgID, Chat: channel} msg := &tele.Message{ID: msgID, Chat: channel}
if err := b.bot.Delete(msg); err == nil { if err := b.bot.Delete(msg); err == nil {
msgDeleted = true msgDeleted = true
} }
} }
} }
if err := b.storage.DeleteEntry(entryID); err != nil { if err := b.storage.DeleteEntry(entryID); err != nil {
c.Edit(fmt.Sprintf("❌ 删除失败: %v", err)) c.Edit(fmt.Sprintf("❌ 删除失败: %v", err))
return c.Respond(&tele.CallbackResponse{Text: "删除失败"}) return c.Respond(&tele.CallbackResponse{Text: "删除失败"})
} }
b.toc.TriggerUpdate() b.toc.TriggerUpdate()
var text string var text string
if deleteChannelMsg && msgDeleted { if deleteChannelMsg && msgDeleted {
text = fmt.Sprintf("✅ 已删除: [%s] %s\n📨 频道消息已同步删除", entry.Category, entry.Title) text = fmt.Sprintf("✅ 已删除: [%s] %s\n📨 频道消息已同步删除", entry.Category, entry.Title)
} else if deleteChannelMsg && !msgDeleted { } else if deleteChannelMsg && !msgDeleted {
text = fmt.Sprintf("✅ 已删除: [%s] %s\n⚠ 频道消息删除失败(可能已被删除)", entry.Category, entry.Title) text = fmt.Sprintf("✅ 已删除: [%s] %s\n⚠ 频道消息删除失败(可能已被删除)", entry.Category, entry.Title)
} else { } else {
text = fmt.Sprintf("✅ 已删除: [%s] %s\n📝 频道消息已保留", entry.Category, entry.Title) text = fmt.Sprintf("✅ 已删除: [%s] %s\n📝 频道消息已保留", entry.Category, entry.Title)
} }
c.Edit(text) c.Edit(text)
return c.Respond(&tele.CallbackResponse{Text: "删除成功"}) return c.Respond(&tele.CallbackResponse{Text: "删除成功"})
} }
// parseMessageIDFromLink 从链接中解析消息ID // parseMessageIDFromLink 从链接中解析消息ID
// 支持格式: https://t.me/c/123456/789 或 https://t.me/username/789 // 支持格式: https://t.me/c/123456/789 或 https://t.me/username/789
func parseMessageIDFromLink(link string) int { func parseMessageIDFromLink(link string) int {
parts := strings.Split(link, "/") parts := strings.Split(link, "/")
if len(parts) < 1 { if len(parts) < 1 {
return 0 return 0
} }
msgID, err := strconv.Atoi(parts[len(parts)-1]) msgID, err := strconv.Atoi(parts[len(parts)-1])
if err != nil { if err != nil {
return 0 return 0
} }
return msgID return msgID
} }

View File

@@ -1,10 +1,11 @@
package telegram package telegram
import ( import (
"fmt" "fmt"
"strings" "regexp"
"sync" "strings"
"time" "sync"
"time"
"tgchanbot/internal/storage" "tgchanbot/internal/storage"
@@ -21,10 +22,12 @@ const (
) )
// 相册收集定时器 // 相册收集定时器
var ( var (
albumTimers = make(map[int64]*time.Timer) albumTimers = make(map[int64]*time.Timer)
albumTimersMu sync.Mutex albumTimersMu sync.Mutex
) )
var directURLPattern = regexp.MustCompile(`https?://[^\s<>"']+`)
func (b *Bot) handlePost(c tele.Context) error { func (b *Bot) handlePost(c tele.Context) error {
msg := c.Message() msg := c.Message()
@@ -69,20 +72,20 @@ func (b *Bot) handleQuickPost(c tele.Context, replyMsg *tele.Message, payload st
title = extractTitle(replyMsg) title = extractTitle(replyMsg)
} }
// 构建链接 channelLink := buildMessageLinkFromReply(c.Message(), replyMsg)
link := buildMessageLinkFromReply(c.Message(), replyMsg) directLink := extractDirectLink(replyMsg)
// 创建条目 // 创建条目
entry, err := b.storage.CreateEntry(category, title, link) entry, err := b.storage.CreateEntry(category, title, channelLink, directLink)
if err != nil { if err != nil {
return c.Reply(fmt.Sprintf("❌ 保存失败: %v", err)) 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", return c.Reply(fmt.Sprintf("✅ 已添加\n\nID: `%s`\n分类: %s\n标题: %s\n链接: %s",
entry.ID, entry.Category, entry.Title, entry.Link), tele.ModeMarkdown) entry.ID, entry.Category, entry.Title, entry.MessageLink()), tele.ModeMarkdown)
} }
func buildMessageLinkFromReply(currentMsg, replyMsg *tele.Message) string { func buildMessageLinkFromReply(currentMsg, replyMsg *tele.Message) string {
chat := currentMsg.Chat chat := currentMsg.Chat
@@ -437,13 +440,13 @@ func (b *Bot) handleConfirmCallback(c tele.Context, userID int64, action string)
return c.Respond(&tele.CallbackResponse{Text: "发送失败"}) return c.Respond(&tele.CallbackResponse{Text: "发送失败"})
} }
// 使用频道消息的链接 channelLink := b.buildChannelLink(channelMsg.ID)
link := b.buildChannelLink(channelMsg.ID) directLink := extractDirectLinkFromState(state)
entry, err := b.storage.CreateEntry(state.SelectedCat, title, link) entry, err := b.storage.CreateEntry(state.SelectedCat, title, channelLink, directLink)
if err != nil { if err != nil {
c.Edit(fmt.Sprintf("❌ 保存失败: %v", err)) c.Edit(fmt.Sprintf("❌ 保存失败: %v", err))
return c.Respond(&tele.CallbackResponse{Text: "保存失败"}) return c.Respond(&tele.CallbackResponse{Text: "保存失败"})
} }
b.toc.TriggerUpdate() 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) 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 text := msg.Text
if text == "" { if text == "" {
text = msg.Caption text = msg.Caption
@@ -516,8 +519,58 @@ func extractTitle(msg *tele.Message) string {
title = "无标题" 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 { func buildMessageLink(msg *tele.Message) string {
if msg.OriginalChat == nil { if msg.OriginalChat == nil {

View File

@@ -1,51 +1,55 @@
package toc package toc
import ( import (
"fmt" "fmt"
"html" "html"
"strings" "strings"
) )
func (m *Manager) Render() (string, error) { func (m *Manager) Render() (string, error) {
categories, err := m.storage.ListCategories() categories, err := m.storage.ListCategories()
if err != nil { if err != nil {
return "", err return "", err
} }
entriesByCategory, err := m.storage.GetEntriesByCategory() entriesByCategory, err := m.storage.GetEntriesByCategory()
if err != nil { if err != nil {
return "", err return "", err
} }
var sb strings.Builder var sb strings.Builder
sb.WriteString("📚 <b>频道目录</b>\n") sb.WriteString("📚 <b>频道目录</b>\n")
sb.WriteString("━━━━━━━━━━━━━━━\n\n") sb.WriteString("━━━━━━━━━━━━━━━\n\n")
if len(categories) == 0 { if len(categories) == 0 {
sb.WriteString("<i>暂无分类</i>") sb.WriteString("<i>暂无分类</i>")
return sb.String(), nil return sb.String(), nil
} }
for _, cat := range categories { for _, cat := range categories {
entries := entriesByCategory[cat.Name] entries := entriesByCategory[cat.Name]
sb.WriteString(fmt.Sprintf("📁 <b>%s</b>", html.EscapeString(cat.Name))) sb.WriteString(fmt.Sprintf("📁 <b>%s</b>", html.EscapeString(cat.Name)))
if len(entries) > 0 { if len(entries) > 0 {
sb.WriteString(fmt.Sprintf(" (%d)", len(entries))) sb.WriteString(fmt.Sprintf(" (%d)", len(entries)))
} }
sb.WriteString("\n") sb.WriteString("\n")
if len(entries) == 0 { if len(entries) == 0 {
sb.WriteString(" <i>暂无内容</i>\n") sb.WriteString(" <i>暂无内容</i>\n")
} else { } else {
for _, entry := range entries { for _, entry := range entries {
sb.WriteString(fmt.Sprintf(" • <a href=\"%s\">%s</a>\n", entry.Link, html.EscapeString(entry.Title))) sb.WriteString(fmt.Sprintf(" • <a href=\"%s\">%s</a>", html.EscapeString(entry.MessageLink()), html.EscapeString(entry.Title)))
if entry.HasDirectLink() {
sb.WriteString(fmt.Sprintf(" <a href=\"%s\">[直达]</a>", html.EscapeString(entry.DirectLink)))
}
sb.WriteString("\n")
} }
} }
sb.WriteString("\n") sb.WriteString("\n")
} }
sb.WriteString("━━━━━━━━━━━━━━━\n") sb.WriteString("━━━━━━━━━━━━━━━\n")
return sb.String(), nil return sb.String(), nil
} }