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 选择分类
|
- **交互式按钮投稿** - 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` | 手动刷新频道目录 |
|
||||||
@@ -68,6 +72,7 @@ toc:
|
|||||||
|------|------|
|
|------|------|
|
||||||
| `/cat_add <名称> [排序]` | 创建分类 |
|
| `/cat_add <名称> [排序]` | 创建分类 |
|
||||||
| `/cat_del <名称>` | 删除分类 |
|
| `/cat_del <名称>` | 删除分类 |
|
||||||
|
| `/cat_rename <旧名称> <新名称>` | 重命名分类(自动更新所有条目) |
|
||||||
| `/cat_list` | 列出所有分类 |
|
| `/cat_list` | 列出所有分类 |
|
||||||
|
|
||||||
### 管理员管理 (仅超级管理员)
|
### 管理员管理 (仅超级管理员)
|
||||||
@@ -80,14 +85,13 @@ toc:
|
|||||||
|
|
||||||
## 投稿方式
|
## 投稿方式
|
||||||
|
|
||||||
### 方式一:交互式流程(私聊 Bot)
|
### 方式一:私聊转发(推荐)
|
||||||
|
|
||||||
```
|
```
|
||||||
1. 私聊 Bot 发送 /post
|
1. 私聊 Bot,直接转发任意消息(支持来自个人、群组、频道等)
|
||||||
2. 转发任意消息给 Bot(支持来自任意来源:个人、群组、频道等)
|
2. 点击分类按钮
|
||||||
3. 点击分类按钮
|
3. 确认标题 (使用默认 / 自定义输入)
|
||||||
4. 确认标题 (使用默认 / 自定义输入)
|
4. 确认添加
|
||||||
5. 确认添加
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Bot 会将消息内容复制发送到频道,目录链接指向频道中的新消息。
|
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 {
|
func (s *Storage) CategoryExists(name string) bool {
|
||||||
exists := false
|
exists := false
|
||||||
s.db.View(func(tx *bolt.Tx) error {
|
s.db.View(func(tx *bolt.Tx) error {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ func (b *Bot) setupRoutes() {
|
|||||||
// Category management
|
// Category management
|
||||||
adminOnly.Handle("/cat_add", b.handleCatAdd)
|
adminOnly.Handle("/cat_add", b.handleCatAdd)
|
||||||
adminOnly.Handle("/cat_del", b.handleCatDel)
|
adminOnly.Handle("/cat_del", b.handleCatDel)
|
||||||
|
adminOnly.Handle("/cat_rename", b.handleCatRename)
|
||||||
adminOnly.Handle("/cat_list", b.handleCatList)
|
adminOnly.Handle("/cat_list", b.handleCatList)
|
||||||
|
|
||||||
// Post flow
|
// Post flow
|
||||||
@@ -97,6 +98,7 @@ func (b *Bot) setCommands() {
|
|||||||
{Text: "cat_list", Description: "分类列表"},
|
{Text: "cat_list", Description: "分类列表"},
|
||||||
{Text: "cat_add", Description: "添加分类 <名称> [排序]"},
|
{Text: "cat_add", Description: "添加分类 <名称> [排序]"},
|
||||||
{Text: "cat_del", Description: "删除分类 <名称>"},
|
{Text: "cat_del", Description: "删除分类 <名称>"},
|
||||||
|
{Text: "cat_rename", Description: "重命名分类 <旧名称> <新名称>"},
|
||||||
{Text: "del", Description: "删除条目 <ID>"},
|
{Text: "del", Description: "删除条目 <ID>"},
|
||||||
{Text: "edit", Description: "编辑标题 <ID> <新标题>"},
|
{Text: "edit", Description: "编辑标题 <ID> <新标题>"},
|
||||||
{Text: "move", 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))
|
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 {
|
func (b *Bot) handleCatList(c tele.Context) error {
|
||||||
categories, err := b.storage.ListCategories()
|
categories, err := b.storage.ListCategories()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2,12 +2,19 @@ package telegram
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
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 +26,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 {
|
||||||
@@ -96,7 +95,7 @@ func (b *Bot) handleEntryList(c tele.Context) error {
|
|||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
if category != "" {
|
if category != "" {
|
||||||
sb.WriteString(fmt.Sprintf("📋 分类 [%s] 的条目:\n\n", category))
|
sb.WriteString(fmt.Sprintf("📋 分类 [%s] 的条目:\n\n", html.EscapeString(category)))
|
||||||
} else {
|
} else {
|
||||||
sb.WriteString("📋 所有条目:\n\n")
|
sb.WriteString("📋 所有条目:\n\n")
|
||||||
}
|
}
|
||||||
@@ -105,12 +104,12 @@ func (b *Bot) handleEntryList(c tele.Context) error {
|
|||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if category == "" && e.Category != currentCat {
|
if category == "" && e.Category != currentCat {
|
||||||
currentCat = e.Category
|
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 {
|
func (b *Bot) handleRefresh(c tele.Context) error {
|
||||||
@@ -118,6 +117,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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -100,17 +110,23 @@ func (b *Bot) handleTextInput(c tele.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只处理有活跃投稿状态的用户
|
// 检查是否为转发消息(OriginalUnixtime 在隐私设置隐藏来源时仍存在)
|
||||||
|
isForwarded := msg.OriginalChat != nil || msg.OriginalSender != nil || msg.OriginalUnixtime > 0
|
||||||
|
|
||||||
state := b.states.Get(c.Sender().ID)
|
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)
|
if state == nil {
|
||||||
// OriginalChat: 转发自频道/群组; OriginalSender: 转发自个人用户
|
return nil
|
||||||
isForwarded := msg.OriginalChat != nil || msg.OriginalSender != nil
|
|
||||||
if isForwarded && state.Step == StepAwaitForward {
|
|
||||||
return b.handleForwarded(c)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自定义标题输入 (StepAwaitTitle)
|
// 自定义标题输入 (StepAwaitTitle)
|
||||||
@@ -132,13 +148,48 @@ func (b *Bot) handleForwarded(c tele.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
msg := c.Message()
|
msg := c.Message()
|
||||||
// 检查是否为转发消息(来自频道/群组或个人用户)
|
// 检查是否为转发消息(OriginalUnixtime 在隐私设置隐藏来源时仍存在)
|
||||||
if msg.OriginalChat == nil && msg.OriginalSender == nil {
|
if msg.OriginalChat == nil && msg.OriginalSender == nil && msg.OriginalUnixtime == 0 {
|
||||||
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)
|
||||||
@@ -150,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
|
||||||
@@ -204,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()
|
||||||
@@ -321,19 +418,27 @@ 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: "发送失败"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用频道消息的链接
|
// 使用频道消息的链接
|
||||||
link := buildChannelLink(b.cfg.Channel.ID, channelMsg.ID)
|
link := b.buildChannelLink(channelMsg.ID)
|
||||||
|
|
||||||
entry, err := b.storage.CreateEntry(state.SelectedCat, title, link)
|
entry, err := b.storage.CreateEntry(state.SelectedCat, title, link)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -348,60 +453,46 @@ func (b *Bot) handleConfirmCallback(c tele.Context, userID int64, action string)
|
|||||||
return c.Respond(&tele.CallbackResponse{Text: "添加成功"})
|
return c.Respond(&tele.CallbackResponse{Text: "添加成功"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendMessageCopy 复制消息内容发送(非转发,可编辑)
|
// sendMessageCopy 复制消息内容发送(使用 copyMessage API 保留原格式)
|
||||||
func (b *Bot) sendMessageCopy(to *tele.Chat, msg *tele.Message) (*tele.Message, error) {
|
func (b *Bot) sendMessageCopy(to *tele.Chat, msg *tele.Message) (*tele.Message, error) {
|
||||||
// 图片
|
// 使用 Copy 方法,完整保留原消息的 entities(代码块、粗体、斜体等格式)
|
||||||
if msg.Photo != nil {
|
return b.bot.Copy(to, msg)
|
||||||
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("不支持的消息类型")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildChannelLink(channelID int64, msgID int) string {
|
// sendAlbumCopy 复制相册发送到频道,返回第一条消息
|
||||||
chatID := channelID
|
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 {
|
if chatID < 0 {
|
||||||
chatID = -chatID - 1000000000000
|
chatID = -chatID - 1000000000000
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ func (m *Manager) updateMessage(content string) error {
|
|||||||
|
|
||||||
// 纯文本模式
|
// 纯文本模式
|
||||||
if msgID == 0 {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -93,7 +93,7 @@ func (m *Manager) updateMessage(content string) error {
|
|||||||
Chat: chat,
|
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 {
|
if err != nil {
|
||||||
errMsg := err.Error()
|
errMsg := err.Error()
|
||||||
// 内容未变化,忽略
|
// 内容未变化,忽略
|
||||||
@@ -101,7 +101,7 @@ func (m *Manager) updateMessage(content string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if strings.Contains(errMsg, "message to edit not found") {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -120,7 +120,7 @@ func (m *Manager) updateWithPhoto(chat *tele.Chat, msgID int, content string) er
|
|||||||
}
|
}
|
||||||
|
|
||||||
if msgID == 0 {
|
if msgID == 0 {
|
||||||
msg, err := m.bot.Send(chat, photo, tele.ModeMarkdown)
|
msg, err := m.bot.Send(chat, photo, tele.ModeHTML)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ func (m *Manager) updateWithPhoto(chat *tele.Chat, msgID int, content string) er
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 编辑图片消息的 caption
|
// 编辑图片消息的 caption
|
||||||
_, err := m.bot.EditCaption(existingMsg, content, tele.ModeMarkdown)
|
_, err := m.bot.EditCaption(existingMsg, content, tele.ModeHTML)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errMsg := err.Error()
|
errMsg := err.Error()
|
||||||
if err == tele.ErrMessageNotModified || strings.Contains(errMsg, "message is not modified") {
|
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") ||
|
if strings.Contains(errMsg, "message to edit not found") ||
|
||||||
strings.Contains(errMsg, "no caption") {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package toc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,28 +18,28 @@ func (m *Manager) Render() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString("📚 **频道目录**\n")
|
sb.WriteString("📚 <b>频道目录</b>\n")
|
||||||
sb.WriteString("━━━━━━━━━━━━━━━\n\n")
|
sb.WriteString("━━━━━━━━━━━━━━━\n\n")
|
||||||
|
|
||||||
if len(categories) == 0 {
|
if len(categories) == 0 {
|
||||||
sb.WriteString("_暂无分类_")
|
sb.WriteString("<i>暂无分类</i>")
|
||||||
return sb.String(), nil
|
return sb.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, cat := range categories {
|
for _, cat := range categories {
|
||||||
entries := entriesByCategory[cat.Name]
|
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 {
|
if len(entries) > 0 {
|
||||||
sb.WriteString(fmt.Sprintf(" (%d)", len(entries)))
|
sb.WriteString(fmt.Sprintf(" (%d)", len(entries)))
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
sb.WriteString(" _暂无内容_\n")
|
sb.WriteString(" <i>暂无内容</i>\n")
|
||||||
} else {
|
} else {
|
||||||
for _, entry := range entries {
|
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")
|
sb.WriteString("\n")
|
||||||
@@ -48,16 +49,3 @@ func (m *Manager) Render() (string, error) {
|
|||||||
|
|
||||||
return sb.String(), nil
|
return sb.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func escapeMarkdown(s string) string {
|
|
||||||
replacer := strings.NewReplacer(
|
|
||||||
"[", "\\[",
|
|
||||||
"]", "\\]",
|
|
||||||
"(", "\\(",
|
|
||||||
")", "\\)",
|
|
||||||
"*", "\\*",
|
|
||||||
"_", "\\_",
|
|
||||||
"`", "\\`",
|
|
||||||
)
|
|
||||||
return replacer.Replace(s)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user