From 8a6859269c96dccda8e8185a54876297916097d9 Mon Sep 17 00:00:00 2001 From: dela Date: Thu, 5 Feb 2026 00:52:29 +0800 Subject: [PATCH] Refactor post handling and add command setup for Telegram bot --- README.md | 132 ++++++++++++++ internal/config/config.go | 10 + internal/storage/storage.go | 50 ++++- internal/telegram/bot.go | 28 ++- internal/telegram/handlers_admin.go | 89 +++++++++ internal/telegram/handlers_post.go | 272 ++++++++++++++++++++++++++-- internal/telegram/middleware.go | 29 ++- internal/telegram/state.go | 16 ++ internal/toc/manager.go | 7 +- 9 files changed, 612 insertions(+), 21 deletions(-) create mode 100644 README.md create mode 100644 internal/telegram/handlers_admin.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..d302739 --- /dev/null +++ b/README.md @@ -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 ` | 删除条目 | +| `/edit <新标题>` | 修改标题 | +| `/move <新分类>` | 移动条目到其他分类 | +| `/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 diff --git a/internal/config/config.go b/internal/config/config.go index 7abcca0..d1c99dd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -65,3 +65,13 @@ func (c *Config) IsAdmin(userID int64) bool { } return false } + +// IsSuperAdmin 检查是否为超级管理员(配置文件中的管理员) +func (c *Config) IsSuperAdmin(userID int64) bool { + for _, id := range c.Admins { + if id == userID { + return true + } + } + return false +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index a27d29e..0142a48 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -15,6 +15,7 @@ var ( bucketAppConfig = []byte("AppConfig") bucketCategories = []byte("Categories") bucketEntries = []byte("Entries") + bucketAdmins = []byte("Admins") keyTocMsgID = []byte("toc_msg_id") ) @@ -59,7 +60,7 @@ func New(path string) (*Storage, error) { func (s *Storage) initBuckets() 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 { if _, err := tx.CreateBucketIfNotExists(b); err != nil { 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 { 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 +} diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index 410c85f..bd5b9d4 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -55,7 +55,7 @@ func (b *Bot) setupRoutes() { // Post flow adminOnly.Handle("/post", b.handlePost) - adminOnly.Handle(tele.OnText, b.handleTextOrForwarded) + adminOnly.Handle(tele.OnText, b.handleTextInput) // Entry management adminOnly.Handle("/del", b.handleEntryDel) @@ -66,15 +66,41 @@ func (b *Bot) setupRoutes() { // TOC 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 b.bot.Handle(tele.OnCallback, b.handleCallback) } func (b *Bot) Start() { + b.setCommands() log.Println("Bot started...") 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: "删除条目 "}, + {Text: "edit", Description: "编辑标题 <新标题>"}, + {Text: "move", Description: "移动条目 <新分类>"}, + {Text: "refresh", Description: "刷新目录"}, + } + + if err := b.bot.SetCommands(commands); err != nil { + log.Printf("Failed to set commands: %v", err) + } +} + func (b *Bot) Stop() { b.bot.Stop() b.toc.Stop() diff --git a/internal/telegram/handlers_admin.go b/internal/telegram/handlers_admin.go new file mode 100644 index 0000000..1a5e038 --- /dev/null +++ b/internal/telegram/handlers_admin.go @@ -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) +} diff --git a/internal/telegram/handlers_post.go b/internal/telegram/handlers_post.go index 6c1ccb5..7ac6278 100644 --- a/internal/telegram/handlers_post.go +++ b/internal/telegram/handlers_post.go @@ -12,20 +12,113 @@ import ( const ( cbPrefixCat = "cat:" cbPrefixConfirm = "confirm:" + cbPrefixTitle = "title:" cbCancel = "cancel" ) 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("📨 请转发一条来自目标频道的消息") + return c.Reply("📨 请转发需要归档到目录的消息\n\n消息将被同步发送到频道并添加到目录\n\n💡 快捷方式: 回复消息并发送\n`/post <分类> /tt <标题>`", tele.ModeMarkdown) } -func (b *Bot) handleTextOrForwarded(c tele.Context) error { +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 || msg.OriginalChat == nil { + if msg == nil { return nil } - return b.handleForwarded(c) + + // 只处理私聊消息,忽略群组对话 + 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) + } + + // 自定义标题输入 (StepAwaitTitle) + if state.Step == StepAwaitTitle { + return b.handleTitleInput(c) + } + + return nil } func (b *Bot) handleForwarded(c tele.Context) error { @@ -39,8 +132,9 @@ func (b *Bot) handleForwarded(c tele.Context) error { } 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) @@ -91,7 +185,8 @@ func (b *Bot) handleCallback(c tele.Context) error { 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 switch { @@ -102,6 +197,10 @@ func (b *Bot) handleCallback(c tele.Context) error { 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) @@ -116,6 +215,74 @@ func (b *Bot) handleCancelCallback(c tele.Context, userID int64) error { 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 { @@ -124,17 +291,20 @@ func (b *Bot) handleCategoryCallback(c tele.Context, userID int64, category stri b.states.SetCategory(userID, category) - channelName := "未知频道" - if state.ForwardedMsg.OriginalChat != nil { - channelName = state.ForwardedMsg.OriginalChat.Title - } + // 提取默认标题 + defaultTitle := extractTitle(state.ForwardedMsg) menu := &tele.ReplyMarkup{} - confirmBtn := menu.Data("✅ 确认", cbPrefixConfirm+"yes") + useDefaultBtn := menu.Data("✅ 使用此标题", cbPrefixTitle+"default") + customBtn := menu.Data("✏️ 自定义标题", cbPrefixTitle+"custom") 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) return c.Respond() } @@ -152,8 +322,18 @@ func (b *Bot) handleConfirmCallback(c tele.Context, userID int64, action string) defer b.states.Delete(userID) msg := state.ForwardedMsg - title := extractTitle(msg) - link := buildMessageLink(msg) + title := state.Title + + // 复制消息内容发送到频道(非转发,可编辑) + 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) if err != nil { @@ -168,6 +348,66 @@ func (b *Bot) handleConfirmCallback(c tele.Context, userID int64, action string) 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 { text := msg.Text if text == "" { diff --git a/internal/telegram/middleware.go b/internal/telegram/middleware.go index a0478ef..04a775b 100644 --- a/internal/telegram/middleware.go +++ b/internal/telegram/middleware.go @@ -4,13 +4,40 @@ import ( 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 { return func(next tele.HandlerFunc) tele.HandlerFunc { 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 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) + } + } +} diff --git a/internal/telegram/state.go b/internal/telegram/state.go index 225bc5b..b6059ae 100644 --- a/internal/telegram/state.go +++ b/internal/telegram/state.go @@ -12,6 +12,7 @@ type PostStep int const ( StepAwaitForward PostStep = iota StepAwaitCategory + StepAwaitTitle StepAwaitConfirm ) @@ -19,6 +20,7 @@ type PostState struct { UserID int64 ForwardedMsg *tele.Message SelectedCat string + Title string Step PostStep CreatedAt time.Time } @@ -86,6 +88,20 @@ func (sm *StateManager) SetCategory(userID int64, category string) *PostState { } 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 return state } diff --git a/internal/toc/manager.go b/internal/toc/manager.go index fbc2ae8..18b2688 100644 --- a/internal/toc/manager.go +++ b/internal/toc/manager.go @@ -2,6 +2,7 @@ package toc import ( "log" + "strings" "sync" "time" @@ -86,10 +87,12 @@ func (m *Manager) updateMessage(content string) error { _, err = m.bot.Edit(existingMsg, content, tele.ModeMarkdown, tele.NoPreview) if err != nil { - if err == tele.ErrMessageNotModified { + errMsg := err.Error() + // 内容未变化,忽略 + if err == tele.ErrMessageNotModified || strings.Contains(errMsg, "message is not modified") { 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) if err != nil { return err