增加功能:删除选择;相册转发

This commit is contained in:
dela
2026-02-05 11:07:37 +08:00
parent ee9418b7cf
commit 6691dbaff2
4 changed files with 264 additions and 32 deletions

View File

@@ -10,6 +10,9 @@
- **交互式按钮投稿** - Inline Keyboard 选择分类 - **交互式按钮投稿** - Inline Keyboard 选择分类
- **快捷回复投稿** - 回复消息直接添加到目录 - **快捷回复投稿** - 回复消息直接添加到目录
- **内容同步发布** - 转发的内容会复制发送到频道(非转发,可编辑) - **内容同步发布** - 转发的内容会复制发送到频道(非转发,可编辑)
- **相册/多文件支持** - 自动收集相册消息,一次性发送到频道
- **目录封面图** - 可配置封面图片美化目录展示
- **交互式删除** - 删除条目时可选择是否同步删除频道消息
- **防抖目录更新** - 多次操作合并为一次渲染,避免限流 - **防抖目录更新** - 多次操作合并为一次渲染,避免限流
## 安装 ## 安装
@@ -38,6 +41,7 @@ database:
toc: toc:
debounce_seconds: 3 debounce_seconds: 3
cover_image: "./assets/cover.jpg" # 可选,目录封面图片
``` ```
## 运行 ## 运行
@@ -57,7 +61,7 @@ toc:
| `/post` | 开始投稿流程 | | `/post` | 开始投稿流程 |
| `/post <分类> /tt <标题>` | 快捷投稿 (回复消息时使用) | | `/post <分类> /tt <标题>` | 快捷投稿 (回复消息时使用) |
| `/list [分类]` | 查看条目 | | `/list [分类]` | 查看条目 |
| `/del <ID>` | 删除条目(同时删除频道消息) | | `/del <ID>` | 删除条目(可选是否删除频道消息) |
| `/edit <ID> <新标题>` | 修改标题 | | `/edit <ID> <新标题>` | 修改标题 |
| `/move <ID> <新分类>` | 移动条目到其他分类 | | `/move <ID> <新分类>` | 移动条目到其他分类 |
| `/refresh` | 手动刷新频道目录 | | `/refresh` | 手动刷新频道目录 |
@@ -112,6 +116,7 @@ Bot 会将消息内容复制发送到频道,目录链接指向频道中的新
- ✅ 动图(带说明文字) - ✅ 动图(带说明文字)
- ✅ 语音 - ✅ 语音
- ✅ 贴纸 - ✅ 贴纸
- ✅ 相册/多文件(自动收集并合并发送)
## 目录结构 ## 目录结构

View File

