Compare commits

...

12 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
dela
270369ae0a 修复转发跳收藏;修复若干小bug 2026-02-05 21:29:48 +08:00
dela
098fd57c19 修复反斜杠转;代码格式 2026-02-05 13:57:20 +08:00
dela
6691dbaff2 增加功能:删除选择;相册转发 2026-02-05 11:07:37 +08:00
dela
ee9418b7cf 优化私聊post 2026-02-05 10:40:17 +08:00
dela
3fea0ee89c 更新封面和readme 2026-02-05 10:17:55 +08:00
dela
e5a717e94e 修复发图片/文件/视频等消息时不会触发 2026-02-05 09:48:32 +08:00
14 changed files with 776 additions and 166 deletions

6
.gitignore vendored
View File

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

View File

@@ -1,5 +1,7 @@
# Telegram 频道目录机器人
![Cover](assets/mygo.png)
交互式、数据库驱动的 Telegram 频道内容管理系统。
## 功能
@@ -8,6 +10,9 @@
- **交互式按钮投稿** - Inline Keyboard 选择分类
- **快捷回复投稿** - 回复消息直接添加到目录
- **内容同步发布** - 转发的内容会复制发送到频道(非转发,可编辑)
- **相册/多文件支持** - 自动收集相册消息,一次性发送到频道
- **目录封面图** - 可配置封面图片美化目录展示
- **交互式删除** - 删除条目时可选择是否同步删除频道消息
- **防抖目录更新** - 多次操作合并为一次渲染,避免限流
## 安装
@@ -36,6 +41,7 @@ database:
toc:
debounce_seconds: 3
cover_image: "./assets/cover.jpg" # 可选,目录封面图片
```
## 运行
@@ -48,29 +54,44 @@ toc:
## 命令
### 投稿管理
| 命令 | 说明 |
|------|------|
| `/post` | 开始投稿流程 |
| `/post <分类> /tt <标题>` | 快捷投稿 (回复消息时使用) |
| `/list [分类]` | 查看条目 |
| `/del <ID>` | 删除条目 |
| `/del <ID>` | 删除条目(可选是否删除频道消息) |
| `/edit <ID> <新标题>` | 修改标题 |
| `/move <ID> <新分类>` | 移动条目到其他分类 |
| `/refresh` | 手动刷新频道目录 |
### 分类管理
| 命令 | 说明 |
|------|------|
| `/cat_add <名称> [排序]` | 创建分类 |
| `/cat_del <名称>` | 删除分类 |
| `/cat_rename <旧名称> <新名称>` | 重命名分类(自动更新所有条目) |
| `/cat_list` | 列出所有分类 |
| `/refresh` | 手动刷新频道目录 |
### 管理员管理 (仅超级管理员)
| 命令 | 说明 |
|------|------|
| `/admin_add <用户ID>` | 添加管理员 |
| `/admin_del <用户ID>` | 移除管理员 |
| `/admin_list` | 列出所有管理员 |
## 投稿方式
### 方式一:交互式流程(私聊 Bot
### 方式一:私聊转发(推荐
```
1. 私聊 Bot 发送 /post
2. 转发任意消息给 Bot支持来自任意来源个人、群组、频道等
3. 点击分类按钮
4. 确认标题 (使用默认 / 自定义输入)
5. 确认添加
1. 私聊 Bot,直接转发任意消息(支持来自个人、群组、频道等)
2. 点击分类按钮
3. 确认标题 (使用默认 / 自定义输入)
4. 确认添加
```
Bot 会将消息内容复制发送到频道,目录链接指向频道中的新消息。
@@ -96,6 +117,7 @@ Bot 会将消息内容复制发送到频道,目录链接指向频道中的新
- ✅ 动图(带说明文字)
- ✅ 语音
- ✅ 贴纸
- ✅ 相册/多文件(自动收集并合并发送)
## 目录结构

BIN
assets/mygo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

View File

@@ -28,7 +28,8 @@ type DatabaseConfig struct {
}
type TOCConfig struct {
DebounceSeconds int `yaml:"debounce_seconds"`
DebounceSeconds int `yaml:"debounce_seconds"`
CoverImage string `yaml:"cover_image"` // 封面图片路径,留空则不使用图片
}
func Load(path string) (*Config, error) {

View File

@@ -105,6 +105,62 @@ func (s *Storage) UpdateCategoryOrder(name string, order int) error {
})
}
func (s *Storage) RenameCategory(oldName, newName string) error {
return s.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketCategories)
// 检查旧分类是否存在
data := b.Get([]byte(oldName))
if data == nil {
return fmt.Errorf("category %q not found", oldName)
}
// 检查新名称是否已存在
if b.Get([]byte(newName)) != nil {
return fmt.Errorf("category %q already exists", newName)
}
// 解码旧分类
var cat Category
if err := decodeJSON(data, &cat); err != nil {
return err
}
// 更新名称
cat.Name = newName
newData, err := encodeJSON(cat)
if err != nil {
return err
}
// 删除旧键,写入新键
if err := b.Delete([]byte(oldName)); err != nil {
return err
}
if err := b.Put([]byte(newName), newData); err != nil {
return err
}
// 同步更新所有条目的分类名
entries := tx.Bucket(bucketEntries)
return entries.ForEach(func(k, v []byte) error {
var entry Entry
if err := decodeJSON(v, &entry); err != nil {
return err
}
if entry.Category == oldName {
entry.Category = newName
newEntryData, err := encodeJSON(entry)
if err != nil {
return err
}
return entries.Put(k, newEntryData)
}
return nil
})
})
}
func (s *Storage) CategoryExists(name string) bool {
exists := false
s.db.View(func(tx *bolt.Tx) error {

View File

@@ -9,13 +9,15 @@ import (
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{
ID: shortid.MustGenerate(),
Category: category,
Title: title,
Link: link,
Timestamp: time.Now(),
ID: shortid.MustGenerate(),
Category: category,
Title: title,
Link: channelLink,
ChannelLink: channelLink,
DirectLink: directLink,
Timestamp: time.Now(),
}
err := s.db.Update(func(tx *bolt.Tx) error {

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
bolt "go.etcd.io/bbolt"
@@ -27,11 +28,25 @@ type Category struct {
}
type Entry struct {
ID string `json:"id"`
Category string `json:"category"`
Title string `json:"title"`
Link string `json:"link"`
Timestamp time.Time `json:"timestamp"`
ID string `json:"id"`
Category string `json:"category"`
Title string `json:"title"`
Link string `json:"link,omitempty"` // 兼容旧数据
ChannelLink string `json:"channel_link,omitempty"`
DirectLink string `json:"direct_link,omitempty"`
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 {

View File

@@ -37,7 +37,7 @@ func New(cfg *config.Config, store *storage.Storage) (*Bot, error) {
states: NewStateManager(),
}
bot.toc = toc.NewManager(store, b, cfg.Channel.ID, time.Duration(cfg.TOC.DebounceSeconds)*time.Second)
bot.toc = toc.NewManager(store, b, cfg.Channel.ID, time.Duration(cfg.TOC.DebounceSeconds)*time.Second, cfg.TOC.CoverImage)
bot.setupRoutes()
@@ -51,11 +51,19 @@ func (b *Bot) setupRoutes() {
// Category management
adminOnly.Handle("/cat_add", b.handleCatAdd)
adminOnly.Handle("/cat_del", b.handleCatDel)
adminOnly.Handle("/cat_rename", b.handleCatRename)
adminOnly.Handle("/cat_list", b.handleCatList)
// Post flow
adminOnly.Handle("/post", b.handlePost)
adminOnly.Handle(tele.OnText, b.handleTextInput)
adminOnly.Handle(tele.OnPhoto, b.handleTextInput)
adminOnly.Handle(tele.OnVideo, b.handleTextInput)
adminOnly.Handle(tele.OnDocument, b.handleTextInput)
adminOnly.Handle(tele.OnAudio, b.handleTextInput)
adminOnly.Handle(tele.OnVoice, b.handleTextInput)
adminOnly.Handle(tele.OnAnimation, b.handleTextInput)
adminOnly.Handle(tele.OnSticker, b.handleTextInput)
// Entry management
adminOnly.Handle("/del", b.handleEntryDel)
@@ -90,6 +98,7 @@ func (b *Bot) setCommands() {
{Text: "cat_list", Description: "分类列表"},
{Text: "cat_add", Description: "添加分类 <名称> [排序]"},
{Text: "cat_del", Description: "删除分类 <名称>"},
{Text: "cat_rename", Description: "重命名分类 <旧名称> <新名称>"},
{Text: "del", Description: "删除条目 <ID>"},
{Text: "edit", Description: "编辑标题 <ID> <新标题>"},
{Text: "move", Description: "移动条目 <ID> <新分类>"},

View File

@@ -42,6 +42,24 @@ func (b *Bot) handleCatDel(c tele.Context) error {
return c.Reply(fmt.Sprintf("✅ 分类 [%s] 已删除", name))
}
func (b *Bot) handleCatRename(c tele.Context) error {
args := strings.Fields(c.Message().Payload)
if len(args) < 2 {
return c.Reply("用法: /cat_rename <旧名称> <新名称>\n例如: /cat_rename iOS Apple")
}
oldName := args[0]
newName := args[1]
if err := b.storage.RenameCategory(oldName, newName); err != nil {
return c.Reply(fmt.Sprintf("❌ 重命名失败: %v", err))
}
b.toc.TriggerUpdate()
return c.Reply(fmt.Sprintf("✅ 分类 [%s] 已重命名为 [%s]", oldName, newName))
}
func (b *Bot) handleCatList(c tele.Context) error {
categories, err := b.storage.ListCategories()
if err != nil {

View File

@@ -2,11 +2,19 @@ package telegram
import (
"fmt"
"html"
"strconv"
"strings"
tele "gopkg.in/telebot.v3"
)
const (
cbPrefixDelEntry = "del:"
cbPrefixDelWithMsg = "delwithmsg:"
cbPrefixDelOnlyToc = "delonlytoc:"
)
func (b *Bot) handleEntryDel(c tele.Context) error {
id := strings.TrimSpace(c.Message().Payload)
if id == "" {
@@ -18,12 +26,18 @@ func (b *Bot) handleEntryDel(c tele.Context) error {
return c.Reply(fmt.Sprintf("❌ %v", err))
}
if err := b.storage.DeleteEntry(id); err != nil {
return c.Reply(fmt.Sprintf(" 删除失败: %v", err))
}
menu := &tele.ReplyMarkup{}
delWithMsgBtn := menu.Data("🗑 删除条目+频道消息", cbPrefixDelWithMsg+id)
delOnlyTocBtn := menu.Data("📝 仅删除目录条目", cbPrefixDelOnlyToc+id)
cancelBtn := menu.Data("❌ 取消", cbCancel)
menu.Inline(
menu.Row(delWithMsgBtn),
menu.Row(delOnlyTocBtn),
menu.Row(cancelBtn),
)
b.toc.TriggerUpdate()
return c.Reply(fmt.Sprintf("✅ 已删除: [%s] %s", entry.Category, entry.Title))
text := fmt.Sprintf("🗑 确认删除?\n\n分类: %s\n标题: %s\n\n请选择删除方式:", entry.Category, entry.Title)
return c.Reply(text, menu)
}
func (b *Bot) handleEntryEdit(c tele.Context) error {
@@ -81,7 +95,7 @@ func (b *Bot) handleEntryList(c tele.Context) error {
var sb strings.Builder
if category != "" {
sb.WriteString(fmt.Sprintf("📋 分类 [%s] 的条目:\n\n", category))
sb.WriteString(fmt.Sprintf("📋 分类 [%s] 的条目:\n\n", html.EscapeString(category)))
} else {
sb.WriteString("📋 所有条目:\n\n")
}
@@ -90,15 +104,69 @@ func (b *Bot) handleEntryList(c tele.Context) error {
for _, e := range entries {
if category == "" && e.Category != currentCat {
currentCat = e.Category
sb.WriteString(fmt.Sprintf("\n【%s】\n", currentCat))
sb.WriteString(fmt.Sprintf("\n【%s】\n", html.EscapeString(currentCat)))
}
sb.WriteString(fmt.Sprintf("• [%s] %s\n", e.ID, e.Title))
sb.WriteString(fmt.Sprintf("• <code>%s</code> %s\n", e.ID, html.EscapeString(e.Title)))
}
return c.Reply(sb.String())
return c.Reply(sb.String(), tele.ModeHTML)
}
func (b *Bot) handleRefresh(c tele.Context) error {
b.toc.TriggerUpdate()
return c.Reply("🔄 目录刷新已触发")
}
// handleDeleteEntryCallback 处理删除条目的回调
func (b *Bot) handleDeleteEntryCallback(c tele.Context, entryID string, deleteChannelMsg bool) error {
entry, err := b.storage.GetEntry(entryID)
if err != nil {
c.Edit(fmt.Sprintf("❌ %v", err))
return c.Respond(&tele.CallbackResponse{Text: "条目不存在"})
}
// 根据用户选择决定是否删除频道消息
msgDeleted := false
if deleteChannelMsg {
if msgID := parseMessageIDFromLink(entry.MessageLink()); msgID != 0 {
channel := &tele.Chat{ID: b.cfg.Channel.ID}
msg := &tele.Message{ID: msgID, Chat: channel}
if err := b.bot.Delete(msg); err == nil {
msgDeleted = true
}
}
}
if err := b.storage.DeleteEntry(entryID); err != nil {
c.Edit(fmt.Sprintf("❌ 删除失败: %v", err))
return c.Respond(&tele.CallbackResponse{Text: "删除失败"})
}
b.toc.TriggerUpdate()
var text string
if deleteChannelMsg && msgDeleted {
text = fmt.Sprintf("✅ 已删除: [%s] %s\n📨 频道消息已同步删除", entry.Category, entry.Title)
} else if deleteChannelMsg && !msgDeleted {
text = fmt.Sprintf("✅ 已删除: [%s] %s\n⚠ 频道消息删除失败(可能已被删除)", entry.Category, entry.Title)
} else {
text = fmt.Sprintf("✅ 已删除: [%s] %s\n📝 频道消息已保留", entry.Category, entry.Title)
}
c.Edit(text)
return c.Respond(&tele.CallbackResponse{Text: "删除成功"})
}
// parseMessageIDFromLink 从链接中解析消息ID
// 支持格式: https://t.me/c/123456/789 或 https://t.me/username/789
func parseMessageIDFromLink(link string) int {
parts := strings.Split(link, "/")
if len(parts) < 1 {
return 0
}
msgID, err := strconv.Atoi(parts[len(parts)-1])
if err != nil {
return 0
}
return msgID
}

View File

@@ -2,9 +2,14 @@ package telegram
import (
"fmt"
"regexp"
"strconv"
"strings"
"sync"
"time"
"tgchanbot/internal/storage"
"tgchanbot/internal/toc"
tele "gopkg.in/telebot.v3"
)
@@ -14,8 +19,18 @@ const (
cbPrefixConfirm = "confirm:"
cbPrefixTitle = "title:"
cbCancel = "cancel"
albumCollectDelay = 500 * time.Millisecond // 等待相册其他消息的时间
)
// 相册收集定时器
var (
albumTimers = make(map[int64]*time.Timer)
albumTimersMu sync.Mutex
)
var directURLPattern = regexp.MustCompile(`https?://[^\s<>"']+`)
func (b *Bot) handlePost(c tele.Context) error {
msg := c.Message()
payload := strings.TrimSpace(c.Message().Payload)
@@ -59,11 +74,11 @@ func (b *Bot) handleQuickPost(c tele.Context, replyMsg *tele.Message, payload st
title = extractTitle(replyMsg)
}
// 构建链接
link := buildMessageLinkFromReply(c.Message(), replyMsg)
channelLink := 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 {
return c.Reply(fmt.Sprintf("❌ 保存失败: %v", err))
}
@@ -71,7 +86,7 @@ func (b *Bot) handleQuickPost(c tele.Context, replyMsg *tele.Message, payload st
b.toc.TriggerUpdate()
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 {
@@ -100,17 +115,23 @@ func (b *Bot) handleTextInput(c tele.Context) error {
return nil
}
// 只处理有活跃投稿状态的用户
// 检查是否为转发消息OriginalUnixtime 在隐私设置隐藏来源时仍存在)
isForwarded := msg.OriginalChat != nil || msg.OriginalSender != nil || msg.OriginalUnixtime > 0
state := b.states.Get(c.Sender().ID)
if state == nil {
return nil
// 私聊收到转发消息,直接启动投稿流程(无需先 /post
if isForwarded && b.isAdmin(c.Sender().ID) {
if state == nil {
b.states.StartPost(c.Sender().ID)
}
if state == nil || state.Step == StepAwaitForward {
return b.handleForwarded(c)
}
}
// 转发消息处理 (StepAwaitForward)
// OriginalChat: 转发自频道/群组; OriginalSender: 转发自个人用户
isForwarded := msg.OriginalChat != nil || msg.OriginalSender != nil
if isForwarded && state.Step == StepAwaitForward {
return b.handleForwarded(c)
if state == nil {
return nil
}
// 自定义标题输入 (StepAwaitTitle)
@@ -122,7 +143,7 @@ func (b *Bot) handleTextInput(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
}
@@ -132,13 +153,48 @@ func (b *Bot) handleForwarded(c tele.Context) error {
}
msg := c.Message()
// 检查是否为转发消息(来自频道/群组或个人用户
if msg.OriginalChat == nil && msg.OriginalSender == nil {
// 检查是否为转发消息(OriginalUnixtime 在隐私设置隐藏来源时仍存在
if msg.OriginalChat == nil && msg.OriginalSender == nil && msg.OriginalUnixtime == 0 {
return c.Reply("❌ 这不是一条转发消息,请转发一条消息给我")
}
b.states.SetForwarded(c.Sender().ID, msg)
userID := c.Sender().ID
// 相册消息处理
if msg.AlbumID != "" {
b.states.AddAlbumMessage(userID, msg)
b.scheduleAlbumFinalize(c, userID)
return nil // 等待收集完成
}
// 单条消息
b.states.SetForwarded(userID, msg)
return b.showCategorySelection(c)
}
// scheduleAlbumFinalize 延迟完成相册收集
func (b *Bot) scheduleAlbumFinalize(c tele.Context, userID int64) {
albumTimersMu.Lock()
defer albumTimersMu.Unlock()
// 取消之前的定时器
if timer, exists := albumTimers[userID]; exists {
timer.Stop()
}
// 设置新定时器
albumTimers[userID] = time.AfterFunc(albumCollectDelay, func() {
albumTimersMu.Lock()
delete(albumTimers, userID)
albumTimersMu.Unlock()
b.states.FinalizeAlbum(userID)
b.showCategorySelectionAsync(c, userID)
})
}
// showCategorySelection 显示分类选择
func (b *Bot) showCategorySelection(c tele.Context) error {
categories, err := b.storage.ListCategories()
if err != nil {
b.states.Delete(c.Sender().ID)
@@ -150,10 +206,48 @@ func (b *Bot) handleForwarded(c tele.Context) error {
return c.Reply("❌ 暂无分类,请先使用 /cat_add 创建分类")
}
state := b.states.Get(c.Sender().ID)
msgCount := 1
if state != nil && len(state.ForwardedMsgs) > 0 {
msgCount = len(state.ForwardedMsgs)
}
keyboard := b.buildCategoryKeyboard(categories)
if msgCount > 1 {
return c.Reply(fmt.Sprintf("📁 已收到 %d 条消息(相册),请选择分类:", msgCount), keyboard)
}
return c.Reply("📁 请选择分类:", keyboard)
}
// showCategorySelectionAsync 异步显示分类选择(用于定时器回调)
func (b *Bot) showCategorySelectionAsync(c tele.Context, userID int64) {
categories, err := b.storage.ListCategories()
if err != nil {
b.states.Delete(userID)
c.Bot().Send(c.Chat(), fmt.Sprintf("❌ 获取分类失败: %v", err))
return
}
if len(categories) == 0 {
b.states.Delete(userID)
c.Bot().Send(c.Chat(), "❌ 暂无分类,请先使用 /cat_add 创建分类")
return
}
state := b.states.Get(userID)
msgCount := 1
if state != nil && len(state.ForwardedMsgs) > 0 {
msgCount = len(state.ForwardedMsgs)
}
keyboard := b.buildCategoryKeyboard(categories)
if msgCount > 1 {
c.Bot().Send(c.Chat(), fmt.Sprintf("📁 已收到 %d 条消息(相册),请选择分类:", msgCount), keyboard)
} else {
c.Bot().Send(c.Chat(), "📁 请选择分类:", keyboard)
}
}
func (b *Bot) buildCategoryKeyboard(categories []storage.Category) *tele.ReplyMarkup {
menu := &tele.ReplyMarkup{}
var rows []tele.Row
@@ -181,12 +275,18 @@ func (b *Bot) buildCategoryKeyboard(categories []storage.Category) *tele.ReplyMa
}
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: "无权限"})
}
// telebot v3 会在 data 前加 \f 前缀,需要去掉
data := strings.TrimPrefix(c.Callback().Data, "\f")
userID := c.Sender().ID
switch {
@@ -204,6 +304,14 @@ func (b *Bot) handleCallback(c tele.Context) error {
case strings.HasPrefix(data, cbPrefixConfirm):
action := strings.TrimPrefix(data, cbPrefixConfirm)
return b.handleConfirmCallback(c, userID, action)
case strings.HasPrefix(data, cbPrefixDelWithMsg):
entryID := strings.TrimPrefix(data, cbPrefixDelWithMsg)
return b.handleDeleteEntryCallback(c, entryID, true)
case strings.HasPrefix(data, cbPrefixDelOnlyToc):
entryID := strings.TrimPrefix(data, cbPrefixDelOnlyToc)
return b.handleDeleteEntryCallback(c, entryID, false)
}
return c.Respond()
@@ -321,21 +429,29 @@ func (b *Bot) handleConfirmCallback(c tele.Context, userID int64, action string)
defer b.states.Delete(userID)
msg := state.ForwardedMsg
title := state.Title
// 复制消息内容发送到频道(非转发,可编辑)
channel := &tele.Chat{ID: b.cfg.Channel.ID}
channelMsg, err := b.sendMessageCopy(channel, msg)
var channelMsg *tele.Message
var err error
// 相册消息
if len(state.ForwardedMsgs) > 1 {
channelMsg, err = b.sendAlbumCopy(channel, state.ForwardedMsgs)
} else {
// 单条消息
channelMsg, err = b.sendMessageCopy(channel, state.ForwardedMsg)
}
if err != nil {
c.Edit(fmt.Sprintf("❌ 发送到频道失败: %v", err))
return c.Respond(&tele.CallbackResponse{Text: "发送失败"})
}
// 使用频道消息的链接
link := buildChannelLink(b.cfg.Channel.ID, channelMsg.ID)
channelLink := 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 {
c.Edit(fmt.Sprintf("❌ 保存失败: %v", err))
return c.Respond(&tele.CallbackResponse{Text: "保存失败"})
@@ -348,60 +464,46 @@ func (b *Bot) handleConfirmCallback(c tele.Context, userID int64, action string)
return c.Respond(&tele.CallbackResponse{Text: "添加成功"})
}
// sendMessageCopy 复制消息内容发送(非转发,可编辑
// sendMessageCopy 复制消息内容发送(使用 copyMessage API 保留原格式
func (b *Bot) sendMessageCopy(to *tele.Chat, msg *tele.Message) (*tele.Message, error) {
// 图片
if msg.Photo != nil {
photo := &tele.Photo{File: msg.Photo.File, Caption: msg.Caption}
return b.bot.Send(to, photo, tele.ModeHTML)
}
// 视频
if msg.Video != nil {
video := &tele.Video{File: msg.Video.File, Caption: msg.Caption}
return b.bot.Send(to, video, tele.ModeHTML)
}
// 文档
if msg.Document != nil {
doc := &tele.Document{File: msg.Document.File, Caption: msg.Caption}
return b.bot.Send(to, doc, tele.ModeHTML)
}
// 音频
if msg.Audio != nil {
audio := &tele.Audio{File: msg.Audio.File, Caption: msg.Caption}
return b.bot.Send(to, audio, tele.ModeHTML)
}
// 语音
if msg.Voice != nil {
voice := &tele.Voice{File: msg.Voice.File}
return b.bot.Send(to, voice)
}
// 贴纸
if msg.Sticker != nil {
sticker := &tele.Sticker{File: msg.Sticker.File}
return b.bot.Send(to, sticker)
}
// 动图
if msg.Animation != nil {
anim := &tele.Animation{File: msg.Animation.File, Caption: msg.Caption}
return b.bot.Send(to, anim, tele.ModeHTML)
}
// 纯文本
if msg.Text != "" {
return b.bot.Send(to, msg.Text, tele.ModeHTML, tele.NoPreview)
}
return nil, fmt.Errorf("不支持的消息类型")
// 使用 Copy 方法,完整保留原消息的 entities代码块、粗体、斜体等格式
return b.bot.Copy(to, msg)
}
func buildChannelLink(channelID int64, msgID int) string {
chatID := channelID
// sendAlbumCopy 复制相册发送到频道,返回第一条消息
func (b *Bot) sendAlbumCopy(to *tele.Chat, msgs []*tele.Message) (*tele.Message, error) {
if len(msgs) == 0 {
return nil, fmt.Errorf("相册为空")
}
// 转换为 Editable 接口切片
editables := make([]tele.Editable, len(msgs))
for i, msg := range msgs {
editables[i] = msg
}
// 使用 CopyMany 保留原格式(代码块、粗体等 entities
sentMsgs, err := b.bot.CopyMany(to, editables)
if err != nil {
return nil, err
}
if len(sentMsgs) == 0 {
return nil, fmt.Errorf("发送相册失败")
}
return &sentMsgs[0], nil
}
func (b *Bot) buildChannelLink(msgID int) string {
// 尝试获取频道信息,检查是否为公开频道
chat, err := b.bot.ChatByID(b.cfg.Channel.ID)
if err == nil && chat.Username != "" {
return fmt.Sprintf("https://t.me/%s/%d", chat.Username, msgID)
}
// 私有频道 fallback
chatID := b.cfg.Channel.ID
if chatID < 0 {
chatID = -chatID - 1000000000000
}
@@ -428,6 +530,56 @@ func extractTitle(msg *tele.Message) string {
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 {
if msg.OriginalChat == nil {
return ""
@@ -449,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()
}

View File

@@ -17,12 +17,14 @@ const (
)
type PostState struct {
UserID int64
ForwardedMsg *tele.Message
SelectedCat string
Title string
Step PostStep
CreatedAt time.Time
UserID int64
ForwardedMsg *tele.Message // 单条消息
ForwardedMsgs []*tele.Message // 相册消息
AlbumID string // 相册ID
SelectedCat string
Title string
Step PostStep
CreatedAt time.Time
}
type StateManager struct {
@@ -74,6 +76,51 @@ func (sm *StateManager) SetForwarded(userID int64, msg *tele.Message) *PostState
}
state.ForwardedMsg = msg
state.ForwardedMsgs = nil
state.AlbumID = ""
state.Step = StepAwaitCategory
return state
}
// AddAlbumMessage 添加相册消息,返回是否应该继续等待更多消息
func (sm *StateManager) AddAlbumMessage(userID int64, msg *tele.Message) (shouldWait bool) {
sm.mu.Lock()
defer sm.mu.Unlock()
state := sm.states[userID]
if state == nil {
return false
}
// 第一条相册消息
if state.AlbumID == "" {
state.AlbumID = msg.AlbumID
state.ForwardedMsgs = []*tele.Message{msg}
return true
}
// 同一相册的后续消息
if state.AlbumID == msg.AlbumID {
state.ForwardedMsgs = append(state.ForwardedMsgs, msg)
return true
}
return false
}
// FinalizeAlbum 完成相册收集
func (sm *StateManager) FinalizeAlbum(userID int64) *PostState {
sm.mu.Lock()
defer sm.mu.Unlock()
state := sm.states[userID]
if state == nil {
return nil
}
if len(state.ForwardedMsgs) > 0 {
state.ForwardedMsg = state.ForwardedMsgs[0] // 用第一条提取标题
}
state.Step = StepAwaitCategory
return state
}

View File

@@ -1,6 +1,7 @@
package toc
import (
"fmt"
"log"
"strings"
"sync"
@@ -11,11 +12,20 @@ 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
chanID int64
debounce time.Duration
storage *storage.Storage
bot *tele.Bot
chanID int64
debounce time.Duration
coverImage string
mu sync.Mutex
pending bool
@@ -23,13 +33,14 @@ type Manager struct {
stopCh chan struct{}
}
func NewManager(store *storage.Storage, bot *tele.Bot, chanID int64, debounce time.Duration) *Manager {
func NewManager(store *storage.Storage, bot *tele.Bot, chanID int64, debounce time.Duration, coverImage string) *Manager {
return &Manager{
storage: store,
bot: bot,
chanID: chanID,
debounce: debounce,
stopCh: make(chan struct{}),
storage: store,
bot: bot,
chanID: chanID,
debounce: debounce,
coverImage: coverImage,
stopCh: make(chan struct{}),
}
}
@@ -53,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()
@@ -72,8 +129,66 @@ func (m *Manager) updateMessage(content string) error {
return err
}
// 带封面图片模式
if m.coverImage != "" {
return m.updateWithPhoto(chat, msgID, content, markup)
}
// 纯文本模式
if msgID == 0 {
msg, err := m.bot.Send(chat, content, tele.ModeMarkdown, tele.NoPreview)
return m.sendNewText(chat, content, markup)
}
existingMsg := &tele.Message{
ID: msgID,
Chat: chat,
}
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") {
return m.sendNewText(chat, content, markup)
}
return err
}
return nil
}
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 {
opts := []interface{}{tele.ModeHTML}
if markup != nil {
opts = append(opts, markup)
}
msg, err := m.bot.Send(chat, photo, opts...)
if err != nil {
return err
}
@@ -85,15 +200,25 @@ func (m *Manager) updateMessage(content string) error {
Chat: chat,
}
_, err = m.bot.Edit(existingMsg, content, tele.ModeMarkdown, tele.NoPreview)
// 编辑图片消息的 caption
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") {
msg, err := m.bot.Send(chat, content, tele.ModeMarkdown, tele.NoPreview)
if strings.Contains(errMsg, "message to edit not found") ||
strings.Contains(errMsg, "no caption") {
opts := []interface{}{tele.ModeHTML}
if markup != nil {
opts = append(opts, markup)
}
msg, err := m.bot.Send(chat, photo, opts...)
if err != nil {
return err
}

View File

@@ -2,63 +2,139 @@ 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 = "📚 <b>频道目录</b>\n━━━━━━━━━━━━━━━\n\n"
footer = "━━━━━━━━━━━━━━━\n"
if len(categories) == 0 {
sb.WriteString("_暂无分类_")
return sb.String(), nil
lines = append(lines, "<i>暂无分类</i>\n")
return header, lines, footer, nil
}
for _, cat := range categories {
entries := entriesByCategory[cat.Name]
sb.WriteString(fmt.Sprintf("📁 **%s**", cat.Name))
catLine := fmt.Sprintf("📁 <b>%s</b>", 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, " <i>暂无内容</i>\n")
} else {
for _, entry := range entries {
sb.WriteString(fmt.Sprintf(" • [%s](%s)\n", escapeMarkdown(entry.Title), entry.Link))
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")
// 分类之间空行
lines = append(lines, "\n")
}
sb.WriteString("━━━━━━━━━━━━━━━\n")
sb.WriteString("_自动生成_")
return sb.String(), nil
return header, lines, footer, nil
}
func escapeMarkdown(s string) string {
replacer := strings.NewReplacer(
"[", "\\[",
"]", "\\]",
"(", "\\(",
")", "\\)",
"*", "\\*",
"_", "\\_",
"`", "\\`",
)
return replacer.Replace(s)
// 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
}