From df1fb034b30d5859c3882f1e91f4bf3f88fa80d3 Mon Sep 17 00:00:00 2001 From: zuimao_kaf Date: Fri, 3 Apr 2026 22:15:54 +0800 Subject: [PATCH] =?UTF-8?q?TOC=E5=88=86=E9=A1=B5=EF=BC=9A=E8=A7=A3?= =?UTF-8?q?=E5=86=B3=E5=86=85=E5=AE=B9=E8=B6=85=E9=95=BF=E5=AF=BC=E8=87=B4?= =?UTF-8?q?MEDIA=5FCAPTION=5FTOO=5FLONG=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当目录条目增多超过Telegram caption字符限制时,自动按字符上限分页渲染, 附带翻页inline keyboard(⏮首页/⬅️上一页/➡️下一页),支持photo和text两种模式。 Co-Authored-By: Claude Opus 4.6 --- .gitignore | 5 +- internal/telegram/handlers_post.go | 27 +++++- internal/toc/manager.go | 128 +++++++++++++++++++++++------ internal/toc/renderer.go | 123 ++++++++++++++++++++++----- 4 files changed, 235 insertions(+), 48 deletions(-) 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 }