forked from carrydela/mygoTgChanBot
first
This commit is contained in:
67
internal/config/config.go
Normal file
67
internal/config/config.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Bot BotConfig `yaml:"bot"`
|
||||
Admins []int64 `yaml:"admins"`
|
||||
Channel ChannelConfig `yaml:"channel"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
TOC TOCConfig `yaml:"toc"`
|
||||
}
|
||||
|
||||
type BotConfig struct {
|
||||
Token string `yaml:"token"`
|
||||
}
|
||||
|
||||
type ChannelConfig struct {
|
||||
ID int64 `yaml:"id"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
|
||||
type TOCConfig struct {
|
||||
DebounceSeconds int `yaml:"debounce_seconds"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
if cfg.Bot.Token == "" {
|
||||
return nil, fmt.Errorf("bot token is required")
|
||||
}
|
||||
|
||||
if cfg.Database.Path == "" {
|
||||
cfg.Database.Path = "./data/bot.db"
|
||||
}
|
||||
|
||||
if cfg.TOC.DebounceSeconds <= 0 {
|
||||
cfg.TOC.DebounceSeconds = 3
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) IsAdmin(userID int64) bool {
|
||||
for _, id := range c.Admins {
|
||||
if id == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
116
internal/storage/category.go
Normal file
116
internal/storage/category.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
func (s *Storage) CreateCategory(name string, order int) error {
|
||||
return s.db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketCategories)
|
||||
|
||||
if b.Get([]byte(name)) != nil {
|
||||
return fmt.Errorf("category %q already exists", name)
|
||||
}
|
||||
|
||||
cat := Category{
|
||||
Name: name,
|
||||
Order: order,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
data, err := encodeJSON(cat)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return b.Put([]byte(name), data)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Storage) DeleteCategory(name string) error {
|
||||
return s.db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketCategories)
|
||||
if b.Get([]byte(name)) == nil {
|
||||
return fmt.Errorf("category %q not found", name)
|
||||
}
|
||||
return b.Delete([]byte(name))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Storage) GetCategory(name string) (*Category, error) {
|
||||
var cat *Category
|
||||
err := s.db.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketCategories)
|
||||
data := b.Get([]byte(name))
|
||||
if data == nil {
|
||||
return fmt.Errorf("category %q not found", name)
|
||||
}
|
||||
cat = &Category{}
|
||||
return decodeJSON(data, cat)
|
||||
})
|
||||
return cat, err
|
||||
}
|
||||
|
||||
func (s *Storage) ListCategories() ([]Category, error) {
|
||||
var categories []Category
|
||||
err := s.db.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketCategories)
|
||||
return b.ForEach(func(k, v []byte) error {
|
||||
var cat Category
|
||||
if err := decodeJSON(v, &cat); err != nil {
|
||||
return err
|
||||
}
|
||||
categories = append(categories, cat)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.Slice(categories, func(i, j int) bool {
|
||||
if categories[i].Order != categories[j].Order {
|
||||
return categories[i].Order < categories[j].Order
|
||||
}
|
||||
return categories[i].CreatedAt.Before(categories[j].CreatedAt)
|
||||
})
|
||||
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
func (s *Storage) UpdateCategoryOrder(name string, order int) error {
|
||||
return s.db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketCategories)
|
||||
data := b.Get([]byte(name))
|
||||
if data == nil {
|
||||
return fmt.Errorf("category %q not found", name)
|
||||
}
|
||||
|
||||
var cat Category
|
||||
if err := decodeJSON(data, &cat); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cat.Order = order
|
||||
newData, err := encodeJSON(cat)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return b.Put([]byte(name), newData)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Storage) CategoryExists(name string) bool {
|
||||
exists := false
|
||||
s.db.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketCategories)
|
||||
exists = b.Get([]byte(name)) != nil
|
||||
return nil
|
||||
})
|
||||
return exists
|
||||
}
|
||||
147
internal/storage/entry.go
Normal file
147
internal/storage/entry.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/teris-io/shortid"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
func (s *Storage) CreateEntry(category, title, link string) (*Entry, error) {
|
||||
entry := &Entry{
|
||||
ID: shortid.MustGenerate(),
|
||||
Category: category,
|
||||
Title: title,
|
||||
Link: link,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
err := s.db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketEntries)
|
||||
data, err := encodeJSON(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Put([]byte(entry.ID), data)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (s *Storage) GetEntry(id string) (*Entry, error) {
|
||||
var entry *Entry
|
||||
err := s.db.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketEntries)
|
||||
data := b.Get([]byte(id))
|
||||
if data == nil {
|
||||
return fmt.Errorf("entry %q not found", id)
|
||||
}
|
||||
entry = &Entry{}
|
||||
return decodeJSON(data, entry)
|
||||
})
|
||||
return entry, err
|
||||
}
|
||||
|
||||
func (s *Storage) DeleteEntry(id string) error {
|
||||
return s.db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketEntries)
|
||||
if b.Get([]byte(id)) == nil {
|
||||
return fmt.Errorf("entry %q not found", id)
|
||||
}
|
||||
return b.Delete([]byte(id))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Storage) UpdateEntryTitle(id, title string) error {
|
||||
return s.db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketEntries)
|
||||
data := b.Get([]byte(id))
|
||||
if data == nil {
|
||||
return fmt.Errorf("entry %q not found", id)
|
||||
}
|
||||
|
||||
var entry Entry
|
||||
if err := decodeJSON(data, &entry); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entry.Title = title
|
||||
newData, err := encodeJSON(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return b.Put([]byte(id), newData)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Storage) UpdateEntryCategory(id, category string) error {
|
||||
return s.db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketEntries)
|
||||
data := b.Get([]byte(id))
|
||||
if data == nil {
|
||||
return fmt.Errorf("entry %q not found", id)
|
||||
}
|
||||
|
||||
var entry Entry
|
||||
if err := decodeJSON(data, &entry); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entry.Category = category
|
||||
newData, err := encodeJSON(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return b.Put([]byte(id), newData)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Storage) ListEntries(category string) ([]Entry, error) {
|
||||
var entries []Entry
|
||||
err := s.db.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketEntries)
|
||||
return b.ForEach(func(k, v []byte) error {
|
||||
var entry Entry
|
||||
if err := decodeJSON(v, &entry); err != nil {
|
||||
return err
|
||||
}
|
||||
if category == "" || entry.Category == category {
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Timestamp.Before(entries[j].Timestamp)
|
||||
})
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (s *Storage) ListAllEntries() ([]Entry, error) {
|
||||
return s.ListEntries("")
|
||||
}
|
||||
|
||||
func (s *Storage) GetEntriesByCategory() (map[string][]Entry, error) {
|
||||
entries, err := s.ListAllEntries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string][]Entry)
|
||||
for _, e := range entries {
|
||||
result[e.Category] = append(result[e.Category], e)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
104
internal/storage/storage.go
Normal file
104
internal/storage/storage.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
var (
|
||||
bucketAppConfig = []byte("AppConfig")
|
||||
bucketCategories = []byte("Categories")
|
||||
bucketEntries = []byte("Entries")
|
||||
|
||||
keyTocMsgID = []byte("toc_msg_id")
|
||||
)
|
||||
|
||||
type Category struct {
|
||||
Name string `json:"name"`
|
||||
Order int `json:"order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
ID string `json:"id"`
|
||||
Category string `json:"category"`
|
||||
Title string `json:"title"`
|
||||
Link string `json:"link"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
type Storage struct {
|
||||
db *bolt.DB
|
||||
}
|
||||
|
||||
func New(path string) (*Storage, error) {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
db, err := bolt.Open(path, 0600, &bolt.Options{Timeout: 1 * time.Second})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
s := &Storage{db: db}
|
||||
if err := s.initBuckets(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Storage) initBuckets() error {
|
||||
return s.db.Update(func(tx *bolt.Tx) error {
|
||||
buckets := [][]byte{bucketAppConfig, bucketCategories, bucketEntries}
|
||||
for _, b := range buckets {
|
||||
if _, err := tx.CreateBucketIfNotExists(b); err != nil {
|
||||
return fmt.Errorf("failed to create bucket %s: %w", b, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Storage) Close() error {
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *Storage) GetTocMsgID() (int, error) {
|
||||
var msgID int
|
||||
err := s.db.View(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketAppConfig)
|
||||
v := b.Get(keyTocMsgID)
|
||||
if v != nil && len(v) >= 4 {
|
||||
msgID = int(binary.BigEndian.Uint32(v))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return msgID, err
|
||||
}
|
||||
|
||||
func (s *Storage) SetTocMsgID(msgID int) error {
|
||||
return s.db.Update(func(tx *bolt.Tx) error {
|
||||
b := tx.Bucket(bucketAppConfig)
|
||||
buf := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(buf, uint32(msgID))
|
||||
return b.Put(keyTocMsgID, buf)
|
||||
})
|
||||
}
|
||||
|
||||
func encodeJSON(v any) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func decodeJSON(data []byte, v any) error {
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
81
internal/telegram/bot.go
Normal file
81
internal/telegram/bot.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"tgchanbot/internal/config"
|
||||
"tgchanbot/internal/storage"
|
||||
"tgchanbot/internal/toc"
|
||||
|
||||
tele "gopkg.in/telebot.v3"
|
||||
)
|
||||
|
||||
type Bot struct {
|
||||
bot *tele.Bot
|
||||
cfg *config.Config
|
||||
storage *storage.Storage
|
||||
toc *toc.Manager
|
||||
states *StateManager
|
||||
}
|
||||
|
||||
func New(cfg *config.Config, store *storage.Storage) (*Bot, error) {
|
||||
pref := tele.Settings{
|
||||
Token: cfg.Bot.Token,
|
||||
Poller: &tele.LongPoller{Timeout: 10 * time.Second},
|
||||
}
|
||||
|
||||
b, err := tele.NewBot(pref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bot := &Bot{
|
||||
bot: b,
|
||||
cfg: cfg,
|
||||
storage: store,
|
||||
states: NewStateManager(),
|
||||
}
|
||||
|
||||
bot.toc = toc.NewManager(store, b, cfg.Channel.ID, time.Duration(cfg.TOC.DebounceSeconds)*time.Second)
|
||||
|
||||
bot.setupRoutes()
|
||||
|
||||
return bot, nil
|
||||
}
|
||||
|
||||
func (b *Bot) setupRoutes() {
|
||||
adminOnly := b.bot.Group()
|
||||
adminOnly.Use(b.AdminMiddleware())
|
||||
|
||||
// Category management
|
||||
adminOnly.Handle("/cat_add", b.handleCatAdd)
|
||||
adminOnly.Handle("/cat_del", b.handleCatDel)
|
||||
adminOnly.Handle("/cat_list", b.handleCatList)
|
||||
|
||||
// Post flow
|
||||
adminOnly.Handle("/post", b.handlePost)
|
||||
adminOnly.Handle(tele.OnText, b.handleTextOrForwarded)
|
||||
|
||||
// Entry management
|
||||
adminOnly.Handle("/del", b.handleEntryDel)
|
||||
adminOnly.Handle("/edit", b.handleEntryEdit)
|
||||
adminOnly.Handle("/move", b.handleEntryMove)
|
||||
adminOnly.Handle("/list", b.handleEntryList)
|
||||
|
||||
// TOC
|
||||
adminOnly.Handle("/refresh", b.handleRefresh)
|
||||
|
||||
// Callbacks
|
||||
b.bot.Handle(tele.OnCallback, b.handleCallback)
|
||||
}
|
||||
|
||||
func (b *Bot) Start() {
|
||||
log.Println("Bot started...")
|
||||
b.bot.Start()
|
||||
}
|
||||
|
||||
func (b *Bot) Stop() {
|
||||
b.bot.Stop()
|
||||
b.toc.Stop()
|
||||
}
|
||||
62
internal/telegram/handlers_cat.go
Normal file
62
internal/telegram/handlers_cat.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v3"
|
||||
)
|
||||
|
||||
func (b *Bot) handleCatAdd(c tele.Context) error {
|
||||
args := strings.Fields(c.Message().Payload)
|
||||
if len(args) == 0 {
|
||||
return c.Reply("用法: /cat_add <名称> [排序]\n例如: /cat_add iOS 1")
|
||||
}
|
||||
|
||||
name := args[0]
|
||||
order := 0
|
||||
if len(args) > 1 {
|
||||
if o, err := strconv.Atoi(args[1]); err == nil {
|
||||
order = o
|
||||
}
|
||||
}
|
||||
|
||||
if err := b.storage.CreateCategory(name, order); err != nil {
|
||||
return c.Reply(fmt.Sprintf("❌ 创建失败: %v", err))
|
||||
}
|
||||
|
||||
return c.Reply(fmt.Sprintf("✅ 分类 [%s] 已创建 (排序: %d)", name, order))
|
||||
}
|
||||
|
||||
func (b *Bot) handleCatDel(c tele.Context) error {
|
||||
name := strings.TrimSpace(c.Message().Payload)
|
||||
if name == "" {
|
||||
return c.Reply("用法: /cat_del <名称>")
|
||||
}
|
||||
|
||||
if err := b.storage.DeleteCategory(name); err != nil {
|
||||
return c.Reply(fmt.Sprintf("❌ 删除失败: %v", err))
|
||||
}
|
||||
|
||||
return c.Reply(fmt.Sprintf("✅ 分类 [%s] 已删除", name))
|
||||
}
|
||||
|
||||
func (b *Bot) handleCatList(c tele.Context) error {
|
||||
categories, err := b.storage.ListCategories()
|
||||
if err != nil {
|
||||
return c.Reply(fmt.Sprintf("❌ 获取失败: %v", err))
|
||||
}
|
||||
|
||||
if len(categories) == 0 {
|
||||
return c.Reply("📂 暂无分类\n使用 /cat_add <名称> 创建")
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("📂 分类列表:\n\n")
|
||||
for i, cat := range categories {
|
||||
sb.WriteString(fmt.Sprintf("%d. %s (排序: %d)\n", i+1, cat.Name, cat.Order))
|
||||
}
|
||||
|
||||
return c.Reply(sb.String())
|
||||
}
|
||||
104
internal/telegram/handlers_entry.go
Normal file
104
internal/telegram/handlers_entry.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tele "gopkg.in/telebot.v3"
|
||||
)
|
||||
|
||||
func (b *Bot) handleEntryDel(c tele.Context) error {
|
||||
id := strings.TrimSpace(c.Message().Payload)
|
||||
if id == "" {
|
||||
return c.Reply("用法: /del <ID>")
|
||||
}
|
||||
|
||||
entry, err := b.storage.GetEntry(id)
|
||||
if err != nil {
|
||||
return c.Reply(fmt.Sprintf("❌ %v", err))
|
||||
}
|
||||
|
||||
if err := b.storage.DeleteEntry(id); err != nil {
|
||||
return c.Reply(fmt.Sprintf("❌ 删除失败: %v", err))
|
||||
}
|
||||
|
||||
b.toc.TriggerUpdate()
|
||||
return c.Reply(fmt.Sprintf("✅ 已删除: [%s] %s", entry.Category, entry.Title))
|
||||
}
|
||||
|
||||
func (b *Bot) handleEntryEdit(c tele.Context) error {
|
||||
args := strings.SplitN(c.Message().Payload, " ", 2)
|
||||
if len(args) < 2 {
|
||||
return c.Reply("用法: /edit <ID> <新标题>")
|
||||
}
|
||||
|
||||
id := args[0]
|
||||
newTitle := strings.TrimSpace(args[1])
|
||||
|
||||
if err := b.storage.UpdateEntryTitle(id, newTitle); err != nil {
|
||||
return c.Reply(fmt.Sprintf("❌ %v", err))
|
||||
}
|
||||
|
||||
b.toc.TriggerUpdate()
|
||||
return c.Reply(fmt.Sprintf("✅ 标题已更新为: %s", newTitle))
|
||||
}
|
||||
|
||||
func (b *Bot) handleEntryMove(c tele.Context) error {
|
||||
args := strings.Fields(c.Message().Payload)
|
||||
if len(args) < 2 {
|
||||
return c.Reply("用法: /move <ID> <新分类>")
|
||||
}
|
||||
|
||||
id := args[0]
|
||||
newCategory := args[1]
|
||||
|
||||
if !b.storage.CategoryExists(newCategory) {
|
||||
return c.Reply(fmt.Sprintf("❌ 分类 [%s] 不存在", newCategory))
|
||||
}
|
||||
|
||||
if err := b.storage.UpdateEntryCategory(id, newCategory); err != nil {
|
||||
return c.Reply(fmt.Sprintf("❌ %v", err))
|
||||
}
|
||||
|
||||
b.toc.TriggerUpdate()
|
||||
return c.Reply(fmt.Sprintf("✅ 已移动到分类: %s", newCategory))
|
||||
}
|
||||
|
||||
func (b *Bot) handleEntryList(c tele.Context) error {
|
||||
category := strings.TrimSpace(c.Message().Payload)
|
||||
|
||||
entries, err := b.storage.ListEntries(category)
|
||||
if err != nil {
|
||||
return c.Reply(fmt.Sprintf("❌ %v", err))
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
if category != "" {
|
||||
return c.Reply(fmt.Sprintf("📭 分类 [%s] 暂无条目", category))
|
||||
}
|
||||
return c.Reply("📭 暂无条目")
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
if category != "" {
|
||||
sb.WriteString(fmt.Sprintf("📋 分类 [%s] 的条目:\n\n", category))
|
||||
} else {
|
||||
sb.WriteString("📋 所有条目:\n\n")
|
||||
}
|
||||
|
||||
currentCat := ""
|
||||
for _, e := range entries {
|
||||
if category == "" && e.Category != currentCat {
|
||||
currentCat = e.Category
|
||||
sb.WriteString(fmt.Sprintf("\n【%s】\n", currentCat))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("• [%s] %s\n", e.ID, e.Title))
|
||||
}
|
||||
|
||||
return c.Reply(sb.String())
|
||||
}
|
||||
|
||||
func (b *Bot) handleRefresh(c tele.Context) error {
|
||||
b.toc.TriggerUpdate()
|
||||
return c.Reply("🔄 目录刷新已触发")
|
||||
}
|
||||
211
internal/telegram/handlers_post.go
Normal file
211
internal/telegram/handlers_post.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"tgchanbot/internal/storage"
|
||||
|
||||
tele "gopkg.in/telebot.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
cbPrefixCat = "cat:"
|
||||
cbPrefixConfirm = "confirm:"
|
||||
cbCancel = "cancel"
|
||||
)
|
||||
|
||||
func (b *Bot) handlePost(c tele.Context) error {
|
||||
b.states.StartPost(c.Sender().ID)
|
||||
return c.Reply("📨 请转发一条来自目标频道的消息")
|
||||
}
|
||||
|
||||
func (b *Bot) handleTextOrForwarded(c tele.Context) error {
|
||||
msg := c.Message()
|
||||
if msg == nil || msg.OriginalChat == nil {
|
||||
return nil
|
||||
}
|
||||
return b.handleForwarded(c)
|
||||
}
|
||||
|
||||
func (b *Bot) handleForwarded(c tele.Context) error {
|
||||
if !b.cfg.IsAdmin(c.Sender().ID) {
|
||||
return nil
|
||||
}
|
||||
|
||||
state := b.states.Get(c.Sender().ID)
|
||||
if state == nil || state.Step != StepAwaitForward {
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := c.Message()
|
||||
if msg.OriginalChat == nil {
|
||||
return c.Reply("❌ 这不是一条转发消息,请转发频道消息")
|
||||
}
|
||||
|
||||
b.states.SetForwarded(c.Sender().ID, msg)
|
||||
|
||||
categories, err := b.storage.ListCategories()
|
||||
if err != nil {
|
||||
b.states.Delete(c.Sender().ID)
|
||||
return c.Reply(fmt.Sprintf("❌ 获取分类失败: %v", err))
|
||||
}
|
||||
|
||||
if len(categories) == 0 {
|
||||
b.states.Delete(c.Sender().ID)
|
||||
return c.Reply("❌ 暂无分类,请先使用 /cat_add 创建分类")
|
||||
}
|
||||
|
||||
keyboard := b.buildCategoryKeyboard(categories)
|
||||
return c.Reply("📁 请选择分类:", keyboard)
|
||||
}
|
||||
|
||||
func (b *Bot) buildCategoryKeyboard(categories []storage.Category) *tele.ReplyMarkup {
|
||||
menu := &tele.ReplyMarkup{}
|
||||
var rows []tele.Row
|
||||
|
||||
var currentRow []tele.Btn
|
||||
for _, cat := range categories {
|
||||
btn := menu.Data(cat.Name, cbPrefixCat+cat.Name)
|
||||
currentRow = append(currentRow, btn)
|
||||
|
||||
if len(currentRow) == 3 {
|
||||
rows = append(rows, menu.Row(currentRow...))
|
||||
currentRow = nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(currentRow) > 0 {
|
||||
rows = append(rows, menu.Row(currentRow...))
|
||||
}
|
||||
|
||||
cancelBtn := menu.Data("❌ 取消", cbCancel)
|
||||
rows = append(rows, menu.Row(cancelBtn))
|
||||
|
||||
menu.Inline(rows...)
|
||||
return menu
|
||||
}
|
||||
|
||||
func (b *Bot) handleCallback(c tele.Context) error {
|
||||
if !b.cfg.IsAdmin(c.Sender().ID) {
|
||||
return c.Respond(&tele.CallbackResponse{Text: "无权限"})
|
||||
}
|
||||
|
||||
data := c.Callback().Data
|
||||
userID := c.Sender().ID
|
||||
|
||||
switch {
|
||||
case data == cbCancel:
|
||||
return b.handleCancelCallback(c, userID)
|
||||
|
||||
case strings.HasPrefix(data, cbPrefixCat):
|
||||
category := strings.TrimPrefix(data, cbPrefixCat)
|
||||
return b.handleCategoryCallback(c, userID, category)
|
||||
|
||||
case strings.HasPrefix(data, cbPrefixConfirm):
|
||||
action := strings.TrimPrefix(data, cbPrefixConfirm)
|
||||
return b.handleConfirmCallback(c, userID, action)
|
||||
}
|
||||
|
||||
return c.Respond()
|
||||
}
|
||||
|
||||
func (b *Bot) handleCancelCallback(c tele.Context, userID int64) error {
|
||||
b.states.Delete(userID)
|
||||
c.Edit("❌ 已取消")
|
||||
return c.Respond(&tele.CallbackResponse{Text: "已取消"})
|
||||
}
|
||||
|
||||
func (b *Bot) handleCategoryCallback(c tele.Context, userID int64, category string) error {
|
||||
state := b.states.Get(userID)
|
||||
if state == nil || state.Step != StepAwaitCategory {
|
||||
return c.Respond(&tele.CallbackResponse{Text: "会话已过期"})
|
||||
}
|
||||
|
||||
b.states.SetCategory(userID, category)
|
||||
|
||||
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", channelName, category)
|
||||
c.Edit(text, menu)
|
||||
return c.Respond()
|
||||
}
|
||||
|
||||
func (b *Bot) handleConfirmCallback(c tele.Context, userID int64, action string) error {
|
||||
if action != "yes" {
|
||||
return b.handleCancelCallback(c, userID)
|
||||
}
|
||||
|
||||
state := b.states.Get(userID)
|
||||
if state == nil || state.Step != StepAwaitConfirm {
|
||||
return c.Respond(&tele.CallbackResponse{Text: "会话已过期"})
|
||||
}
|
||||
|
||||
defer b.states.Delete(userID)
|
||||
|
||||
msg := state.ForwardedMsg
|
||||
title := extractTitle(msg)
|
||||
link := buildMessageLink(msg)
|
||||
|
||||
entry, err := b.storage.CreateEntry(state.SelectedCat, title, link)
|
||||
if err != nil {
|
||||
c.Edit(fmt.Sprintf("❌ 保存失败: %v", err))
|
||||
return c.Respond(&tele.CallbackResponse{Text: "保存失败"})
|
||||
}
|
||||
|
||||
b.toc.TriggerUpdate()
|
||||
|
||||
text := fmt.Sprintf("✅ 已添加\n\nID: %s\n分类: %s\n标题: %s", entry.ID, entry.Category, entry.Title)
|
||||
c.Edit(text)
|
||||
return c.Respond(&tele.CallbackResponse{Text: "添加成功"})
|
||||
}
|
||||
|
||||
func extractTitle(msg *tele.Message) string {
|
||||
text := msg.Text
|
||||
if text == "" {
|
||||
text = msg.Caption
|
||||
}
|
||||
|
||||
lines := strings.Split(text, "\n")
|
||||
title := strings.TrimSpace(lines[0])
|
||||
|
||||
if len(title) > 50 {
|
||||
title = title[:47] + "..."
|
||||
}
|
||||
|
||||
if title == "" {
|
||||
title = "无标题"
|
||||
}
|
||||
|
||||
return title
|
||||
}
|
||||
|
||||
func buildMessageLink(msg *tele.Message) string {
|
||||
if msg.OriginalChat == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
chat := msg.OriginalChat
|
||||
msgID := msg.OriginalMessageID
|
||||
if msgID == 0 {
|
||||
msgID = msg.ID
|
||||
}
|
||||
|
||||
if chat.Username != "" {
|
||||
return fmt.Sprintf("https://t.me/%s/%d", chat.Username, msgID)
|
||||
}
|
||||
|
||||
chatID := chat.ID
|
||||
if chatID < 0 {
|
||||
chatID = -chatID - 1000000000000
|
||||
}
|
||||
return fmt.Sprintf("https://t.me/c/%d/%d", chatID, msgID)
|
||||
}
|
||||
16
internal/telegram/middleware.go
Normal file
16
internal/telegram/middleware.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
tele "gopkg.in/telebot.v3"
|
||||
)
|
||||
|
||||
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) {
|
||||
return c.Reply("⛔ 无权限访问")
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
91
internal/telegram/state.go
Normal file
91
internal/telegram/state.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tele "gopkg.in/telebot.v3"
|
||||
)
|
||||
|
||||
type PostStep int
|
||||
|
||||
const (
|
||||
StepAwaitForward PostStep = iota
|
||||
StepAwaitCategory
|
||||
StepAwaitConfirm
|
||||
)
|
||||
|
||||
type PostState struct {
|
||||
UserID int64
|
||||
ForwardedMsg *tele.Message
|
||||
SelectedCat string
|
||||
Step PostStep
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type StateManager struct {
|
||||
mu sync.RWMutex
|
||||
states map[int64]*PostState
|
||||
}
|
||||
|
||||
func NewStateManager() *StateManager {
|
||||
return &StateManager{
|
||||
states: make(map[int64]*PostState),
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *StateManager) Get(userID int64) *PostState {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
return sm.states[userID]
|
||||
}
|
||||
|
||||
func (sm *StateManager) Set(state *PostState) {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
sm.states[state.UserID] = state
|
||||
}
|
||||
|
||||
func (sm *StateManager) Delete(userID int64) {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
delete(sm.states, userID)
|
||||
}
|
||||
|
||||
func (sm *StateManager) StartPost(userID int64) *PostState {
|
||||
state := &PostState{
|
||||
UserID: userID,
|
||||
Step: StepAwaitForward,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
sm.Set(state)
|
||||
return state
|
||||
}
|
||||
|
||||
func (sm *StateManager) SetForwarded(userID int64, msg *tele.Message) *PostState {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
state := sm.states[userID]
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
state.ForwardedMsg = msg
|
||||
state.Step = StepAwaitCategory
|
||||
return state
|
||||
}
|
||||
|
||||
func (sm *StateManager) SetCategory(userID int64, category string) *PostState {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
|
||||
state := sm.states[userID]
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
state.SelectedCat = category
|
||||
state.Step = StepAwaitConfirm
|
||||
return state
|
||||
}
|
||||
114
internal/toc/manager.go
Normal file
114
internal/toc/manager.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package toc
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"tgchanbot/internal/storage"
|
||||
|
||||
tele "gopkg.in/telebot.v3"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
storage *storage.Storage
|
||||
bot *tele.Bot
|
||||
chanID int64
|
||||
debounce time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
pending bool
|
||||
timer *time.Timer
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func NewManager(store *storage.Storage, bot *tele.Bot, chanID int64, debounce time.Duration) *Manager {
|
||||
return &Manager{
|
||||
storage: store,
|
||||
bot: bot,
|
||||
chanID: chanID,
|
||||
debounce: debounce,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) TriggerUpdate() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.pending = true
|
||||
|
||||
if m.timer != nil {
|
||||
m.timer.Stop()
|
||||
}
|
||||
|
||||
m.timer = time.AfterFunc(m.debounce, func() {
|
||||
m.doUpdate()
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) doUpdate() {
|
||||
m.mu.Lock()
|
||||
m.pending = false
|
||||
m.mu.Unlock()
|
||||
|
||||
content, err := m.Render()
|
||||
if err != nil {
|
||||
log.Printf("TOC render error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.updateMessage(content); err != nil {
|
||||
log.Printf("TOC update error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) updateMessage(content string) error {
|
||||
chat := &tele.Chat{ID: m.chanID}
|
||||
|
||||
msgID, err := m.storage.GetTocMsgID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if msgID == 0 {
|
||||
msg, err := m.bot.Send(chat, content, tele.ModeMarkdown, tele.NoPreview)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.storage.SetTocMsgID(msg.ID)
|
||||
}
|
||||
|
||||
existingMsg := &tele.Message{
|
||||
ID: msgID,
|
||||
Chat: chat,
|
||||
}
|
||||
|
||||
_, err = m.bot.Edit(existingMsg, content, tele.ModeMarkdown, tele.NoPreview)
|
||||
if err != nil {
|
||||
if err == tele.ErrMessageNotModified {
|
||||
return nil
|
||||
}
|
||||
if err.Error() == "telegram: message to edit not found (400)" {
|
||||
msg, err := m.bot.Send(chat, content, tele.ModeMarkdown, tele.NoPreview)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.storage.SetTocMsgID(msg.ID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Stop() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.timer != nil {
|
||||
m.timer.Stop()
|
||||
}
|
||||
|
||||
close(m.stopCh)
|
||||
}
|
||||
64
internal/toc/renderer.go
Normal file
64
internal/toc/renderer.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package toc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (m *Manager) Render() (string, error) {
|
||||
categories, err := m.storage.ListCategories()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
entriesByCategory, err := m.storage.GetEntriesByCategory()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("📚 **频道目录**\n")
|
||||
sb.WriteString("━━━━━━━━━━━━━━━\n\n")
|
||||
|
||||
if len(categories) == 0 {
|
||||
sb.WriteString("_暂无分类_")
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
for _, cat := range categories {
|
||||
entries := entriesByCategory[cat.Name]
|
||||
|
||||
sb.WriteString(fmt.Sprintf("📁 **%s**", cat.Name))
|
||||
if len(entries) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (%d)", len(entries)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
if len(entries) == 0 {
|
||||
sb.WriteString(" _暂无内容_\n")
|
||||
} else {
|
||||
for _, entry := range entries {
|
||||
sb.WriteString(fmt.Sprintf(" • [%s](%s)\n", escapeMarkdown(entry.Title), entry.Link))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("━━━━━━━━━━━━━━━\n")
|
||||
sb.WriteString("_自动生成_")
|
||||
|
||||
return sb.String(), nil
|
||||
}
|
||||
|
||||
func escapeMarkdown(s string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"[", "\\[",
|
||||
"]", "\\]",
|
||||
"(", "\\(",
|
||||
")", "\\)",
|
||||
"*", "\\*",
|
||||
"_", "\\_",
|
||||
"`", "\\`",
|
||||
)
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
Reference in New Issue
Block a user