Compare commits

...

4 Commits

Author SHA1 Message Date
480a864436 Merge pull request 'TOC分页:解决内容超长导致MEDIA_CAPTION_TOO_LONG错误' (#3) from zuimao_kaf/mygoTgChanBot:main into main
Reviewed-on: https://github.carrydelahaye.work/carrydela/mygoTgChanBot/pulls/3
Reviewed-by: carrydela
2026-04-03 22:56:26 +08:00
df1fb034b3 TOC分页:解决内容超长导致MEDIA_CAPTION_TOO_LONG错误
当目录条目增多超过Telegram caption字符限制时,自动按字符上限分页渲染,
附带翻页inline keyboard(⏮首页/⬅️上一页/➡️下一页),支持photo和text两种模式。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 22:15:54 +08:00
d8fd372e53 Merge pull request 'main' (#2) from zuimao_kaf/mygoTgChanBot:main into main
Reviewed-on: https://github.carrydelahaye.work/carrydela/mygoTgChanBot/pulls/2
2026-03-18 10:33:16 +08:00
f946c68ca9 清理目录链接功能提交中的 CRLF 2026-03-18 10:23:13 +08:00
7 changed files with 1216 additions and 1029 deletions

5
.gitignore vendored
View File

@@ -1,4 +1,7 @@
config.yaml config.yaml
/data /data
bot bot
mygo_chanbot mygo_chanbot
CLAUDE.md
AGENTS.md
cmd/seed/

View File

@@ -1,14 +1,14 @@
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, channelLink, directLink string) (*Entry, error) { func (s *Storage) CreateEntry(category, title, channelLink, directLink string) (*Entry, error) {
entry := &Entry{ entry := &Entry{
ID: shortid.MustGenerate(), ID: shortid.MustGenerate(),
@@ -19,131 +19,131 @@ func (s *Storage) CreateEntry(category, title, channelLink, directLink string) (
DirectLink: directLink, DirectLink: directLink,
Timestamp: time.Now(), 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,5 +1,5 @@
package storage package storage
import ( import (
"encoding/binary" "encoding/binary"
"encoding/json" "encoding/json"
@@ -8,25 +8,25 @@ import (
"path/filepath" "path/filepath"
"strings" "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"`
@@ -48,120 +48,120 @@ func (e Entry) HasDirectLink() bool {
direct := strings.TrimSpace(e.DirectLink) direct := strings.TrimSpace(e.DirectLink)
return direct != "" && direct != e.MessageLink() return direct != "" && direct != e.MessageLink()
} }
type Storage struct { type Storage struct {
db *bolt.DB db *bolt.DB
} }
func New(path string) (*Storage, error) { func New(path string) (*Storage, error) {
dir := filepath.Dir(path) dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create data directory: %w", err) return nil, fmt.Errorf("failed to create data directory: %w", err)
} }
db, err := bolt.Open(path, 0600, &bolt.Options{Timeout: 1 * time.Second}) db, err := bolt.Open(path, 0600, &bolt.Options{Timeout: 1 * time.Second})
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err) return nil, fmt.Errorf("failed to open database: %w", err)
} }
s := &Storage{db: db} s := &Storage{db: db}
if err := s.initBuckets(); err != nil { if err := s.initBuckets(); err != nil {
db.Close() db.Close()
return nil, err return nil, err
} }
return s, nil return s, nil
} }
func (s *Storage) initBuckets() error { func (s *Storage) initBuckets() error {
return s.db.Update(func(tx *bolt.Tx) error { return s.db.Update(func(tx *bolt.Tx) error {
buckets := [][]byte{bucketAppConfig, bucketCategories, bucketEntries, bucketAdmins} buckets := [][]byte{bucketAppConfig, bucketCategories, bucketEntries, bucketAdmins}
for _, b := range buckets { for _, b := range buckets {
if _, err := tx.CreateBucketIfNotExists(b); err != nil { if _, err := tx.CreateBucketIfNotExists(b); err != nil {
return fmt.Errorf("failed to create bucket %s: %w", b, err) return fmt.Errorf("failed to create bucket %s: %w", b, err)
} }
} }
return nil return nil
}) })
} }
func (s *Storage) Close() error { func (s *Storage) Close() error {
return s.db.Close() return s.db.Close()
} }
func (s *Storage) GetTocMsgID() (int, error) { func (s *Storage) GetTocMsgID() (int, error) {
var msgID int var msgID int
err := s.db.View(func(tx *bolt.Tx) error { err := s.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketAppConfig) b := tx.Bucket(bucketAppConfig)
v := b.Get(keyTocMsgID) v := b.Get(keyTocMsgID)
if v != nil && len(v) >= 4 { if v != nil && len(v) >= 4 {
msgID = int(binary.BigEndian.Uint32(v)) msgID = int(binary.BigEndian.Uint32(v))
} }
return nil return nil
}) })
return msgID, err return msgID, err
} }
func (s *Storage) SetTocMsgID(msgID int) error { func (s *Storage) SetTocMsgID(msgID int) error {
return s.db.Update(func(tx *bolt.Tx) error { return s.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketAppConfig) b := tx.Bucket(bucketAppConfig)
buf := make([]byte, 4) buf := make([]byte, 4)
binary.BigEndian.PutUint32(buf, uint32(msgID)) binary.BigEndian.PutUint32(buf, uint32(msgID))
return b.Put(keyTocMsgID, buf) return b.Put(keyTocMsgID, buf)
}) })
} }
func encodeJSON(v any) ([]byte, error) { func encodeJSON(v any) ([]byte, error) {
return json.Marshal(v) return json.Marshal(v)
} }
func decodeJSON(data []byte, v any) error { func decodeJSON(data []byte, v any) error {
return json.Unmarshal(data, v) return json.Unmarshal(data, v)
} }
// Admin management // Admin management
func (s *Storage) AddAdmin(userID int64) error { func (s *Storage) AddAdmin(userID int64) error {
return s.db.Update(func(tx *bolt.Tx) error { return s.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketAdmins) b := tx.Bucket(bucketAdmins)
key := make([]byte, 8) key := make([]byte, 8)
binary.BigEndian.PutUint64(key, uint64(userID)) binary.BigEndian.PutUint64(key, uint64(userID))
return b.Put(key, []byte("1")) return b.Put(key, []byte("1"))
}) })
} }
func (s *Storage) RemoveAdmin(userID int64) error { func (s *Storage) RemoveAdmin(userID int64) error {
return s.db.Update(func(tx *bolt.Tx) error { return s.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketAdmins) b := tx.Bucket(bucketAdmins)
key := make([]byte, 8) key := make([]byte, 8)
binary.BigEndian.PutUint64(key, uint64(userID)) binary.BigEndian.PutUint64(key, uint64(userID))
return b.Delete(key) return b.Delete(key)
}) })
} }
func (s *Storage) IsAdmin(userID int64) bool { func (s *Storage) IsAdmin(userID int64) bool {
var exists bool var exists bool
s.db.View(func(tx *bolt.Tx) error { s.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketAdmins) b := tx.Bucket(bucketAdmins)
key := make([]byte, 8) key := make([]byte, 8)
binary.BigEndian.PutUint64(key, uint64(userID)) binary.BigEndian.PutUint64(key, uint64(userID))
exists = b.Get(key) != nil exists = b.Get(key) != nil
return nil return nil
}) })
return exists return exists
} }
func (s *Storage) ListAdmins() ([]int64, error) { func (s *Storage) ListAdmins() ([]int64, error) {
var admins []int64 var admins []int64
err := s.db.View(func(tx *bolt.Tx) error { err := s.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketAdmins) b := tx.Bucket(bucketAdmins)
return b.ForEach(func(k, v []byte) error { return b.ForEach(func(k, v []byte) error {
if len(k) == 8 { if len(k) == 8 {
userID := int64(binary.BigEndian.Uint64(k)) userID := int64(binary.BigEndian.Uint64(k))
admins = append(admins, userID) admins = append(admins, userID)
} }
return nil return nil
}) })
}) })
return admins, err 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.MessageLink()); 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
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
package toc package toc
import ( import (
"fmt"
"log" "log"
"strings" "strings"
"sync" "sync"
@@ -11,6 +12,14 @@ import (
tele "gopkg.in/telebot.v3" tele "gopkg.in/telebot.v3"
) )
const (
captionCharLimit = 950 // photo caption 字符上限(预留安全余量)
textCharLimit = 3900 // 纯文本消息字符上限
// CbPrefixTocPage 翻页回调前缀,导出供 telegram 包使用
CbPrefixTocPage = "tocpage:"
)
type Manager struct { type Manager struct {
storage *storage.Storage storage *storage.Storage
bot *tele.Bot bot *tele.Bot
@@ -55,18 +64,64 @@ func (m *Manager) doUpdate() {
m.pending = false m.pending = false
m.mu.Unlock() m.mu.Unlock()
content, err := m.Render() if err := m.DoUpdatePage(1); err != nil {
if err != nil {
log.Printf("TOC render error: %v", err)
return
}
if err := m.updateMessage(content); err != nil {
log.Printf("TOC update error: %v", err) 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} chat := &tele.Chat{ID: m.chanID}
msgID, err := m.storage.GetTocMsgID() msgID, err := m.storage.GetTocMsgID()
@@ -76,16 +131,12 @@ func (m *Manager) updateMessage(content string) error {
// 带封面图片模式 // 带封面图片模式
if m.coverImage != "" { if m.coverImage != "" {
return m.updateWithPhoto(chat, msgID, content) return m.updateWithPhoto(chat, msgID, content, markup)
} }
// 纯文本模式 // 纯文本模式
if msgID == 0 { if msgID == 0 {
msg, err := m.bot.Send(chat, content, tele.ModeHTML, tele.NoPreview) return m.sendNewText(chat, content, markup)
if err != nil {
return err
}
return m.storage.SetTocMsgID(msg.ID)
} }
existingMsg := &tele.Message{ existingMsg := &tele.Message{
@@ -93,19 +144,19 @@ func (m *Manager) updateMessage(content string) error {
Chat: chat, 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 { if err != nil {
errMsg := err.Error() errMsg := err.Error()
// 内容未变化,忽略
if err == tele.ErrMessageNotModified || strings.Contains(errMsg, "message is not modified") { if err == tele.ErrMessageNotModified || strings.Contains(errMsg, "message is not modified") {
return nil return nil
} }
if strings.Contains(errMsg, "message to edit not found") { if strings.Contains(errMsg, "message to edit not found") {
msg, err := m.bot.Send(chat, content, tele.ModeHTML, tele.NoPreview) return m.sendNewText(chat, content, markup)
if err != nil {
return err
}
return m.storage.SetTocMsgID(msg.ID)
} }
return err return err
} }
@@ -113,14 +164,31 @@ func (m *Manager) updateMessage(content string) error {
return nil 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{ photo := &tele.Photo{
File: tele.FromDisk(m.coverImage), File: tele.FromDisk(m.coverImage),
Caption: content, Caption: content,
} }
if msgID == 0 { 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 { if err != nil {
return err return err
} }
@@ -133,16 +201,24 @@ func (m *Manager) updateWithPhoto(chat *tele.Chat, msgID int, content string) er
} }
// 编辑图片消息的 caption // 编辑图片消息的 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 { if err != nil {
errMsg := err.Error() errMsg := err.Error()
if err == tele.ErrMessageNotModified || strings.Contains(errMsg, "message is not modified") { if err == tele.ErrMessageNotModified || strings.Contains(errMsg, "message is not modified") {
return nil return nil
} }
// 旧消息不是图片或找不到,重新发送
if strings.Contains(errMsg, "message to edit not found") || if strings.Contains(errMsg, "message to edit not found") ||
strings.Contains(errMsg, "no caption") { 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 { if err != nil {
return err return err
} }

View File

@@ -1,55 +1,140 @@
package toc package toc
import ( import (
"fmt" "fmt"
"html" "html"
"strings" "regexp"
) "strings"
"unicode/utf8"
func (m *Manager) Render() (string, error) { )
categories, err := m.storage.ListCategories()
if err != nil { var htmlTagRegexp = regexp.MustCompile(`<[^>]*>`)
return "", err
} // htmlTextLength 计算剥离 HTML 标签后的字符数,匹配 Telegram 的计数方式
func htmlTextLength(s string) int {
entriesByCategory, err := m.storage.GetEntriesByCategory() stripped := htmlTagRegexp.ReplaceAllString(s, "")
if err != nil { return utf8.RuneCountInString(stripped)
return "", err }
}
// renderLines 将所有分类和条目渲染为独立的行
var sb strings.Builder func (m *Manager) renderLines() (header string, lines []string, footer string, err error) {
sb.WriteString("📚 <b>频道目录</b>\n") categories, err := m.storage.ListCategories()
sb.WriteString("━━━━━━━━━━━━━━━\n\n") if err != nil {
return "", nil, "", err
if len(categories) == 0 { }
sb.WriteString("<i>暂无分类</i>")
return sb.String(), nil entriesByCategory, err := m.storage.GetEntriesByCategory()
} if err != nil {
return "", nil, "", err
for _, cat := range categories { }
entries := entriesByCategory[cat.Name]
header = "📚 <b>频道目录</b>\n━━━━━━━━━━━━━━━\n\n"
sb.WriteString(fmt.Sprintf("📁 <b>%s</b>", html.EscapeString(cat.Name))) footer = "━━━━━━━━━━━━━━━\n"
if len(entries) > 0 {
sb.WriteString(fmt.Sprintf(" (%d)", len(entries))) if len(categories) == 0 {
} lines = append(lines, "<i>暂无分类</i>\n")
sb.WriteString("\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 { if len(entries) == 0 {
sb.WriteString(" <i>暂无内容</i>\n") lines = append(lines, " <i>暂无内容</i>\n")
} else { } else {
for _, entry := range entries { 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() { 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")
} // 分类之间空行
lines = append(lines, "\n")
sb.WriteString("━━━━━━━━━━━━━━━\n") }
return sb.String(), nil 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
}