Files
mygoTgChanBot/internal/telegram/handlers_post.go
2026-02-05 21:29:48 +08:00

543 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package telegram
import (
"fmt"
"strings"
"sync"
"time"
"tgchanbot/internal/storage"
tele "gopkg.in/telebot.v3"
)
const (
cbPrefixCat = "cat:"
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 {
msg := c.Message()
payload := strings.TrimSpace(c.Message().Payload)
// 快捷方式: 回复消息 + /post <分类> /tt <标题>
if msg.ReplyTo != nil && payload != "" {
return b.handleQuickPost(c, msg.ReplyTo, payload)
}
// 常规交互流程
b.states.StartPost(c.Sender().ID)
return c.Reply("📨 请转发需要归档到目录的消息\n\n消息将被同步发送到频道并添加到目录\n\n💡 快捷方式: 回复消息并发送\n`/post <分类> /tt <标题>`", tele.ModeMarkdown)
}
func (b *Bot) handleQuickPost(c tele.Context, replyMsg *tele.Message, payload string) error {
// 解析: <分类> /tt <标题>
parts := strings.SplitN(payload, "/tt", 2)
category := strings.TrimSpace(parts[0])
var title string
if len(parts) > 1 {
title = strings.TrimSpace(parts[1])
}
if category == "" {
return c.Reply("❌ 用法: /post <分类> /tt <标题>\n例如: /post iOS /tt 某个APP推荐")
}
// 验证分类存在
if !b.storage.CategoryExists(category) {
categories, _ := b.storage.ListCategories()
var names []string
for _, cat := range categories {
names = append(names, cat.Name)
}
return c.Reply(fmt.Sprintf("❌ 分类 [%s] 不存在\n\n可用分类: %s", category, strings.Join(names, ", ")))
}
// 标题: 优先使用指定标题,否则从回复消息提取
if title == "" {
title = extractTitle(replyMsg)
}
// 构建链接
link := buildMessageLinkFromReply(c.Message(), replyMsg)
// 创建条目
entry, err := b.storage.CreateEntry(category, title, link)
if err != nil {
return c.Reply(fmt.Sprintf("❌ 保存失败: %v", err))
}
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)
}
func buildMessageLinkFromReply(currentMsg, replyMsg *tele.Message) string {
chat := currentMsg.Chat
msgID := replyMsg.ID
if chat.Username != "" {
return fmt.Sprintf("https://t.me/%s/%d", chat.Username, msgID)
}
chatID := chat.ID
if chatID < 0 {
chatID = -chatID - 1000000000000
}
return fmt.Sprintf("https://t.me/c/%d/%d", chatID, msgID)
}
func (b *Bot) handleTextInput(c tele.Context) error {
msg := c.Message()
if msg == nil {
return nil
}
// 只处理私聊消息,忽略群组对话
if c.Chat().Type != tele.ChatPrivate {
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 {
b.states.StartPost(c.Sender().ID)
}
if state == nil || state.Step == StepAwaitForward {
return b.handleForwarded(c)
}
}
if state == nil {
return nil
}
// 自定义标题输入 (StepAwaitTitle)
if state.Step == StepAwaitTitle {
return b.handleTitleInput(c)
}
return nil
}
func (b *Bot) handleForwarded(c tele.Context) error {
if !b.cfg.IsAdmin(c.Sender().ID) {
return nil
}
state := b.states.Get(c.Sender().ID)
if state == nil || state.Step != StepAwaitForward {
return nil
}
msg := c.Message()
// 检查是否为转发消息OriginalUnixtime 在隐私设置隐藏来源时仍存在)
if msg.OriginalChat == nil && msg.OriginalSender == nil && msg.OriginalUnixtime == 0 {
return c.Reply("❌ 这不是一条转发消息,请转发一条消息给我")
}
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)
return c.Reply(fmt.Sprintf("❌ 获取分类失败: %v", err))
}
if len(categories) == 0 {
b.states.Delete(c.Sender().ID)
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
var currentRow []tele.Btn
for _, cat := range categories {
btn := menu.Data(cat.Name, cbPrefixCat+cat.Name)
currentRow = append(currentRow, btn)
if len(currentRow) == 3 {
rows = append(rows, menu.Row(currentRow...))
currentRow = nil
}
}
if len(currentRow) > 0 {
rows = append(rows, menu.Row(currentRow...))
}
cancelBtn := menu.Data("❌ 取消", cbCancel)
rows = append(rows, menu.Row(cancelBtn))
menu.Inline(rows...)
return menu
}
func (b *Bot) handleCallback(c tele.Context) error {
if !b.cfg.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 {
case data == cbCancel:
return b.handleCancelCallback(c, userID)
case strings.HasPrefix(data, cbPrefixCat):
category := strings.TrimPrefix(data, cbPrefixCat)
return b.handleCategoryCallback(c, userID, category)
case strings.HasPrefix(data, cbPrefixTitle):
action := strings.TrimPrefix(data, cbPrefixTitle)
return b.handleTitleCallback(c, userID, action)
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()
}
func (b *Bot) handleCancelCallback(c tele.Context, userID int64) error {
b.states.Delete(userID)
c.Edit("❌ 已取消")
return c.Respond(&tele.CallbackResponse{Text: "已取消"})
}
func (b *Bot) handleTitleCallback(c tele.Context, userID int64, action string) error {
state := b.states.Get(userID)
if state == nil || state.Step != StepAwaitTitle {
return c.Respond(&tele.CallbackResponse{Text: "会话已过期"})
}
if action == "default" {
// 使用默认标题
title := extractTitle(state.ForwardedMsg)
b.states.SetTitle(userID, title)
return b.showConfirmation(c, state, title)
}
// 自定义标题 - 提示用户输入
c.Edit("✏️ 请发送新标题:")
return c.Respond()
}
func (b *Bot) handleTitleInput(c tele.Context) error {
state := b.states.Get(c.Sender().ID)
if state == nil || state.Step != StepAwaitTitle {
return nil
}
title := strings.TrimSpace(c.Message().Text)
if title == "" {
return c.Reply("❌ 标题不能为空,请重新输入:")
}
if len(title) > 50 {
title = title[:47] + "..."
}
b.states.SetTitle(c.Sender().ID, title)
return b.showConfirmationMsg(c, state, title)
}
func (b *Bot) showConfirmation(c tele.Context, state *PostState, title string) error {
channelName := "未知频道"
if state.ForwardedMsg.OriginalChat != nil {
channelName = state.ForwardedMsg.OriginalChat.Title
}
menu := &tele.ReplyMarkup{}
confirmBtn := menu.Data("✅ 确认添加", cbPrefixConfirm+"yes")
cancelBtn := menu.Data("❌ 取消", cbCancel)
menu.Inline(menu.Row(confirmBtn, cancelBtn))
text := fmt.Sprintf("📋 确认添加?\n\n频道: %s\n分类: %s\n标题: %s", channelName, state.SelectedCat, title)
c.Edit(text, menu)
return c.Respond()
}
func (b *Bot) showConfirmationMsg(c tele.Context, state *PostState, title string) error {
channelName := "未知频道"
if state.ForwardedMsg.OriginalChat != nil {
channelName = state.ForwardedMsg.OriginalChat.Title
}
menu := &tele.ReplyMarkup{}
confirmBtn := menu.Data("✅ 确认添加", cbPrefixConfirm+"yes")
cancelBtn := menu.Data("❌ 取消", cbCancel)
menu.Inline(menu.Row(confirmBtn, cancelBtn))
text := fmt.Sprintf("📋 确认添加?\n\n频道: %s\n分类: %s\n标题: %s", channelName, state.SelectedCat, title)
return c.Reply(text, menu)
}
func (b *Bot) handleCategoryCallback(c tele.Context, userID int64, category string) error {
state := b.states.Get(userID)
if state == nil || state.Step != StepAwaitCategory {
return c.Respond(&tele.CallbackResponse{Text: "会话已过期"})
}
b.states.SetCategory(userID, category)
// 提取默认标题
defaultTitle := extractTitle(state.ForwardedMsg)
menu := &tele.ReplyMarkup{}
useDefaultBtn := menu.Data("✅ 使用此标题", cbPrefixTitle+"default")
customBtn := menu.Data("✏️ 自定义标题", cbPrefixTitle+"custom")
cancelBtn := menu.Data("❌ 取消", cbCancel)
menu.Inline(
menu.Row(useDefaultBtn),
menu.Row(customBtn),
menu.Row(cancelBtn),
)
text := fmt.Sprintf("📝 确认标题\n\n分类: %s\n标题: %s\n\n使用此标题或自定义?", category, defaultTitle)
c.Edit(text, menu)
return c.Respond()
}
func (b *Bot) handleConfirmCallback(c tele.Context, userID int64, action string) error {
if action != "yes" {
return b.handleCancelCallback(c, userID)
}
state := b.states.Get(userID)
if state == nil || state.Step != StepAwaitConfirm {
return c.Respond(&tele.CallbackResponse{Text: "会话已过期"})
}
defer b.states.Delete(userID)
title := state.Title
channel := &tele.Chat{ID: b.cfg.Channel.ID}
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 := b.buildChannelLink(channelMsg.ID)
entry, err := b.storage.CreateEntry(state.SelectedCat, title, link)
if err != nil {
c.Edit(fmt.Sprintf("❌ 保存失败: %v", err))
return c.Respond(&tele.CallbackResponse{Text: "保存失败"})
}
b.toc.TriggerUpdate()
text := fmt.Sprintf("✅ 已添加\n\nID: %s\n分类: %s\n标题: %s", entry.ID, entry.Category, entry.Title)
c.Edit(text)
return c.Respond(&tele.CallbackResponse{Text: "添加成功"})
}
// sendMessageCopy 复制消息内容发送(使用 copyMessage API 保留原格式)
func (b *Bot) sendMessageCopy(to *tele.Chat, msg *tele.Message) (*tele.Message, error) {
// 使用 Copy 方法,完整保留原消息的 entities代码块、粗体、斜体等格式
return b.bot.Copy(to, msg)
}
// 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
}
return fmt.Sprintf("https://t.me/c/%d/%d", chatID, msgID)
}
func extractTitle(msg *tele.Message) string {
text := msg.Text
if text == "" {
text = msg.Caption
}
lines := strings.Split(text, "\n")
title := strings.TrimSpace(lines[0])
if len(title) > 50 {
title = title[:47] + "..."
}
if title == "" {
title = "无标题"
}
return title
}
func buildMessageLink(msg *tele.Message) string {
if msg.OriginalChat == nil {
return ""
}
chat := msg.OriginalChat
msgID := msg.OriginalMessageID
if msgID == 0 {
msgID = msg.ID
}
if chat.Username != "" {
return fmt.Sprintf("https://t.me/%s/%d", chat.Username, msgID)
}
chatID := chat.ID
if chatID < 0 {
chatID = -chatID - 1000000000000
}
return fmt.Sprintf("https://t.me/c/%d/%d", chatID, msgID)
}