From 6691dbaff290e601f6c42d33928d51ddf989db0f Mon Sep 17 00:00:00 2001 From: dela Date: Thu, 5 Feb 2026 11:07:37 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=8A=9F=E8=83=BD=EF=BC=9A?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E9=80=89=E6=8B=A9=EF=BC=9B=E7=9B=B8=E5=86=8C?= =?UTF-8?q?=E8=BD=AC=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 +- internal/telegram/handlers_entry.go | 76 ++++++++++---- internal/telegram/handlers_post.go | 154 ++++++++++++++++++++++++++-- internal/telegram/state.go | 59 +++++++++-- 4 files changed, 264 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index addee6a..e67c380 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ - **交互式按钮投稿** - Inline Keyboard 选择分类 - **快捷回复投稿** - 回复消息直接添加到目录 - **内容同步发布** - 转发的内容会复制发送到频道(非转发,可编辑) +- **相册/多文件支持** - 自动收集相册消息,一次性发送到频道 +- **目录封面图** - 可配置封面图片美化目录展示 +- **交互式删除** - 删除条目时可选择是否同步删除频道消息 - **防抖目录更新** - 多次操作合并为一次渲染,避免限流 ## 安装 @@ -38,6 +41,7 @@ database: toc: debounce_seconds: 3 + cover_image: "./assets/cover.jpg" # 可选,目录封面图片 ``` ## 运行 @@ -57,7 +61,7 @@ toc: | `/post` | 开始投稿流程 | | `/post <分类> /tt <标题>` | 快捷投稿 (回复消息时使用) | | `/list [分类]` | 查看条目 | -| `/del ` | 删除条目(同时删除频道消息) | +| `/del ` | 删除条目(可选是否删除频道消息) | | `/edit <新标题>` | 修改标题 | | `/move <新分类>` | 移动条目到其他分类 | | `/refresh` | 手动刷新频道目录 | @@ -112,6 +116,7 @@ Bot 会将消息内容复制发送到频道,目录链接指向频道中的新 - ✅ 动图(带说明文字) - ✅ 语音 - ✅ 贴纸 +- ✅ 相册/多文件(自动收集并合并发送) ## 目录结构 diff --git a/internal/telegram/handlers_entry.go b/internal/telegram/handlers_entry.go index 82f8a1f..37c963e 100644 --- a/internal/telegram/handlers_entry.go +++ b/internal/telegram/handlers_entry.go @@ -8,6 +8,12 @@ import ( 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 == "" { @@ -19,26 +25,18 @@ func (b *Bot) handleEntryDel(c tele.Context) error { return c.Reply(fmt.Sprintf("❌ %v", err)) } - // 尝试删除频道消息 - msgDeleted := false - 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 - } - } + 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), + ) - if err := b.storage.DeleteEntry(id); err != nil { - return c.Reply(fmt.Sprintf("❌ 删除失败: %v", err)) - } - - 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)) + 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 { @@ -118,6 +116,46 @@ func (b *Bot) handleRefresh(c tele.Context) error { 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 { diff --git a/internal/telegram/handlers_post.go b/internal/telegram/handlers_post.go index 99a954e..5820a72 100644 --- a/internal/telegram/handlers_post.go +++ b/internal/telegram/handlers_post.go @@ -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 { @@ -107,8 +117,10 @@ func (b *Bot) handleTextInput(c tele.Context) error { // 私聊收到转发消息,直接启动投稿流程(无需先 /post) if isForwarded && b.cfg.IsAdmin(c.Sender().ID) { - if state == nil || state.Step == StepAwaitForward { + if state == nil { b.states.StartPost(c.Sender().ID) + } + if state == nil || state.Step == StepAwaitForward { return b.handleForwarded(c) } } @@ -141,8 +153,43 @@ func (b *Bot) handleForwarded(c tele.Context) error { 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) @@ -154,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 @@ -208,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() @@ -325,12 +418,20 @@ 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: "发送失败"}) @@ -404,6 +505,47 @@ func (b *Bot) sendMessageCopy(to *tele.Chat, msg *tele.Message) (*tele.Message, 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 { chatID := channelID if chatID < 0 { diff --git a/internal/telegram/state.go b/internal/telegram/state.go index b6059ae..c9c1ba2 100644 --- a/internal/telegram/state.go +++ b/internal/telegram/state.go @@ -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 }