forked from carrydela/mygoTgChanBot
536 lines
14 KiB
Go
536 lines
14 KiB
Go
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
|
||
}
|
||
|
||
// 检查是否为转发消息
|
||
isForwarded := msg.OriginalChat != nil || msg.OriginalSender != nil
|
||
|
||
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()
|
||
// 检查是否为转发消息(来自频道/群组或个人用户)
|
||
if msg.OriginalChat == nil && msg.OriginalSender == nil {
|
||
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 := buildChannelLink(b.cfg.Channel.ID, 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 buildChannelLink(channelID int64, msgID int) string {
|
||
chatID := channelID
|
||
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)
|
||
}
|