Compare commits
6 Commits
270369ae0a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 480a864436 | |||
| df1fb034b3 | |||
| d8fd372e53 | |||
| f946c68ca9 | |||
| 05fbeabcd0 | |||
| 6686a43a35 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,3 +2,6 @@ config.yaml
|
|||||||
/data
|
/data
|
||||||
bot
|
bot
|
||||||
mygo_chanbot
|
mygo_chanbot
|
||||||
|
CLAUDE.md
|
||||||
|
AGENTS.md
|
||||||
|
cmd/seed/
|
||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user