当目录条目增多超过Telegram caption字符限制时,自动按字符上限分页渲染, 附带翻页inline keyboard(⏮首页/⬅️上一页/➡️下一页),支持photo和text两种模式。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
141 lines
3.4 KiB
Go
141 lines
3.4 KiB
Go
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 {
|
||
lines = append(lines, " <i>暂无内容</i>\n")
|
||
} else {
|
||
for _, entry := range entries {
|
||
entryLine := fmt.Sprintf(" • <a href=\"%s\">%s</a>", html.EscapeString(entry.MessageLink()), html.EscapeString(entry.Title))
|
||
if entry.HasDirectLink() {
|
||
entryLine += fmt.Sprintf(" <a href=\"%s\">[直达]</a>", html.EscapeString(entry.DirectLink))
|
||
}
|
||
entryLine += "\n"
|
||
lines = append(lines, entryLine)
|
||
}
|
||
}
|
||
|
||
// 分类之间空行
|
||
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
|
||
}
|