Compare commits

..

6 Commits

Author SHA1 Message Date
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
12 changed files with 474 additions and 125 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
config.yaml
/data
bot
mygo_chanbot

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

@@ -29,6 +29,7 @@ type DatabaseConfig struct {
type TOCConfig struct {
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

@@ -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.Link); 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

@@ -3,6 +3,8 @@ package telegram
import (
"fmt"
"strings"
"sync"
"time"
"tgchanbot/internal/storage"
@@ -14,6 +16,14 @@ const (
cbPrefixConfirm = "confirm:"
cbPrefixTitle = "title:"
cbCancel = "cancel"
albumCollectDelay = 500 * time.Millisecond // 等待相册其他消息的时间
)
// 相册收集定时器
var (
albumTimers = make(map[int64]*time.Timer)
albumTimersMu sync.Mutex
)
func (b *Bot) handlePost(c tele.Context) error {
@@ -100,17 +110,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)
// 私聊收到转发消息,直接启动投稿流程(无需先 /post
if isForwarded && b.cfg.IsAdmin(c.Sender().ID) {
if state == nil {
return 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)
@@ -132,13 +148,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 +201,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
@@ -204,6 +293,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,19 +418,27 @@ 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)
link := b.buildChannelLink(channelMsg.ID)
entry, err := b.storage.CreateEntry(state.SelectedCat, title, link)
if err != nil {
@@ -348,60 +453,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)
// 使用 Copy 方法,完整保留原消息的 entities代码块、粗体、斜体等格式
return b.bot.Copy(to, msg)
}
// 视频
if msg.Video != nil {
video := &tele.Video{File: msg.Video.File, Caption: msg.Caption}
return b.bot.Send(to, video, tele.ModeHTML)
// sendAlbumCopy 复制相册发送到频道,返回第一条消息
func (b *Bot) sendAlbumCopy(to *tele.Chat, msgs []*tele.Message) (*tele.Message, error) {
if len(msgs) == 0 {
return nil, fmt.Errorf("相册为空")
}
// 文档
if msg.Document != nil {
doc := &tele.Document{File: msg.Document.File, Caption: msg.Caption}
return b.bot.Send(to, doc, tele.ModeHTML)
// 转换为 Editable 接口切片
editables := make([]tele.Editable, len(msgs))
for i, msg := range msgs {
editables[i] = msg
}
// 音频
if msg.Audio != nil {
audio := &tele.Audio{File: msg.Audio.File, Caption: msg.Caption}
return b.bot.Send(to, audio, tele.ModeHTML)
// 使用 CopyMany 保留原格式(代码块、粗体等 entities
sentMsgs, err := b.bot.CopyMany(to, editables)
if err != nil {
return nil, err
}
// 语音
if msg.Voice != nil {
voice := &tele.Voice{File: msg.Voice.File}
return b.bot.Send(to, voice)
if len(sentMsgs) == 0 {
return nil, fmt.Errorf("发送相册失败")
}
// 贴纸
if msg.Sticker != nil {
sticker := &tele.Sticker{File: msg.Sticker.File}
return b.bot.Send(to, sticker)
return &sentMsgs[0], nil
}
// 动图
if msg.Animation != nil {
anim := &tele.Animation{File: msg.Animation.File, Caption: msg.Caption}
return b.bot.Send(to, anim, tele.ModeHTML)
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)
}
// 纯文本
if msg.Text != "" {
return b.bot.Send(to, msg.Text, tele.ModeHTML, tele.NoPreview)
}
return nil, fmt.Errorf("不支持的消息类型")
}
func buildChannelLink(channelID int64, msgID int) string {
chatID := channelID
// 私有频道 fallback
chatID := b.cfg.Channel.ID
if chatID < 0 {
chatID = -chatID - 1000000000000
}

View File

@@ -18,7 +18,9 @@ const (
type PostState struct {
UserID int64
ForwardedMsg *tele.Message
ForwardedMsg *tele.Message // 单条消息
ForwardedMsgs []*tele.Message // 相册消息
AlbumID string // 相册ID
SelectedCat string
Title string
Step PostStep
@@ -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

@@ -16,6 +16,7 @@ type Manager struct {
bot *tele.Bot
chanID int64
debounce time.Duration
coverImage string
mu sync.Mutex
pending bool
@@ -23,12 +24,13 @@ 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,
coverImage: coverImage,
stopCh: make(chan struct{}),
}
}
@@ -72,8 +74,14 @@ func (m *Manager) updateMessage(content string) error {
return err
}
// 带封面图片模式
if m.coverImage != "" {
return m.updateWithPhoto(chat, msgID, content)
}
// 纯文本模式
if msgID == 0 {
msg, err := m.bot.Send(chat, content, tele.ModeMarkdown, tele.NoPreview)
msg, err := m.bot.Send(chat, content, tele.ModeHTML, tele.NoPreview)
if err != nil {
return err
}
@@ -85,7 +93,7 @@ func (m *Manager) updateMessage(content string) error {
Chat: chat,
}
_, err = m.bot.Edit(existingMsg, content, tele.ModeMarkdown, tele.NoPreview)
_, err = m.bot.Edit(existingMsg, content, tele.ModeHTML, tele.NoPreview)
if err != nil {
errMsg := err.Error()
// 内容未变化,忽略
@@ -93,7 +101,48 @@ func (m *Manager) updateMessage(content string) error {
return nil
}
if strings.Contains(errMsg, "message to edit not found") {
msg, err := m.bot.Send(chat, content, tele.ModeMarkdown, tele.NoPreview)
msg, err := m.bot.Send(chat, content, tele.ModeHTML, tele.NoPreview)
if err != nil {
return err
}
return m.storage.SetTocMsgID(msg.ID)
}
return err
}
return nil
}
func (m *Manager) updateWithPhoto(chat *tele.Chat, msgID int, content string) error {
photo := &tele.Photo{
File: tele.FromDisk(m.coverImage),
Caption: content,
}
if msgID == 0 {
msg, err := m.bot.Send(chat, photo, tele.ModeHTML)
if err != nil {
return err
}
return m.storage.SetTocMsgID(msg.ID)
}
existingMsg := &tele.Message{
ID: msgID,
Chat: chat,
}
// 编辑图片消息的 caption
_, err := m.bot.EditCaption(existingMsg, content, tele.ModeHTML)
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)
if err != nil {
return err
}

View File

@@ -2,6 +2,7 @@ package toc
import (
"fmt"
"html"
"strings"
)
@@ -17,48 +18,34 @@ func (m *Manager) Render() (string, error) {
}
var sb strings.Builder
sb.WriteString("📚 **频道目录**\n")
sb.WriteString("📚 <b>频道目录</b>\n")
sb.WriteString("━━━━━━━━━━━━━━━\n\n")
if len(categories) == 0 {
sb.WriteString("_暂无分类_")
sb.WriteString("<i>暂无分类</i>")
return sb.String(), nil
}
for _, cat := range categories {
entries := entriesByCategory[cat.Name]
sb.WriteString(fmt.Sprintf("📁 **%s**", cat.Name))
sb.WriteString(fmt.Sprintf("📁 <b>%s</b>", html.EscapeString(cat.Name)))
if len(entries) > 0 {
sb.WriteString(fmt.Sprintf(" (%d)", len(entries)))
}
sb.WriteString("\n")
if len(entries) == 0 {
sb.WriteString(" _暂无内容_\n")
sb.WriteString(" <i>暂无内容</i>\n")
} else {
for _, entry := range entries {
sb.WriteString(fmt.Sprintf(" • [%s](%s)\n", escapeMarkdown(entry.Title), entry.Link))
sb.WriteString(fmt.Sprintf(" • <a href=\"%s\">%s</a>\n", entry.Link, html.EscapeString(entry.Title)))
}
}
sb.WriteString("\n")
}
sb.WriteString("━━━━━━━━━━━━━━━\n")
sb.WriteString("_自动生成_")
return sb.String(), nil
}
func escapeMarkdown(s string) string {
replacer := strings.NewReplacer(
"[", "\\[",
"]", "\\]",
"(", "\\(",
")", "\\)",
"*", "\\*",
"_", "\\_",
"`", "\\`",
)
return replacer.Replace(s)
}