forked from carrydela/mygoTgChanBot
Compare commits
3 Commits
05fbeabcd0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| df1fb034b3 | |||
| d8fd372e53 | |||
| f946c68ca9 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,4 +1,7 @@
|
||||
config.yaml
|
||||
/data
|
||||
bot
|
||||
mygo_chanbot
|
||||
mygo_chanbot
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
cmd/seed/
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 <ID>")
|
||||
}
|
||||
|
||||
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> <新标题>")
|
||||
}
|
||||
|
||||
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> <新分类>")
|
||||
}
|
||||
|
||||
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("• <code>%s</code> %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 <ID>")
|
||||
}
|
||||
|
||||
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> <新标题>")
|
||||
}
|
||||
|
||||
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> <新分类>")
|
||||
}
|
||||
|
||||
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("• <code>%s</code> %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
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
package toc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -11,6 +12,14 @@ import (
|
||||
tele "gopkg.in/telebot.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
captionCharLimit = 950 // photo caption 字符上限(预留安全余量)
|
||||
textCharLimit = 3900 // 纯文本消息字符上限
|
||||
|
||||
// CbPrefixTocPage 翻页回调前缀,导出供 telegram 包使用
|
||||
CbPrefixTocPage = "tocpage:"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
storage *storage.Storage
|
||||
bot *tele.Bot
|
||||
@@ -55,18 +64,64 @@ func (m *Manager) doUpdate() {
|
||||
m.pending = false
|
||||
m.mu.Unlock()
|
||||
|
||||
content, err := m.Render()
|
||||
if err != nil {
|
||||
log.Printf("TOC render error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.updateMessage(content); err != nil {
|
||||
if err := m.DoUpdatePage(1); err != nil {
|
||||
log.Printf("TOC update error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) updateMessage(content string) error {
|
||||
// DoUpdatePage 渲染指定页并更新频道 TOC 消息
|
||||
func (m *Manager) DoUpdatePage(page int) error {
|
||||
charLimit := captionCharLimit
|
||||
if m.coverImage == "" {
|
||||
charLimit = textCharLimit
|
||||
}
|
||||
|
||||
content, totalPages, err := m.RenderPage(page, charLimit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("TOC render error: %w", err)
|
||||
}
|
||||
|
||||
var markup *tele.ReplyMarkup
|
||||
if totalPages > 1 {
|
||||
markup = m.buildPageKeyboard(page, totalPages)
|
||||
}
|
||||
|
||||
return m.updateMessage(content, markup)
|
||||
}
|
||||
|
||||
// HandlePageCallback 处理翻页回调,供 telegram 包调用
|
||||
func (m *Manager) HandlePageCallback(page int) error {
|
||||
return m.DoUpdatePage(page)
|
||||
}
|
||||
|
||||
// buildPageKeyboard 构建翻页 inline keyboard
|
||||
func (m *Manager) buildPageKeyboard(currentPage, totalPages int) *tele.ReplyMarkup {
|
||||
menu := &tele.ReplyMarkup{}
|
||||
var btns []tele.Btn
|
||||
|
||||
// 回到第一页(非首页时显示)
|
||||
if currentPage > 1 {
|
||||
btns = append(btns, menu.Data("⏮", CbPrefixTocPage+"1"))
|
||||
}
|
||||
|
||||
// 上一页(非首页时显示)
|
||||
if currentPage > 1 {
|
||||
btns = append(btns, menu.Data("⬅️", fmt.Sprintf("%s%d", CbPrefixTocPage, currentPage-1)))
|
||||
}
|
||||
|
||||
// 下一页(非末页时显示)
|
||||
if currentPage < totalPages {
|
||||
btns = append(btns, menu.Data("➡️", fmt.Sprintf("%s%d", CbPrefixTocPage, currentPage+1)))
|
||||
}
|
||||
|
||||
if len(btns) > 0 {
|
||||
menu.Inline(menu.Row(btns...))
|
||||
}
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
func (m *Manager) updateMessage(content string, markup *tele.ReplyMarkup) error {
|
||||
chat := &tele.Chat{ID: m.chanID}
|
||||
|
||||
msgID, err := m.storage.GetTocMsgID()
|
||||
@@ -76,16 +131,12 @@ func (m *Manager) updateMessage(content string) error {
|
||||
|
||||
// 带封面图片模式
|
||||
if m.coverImage != "" {
|
||||
return m.updateWithPhoto(chat, msgID, content)
|
||||
return m.updateWithPhoto(chat, msgID, content, markup)
|
||||
}
|
||||
|
||||
// 纯文本模式
|
||||
if msgID == 0 {
|
||||
msg, err := m.bot.Send(chat, content, tele.ModeHTML, tele.NoPreview)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.storage.SetTocMsgID(msg.ID)
|
||||
return m.sendNewText(chat, content, markup)
|
||||
}
|
||||
|
||||
existingMsg := &tele.Message{
|
||||
@@ -93,19 +144,19 @@ func (m *Manager) updateMessage(content string) error {
|
||||
Chat: chat,
|
||||
}
|
||||
|
||||
_, err = m.bot.Edit(existingMsg, content, tele.ModeHTML, tele.NoPreview)
|
||||
opts := []interface{}{tele.ModeHTML, tele.NoPreview}
|
||||
if markup != nil {
|
||||
opts = append(opts, markup)
|
||||
}
|
||||
|
||||
_, err = m.bot.Edit(existingMsg, content, opts...)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
// 内容未变化,忽略
|
||||
if err == tele.ErrMessageNotModified || strings.Contains(errMsg, "message is not modified") {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(errMsg, "message to edit not found") {
|
||||
msg, err := m.bot.Send(chat, content, tele.ModeHTML, tele.NoPreview)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.storage.SetTocMsgID(msg.ID)
|
||||
return m.sendNewText(chat, content, markup)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -113,14 +164,31 @@ func (m *Manager) updateMessage(content string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) updateWithPhoto(chat *tele.Chat, msgID int, content string) error {
|
||||
func (m *Manager) sendNewText(chat *tele.Chat, content string, markup *tele.ReplyMarkup) error {
|
||||
opts := []interface{}{tele.ModeHTML, tele.NoPreview}
|
||||
if markup != nil {
|
||||
opts = append(opts, markup)
|
||||
}
|
||||
|
||||
msg, err := m.bot.Send(chat, content, opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.storage.SetTocMsgID(msg.ID)
|
||||
}
|
||||
|
||||
func (m *Manager) updateWithPhoto(chat *tele.Chat, msgID int, content string, markup *tele.ReplyMarkup) error {
|
||||
photo := &tele.Photo{
|
||||
File: tele.FromDisk(m.coverImage),
|
||||
Caption: content,
|
||||
}
|
||||
|
||||
if msgID == 0 {
|
||||
msg, err := m.bot.Send(chat, photo, tele.ModeHTML)
|
||||
opts := []interface{}{tele.ModeHTML}
|
||||
if markup != nil {
|
||||
opts = append(opts, markup)
|
||||
}
|
||||
msg, err := m.bot.Send(chat, photo, opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -133,16 +201,24 @@ func (m *Manager) updateWithPhoto(chat *tele.Chat, msgID int, content string) er
|
||||
}
|
||||
|
||||
// 编辑图片消息的 caption
|
||||
_, err := m.bot.EditCaption(existingMsg, content, tele.ModeHTML)
|
||||
opts := []interface{}{tele.ModeHTML}
|
||||
if markup != nil {
|
||||
opts = append(opts, markup)
|
||||
}
|
||||
|
||||
_, err := m.bot.EditCaption(existingMsg, content, opts...)
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if err == tele.ErrMessageNotModified || strings.Contains(errMsg, "message is not modified") {
|
||||
return nil
|
||||
}
|
||||
// 旧消息不是图片或找不到,重新发送
|
||||
if strings.Contains(errMsg, "message to edit not found") ||
|
||||
strings.Contains(errMsg, "no caption") {
|
||||
msg, err := m.bot.Send(chat, photo, tele.ModeHTML)
|
||||
opts := []interface{}{tele.ModeHTML}
|
||||
if markup != nil {
|
||||
opts = append(opts, markup)
|
||||
}
|
||||
msg, err := m.bot.Send(chat, photo, opts...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,55 +1,140 @@
|
||||
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("📚 <b>频道目录</b>\n")
|
||||
sb.WriteString("━━━━━━━━━━━━━━━\n\n")
|
||||
|
||||
if len(categories) == 0 {
|
||||
sb.WriteString("<i>暂无分类</i>")
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
for _, cat := range categories {
|
||||
entries := entriesByCategory[cat.Name]
|
||||
|
||||
sb.WriteString(fmt.Sprintf("📁 <b>%s</b>", html.EscapeString(cat.Name)))
|
||||
if len(entries) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (%d)", len(entries)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
package toc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var htmlTagRegexp = regexp.MustCompile(`<[^>]*>`)
|
||||
|
||||
// htmlTextLength 计算剥离 HTML 标签后的字符数,匹配 Telegram 的计数方式
|
||||
func htmlTextLength(s string) int {
|
||||
stripped := htmlTagRegexp.ReplaceAllString(s, "")
|
||||
return utf8.RuneCountInString(stripped)
|
||||
}
|
||||
|
||||
// renderLines 将所有分类和条目渲染为独立的行
|
||||
func (m *Manager) renderLines() (header string, lines []string, footer string, err error) {
|
||||
categories, err := m.storage.ListCategories()
|
||||
if err != nil {
|
||||
return "", nil, "", err
|
||||
}
|
||||
|
||||
entriesByCategory, err := m.storage.GetEntriesByCategory()
|
||||
if err != nil {
|
||||
return "", nil, "", err
|
||||
}
|
||||
|
||||
header = "📚 <b>频道目录</b>\n━━━━━━━━━━━━━━━\n\n"
|
||||
footer = "━━━━━━━━━━━━━━━\n"
|
||||
|
||||
if len(categories) == 0 {
|
||||
lines = append(lines, "<i>暂无分类</i>\n")
|
||||
return header, lines, footer, nil
|
||||
}
|
||||
|
||||
for _, cat := range categories {
|
||||
entries := entriesByCategory[cat.Name]
|
||||
|
||||
catLine := fmt.Sprintf("📁 <b>%s</b>", html.EscapeString(cat.Name))
|
||||
if len(entries) > 0 {
|
||||
catLine += fmt.Sprintf(" (%d)", len(entries))
|
||||
}
|
||||
catLine += "\n"
|
||||
lines = append(lines, catLine)
|
||||
|
||||
if len(entries) == 0 {
|
||||
sb.WriteString(" <i>暂无内容</i>\n")
|
||||
lines = append(lines, " <i>暂无内容</i>\n")
|
||||
} else {
|
||||
for _, entry := range entries {
|
||||
sb.WriteString(fmt.Sprintf(" • <a href=\"%s\">%s</a>", html.EscapeString(entry.MessageLink()), html.EscapeString(entry.Title)))
|
||||
entryLine := 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)))
|
||||
entryLine += fmt.Sprintf(" <a href=\"%s\">[直达]</a>", html.EscapeString(entry.DirectLink))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
entryLine += "\n"
|
||||
lines = append(lines, entryLine)
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("━━━━━━━━━━━━━━━\n")
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
// 分类之间空行
|
||||
lines = append(lines, "\n")
|
||||
}
|
||||
|
||||
return header, lines, footer, nil
|
||||
}
|
||||
|
||||
// RenderPage 渲染指定页的 TOC 内容,返回内容、总页数
|
||||
func (m *Manager) RenderPage(page, charLimit int) (string, int, error) {
|
||||
header, lines, footer, err := m.renderLines()
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
// 页码行的最大长度预估(如 "📄 99 / 99\n")
|
||||
pageIndicatorLen := 15
|
||||
|
||||
headerLen := htmlTextLength(header)
|
||||
footerLen := htmlTextLength(footer)
|
||||
fixedLen := headerLen + footerLen + pageIndicatorLen
|
||||
|
||||
// 将行分配到各页
|
||||
var pages [][]string
|
||||
var currentPage []string
|
||||
currentLen := fixedLen
|
||||
|
||||
for _, line := range lines {
|
||||
lineLen := htmlTextLength(line)
|
||||
|
||||
// 如果当前页加上这行会超限,且当前页已有内容,则开新页
|
||||
if currentLen+lineLen > charLimit && len(currentPage) > 0 {
|
||||
pages = append(pages, currentPage)
|
||||
currentPage = nil
|
||||
currentLen = fixedLen
|
||||
}
|
||||
|
||||
currentPage = append(currentPage, line)
|
||||
currentLen += lineLen
|
||||
}
|
||||
|
||||
// 最后一页
|
||||
if len(currentPage) > 0 {
|
||||
pages = append(pages, currentPage)
|
||||
}
|
||||
|
||||
totalPages := len(pages)
|
||||
if totalPages == 0 {
|
||||
totalPages = 1
|
||||
pages = [][]string{{}}
|
||||
}
|
||||
|
||||
// clamp page
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
|
||||
// 组装当前页内容
|
||||
var sb strings.Builder
|
||||
sb.WriteString(header)
|
||||
for _, line := range pages[page-1] {
|
||||
sb.WriteString(line)
|
||||
}
|
||||
sb.WriteString(footer)
|
||||
|
||||
if totalPages > 1 {
|
||||
sb.WriteString(fmt.Sprintf("📄 %d / %d\n", page, totalPages))
|
||||
}
|
||||
|
||||
return sb.String(), totalPages, nil
|
||||
}
|
||||
|
||||
// Render 渲染完整 TOC(兼容方法,返回第 1 页,使用 photo caption 限制)
|
||||
func (m *Manager) Render() (string, error) {
|
||||
content, _, err := m.RenderPage(1, captionCharLimit)
|
||||
return content, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user