@@ -8,6 +8,12 @@ import (
tele "gopkg.in/telebot.v3" tele "gopkg.in/telebot.v3"
) )
const (
cbPrefixDelEntry = "del:"
cbPrefixDelWithMsg = "delwithmsg:"
cbPrefixDelOnlyToc = "delonlytoc:"
)
func (b *Bot) handleEntryDel(c tele.Context) error { func (b *Bot) handleEntryDel(c tele.Context) error {
id := strings.TrimSpace(c.Message().Payload) id := strings.TrimSpace(c.Message().Payload)
if id == "" { if id == "" {
@@ -19,26 +25,18 @@ func (b *Bot) handleEntryDel(c tele.Context) error {
return c.Reply(fmt.Sprintf("❌ %v", err)) return c.Reply(fmt.Sprintf("❌ %v", err))
} }
// 尝试删除频道消息 menu := &tele.ReplyMarkup{}
msgDeleted := false delWithMsgBtn := menu.Data("🗑 删除条目+频道消息", cbPrefixDelWithMsg+id)
if msgID := parseMessageIDFromLink(entry.Link); msgID != 0 { delOnlyTocBtn := menu.Data("📝 仅删除目录条目", cbPrefixDelOnlyToc+id)
channel := &tele.Chat{ID: b.cfg.Channel.ID} cancelBtn := menu.Data("❌ 取消", cbCancel)
msg := &tele.Message{ID: msgID, Chat: channel} menu.Inline(
if err := b.bot.Delete(msg); err == nil { menu.Row(delWithMsgBtn),
msgDeleted = true menu.Row(delOnlyTocBtn),
} menu.Row(cancelBtn),
} )
if err := b.storage.DeleteEntry(id); err != nil { text := fmt.Sprintf("🗑 确认删除?\n\n分类: %s\n标题: %s\n\n请选择删除方式:", entry.Category, entry.Title)
return c.Reply(fmt.Sprintf("❌ 删除失败: %v", err)) return c.Reply(text, menu)
}
b.toc.TriggerUpdate()
if msgDeleted {
return c.Reply(fmt.Sprintf("✅ 已删除: [%s] %s\n📨 频道消息已同步删除", entry.Category, entry.Title))
}
return c.Reply(fmt.Sprintf("✅ 已删除: [%s] %s", entry.Category, entry.Title))
} }
func (b *Bot) handleEntryEdit(c tele.Context) error { func (b *Bot) handleEntryEdit(c tele.Context) error {
@@ -118,6 +116,46 @@ func (b *Bot) handleRefresh(c tele.Context) error {
return c.Reply("🔄 目录刷新已触发") 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 // parseMessageIDFromLink 从链接中解析消息ID
// 支持格式: https://t.me/c/123456/789 或 https://t.me/username/789 // 支持格式: https://t.me/c/123456/789 或 https://t.me/username/789
func parseMessageIDFromLink(link string) int { func parseMessageIDFromLink(link string) int {

View File

@@ -3,6 +3,8 @@ package telegram
import ( import (
"fmt" "fmt"
"strings" "strings"
"sync"
"time"
"tgchanbot/internal/storage" "tgchanbot/internal/storage"
@@ -14,6 +16,14 @@ const (
cbPrefixConfirm = "confirm:" cbPrefixConfirm = "confirm:"
cbPrefixTitle = "title:" cbPrefixTitle = "title:"
cbCancel = "cancel" cbCancel = "cancel"
albumCollectDelay = 500 * time.Millisecond // 等待相册其他消息的时间
)
// 相册收集定时器
var (
albumTimers = make(map[int64]*time.Timer)
albumTimersMu sync.Mutex
) )
func (b *Bot) handlePost(c tele.Context) error { func (b *Bot) handlePost(c tele.Context) error {
@@ -107,8 +117,10 @@ func (b *Bot) handleTextInput(c tele.Context) error {
// 私聊收到转发消息,直接启动投稿流程(无需先 /post // 私聊收到转发消息,直接启动投稿流程(无需先 /post
if isForwarded && b.cfg.IsAdmin(c.Sender().ID) { if isForwarded && b.cfg.IsAdmin(c.Sender().ID) {
if state == nil || state.Step == StepAwaitForward { if state == nil {
b.states.StartPost(c.Sender().ID) b.states.StartPost(c.Sender().ID)
}
if state == nil || state.Step == StepAwaitForward {
return b.handleForwarded(c) return b.handleForwarded(c)
} }
} }
@@ -141,8 +153,43 @@ func (b *Bot) handleForwarded(c tele.Context) error {
return c.Reply("❌ 这不是一条转发消息,请转发一条消息给我") 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() categories, err := b.storage.ListCategories()
if err != nil { if err != nil {
b.states.Delete(c.Sender().ID) b.states.Delete(c.Sender().ID)
@@ -154,10 +201,48 @@ func (b *Bot) handleForwarded(c tele.Context) error {
return c.Reply("❌ 暂无分类,请先使用 /cat_add 创建分类") 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) keyboard := b.buildCategoryKeyboard(categories)
if msgCount > 1 {
return c.Reply(fmt.Sprintf("📁 已收到 %d 条消息(相册),请选择分类:", msgCount), keyboard)
}
return c.Reply("📁 请选择分类:", 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 { func (b *Bot) buildCategoryKeyboard(categories []storage.Category) *tele.ReplyMarkup {
menu := &tele.ReplyMarkup{} menu := &tele.ReplyMarkup{}
var rows []tele.Row var rows []tele.Row
@@ -208,6 +293,14 @@ func (b *Bot) handleCallback(c tele.Context) error {
case strings.HasPrefix(data, cbPrefixConfirm): case strings.HasPrefix(data, cbPrefixConfirm):
action := strings.TrimPrefix(data, cbPrefixConfirm) action := strings.TrimPrefix(data, cbPrefixConfirm)
return b.handleConfirmCallback(c, userID, action) 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() return c.Respond()
@@ -325,12 +418,20 @@ func (b *Bot) handleConfirmCallback(c tele.Context, userID int64, action string)
defer b.states.Delete(userID) defer b.states.Delete(userID)
msg := state.ForwardedMsg
title := state.Title title := state.Title
// 复制消息内容发送到频道(非转发,可编辑)
channel := &tele.Chat{ID: b.cfg.Channel.ID} 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 { 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: "发送失败"})
@@ -404,6 +505,47 @@ func (b *Bot) sendMessageCopy(to *tele.Chat, msg *tele.Message) (*tele.Message,
return nil, fmt.Errorf("不支持的消息类型") return nil, fmt.Errorf("不支持的消息类型")
} }
// sendAlbumCopy 复制相册发送到频道,返回第一条消息
func (b *Bot) sendAlbumCopy(to *tele.Chat, msgs []*tele.Message) (*tele.Message, error) {
if len(msgs) == 0 {
return nil, fmt.Errorf("相册为空")
}
var album tele.Album
for i, msg := range msgs {
var caption string
if i == 0 {
// 只有第一条消息带 caption
caption = msg.Caption
}
if msg.Photo != nil {
album = append(album, &tele.Photo{File: msg.Photo.File, Caption: caption})
} else if msg.Video != nil {
album = append(album, &tele.Video{File: msg.Video.File, Caption: caption})
} else if msg.Document != nil {
album = append(album, &tele.Document{File: msg.Document.File, Caption: caption})
} else if msg.Audio != nil {
album = append(album, &tele.Audio{File: msg.Audio.File, Caption: caption})
}
}
if len(album) == 0 {
return nil, fmt.Errorf("相册中没有支持的媒体类型")
}
sentMsgs, err := b.bot.SendAlbum(to, album, tele.ModeHTML)
if err != nil {
return nil, err
}
if len(sentMsgs) == 0 {
return nil, fmt.Errorf("发送相册失败")
}
return &sentMsgs[0], nil
}
func buildChannelLink(channelID int64, msgID int) string { func buildChannelLink(channelID int64, msgID int) string {
chatID := channelID chatID := channelID
if chatID < 0 { if chatID < 0 {

View File

@@ -17,12 +17,14 @@ const (
) )
type PostState struct { type PostState struct {
UserID int64 UserID int64
ForwardedMsg *tele.Message ForwardedMsg *tele.Message // 单条消息
SelectedCat string ForwardedMsgs []*tele.Message // 相册消息
Title string AlbumID string // 相册ID
Step PostStep SelectedCat string
CreatedAt time.Time Title string
Step PostStep
CreatedAt time.Time
} }
type StateManager struct { type StateManager struct {
@@ -74,6 +76,51 @@ func (sm *StateManager) SetForwarded(userID int64, msg *tele.Message) *PostState
} }
state.ForwardedMsg = msg 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 state.Step = StepAwaitCategory
return state return state
} }