Compare commits
4 Commits
3fea0ee89c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
270369ae0a | ||
|
|
098fd57c19 | ||
|
|
6691dbaff2 | ||
|
|
ee9418b7cf |
19
README.md
19
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 <ID>` | 删除条目(同时删除频道消息) |
|
||||
| `/del <ID>` | 删除条目(可选是否删除频道消息) |
|
||||
| `/edit <ID> <新标题>` | 修改标题 |
|
||||
| `/move <ID> <新分类>` | 移动条目到其他分类 |
|
||||
| `/refresh` | 手动刷新频道目录 |
|
||||
@@ -68,6 +72,7 @@ toc:
|
||||
|------|------|
|
||||
| `/cat_add <名称> [排序]` | 创建分类 |
|
||||
| `/cat_del <名称>` | 删除分类 |
|
||||
| `/cat_rename <旧名称> <新名称>` | 重命名分类(自动更新所有条目) |
|
||||
| `/cat_list` | 列出所有分类 |
|
||||
|
||||
### 管理员管理 (仅超级管理员)
|
||||
@@ -80,14 +85,13 @@ toc:
|
||||
|
||||
## 投稿方式
|
||||
|
||||
### 方式一:交互式流程(私聊 Bot)
|
||||
### 方式一:私聊转发(推荐)
|
||||
|
||||
```
|
||||
1. 私聊 Bot 发送 /post
|
||||
2. 转发任意消息给 Bot(支持来自任意来源:个人、群组、频道等)
|
||||
3. 点击分类按钮
|
||||
4. 确认标题 (使用默认 / 自定义输入)
|
||||
5. 确认添加
|
||||
1. 私聊 Bot,直接转发任意消息(支持来自个人、群组、频道等)
|
||||
2. 点击分类按钮
|
||||
3. 确认标题 (使用默认 / 自定义输入)
|
||||
4. 确认添加
|
||||
```
|
||||
|
||||
Bot 会将消息内容复制发送到频道,目录链接指向频道中的新消息。
|
||||
@@ -113,6 +117,7 @@ Bot 会将消息内容复制发送到频道,目录链接指向频道中的新
|
||||
- ✅ 动图(带说明文字)
|
||||
- ✅ 语音
|
||||
- ✅ 贴纸
|
||||
- ✅ 相册/多文件(自动收集并合并发送)
|
||||
|
||||
## 目录结构
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -51,6 +51,7 @@ 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
|
||||
@@ -97,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> <新分类>"},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2,12 +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 == "" {
|
||||
@@ -19,26 +26,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 {
|
||||
@@ -96,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")
|
||||
}
|
||||
@@ -105,12 +104,12 @@ 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 {
|
||||
@@ -118,6 +117,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 {
|
||||
|
||||
@@ -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)
|
||||
if state == nil {
|
||||
return nil
|
||||
|
||||
// 私聊收到转发消息,直接启动投稿流程(无需先 /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)
|
||||
}
|
||||
}
|
||||
|
||||
// 转发消息处理 (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)
|
||||
}
|
||||
|
||||
// 视频
|
||||
if msg.Video != nil {
|
||||
video := &tele.Video{File: msg.Video.File, Caption: msg.Caption}
|
||||
return b.bot.Send(to, video, tele.ModeHTML)
|
||||
}
|
||||
|
||||
// 文档
|
||||
if msg.Document != nil {
|
||||
doc := &tele.Document{File: msg.Document.File, Caption: msg.Caption}
|
||||
return b.bot.Send(to, doc, tele.ModeHTML)
|
||||
}
|
||||
|
||||
// 音频
|
||||
if msg.Audio != nil {
|
||||
audio := &tele.Audio{File: msg.Audio.File, Caption: msg.Caption}
|
||||
return b.bot.Send(to, audio, tele.ModeHTML)
|
||||
}
|
||||
|
||||
// 语音
|
||||
if msg.Voice != nil {
|
||||
voice := &tele.Voice{File: msg.Voice.File}
|
||||
return b.bot.Send(to, voice)
|
||||
}
|
||||
|
||||
// 贴纸
|
||||
if msg.Sticker != nil {
|
||||
sticker := &tele.Sticker{File: msg.Sticker.File}
|
||||
return b.bot.Send(to, sticker)
|
||||
}
|
||||
|
||||
// 动图
|
||||
if msg.Animation != nil {
|
||||
anim := &tele.Animation{File: msg.Animation.File, Caption: msg.Caption}
|
||||
return b.bot.Send(to, anim, tele.ModeHTML)
|
||||
}
|
||||
|
||||
// 纯文本
|
||||
if msg.Text != "" {
|
||||
return b.bot.Send(to, msg.Text, tele.ModeHTML, tele.NoPreview)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("不支持的消息类型")
|
||||
// 使用 Copy 方法,完整保留原消息的 entities(代码块、粗体、斜体等格式)
|
||||
return b.bot.Copy(to, msg)
|
||||
}
|
||||
|
||||
func buildChannelLink(channelID int64, msgID int) string {
|
||||
chatID := channelID
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ func (m *Manager) updateMessage(content string) error {
|
||||
|
||||
// 纯文本模式
|
||||
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
|
||||
}
|
||||
@@ -93,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()
|
||||
// 内容未变化,忽略
|
||||
@@ -101,7 +101,7 @@ 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
|
||||
}
|
||||
@@ -120,7 +120,7 @@ func (m *Manager) updateWithPhoto(chat *tele.Chat, msgID int, content string) er
|
||||
}
|
||||
|
||||
if msgID == 0 {
|
||||
msg, err := m.bot.Send(chat, photo, tele.ModeMarkdown)
|
||||
msg, err := m.bot.Send(chat, photo, tele.ModeHTML)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func (m *Manager) updateWithPhoto(chat *tele.Chat, msgID int, content string) er
|
||||
}
|
||||
|
||||
// 编辑图片消息的 caption
|
||||
_, err := m.bot.EditCaption(existingMsg, content, tele.ModeMarkdown)
|
||||
_, 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") {
|
||||
@@ -142,7 +142,7 @@ func (m *Manager) updateWithPhoto(chat *tele.Chat, msgID int, content string) er
|
||||
// 旧消息不是图片或找不到,重新发送
|
||||
if strings.Contains(errMsg, "message to edit not found") ||
|
||||
strings.Contains(errMsg, "no caption") {
|
||||
msg, err := m.bot.Send(chat, photo, tele.ModeMarkdown)
|
||||
msg, err := m.bot.Send(chat, photo, tele.ModeHTML)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package toc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -17,28 +18,28 @@ 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")
|
||||
@@ -48,16 +49,3 @@ func (m *Manager) Render() (string, error) {
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func escapeMarkdown(s string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"[", "\\[",
|
||||
"]", "\\]",
|
||||
"(", "\\(",
|
||||
")", "\\)",
|
||||
"*", "\\*",
|
||||
"_", "\\_",
|
||||
"`", "\\`",
|
||||
)
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user