diff --git a/.gitignore b/.gitignore
index e14add4..a4195e9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,7 @@
config.yaml
/data
bot
-mygo_chanbot
\ No newline at end of file
+mygo_chanbot
+CLAUDE.md
+AGENTS.md
+cmd/seed/
\ No newline at end of file
diff --git a/internal/telegram/handlers_post.go b/internal/telegram/handlers_post.go
index a337417..aadae1e 100644
--- a/internal/telegram/handlers_post.go
+++ b/internal/telegram/handlers_post.go
@@ -3,11 +3,13 @@ package telegram
import (
"fmt"
"regexp"
+ "strconv"
"strings"
"sync"
"time"
"tgchanbot/internal/storage"
+ "tgchanbot/internal/toc"
tele "gopkg.in/telebot.v3"
)
@@ -273,12 +275,18 @@ func (b *Bot) buildCategoryKeyboard(categories []storage.Category) *tele.ReplyMa
}
func (b *Bot) handleCallback(c tele.Context) error {
+ // telebot v3 会在 data 前加 \f 前缀,需要去掉
+ data := strings.TrimPrefix(c.Callback().Data, "\f")
+
+ // TOC 翻页回调:任何用户都可点击(频道公开按钮),不需要 admin 权限
+ if strings.HasPrefix(data, toc.CbPrefixTocPage) {
+ return b.handleTocPageCallback(c, data)
+ }
+
if !b.isAdmin(c.Sender().ID) {
return c.Respond(&tele.CallbackResponse{Text: "无权限"})
}
- // telebot v3 会在 data 前加 \f 前缀,需要去掉
- data := strings.TrimPrefix(c.Callback().Data, "\f")
userID := c.Sender().ID
switch {
@@ -593,3 +601,18 @@ func buildMessageLink(msg *tele.Message) string {
}
return fmt.Sprintf("https://t.me/c/%d/%d", chatID, msgID)
}
+
+// handleTocPageCallback 处理 TOC 翻页回调
+func (b *Bot) handleTocPageCallback(c tele.Context, data string) error {
+ pageStr := strings.TrimPrefix(data, toc.CbPrefixTocPage)
+ page, err := strconv.Atoi(pageStr)
+ if err != nil || page < 1 {
+ return c.Respond(&tele.CallbackResponse{Text: "无效页码"})
+ }
+
+ if err := b.toc.HandlePageCallback(page); err != nil {
+ return c.Respond(&tele.CallbackResponse{Text: "更新失败"})
+ }
+
+ return c.Respond()
+}
diff --git a/internal/toc/manager.go b/internal/toc/manager.go
index 9e22a3a..fd6fcaa 100644
--- a/internal/toc/manager.go
+++ b/internal/toc/manager.go
@@ -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
}
diff --git a/internal/toc/renderer.go b/internal/toc/renderer.go
index 204f8e7..be8e4c5 100644
--- a/internal/toc/renderer.go
+++ b/internal/toc/renderer.go
@@ -3,53 +3,138 @@ package toc
import (
"fmt"
"html"
+ "regexp"
"strings"
+ "unicode/utf8"
)
-func (m *Manager) Render() (string, error) {
+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 "", err
+ return "", nil, "", err
}
entriesByCategory, err := m.storage.GetEntriesByCategory()
if err != nil {
- return "", err
+ return "", nil, "", err
}
- var sb strings.Builder
- sb.WriteString("📚 频道目录\n")
- sb.WriteString("━━━━━━━━━━━━━━━\n\n")
+ header = "📚 频道目录\n━━━━━━━━━━━━━━━\n\n"
+ footer = "━━━━━━━━━━━━━━━\n"
if len(categories) == 0 {
- sb.WriteString("暂无分类")
- return sb.String(), nil
+ lines = append(lines, "暂无分类\n")
+ return header, lines, footer, nil
}
for _, cat := range categories {
entries := entriesByCategory[cat.Name]
- sb.WriteString(fmt.Sprintf("📁 %s", html.EscapeString(cat.Name)))
+ catLine := fmt.Sprintf("📁 %s", html.EscapeString(cat.Name))
if len(entries) > 0 {
- sb.WriteString(fmt.Sprintf(" (%d)", len(entries)))
+ catLine += fmt.Sprintf(" (%d)", len(entries))
}
- sb.WriteString("\n")
+ catLine += "\n"
+ lines = append(lines, catLine)
if len(entries) == 0 {
- sb.WriteString(" 暂无内容\n")
+ lines = append(lines, " 暂无内容\n")
} else {
for _, entry := range entries {
- sb.WriteString(fmt.Sprintf(" • %s", html.EscapeString(entry.MessageLink()), html.EscapeString(entry.Title)))
+ entryLine := fmt.Sprintf(" • %s", html.EscapeString(entry.MessageLink()), html.EscapeString(entry.Title))
if entry.HasDirectLink() {
- sb.WriteString(fmt.Sprintf(" [直达]", html.EscapeString(entry.DirectLink)))
+ entryLine += fmt.Sprintf(" [直达]", 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
}