Compare commits

..

6 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
05fbeabcd0 目录保留消息上下文并增加直达链接 2026-03-17 13:58:16 +08:00
6686a43a35 修复动态管理员自动识别转发消息 2026-03-17 13:14:32 +08:00
7 changed files with 329 additions and 68 deletions

3
.gitignore vendored
View File

@@ -2,3 +2,6 @@ config.yaml
/data /data
bot bot
mygo_chanbot mygo_chanbot
CLAUDE.md
AGENTS.md
cmd/seed/

View File

@@ -9,12 +9,14 @@ import (
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
) )
func (s *Storage) CreateEntry(category, title, link string) (*Entry, error) { func (s *Storage) CreateEntry(category, title, channelLink, directLink string) (*Entry, error) {
entry := &Entry{ entry := &Entry{
ID: shortid.MustGenerate(), ID: shortid.MustGenerate(),
Category: category, Category: category,
Title: title, Title: title,
Link: link, Link: channelLink,
ChannelLink: channelLink,
DirectLink: directLink,
Timestamp: time.Now(), Timestamp: time.Now(),
} }

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
@@ -30,10 +31,24 @@ type Entry struct {
ID string `json:"id"` ID string `json:"id"`
Category string `json:"category"` Category string `json:"category"`
Title string `json:"title"` Title string `json:"title"`
Link string `json:"link"` Link string `json:"link,omitempty"` // 兼容旧数据
ChannelLink string `json:"channel_link,omitempty"`
DirectLink string `json:"direct_link,omitempty"`
Timestamp time.Time `json:"timestamp"` Timestamp time.Time `json:"timestamp"`
} }
func (e Entry) MessageLink() string {
if e.ChannelLink != "" {
return e.ChannelLink
}
return e.Link
}
func (e Entry) HasDirectLink() bool {
direct := strings.TrimSpace(e.DirectLink)
return direct != "" && direct != e.MessageLink()
}
type Storage struct { type Storage struct {
db *bolt.DB db *bolt.DB
} }

View File

@@ -128,7 +128,7 @@ func (b *Bot) handleDeleteEntryCallback(c tele.Context, entryID string, deleteCh
// 根据用户选择决定是否删除频道消息 // 根据用户选择决定是否删除频道消息
msgDeleted := false msgDeleted := false
if deleteChannelMsg { if deleteChannelMsg {
if msgID := parseMessageIDFromLink(entry.Link); 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 {

View File

@@ -2,11 +2,14 @@ package telegram
import ( import (
"fmt" "fmt"
"regexp"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"tgchanbot/internal/storage" "tgchanbot/internal/storage"
"tgchanbot/internal/toc"
tele "gopkg.in/telebot.v3" tele "gopkg.in/telebot.v3"
) )
@@ -26,6 +29,8 @@ var (
albumTimersMu sync.Mutex albumTimersMu sync.Mutex
) )
var directURLPattern = regexp.MustCompile(`https?://[^\s<>"']+`)
func (b *Bot) handlePost(c tele.Context) error { func (b *Bot) handlePost(c tele.Context) error {
msg := c.Message() msg := c.Message()
payload := strings.TrimSpace(c.Message().Payload) payload := strings.TrimSpace(c.Message().Payload)
@@ -69,11 +74,11 @@ func (b *Bot) handleQuickPost(c tele.Context, replyMsg *tele.Message, payload st
title = extractTitle(replyMsg) title = extractTitle(replyMsg)
} }
// 构建链接 channelLink := buildMessageLinkFromReply(c.Message(), replyMsg)
link := buildMessageLinkFromReply(c.Message(), replyMsg) directLink := extractDirectLink(replyMsg)
// 创建条目 // 创建条目
entry, err := b.storage.CreateEntry(category, title, link) entry, err := b.storage.CreateEntry(category, title, channelLink, directLink)
if err != nil { if err != nil {
return c.Reply(fmt.Sprintf("❌ 保存失败: %v", err)) return c.Reply(fmt.Sprintf("❌ 保存失败: %v", err))
} }
@@ -81,7 +86,7 @@ func (b *Bot) handleQuickPost(c tele.Context, replyMsg *tele.Message, payload st
b.toc.TriggerUpdate() b.toc.TriggerUpdate()
return c.Reply(fmt.Sprintf("✅ 已添加\n\nID: `%s`\n分类: %s\n标题: %s\n链接: %s", return c.Reply(fmt.Sprintf("✅ 已添加\n\nID: `%s`\n分类: %s\n标题: %s\n链接: %s",
entry.ID, entry.Category, entry.Title, entry.Link), tele.ModeMarkdown) entry.ID, entry.Category, entry.Title, entry.MessageLink()), tele.ModeMarkdown)
} }
func buildMessageLinkFromReply(currentMsg, replyMsg *tele.Message) string { func buildMessageLinkFromReply(currentMsg, replyMsg *tele.Message) string {
@@ -116,7 +121,7 @@ func (b *Bot) handleTextInput(c tele.Context) error {
state := b.states.Get(c.Sender().ID) state := b.states.Get(c.Sender().ID)
// 私聊收到转发消息,直接启动投稿流程(无需先 /post // 私聊收到转发消息,直接启动投稿流程(无需先 /post
if isForwarded && b.cfg.IsAdmin(c.Sender().ID) { if isForwarded && b.isAdmin(c.Sender().ID) {
if state == nil { if state == nil {
b.states.StartPost(c.Sender().ID) b.states.StartPost(c.Sender().ID)
} }
@@ -138,7 +143,7 @@ func (b *Bot) handleTextInput(c tele.Context) error {
} }
func (b *Bot) handleForwarded(c tele.Context) error { func (b *Bot) handleForwarded(c tele.Context) error {
if !b.cfg.IsAdmin(c.Sender().ID) { if !b.isAdmin(c.Sender().ID) {
return nil return nil
} }
@@ -270,12 +275,18 @@ func (b *Bot) buildCategoryKeyboard(categories []storage.Category) *tele.ReplyMa
} }
func (b *Bot) handleCallback(c tele.Context) error { func (b *Bot) handleCallback(c tele.Context) error {
if !b.cfg.IsAdmin(c.Sender().ID) { // 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: "无权限"}) return c.Respond(&tele.CallbackResponse{Text: "无权限"})
} }
// telebot v3 会在 data 前加 \f 前缀,需要去掉
data := strings.TrimPrefix(c.Callback().Data, "\f")
userID := c.Sender().ID userID := c.Sender().ID
switch { switch {
@@ -437,10 +448,10 @@ func (b *Bot) handleConfirmCallback(c tele.Context, userID int64, action string)
return c.Respond(&tele.CallbackResponse{Text: "发送失败"}) return c.Respond(&tele.CallbackResponse{Text: "发送失败"})
} }
// 使用频道消息的链接 channelLink := b.buildChannelLink(channelMsg.ID)
link := b.buildChannelLink(channelMsg.ID) directLink := extractDirectLinkFromState(state)
entry, err := b.storage.CreateEntry(state.SelectedCat, title, link) entry, err := b.storage.CreateEntry(state.SelectedCat, title, channelLink, directLink)
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: "保存失败"})
@@ -519,6 +530,56 @@ func extractTitle(msg *tele.Message) string {
return title return title
} }
func extractDirectLinkFromMessages(msgs []*tele.Message) string {
for _, msg := range msgs {
if msg == nil {
continue
}
if link := extractDirectLink(msg); link != "" {
return link
}
}
return ""
}
func extractDirectLinkFromState(state *PostState) string {
if state == nil {
return ""
}
if link := extractDirectLink(state.ForwardedMsg); link != "" {
return link
}
return extractDirectLinkFromMessages(state.ForwardedMsgs)
}
func extractDirectLink(msg *tele.Message) string {
if msg == nil {
return ""
}
for _, entity := range msg.Entities {
if entity.Type == tele.EntityTextLink && entity.URL != "" {
return entity.URL
}
}
for _, entity := range msg.CaptionEntities {
if entity.Type == tele.EntityTextLink && entity.URL != "" {
return entity.URL
}
}
for _, text := range []string{msg.Text, msg.Caption} {
if text == "" {
continue
}
if match := directURLPattern.FindString(text); match != "" {
return match
}
}
return ""
}
func buildMessageLink(msg *tele.Message) string { func buildMessageLink(msg *tele.Message) string {
if msg.OriginalChat == nil { if msg.OriginalChat == nil {
return "" return ""
@@ -540,3 +601,18 @@ func buildMessageLink(msg *tele.Message) string {
} }
return fmt.Sprintf("https://t.me/c/%d/%d", chatID, msgID) 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()
}

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

@@ -3,49 +3,138 @@ package toc
import ( import (
"fmt" "fmt"
"html" "html"
"regexp"
"strings" "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() categories, err := m.storage.ListCategories()
if err != nil { if err != nil {
return "", err return "", nil, "", err
} }
entriesByCategory, err := m.storage.GetEntriesByCategory() entriesByCategory, err := m.storage.GetEntriesByCategory()
if err != nil { if err != nil {
return "", err return "", nil, "", err
} }
var sb strings.Builder header = "📚 <b>频道目录</b>\n━━━━━━━━━━━━━━━\n\n"
sb.WriteString("📚 <b>频道目录</b>\n") footer = "━━━━━━━━━━━━━━━\n"
sb.WriteString("━━━━━━━━━━━━━━━\n\n")
if len(categories) == 0 { if len(categories) == 0 {
sb.WriteString("<i>暂无分类</i>") lines = append(lines, "<i>暂无分类</i>\n")
return sb.String(), nil return header, lines, footer, nil
} }
for _, cat := range categories { for _, cat := range categories {
entries := entriesByCategory[cat.Name] entries := entriesByCategory[cat.Name]
sb.WriteString(fmt.Sprintf("📁 <b>%s</b>", html.EscapeString(cat.Name))) catLine := fmt.Sprintf("📁 <b>%s</b>", html.EscapeString(cat.Name))
if len(entries) > 0 { 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 { 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>\n", entry.Link, html.EscapeString(entry.Title))) 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)
} }
sb.WriteString("\n")
} }
sb.WriteString("━━━━━━━━━━━━━━━\n") // 分类之间空行
lines = append(lines, "\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
} }