Refactor post handling and add command setup for Telegram bot
This commit is contained in:
132
README.md
Normal file
132
README.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# Telegram 频道目录机器人
|
||||||
|
|
||||||
|
交互式、数据库驱动的 Telegram 频道内容管理系统。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- **数据库管理分类** - 动态 CRUD,无需硬编码
|
||||||
|
- **交互式按钮投稿** - Inline Keyboard 选择分类
|
||||||
|
- **快捷回复投稿** - 回复消息直接添加到目录
|
||||||
|
- **内容同步发布** - 转发的内容会复制发送到频道(非转发,可编辑)
|
||||||
|
- **防抖目录更新** - 多次操作合并为一次渲染,避免限流
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go mod tidy
|
||||||
|
go build -o bot ./cmd/bot
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
编辑 `config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
bot:
|
||||||
|
token: "YOUR_BOT_TOKEN"
|
||||||
|
|
||||||
|
admins:
|
||||||
|
- 123456789 # 管理员 Telegram User ID
|
||||||
|
|
||||||
|
channel:
|
||||||
|
id: -1001234567890 # 目标频道 ID
|
||||||
|
|
||||||
|
database:
|
||||||
|
path: "./data/bot.db"
|
||||||
|
|
||||||
|
toc:
|
||||||
|
debounce_seconds: 3
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bot
|
||||||
|
# 或指定配置文件
|
||||||
|
./bot -config /path/to/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
## 命令
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `/post` | 开始投稿流程 |
|
||||||
|
| `/post <分类> /tt <标题>` | 快捷投稿 (回复消息时使用) |
|
||||||
|
| `/list [分类]` | 查看条目 |
|
||||||
|
| `/del <ID>` | 删除条目 |
|
||||||
|
| `/edit <ID> <新标题>` | 修改标题 |
|
||||||
|
| `/move <ID> <新分类>` | 移动条目到其他分类 |
|
||||||
|
| `/cat_add <名称> [排序]` | 创建分类 |
|
||||||
|
| `/cat_del <名称>` | 删除分类 |
|
||||||
|
| `/cat_list` | 列出所有分类 |
|
||||||
|
| `/refresh` | 手动刷新频道目录 |
|
||||||
|
|
||||||
|
## 投稿方式
|
||||||
|
|
||||||
|
### 方式一:交互式流程(私聊 Bot)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 私聊 Bot 发送 /post
|
||||||
|
2. 转发任意消息给 Bot(支持来自任意来源:个人、群组、频道等)
|
||||||
|
3. 点击分类按钮
|
||||||
|
4. 确认标题 (使用默认 / 自定义输入)
|
||||||
|
5. 确认添加
|
||||||
|
```
|
||||||
|
|
||||||
|
Bot 会将消息内容复制发送到频道,目录链接指向频道中的新消息。
|
||||||
|
|
||||||
|
### 方式二:快捷回复(群组内)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 在关联群组找到目标消息
|
||||||
|
2. 回复该消息,发送:
|
||||||
|
/post iOS /tt 某个APP推荐
|
||||||
|
```
|
||||||
|
|
||||||
|
- `/tt` 后的内容为自定义标题
|
||||||
|
- 省略标题则自动提取消息首行
|
||||||
|
|
||||||
|
## 支持的消息类型
|
||||||
|
|
||||||
|
- ✅ 纯文本
|
||||||
|
- ✅ 图片(带说明文字)
|
||||||
|
- ✅ 视频(带说明文字)
|
||||||
|
- ✅ 文档(带说明文字)
|
||||||
|
- ✅ 音频(带说明文字)
|
||||||
|
- ✅ 动图(带说明文字)
|
||||||
|
- ✅ 语音
|
||||||
|
- ✅ 贴纸
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
├── cmd/bot/main.go # 程序入口
|
||||||
|
├── config.yaml # 配置文件
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/config.go # 配置加载
|
||||||
|
│ ├── storage/
|
||||||
|
│ │ ├── storage.go # BoltDB 初始化
|
||||||
|
│ │ ├── category.go # 分类 CRUD
|
||||||
|
│ │ └── entry.go # 条目 CRUD
|
||||||
|
│ ├── telegram/
|
||||||
|
│ │ ├── bot.go # Bot 初始化与路由
|
||||||
|
│ │ ├── middleware.go # 权限中间件
|
||||||
|
│ │ ├── state.go # 用户状态管理
|
||||||
|
│ │ ├── handlers_cat.go # 分类命令
|
||||||
|
│ │ ├── handlers_post.go # 投稿流程
|
||||||
|
│ │ └── handlers_entry.go # 条目命令
|
||||||
|
│ └── toc/
|
||||||
|
│ ├── manager.go # 目录管理器 (防抖)
|
||||||
|
│ └── renderer.go # 目录渲染
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- Go 1.23+
|
||||||
|
- [telebot.v3](https://gopkg.in/telebot.v3) - Telegram Bot 框架
|
||||||
|
- [bbolt](https://go.etcd.io/bbolt) - 嵌入式 KV 数据库
|
||||||
|
- [shortid](https://github.com/teris-io/shortid) - 短 ID 生成
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -65,3 +65,13 @@ func (c *Config) IsAdmin(userID int64) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsSuperAdmin 检查是否为超级管理员(配置文件中的管理员)
|
||||||
|
func (c *Config) IsSuperAdmin(userID int64) bool {
|
||||||
|
for _, id := range c.Admins {
|
||||||
|
if id == userID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ var (
|
|||||||
bucketAppConfig = []byte("AppConfig")
|
bucketAppConfig = []byte("AppConfig")
|
||||||
bucketCategories = []byte("Categories")
|
bucketCategories = []byte("Categories")
|
||||||
bucketEntries = []byte("Entries")
|
bucketEntries = []byte("Entries")
|
||||||
|
bucketAdmins = []byte("Admins")
|
||||||
|
|
||||||
keyTocMsgID = []byte("toc_msg_id")
|
keyTocMsgID = []byte("toc_msg_id")
|
||||||
)
|
)
|
||||||
@@ -59,7 +60,7 @@ func New(path string) (*Storage, error) {
|
|||||||
|
|
||||||
func (s *Storage) initBuckets() error {
|
func (s *Storage) initBuckets() error {
|
||||||
return s.db.Update(func(tx *bolt.Tx) error {
|
return s.db.Update(func(tx *bolt.Tx) error {
|
||||||
buckets := [][]byte{bucketAppConfig, bucketCategories, bucketEntries}
|
buckets := [][]byte{bucketAppConfig, bucketCategories, bucketEntries, bucketAdmins}
|
||||||
for _, b := range buckets {
|
for _, b := range buckets {
|
||||||
if _, err := tx.CreateBucketIfNotExists(b); err != nil {
|
if _, err := tx.CreateBucketIfNotExists(b); err != nil {
|
||||||
return fmt.Errorf("failed to create bucket %s: %w", b, err)
|
return fmt.Errorf("failed to create bucket %s: %w", b, err)
|
||||||
@@ -102,3 +103,50 @@ func encodeJSON(v any) ([]byte, error) {
|
|||||||
func decodeJSON(data []byte, v any) error {
|
func decodeJSON(data []byte, v any) error {
|
||||||
return json.Unmarshal(data, v)
|
return json.Unmarshal(data, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin management
|
||||||
|
|
||||||
|
func (s *Storage) AddAdmin(userID int64) error {
|
||||||
|
return s.db.Update(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket(bucketAdmins)
|
||||||
|
key := make([]byte, 8)
|
||||||
|
binary.BigEndian.PutUint64(key, uint64(userID))
|
||||||
|
return b.Put(key, []byte("1"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) RemoveAdmin(userID int64) error {
|
||||||
|
return s.db.Update(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket(bucketAdmins)
|
||||||
|
key := make([]byte, 8)
|
||||||
|
binary.BigEndian.PutUint64(key, uint64(userID))
|
||||||
|
return b.Delete(key)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) IsAdmin(userID int64) bool {
|
||||||
|
var exists bool
|
||||||
|
s.db.View(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket(bucketAdmins)
|
||||||
|
key := make([]byte, 8)
|
||||||
|
binary.BigEndian.PutUint64(key, uint64(userID))
|
||||||
|
exists = b.Get(key) != nil
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) ListAdmins() ([]int64, error) {
|
||||||
|
var admins []int64
|
||||||
|
err := s.db.View(func(tx *bolt.Tx) error {
|
||||||
|
b := tx.Bucket(bucketAdmins)
|
||||||
|
return b.ForEach(func(k, v []byte) error {
|
||||||
|
if len(k) == 8 {
|
||||||
|
userID := int64(binary.BigEndian.Uint64(k))
|
||||||
|
admins = append(admins, userID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return admins, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func (b *Bot) setupRoutes() {
|
|||||||
|
|
||||||
// Post flow
|
// Post flow
|
||||||
adminOnly.Handle("/post", b.handlePost)
|
adminOnly.Handle("/post", b.handlePost)
|
||||||
adminOnly.Handle(tele.OnText, b.handleTextOrForwarded)
|
adminOnly.Handle(tele.OnText, b.handleTextInput)
|
||||||
|
|
||||||
// Entry management
|
// Entry management
|
||||||
adminOnly.Handle("/del", b.handleEntryDel)
|
adminOnly.Handle("/del", b.handleEntryDel)
|
||||||
@@ -66,15 +66,41 @@ func (b *Bot) setupRoutes() {
|
|||||||
// TOC
|
// TOC
|
||||||
adminOnly.Handle("/refresh", b.handleRefresh)
|
adminOnly.Handle("/refresh", b.handleRefresh)
|
||||||
|
|
||||||
|
// Admin management (super admin only)
|
||||||
|
superAdminOnly := b.bot.Group()
|
||||||
|
superAdminOnly.Use(b.SuperAdminMiddleware())
|
||||||
|
superAdminOnly.Handle("/admin_add", b.handleAdminAdd)
|
||||||
|
superAdminOnly.Handle("/admin_del", b.handleAdminDel)
|
||||||
|
superAdminOnly.Handle("/admin_list", b.handleAdminList)
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
b.bot.Handle(tele.OnCallback, b.handleCallback)
|
b.bot.Handle(tele.OnCallback, b.handleCallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) Start() {
|
func (b *Bot) Start() {
|
||||||
|
b.setCommands()
|
||||||
log.Println("Bot started...")
|
log.Println("Bot started...")
|
||||||
b.bot.Start()
|
b.bot.Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *Bot) setCommands() {
|
||||||
|
commands := []tele.Command{
|
||||||
|
{Text: "post", Description: "投稿 - 添加频道内容到目录"},
|
||||||
|
{Text: "list", Description: "列表 - 查看所有条目"},
|
||||||
|
{Text: "cat_list", Description: "分类列表"},
|
||||||
|
{Text: "cat_add", Description: "添加分类 <名称> [排序]"},
|
||||||
|
{Text: "cat_del", Description: "删除分类 <名称>"},
|
||||||
|
{Text: "del", Description: "删除条目 <ID>"},
|
||||||
|
{Text: "edit", Description: "编辑标题 <ID> <新标题>"},
|
||||||
|
{Text: "move", Description: "移动条目 <ID> <新分类>"},
|
||||||
|
{Text: "refresh", Description: "刷新目录"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.bot.SetCommands(commands); err != nil {
|
||||||
|
log.Printf("Failed to set commands: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (b *Bot) Stop() {
|
func (b *Bot) Stop() {
|
||||||
b.bot.Stop()
|
b.bot.Stop()
|
||||||
b.toc.Stop()
|
b.toc.Stop()
|
||||||
|
|||||||
89
internal/telegram/handlers_admin.go
Normal file
89
internal/telegram/handlers_admin.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package telegram
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tele "gopkg.in/telebot.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (b *Bot) handleAdminAdd(c tele.Context) error {
|
||||||
|
payload := strings.TrimSpace(c.Message().Payload)
|
||||||
|
if payload == "" {
|
||||||
|
return c.Reply("用法: /admin_add <用户ID>")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := strconv.ParseInt(payload, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return c.Reply("❌ 无效的用户ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已是管理员
|
||||||
|
if b.isAdmin(userID) {
|
||||||
|
return c.Reply("⚠️ 该用户已是管理员")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.storage.AddAdmin(userID); err != nil {
|
||||||
|
return c.Reply(fmt.Sprintf("❌ 添加失败: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Reply(fmt.Sprintf("✅ 已添加管理员: %d", userID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleAdminDel(c tele.Context) error {
|
||||||
|
payload := strings.TrimSpace(c.Message().Payload)
|
||||||
|
if payload == "" {
|
||||||
|
return c.Reply("用法: /admin_del <用户ID>")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := strconv.ParseInt(payload, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return c.Reply("❌ 无效的用户ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不能删除超级管理员
|
||||||
|
if b.cfg.IsSuperAdmin(userID) {
|
||||||
|
return c.Reply("❌ 无法删除超级管理员(配置文件中的管理员)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := b.storage.RemoveAdmin(userID); err != nil {
|
||||||
|
return c.Reply(fmt.Sprintf("❌ 删除失败: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Reply(fmt.Sprintf("✅ 已移除管理员: %d", userID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bot) handleAdminList(c tele.Context) error {
|
||||||
|
// 配置文件中的超级管理员
|
||||||
|
superAdmins := b.cfg.Admins
|
||||||
|
|
||||||
|
// 数据库中的动态管理员
|
||||||
|
dbAdmins, err := b.storage.ListAdmins()
|
||||||
|
if err != nil {
|
||||||
|
return c.Reply(fmt.Sprintf("❌ 获取管理员列表失败: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("👑 **管理员列表**\n\n")
|
||||||
|
|
||||||
|
sb.WriteString("**超级管理员(配置文件):**\n")
|
||||||
|
if len(superAdmins) == 0 {
|
||||||
|
sb.WriteString(" _无_\n")
|
||||||
|
} else {
|
||||||
|
for _, id := range superAdmins {
|
||||||
|
sb.WriteString(fmt.Sprintf(" • `%d`\n", id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n**动态管理员(数据库):**\n")
|
||||||
|
if len(dbAdmins) == 0 {
|
||||||
|
sb.WriteString(" _无_\n")
|
||||||
|
} else {
|
||||||
|
for _, id := range dbAdmins {
|
||||||
|
sb.WriteString(fmt.Sprintf(" • `%d`\n", id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Reply(sb.String(), tele.ModeMarkdown)
|
||||||
|
}
|
||||||
@@ -12,22 +12,115 @@ import (
|
|||||||
const (
|
const (
|
||||||
cbPrefixCat = "cat:"
|
cbPrefixCat = "cat:"
|
||||||
cbPrefixConfirm = "confirm:"
|
cbPrefixConfirm = "confirm:"
|
||||||
|
cbPrefixTitle = "title:"
|
||||||
cbCancel = "cancel"
|
cbCancel = "cancel"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (b *Bot) handlePost(c tele.Context) error {
|
func (b *Bot) handlePost(c tele.Context) error {
|
||||||
b.states.StartPost(c.Sender().ID)
|
msg := c.Message()
|
||||||
return c.Reply("📨 请转发一条来自目标频道的消息")
|
payload := strings.TrimSpace(c.Message().Payload)
|
||||||
|
|
||||||
|
// 快捷方式: 回复消息 + /post <分类> /tt <标题>
|
||||||
|
if msg.ReplyTo != nil && payload != "" {
|
||||||
|
return b.handleQuickPost(c, msg.ReplyTo, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) handleTextOrForwarded(c tele.Context) error {
|
// 常规交互流程
|
||||||
|
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()
|
msg := c.Message()
|
||||||
if msg == nil || msg.OriginalChat == nil {
|
if msg == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 只处理私聊消息,忽略群组对话
|
||||||
|
if c.Chat().Type != tele.ChatPrivate {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只处理有活跃投稿状态的用户
|
||||||
|
state := b.states.Get(c.Sender().ID)
|
||||||
|
if state == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转发消息处理 (StepAwaitForward)
|
||||||
|
// OriginalChat: 转发自频道/群组; OriginalSender: 转发自个人用户
|
||||||
|
isForwarded := msg.OriginalChat != nil || msg.OriginalSender != nil
|
||||||
|
if isForwarded && state.Step == StepAwaitForward {
|
||||||
return b.handleForwarded(c)
|
return b.handleForwarded(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 自定义标题输入 (StepAwaitTitle)
|
||||||
|
if state.Step == StepAwaitTitle {
|
||||||
|
return b.handleTitleInput(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (b *Bot) handleForwarded(c tele.Context) error {
|
func (b *Bot) handleForwarded(c tele.Context) error {
|
||||||
if !b.cfg.IsAdmin(c.Sender().ID) {
|
if !b.cfg.IsAdmin(c.Sender().ID) {
|
||||||
return nil
|
return nil
|
||||||
@@ -39,8 +132,9 @@ func (b *Bot) handleForwarded(c tele.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
msg := c.Message()
|
msg := c.Message()
|
||||||
if msg.OriginalChat == nil {
|
// 检查是否为转发消息(来自频道/群组或个人用户)
|
||||||
return c.Reply("❌ 这不是一条转发消息,请转发频道消息")
|
if msg.OriginalChat == nil && msg.OriginalSender == nil {
|
||||||
|
return c.Reply("❌ 这不是一条转发消息,请转发一条消息给我")
|
||||||
}
|
}
|
||||||
|
|
||||||
b.states.SetForwarded(c.Sender().ID, msg)
|
b.states.SetForwarded(c.Sender().ID, msg)
|
||||||
@@ -91,7 +185,8 @@ func (b *Bot) handleCallback(c tele.Context) error {
|
|||||||
return c.Respond(&tele.CallbackResponse{Text: "无权限"})
|
return c.Respond(&tele.CallbackResponse{Text: "无权限"})
|
||||||
}
|
}
|
||||||
|
|
||||||
data := c.Callback().Data
|
// telebot v3 会在 data 前加 \f 前缀,需要去掉
|
||||||
|
data := strings.TrimPrefix(c.Callback().Data, "\f")
|
||||||
userID := c.Sender().ID
|
userID := c.Sender().ID
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
@@ -102,6 +197,10 @@ func (b *Bot) handleCallback(c tele.Context) error {
|
|||||||
category := strings.TrimPrefix(data, cbPrefixCat)
|
category := strings.TrimPrefix(data, cbPrefixCat)
|
||||||
return b.handleCategoryCallback(c, userID, category)
|
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):
|
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)
|
||||||
@@ -116,6 +215,74 @@ func (b *Bot) handleCancelCallback(c tele.Context, userID int64) error {
|
|||||||
return c.Respond(&tele.CallbackResponse{Text: "已取消"})
|
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 {
|
func (b *Bot) handleCategoryCallback(c tele.Context, userID int64, category string) error {
|
||||||
state := b.states.Get(userID)
|
state := b.states.Get(userID)
|
||||||
if state == nil || state.Step != StepAwaitCategory {
|
if state == nil || state.Step != StepAwaitCategory {
|
||||||
@@ -124,17 +291,20 @@ func (b *Bot) handleCategoryCallback(c tele.Context, userID int64, category stri
|
|||||||
|
|
||||||
b.states.SetCategory(userID, category)
|
b.states.SetCategory(userID, category)
|
||||||
|
|
||||||
channelName := "未知频道"
|
// 提取默认标题
|
||||||
if state.ForwardedMsg.OriginalChat != nil {
|
defaultTitle := extractTitle(state.ForwardedMsg)
|
||||||
channelName = state.ForwardedMsg.OriginalChat.Title
|
|
||||||
}
|
|
||||||
|
|
||||||
menu := &tele.ReplyMarkup{}
|
menu := &tele.ReplyMarkup{}
|
||||||
confirmBtn := menu.Data("✅ 确认", cbPrefixConfirm+"yes")
|
useDefaultBtn := menu.Data("✅ 使用此标题", cbPrefixTitle+"default")
|
||||||
|
customBtn := menu.Data("✏️ 自定义标题", cbPrefixTitle+"custom")
|
||||||
cancelBtn := menu.Data("❌ 取消", cbCancel)
|
cancelBtn := menu.Data("❌ 取消", cbCancel)
|
||||||
menu.Inline(menu.Row(confirmBtn, cancelBtn))
|
menu.Inline(
|
||||||
|
menu.Row(useDefaultBtn),
|
||||||
|
menu.Row(customBtn),
|
||||||
|
menu.Row(cancelBtn),
|
||||||
|
)
|
||||||
|
|
||||||
text := fmt.Sprintf("📋 确认添加?\n\n频道: %s\n分类: %s", channelName, category)
|
text := fmt.Sprintf("📝 确认标题\n\n分类: %s\n标题: %s\n\n使用此标题或自定义?", category, defaultTitle)
|
||||||
c.Edit(text, menu)
|
c.Edit(text, menu)
|
||||||
return c.Respond()
|
return c.Respond()
|
||||||
}
|
}
|
||||||
@@ -152,8 +322,18 @@ func (b *Bot) handleConfirmCallback(c tele.Context, userID int64, action string)
|
|||||||
defer b.states.Delete(userID)
|
defer b.states.Delete(userID)
|
||||||
|
|
||||||
msg := state.ForwardedMsg
|
msg := state.ForwardedMsg
|
||||||
title := extractTitle(msg)
|
title := state.Title
|
||||||
link := buildMessageLink(msg)
|
|
||||||
|
// 复制消息内容发送到频道(非转发,可编辑)
|
||||||
|
channel := &tele.Chat{ID: b.cfg.Channel.ID}
|
||||||
|
channelMsg, err := b.sendMessageCopy(channel, msg)
|
||||||
|
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)
|
entry, err := b.storage.CreateEntry(state.SelectedCat, title, link)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -168,6 +348,66 @@ func (b *Bot) handleConfirmCallback(c tele.Context, userID int64, action string)
|
|||||||
return c.Respond(&tele.CallbackResponse{Text: "添加成功"})
|
return c.Respond(&tele.CallbackResponse{Text: "添加成功"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendMessageCopy 复制消息内容发送(非转发,可编辑)
|
||||||
|
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("不支持的消息类型")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
func extractTitle(msg *tele.Message) string {
|
||||||
text := msg.Text
|
text := msg.Text
|
||||||
if text == "" {
|
if text == "" {
|
||||||
|
|||||||
@@ -4,13 +4,40 @@ import (
|
|||||||
tele "gopkg.in/telebot.v3"
|
tele "gopkg.in/telebot.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// isAdmin 检查用户是否为管理员(配置文件或数据库)
|
||||||
|
func (b *Bot) isAdmin(userID int64) bool {
|
||||||
|
return b.cfg.IsAdmin(userID) || b.storage.IsAdmin(userID)
|
||||||
|
}
|
||||||
|
|
||||||
func (b *Bot) AdminMiddleware() tele.MiddlewareFunc {
|
func (b *Bot) AdminMiddleware() tele.MiddlewareFunc {
|
||||||
return func(next tele.HandlerFunc) tele.HandlerFunc {
|
return func(next tele.HandlerFunc) tele.HandlerFunc {
|
||||||
return func(c tele.Context) error {
|
return func(c tele.Context) error {
|
||||||
if !b.cfg.IsAdmin(c.Sender().ID) {
|
// 群组中的非命令消息,非管理员直接忽略不回复
|
||||||
|
if c.Chat().Type != tele.ChatPrivate {
|
||||||
|
msg := c.Message()
|
||||||
|
if msg != nil && !msg.IsService() && (msg.Text == "" || msg.Text[0] != '/') {
|
||||||
|
if !b.isAdmin(c.Sender().ID) {
|
||||||
|
return nil // 静默忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !b.isAdmin(c.Sender().ID) {
|
||||||
return c.Reply("⛔ 无权限访问")
|
return c.Reply("⛔ 无权限访问")
|
||||||
}
|
}
|
||||||
return next(c)
|
return next(c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SuperAdminMiddleware 超级管理员中间件(仅配置文件中的管理员)
|
||||||
|
func (b *Bot) SuperAdminMiddleware() tele.MiddlewareFunc {
|
||||||
|
return func(next tele.HandlerFunc) tele.HandlerFunc {
|
||||||
|
return func(c tele.Context) error {
|
||||||
|
if !b.cfg.IsSuperAdmin(c.Sender().ID) {
|
||||||
|
return c.Reply("⛔ 需要超级管理员权限")
|
||||||
|
}
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type PostStep int
|
|||||||
const (
|
const (
|
||||||
StepAwaitForward PostStep = iota
|
StepAwaitForward PostStep = iota
|
||||||
StepAwaitCategory
|
StepAwaitCategory
|
||||||
|
StepAwaitTitle
|
||||||
StepAwaitConfirm
|
StepAwaitConfirm
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ type PostState struct {
|
|||||||
UserID int64
|
UserID int64
|
||||||
ForwardedMsg *tele.Message
|
ForwardedMsg *tele.Message
|
||||||
SelectedCat string
|
SelectedCat string
|
||||||
|
Title string
|
||||||
Step PostStep
|
Step PostStep
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
@@ -86,6 +88,20 @@ func (sm *StateManager) SetCategory(userID int64, category string) *PostState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.SelectedCat = category
|
state.SelectedCat = category
|
||||||
|
state.Step = StepAwaitTitle
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *StateManager) SetTitle(userID int64, title string) *PostState {
|
||||||
|
sm.mu.Lock()
|
||||||
|
defer sm.mu.Unlock()
|
||||||
|
|
||||||
|
state := sm.states[userID]
|
||||||
|
if state == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
state.Title = title
|
||||||
state.Step = StepAwaitConfirm
|
state.Step = StepAwaitConfirm
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package toc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -86,10 +87,12 @@ func (m *Manager) updateMessage(content string) error {
|
|||||||
|
|
||||||
_, err = m.bot.Edit(existingMsg, content, tele.ModeMarkdown, tele.NoPreview)
|
_, err = m.bot.Edit(existingMsg, content, tele.ModeMarkdown, tele.NoPreview)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == tele.ErrMessageNotModified {
|
errMsg := err.Error()
|
||||||
|
// 内容未变化,忽略
|
||||||
|
if err == tele.ErrMessageNotModified || strings.Contains(errMsg, "message is not modified") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err.Error() == "telegram: message to edit not found (400)" {
|
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.ModeMarkdown, tele.NoPreview)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
Reference in New Issue
Block a user