├── docs ├── .nojekyll ├── _sidebar.md ├── _media │ ├── favicon.ico │ └── icon.svg ├── README.md ├── faq.md ├── usage.md ├── index.html └── install.md ├── internal ├── model │ ├── user.go │ ├── model.go │ ├── option.go │ ├── source.go │ ├── id.go │ ├── content.go │ ├── subscribe.go │ └── id_test.go ├── bot │ ├── session │ │ ├── attachment.proto │ │ ├── attachment_test.go │ │ ├── session_test.go │ │ ├── session.go │ │ ├── attachment.go │ │ └── attachment.pb.go │ ├── handler │ │ ├── ping.go │ │ ├── import.go │ │ ├── version.go │ │ ├── handler.go │ │ ├── start.go │ │ ├── help.go │ │ ├── active_all.go │ │ ├── pause_all.go │ │ ├── set_feed_tag.go │ │ ├── set_subscription_tag_button.go │ │ ├── set_update_interval.go │ │ ├── subscription_switch_button.go │ │ ├── notification_switch_button.go │ │ ├── telegraph_switch_button.go │ │ ├── remove_all_subscription.go │ │ ├── export.go │ │ ├── list_subscription.go │ │ ├── on_document.go │ │ ├── add_subscription.go │ │ ├── remove_subscription.go │ │ └── set.go │ ├── middleware │ │ ├── user_filter.go │ │ ├── pre_load_mention_chat.go │ │ └── check_chat_admin.go │ ├── chat │ │ └── chat.go │ ├── preview │ │ └── util.go │ ├── message │ │ └── message.go │ └── bot.go ├── preview │ ├── publish.go │ └── tgraph.go ├── feed │ └── parser.go ├── storage │ ├── user_test.go │ ├── user.go │ ├── content_test.go │ ├── content.go │ ├── source_test.go │ ├── source.go │ ├── storage.go │ ├── subscription_test.go │ ├── subscription.go │ └── mock │ │ └── storage_mock.go ├── config │ ├── autoload_test.go │ ├── config.go │ └── autoload.go ├── log │ └── log.go ├── opml │ └── opml.go ├── scheduler │ └── rss.go └── core │ ├── core.go │ └── core_test.go ├── .github ├── dependabot.yml └── workflows │ ├── test.yml │ └── release.yml ├── CHANGELOG.md ├── Dockerfile ├── docker-compose.yml ├── config.yml.sample ├── Makefile ├── main.go ├── LICENSE ├── README.md ├── go.mod ├── .goreleaser.yml ├── pkg └── client │ ├── http.go │ └── http_test.go └── .gitignore /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 | * [介绍](/) 2 | * [安装与部署](install.md) 3 | * [使用](usage.md) 4 | * [常见问题](faq.md) -------------------------------------------------------------------------------- /docs/_media/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/indes/flowerss-bot/HEAD/docs/_media/favicon.ico -------------------------------------------------------------------------------- /internal/model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // User subscriber 4 | type User struct { 5 | ID int64 `gorm:"primary_key"` 6 | EditTime 7 | } 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "21:00" 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /internal/model/model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // EditTime timestamp 8 | type EditTime struct { 9 | CreatedAt time.Time 10 | UpdatedAt time.Time 11 | } 12 | -------------------------------------------------------------------------------- /internal/model/option.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // Option bot 设置 4 | type Option struct { 5 | ID int `gorm:"primary_key;AUTO_INCREMENT"` 6 | Name string 7 | Value string 8 | EditTime 9 | } 10 | -------------------------------------------------------------------------------- /internal/model/source.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Source struct { 4 | ID uint `gorm:"primary_key;AUTO_INCREMENT"` 5 | Link string 6 | Title string 7 | ErrorCount uint 8 | Content []Content 9 | EditTime 10 | } 11 | -------------------------------------------------------------------------------- /internal/bot/session/attachment.proto: -------------------------------------------------------------------------------- 1 | //go:generate protoc --go_out=. attachment.proto 2 | syntax = "proto3"; 3 | 4 | package session; 5 | option go_package = "../session"; 6 | 7 | message Attachment { 8 | int64 user_id = 1; 9 | uint32 source_id = 2; 10 | } -------------------------------------------------------------------------------- /internal/model/id.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "encoding/hex" 5 | "hash/fnv" 6 | ) 7 | 8 | func GenHashID(sLink string, id string) string { 9 | idString := string(sLink) + "||" + id 10 | f := fnv.New32() 11 | f.Write([]byte(idString)) 12 | 13 | encoded := hex.EncodeToString(f.Sum(nil)) 14 | return encoded 15 | } 16 | -------------------------------------------------------------------------------- /internal/model/content.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // Content fetcher content 4 | type Content struct { 5 | SourceID uint 6 | HashID string `gorm:"primary_key"` 7 | RawID string 8 | RawLink string 9 | Title string 10 | Description string `gorm:"-"` //ignore to db 11 | TelegraphURL string 12 | EditTime 13 | } 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased] 2019-10-29 4 | 5 | ### Added 6 | - source update fetch control by `/set` command (usually used when source updating paused by reached 100 error when 7 | getting update data) 8 | - export feeds 9 | 10 | ### Changed 11 | - merge `/set` command's response message template to a function for more struct 12 | 13 | -------------------------------------------------------------------------------- /internal/model/subscribe.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Subscribe struct { 4 | ID uint `gorm:"primary_key;AUTO_INCREMENT"` 5 | UserID int64 6 | SourceID uint 7 | EnableNotification int 8 | EnableTelegraph int 9 | Tag string 10 | Interval int 11 | WaitTime int 12 | EditTime 13 | } 14 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # flowerss bot 2 | 3 | > 一个支持应用内即时预览的 Telegram RSS Bot。 4 | 5 | ## Features 6 | 7 | - 常见的 RSS Bot 该有的功能; 8 | - 支持 Telegram 应用内即时预览; 9 | - 支持为 Group 和 Channel 订阅 RSS 消息; 10 | - 丰富的订阅设置。 11 | 12 | 13 | 14 | 15 | 16 | ## 问题反馈 17 | 18 | 如果你在使用过程中遇到问题,请在 GitHub 提交 Issue,并附上 Bot 日志。 -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.18.6-alpine as builder 2 | #ENV CGO_ENABLED=0 3 | COPY . /flowerss 4 | RUN apk add git make gcc libc-dev && \ 5 | cd /flowerss && make build 6 | 7 | # Image starts here 8 | FROM alpine 9 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 10 | COPY --from=builder /flowerss/flowerss-bot /bin/ 11 | VOLUME /root/.flowerss 12 | WORKDIR /root/.flowerss 13 | ENTRYPOINT ["/bin/flowerss-bot"] 14 | 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | flowerss-bot: 5 | build: 6 | network: host 7 | context: . 8 | dockerfile: Dockerfile 9 | image: flowerss-bot 10 | volumes: 11 | - ./conf:/root/.flowerss-bot 12 | restart: unless-stopped 13 | deploy: 14 | replicas: 1 15 | resources: 16 | limits: 17 | cpus: "0.5" 18 | memory: 1g 19 | restart_policy: 20 | condition: on-failure 21 | -------------------------------------------------------------------------------- /internal/bot/session/attachment_test.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestAttachment(t *testing.T) { 10 | t.Run( 11 | "encode and decode", func(t *testing.T) { 12 | a := &Attachment{UserId: 123, SourceId: 321} 13 | data := Marshal(a) 14 | a2, err := UnmarshalAttachment(data) 15 | assert.Nil(t, err) 16 | assert.Equal(t, a.GetUserId(), a2.GetUserId()) 17 | assert.Equal(t, a.GetSourceId(), a2.GetSourceId()) 18 | }, 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /internal/bot/session/session_test.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import "testing" 4 | 5 | func TestBotContextStoreKey_String(t *testing.T) { 6 | tests := []struct { 7 | name string 8 | k BotContextStoreKey 9 | want string 10 | }{ 11 | {"mention", StoreKeyMentionChat, string(StoreKeyMentionChat)}, 12 | } 13 | for _, tt := range tests { 14 | t.Run(tt.name, func(t *testing.T) { 15 | if got := tt.k.String(); got != tt.want { 16 | t.Errorf("String() = %v, want %v", got, tt.want) 17 | } 18 | }) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ### 日志中大量类似于 `Create telegraph page error: FLOOD_WAIT_7` 的提示。 4 | 5 | 原因是创建 Telegraph 页面请求过快触发了接口限制,可尝试在配置文件中添加多个 Telegraph token。 6 | 7 | 8 | ### 如何申请 Telegraph Token? 9 | 10 | 如果要使用应用内即时预览,必须在配置文件中填写 `telegraph_token` 配置项,Telegraph Token 申请命令如下: 11 | ```bash 12 | curl https://api.telegra.ph/createAccount?short_name=flowerss&author_name=flowerss&author_url=https://github.com/indes/flowerss-bot 13 | ``` 14 | 15 | 返回的 JSON 中 access_token 字段值即为 Telegraph Token。 16 | 17 | 18 | ### 如何获取我的telegram id? 19 | 可以参考这个网页获取:https://botostore.com/c/getmyid_bot/ -------------------------------------------------------------------------------- /internal/bot/handler/ping.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | tb "gopkg.in/telebot.v3" 5 | ) 6 | 7 | type Ping struct { 8 | bot *tb.Bot 9 | } 10 | 11 | // NewPing new ping cmd handler 12 | func NewPing(bot *tb.Bot) *Ping { 13 | return &Ping{bot: bot} 14 | } 15 | 16 | func (p *Ping) Command() string { 17 | return "/ping" 18 | } 19 | 20 | func (p *Ping) Description() string { 21 | return "" 22 | } 23 | 24 | func (p *Ping) Handle(ctx tb.Context) error { 25 | return ctx.Send("pong") 26 | } 27 | 28 | func (p *Ping) Middlewares() []tb.MiddlewareFunc { 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /config.yml.sample: -------------------------------------------------------------------------------- 1 | bot_token: 2 | telegraph_token: 3 | telegraph_account: 4 | telegraph_author_name: 5 | telegraph_author_url: 6 | socks5: 7 | update_interval: 10 8 | user_agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36 9 | 10 | mysql: 11 | host: 12 | port: 13 | user: 14 | password: 15 | database: 16 | 17 | telegram: 18 | endpoint: 19 | 20 | log: 21 | level: release 22 | db_log: false # 打印数据库日志,false则只会打印数据库错误日志 23 | 24 | # file: ./flowerss.log 25 | 26 | sqlite: 27 | path: ./data.db 28 | 29 | allowed_users: 30 | -------------------------------------------------------------------------------- /internal/bot/handler/import.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import tb "gopkg.in/telebot.v3" 4 | 5 | type Import struct { 6 | } 7 | 8 | func NewImport() *Import { 9 | return &Import{} 10 | } 11 | 12 | func (i *Import) Command() string { 13 | return "/import" 14 | } 15 | 16 | func (i *Import) Description() string { 17 | return "导入OPML文件" 18 | } 19 | 20 | func (i *Import) Handle(ctx tb.Context) error { 21 | reply := "请直接发送OPML文件,如果需要为频道导入OPML,请在发送文件的时候附上channel id,例如@telegram" 22 | return ctx.Reply(reply) 23 | } 24 | 25 | func (i *Import) Middlewares() []tb.MiddlewareFunc { 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /internal/bot/handler/version.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | tb "gopkg.in/telebot.v3" 5 | 6 | "github.com/indes/flowerss-bot/internal/config" 7 | ) 8 | 9 | type Version struct { 10 | } 11 | 12 | func NewVersion() *Version { 13 | return &Version{} 14 | } 15 | 16 | func (c *Version) Command() string { 17 | return "/version" 18 | } 19 | 20 | func (c *Version) Description() string { 21 | return "Bot 版本信息" 22 | } 23 | 24 | func (c *Version) Handle(ctx tb.Context) error { 25 | return ctx.Send(config.AppVersionInfo()) 26 | } 27 | 28 | func (c *Version) Middlewares() []tb.MiddlewareFunc { 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | app_name = flowerss-bot 2 | 3 | VERSION=$(shell git describe --tags --always) 4 | DATA=$(shell date) 5 | COMMIT=$(shell git rev-parse --short HEAD) 6 | test: 7 | go test ./... -v 8 | 9 | all: build 10 | 11 | build: get 12 | go build -trimpath -ldflags \ 13 | "-s -w -buildid= \ 14 | -X 'github.com/indes/flowerss-bot/internal/config.commit=$(COMMIT)' \ 15 | -X 'github.com/indes/flowerss-bot/internal/config.date=$(DATA)' \ 16 | -X 'github.com/indes/flowerss-bot/internal/config.version=$(VERSION)'" -o $(app_name) 17 | 18 | get: 19 | go mod download 20 | 21 | run: 22 | go run . 23 | 24 | clean: 25 | rm flowerss-bot -------------------------------------------------------------------------------- /internal/bot/middleware/user_filter.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "fmt" 5 | 6 | tb "gopkg.in/telebot.v3" 7 | 8 | "github.com/indes/flowerss-bot/internal/config" 9 | ) 10 | 11 | func UserFilter() tb.MiddlewareFunc { 12 | return func(next tb.HandlerFunc) tb.HandlerFunc { 13 | return func(c tb.Context) error { 14 | if len(config.AllowUsers) == 0 { 15 | return next(c) 16 | } 17 | userID := c.Sender().ID 18 | for _, allowUserID := range config.AllowUsers { 19 | if allowUserID == userID { 20 | return next(c) 21 | } 22 | } 23 | return fmt.Errorf("deny user %d", userID) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /internal/bot/session/session.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import tb "gopkg.in/telebot.v3" 4 | 5 | // EntityType is a MessageEntity type. 6 | type BotContextStoreKey string 7 | 8 | const ( 9 | StoreKeyMentionChat BotContextStoreKey = "mention_chat" 10 | ) 11 | 12 | func (k BotContextStoreKey) String() string { 13 | return string(k) 14 | } 15 | 16 | func GetMentionChatFromCtxStore(ctx tb.Context) (*tb.Chat, bool) { 17 | v := ctx.Get(StoreKeyMentionChat.String()) 18 | if v == nil { 19 | return nil, false 20 | } 21 | 22 | mentionChat, ok := v.(*tb.Chat) 23 | if !ok { 24 | return nil, false 25 | } 26 | return mentionChat, true 27 | } 28 | -------------------------------------------------------------------------------- /internal/bot/handler/handler.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import tb "gopkg.in/telebot.v3" 4 | 5 | type CommandHandler interface { 6 | // Command string of bot Command 7 | Command() string 8 | // Description of Command 9 | Description() string 10 | // Handle function 11 | Handle(ctx tb.Context) error 12 | // Middlewares Handler middlewares 13 | Middlewares() []tb.MiddlewareFunc 14 | } 15 | 16 | type ButtonHandler interface { 17 | tb.CallbackEndpoint 18 | // Description of Command 19 | Description() string 20 | // Handle function 21 | Handle(ctx tb.Context) error 22 | // Middlewares Handler middlewares 23 | Middlewares() []tb.MiddlewareFunc 24 | } 25 | -------------------------------------------------------------------------------- /internal/bot/handler/start.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "fmt" 5 | 6 | tb "gopkg.in/telebot.v3" 7 | 8 | "github.com/indes/flowerss-bot/internal/log" 9 | ) 10 | 11 | type Start struct { 12 | } 13 | 14 | func NewStart() *Start { 15 | return &Start{} 16 | } 17 | 18 | func (s *Start) Command() string { 19 | return "/start" 20 | } 21 | 22 | func (s *Start) Description() string { 23 | return "开始使用" 24 | } 25 | 26 | func (s *Start) Handle(ctx tb.Context) error { 27 | log.Infof("/start id: %d", ctx.Chat().ID) 28 | return ctx.Send(fmt.Sprintf("你好,欢迎使用flowerss。")) 29 | } 30 | 31 | func (s *Start) Middlewares() []tb.MiddlewareFunc { 32 | return nil 33 | } 34 | -------------------------------------------------------------------------------- /internal/bot/chat/chat.go: -------------------------------------------------------------------------------- 1 | package chat 2 | 3 | import ( 4 | tb "gopkg.in/telebot.v3" 5 | 6 | "github.com/indes/flowerss-bot/internal/log" 7 | ) 8 | 9 | func IsChatAdmin(bot *tb.Bot, chat *tb.Chat, userID int64) bool { 10 | if chat == nil || bot == nil { 11 | log.Errorf("chat or bot is nil, chat %v bot %v", chat, bot) 12 | return false 13 | } 14 | 15 | if chat.Type == tb.ChatPrivate { 16 | return true 17 | } 18 | 19 | admins, err := bot.AdminsOf(chat) 20 | if err != nil { 21 | log.Warnf("get admins of chat %v failed, %v", chat.ID, err) 22 | return false 23 | } 24 | 25 | for _, admin := range admins { 26 | if userID != admin.User.ID { 27 | continue 28 | } 29 | return true 30 | } 31 | return false 32 | } 33 | -------------------------------------------------------------------------------- /internal/bot/preview/util.go: -------------------------------------------------------------------------------- 1 | package preview 2 | 3 | import ( 4 | "html" 5 | "regexp" 6 | "strings" 7 | 8 | strip "github.com/grokify/html-strip-tags-go" 9 | ) 10 | 11 | func TrimDescription(desc string, limit int) string { 12 | if limit == 0 { 13 | return "" 14 | } 15 | desc = strings.Trim( 16 | strip.StripTags( 17 | regexp.MustCompile("\n+").ReplaceAllLiteralString( 18 | strings.ReplaceAll( 19 | regexp.MustCompile(``).ReplaceAllString( 20 | html.UnescapeString(desc), "
", 21 | ), 22 | "
", "\n", 23 | ), 24 | "\n", 25 | ), 26 | ), 27 | "\n", 28 | ) 29 | 30 | contentDescRune := []rune(desc) 31 | if len(contentDescRune) > limit { 32 | desc = string(contentDescRune[:limit]) 33 | } 34 | 35 | return desc 36 | } 37 | -------------------------------------------------------------------------------- /internal/bot/session/attachment.go: -------------------------------------------------------------------------------- 1 | package session 2 | 3 | import ( 4 | "encoding/hex" 5 | 6 | "google.golang.org/protobuf/proto" 7 | 8 | "github.com/indes/flowerss-bot/internal/log" 9 | ) 10 | 11 | // Marshal 编码成字符串 12 | func Marshal(a *Attachment) string { 13 | bytes, err := proto.Marshal(a) 14 | if err != nil { 15 | log.Errorf("marshal attachment failed, %v", err) 16 | return "" 17 | } 18 | return hex.EncodeToString(bytes) 19 | } 20 | 21 | // UnmarshalAttachment 从字符串解析透传信息 22 | func UnmarshalAttachment(data string) (*Attachment, error) { 23 | bytes, err := hex.DecodeString(data) 24 | if err != nil { 25 | return nil, err 26 | } 27 | a := &Attachment{} 28 | if err := proto.Unmarshal(bytes, a); err != nil { 29 | return nil, err 30 | } 31 | return a, nil 32 | } 33 | -------------------------------------------------------------------------------- /internal/model/id_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "testing" 4 | 5 | func Test_genHashID(t *testing.T) { 6 | type args struct { 7 | sLink string 8 | id string 9 | } 10 | tests := []struct { 11 | name string 12 | args args 13 | want string 14 | }{ 15 | { 16 | "case1", args{"http://www.ruanyifeng.com/blog/atom.xml", "tag:www.ruanyifeng.com,2019:/blog//1.2054"}, 17 | "96b2e254", 18 | }, 19 | {"case2", args{"https://rsshub.app/guokr/scientific", "https://www.guokr.com/article/445877/"}, "770fff44"}, 20 | } 21 | for _, tt := range tests { 22 | t.Run( 23 | tt.name, func(t *testing.T) { 24 | if got := GenHashID(tt.args.sLink, tt.args.id); got != tt.want { 25 | t.Errorf("GenHashID() = %v, want %v", got, tt.want) 26 | } 27 | }, 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "os/signal" 6 | "syscall" 7 | 8 | "github.com/indes/flowerss-bot/internal/bot" 9 | "github.com/indes/flowerss-bot/internal/core" 10 | "github.com/indes/flowerss-bot/internal/log" 11 | "github.com/indes/flowerss-bot/internal/scheduler" 12 | ) 13 | 14 | func main() { 15 | appCore := core.NewCoreFormConfig() 16 | if err := appCore.Init(); err != nil { 17 | log.Fatal(err) 18 | } 19 | go handleSignal() 20 | b := bot.NewBot(appCore) 21 | 22 | task := scheduler.NewRssTask(appCore) 23 | task.Register(b) 24 | task.Start() 25 | b.Run() 26 | } 27 | 28 | func handleSignal() { 29 | c := make(chan os.Signal) 30 | signal.Notify(c, os.Interrupt, os.Kill, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM) 31 | 32 | <-c 33 | 34 | os.Exit(0) 35 | } 36 | -------------------------------------------------------------------------------- /internal/bot/middleware/pre_load_mention_chat.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/indes/flowerss-bot/internal/bot/message" 5 | "github.com/indes/flowerss-bot/internal/bot/session" 6 | "github.com/indes/flowerss-bot/internal/log" 7 | 8 | tb "gopkg.in/telebot.v3" 9 | ) 10 | 11 | func PreLoadMentionChat() tb.MiddlewareFunc { 12 | return func(next tb.HandlerFunc) tb.HandlerFunc { 13 | return func(c tb.Context) error { 14 | mention := message.MentionFromMessage(c.Message()) 15 | if mention != "" { 16 | chat, err := c.Bot().ChatByUsername(mention) 17 | if err != nil { 18 | log.Errorf("pre load mention %s chat failed, %v", mention, err) 19 | return next(c) 20 | } 21 | c.Set(session.StoreKeyMentionChat.String(), chat) 22 | } 23 | return next(c) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /internal/preview/publish.go: -------------------------------------------------------------------------------- 1 | package tgraph 2 | 3 | import ( 4 | "html" 5 | "math/rand" 6 | "time" 7 | 8 | "go.uber.org/zap" 9 | 10 | "github.com/indes/flowerss-bot/internal/log" 11 | ) 12 | 13 | func PublishHtml(sourceTitle string, title string, rawLink string, htmlContent string) (string, error) { 14 | htmlContent = html.UnescapeString(htmlContent) 15 | rand.Seed(time.Now().Unix()) // initialize global pseudo random generator 16 | client := clientPool[rand.Intn(len(clientPool))] 17 | if page, err := client.CreatePageWithHTML( 18 | title+" - "+sourceTitle, sourceTitle, rawLink, htmlContent, true, 19 | ); err == nil { 20 | zap.S().Infof("Created telegraph page url: %s", page.URL) 21 | return page.URL, err 22 | } else { 23 | log.Warnf("Create telegraph page failed, error: %s", err) 24 | return "", nil 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /internal/bot/middleware/check_chat_admin.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/indes/flowerss-bot/internal/bot/chat" 5 | "github.com/indes/flowerss-bot/internal/bot/session" 6 | 7 | tb "gopkg.in/telebot.v3" 8 | ) 9 | 10 | func IsChatAdmin() tb.MiddlewareFunc { 11 | return func(next tb.HandlerFunc) tb.HandlerFunc { 12 | return func(c tb.Context) error { 13 | if !chat.IsChatAdmin(c.Bot(), c.Chat(), c.Sender().ID) { 14 | return c.Reply("您不是当前会话的管理员") 15 | } 16 | 17 | v := c.Get(session.StoreKeyMentionChat.String()) 18 | if v != nil { 19 | mentionChat, ok := v.(*tb.Chat) 20 | if !ok { 21 | return c.Reply("内部错误") 22 | } 23 | if !chat.IsChatAdmin(c.Bot(), mentionChat, c.Sender().ID) { 24 | return c.Reply("您不是当前会话的管理员") 25 | } 26 | } 27 | return next(c) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /internal/bot/handler/help.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | tb "gopkg.in/telebot.v3" 5 | ) 6 | 7 | type Help struct { 8 | } 9 | 10 | func NewHelp() *Help { 11 | return &Help{} 12 | } 13 | 14 | func (h *Help) Command() string { 15 | return "/help" 16 | } 17 | 18 | func (h *Help) Description() string { 19 | return "帮助" 20 | } 21 | 22 | func (h *Help) Handle(ctx tb.Context) error { 23 | message := ` 24 | 命令: 25 | /sub 订阅源 26 | /unsub 取消订阅 27 | /list 查看当前订阅源 28 | /set 设置订阅 29 | /check 检查当前订阅 30 | /setfeedtag 设置订阅标签 31 | /setinterval 设置订阅刷新频率 32 | /activeall 开启所有订阅 33 | /pauseall 暂停所有订阅 34 | /help 帮助 35 | /import 导入 OPML 文件 36 | /export 导出 OPML 文件 37 | /unsuball 取消所有订阅 38 | 详细使用方法请看:https://github.com/indes/flowerss-bot 39 | ` 40 | return ctx.Send(message) 41 | } 42 | 43 | func (h *Help) Middlewares() []tb.MiddlewareFunc { 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Test 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [1.18.x, 1.19.x] 8 | os: [ubuntu-latest, macos-latest] 9 | runs-on: ${{ matrix.os }} 10 | steps: 11 | - name: Install Go 12 | uses: actions/setup-go@v2 13 | with: 14 | go-version: ${{ matrix.go-version }} 15 | - name: Checkout code 16 | uses: actions/checkout@v2 17 | 18 | - name: Generate coverage report 19 | run: | 20 | go test -v -race -coverprofile=coverage.txt -covermode=atomic ./... 21 | 22 | - name: Upload coverage report 23 | uses: codecov/codecov-action@v1 24 | with: 25 | token: ${{ secrets.CODECOV_TOKEN }} 26 | file: ./coverage.txt 27 | flags: unittests 28 | name: codecov-umbrella-${{ matrix.os }}-${{ matrix.go-version }} 29 | -------------------------------------------------------------------------------- /internal/feed/parser.go: -------------------------------------------------------------------------------- 1 | package feed 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "net/http" 7 | 8 | "github.com/indes/flowerss-bot/pkg/client" 9 | 10 | "github.com/mmcdole/gofeed" 11 | ) 12 | 13 | type FeedParser struct { 14 | client *client.HttpClient 15 | parser *gofeed.Parser 16 | } 17 | 18 | func NewFeedParser(httpClient *client.HttpClient) *FeedParser { 19 | return &FeedParser{ 20 | client: httpClient, 21 | parser: gofeed.NewParser(), 22 | } 23 | } 24 | 25 | func (p *FeedParser) ParseFromURL(ctx context.Context, URL string) (*gofeed.Feed, error) { 26 | resp, err := p.client.GetWithContext(ctx, URL) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | if resp != nil { 32 | defer func() { 33 | ce := resp.Body.Close() 34 | if ce != nil { 35 | err = ce 36 | } 37 | }() 38 | } 39 | 40 | if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { 41 | return nil, errors.New(resp.Status) 42 | } 43 | return p.parser.Parse(resp.Body) 44 | } 45 | -------------------------------------------------------------------------------- /internal/storage/user_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "gorm.io/driver/sqlite" 9 | "gorm.io/gorm" 10 | 11 | "github.com/indes/flowerss-bot/internal/model" 12 | ) 13 | 14 | func GetTestDB(t *testing.T) *gorm.DB { 15 | db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared")) 16 | if err != nil { 17 | t.Log(err) 18 | return nil 19 | } 20 | return db.Debug() 21 | } 22 | 23 | func TestUserStorageImpl(t *testing.T) { 24 | db := GetTestDB(t) 25 | s := NewUserStorageImpl(db) 26 | ctx := context.Background() 27 | s.Init(ctx) 28 | user := &model.User{ 29 | ID: 123, 30 | } 31 | 32 | t.Run( 33 | "save user", func(t *testing.T) { 34 | err := s.CrateUser(ctx, user) 35 | assert.Nil(t, err) 36 | }, 37 | ) 38 | 39 | t.Run( 40 | "get user", func(t *testing.T) { 41 | got, err := s.GetUser(ctx, user.ID) 42 | assert.Nil(t, err) 43 | assert.NotNil(t, got) 44 | assert.Equal(t, user.ID, got.ID) 45 | }, 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /internal/storage/user.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "gorm.io/gorm" 8 | 9 | "github.com/indes/flowerss-bot/internal/model" 10 | ) 11 | 12 | type UserStorageImpl struct { 13 | db *gorm.DB 14 | } 15 | 16 | func NewUserStorageImpl(db *gorm.DB) *UserStorageImpl { 17 | return &UserStorageImpl{db: db} 18 | } 19 | 20 | func (s *UserStorageImpl) Init(ctx context.Context) error { 21 | return s.db.Migrator().AutoMigrate(&model.User{}) 22 | } 23 | 24 | func (s *UserStorageImpl) CrateUser(ctx context.Context, user *model.User) error { 25 | result := s.db.WithContext(ctx).Create(user) 26 | if result.Error != nil { 27 | return result.Error 28 | } 29 | return nil 30 | } 31 | 32 | func (s *UserStorageImpl) GetUser(ctx context.Context, id int64) (*model.User, error) { 33 | var user = &model.User{} 34 | result := s.db.WithContext(ctx).Where(&model.User{ID: id}).First(user) 35 | if result.Error != nil { 36 | if errors.Is(result.Error, gorm.ErrRecordNotFound) { 37 | return nil, ErrRecordNotFound 38 | } 39 | return nil, result.Error 40 | } 41 | return user, nil 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 indes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/bot/message/message.go: -------------------------------------------------------------------------------- 1 | package message 2 | 3 | import ( 4 | "regexp" 5 | 6 | tb "gopkg.in/telebot.v3" 7 | ) 8 | 9 | // MentionFromMessage get message mention 10 | func MentionFromMessage(m *tb.Message) string { 11 | if m.Text != "" { 12 | for _, entity := range m.Entities { 13 | if entity.Type != tb.EntityMention { 14 | continue 15 | } 16 | return m.Text[entity.Offset : entity.Offset+entity.Length] 17 | } 18 | } 19 | 20 | for _, entity := range m.CaptionEntities { 21 | if entity.Type != tb.EntityMention { 22 | continue 23 | } 24 | return m.Caption[entity.Offset : entity.Offset+entity.Length] 25 | } 26 | return "" 27 | } 28 | 29 | var relaxUrlMatcher = regexp.MustCompile(`^(https?://.*?)($| )`) 30 | 31 | // URLFromMessage get message url 32 | func URLFromMessage(m *tb.Message) string { 33 | for _, entity := range m.Entities { 34 | if entity.Type == tb.EntityURL { 35 | return m.Text[entity.Offset : entity.Offset+entity.Length] 36 | } 37 | } 38 | 39 | var payloadMatching = relaxUrlMatcher.FindStringSubmatch(m.Payload) 40 | if len(payloadMatching) > 0 { 41 | return payloadMatching[0] 42 | } 43 | return "" 44 | } 45 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | ## 使用 2 | 3 | 命令: 4 | 5 | ``` 6 | /sub [url] 订阅(url 为可选) 7 | /unsub [url] 取消订阅(url 为可选) 8 | /list 查看当前订阅 9 | /set 设置订阅 10 | /check 检查当前订阅 11 | /setfeedtag [sub id] [tag1] [tag2] 设置订阅标签(最多设置三个Tag,以空格分隔) 12 | /setinterval [interval] [sub id] 设置订阅刷新频率(可设置多个sub id,以空格分隔) 13 | /activeall 开启所有订阅 14 | /pauseall 暂停所有订阅 15 | /import 导入 OPML 文件 16 | /export 导出 OPML 文件 17 | /unsuball 取消所有订阅 18 | /help 帮助 19 | ``` 20 | 21 | ### Channel 订阅使用方法 22 | 23 | 1. 将 Bot 添加为 Channel 管理员 24 | 2. 发送相关命令给 Bot 25 | 26 | Channel 订阅支持的命令: 27 | 28 | ``` 29 | /sub @ChannelID [url] 订阅 30 | /unsub @ChannelID [url] 取消订阅 31 | /list @ChannelID 查看当前订阅 32 | /check @ChannelID 检查当前订阅 33 | /unsuball @ChannelID 取消所有订阅 34 | /activeall @ChannelID 开启所有订阅 35 | /setfeedtag @ChannelID [sub id] [tag1] [tag2] 设置订阅标签(最多设置三个Tag,以空格分隔) 36 | /import 导入 OPML 文件 37 | /export @ChannelID 导出 OPML 文件 38 | /pauseall @ChannelID 暂停所有订阅 39 | ``` 40 | 41 | **ChannelID 只有设置为 Public Channel 才有。如果是 Private Channel,可以暂时设置为 Public,订阅完成后改为 Private,不影响 Bot 推送消息。** 42 | 43 | 例如要给 t.me/debug 频道订阅 [阮一峰的网络日志](http://www.ruanyifeng.com/blog/atom.xml) RSS 更新: 44 | 45 | 1. 将 Bot 添加到 debug 频道管理员列表中 46 | 2. 给 Bot 发送 `/sub @debug http://www.ruanyifeng.com/blog/atom.xml` 命令 47 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | linux-releases-matrix: 9 | name: Release Matrix 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | goos: [ linux ] 14 | goarch: ["386", amd64] 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | 19 | - name: Set APP_VERSION env 20 | run: echo ::`echo -n name=APP_VERSION`::$(echo ${GITHUB_REF} | rev | cut -d'/' -f 1 | rev ) 21 | - name: Set BUILD_TIME env 22 | run: echo ::`echo -n name=BUILD_TIME`::$(date) 23 | - name: Environment Printer 24 | uses: managedkaos/print-env@v1.0 25 | 26 | - uses: wangyoucao577/go-release-action@master 27 | with: 28 | github_token: ${{ secrets.GITHUB_TOKEN }} 29 | goos: ${{ matrix.goos }} 30 | goarch: ${{ matrix.goarch }} 31 | extra_files: config.yml.sample README.md 32 | build_flags: -v 33 | ldflags: -X 'github.com/indes/flowerss-bot/internal/config.commit=${{ github.sha }}' -X 'github.com/indes/flowerss-bot/internal/config.date=${{ env.BUILD_TIME }}' -X 'github.com/indes/flowerss-bot/internal/config.version=${{ env.APP_VERSION }}' 34 | -------------------------------------------------------------------------------- /internal/storage/content_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/indes/flowerss-bot/internal/model" 10 | ) 11 | 12 | func TestContentStorageImpl(t *testing.T) { 13 | db := GetTestDB(t) 14 | s := NewContentStorageImpl(db) 15 | ctx := context.Background() 16 | s.Init(ctx) 17 | 18 | content := &model.Content{ 19 | SourceID: 1, 20 | HashID: "id", 21 | } 22 | content2 := &model.Content{ 23 | SourceID: 1, 24 | HashID: "id2", 25 | } 26 | 27 | t.Run( 28 | "add content", func(t *testing.T) { 29 | err := s.AddContent(ctx, content) 30 | assert.Nil(t, err) 31 | err = s.AddContent(ctx, content2) 32 | assert.Nil(t, err) 33 | }, 34 | ) 35 | 36 | t.Run( 37 | "hash id exist", func(t *testing.T) { 38 | exist, err := s.HashIDExist(ctx, content.HashID) 39 | assert.Nil(t, err) 40 | assert.True(t, exist) 41 | }, 42 | ) 43 | 44 | t.Run( 45 | "del content", func(t *testing.T) { 46 | got, err := s.DeleteSourceContents(ctx, content.SourceID) 47 | assert.Nil(t, err) 48 | assert.Equal(t, int64(2), got) 49 | }, 50 | ) 51 | 52 | t.Run( 53 | "hash id exist2", func(t *testing.T) { 54 | exist, err := s.HashIDExist(ctx, content.HashID) 55 | assert.Nil(t, err) 56 | assert.False(t, exist) 57 | }, 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /internal/storage/content.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | 6 | "gorm.io/gorm" 7 | 8 | "github.com/indes/flowerss-bot/internal/model" 9 | ) 10 | 11 | type ContentStorageImpl struct { 12 | db *gorm.DB 13 | } 14 | 15 | func NewContentStorageImpl(db *gorm.DB) *ContentStorageImpl { 16 | return &ContentStorageImpl{db: db.Model(&model.Content{})} 17 | } 18 | 19 | func (s *ContentStorageImpl) Init(ctx context.Context) error { 20 | return s.db.Migrator().AutoMigrate(&model.Content{}) 21 | } 22 | 23 | func (s *ContentStorageImpl) DeleteSourceContents(ctx context.Context, sourceID uint) (int64, error) { 24 | result := s.db.WithContext(ctx).Where("source_id = ?", sourceID).Delete(&model.Content{}) 25 | if result.Error != nil { 26 | return 0, result.Error 27 | } 28 | return result.RowsAffected, nil 29 | } 30 | 31 | func (s *ContentStorageImpl) AddContent(ctx context.Context, content *model.Content) error { 32 | result := s.db.WithContext(ctx).Create(content) 33 | if result.Error != nil { 34 | return result.Error 35 | } 36 | return nil 37 | } 38 | 39 | func (s *ContentStorageImpl) HashIDExist(ctx context.Context, hashID string) (bool, error) { 40 | var count int64 41 | result := s.db.WithContext(ctx).Where("hash_id = ?", hashID).Count(&count) 42 | if result.Error != nil { 43 | return false, result.Error 44 | } 45 | return (count > 0), nil 46 | } 47 | -------------------------------------------------------------------------------- /internal/preview/tgraph.go: -------------------------------------------------------------------------------- 1 | package tgraph 2 | 3 | import ( 4 | "github.com/indes/flowerss-bot/internal/config" 5 | "github.com/indes/flowerss-bot/internal/log" 6 | 7 | "github.com/indes/telegraph-go" 8 | ) 9 | 10 | var ( 11 | authToken = config.TelegraphToken 12 | socks5Proxy = config.Socks5 13 | clientPool []*telegraph.Client 14 | ) 15 | 16 | func init() { 17 | if config.EnableTelegraph { 18 | log.Infof("telegraph enabled, count %d, %#v", len(authToken), authToken) 19 | for _, t := range authToken { 20 | client, err := telegraph.Load(t, socks5Proxy) 21 | if err != nil { 22 | log.Errorf("telegraph load %s failed, %v", t, err) 23 | } else { 24 | clientPool = append(clientPool, client) 25 | } 26 | } 27 | 28 | if len(clientPool) == 0 { 29 | if config.TelegraphAccountName == "" { 30 | config.EnableTelegraph = false 31 | log.Error("telegraph token error, telegraph disabled") 32 | } else if len(authToken) == 0 { 33 | // create account 34 | client, err := telegraph.Create( 35 | config.TelegraphAccountName, 36 | config.TelegraphAuthorName, 37 | config.TelegraphAuthorURL, 38 | config.Socks5, 39 | ) 40 | 41 | if err != nil { 42 | config.EnableTelegraph = false 43 | log.Errorf("create telegraph account failed, %v", err) 44 | } 45 | 46 | clientPool = append(clientPool, client) 47 | log.Infof("create telegraph account success, token %v", client.AccessToken) 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flowerss bot 2 | 3 | [![Build Status](https://github.com/indes/flowerss-bot/workflows/Release/badge.svg)](https://github.com/indes/flowerss-bot/actions?query=workflow%3ARelease) 4 | [![Test Status](https://github.com/indes/flowerss-bot/workflows/Test/badge.svg)](https://github.com/indes/flowerss-bot/actions?query=workflow%3ATest) 5 | ![Build Docker Image](https://github.com/indes/flowerss-bot/workflows/Build%20Docker%20Image/badge.svg) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/indes/flowerss-bot)](https://goreportcard.com/report/github.com/indes/flowerss-bot) 7 | ![GitHub](https://img.shields.io/github/license/indes/flowerss-bot.svg) 8 | 9 | [安装与使用文档](https://flowerss-bot.now.sh/) 10 | 11 | 12 | 13 | ## Features 14 | 15 | - 常见的 RSS Bot 该有的功能 16 | - 支持 Telegram 应用内 instant view 17 | - 支持为 Group 和 Channel 订阅 RSS 消息 18 | - 丰富的订阅设置 19 | 20 | ## 安装与使用 21 | 22 | 详细安装与使用方法请查阅项目[使用文档](https://flowerss-bot.now.sh/)。 23 | 24 | 使用命令: 25 | 26 | ``` 27 | /sub [url] 订阅(url 为可选) 28 | /unsub [url] 取消订阅(url 为可选) 29 | /list 查看当前订阅 30 | /set 设置订阅 31 | /check 检查当前订阅 32 | /setfeedtag [sub id] [tag1] [tag2] 设置订阅标签(最多设置三个Tag,以空格分割) 33 | /setinterval [interval] [sub id] 设置订阅刷新频率(可设置多个sub id,以空格分割) 34 | /activeall 开启所有订阅 35 | /pauseall 暂停所有订阅 36 | /import 导入 OPML 文件 37 | /export 导出 OPML 文件 38 | /unsuball 取消所有订阅 39 | /help 帮助 40 | ``` 41 | 详细使用方法请查阅项目[使用文档](https://flowerss-bot.now.sh/#/usage)。 -------------------------------------------------------------------------------- /internal/bot/handler/active_all.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | tb "gopkg.in/telebot.v3" 8 | 9 | "github.com/indes/flowerss-bot/internal/bot/session" 10 | "github.com/indes/flowerss-bot/internal/core" 11 | ) 12 | 13 | type ActiveAll struct { 14 | core *core.Core 15 | } 16 | 17 | func NewActiveAll(core *core.Core) *ActiveAll { 18 | return &ActiveAll{core: core} 19 | } 20 | 21 | func (a *ActiveAll) Command() string { 22 | return "/activeall" 23 | } 24 | 25 | func (a *ActiveAll) Description() string { 26 | return "开启抓取订阅更新" 27 | } 28 | 29 | func (a *ActiveAll) Handle(ctx tb.Context) error { 30 | mentionChat, _ := session.GetMentionChatFromCtxStore(ctx) 31 | subscribeUserID := ctx.Chat().ID 32 | if mentionChat != nil { 33 | subscribeUserID = mentionChat.ID 34 | } 35 | 36 | source, err := a.core.GetUserSubscribedSources(context.Background(), subscribeUserID) 37 | if err != nil { 38 | return ctx.Reply("系统错误") 39 | } 40 | 41 | for _, s := range source { 42 | err := a.core.EnableSourceUpdate(context.Background(), s.ID) 43 | if err != nil { 44 | return ctx.Reply("激活失败") 45 | } 46 | } 47 | 48 | reply := "订阅已全部开启" 49 | if mentionChat != nil { 50 | reply = fmt.Sprintf("频道 [%s](https://t.me/%s) 订阅已全部开启", mentionChat.Title, mentionChat.Username) 51 | } 52 | 53 | return ctx.Reply( 54 | reply, &tb.SendOptions{ 55 | DisableWebPagePreview: true, 56 | ParseMode: tb.ModeMarkdown, 57 | }, 58 | ) 59 | } 60 | 61 | func (a *ActiveAll) Middlewares() []tb.MiddlewareFunc { 62 | return nil 63 | } 64 | -------------------------------------------------------------------------------- /internal/storage/source_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/indes/flowerss-bot/internal/model" 10 | ) 11 | 12 | func TestSourceStorageImpl(t *testing.T) { 13 | db := GetTestDB(t) 14 | s := NewSourceStorageImpl(db) 15 | ctx := context.Background() 16 | s.Init(ctx) 17 | 18 | source := &model.Source{ 19 | Link: "http://google.com", 20 | } 21 | 22 | t.Run( 23 | "add source", func(t *testing.T) { 24 | err := s.AddSource(ctx, source) 25 | assert.Nil(t, err) 26 | 27 | got, err := s.GetSource(ctx, source.ID) 28 | assert.Nil(t, err) 29 | assert.NotNil(t, got) 30 | assert.Equal(t, source.Link, got.Link) 31 | 32 | got, err = s.GetSourceByURL(ctx, source.Link) 33 | assert.Nil(t, err) 34 | assert.NotNil(t, got) 35 | assert.Equal(t, source.ID, got.ID) 36 | 37 | err = s.Delete(ctx, got.ID) 38 | assert.Nil(t, err) 39 | 40 | got, err = s.GetSource(ctx, source.ID) 41 | assert.Equal(t, ErrRecordNotFound, err) 42 | assert.Nil(t, got) 43 | }, 44 | ) 45 | 46 | t.Run( 47 | "update source", func(t *testing.T) { 48 | source := &model.Source{ 49 | ID: 1, 50 | Link: "http://google.com", 51 | Title: "title", 52 | } 53 | err := s.UpsertSource(ctx, source.ID, source) 54 | assert.Nil(t, err) 55 | 56 | source.Title = "title2" 57 | err = s.UpsertSource(ctx, source.ID, source) 58 | assert.Nil(t, err) 59 | 60 | got, err := s.GetSource(ctx, source.ID) 61 | assert.Nil(t, err) 62 | assert.Equal(t, source.Title, got.Title) 63 | }, 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /internal/bot/handler/pause_all.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | tb "gopkg.in/telebot.v3" 8 | 9 | "github.com/indes/flowerss-bot/internal/bot/session" 10 | "github.com/indes/flowerss-bot/internal/core" 11 | ) 12 | 13 | type PauseAll struct { 14 | core *core.Core 15 | } 16 | 17 | func NewPauseAll(core *core.Core) *PauseAll { 18 | return &PauseAll{core: core} 19 | } 20 | 21 | func (p *PauseAll) Command() string { 22 | return "/pauseall" 23 | } 24 | 25 | func (p *PauseAll) Description() string { 26 | return "停止抓取所有订阅更新" 27 | } 28 | 29 | func (p *PauseAll) Handle(ctx tb.Context) error { 30 | subscribeUserID := ctx.Message().Chat.ID 31 | var channelChat *tb.Chat 32 | v := ctx.Get(session.StoreKeyMentionChat.String()) 33 | if v != nil { 34 | var ok bool 35 | channelChat, ok = v.(*tb.Chat) 36 | if ok && channelChat != nil { 37 | subscribeUserID = channelChat.ID 38 | } 39 | } 40 | 41 | source, err := p.core.GetUserSubscribedSources(context.Background(), subscribeUserID) 42 | if err != nil { 43 | return ctx.Reply("系统错误") 44 | } 45 | 46 | for _, s := range source { 47 | err := p.core.DisableSourceUpdate(context.Background(), s.ID) 48 | if err != nil { 49 | return ctx.Reply("暂停失败") 50 | } 51 | } 52 | 53 | reply := "订阅已全部暂停" 54 | if channelChat != nil { 55 | reply = fmt.Sprintf("频道 [%s](https://t.me/%s) 订阅已全部暂停", channelChat.Title, channelChat.Username) 56 | } 57 | return ctx.Send( 58 | reply, &tb.SendOptions{ 59 | DisableWebPagePreview: true, 60 | ParseMode: tb.ModeMarkdown, 61 | }, 62 | ) 63 | } 64 | 65 | func (p *PauseAll) Middlewares() []tb.MiddlewareFunc { 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | flowerss bot 7 | 8 | 9 | 10 | 12 | 13 | 14 | 15 | 16 |
Please wait...
17 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /internal/bot/handler/set_feed_tag.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/spf13/cast" 8 | tb "gopkg.in/telebot.v3" 9 | 10 | "github.com/indes/flowerss-bot/internal/bot/message" 11 | "github.com/indes/flowerss-bot/internal/bot/session" 12 | "github.com/indes/flowerss-bot/internal/core" 13 | ) 14 | 15 | type SetFeedTag struct { 16 | core *core.Core 17 | } 18 | 19 | func NewSetFeedTag(core *core.Core) *SetFeedTag { 20 | return &SetFeedTag{core: core} 21 | } 22 | 23 | func (s *SetFeedTag) Command() string { 24 | return "/setfeedtag" 25 | } 26 | 27 | func (s *SetFeedTag) Description() string { 28 | return "设置rss订阅标签" 29 | } 30 | 31 | func (s *SetFeedTag) getMessageWithoutMention(ctx tb.Context) string { 32 | mention := message.MentionFromMessage(ctx.Message()) 33 | if mention == "" { 34 | return ctx.Message().Payload 35 | } 36 | return strings.Replace(ctx.Message().Payload, mention, "", -1) 37 | } 38 | 39 | func (s *SetFeedTag) Handle(ctx tb.Context) error { 40 | msg := s.getMessageWithoutMention(ctx) 41 | args := strings.Split(strings.TrimSpace(msg), " ") 42 | if len(args) < 1 { 43 | return ctx.Reply("/setfeedtag [sourceID] [tag1] [tag2] 设置订阅标签(最多设置三个Tag,以空格分割)") 44 | } 45 | 46 | // 截短参数 47 | if len(args) > 4 { 48 | args = args[:4] 49 | } 50 | 51 | sourceID := cast.ToUint(args[0]) 52 | mentionChat, _ := session.GetMentionChatFromCtxStore(ctx) 53 | subscribeUserID := ctx.Chat().ID 54 | if mentionChat != nil { 55 | subscribeUserID = mentionChat.ID 56 | } 57 | 58 | if err := s.core.SetSubscriptionTag(context.Background(), subscribeUserID, sourceID, args[1:]); err != nil { 59 | return ctx.Reply("订阅标签设置失败!") 60 | } 61 | return ctx.Reply("订阅标签设置成功!") 62 | } 63 | 64 | func (s *SetFeedTag) Middlewares() []tb.MiddlewareFunc { 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /internal/bot/handler/set_subscription_tag_button.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "fmt" 5 | 6 | tb "gopkg.in/telebot.v3" 7 | 8 | "github.com/indes/flowerss-bot/internal/bot/chat" 9 | "github.com/indes/flowerss-bot/internal/bot/session" 10 | ) 11 | 12 | const ( 13 | SetSubscriptionTagButtonUnique = "set_set_sub_tag_btn" 14 | ) 15 | 16 | type SetSubscriptionTagButton struct { 17 | bot *tb.Bot 18 | } 19 | 20 | func NewSetSubscriptionTagButton(bot *tb.Bot) *SetSubscriptionTagButton { 21 | return &SetSubscriptionTagButton{bot: bot} 22 | } 23 | 24 | func (b *SetSubscriptionTagButton) CallbackUnique() string { 25 | return "\f" + SetSubscriptionTagButtonUnique 26 | } 27 | 28 | func (b *SetSubscriptionTagButton) Description() string { 29 | return "" 30 | } 31 | 32 | func (b *SetSubscriptionTagButton) feedSetAuth(c *tb.Callback, attachData *session.Attachment) bool { 33 | subscriberID := attachData.GetUserId() 34 | // 如果订阅者与按钮点击者id不一致,需要验证管理员权限 35 | if subscriberID != c.Sender.ID { 36 | channelChat, err := b.bot.ChatByID(subscriberID) 37 | if err != nil { 38 | return false 39 | } 40 | 41 | if !chat.IsChatAdmin(b.bot, channelChat, c.Sender.ID) { 42 | return false 43 | } 44 | } 45 | return true 46 | } 47 | 48 | func (b *SetSubscriptionTagButton) Handle(ctx tb.Context) error { 49 | c := ctx.Callback() 50 | attachData, err := session.UnmarshalAttachment(ctx.Callback().Data) 51 | if err != nil { 52 | return ctx.Edit("系统错误!") 53 | } 54 | 55 | // 权限验证 56 | if !b.feedSetAuth(c, attachData) { 57 | return ctx.Send("无权限") 58 | } 59 | sourceID := uint(attachData.GetSourceId()) 60 | msg := fmt.Sprintf( 61 | "请使用`/setfeedtag %d tags`命令为该订阅设置标签,tags为需要设置的标签,以空格分隔。(最多设置三个标签) \n"+ 62 | "例如:`/setfeedtag %d 科技 苹果`", 63 | sourceID, sourceID, 64 | ) 65 | return ctx.Edit(msg, &tb.SendOptions{ParseMode: tb.ModeMarkdown}) 66 | } 67 | 68 | func (b *SetSubscriptionTagButton) Middlewares() []tb.MiddlewareFunc { 69 | return nil 70 | } 71 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/indes/flowerss-bot 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/go-sql-driver/mysql v1.6.0 7 | github.com/golang/mock v1.6.0 8 | github.com/grokify/html-strip-tags-go v0.0.0-20200923094847-079d207a09f1 9 | github.com/indes/telegraph-go v1.0.1 10 | github.com/mmcdole/gofeed v1.2.1 11 | github.com/spf13/cast v1.5.0 12 | github.com/spf13/viper v1.13.0 13 | github.com/stretchr/testify v1.8.1 14 | go.uber.org/atomic v1.9.0 15 | go.uber.org/zap v1.23.0 16 | google.golang.org/protobuf v1.28.1 17 | gopkg.in/telebot.v3 v3.1.0 18 | gorm.io/driver/mysql v1.3.6 19 | gorm.io/driver/sqlite v1.3.6 20 | gorm.io/gorm v1.23.10 21 | ) 22 | 23 | require ( 24 | github.com/PuerkitoBio/goquery v1.8.0 // indirect 25 | github.com/andybalholm/cascadia v1.3.1 // indirect 26 | github.com/davecgh/go-spew v1.1.1 // indirect 27 | github.com/fsnotify/fsnotify v1.5.4 // indirect 28 | github.com/hashicorp/hcl v1.0.0 // indirect 29 | github.com/jinzhu/inflection v1.0.0 // indirect 30 | github.com/jinzhu/now v1.1.5 // indirect 31 | github.com/json-iterator/go v1.1.12 // indirect 32 | github.com/magiconair/properties v1.8.6 // indirect 33 | github.com/mattn/go-sqlite3 v1.14.15 // indirect 34 | github.com/mitchellh/mapstructure v1.5.0 // indirect 35 | github.com/mmcdole/goxpp v1.1.0 // indirect 36 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 37 | github.com/modern-go/reflect2 v1.0.2 // indirect 38 | github.com/pelletier/go-toml v1.9.5 // indirect 39 | github.com/pelletier/go-toml/v2 v2.0.5 // indirect 40 | github.com/pmezard/go-difflib v1.0.0 // indirect 41 | github.com/spf13/afero v1.8.2 // indirect 42 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 43 | github.com/spf13/pflag v1.0.5 // indirect 44 | github.com/subosito/gotenv v1.4.1 // indirect 45 | go.uber.org/multierr v1.6.0 // indirect 46 | golang.org/x/net v0.4.0 // indirect 47 | golang.org/x/sys v0.3.0 // indirect 48 | golang.org/x/text v0.5.0 // indirect 49 | gopkg.in/ini.v1 v1.67.0 // indirect 50 | gopkg.in/yaml.v2 v2.4.0 // indirect 51 | gopkg.in/yaml.v3 v3.0.1 // indirect 52 | ) 53 | -------------------------------------------------------------------------------- /internal/bot/handler/set_update_interval.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/spf13/cast" 9 | tb "gopkg.in/telebot.v3" 10 | 11 | "github.com/indes/flowerss-bot/internal/bot/message" 12 | "github.com/indes/flowerss-bot/internal/bot/session" 13 | "github.com/indes/flowerss-bot/internal/core" 14 | "github.com/indes/flowerss-bot/internal/log" 15 | ) 16 | 17 | type SetUpdateInterval struct { 18 | core *core.Core 19 | } 20 | 21 | func NewSetUpdateInterval(core *core.Core) *SetUpdateInterval { 22 | return &SetUpdateInterval{core: core} 23 | } 24 | 25 | func (s *SetUpdateInterval) Command() string { 26 | return "/setinterval" 27 | } 28 | 29 | func (s *SetUpdateInterval) Description() string { 30 | return "设置订阅刷新频率" 31 | } 32 | 33 | func (s *SetUpdateInterval) getMessageWithoutMention(ctx tb.Context) string { 34 | mention := message.MentionFromMessage(ctx.Message()) 35 | if mention == "" { 36 | return ctx.Message().Payload 37 | } 38 | return strings.Replace(ctx.Message().Payload, mention, "", -1) 39 | } 40 | 41 | func (s *SetUpdateInterval) Handle(ctx tb.Context) error { 42 | msg := s.getMessageWithoutMention(ctx) 43 | args := strings.Split(strings.TrimSpace(msg), " ") 44 | if len(args) < 2 { 45 | return ctx.Reply("/setinterval [interval] [sourceID] 设置订阅刷新频率(可设置多个sub id,以空格分割)") 46 | } 47 | 48 | interval, err := strconv.Atoi(args[0]) 49 | if interval <= 0 || err != nil { 50 | return ctx.Reply("请输入正确的抓取频率") 51 | } 52 | 53 | subscribeUserID := ctx.Message().Chat.ID 54 | mentionChat, _ := session.GetMentionChatFromCtxStore(ctx) 55 | if mentionChat != nil { 56 | subscribeUserID = mentionChat.ID 57 | } 58 | 59 | for _, id := range args[1:] { 60 | sourceID := cast.ToUint(id) 61 | if err := s.core.SetSubscriptionInterval( 62 | context.Background(), subscribeUserID, sourceID, interval, 63 | ); err != nil { 64 | log.Errorf("SetSubscriptionInterval failed, %v", err) 65 | return ctx.Reply("抓取频率设置失败!") 66 | } 67 | } 68 | return ctx.Reply("抓取频率设置成功!") 69 | } 70 | 71 | func (s *SetUpdateInterval) Middlewares() []tb.MiddlewareFunc { 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /internal/config/autoload_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "gopkg.in/telebot.v3" 8 | ) 9 | 10 | func TestTplData_Render(t1 *testing.T) { 11 | type fields struct { 12 | SourceTitle string 13 | ContentTitle string 14 | RawLink string 15 | PreviewText string 16 | TelegraphURL string 17 | Tags string 18 | EnableTelegraph bool 19 | } 20 | type args struct { 21 | mode telebot.ParseMode 22 | } 23 | tests := []struct { 24 | name string 25 | fields fields 26 | args args 27 | want string 28 | wantErr bool 29 | }{ 30 | //{ 31 | // "markdown", 32 | // fields{SourceTitle: "[aaa](qq) *123*"}, 33 | // args{telebot.ModeMarkdown}, 34 | // "** \\[aaa](qq) \\*123\\* **\n[]()", 35 | // false, 36 | //}, 37 | {"HTML Mode", 38 | fields{SourceTitle: "[aaa] *123*", ContentTitle: "google", RawLink: "https://google.com"}, 39 | args{telebot.ModeHTML}, 40 | "[aaa] *123*\ngoogle", 41 | false, 42 | }, 43 | } 44 | for _, tt := range tests { 45 | t1.Run(tt.name, func(t1 *testing.T) { 46 | t := TplData{ 47 | SourceTitle: tt.fields.SourceTitle, 48 | ContentTitle: tt.fields.ContentTitle, 49 | RawLink: tt.fields.RawLink, 50 | PreviewText: tt.fields.PreviewText, 51 | TelegraphURL: tt.fields.TelegraphURL, 52 | Tags: tt.fields.Tags, 53 | EnableTelegraph: tt.fields.EnableTelegraph, 54 | } 55 | got, err := t.Render(tt.args.mode) 56 | 57 | assert.Equal(t1, tt.want, got) 58 | assert.Equal(t1, err != nil, tt.wantErr) 59 | }) 60 | } 61 | } 62 | 63 | func TestTplData_replaceHTMLTags(t1 *testing.T) { 64 | tests := []struct { 65 | name string 66 | arg string 67 | want string 68 | }{ 69 | {"case1", "", "<hello>"}, 70 | {"case2", "<\"hello\">", "<"hello">"}, 71 | } 72 | for _, tt := range tests { 73 | t1.Run(tt.name, func(t1 *testing.T) { 74 | t := TplData{} 75 | 76 | got := t.replaceHTMLTags(tt.arg) 77 | assert.Equal(t1, tt.want, got) 78 | 79 | }) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: flowerss-bot 2 | 3 | env: 4 | - GO111MODULE=on 5 | 6 | before: 7 | hooks: 8 | - go mod download 9 | 10 | builds: 11 | - id: darwin 12 | ldflags: -s -w -X github.com/indes/flowerss-bot/internal/config.version={{.Version}} -X github.com/indes/flowerss-bot/internal/config.date={{.Date}} -X github.com/indes/flowerss-bot/internal/config.commit={{ .ShortCommit }} 13 | env: 14 | - CGO_ENABLED=1 15 | - CC=o64-clang 16 | - CXX=o64-clang++ 17 | goos: 18 | - darwin 19 | goarch: 20 | - amd64 21 | 22 | - id: linux 23 | ldflags: -s -w -X github.com/indes/flowerss-bot/internal/config.version={{.Version}} -X github.com/indes/flowerss-bot/internal/config.date={{.Date}} -X github.com/indes/flowerss-bot/internal/config.commit={{ .ShortCommit }} 24 | env: 25 | - CGO_ENABLED=1 26 | goos: 27 | - linux 28 | goarch: 29 | - amd64 30 | 31 | - id: windows 32 | ldflags: -s -w -X github.com/indes/flowerss-bot/internal/config.version={{.Version}} -X github.com/indes/flowerss-bot/internal/config.date={{.Date}} -X github.com/indes/flowerss-bot/internal/config.commit={{ .ShortCommit }} 33 | env: 34 | - CGO_ENABLED=1 35 | - CC=x86_64-w64-mingw32-gcc 36 | - CXX=x86_64-w64-mingw32-g++ 37 | goos: 38 | - windows 39 | goarch: 40 | - amd64 41 | 42 | 43 | archives: 44 | - format: tar.gz 45 | format_overrides: 46 | - goos: windows 47 | format: zip 48 | name_template: "{{.ProjectName}}_{{.Version}}_{{.Os}}-{{.Arch}}" 49 | replacements: 50 | amd64: 64bit 51 | 386: 32bit 52 | arm: ARM 53 | arm64: ARM64 54 | darwin: macOS 55 | linux: Linux 56 | windows: Windows 57 | openbsd: OpenBSD 58 | netbsd: NetBSD 59 | freebsd: FreeBSD 60 | dragonfly: DragonFlyBSD 61 | files: 62 | - README.md 63 | - config.yml.sample 64 | - LICENSE 65 | 66 | checksum: 67 | name_template: 'checksums.txt' 68 | 69 | snapshot: 70 | name_template: "{{ .Tag }}-next" 71 | 72 | changelog: 73 | sort: asc 74 | filters: 75 | exclude: 76 | - '^docs:' 77 | - '^test:' 78 | - '^dev:' 79 | - 'README' 80 | - Merge pull request 81 | - Merge branch -------------------------------------------------------------------------------- /pkg/client/http.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/url" 7 | "time" 8 | ) 9 | 10 | type HttpClientOptions struct { 11 | UserAgent string 12 | Timeout time.Duration 13 | ProxyURL string 14 | } 15 | 16 | func NewHttpClientOptions() *HttpClientOptions { 17 | return &HttpClientOptions{Timeout: time.Second} 18 | } 19 | 20 | type HttpClientOption func(opts *HttpClientOptions) 21 | 22 | func WithUserAgent(userAgent string) HttpClientOption { 23 | return func(o *HttpClientOptions) { 24 | o.UserAgent = userAgent 25 | } 26 | } 27 | 28 | func WithTimeout(timeout time.Duration) HttpClientOption { 29 | return func(o *HttpClientOptions) { 30 | o.Timeout = timeout 31 | } 32 | } 33 | 34 | func WithProxyURL(url string) HttpClientOption { 35 | return func(opts *HttpClientOptions) { 36 | opts.ProxyURL = url 37 | } 38 | } 39 | 40 | type HttpClient struct { 41 | client *http.Client 42 | userAgent string 43 | } 44 | 45 | func NewHttpClient(opts ...HttpClientOption) *HttpClient { 46 | o := NewHttpClientOptions() 47 | for _, opt := range opts { 48 | opt(o) 49 | } 50 | 51 | transport := &http.Transport{} 52 | if o.ProxyURL != "" { 53 | proxyURL, _ := url.Parse(o.ProxyURL) 54 | transport.Proxy = http.ProxyURL(proxyURL) 55 | } 56 | 57 | client := &http.Client{ 58 | Timeout: o.Timeout, 59 | Transport: transport, 60 | } 61 | 62 | return &HttpClient{ 63 | client: client, 64 | userAgent: o.UserAgent, 65 | } 66 | } 67 | 68 | func (c *HttpClient) GetWithContext(ctx context.Context, url string, opts ...HttpClientOption) (*http.Response, error) { 69 | o := NewHttpClientOptions() 70 | for _, opt := range opts { 71 | opt(o) 72 | } 73 | 74 | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | if c.userAgent != "" { 80 | req.Header.Set("User-Agent", c.userAgent) 81 | } 82 | if o.UserAgent != "" { 83 | req.Header.Set("User-Agent", o.UserAgent) 84 | } 85 | return c.client.Do(req) 86 | } 87 | 88 | func (c *HttpClient) Get(url string, opts ...HttpClientOption) (*http.Response, error) { 89 | return c.GetWithContext(context.Background(), url, opts...) 90 | } 91 | 92 | // Client method returns the current `http.Client` used by HttpClient. 93 | func (c *HttpClient) Client() *http.Client { 94 | return c.client 95 | } 96 | -------------------------------------------------------------------------------- /internal/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/indes/flowerss-bot/internal/config" 7 | 8 | "go.uber.org/zap" 9 | "go.uber.org/zap/zapcore" 10 | ) 11 | 12 | var ( 13 | // Logger 日志对象 14 | Logger *zap.Logger 15 | globalLogger *zap.Logger 16 | zapConfig zap.Config 17 | ) 18 | 19 | func init() { 20 | logLevel := config.GetString("log.level") 21 | if strings.ToLower(logLevel) == "debug" { 22 | zapConfig.Level = zap.NewAtomicLevelAt(zapcore.DebugLevel) 23 | zapConfig.EncoderConfig = zap.NewDevelopmentEncoderConfig() 24 | } else { 25 | zapConfig.Level = zap.NewAtomicLevelAt(zapcore.InfoLevel) 26 | zapConfig.EncoderConfig = zap.NewProductionEncoderConfig() 27 | } 28 | 29 | //日志时间戳人类可读 30 | zapConfig.EncoderConfig.EncodeTime = zapcore.RFC3339TimeEncoder 31 | 32 | logFile := config.GetString("log.file") 33 | if logFile != "" { 34 | zapConfig.Sampling = &zap.SamplingConfig{ 35 | Initial: 100, 36 | Thereafter: 100, 37 | } 38 | zapConfig.Encoding = "json" 39 | zapConfig.OutputPaths = []string{logFile} 40 | zapConfig.ErrorOutputPaths = []string{logFile} 41 | 42 | } else { 43 | zapConfig.OutputPaths = []string{"stderr"} 44 | zapConfig.ErrorOutputPaths = []string{"stderr"} 45 | zapConfig.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder 46 | zapConfig.Encoding = "console" 47 | } 48 | 49 | Logger, _ = zapConfig.Build() 50 | zap.ReplaceGlobals(Logger) 51 | globalLogger = Logger.WithOptions(zap.AddCallerSkip(1)) 52 | } 53 | 54 | func Warn(args ...interface{}) { 55 | globalLogger.Sugar().Warn(args...) 56 | } 57 | 58 | func Warnf(template string, args ...interface{}) { 59 | globalLogger.Sugar().Warnf(template, args...) 60 | } 61 | 62 | func Errorf(template string, args ...interface{}) { 63 | globalLogger.Sugar().Errorf(template, args...) 64 | } 65 | 66 | func Error(args ...interface{}) { 67 | globalLogger.Sugar().Error(args...) 68 | } 69 | 70 | func Info(args ...interface{}) { 71 | globalLogger.Sugar().Info(args...) 72 | } 73 | 74 | func Infof(template string, args ...interface{}) { 75 | globalLogger.Sugar().Infof(template, args...) 76 | } 77 | 78 | func Fatal(args ...interface{}) { 79 | globalLogger.Sugar().Fatal(args...) 80 | } 81 | 82 | // Fatalf uses fmt.Sprintf to log a templated message, then calls os.Exit. 83 | func Fatalf(template string, args ...interface{}) { 84 | globalLogger.Sugar().Fatalf(template, args...) 85 | } 86 | 87 | func Debugf(template string, args ...interface{}) { 88 | globalLogger.Sugar().Debugf(template, args...) 89 | } 90 | -------------------------------------------------------------------------------- /internal/bot/handler/subscription_switch_button.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "text/template" 7 | 8 | tb "gopkg.in/telebot.v3" 9 | 10 | "github.com/indes/flowerss-bot/internal/bot/chat" 11 | "github.com/indes/flowerss-bot/internal/bot/session" 12 | "github.com/indes/flowerss-bot/internal/config" 13 | "github.com/indes/flowerss-bot/internal/core" 14 | ) 15 | 16 | const ( 17 | SubscriptionSwitchButtonUnique = "set_toggle_update_btn" 18 | ) 19 | 20 | type SubscriptionSwitchButton struct { 21 | bot *tb.Bot 22 | core *core.Core 23 | } 24 | 25 | func NewSubscriptionSwitchButton(bot *tb.Bot, core *core.Core) *SubscriptionSwitchButton { 26 | return &SubscriptionSwitchButton{bot: bot, core: core} 27 | } 28 | 29 | func (b *SubscriptionSwitchButton) CallbackUnique() string { 30 | return "\f" + SubscriptionSwitchButtonUnique 31 | } 32 | 33 | func (b *SubscriptionSwitchButton) Description() string { 34 | return "" 35 | } 36 | 37 | func (b *SubscriptionSwitchButton) Handle(ctx tb.Context) error { 38 | c := ctx.Callback() 39 | if c == nil { 40 | return ctx.Respond(&tb.CallbackResponse{Text: "error"}) 41 | } 42 | 43 | attachData, err := session.UnmarshalAttachment(ctx.Callback().Data) 44 | subscriberID := attachData.GetUserId() 45 | if subscriberID != c.Sender.ID { 46 | // 如果订阅者与按钮点击者id不一致,需要验证管理员权限 47 | channelChat, err := b.bot.ChatByID(subscriberID) 48 | if err != nil { 49 | return ctx.Respond(&tb.CallbackResponse{Text: "error"}) 50 | } 51 | if !chat.IsChatAdmin(b.bot, channelChat, c.Sender.ID) { 52 | return ctx.Respond(&tb.CallbackResponse{Text: "error"}) 53 | } 54 | } 55 | 56 | sourceID := uint(attachData.GetSourceId()) 57 | sub, err := b.core.GetSubscription(context.Background(), subscriberID, sourceID) 58 | if sub == nil || err != nil { 59 | return ctx.Respond(&tb.CallbackResponse{Text: "error"}) 60 | } 61 | 62 | err = b.core.ToggleSourceUpdateStatus(context.Background(), sourceID) 63 | if err != nil { 64 | return ctx.Respond(&tb.CallbackResponse{Text: "error"}) 65 | } 66 | 67 | source, _ := b.core.GetSource(context.Background(), sourceID) 68 | t := template.New("setting template") 69 | _, _ = t.Parse(feedSettingTmpl) 70 | 71 | text := new(bytes.Buffer) 72 | _ = t.Execute(text, map[string]interface{}{"source": source, "sub": sub, "Count": config.ErrorThreshold}) 73 | _ = ctx.Respond(&tb.CallbackResponse{Text: "修改成功"}) 74 | return ctx.Edit( 75 | text.String(), 76 | &tb.SendOptions{ParseMode: tb.ModeHTML}, 77 | &tb.ReplyMarkup{InlineKeyboard: genFeedSetBtn(c, sub, source)}, 78 | ) 79 | } 80 | 81 | func (b *SubscriptionSwitchButton) Middlewares() []tb.MiddlewareFunc { 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /internal/storage/source.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "gorm.io/gorm" 8 | 9 | "github.com/indes/flowerss-bot/internal/log" 10 | "github.com/indes/flowerss-bot/internal/model" 11 | ) 12 | 13 | type SourceStorageImpl struct { 14 | db *gorm.DB 15 | } 16 | 17 | func NewSourceStorageImpl(db *gorm.DB) *SourceStorageImpl { 18 | return &SourceStorageImpl{db: db} 19 | } 20 | 21 | func (s *SourceStorageImpl) Init(ctx context.Context) error { 22 | return s.db.Migrator().AutoMigrate(&model.Source{}) 23 | } 24 | 25 | func (s *SourceStorageImpl) AddSource(ctx context.Context, source *model.Source) error { 26 | result := s.db.WithContext(ctx).Create(source) 27 | if result.Error != nil { 28 | return result.Error 29 | } 30 | return nil 31 | } 32 | 33 | func (s *SourceStorageImpl) GetSource(ctx context.Context, id uint) (*model.Source, error) { 34 | var source = &model.Source{} 35 | result := s.db.WithContext(ctx).Where(&model.Source{ID: id}).First(source) 36 | if result.Error != nil { 37 | if errors.Is(result.Error, gorm.ErrRecordNotFound) { 38 | return nil, ErrRecordNotFound 39 | } 40 | return nil, result.Error 41 | } 42 | 43 | return source, nil 44 | } 45 | 46 | func (s *SourceStorageImpl) GetSources(ctx context.Context) ([]*model.Source, error) { 47 | var sources []*model.Source 48 | result := s.db.WithContext(ctx).Find(&sources) 49 | if result.Error != nil { 50 | if errors.Is(result.Error, gorm.ErrRecordNotFound) { 51 | return nil, ErrRecordNotFound 52 | } 53 | return nil, result.Error 54 | } 55 | return sources, nil 56 | } 57 | 58 | func (s *SourceStorageImpl) GetSourceByURL(ctx context.Context, url string) (*model.Source, error) { 59 | var source = &model.Source{} 60 | result := s.db.WithContext(ctx).Where(&model.Source{Link: url}).First(source) 61 | if result.Error != nil { 62 | if errors.Is(result.Error, gorm.ErrRecordNotFound) { 63 | return nil, ErrRecordNotFound 64 | } 65 | return nil, result.Error 66 | } 67 | return source, nil 68 | } 69 | 70 | func (s *SourceStorageImpl) Delete(ctx context.Context, id uint) error { 71 | result := s.db.WithContext(ctx).Where("id = ?", id).Delete(&model.Source{}) 72 | if result.Error != nil { 73 | return result.Error 74 | } 75 | return nil 76 | } 77 | 78 | func (s *SourceStorageImpl) UpsertSource(ctx context.Context, sourceID uint, newSource *model.Source) error { 79 | newSource.ID = sourceID 80 | result := s.db.WithContext(ctx).Where("id = ?", sourceID).Save(newSource) 81 | if result.Error != nil { 82 | return result.Error 83 | } 84 | log.Debugf("update %d row, sourceID %d new %#v", result.RowsAffected, sourceID, newSource) 85 | return nil 86 | } 87 | -------------------------------------------------------------------------------- /pkg/client/http_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func createTestServer(t *testing.T) *httptest.Server { 15 | handle := func(w http.ResponseWriter, r *http.Request) { 16 | t.Logf("Method: %v", r.Method) 17 | t.Logf("Path: %v", r.URL.Path) 18 | 19 | if r.Method == http.MethodGet { 20 | switch r.URL.Path { 21 | case "/": 22 | _, _ = w.Write([]byte("/")) 23 | 24 | case "/useragent": 25 | for i := range r.Header["User-Agent"] { 26 | _, _ = w.Write([]byte(r.Header["User-Agent"][i])) 27 | } 28 | case "/timeout": 29 | time.Sleep(time.Second) 30 | } 31 | } 32 | } 33 | return httptest.NewServer(http.HandlerFunc(handle)) 34 | } 35 | 36 | func TestHttpClient_Get(t *testing.T) { 37 | ts := createTestServer(t) 38 | defer ts.Close() 39 | 40 | t.Run("get", func(t *testing.T) { 41 | client := NewHttpClient() 42 | 43 | resp, err := client.Get(ts.URL) 44 | assert.Equal(t, http.StatusOK, resp.StatusCode) 45 | assert.Nil(t, err) 46 | }) 47 | 48 | t.Run("custom client user-agent", func(t *testing.T) { 49 | userAgent := "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36" 50 | client := NewHttpClient(WithUserAgent(userAgent)) 51 | url := fmt.Sprintf("%s/useragent", ts.URL) 52 | resp, err := client.Get(url) 53 | assert.Nil(t, err) 54 | assert.Equal(t, http.StatusOK, resp.StatusCode) 55 | 56 | body, err := io.ReadAll(resp.Body) 57 | assert.Nil(t, err) 58 | t.Logf("Got: %v", string(body)) 59 | assert.Equal(t, userAgent, string(body)) 60 | }) 61 | 62 | t.Run("custom get user-agent", func(t *testing.T) { 63 | userAgent := "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36" 64 | client := NewHttpClient() 65 | url := fmt.Sprintf("%s/useragent", ts.URL) 66 | resp, err := client.Get(url, WithUserAgent(userAgent)) 67 | assert.Nil(t, err) 68 | assert.Equal(t, http.StatusOK, resp.StatusCode) 69 | 70 | body, err := io.ReadAll(resp.Body) 71 | assert.Nil(t, err) 72 | t.Logf("Got: %v", string(body)) 73 | assert.Equal(t, userAgent, string(body)) 74 | }) 75 | 76 | t.Run("timeout", func(t *testing.T) { 77 | client := NewHttpClient(WithTimeout(time.Millisecond)) 78 | url := fmt.Sprintf("%s/timeout", ts.URL) 79 | _, err := client.Get(url) 80 | assert.Error(t, err) 81 | 82 | client = NewHttpClient(WithTimeout(time.Minute)) 83 | response, err := client.Get(url) 84 | assert.Nil(t, err) 85 | assert.Equal(t, http.StatusOK, response.StatusCode) 86 | }) 87 | } 88 | -------------------------------------------------------------------------------- /internal/bot/handler/notification_switch_button.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "text/template" 7 | 8 | tb "gopkg.in/telebot.v3" 9 | 10 | "github.com/indes/flowerss-bot/internal/bot/chat" 11 | "github.com/indes/flowerss-bot/internal/bot/session" 12 | "github.com/indes/flowerss-bot/internal/config" 13 | "github.com/indes/flowerss-bot/internal/core" 14 | ) 15 | 16 | const ( 17 | NotificationSwitchButtonUnique = "set_toggle_notice_btn" 18 | ) 19 | 20 | type NotificationSwitchButton struct { 21 | bot *tb.Bot 22 | core *core.Core 23 | } 24 | 25 | func NewNotificationSwitchButton(bot *tb.Bot, core *core.Core) *NotificationSwitchButton { 26 | return &NotificationSwitchButton{bot: bot, core: core} 27 | } 28 | 29 | func (b *NotificationSwitchButton) CallbackUnique() string { 30 | return "\f" + NotificationSwitchButtonUnique 31 | } 32 | 33 | func (b *NotificationSwitchButton) Description() string { 34 | return "" 35 | } 36 | 37 | func (b *NotificationSwitchButton) Handle(ctx tb.Context) error { 38 | c := ctx.Callback() 39 | if c == nil { 40 | return ctx.Respond(&tb.CallbackResponse{Text: "error"}) 41 | } 42 | 43 | attachData, err := session.UnmarshalAttachment(ctx.Callback().Data) 44 | if err != nil { 45 | return ctx.Edit("系统错误!") 46 | } 47 | 48 | subscriberID := attachData.GetUserId() 49 | if subscriberID != c.Sender.ID { 50 | // 如果订阅者与按钮点击者id不一致,需要验证管理员权限 51 | channelChat, err := b.bot.ChatByID(subscriberID) 52 | if err != nil { 53 | return ctx.Respond(&tb.CallbackResponse{Text: "error"}) 54 | } 55 | if !chat.IsChatAdmin(b.bot, channelChat, c.Sender.ID) { 56 | return ctx.Respond(&tb.CallbackResponse{Text: "error"}) 57 | } 58 | } 59 | 60 | sourceID := uint(attachData.GetSourceId()) 61 | source, _ := b.core.GetSource(context.Background(), sourceID) 62 | t := template.New("setting template") 63 | _, _ = t.Parse(feedSettingTmpl) 64 | 65 | err = b.core.ToggleSubscriptionNotice(context.Background(), subscriberID, sourceID) 66 | if err != nil { 67 | return ctx.Respond(&tb.CallbackResponse{Text: "error"}) 68 | } 69 | 70 | sub, err := b.core.GetSubscription(context.Background(), subscriberID, sourceID) 71 | if err != nil { 72 | return ctx.Respond(&tb.CallbackResponse{Text: "error"}) 73 | } 74 | text := new(bytes.Buffer) 75 | _ = t.Execute(text, map[string]interface{}{"source": source, "sub": sub, "Count": config.ErrorThreshold}) 76 | _ = ctx.Respond(&tb.CallbackResponse{Text: "修改成功"}) 77 | return ctx.Edit( 78 | text.String(), 79 | &tb.SendOptions{ParseMode: tb.ModeHTML}, 80 | &tb.ReplyMarkup{InlineKeyboard: genFeedSetBtn(c, sub, source)}, 81 | ) 82 | } 83 | 84 | func (b *NotificationSwitchButton) Middlewares() []tb.MiddlewareFunc { 85 | return nil 86 | } 87 | -------------------------------------------------------------------------------- /internal/bot/handler/telegraph_switch_button.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "text/template" 7 | 8 | tb "gopkg.in/telebot.v3" 9 | 10 | "github.com/indes/flowerss-bot/internal/bot/chat" 11 | "github.com/indes/flowerss-bot/internal/bot/session" 12 | "github.com/indes/flowerss-bot/internal/config" 13 | "github.com/indes/flowerss-bot/internal/core" 14 | ) 15 | 16 | const ( 17 | TelegraphSwitchButtonUnique = "set_toggle_telegraph_btn" 18 | ) 19 | 20 | type TelegraphSwitchButton struct { 21 | bot *tb.Bot 22 | core *core.Core 23 | } 24 | 25 | func NewTelegraphSwitchButton(bot *tb.Bot, core *core.Core) *TelegraphSwitchButton { 26 | return &TelegraphSwitchButton{bot: bot, core: core} 27 | } 28 | 29 | func (b *TelegraphSwitchButton) CallbackUnique() string { 30 | return "\f" + TelegraphSwitchButtonUnique 31 | } 32 | 33 | func (b *TelegraphSwitchButton) Description() string { 34 | return "" 35 | } 36 | 37 | func (b *TelegraphSwitchButton) Handle(ctx tb.Context) error { 38 | c := ctx.Callback() 39 | if c == nil { 40 | return ctx.Respond(&tb.CallbackResponse{Text: "error"}) 41 | } 42 | 43 | attachData, err := session.UnmarshalAttachment(ctx.Callback().Data) 44 | if err != nil { 45 | return ctx.Respond(&tb.CallbackResponse{Text: "error"}) 46 | } 47 | subscriberID := attachData.GetUserId() 48 | if subscriberID != c.Sender.ID { 49 | // 如果订阅者与按钮点击者id不一致,需要验证管理员权限 50 | channelChat, err := b.bot.ChatByID(subscriberID) 51 | if err != nil { 52 | return ctx.Respond(&tb.CallbackResponse{Text: "error"}) 53 | } 54 | if !chat.IsChatAdmin(b.bot, channelChat, c.Sender.ID) { 55 | return ctx.Respond(&tb.CallbackResponse{Text: "error"}) 56 | } 57 | } 58 | 59 | sourceID := uint(attachData.GetSourceId()) 60 | source, _ := b.core.GetSource(context.Background(), sourceID) 61 | 62 | err = b.core.ToggleSubscriptionTelegraph(context.Background(), subscriberID, sourceID) 63 | if err != nil { 64 | return ctx.Respond(&tb.CallbackResponse{Text: "error"}) 65 | } 66 | 67 | sub, err := b.core.GetSubscription(context.Background(), subscriberID, sourceID) 68 | if sub == nil || err != nil { 69 | return ctx.Respond(&tb.CallbackResponse{Text: "error"}) 70 | } 71 | 72 | t := template.New("setting template") 73 | _, _ = t.Parse(feedSettingTmpl) 74 | 75 | text := new(bytes.Buffer) 76 | _ = t.Execute(text, map[string]interface{}{"source": source, "sub": sub, "Count": config.ErrorThreshold}) 77 | _ = ctx.Respond(&tb.CallbackResponse{Text: "修改成功"}) 78 | return ctx.Edit( 79 | text.String(), 80 | &tb.SendOptions{ParseMode: tb.ModeHTML}, 81 | &tb.ReplyMarkup{InlineKeyboard: genFeedSetBtn(c, sub, source)}, 82 | ) 83 | } 84 | 85 | func (b *TelegraphSwitchButton) Middlewares() []tb.MiddlewareFunc { 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /internal/bot/handler/remove_all_subscription.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | 6 | tb "gopkg.in/telebot.v3" 7 | 8 | "github.com/indes/flowerss-bot/internal/core" 9 | ) 10 | 11 | type RemoveAllSubscription struct { 12 | } 13 | 14 | func NewRemoveAllSubscription() *RemoveAllSubscription { 15 | return &RemoveAllSubscription{} 16 | } 17 | 18 | func (r RemoveAllSubscription) Command() string { 19 | return "/unsuball" 20 | } 21 | 22 | func (r RemoveAllSubscription) Description() string { 23 | return "取消所有订阅" 24 | } 25 | 26 | func (r RemoveAllSubscription) Handle(ctx tb.Context) error { 27 | reply := "是否退订当前用户的所有订阅?" 28 | var confirmKeys [][]tb.InlineButton 29 | confirmKeys = append( 30 | confirmKeys, []tb.InlineButton{ 31 | tb.InlineButton{ 32 | Unique: UnSubAllButtonUnique, 33 | Text: "确认", 34 | }, 35 | tb.InlineButton{ 36 | Unique: CancelUnSubAllButtonUnique, 37 | Text: "取消", 38 | }, 39 | }, 40 | ) 41 | return ctx.Reply(reply, &tb.ReplyMarkup{InlineKeyboard: confirmKeys}) 42 | } 43 | 44 | func (r RemoveAllSubscription) Middlewares() []tb.MiddlewareFunc { 45 | return nil 46 | } 47 | 48 | const ( 49 | UnSubAllButtonUnique = "unsub_all_confirm_btn" 50 | CancelUnSubAllButtonUnique = "unsub_all_cancel_btn" 51 | ) 52 | 53 | type RemoveAllSubscriptionButton struct { 54 | core *core.Core 55 | } 56 | 57 | func NewRemoveAllSubscriptionButton(core *core.Core) *RemoveAllSubscriptionButton { 58 | return &RemoveAllSubscriptionButton{core: core} 59 | } 60 | 61 | func (r *RemoveAllSubscriptionButton) CallbackUnique() string { 62 | return "\f" + UnSubAllButtonUnique 63 | } 64 | 65 | func (r *RemoveAllSubscriptionButton) Description() string { 66 | return "" 67 | } 68 | 69 | func (r *RemoveAllSubscriptionButton) Handle(ctx tb.Context) error { 70 | err := r.core.UnsubscribeAllSource(context.Background(), ctx.Sender().ID) 71 | if err != nil { 72 | return ctx.Edit("退订失败") 73 | } 74 | return ctx.Edit("退订成功") 75 | } 76 | 77 | func (r *RemoveAllSubscriptionButton) Middlewares() []tb.MiddlewareFunc { 78 | return nil 79 | } 80 | 81 | type CancelRemoveAllSubscriptionButton struct { 82 | } 83 | 84 | func NewCancelRemoveAllSubscriptionButton() *CancelRemoveAllSubscriptionButton { 85 | return &CancelRemoveAllSubscriptionButton{} 86 | } 87 | 88 | func (r *CancelRemoveAllSubscriptionButton) CallbackUnique() string { 89 | return "\f" + CancelUnSubAllButtonUnique 90 | } 91 | 92 | func (r *CancelRemoveAllSubscriptionButton) Description() string { 93 | return "" 94 | } 95 | 96 | func (r *CancelRemoveAllSubscriptionButton) Handle(ctx tb.Context) error { 97 | return ctx.Edit("操作取消") 98 | } 99 | 100 | func (r *CancelRemoveAllSubscriptionButton) Middlewares() []tb.MiddlewareFunc { 101 | return nil 102 | } 103 | -------------------------------------------------------------------------------- /internal/bot/handler/export.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | "time" 9 | 10 | "go.uber.org/zap" 11 | tb "gopkg.in/telebot.v3" 12 | 13 | "github.com/indes/flowerss-bot/internal/bot/message" 14 | "github.com/indes/flowerss-bot/internal/core" 15 | "github.com/indes/flowerss-bot/internal/log" 16 | "github.com/indes/flowerss-bot/internal/model" 17 | "github.com/indes/flowerss-bot/internal/opml" 18 | ) 19 | 20 | type Export struct { 21 | core *core.Core 22 | } 23 | 24 | func NewExport(core *core.Core) *Export { 25 | return &Export{core: core} 26 | } 27 | 28 | func (e *Export) Description() string { 29 | return "导出OPML" 30 | } 31 | 32 | func (e *Export) Command() string { 33 | return "/export" 34 | } 35 | 36 | func (e *Export) getChannelSources(bot *tb.Bot, opUserID int64, channelName string) ([]*model.Source, error) { 37 | // 导出channel订阅 38 | channelChat, err := bot.ChatByUsername(channelName) 39 | if err != nil { 40 | return nil, errors.New("无法获取频道信息") 41 | } 42 | 43 | adminList, err := bot.AdminsOf(channelChat) 44 | if err != nil { 45 | return nil, errors.New("无法获取频道管理员信息") 46 | } 47 | 48 | senderIsAdmin := false 49 | for _, admin := range adminList { 50 | if opUserID == admin.User.ID { 51 | senderIsAdmin = true 52 | break 53 | } 54 | } 55 | 56 | if !senderIsAdmin { 57 | return nil, errors.New("非频道管理员无法执行此操作") 58 | } 59 | 60 | sources, err := e.core.GetUserSubscribedSources(context.Background(), channelChat.ID) 61 | if err != nil { 62 | zap.S().Error(err) 63 | return nil, errors.New("获取订阅源信息失败") 64 | } 65 | return sources, nil 66 | } 67 | 68 | func (e *Export) Handle(ctx tb.Context) error { 69 | mention := message.MentionFromMessage(ctx.Message()) 70 | var sources []*model.Source 71 | if mention == "" { 72 | var err error 73 | sources, err = e.core.GetUserSubscribedSources(context.Background(), ctx.Chat().ID) 74 | if err != nil { 75 | log.Error(err) 76 | return ctx.Send("导出失败") 77 | } 78 | } else { 79 | var err error 80 | sources, err = e.getChannelSources(ctx.Bot(), ctx.Chat().ID, mention) 81 | if err != nil { 82 | log.Error(err) 83 | return ctx.Send("导出失败") 84 | } 85 | } 86 | 87 | if len(sources) == 0 { 88 | return ctx.Send("订阅列表为空") 89 | } 90 | 91 | opmlStr, err := opml.ToOPML(sources) 92 | if err != nil { 93 | return ctx.Send("导出失败") 94 | } 95 | opmlFile := &tb.Document{File: tb.FromReader(strings.NewReader(opmlStr))} 96 | opmlFile.FileName = fmt.Sprintf("subscriptions_%d.opml", time.Now().Unix()) 97 | if err := ctx.Send(opmlFile); err != nil { 98 | log.Errorf("send OPML file failed, err:%v", err) 99 | return ctx.Send("导出失败") 100 | } 101 | return nil 102 | } 103 | 104 | func (e *Export) Middlewares() []tb.MiddlewareFunc { 105 | return nil 106 | } 107 | -------------------------------------------------------------------------------- /internal/storage/storage.go: -------------------------------------------------------------------------------- 1 | //go:generate mockgen -source=storage.go -destination=./mock/storage_mock.go -package=mock 2 | 3 | package storage 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | 9 | "github.com/indes/flowerss-bot/internal/model" 10 | ) 11 | 12 | var ( 13 | // ErrRecordNotFound 数据不存在错误 14 | ErrRecordNotFound = errors.New("record not found") 15 | ) 16 | 17 | type Storage interface { 18 | Init(ctx context.Context) error 19 | } 20 | 21 | // User 用户存储接口 22 | type User interface { 23 | Storage 24 | CrateUser(ctx context.Context, user *model.User) error 25 | GetUser(ctx context.Context, id int64) (*model.User, error) 26 | } 27 | 28 | // Source 订阅源存储接口 29 | type Source interface { 30 | Storage 31 | AddSource(ctx context.Context, source *model.Source) error 32 | GetSource(ctx context.Context, id uint) (*model.Source, error) 33 | GetSources(ctx context.Context) ([]*model.Source, error) 34 | GetSourceByURL(ctx context.Context, url string) (*model.Source, error) 35 | Delete(ctx context.Context, id uint) error 36 | UpsertSource(ctx context.Context, sourceID uint, newSource *model.Source) error 37 | } 38 | 39 | type SubscriptionSortType = int 40 | 41 | const ( 42 | SubscriptionSortTypeCreatedTimeDesc SubscriptionSortType = iota 43 | ) 44 | 45 | type GetSubscriptionsOptions struct { 46 | Count int // 需要获取的数量,-1为获取全部 47 | Offset int 48 | SortType SubscriptionSortType 49 | } 50 | 51 | type GetSubscriptionsResult struct { 52 | Subscriptions []*model.Subscribe 53 | HasMore bool 54 | } 55 | 56 | type Subscription interface { 57 | Storage 58 | AddSubscription(ctx context.Context, subscription *model.Subscribe) error 59 | SubscriptionExist(ctx context.Context, userID int64, sourceID uint) (bool, error) 60 | GetSubscription(ctx context.Context, userID int64, sourceID uint) (*model.Subscribe, error) 61 | GetSubscriptionsByUserID( 62 | ctx context.Context, userID int64, opts *GetSubscriptionsOptions, 63 | ) (*GetSubscriptionsResult, error) 64 | GetSubscriptionsBySourceID( 65 | ctx context.Context, sourceID uint, opts *GetSubscriptionsOptions, 66 | ) (*GetSubscriptionsResult, error) 67 | CountSubscriptions(ctx context.Context) (int64, error) 68 | DeleteSubscription(ctx context.Context, userID int64, sourceID uint) (int64, error) 69 | CountSourceSubscriptions(ctx context.Context, sourceID uint) (int64, error) 70 | UpdateSubscription( 71 | ctx context.Context, userID int64, sourceID uint, newSubscription *model.Subscribe, 72 | ) error 73 | UpsertSubscription( 74 | ctx context.Context, userID int64, sourceID uint, newSubscription *model.Subscribe, 75 | ) error 76 | } 77 | 78 | type Content interface { 79 | Storage 80 | // AddContent 添加一条文章 81 | AddContent(ctx context.Context, content *model.Content) error 82 | // DeleteSourceContents 删除订阅源的所有文章,返回被删除的文章数 83 | DeleteSourceContents(ctx context.Context, sourceID uint) (int64, error) 84 | // HashIDExist hash id 对应的文章是否已存在 85 | HashIDExist(ctx context.Context, hashID string) (bool, error) 86 | } 87 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | # 部署 2 | 3 | ## 二进制部署 4 | 5 | 从 [Releases](https://github.com/indes/flowerss-bot/releases) 页面下载对应的版本解压运行即可。 6 | 7 | ## Docker 部署 8 | 9 | 1.下载配置文件 10 | 在项目目录下新建 `config.yml` 文件 11 | 12 | 13 | ```bash 14 | mkdir ~/flowerss &&\ 15 | wget -O ~/flowerss/config.yml \ 16 | https://raw.githubusercontent.com/indes/flowerss-bot/master/config.yml.sample 17 | ``` 18 | 19 | 20 | 2.修改配置文件 21 | 22 | ```bash 23 | vim ~/flowerss/config.yml 24 | ``` 25 | 26 | 修改配置文件中sqlite路径(如果使用sqlite作为数据库): 27 | ```yaml 28 | sqlite: 29 | path: /root/.flowerss/data.db 30 | ``` 31 | 32 | 3.运行 33 | 34 | ```shell script 35 | docker run -d -v ~/flowerss:/root/.flowerss indes/flowerss-bot 36 | ``` 37 | 38 | ## 源码编译部署 39 | 40 | ```shell script 41 | git clone https://github.com/indes/flowerss-bot && cd flowerss-bot 42 | make build 43 | ./flowerss-bot 44 | ``` 45 | 46 | 47 | 48 | ## 配置 49 | 50 | 根据以下模板,新建 `config.yml` 文件。 51 | 52 | ```yml 53 | bot_token: XXX 54 | #多个telegraph_token可采用数组格式: 55 | # telegraph_token: 56 | # - token_1 57 | # - token_2 58 | telegraph_token: xxxx 59 | user_agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36 60 | preview_text: 0 61 | disable_web_page_preview: false 62 | socks5: 127.0.0.1:1080 63 | update_interval: 10 64 | error_threshold: 100 65 | telegram: 66 | endpoint: https://xxx.com/ 67 | mysql: 68 | host: 127.0.0.1 69 | port: 3306 70 | user: user 71 | password: pwd 72 | database: flowerss 73 | sqlite: 74 | path: ./data.db 75 | allowed_users: 76 | - 123 77 | - 234 78 | ``` 79 | 80 | 配置说明: 81 | 82 | | 配置项 | 含义 | 是否必填 | 83 | | --------------------------| ----------------------------------------- | ------------------------------------------ | 84 | | bot_token | Telegram Bot Token | 必填 | 85 | | telegraph_token | Telegraph Token, 用于转存原文到 Telegraph | 可忽略(不转存原文到 Telegraph ) | 86 | | preview_text | 纯文字预览字数(不借助Telegraph) |可忽略(默认0, 0为禁用) | 87 | | user_agent | User Agent |可忽略 | 88 | | disable_web_page_preview | 是否禁用 web 页面预览 | 可忽略(默认 false, true 为禁用) | 89 | | update_interval | RSS 源扫描间隔(分钟) | 可忽略(默认 10) | 90 | | error_threshold | 源最大出错次数 |可忽略(默认 100) | 91 | | socks5 | 用于无法正常 Telegram API 的环境 | 可忽略(能正常连接上 Telegram API 服务器) | 92 | | mysql | MySQL 数据库配置 | 可忽略(使用 SQLite ) | 93 | | sqlite | SQLite 配置 | 可忽略(已配置mysql时,该项失效) | 94 | | telegram.endpoint | 自定义telegram bot api url | 可忽略(使用默认api url) | 95 | | allowed_users | 允许使用bot的用户telegram id, | 可忽略,为空时所有用户都能使用bot | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "text/template" 6 | 7 | "github.com/go-sql-driver/mysql" 8 | "github.com/spf13/viper" 9 | tb "gopkg.in/telebot.v3" 10 | ) 11 | 12 | type RunType string 13 | 14 | var ( 15 | version = "dev" 16 | commit = "none" 17 | date = "unknown" 18 | 19 | ProjectName string = "flowerss" 20 | BotToken string 21 | Socks5 string 22 | TelegraphToken []string 23 | TelegraphAccountName string 24 | TelegraphAuthorName string = "flowerss-bot" 25 | TelegraphAuthorURL string 26 | 27 | // EnableTelegraph 是否启用telegraph 28 | EnableTelegraph bool = false 29 | PreviewText int = 0 30 | DisableWebPagePreview bool = false 31 | mysqlConfig *mysql.Config 32 | SQLitePath string 33 | EnableMysql bool = false 34 | 35 | // UpdateInterval rss抓取间隔 36 | UpdateInterval int = 10 37 | 38 | // ErrorThreshold rss源抓取错误阈值 39 | ErrorThreshold uint = 100 40 | 41 | // MessageTpl rss更新推送模版 42 | MessageTpl *template.Template 43 | 44 | // MessageMode telegram消息渲染模式 45 | MessageMode tb.ParseMode 46 | 47 | // TelegramEndpoint telegram bot 服务器地址,默认为空 48 | TelegramEndpoint string = tb.DefaultApiURL 49 | 50 | // UserAgent User-Agent 51 | UserAgent string 52 | 53 | // RunMode 运行模式 Release / Debug 54 | RunMode RunType = ReleaseMode 55 | 56 | // AllowUsers 允许使用bot的用户 57 | AllowUsers []int64 58 | 59 | // DBLogMode 是否打印数据库日志 60 | DBLogMode bool = false 61 | ) 62 | 63 | const ( 64 | defaultMessageTplMode = tb.ModeHTML 65 | defaultMessageTpl = `{{.SourceTitle}}{{ if .PreviewText }} 66 | ---------- Preview ---------- 67 | {{.PreviewText}} 68 | ----------------------------- 69 | {{- end}}{{if .EnableTelegraph}} 70 | {{.ContentTitle}} Telegraph | 原文 71 | {{- else }} 72 | {{.ContentTitle}} 73 | {{- end }} 74 | {{.Tags}} 75 | ` 76 | defaultMessageMarkdownTpl = `** {{.SourceTitle}} **{{ if .PreviewText }} 77 | ---------- Preview ---------- 78 | {{.PreviewText}} 79 | ----------------------------- 80 | {{- end}}{{if .EnableTelegraph}} 81 | {{.ContentTitle}} [Telegraph]({{.TelegraphURL}}) | [原文]({{.RawLink}}) 82 | {{- else }} 83 | [{{.ContentTitle}}]({{.RawLink}}) 84 | {{- end }} 85 | {{.Tags}} 86 | ` 87 | TestMode RunType = "Test" 88 | ReleaseMode RunType = "Release" 89 | ) 90 | 91 | type TplData struct { 92 | SourceTitle string 93 | ContentTitle string 94 | RawLink string 95 | PreviewText string 96 | TelegraphURL string 97 | Tags string 98 | EnableTelegraph bool 99 | } 100 | 101 | func AppVersionInfo() (s string) { 102 | s = fmt.Sprintf("version %v, commit %v, built at %v", version, commit, date) 103 | return 104 | } 105 | 106 | // GetString get string config value by key 107 | func GetString(key string) string { 108 | var value string 109 | if viper.IsSet(key) { 110 | value = viper.GetString(key) 111 | } 112 | 113 | return value 114 | } 115 | 116 | func GetMysqlDSN() string { 117 | return mysqlConfig.FormatDSN() 118 | } 119 | -------------------------------------------------------------------------------- /internal/bot/handler/list_subscription.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | tb "gopkg.in/telebot.v3" 9 | 10 | "github.com/indes/flowerss-bot/internal/bot/chat" 11 | "github.com/indes/flowerss-bot/internal/bot/message" 12 | "github.com/indes/flowerss-bot/internal/core" 13 | "github.com/indes/flowerss-bot/internal/log" 14 | "github.com/indes/flowerss-bot/internal/model" 15 | ) 16 | 17 | const ( 18 | MaxSubsSizePerPage = 50 19 | ) 20 | 21 | type ListSubscription struct { 22 | core *core.Core 23 | } 24 | 25 | func NewListSubscription(core *core.Core) *ListSubscription { 26 | return &ListSubscription{core: core} 27 | } 28 | 29 | func (l *ListSubscription) Command() string { 30 | return "/list" 31 | } 32 | 33 | func (l *ListSubscription) Description() string { 34 | return "已订阅的RSS源" 35 | } 36 | 37 | func (l *ListSubscription) listChatSubscription(ctx tb.Context) error { 38 | // private chat or group 39 | if ctx.Chat().Type != tb.ChatPrivate && !chat.IsChatAdmin(ctx.Bot(), ctx.Chat(), ctx.Sender().ID) { 40 | // 无权限 41 | return ctx.Send("无权限") 42 | } 43 | 44 | stdCtx := context.Background() 45 | sources, err := l.core.GetUserSubscribedSources(stdCtx, ctx.Chat().ID) 46 | if err != nil { 47 | log.Errorf("GetUserSubscribedSources failed, %v", err) 48 | return ctx.Send("获取订阅错误") 49 | } 50 | 51 | return l.replaySubscribedSources(ctx, sources) 52 | } 53 | 54 | func (l *ListSubscription) listChannelSubscription(ctx tb.Context, channelName string) error { 55 | channelChat, err := ctx.Bot().ChatByUsername(channelName) 56 | if err != nil { 57 | return ctx.Send("获取频道信息错误") 58 | } 59 | 60 | if !chat.IsChatAdmin(ctx.Bot(), channelChat, ctx.Sender().ID) { 61 | return ctx.Send("非频道管理员无法执行此操作") 62 | } 63 | 64 | stdCtx := context.Background() 65 | sources, err := l.core.GetUserSubscribedSources(stdCtx, channelChat.ID) 66 | if err != nil { 67 | log.Errorf("GetUserSubscribedSources failed, %v", err) 68 | return ctx.Send("获取订阅错误") 69 | } 70 | return l.replaySubscribedSources(ctx, sources) 71 | } 72 | 73 | func (l *ListSubscription) Handle(ctx tb.Context) error { 74 | mention := message.MentionFromMessage(ctx.Message()) 75 | if mention != "" { 76 | return l.listChannelSubscription(ctx, mention) 77 | } 78 | return l.listChatSubscription(ctx) 79 | } 80 | 81 | func (l *ListSubscription) Middlewares() []tb.MiddlewareFunc { 82 | return nil 83 | } 84 | 85 | func (l *ListSubscription) replaySubscribedSources(ctx tb.Context, sources []*model.Source) error { 86 | if len(sources) == 0 { 87 | return ctx.Send("订阅列表为空") 88 | } 89 | var msg strings.Builder 90 | msg.WriteString(fmt.Sprintf("共订阅%d个源,订阅列表\n", len(sources))) 91 | count := 0 92 | for i := range sources { 93 | msg.WriteString(fmt.Sprintf("[[%d]] [%s](%s)\n", sources[i].ID, sources[i].Title, sources[i].Link)) 94 | count++ 95 | if count == MaxSubsSizePerPage { 96 | ctx.Send(msg.String(), &tb.SendOptions{DisableWebPagePreview: true, ParseMode: tb.ModeMarkdown}) 97 | count = 0 98 | msg.Reset() 99 | } 100 | } 101 | 102 | if count != 0 { 103 | ctx.Send(msg.String(), &tb.SendOptions{DisableWebPagePreview: true, ParseMode: tb.ModeMarkdown}) 104 | } 105 | return nil 106 | } 107 | -------------------------------------------------------------------------------- /internal/opml/opml.go: -------------------------------------------------------------------------------- 1 | package opml 2 | 3 | import ( 4 | "encoding/xml" 5 | "errors" 6 | "io" 7 | "time" 8 | 9 | "github.com/indes/flowerss-bot/internal/model" 10 | ) 11 | 12 | // OPML opml struct 13 | type OPML struct { 14 | XMLName xml.Name `xml:"opml"` 15 | Version string `xml:"version,attr"` 16 | Head Head `xml:"head"` 17 | Body Body `xml:"body"` 18 | } 19 | 20 | // Head opml head 21 | type Head struct { 22 | Title string `xml:"title"` 23 | DateCreated string `xml:"dateCreated,omitempty"` 24 | DateModified string `xml:"dateModified,omitempty"` 25 | OwnerName string `xml:"ownerName,omitempty"` 26 | OwnerEmail string `xml:"ownerEmail,omitempty"` 27 | OwnerID string `xml:"ownerId,omitempty"` 28 | Docs string `xml:"docs,omitempty"` 29 | ExpansionState string `xml:"expansionState,omitempty"` 30 | VertScrollState string `xml:"vertScrollState,omitempty"` 31 | WindowTop string `xml:"windowTop,omitempty"` 32 | WindowBottom string `xml:"windowBottom,omitempty"` 33 | WindowLeft string `xml:"windowLeft,omitempty"` 34 | WindowRight string `xml:"windowRight,omitempty"` 35 | } 36 | 37 | // Body opml body 38 | type Body struct { 39 | Outlines []Outline `xml:"outline"` 40 | } 41 | 42 | // Outline opml outline 43 | type Outline struct { 44 | Outlines []Outline `xml:"outline"` 45 | Text string `xml:"text,attr"` 46 | Type string `xml:"type,attr,omitempty"` 47 | IsComment string `xml:"isComment,attr,omitempty"` 48 | IsBreakpoint string `xml:"isBreakpoint,attr,omitempty"` 49 | Created string `xml:"created,attr,omitempty"` 50 | Category string `xml:"category,attr,omitempty"` 51 | XMLURL string `xml:"xmlUrl,attr,omitempty"` 52 | HTMLURL string `xml:"htmlUrl,attr,omitempty"` 53 | URL string `xml:"url,attr,omitempty"` 54 | Language string `xml:"language,attr,omitempty"` 55 | Title string `xml:"title,attr,omitempty"` 56 | Version string `xml:"version,attr,omitempty"` 57 | Description string `xml:"description,attr,omitempty"` 58 | } 59 | 60 | // NewOPML gen OPML form []byte 61 | func NewOPML(b []byte) (*OPML, error) { 62 | var root OPML 63 | err := xml.Unmarshal(b, &root) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | return &root, nil 69 | } 70 | 71 | func ReadOPML(r io.Reader) (*OPML, error) { 72 | body, err := io.ReadAll(r) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | o, err := NewOPML(body) 78 | if err != nil { 79 | return nil, errors.New("parse opml file error") 80 | } 81 | return o, nil 82 | } 83 | 84 | // GetFlattenOutlines make all outline at the same xml level 85 | func (o OPML) GetFlattenOutlines() ([]Outline, error) { 86 | var flattenOutlines []Outline 87 | for _, line := range o.Body.Outlines { 88 | if line.Outlines != nil { 89 | for _, subLine := range line.Outlines { 90 | // 查找子outline 91 | if subLine.XMLURL != "" { 92 | flattenOutlines = append(flattenOutlines, subLine) 93 | } 94 | } 95 | } 96 | 97 | if line.XMLURL != "" { 98 | flattenOutlines = append(flattenOutlines, line) 99 | } 100 | } 101 | return flattenOutlines, nil 102 | } 103 | 104 | // XML dump OPML to xml file 105 | func (o OPML) XML() (string, error) { 106 | b, err := xml.MarshalIndent(o, "", "\t") 107 | return xml.Header + string(b), err 108 | } 109 | 110 | // ToOPML dump sources to opml file 111 | func ToOPML(sources []*model.Source) (string, error) { 112 | O := OPML{} 113 | O.XMLName.Local = "opml" 114 | O.Version = "2.0" 115 | O.XMLName.Space = "" 116 | O.Head.Title = "subscriptions in flowerss" 117 | O.Head.DateCreated = time.Now().Format(time.RFC1123) 118 | for _, s := range sources { 119 | outline := Outline{} 120 | outline.Text = s.Title 121 | outline.Type = "rss" 122 | outline.XMLURL = s.Link 123 | O.Body.Outlines = append(O.Body.Outlines, outline) 124 | } 125 | return O.XML() 126 | } 127 | -------------------------------------------------------------------------------- /internal/bot/handler/on_document.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | "sync" 9 | 10 | "github.com/indes/flowerss-bot/internal/bot/session" 11 | "github.com/indes/flowerss-bot/internal/core" 12 | "github.com/indes/flowerss-bot/internal/log" 13 | "github.com/indes/flowerss-bot/internal/opml" 14 | 15 | tb "gopkg.in/telebot.v3" 16 | ) 17 | 18 | type OnDocument struct { 19 | bot *tb.Bot 20 | core *core.Core 21 | } 22 | 23 | func NewOnDocument(bot *tb.Bot, core *core.Core) *OnDocument { 24 | return &OnDocument{ 25 | bot: bot, 26 | core: core, 27 | } 28 | } 29 | 30 | func (o *OnDocument) Command() string { 31 | return tb.OnDocument 32 | } 33 | 34 | func (o *OnDocument) Description() string { 35 | return "" 36 | } 37 | 38 | func (o *OnDocument) getOPML(ctx tb.Context) (*opml.OPML, error) { 39 | if !strings.HasSuffix(ctx.Message().Document.FileName, ".opml") { 40 | return nil, errors.New("请发送正确的 OPML 文件") 41 | } 42 | 43 | fileRead, err := o.bot.File(&ctx.Message().Document.File) 44 | if err != nil { 45 | return nil, errors.New("获取文件失败") 46 | } 47 | 48 | opmlFile, err := opml.ReadOPML(fileRead) 49 | if err != nil { 50 | log.Errorf("parser opml failed, %v", err) 51 | return nil, errors.New("获取文件失败") 52 | } 53 | return opmlFile, nil 54 | } 55 | 56 | func (o *OnDocument) Handle(ctx tb.Context) error { 57 | opmlFile, err := o.getOPML(ctx) 58 | if err != nil { 59 | return ctx.Reply(err.Error()) 60 | } 61 | userID := ctx.Chat().ID 62 | v := ctx.Get(session.StoreKeyMentionChat.String()) 63 | if mentionChat, ok := v.(*tb.Chat); ok && mentionChat != nil { 64 | userID = mentionChat.ID 65 | } 66 | 67 | outlines, _ := opmlFile.GetFlattenOutlines() 68 | var failImportList = make([]opml.Outline, len(outlines)) 69 | failIndex := 0 70 | var successImportList = make([]opml.Outline, len(outlines)) 71 | successIndex := 0 72 | wg := &sync.WaitGroup{} 73 | for _, outline := range outlines { 74 | outline := outline 75 | wg.Add(1) 76 | go func() { 77 | defer wg.Done() 78 | source, err := o.core.CreateSource(context.Background(), outline.XMLURL) 79 | if err != nil { 80 | failImportList[failIndex] = outline 81 | failIndex++ 82 | return 83 | } 84 | 85 | err = o.core.AddSubscription(context.Background(), userID, source.ID) 86 | if err != nil { 87 | if err == core.ErrSubscriptionExist { 88 | successImportList[successIndex] = outline 89 | successIndex++ 90 | } else { 91 | failImportList[failIndex] = outline 92 | failIndex++ 93 | } 94 | return 95 | } 96 | 97 | log.Infof("%d subscribe [%d]%s %s", ctx.Chat().ID, source.ID, source.Title, source.Link) 98 | successImportList[successIndex] = outline 99 | successIndex++ 100 | return 101 | }() 102 | } 103 | wg.Wait() 104 | 105 | var msg strings.Builder 106 | msg.WriteString(fmt.Sprintf("导入成功:%d,导入失败:%d\n", successIndex, failIndex)) 107 | if successIndex != 0 { 108 | msg.WriteString("以下订阅源导入成功:\n") 109 | for i := 0; i < successIndex; i++ { 110 | line := successImportList[i] 111 | if line.Text != "" { 112 | msg.WriteString( 113 | fmt.Sprintf("[%d] %s\n", i+1, line.XMLURL, line.Text), 114 | ) 115 | } else { 116 | msg.WriteString(fmt.Sprintf("[%d] %s\n", i+1, line.XMLURL)) 117 | } 118 | } 119 | 120 | msg.WriteString("\n") 121 | } 122 | 123 | if failIndex != 0 { 124 | msg.WriteString("以下订阅源导入失败:\n") 125 | for i := 0; i < failIndex; i++ { 126 | line := failImportList[i] 127 | if line.Text != "" { 128 | msg.WriteString(fmt.Sprintf("[%d] %s\n", i+1, line.XMLURL, line.Text)) 129 | } else { 130 | msg.WriteString(fmt.Sprintf("[%d] %s\n", i+1, line.XMLURL)) 131 | } 132 | } 133 | 134 | } 135 | 136 | return ctx.Reply( 137 | msg.String(), &tb.SendOptions{ 138 | DisableWebPagePreview: true, 139 | ParseMode: tb.ModeHTML, 140 | }, 141 | ) 142 | } 143 | 144 | func (o *OnDocument) Middlewares() []tb.MiddlewareFunc { 145 | return nil 146 | } 147 | -------------------------------------------------------------------------------- /internal/bot/handler/add_subscription.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "go.uber.org/zap" 9 | tb "gopkg.in/telebot.v3" 10 | 11 | "github.com/indes/flowerss-bot/internal/bot/message" 12 | "github.com/indes/flowerss-bot/internal/core" 13 | "github.com/indes/flowerss-bot/internal/log" 14 | ) 15 | 16 | type AddSubscription struct { 17 | core *core.Core 18 | } 19 | 20 | func NewAddSubscription(core *core.Core) *AddSubscription { 21 | return &AddSubscription{ 22 | core: core, 23 | } 24 | } 25 | 26 | func (a *AddSubscription) Command() string { 27 | return "/sub" 28 | } 29 | 30 | func (a *AddSubscription) Description() string { 31 | return "订阅RSS源" 32 | } 33 | 34 | func (a *AddSubscription) addSubscriptionForChat(ctx tb.Context) error { 35 | sourceURL := message.URLFromMessage(ctx.Message()) 36 | if sourceURL == "" { 37 | // 未附带链接,使用 38 | hint := fmt.Sprintf("请在命令后带上需要订阅的RSS URL,例如:%s https://justinpot.com/feed/", a.Command()) 39 | return ctx.Send(hint, &tb.SendOptions{ReplyTo: ctx.Message()}) 40 | } 41 | 42 | source, err := a.core.CreateSource(context.Background(), sourceURL) 43 | if err != nil { 44 | return ctx.Reply(fmt.Sprintf("%s,订阅失败", err)) 45 | } 46 | 47 | log.Infof("%d subscribe [%d]%s %s", ctx.Chat().ID, source.ID, source.Title, source.Link) 48 | if err := a.core.AddSubscription(context.Background(), ctx.Chat().ID, source.ID); err != nil { 49 | if err == core.ErrSubscriptionExist { 50 | return ctx.Reply("已订阅该源,请勿重复订阅") 51 | } 52 | log.Errorf("add subscription user %d source %d failed %v", ctx.Chat().ID, source.ID, err) 53 | return ctx.Reply("订阅失败") 54 | } 55 | 56 | return ctx.Reply( 57 | fmt.Sprintf("[[%d]][%s](%s) 订阅成功", source.ID, source.Title, source.Link), 58 | &tb.SendOptions{ 59 | DisableWebPagePreview: true, 60 | ParseMode: tb.ModeMarkdown, 61 | }, 62 | ) 63 | } 64 | 65 | func (a *AddSubscription) hasChannelPrivilege(bot *tb.Bot, channelChat *tb.Chat, opUserID int64, botID int64) ( 66 | bool, error, 67 | ) { 68 | adminList, err := bot.AdminsOf(channelChat) 69 | if err != nil { 70 | zap.S().Error(err) 71 | return false, errors.New("获取频道信息失败") 72 | } 73 | 74 | senderIsAdmin := false 75 | botIsAdmin := false 76 | for _, admin := range adminList { 77 | if opUserID == admin.User.ID { 78 | senderIsAdmin = true 79 | } 80 | if botID == admin.User.ID { 81 | botIsAdmin = true 82 | } 83 | } 84 | 85 | return botIsAdmin && senderIsAdmin, nil 86 | } 87 | 88 | func (a *AddSubscription) addSubscriptionForChannel(ctx tb.Context, channelName string) error { 89 | sourceURL := message.URLFromMessage(ctx.Message()) 90 | if sourceURL == "" { 91 | return ctx.Send("频道订阅请使用' /sub @ChannelID URL ' 命令") 92 | } 93 | 94 | bot := ctx.Bot() 95 | channelChat, err := bot.ChatByUsername(channelName) 96 | if err != nil { 97 | return ctx.Reply("获取频道信息失败") 98 | } 99 | if channelChat.Type != tb.ChatChannel { 100 | return ctx.Reply("您或Bot不是频道管理员,无法设置订阅") 101 | } 102 | 103 | hasPrivilege, err := a.hasChannelPrivilege(bot, channelChat, ctx.Sender().ID, bot.Me.ID) 104 | if err != nil { 105 | return ctx.Reply(err.Error()) 106 | } 107 | if !hasPrivilege { 108 | return ctx.Reply("您或Bot不是频道管理员,无法设置订阅") 109 | } 110 | 111 | source, err := a.core.CreateSource(context.Background(), sourceURL) 112 | if err != nil { 113 | return ctx.Reply(fmt.Sprintf("%s,订阅失败", err)) 114 | } 115 | 116 | log.Infof("%d subscribe [%d]%s %s", channelChat.ID, source.ID, source.Title, source.Link) 117 | if err := a.core.AddSubscription(context.Background(), channelChat.ID, source.ID); err != nil { 118 | if err == core.ErrSubscriptionExist { 119 | return ctx.Reply("已订阅该源,请勿重复订阅") 120 | } 121 | log.Errorf("add subscription user %d source %d failed %v", channelChat.ID, source.ID, err) 122 | return ctx.Reply("订阅失败") 123 | } 124 | 125 | return ctx.Reply( 126 | fmt.Sprintf("[[%d]] [%s](%s) 订阅成功", source.ID, source.Title, source.Link), 127 | &tb.SendOptions{ 128 | DisableWebPagePreview: true, 129 | ParseMode: tb.ModeMarkdown, 130 | }, 131 | ) 132 | } 133 | 134 | func (a *AddSubscription) Handle(ctx tb.Context) error { 135 | mention := message.MentionFromMessage(ctx.Message()) 136 | if mention != "" { 137 | // has mention, add subscription for channel 138 | return a.addSubscriptionForChannel(ctx, mention) 139 | } 140 | return a.addSubscriptionForChat(ctx) 141 | } 142 | 143 | func (a *AddSubscription) Middlewares() []tb.MiddlewareFunc { 144 | return nil 145 | } 146 | -------------------------------------------------------------------------------- /docs/_media/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal/storage/subscription_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | 9 | "github.com/indes/flowerss-bot/internal/model" 10 | ) 11 | 12 | func TestSubscriptionStorageImpl(t *testing.T) { 13 | db := GetTestDB(t) 14 | s := NewSubscriptionStorageImpl(db) 15 | ctx := context.Background() 16 | s.Init(ctx) 17 | 18 | subscriptions := []*model.Subscribe{ 19 | &model.Subscribe{ 20 | SourceID: 1, 21 | UserID: 100, 22 | EnableNotification: 1, 23 | }, 24 | &model.Subscribe{ 25 | SourceID: 1, 26 | UserID: 101, 27 | EnableNotification: 1, 28 | }, 29 | &model.Subscribe{ 30 | SourceID: 2, 31 | UserID: 100, 32 | EnableNotification: 1, 33 | }, 34 | &model.Subscribe{ 35 | SourceID: 2, 36 | UserID: 101, 37 | EnableNotification: 1, 38 | }, 39 | &model.Subscribe{ 40 | SourceID: 3, 41 | UserID: 101, 42 | EnableNotification: 1, 43 | }, 44 | } 45 | 46 | t.Run( 47 | "add subscription", func(t *testing.T) { 48 | for _, subscription := range subscriptions { 49 | err := s.AddSubscription(ctx, subscription) 50 | assert.Nil(t, err) 51 | } 52 | got, err := s.CountSubscriptions(ctx) 53 | assert.Nil(t, err) 54 | assert.Equal(t, int64(5), got) 55 | 56 | exist, err := s.SubscriptionExist(ctx, 101, 1) 57 | assert.Nil(t, err) 58 | assert.True(t, exist) 59 | 60 | subscription, err := s.GetSubscription(ctx, 101, 1) 61 | assert.Nil(t, err) 62 | assert.NotNil(t, subscription) 63 | 64 | opt := &GetSubscriptionsOptions{ 65 | Count: 2, 66 | } 67 | result, err := s.GetSubscriptionsByUserID(ctx, 101, opt) 68 | assert.Nil(t, err) 69 | assert.Equal(t, 2, len(result.Subscriptions)) 70 | assert.True(t, result.HasMore) 71 | 72 | opt = &GetSubscriptionsOptions{ 73 | Count: 1, 74 | Offset: 2, 75 | } 76 | result, err = s.GetSubscriptionsByUserID(ctx, 101, opt) 77 | assert.Nil(t, err) 78 | assert.Equal(t, 1, len(result.Subscriptions)) 79 | assert.False(t, result.HasMore) 80 | 81 | opt = &GetSubscriptionsOptions{ 82 | Count: 2, 83 | } 84 | result, err = s.GetSubscriptionsBySourceID(ctx, 1, opt) 85 | assert.Nil(t, err) 86 | assert.Equal(t, 2, len(result.Subscriptions)) 87 | assert.False(t, result.HasMore) 88 | 89 | opt = &GetSubscriptionsOptions{ 90 | Count: 1, 91 | Offset: 2, 92 | } 93 | result, err = s.GetSubscriptionsByUserID(ctx, 1, opt) 94 | assert.Nil(t, err) 95 | assert.Equal(t, 0, len(result.Subscriptions)) 96 | assert.False(t, result.HasMore) 97 | 98 | got, err = s.DeleteSubscription(ctx, 101, 1) 99 | assert.Nil(t, err) 100 | assert.Equal(t, int64(1), got) 101 | 102 | exist, err = s.SubscriptionExist(ctx, 101, 1) 103 | assert.Nil(t, err) 104 | assert.False(t, exist) 105 | 106 | subscription, err = s.GetSubscription(ctx, 101, 1) 107 | assert.Error(t, err) 108 | assert.Nil(t, subscription) 109 | 110 | got, err = s.CountSubscriptions(ctx) 111 | assert.Nil(t, err) 112 | assert.Equal(t, int64(4), got) 113 | 114 | got, err = s.CountSourceSubscriptions(ctx, 2) 115 | assert.Nil(t, err) 116 | assert.Equal(t, int64(2), got) 117 | }, 118 | ) 119 | 120 | t.Run( 121 | "update subscription", func(t *testing.T) { 122 | sub := &model.Subscribe{ 123 | ID: 10001, 124 | SourceID: 1000, 125 | UserID: 1002, 126 | EnableNotification: 1, 127 | } 128 | err := s.UpdateSubscription(ctx, sub.UserID, sub.SourceID, sub) 129 | assert.Nil(t, err) 130 | 131 | err = s.AddSubscription(ctx, sub) 132 | assert.Nil(t, err) 133 | 134 | sub.Tag = "tag" 135 | err = s.UpdateSubscription(ctx, sub.UserID, sub.SourceID, sub) 136 | assert.Nil(t, err) 137 | 138 | subscription, err := s.GetSubscription(ctx, sub.UserID, sub.SourceID) 139 | assert.Nil(t, err) 140 | assert.Equal(t, sub.Tag, subscription.Tag) 141 | }, 142 | ) 143 | 144 | t.Run( 145 | "upsert subscription", func(t *testing.T) { 146 | sub := &model.Subscribe{ 147 | ID: 10001, 148 | SourceID: 1000, 149 | UserID: 1002, 150 | EnableNotification: 1, 151 | } 152 | err := s.UpsertSubscription(ctx, sub.UserID, sub.SourceID, sub) 153 | assert.Nil(t, err) 154 | 155 | err = s.AddSubscription(ctx, sub) 156 | assert.Error(t, err) 157 | 158 | sub.Tag = "tag" 159 | err = s.UpsertSubscription(ctx, sub.UserID, sub.SourceID, sub) 160 | assert.Nil(t, err) 161 | 162 | subscription, err := s.GetSubscription(ctx, sub.UserID, sub.SourceID) 163 | assert.Nil(t, err) 164 | assert.Equal(t, sub.Tag, subscription.Tag) 165 | }, 166 | ) 167 | } 168 | -------------------------------------------------------------------------------- /internal/scheduler/rss.go: -------------------------------------------------------------------------------- 1 | package scheduler 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | 8 | "github.com/mmcdole/gofeed" 9 | "go.uber.org/atomic" 10 | 11 | "github.com/indes/flowerss-bot/internal/config" 12 | "github.com/indes/flowerss-bot/internal/core" 13 | "github.com/indes/flowerss-bot/internal/feed" 14 | "github.com/indes/flowerss-bot/internal/log" 15 | "github.com/indes/flowerss-bot/internal/model" 16 | "github.com/indes/flowerss-bot/pkg/client" 17 | ) 18 | 19 | // RssUpdateObserver Rss Update observer 20 | type RssUpdateObserver interface { 21 | SourceUpdate(*model.Source, []*model.Content, []*model.Subscribe) 22 | SourceUpdateError(*model.Source) 23 | } 24 | 25 | // NewRssTask new RssUpdateTask 26 | func NewRssTask(appCore *core.Core) *RssUpdateTask { 27 | return &RssUpdateTask{ 28 | observerList: []RssUpdateObserver{}, 29 | core: appCore, 30 | feedParser: appCore.FeedParser(), 31 | httpClient: appCore.HttpClient(), 32 | } 33 | } 34 | 35 | // RssUpdateTask rss更新任务 36 | type RssUpdateTask struct { 37 | observerList []RssUpdateObserver 38 | isStop atomic.Bool 39 | core *core.Core 40 | feedParser *feed.FeedParser 41 | httpClient *client.HttpClient 42 | } 43 | 44 | // Register 注册rss更新订阅者 45 | func (t *RssUpdateTask) Register(observer RssUpdateObserver) { 46 | t.observerList = append(t.observerList, observer) 47 | } 48 | 49 | // Stop scheduler 50 | func (t *RssUpdateTask) Stop() { 51 | t.isStop.Store(true) 52 | } 53 | 54 | // Start run scheduler 55 | func (t *RssUpdateTask) Start() { 56 | if config.RunMode == config.TestMode { 57 | return 58 | } 59 | 60 | t.isStop.Store(false) 61 | go func() { 62 | for { 63 | if t.isStop.Load() { 64 | log.Info("RssUpdateTask stopped") 65 | return 66 | } 67 | 68 | sources, err := t.core.GetSources(context.Background()) 69 | if err != nil { 70 | log.Errorf("get sources failed, %v", err) 71 | time.Sleep(time.Duration(config.UpdateInterval) * time.Minute) 72 | continue 73 | } 74 | for _, source := range sources { 75 | if source.ErrorCount >= config.ErrorThreshold { 76 | continue 77 | } 78 | 79 | newContents, err := t.getSourceNewContents(source) 80 | if err != nil { 81 | if source.ErrorCount >= config.ErrorThreshold { 82 | t.notifyAllObserverErrorUpdate(source) 83 | } 84 | continue 85 | } 86 | 87 | if len(newContents) > 0 { 88 | subs, err := t.core.GetSourceAllSubscriptions( 89 | context.Background(), source.ID, 90 | ) 91 | if err != nil { 92 | log.Errorf("get subscriptions failed, %v", err) 93 | continue 94 | } 95 | t.notifyAllObserverUpdate(source, newContents, subs) 96 | } 97 | } 98 | 99 | time.Sleep(time.Duration(config.UpdateInterval) * time.Minute) 100 | } 101 | }() 102 | } 103 | 104 | // getSourceNewContents 获取rss新内容 105 | func (t *RssUpdateTask) getSourceNewContents(source *model.Source) ([]*model.Content, error) { 106 | log.Debugf("fetch source [%d]%s update", source.ID, source.Link) 107 | 108 | rssFeed, err := t.feedParser.ParseFromURL(context.Background(), source.Link) 109 | if err != nil { 110 | log.Errorf("unable to fetch feed, source %#v, err %v", source, err) 111 | t.core.SourceErrorCountIncr(context.Background(), source.ID) 112 | return nil, err 113 | } 114 | t.core.ClearSourceErrorCount(context.Background(), source.ID) 115 | 116 | newContents, err := t.saveNewContents(source, rssFeed.Items) 117 | if err != nil { 118 | return nil, err 119 | } 120 | return newContents, nil 121 | } 122 | 123 | // saveNewContents generate content by fetcher item 124 | func (t *RssUpdateTask) saveNewContents( 125 | s *model.Source, items []*gofeed.Item, 126 | ) ([]*model.Content, error) { 127 | var newItems []*gofeed.Item 128 | for _, item := range items { 129 | hashID := model.GenHashID(s.Link, item.GUID) 130 | exist, err := t.core.ContentHashIDExist(context.Background(), hashID) 131 | if err != nil { 132 | log.Errorf("check item hash id failed, %v", err) 133 | } 134 | 135 | if exist { 136 | // 已存在,跳过 137 | continue 138 | } 139 | newItems = append(newItems, item) 140 | } 141 | return t.core.AddSourceContents(context.Background(), s, newItems) 142 | } 143 | 144 | // notifyAllObserverUpdate notify all rss SourceUpdate observer 145 | func (t *RssUpdateTask) notifyAllObserverUpdate( 146 | source *model.Source, newContents []*model.Content, subscribes []*model.Subscribe, 147 | ) { 148 | wg := sync.WaitGroup{} 149 | for _, observer := range t.observerList { 150 | wg.Add(1) 151 | go func(o RssUpdateObserver) { 152 | defer wg.Done() 153 | o.SourceUpdate(source, newContents, subscribes) 154 | }(observer) 155 | } 156 | wg.Wait() 157 | } 158 | 159 | // notifyAllObserverErrorUpdate notify all rss error SourceUpdate observer 160 | func (t *RssUpdateTask) notifyAllObserverErrorUpdate(source *model.Source) { 161 | wg := sync.WaitGroup{} 162 | for _, observer := range t.observerList { 163 | wg.Add(1) 164 | go func(o RssUpdateObserver) { 165 | defer wg.Done() 166 | o.SourceUpdateError(source) 167 | }(observer) 168 | } 169 | wg.Wait() 170 | } 171 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python,pycharm,visualstudiocode 3 | # Edit at https://www.gitignore.io/?templates=python,pycharm,visualstudiocode 4 | 5 | ### PyCharm ### 6 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 7 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 8 | 9 | # User-specific stuff 10 | .idea/**/workspace.xml 11 | .idea/**/tasks.xml 12 | .idea/**/usage.statistics.xml 13 | .idea/**/dictionaries 14 | .idea/**/shelf 15 | 16 | # Generated files 17 | .idea/**/contentModel.xml 18 | 19 | # Sensitive or high-churn files 20 | .idea/**/dataSources/ 21 | .idea/**/dataSources.ids 22 | .idea/**/dataSources.local.xml 23 | .idea/**/sqlDataSources.xml 24 | .idea/**/dynamic.xml 25 | .idea/**/uiDesigner.xml 26 | .idea/**/dbnavigator.xml 27 | 28 | # Gradle 29 | .idea/**/gradle.xml 30 | .idea/**/libraries 31 | 32 | # Gradle and Maven with auto-import 33 | # When using Gradle or Maven with auto-import, you should exclude module files, 34 | # since they will be recreated, and may cause churn. Uncomment if using 35 | # auto-import. 36 | # .idea/modules.xml 37 | # .idea/*.iml 38 | # .idea/modules 39 | 40 | # CMake 41 | cmake-build-*/ 42 | 43 | # Mongo Explorer plugin 44 | .idea/**/mongoSettings.xml 45 | 46 | # File-based project format 47 | *.iws 48 | 49 | # IntelliJ 50 | out/ 51 | 52 | # mpeltonen/sbt-idea plugin 53 | .idea_modules/ 54 | 55 | # JIRA plugin 56 | atlassian-ide-plugin.xml 57 | 58 | # Cursive Clojure plugin 59 | .idea/replstate.xml 60 | 61 | # Crashlytics plugin (for Android Studio and IntelliJ) 62 | com_crashlytics_export_strings.xml 63 | crashlytics.properties 64 | crashlytics-build.properties 65 | fabric.properties 66 | 67 | # Editor-based Rest Client 68 | .idea/httpRequests 69 | 70 | # Android studio 3.1+ serialized cache file 71 | .idea/caches/build_file_checksums.ser 72 | 73 | ### PyCharm Patch ### 74 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 75 | 76 | # *.iml 77 | # modules.xml 78 | # .idea/misc.xml 79 | # *.ipr 80 | 81 | # Sonarlint plugin 82 | .idea/sonarlint 83 | 84 | ### Python ### 85 | # Byte-compiled / optimized / DLL files 86 | __pycache__/ 87 | *.py[cod] 88 | *$py.class 89 | 90 | # C extensions 91 | *.so 92 | 93 | # Distribution / packaging 94 | .Python 95 | build/ 96 | develop-eggs/ 97 | dist/ 98 | downloads/ 99 | eggs/ 100 | .eggs/ 101 | lib/ 102 | lib64/ 103 | parts/ 104 | sdist/ 105 | var/ 106 | wheels/ 107 | *.egg-info/ 108 | .installed.cfg 109 | *.egg 110 | MANIFEST 111 | 112 | # PyInstaller 113 | # Usually these files are written by a python script from a template 114 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 115 | *.manifest 116 | *.spec 117 | 118 | # Installer logs 119 | pip-log.txt 120 | pip-delete-this-directory.txt 121 | 122 | # Unit test / coverage reports 123 | htmlcov/ 124 | .tox/ 125 | .nox/ 126 | .coverage 127 | .coverage.* 128 | .cache 129 | nosetests.xml 130 | coverage.xml 131 | *.cover 132 | .hypothesis/ 133 | .pytest_cache/ 134 | 135 | # Translations 136 | *.mo 137 | *.pot 138 | 139 | # Django stuff: 140 | *.log 141 | local_settings.py 142 | db.sqlite3 143 | 144 | # Flask stuff: 145 | instance/ 146 | .webassets-cache 147 | 148 | # Scrapy stuff: 149 | .scrapy 150 | 151 | # Sphinx documentation 152 | docs/_build/ 153 | 154 | # PyBuilder 155 | target/ 156 | 157 | # Jupyter Notebook 158 | .ipynb_checkpoints 159 | 160 | # IPython 161 | profile_default/ 162 | ipython_config.py 163 | 164 | # pyenv 165 | .python-version 166 | 167 | # celery beat schedule file 168 | celerybeat-schedule 169 | 170 | # SageMath parsed files 171 | *.sage.py 172 | 173 | # Environments 174 | .env 175 | .venv 176 | env/ 177 | venv/ 178 | ENV/ 179 | env.bak/ 180 | venv.bak/ 181 | 182 | # Spyder project settings 183 | .spyderproject 184 | .spyproject 185 | 186 | # Rope project settings 187 | .ropeproject 188 | 189 | # mkdocs documentation 190 | /site 191 | 192 | # mypy 193 | .mypy_cache/ 194 | .dmypy.json 195 | dmypy.json 196 | 197 | # Pyre type checker 198 | .pyre/ 199 | 200 | ### Python Patch ### 201 | .venv/ 202 | 203 | ### Python.VirtualEnv Stack ### 204 | # Virtualenv 205 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 206 | [Bb]in 207 | [Ii]nclude 208 | [Ll]ib 209 | [Ll]ib64 210 | [Ll]ocal 211 | [Ss]cripts 212 | pyvenv.cfg 213 | pip-selfcheck.json 214 | 215 | ### VisualStudioCode ### 216 | .vscode/* 217 | !.vscode/settings.json 218 | !.vscode/tasks.json 219 | !.vscode/launch.json 220 | !.vscode/extensions.json 221 | 222 | ### VisualStudioCode Patch ### 223 | # Ignore all local history of files 224 | .history 225 | 226 | ### Go ### 227 | # Binaries for programs and plugins 228 | *.exe 229 | *.exe~ 230 | *.dll 231 | *.so 232 | *.dylib 233 | 234 | # Test binary, built with `go test -c` 235 | *.test 236 | 237 | # Output of the go coverage tool, specifically when used with LiteIDE 238 | *.out 239 | 240 | ### Go Patch ### 241 | /vendor/ 242 | /Godeps/ 243 | 244 | 245 | # End of https://www.gitignore.io/api/python,pycharm,visualstudiocode 246 | 247 | .idea/* 248 | .vscode/* 249 | __pycache__/* 250 | 251 | config.yml 252 | flowerss-bot 253 | 254 | *.db 255 | .dockerfile -------------------------------------------------------------------------------- /internal/bot/session/attachment.pb.go: -------------------------------------------------------------------------------- 1 | //go:generate protoc --go_out=. attachment.proto 2 | 3 | // Code generated by protoc-gen-go. DO NOT EDIT. 4 | // versions: 5 | // protoc-gen-go v1.26.0 6 | // protoc v3.19.4 7 | // source: attachment.proto 8 | 9 | package session 10 | 11 | import ( 12 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 13 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 14 | reflect "reflect" 15 | sync "sync" 16 | ) 17 | 18 | const ( 19 | // Verify that this generated code is sufficiently up-to-date. 20 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 21 | // Verify that runtime/protoimpl is sufficiently up-to-date. 22 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 23 | ) 24 | 25 | type Attachment struct { 26 | state protoimpl.MessageState 27 | sizeCache protoimpl.SizeCache 28 | unknownFields protoimpl.UnknownFields 29 | 30 | UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` 31 | SourceId uint32 `protobuf:"varint,2,opt,name=source_id,json=sourceId,proto3" json:"source_id,omitempty"` 32 | } 33 | 34 | func (x *Attachment) Reset() { 35 | *x = Attachment{} 36 | if protoimpl.UnsafeEnabled { 37 | mi := &file_attachment_proto_msgTypes[0] 38 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 39 | ms.StoreMessageInfo(mi) 40 | } 41 | } 42 | 43 | func (x *Attachment) String() string { 44 | return protoimpl.X.MessageStringOf(x) 45 | } 46 | 47 | func (*Attachment) ProtoMessage() {} 48 | 49 | func (x *Attachment) ProtoReflect() protoreflect.Message { 50 | mi := &file_attachment_proto_msgTypes[0] 51 | if protoimpl.UnsafeEnabled && x != nil { 52 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 53 | if ms.LoadMessageInfo() == nil { 54 | ms.StoreMessageInfo(mi) 55 | } 56 | return ms 57 | } 58 | return mi.MessageOf(x) 59 | } 60 | 61 | // Deprecated: Use Attachment.ProtoReflect.Descriptor instead. 62 | func (*Attachment) Descriptor() ([]byte, []int) { 63 | return file_attachment_proto_rawDescGZIP(), []int{0} 64 | } 65 | 66 | func (x *Attachment) GetUserId() int64 { 67 | if x != nil { 68 | return x.UserId 69 | } 70 | return 0 71 | } 72 | 73 | func (x *Attachment) GetSourceId() uint32 { 74 | if x != nil { 75 | return x.SourceId 76 | } 77 | return 0 78 | } 79 | 80 | var File_attachment_proto protoreflect.FileDescriptor 81 | 82 | var file_attachment_proto_rawDesc = []byte{ 83 | 0x0a, 0x10, 0x61, 0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 84 | 0x74, 0x6f, 0x12, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x42, 0x0a, 0x0a, 0x41, 85 | 0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 86 | 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 87 | 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 88 | 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x64, 0x42, 89 | 0x0c, 0x5a, 0x0a, 0x2e, 0x2e, 0x2f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x62, 0x06, 0x70, 90 | 0x72, 0x6f, 0x74, 0x6f, 0x33, 91 | } 92 | 93 | var ( 94 | file_attachment_proto_rawDescOnce sync.Once 95 | file_attachment_proto_rawDescData = file_attachment_proto_rawDesc 96 | ) 97 | 98 | func file_attachment_proto_rawDescGZIP() []byte { 99 | file_attachment_proto_rawDescOnce.Do(func() { 100 | file_attachment_proto_rawDescData = protoimpl.X.CompressGZIP(file_attachment_proto_rawDescData) 101 | }) 102 | return file_attachment_proto_rawDescData 103 | } 104 | 105 | var file_attachment_proto_msgTypes = make([]protoimpl.MessageInfo, 1) 106 | var file_attachment_proto_goTypes = []interface{}{ 107 | (*Attachment)(nil), // 0: session.Attachment 108 | } 109 | var file_attachment_proto_depIdxs = []int32{ 110 | 0, // [0:0] is the sub-list for method output_type 111 | 0, // [0:0] is the sub-list for method input_type 112 | 0, // [0:0] is the sub-list for extension type_name 113 | 0, // [0:0] is the sub-list for extension extendee 114 | 0, // [0:0] is the sub-list for field type_name 115 | } 116 | 117 | func init() { file_attachment_proto_init() } 118 | func file_attachment_proto_init() { 119 | if File_attachment_proto != nil { 120 | return 121 | } 122 | if !protoimpl.UnsafeEnabled { 123 | file_attachment_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { 124 | switch v := v.(*Attachment); i { 125 | case 0: 126 | return &v.state 127 | case 1: 128 | return &v.sizeCache 129 | case 2: 130 | return &v.unknownFields 131 | default: 132 | return nil 133 | } 134 | } 135 | } 136 | type x struct{} 137 | out := protoimpl.TypeBuilder{ 138 | File: protoimpl.DescBuilder{ 139 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 140 | RawDescriptor: file_attachment_proto_rawDesc, 141 | NumEnums: 0, 142 | NumMessages: 1, 143 | NumExtensions: 0, 144 | NumServices: 0, 145 | }, 146 | GoTypes: file_attachment_proto_goTypes, 147 | DependencyIndexes: file_attachment_proto_depIdxs, 148 | MessageInfos: file_attachment_proto_msgTypes, 149 | }.Build() 150 | File_attachment_proto = out.File 151 | file_attachment_proto_rawDesc = nil 152 | file_attachment_proto_goTypes = nil 153 | file_attachment_proto_depIdxs = nil 154 | } 155 | -------------------------------------------------------------------------------- /internal/bot/handler/remove_subscription.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | tb "gopkg.in/telebot.v3" 8 | 9 | "github.com/indes/flowerss-bot/internal/bot/chat" 10 | "github.com/indes/flowerss-bot/internal/bot/message" 11 | "github.com/indes/flowerss-bot/internal/bot/session" 12 | "github.com/indes/flowerss-bot/internal/core" 13 | "github.com/indes/flowerss-bot/internal/log" 14 | ) 15 | 16 | type RemoveSubscription struct { 17 | bot *tb.Bot 18 | core *core.Core 19 | } 20 | 21 | func NewRemoveSubscription(bot *tb.Bot, core *core.Core) *RemoveSubscription { 22 | return &RemoveSubscription{ 23 | bot: bot, 24 | core: core, 25 | } 26 | } 27 | 28 | func (s *RemoveSubscription) Command() string { 29 | return "/unsub" 30 | } 31 | 32 | func (s *RemoveSubscription) Description() string { 33 | return "退订RSS源" 34 | } 35 | 36 | func (s *RemoveSubscription) removeForChannel(ctx tb.Context, channelName string) error { 37 | sourceURL := message.URLFromMessage(ctx.Message()) 38 | if sourceURL == "" { 39 | return ctx.Send("频道退订请使用' /unsub @ChannelID URL ' 命令") 40 | } 41 | 42 | channelChat, err := s.bot.ChatByUsername(channelName) 43 | if err != nil { 44 | return ctx.Reply("获取频道信息错误") 45 | } 46 | 47 | if !chat.IsChatAdmin(s.bot, channelChat, ctx.Sender().ID) { 48 | return ctx.Reply("非频道管理员无法执行此操作") 49 | } 50 | 51 | source, err := s.core.GetSourceByURL(context.Background(), sourceURL) 52 | if err != nil { 53 | return ctx.Reply("获取订阅信息错误") 54 | } 55 | 56 | log.Infof("%d for [%d]%s unsubscribe %s", ctx.Chat().ID, source.ID, source.Title, source.Link) 57 | if err := s.core.Unsubscribe(context.Background(), channelChat.ID, source.ID); err != nil { 58 | log.Errorf( 59 | "%d for [%d]%s unsubscribe %s failed, %v", 60 | ctx.Chat().ID, source.ID, source.Title, source.Link, err, 61 | ) 62 | return ctx.Reply("退订失败") 63 | } 64 | return ctx.Send( 65 | fmt.Sprintf( 66 | "频道 [%s](https://t.me/%s) 退订 [%s](%s) 成功", 67 | channelChat.Title, channelChat.Username, source.Title, source.Link, 68 | ), 69 | &tb.SendOptions{DisableWebPagePreview: true, ParseMode: tb.ModeMarkdown}, 70 | ) 71 | } 72 | 73 | func (s *RemoveSubscription) removeForChat(ctx tb.Context) error { 74 | sourceURL := message.URLFromMessage(ctx.Message()) 75 | if sourceURL == "" { 76 | sources, err := s.core.GetUserSubscribedSources(context.Background(), ctx.Chat().ID) 77 | if err != nil { 78 | return ctx.Reply("获取订阅列表失败") 79 | } 80 | 81 | if len(sources) == 0 { 82 | return ctx.Reply("没有订阅") 83 | } 84 | 85 | var unsubFeedItemButtons [][]tb.InlineButton 86 | for _, source := range sources { 87 | attachData := &session.Attachment{ 88 | UserId: ctx.Chat().ID, 89 | SourceId: uint32(source.ID), 90 | } 91 | 92 | data := session.Marshal(attachData) 93 | unsubFeedItemButtons = append( 94 | unsubFeedItemButtons, []tb.InlineButton{ 95 | { 96 | Unique: RemoveSubscriptionItemButtonUnique, 97 | Text: fmt.Sprintf("[%d] %s", source.ID, source.Title), 98 | Data: data, 99 | }, 100 | }, 101 | ) 102 | } 103 | return ctx.Reply("请选择你要退订的源", &tb.ReplyMarkup{InlineKeyboard: unsubFeedItemButtons}) 104 | } 105 | 106 | if !chat.IsChatAdmin(s.bot, ctx.Chat(), ctx.Sender().ID) { 107 | return ctx.Reply("非管理员无法执行此操作") 108 | } 109 | 110 | source, err := s.core.GetSourceByURL(context.Background(), sourceURL) 111 | if err != nil { 112 | return ctx.Reply("未订阅该RSS源") 113 | } 114 | 115 | log.Infof("%d unsubscribe [%d]%s %s", ctx.Chat().ID, source.ID, source.Title, source.Link) 116 | if err := s.core.Unsubscribe(context.Background(), ctx.Chat().ID, source.ID); err != nil { 117 | log.Errorf( 118 | "%d for [%d]%s unsubscribe %s failed, %v", 119 | ctx.Chat().ID, source.ID, source.Title, source.Link, err, 120 | ) 121 | return ctx.Reply("退订失败") 122 | } 123 | return ctx.Send( 124 | fmt.Sprintf("[%s](%s) 退订成功!", source.Title, source.Link), 125 | &tb.SendOptions{DisableWebPagePreview: true, ParseMode: tb.ModeMarkdown}, 126 | ) 127 | } 128 | 129 | func (s *RemoveSubscription) Handle(ctx tb.Context) error { 130 | mention := message.MentionFromMessage(ctx.Message()) 131 | if mention != "" { 132 | return s.removeForChannel(ctx, mention) 133 | } 134 | return s.removeForChat(ctx) 135 | } 136 | 137 | func (s *RemoveSubscription) Middlewares() []tb.MiddlewareFunc { 138 | return nil 139 | } 140 | 141 | const ( 142 | RemoveSubscriptionItemButtonUnique = "unsub_feed_item_btn" 143 | ) 144 | 145 | type RemoveSubscriptionItemButton struct { 146 | core *core.Core 147 | } 148 | 149 | func NewRemoveSubscriptionItemButton(core *core.Core) *RemoveSubscriptionItemButton { 150 | return &RemoveSubscriptionItemButton{core: core} 151 | } 152 | 153 | func (r *RemoveSubscriptionItemButton) CallbackUnique() string { 154 | return "\f" + RemoveSubscriptionItemButtonUnique 155 | } 156 | 157 | func (r *RemoveSubscriptionItemButton) Description() string { 158 | return "" 159 | } 160 | 161 | func (r *RemoveSubscriptionItemButton) Handle(ctx tb.Context) error { 162 | if ctx.Callback() == nil { 163 | return ctx.Edit("内部错误!") 164 | } 165 | 166 | attachData, err := session.UnmarshalAttachment(ctx.Callback().Data) 167 | if err != nil { 168 | return ctx.Edit("退订错误!") 169 | } 170 | 171 | userID := attachData.GetUserId() 172 | sourceID := uint(attachData.GetSourceId()) 173 | source, err := r.core.GetSource(context.Background(), sourceID) 174 | if err != nil { 175 | return ctx.Edit("退订错误!") 176 | } 177 | 178 | if err := r.core.Unsubscribe(context.Background(), userID, sourceID); err != nil { 179 | log.Errorf("unsubscribe data %s failed, %v", ctx.Callback().Data, err) 180 | return ctx.Edit("退订错误!") 181 | } 182 | 183 | rtnMsg := fmt.Sprintf("[%d] %s 退订成功", sourceID, source.Link, source.Title) 184 | return ctx.Edit(rtnMsg, &tb.SendOptions{ParseMode: tb.ModeHTML}) 185 | } 186 | 187 | func (r *RemoveSubscriptionItemButton) Middlewares() []tb.MiddlewareFunc { 188 | return nil 189 | } 190 | -------------------------------------------------------------------------------- /internal/bot/handler/set.go: -------------------------------------------------------------------------------- 1 | package handler 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "text/template" 8 | 9 | tb "gopkg.in/telebot.v3" 10 | 11 | "github.com/indes/flowerss-bot/internal/bot/chat" 12 | "github.com/indes/flowerss-bot/internal/bot/session" 13 | "github.com/indes/flowerss-bot/internal/config" 14 | "github.com/indes/flowerss-bot/internal/core" 15 | "github.com/indes/flowerss-bot/internal/model" 16 | ) 17 | 18 | type Set struct { 19 | bot *tb.Bot 20 | core *core.Core 21 | } 22 | 23 | func NewSet(bot *tb.Bot, core *core.Core) *Set { 24 | return &Set{ 25 | bot: bot, 26 | core: core, 27 | } 28 | } 29 | 30 | func (s *Set) Command() string { 31 | return "/set" 32 | } 33 | 34 | func (s *Set) Description() string { 35 | return "设置订阅" 36 | } 37 | 38 | func (s *Set) Handle(ctx tb.Context) error { 39 | mentionChat, _ := session.GetMentionChatFromCtxStore(ctx) 40 | ownerID := ctx.Message().Chat.ID 41 | if mentionChat != nil { 42 | ownerID = mentionChat.ID 43 | } 44 | 45 | sources, err := s.core.GetUserSubscribedSources(context.Background(), ownerID) 46 | if err != nil { 47 | return ctx.Reply("获取订阅失败") 48 | } 49 | if len(sources) <= 0 { 50 | return ctx.Reply("当前没有订阅") 51 | } 52 | 53 | // 配置按钮 54 | var replyButton []tb.ReplyButton 55 | replyKeys := [][]tb.ReplyButton{} 56 | setFeedItemBtns := [][]tb.InlineButton{} 57 | for _, source := range sources { 58 | // 添加按钮 59 | text := fmt.Sprintf("%s %s", source.Title, source.Link) 60 | replyButton = []tb.ReplyButton{ 61 | tb.ReplyButton{Text: text}, 62 | } 63 | replyKeys = append(replyKeys, replyButton) 64 | attachData := &session.Attachment{ 65 | UserId: ctx.Chat().ID, 66 | SourceId: uint32(source.ID), 67 | } 68 | 69 | data := session.Marshal(attachData) 70 | setFeedItemBtns = append( 71 | setFeedItemBtns, []tb.InlineButton{ 72 | tb.InlineButton{ 73 | Unique: SetFeedItemButtonUnique, 74 | Text: fmt.Sprintf("[%d] %s", source.ID, source.Title), 75 | Data: data, 76 | }, 77 | }, 78 | ) 79 | } 80 | 81 | return ctx.Reply( 82 | "请选择你要设置的源", &tb.ReplyMarkup{ 83 | InlineKeyboard: setFeedItemBtns, 84 | }, 85 | ) 86 | } 87 | 88 | func (s *Set) Middlewares() []tb.MiddlewareFunc { 89 | return nil 90 | } 91 | 92 | const ( 93 | SetFeedItemButtonUnique = "set_feed_item_btn" 94 | feedSettingTmpl = ` 95 | 订阅设置 96 | [id] {{ .source.ID }} 97 | [标题] {{ .source.Title }} 98 | [Link] {{.source.Link }} 99 | [抓取更新] {{if ge .source.ErrorCount .Count }}暂停{{else if lt .source.ErrorCount .Count }}抓取中{{end}} 100 | [抓取频率] {{ .sub.Interval }}分钟 101 | [通知] {{if eq .sub.EnableNotification 0}}关闭{{else if eq .sub.EnableNotification 1}}开启{{end}} 102 | [Telegraph] {{if eq .sub.EnableTelegraph 0}}关闭{{else if eq .sub.EnableTelegraph 1}}开启{{end}} 103 | [Tag] {{if .sub.Tag}}{{ .sub.Tag }}{{else}}无{{end}} 104 | ` 105 | ) 106 | 107 | type SetFeedItemButton struct { 108 | bot *tb.Bot 109 | core *core.Core 110 | } 111 | 112 | func NewSetFeedItemButton(bot *tb.Bot, core *core.Core) *SetFeedItemButton { 113 | return &SetFeedItemButton{bot: bot, core: core} 114 | } 115 | 116 | func (r *SetFeedItemButton) CallbackUnique() string { 117 | return "\f" + SetFeedItemButtonUnique 118 | } 119 | 120 | func (r *SetFeedItemButton) Description() string { 121 | return "" 122 | } 123 | 124 | func (r *SetFeedItemButton) Handle(ctx tb.Context) error { 125 | attachData, err := session.UnmarshalAttachment(ctx.Callback().Data) 126 | if err != nil { 127 | return ctx.Edit("退订错误!") 128 | } 129 | 130 | subscriberID := attachData.GetUserId() 131 | // 如果订阅者与按钮点击者id不一致,需要验证管理员权限 132 | if subscriberID != ctx.Callback().Sender.ID { 133 | channelChat, err := r.bot.ChatByUsername(fmt.Sprintf("%d", subscriberID)) 134 | if err != nil { 135 | return ctx.Edit("获取订阅信息失败") 136 | } 137 | 138 | if !chat.IsChatAdmin(r.bot, channelChat, ctx.Callback().Sender.ID) { 139 | return ctx.Edit("获取订阅信息失败") 140 | } 141 | } 142 | 143 | sourceID := uint(attachData.GetSourceId()) 144 | source, err := r.core.GetSource(context.Background(), sourceID) 145 | if err != nil { 146 | return ctx.Edit("找不到该订阅源") 147 | } 148 | 149 | sub, err := r.core.GetSubscription(context.Background(), subscriberID, source.ID) 150 | if err != nil { 151 | return ctx.Edit("用户未订阅该rss") 152 | } 153 | 154 | t := template.New("setting template") 155 | _, _ = t.Parse(feedSettingTmpl) 156 | text := new(bytes.Buffer) 157 | _ = t.Execute(text, map[string]interface{}{"source": source, "sub": sub, "Count": config.ErrorThreshold}) 158 | return ctx.Edit( 159 | text.String(), 160 | &tb.SendOptions{ParseMode: tb.ModeHTML}, 161 | &tb.ReplyMarkup{InlineKeyboard: genFeedSetBtn(ctx.Callback(), sub, source)}, 162 | ) 163 | } 164 | 165 | func genFeedSetBtn( 166 | c *tb.Callback, sub *model.Subscribe, source *model.Source, 167 | ) [][]tb.InlineButton { 168 | setSubTagKey := tb.InlineButton{ 169 | Unique: SetSubscriptionTagButtonUnique, 170 | Text: "标签设置", 171 | Data: c.Data, 172 | } 173 | 174 | toggleNoticeKey := tb.InlineButton{ 175 | Unique: NotificationSwitchButtonUnique, 176 | Text: "开启通知", 177 | Data: c.Data, 178 | } 179 | if sub.EnableNotification == 1 { 180 | toggleNoticeKey.Text = "关闭通知" 181 | } 182 | 183 | toggleTelegraphKey := tb.InlineButton{ 184 | Unique: TelegraphSwitchButtonUnique, 185 | Text: "开启 Telegraph 转码", 186 | Data: c.Data, 187 | } 188 | if sub.EnableTelegraph == 1 { 189 | toggleTelegraphKey.Text = "关闭 Telegraph 转码" 190 | } 191 | 192 | toggleEnabledKey := tb.InlineButton{ 193 | Unique: SubscriptionSwitchButtonUnique, 194 | Text: "暂停更新", 195 | Data: c.Data, 196 | } 197 | 198 | if source.ErrorCount >= config.ErrorThreshold { 199 | toggleEnabledKey.Text = "重启更新" 200 | } 201 | 202 | feedSettingKeys := [][]tb.InlineButton{ 203 | []tb.InlineButton{ 204 | toggleEnabledKey, 205 | toggleNoticeKey, 206 | }, 207 | []tb.InlineButton{ 208 | toggleTelegraphKey, 209 | setSubTagKey, 210 | }, 211 | } 212 | return feedSettingKeys 213 | } 214 | 215 | func (r *SetFeedItemButton) Middlewares() []tb.MiddlewareFunc { 216 | return nil 217 | } 218 | -------------------------------------------------------------------------------- /internal/storage/subscription.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "gorm.io/gorm" 8 | 9 | "github.com/indes/flowerss-bot/internal/log" 10 | "github.com/indes/flowerss-bot/internal/model" 11 | ) 12 | 13 | type SubscriptionStorageImpl struct { 14 | db *gorm.DB 15 | } 16 | 17 | func NewSubscriptionStorageImpl(db *gorm.DB) *SubscriptionStorageImpl { 18 | return &SubscriptionStorageImpl{db: db.Model(&model.Subscribe{})} 19 | } 20 | 21 | func (s *SubscriptionStorageImpl) Init(ctx context.Context) error { 22 | return s.db.Migrator().AutoMigrate(&model.Subscribe{}) 23 | } 24 | 25 | func (s *SubscriptionStorageImpl) AddSubscription(ctx context.Context, subscription *model.Subscribe) error { 26 | result := s.db.WithContext(ctx).Create(subscription) 27 | if result.Error != nil { 28 | return result.Error 29 | } 30 | return nil 31 | } 32 | 33 | func (s *SubscriptionStorageImpl) SubscriptionExist(ctx context.Context, userID int64, sourceID uint) (bool, error) { 34 | var count int64 35 | result := s.db.WithContext(ctx).Where("user_id = ? and source_id = ?", userID, sourceID).Count(&count) 36 | if result.Error != nil { 37 | return false, result.Error 38 | } 39 | return (count > 0), nil 40 | } 41 | 42 | func (s *SubscriptionStorageImpl) GetSubscription(ctx context.Context, userID int64, sourceID uint) ( 43 | *model.Subscribe, error, 44 | ) { 45 | subscription := &model.Subscribe{} 46 | result := s.db.WithContext(ctx).Where("user_id = ? and source_id = ?", userID, sourceID).First(subscription) 47 | if result.Error != nil { 48 | if errors.Is(result.Error, gorm.ErrRecordNotFound) { 49 | return nil, ErrRecordNotFound 50 | } 51 | return nil, result.Error 52 | } 53 | return subscription, nil 54 | } 55 | 56 | func (s *SubscriptionStorageImpl) GetSubscriptionsByUserID( 57 | ctx context.Context, userID int64, opts *GetSubscriptionsOptions, 58 | ) (*GetSubscriptionsResult, error) { 59 | var subscriptions []*model.Subscribe 60 | 61 | count := s.getSubscriptionsCount(opts) 62 | orderBy := s.getSubscriptionsOrderBy(opts) 63 | dbResult := s.db.WithContext(ctx).Where( 64 | &model.Subscribe{UserID: userID}, 65 | ).Limit(count).Order(orderBy).Offset(opts.Offset).Find(&subscriptions) 66 | if dbResult.Error != nil { 67 | if errors.Is(dbResult.Error, gorm.ErrRecordNotFound) { 68 | return nil, ErrRecordNotFound 69 | } 70 | return nil, dbResult.Error 71 | } 72 | 73 | result := &GetSubscriptionsResult{} 74 | if opts.Count != -1 && len(subscriptions) > opts.Count { 75 | result.HasMore = true 76 | subscriptions = subscriptions[:opts.Count] 77 | } 78 | 79 | result.Subscriptions = subscriptions 80 | return result, nil 81 | } 82 | 83 | func (s *SubscriptionStorageImpl) GetSubscriptionsBySourceID( 84 | ctx context.Context, sourceID uint, opts *GetSubscriptionsOptions, 85 | ) (*GetSubscriptionsResult, error) { 86 | var subscriptions []*model.Subscribe 87 | 88 | count := s.getSubscriptionsCount(opts) 89 | orderBy := s.getSubscriptionsOrderBy(opts) 90 | dbResult := s.db.WithContext(ctx).Where( 91 | &model.Subscribe{SourceID: sourceID}, 92 | ).Limit(count).Order(orderBy).Offset(opts.Offset).Find(&subscriptions) 93 | if dbResult.Error != nil { 94 | if errors.Is(dbResult.Error, gorm.ErrRecordNotFound) { 95 | return nil, ErrRecordNotFound 96 | } 97 | return nil, dbResult.Error 98 | } 99 | 100 | result := &GetSubscriptionsResult{} 101 | if opts.Count > 0 && len(subscriptions) > opts.Count { 102 | result.HasMore = true 103 | subscriptions = subscriptions[:opts.Count] 104 | } 105 | 106 | result.Subscriptions = subscriptions 107 | return result, nil 108 | } 109 | 110 | func (s *SubscriptionStorageImpl) getSubscriptionsCount(opts *GetSubscriptionsOptions) int { 111 | count := opts.Count 112 | if count != -1 { 113 | count += 1 114 | } 115 | return count 116 | } 117 | 118 | func (s *SubscriptionStorageImpl) getSubscriptionsOrderBy(opts *GetSubscriptionsOptions) string { 119 | switch opts.SortType { 120 | case SubscriptionSortTypeCreatedTimeDesc: 121 | return "created_at desc" 122 | } 123 | return "" 124 | } 125 | 126 | func (s *SubscriptionStorageImpl) CountSubscriptions(ctx context.Context) (int64, error) { 127 | var count int64 128 | dbResult := s.db.WithContext(ctx).Count(&count) 129 | if dbResult.Error != nil { 130 | return 0, dbResult.Error 131 | } 132 | return count, nil 133 | } 134 | 135 | func (s *SubscriptionStorageImpl) DeleteSubscription(ctx context.Context, userID int64, sourceID uint) (int64, error) { 136 | result := s.db.WithContext(ctx).Where( 137 | "user_id = ? and source_id = ?", userID, sourceID, 138 | ).Delete(&model.Subscribe{}) 139 | if result.Error != nil { 140 | return 0, result.Error 141 | } 142 | return result.RowsAffected, nil 143 | } 144 | 145 | func (s *SubscriptionStorageImpl) CountSourceSubscriptions(ctx context.Context, sourceID uint) (int64, error) { 146 | var count int64 147 | result := s.db.WithContext(ctx).Where("source_id = ?", sourceID).Count(&count) 148 | if result.Error != nil { 149 | return 0, result.Error 150 | } 151 | return count, nil 152 | } 153 | 154 | func (s *SubscriptionStorageImpl) UpdateSubscription( 155 | ctx context.Context, userID int64, sourceID uint, newSubscription *model.Subscribe, 156 | ) error { 157 | result := s.db.WithContext(ctx).Where( 158 | "user_id = ? and source_id = ?", userID, sourceID, 159 | ).Updates(newSubscription) 160 | if result.Error != nil { 161 | return result.Error 162 | } 163 | log.Debugf( 164 | "update %d row, userID %d sourceID %d new %#v", result.RowsAffected, userID, sourceID, newSubscription, 165 | ) 166 | return nil 167 | } 168 | 169 | func (s *SubscriptionStorageImpl) UpsertSubscription( 170 | ctx context.Context, userID int64, sourceID uint, newSubscription *model.Subscribe, 171 | ) error { 172 | result := s.db.WithContext(ctx).Where( 173 | "user_id = ? and source_id = ?", userID, sourceID, 174 | ).Save(newSubscription) 175 | if result.Error != nil { 176 | return result.Error 177 | } 178 | log.Debugf( 179 | "update %d row, userID %d sourceID %d new %#v", result.RowsAffected, userID, sourceID, newSubscription, 180 | ) 181 | return nil 182 | } 183 | -------------------------------------------------------------------------------- /internal/bot/bot.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "go.uber.org/zap" 10 | tb "gopkg.in/telebot.v3" 11 | 12 | "github.com/indes/flowerss-bot/internal/bot/handler" 13 | "github.com/indes/flowerss-bot/internal/bot/middleware" 14 | "github.com/indes/flowerss-bot/internal/bot/preview" 15 | "github.com/indes/flowerss-bot/internal/config" 16 | "github.com/indes/flowerss-bot/internal/core" 17 | "github.com/indes/flowerss-bot/internal/log" 18 | "github.com/indes/flowerss-bot/internal/model" 19 | ) 20 | 21 | type Bot struct { 22 | core *core.Core 23 | tb *tb.Bot // telebot.Bot instance 24 | } 25 | 26 | func NewBot(core *core.Core) *Bot { 27 | log.Infof("init telegram bot, token %s, endpoint %s", config.BotToken, config.TelegramEndpoint) 28 | settings := tb.Settings{ 29 | URL: config.TelegramEndpoint, 30 | Token: config.BotToken, 31 | Poller: &tb.LongPoller{Timeout: 10 * time.Second}, 32 | Client: core.HttpClient().Client(), 33 | } 34 | 35 | logLevel := config.GetString("log.level") 36 | if strings.ToLower(logLevel) == "debug" { 37 | settings.Verbose = true 38 | } 39 | 40 | b := &Bot{ 41 | core: core, 42 | } 43 | 44 | var err error 45 | b.tb, err = tb.NewBot(settings) 46 | if err != nil { 47 | log.Error(err) 48 | return nil 49 | } 50 | b.tb.Use(middleware.UserFilter(), middleware.PreLoadMentionChat(), middleware.IsChatAdmin()) 51 | return b 52 | } 53 | 54 | func (b *Bot) registerCommands(appCore *core.Core) error { 55 | commandHandlers := []handler.CommandHandler{ 56 | handler.NewStart(), 57 | handler.NewPing(b.tb), 58 | handler.NewAddSubscription(appCore), 59 | handler.NewRemoveSubscription(b.tb, appCore), 60 | handler.NewListSubscription(appCore), 61 | handler.NewRemoveAllSubscription(), 62 | handler.NewOnDocument(b.tb, appCore), 63 | handler.NewSet(b.tb, appCore), 64 | handler.NewSetFeedTag(appCore), 65 | handler.NewSetUpdateInterval(appCore), 66 | handler.NewExport(appCore), 67 | handler.NewImport(), 68 | handler.NewPauseAll(appCore), 69 | handler.NewActiveAll(appCore), 70 | handler.NewHelp(), 71 | handler.NewVersion(), 72 | } 73 | 74 | for _, h := range commandHandlers { 75 | b.tb.Handle(h.Command(), h.Handle, h.Middlewares()...) 76 | } 77 | 78 | ButtonHandlers := []handler.ButtonHandler{ 79 | handler.NewRemoveAllSubscriptionButton(appCore), 80 | handler.NewCancelRemoveAllSubscriptionButton(), 81 | handler.NewSetFeedItemButton(b.tb, appCore), 82 | handler.NewRemoveSubscriptionItemButton(appCore), 83 | handler.NewNotificationSwitchButton(b.tb, appCore), 84 | handler.NewSetSubscriptionTagButton(b.tb), 85 | handler.NewTelegraphSwitchButton(b.tb, appCore), 86 | handler.NewSubscriptionSwitchButton(b.tb, appCore), 87 | } 88 | 89 | for _, h := range ButtonHandlers { 90 | b.tb.Handle(h, h.Handle, h.Middlewares()...) 91 | } 92 | 93 | var commands []tb.Command 94 | for _, h := range commandHandlers { 95 | if h.Description() == "" { 96 | continue 97 | } 98 | commands = append(commands, tb.Command{Text: h.Command(), Description: h.Description()}) 99 | } 100 | log.Debugf("set bot command %+v", commands) 101 | if err := b.tb.SetCommands(commands); err != nil { 102 | return err 103 | } 104 | return nil 105 | } 106 | 107 | func (b *Bot) Run() error { 108 | if config.RunMode == config.TestMode { 109 | return nil 110 | } 111 | 112 | if err := b.registerCommands(b.core); err != nil { 113 | return err 114 | } 115 | log.Infof("bot start %s", config.AppVersionInfo()) 116 | b.tb.Start() 117 | return nil 118 | } 119 | 120 | func (b *Bot) SourceUpdate( 121 | source *model.Source, newContents []*model.Content, subscribes []*model.Subscribe, 122 | ) { 123 | b.BroadcastNews(source, subscribes, newContents) 124 | } 125 | 126 | func (b *Bot) SourceUpdateError(source *model.Source) { 127 | b.BroadcastSourceError(source) 128 | } 129 | 130 | // BroadcastNews send new contents message to subscriber 131 | func (b *Bot) BroadcastNews(source *model.Source, subs []*model.Subscribe, contents []*model.Content) { 132 | zap.S().Infow( 133 | "broadcast news", 134 | "fetcher id", source.ID, 135 | "fetcher title", source.Title, 136 | "subscriber count", len(subs), 137 | "new contents", len(contents), 138 | ) 139 | 140 | for _, content := range contents { 141 | previewText := preview.TrimDescription(content.Description, config.PreviewText) 142 | 143 | for _, sub := range subs { 144 | tpldata := &config.TplData{ 145 | SourceTitle: source.Title, 146 | ContentTitle: content.Title, 147 | RawLink: content.RawLink, 148 | PreviewText: previewText, 149 | TelegraphURL: content.TelegraphURL, 150 | Tags: sub.Tag, 151 | EnableTelegraph: sub.EnableTelegraph == 1 && content.TelegraphURL != "", 152 | } 153 | 154 | u := &tb.User{ 155 | ID: sub.UserID, 156 | } 157 | o := &tb.SendOptions{ 158 | DisableWebPagePreview: config.DisableWebPagePreview, 159 | ParseMode: config.MessageMode, 160 | DisableNotification: sub.EnableNotification != 1, 161 | } 162 | msg, err := tpldata.Render(config.MessageMode) 163 | if err != nil { 164 | zap.S().Errorw( 165 | "broadcast news error, tpldata.Render err", 166 | "error", err.Error(), 167 | ) 168 | return 169 | } 170 | if _, err := b.tb.Send(u, msg, o); err != nil { 171 | 172 | if strings.Contains(err.Error(), "Forbidden") { 173 | zap.S().Errorw( 174 | "broadcast news error, bot stopped by user", 175 | "error", err.Error(), 176 | "user id", sub.UserID, 177 | "source id", sub.SourceID, 178 | "title", source.Title, 179 | "link", source.Link, 180 | ) 181 | b.core.Unsubscribe(context.Background(), sub.UserID, sub.SourceID) 182 | } 183 | 184 | /* 185 | Telegram return error if markdown message has incomplete format. 186 | Print the msg to warn the user 187 | api error: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 894 188 | */ 189 | if strings.Contains(err.Error(), "parse entities") { 190 | zap.S().Errorw( 191 | "broadcast news error, markdown error", 192 | "markdown msg", msg, 193 | "error", err.Error(), 194 | ) 195 | } 196 | } 197 | } 198 | } 199 | } 200 | 201 | // BroadcastSourceError send fetcher update error message to subscribers 202 | func (b *Bot) BroadcastSourceError(source *model.Source) { 203 | subs, err := b.core.GetSourceAllSubscriptions(context.Background(), source.ID) 204 | if err != nil { 205 | log.Errorf("get subscriptions failed, %v", err) 206 | } 207 | var u tb.User 208 | for _, sub := range subs { 209 | message := fmt.Sprintf( 210 | "[%s](%s) 已经累计连续%d次更新失败,暂停更新", source.Title, source.Link, config.ErrorThreshold, 211 | ) 212 | u.ID = sub.UserID 213 | _, _ = b.tb.Send( 214 | &u, message, &tb.SendOptions{ 215 | ParseMode: tb.ModeMarkdown, 216 | }, 217 | ) 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /internal/config/autoload.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bytes" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "os" 9 | "path" 10 | "path/filepath" 11 | "regexp" 12 | "strconv" 13 | "strings" 14 | "testing" 15 | "text/template" 16 | 17 | "github.com/go-sql-driver/mysql" 18 | "github.com/spf13/viper" 19 | tb "gopkg.in/telebot.v3" 20 | ) 21 | 22 | func init() { 23 | if isInTests() { 24 | // 测试环境 25 | RunMode = TestMode 26 | initTPL() 27 | return 28 | } 29 | 30 | workDirFlag := flag.String("d", "./", "work directory of flowerss") 31 | configFile := flag.String("c", "", "config file of flowerss") 32 | printVersionFlag := flag.Bool("v", false, "prints flowerss-bot version") 33 | 34 | testTpl := flag.Bool("testtpl", false, "test template") 35 | 36 | testing.Init() 37 | flag.Parse() 38 | 39 | if *printVersionFlag { 40 | // print version 41 | fmt.Printf(AppVersionInfo()) 42 | os.Exit(0) 43 | } 44 | 45 | workDir := filepath.Clean(*workDirFlag) 46 | 47 | if *configFile != "" { 48 | viper.SetConfigFile(*configFile) 49 | } else { 50 | viper.SetConfigFile(filepath.Join(workDir, "config.yml")) 51 | } 52 | 53 | err := viper.ReadInConfig() // Find and read the config file 54 | if err != nil { // Handle errors reading the config file 55 | panic(fmt.Errorf("Fatal error config file: %s", err)) 56 | } 57 | 58 | initTPL() 59 | if *testTpl { 60 | validateTPL() 61 | os.Exit(0) 62 | } 63 | 64 | BotToken = viper.GetString("bot_token") 65 | Socks5 = viper.GetString("socks5") 66 | UserAgent = viper.GetString("user_agent") 67 | 68 | if viper.IsSet("telegraph_token") { 69 | EnableTelegraph = true 70 | TelegraphToken = viper.GetStringSlice("telegraph_token") 71 | } 72 | 73 | if viper.IsSet("telegraph_account") { 74 | EnableTelegraph = true 75 | TelegraphAccountName = viper.GetString("telegraph_account") 76 | 77 | if viper.IsSet("telegraph_author_name") { 78 | TelegraphAuthorName = viper.GetString("telegraph_author_name") 79 | } 80 | 81 | if viper.IsSet("telegraph_author_url") { 82 | TelegraphAuthorURL = viper.GetString("telegraph_author_url") 83 | } 84 | } 85 | 86 | if viper.IsSet("preview_text") { 87 | PreviewText = viper.GetInt("preview_text") 88 | } 89 | 90 | if viper.IsSet("allowed_users") { 91 | intAllowUsers := viper.GetStringSlice("allowed_users") 92 | for _, useIDStr := range intAllowUsers { 93 | userID, err := strconv.ParseInt(useIDStr, 10, 64) 94 | if err != nil { 95 | panic(fmt.Errorf("Fatal error config file: %s", err)) 96 | } 97 | AllowUsers = append(AllowUsers, userID) 98 | } 99 | } 100 | 101 | if viper.IsSet("disable_web_page_preview") { 102 | DisableWebPagePreview = viper.GetBool("disable_web_page_preview") 103 | } 104 | 105 | if viper.IsSet("telegram.endpoint") { 106 | TelegramEndpoint = viper.GetString("telegram.endpoint") 107 | } 108 | 109 | if viper.IsSet("error_threshold") { 110 | ErrorThreshold = uint(viper.GetInt("error_threshold")) 111 | } 112 | 113 | if viper.IsSet("update_interval") { 114 | UpdateInterval = viper.GetInt("update_interval") 115 | } 116 | 117 | if viper.IsSet("mysql.host") { 118 | EnableMysql = true 119 | mysqlConfig = mysql.NewConfig() 120 | mysqlConfig.Net = "tcp" 121 | mysqlConfig.Addr = fmt.Sprintf("%s:%d", viper.GetString("mysql.host"), viper.GetInt("mysql.port")) 122 | mysqlConfig.Passwd = viper.GetString("mysql.host") 123 | mysqlConfig.User = viper.GetString("mysql.user") 124 | mysqlConfig.Passwd = viper.GetString("mysql.password") 125 | mysqlConfig.DBName = viper.GetString("mysql.database") 126 | mysqlConfig.ParseTime = true 127 | mysqlConfig.Params = map[string]string{} 128 | mysqlConfig.Params["charset"] = "utf8mb4" 129 | } 130 | 131 | if !EnableMysql { 132 | if viper.IsSet("sqlite.path") { 133 | SQLitePath = viper.GetString("sqlite.path") 134 | } else { 135 | SQLitePath = filepath.Join(workDir, "data.db") 136 | } 137 | // 判断并创建SQLite目录 138 | dir := path.Dir(SQLitePath) 139 | _, err := os.Stat(dir) 140 | if err != nil { 141 | err := os.MkdirAll(dir, os.ModeDir) 142 | if err != nil { 143 | log.Fatalf("mkdir failed![%v]\n", err) 144 | } 145 | } 146 | } 147 | 148 | if viper.IsSet("log.db_log") { 149 | DBLogMode = viper.GetBool("log.db_log") 150 | } 151 | } 152 | 153 | func (t TplData) Render(mode tb.ParseMode) (string, error) { 154 | var buf []byte 155 | wb := bytes.NewBuffer(buf) 156 | 157 | if mode == tb.ModeMarkdown { 158 | mkd := regexp.MustCompile("(\\[|\\*|\\`|\\_)") 159 | t.SourceTitle = mkd.ReplaceAllString(t.SourceTitle, "\\$1") 160 | t.ContentTitle = mkd.ReplaceAllString(t.ContentTitle, "\\$1") 161 | t.PreviewText = mkd.ReplaceAllString(t.PreviewText, "\\$1") 162 | } else if mode == tb.ModeHTML { 163 | t.SourceTitle = t.replaceHTMLTags(t.SourceTitle) 164 | t.ContentTitle = t.replaceHTMLTags(t.ContentTitle) 165 | t.PreviewText = t.replaceHTMLTags(t.PreviewText) 166 | } 167 | 168 | if err := MessageTpl.Execute(wb, t); err != nil { 169 | return "", err 170 | } 171 | 172 | return strings.TrimSpace(string(wb.Bytes())), nil 173 | } 174 | 175 | func (t TplData) replaceHTMLTags(s string) string { 176 | 177 | rStr := strings.ReplaceAll(s, "&", "&") 178 | rStr = strings.ReplaceAll(rStr, "\"", """) 179 | rStr = strings.ReplaceAll(rStr, "<", "<") 180 | rStr = strings.ReplaceAll(rStr, ">", ">") 181 | 182 | return rStr 183 | } 184 | 185 | func validateTPL() { 186 | testData := []TplData{ 187 | TplData{ 188 | "RSS 源标识 - 无预览无telegraph的消息", 189 | "这是标题", 190 | "https://www.github.com/", 191 | "", 192 | "", 193 | "", 194 | false, 195 | }, 196 | TplData{ 197 | "RSS源标识 - 有预览无telegraph的消息", 198 | "这是标题", 199 | "https://www.github.com/", 200 | "这里是很长很长很长的消息预览字数补丁紫薯补丁紫薯补丁紫薯补丁紫薯补丁[1](123)", 201 | "", 202 | "#标签", 203 | false, 204 | }, 205 | TplData{ 206 | "RSS源标识 - 有预览有telegraph的消息", 207 | "这是标题", 208 | "https://www.github.com/", 209 | "这里是很长很长很长的消息预览字数补丁紫薯补丁紫薯补丁紫薯补丁紫薯补丁", 210 | "https://telegra.ph/markdown-07-07", 211 | "#标签1 #标签2", 212 | true, 213 | }, 214 | } 215 | 216 | for _, d := range testData { 217 | fmt.Println("\n////////////////////////////////////////////") 218 | fmt.Println(d.Render(MessageMode)) 219 | } 220 | fmt.Println("\n////////////////////////////////////////////") 221 | } 222 | 223 | func initTPL() { 224 | var tplMsg string 225 | if viper.IsSet("message_tpl") { 226 | tplMsg = viper.GetString("message_tpl") 227 | } else { 228 | tplMsg = defaultMessageTpl 229 | } 230 | MessageTpl = template.Must(template.New("message").Parse(tplMsg)) 231 | 232 | if viper.IsSet("message_mode") { 233 | switch strings.ToLower(viper.GetString("message_mode")) { 234 | case "md", "markdown": 235 | MessageMode = tb.ModeMarkdown 236 | case "html": 237 | MessageMode = tb.ModeHTML 238 | default: 239 | MessageMode = tb.ModeDefault 240 | } 241 | } else { 242 | MessageMode = defaultMessageTplMode 243 | } 244 | } 245 | 246 | func isInTests() bool { 247 | if flag.Lookup("test.v") != nil { 248 | return true 249 | } 250 | for _, arg := range os.Args { 251 | if strings.HasPrefix(arg, "-test") { 252 | if arg == "-testtpl" { 253 | continue 254 | } 255 | return true 256 | } 257 | } 258 | return false 259 | } 260 | -------------------------------------------------------------------------------- /internal/core/core.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "github.com/mmcdole/gofeed" 12 | "gorm.io/driver/mysql" 13 | "gorm.io/driver/sqlite" 14 | "gorm.io/gorm" 15 | 16 | "github.com/indes/flowerss-bot/internal/config" 17 | "github.com/indes/flowerss-bot/internal/feed" 18 | "github.com/indes/flowerss-bot/internal/log" 19 | "github.com/indes/flowerss-bot/internal/model" 20 | "github.com/indes/flowerss-bot/internal/preview" 21 | "github.com/indes/flowerss-bot/internal/storage" 22 | "github.com/indes/flowerss-bot/pkg/client" 23 | ) 24 | 25 | var ( 26 | ErrSubscriptionExist = errors.New("already subscribed") 27 | ErrSubscriptionNotExist = errors.New("subscription not exist") 28 | ErrSourceNotExist = errors.New("source not exist") 29 | ErrContentNotExist = errors.New("content not exist") 30 | ) 31 | 32 | type Core struct { 33 | // Storage 34 | userStorage storage.User 35 | contentStorage storage.Content 36 | sourceStorage storage.Source 37 | subscriptionStorage storage.Subscription 38 | 39 | feedParser *feed.FeedParser 40 | httpClient *client.HttpClient 41 | } 42 | 43 | func (c *Core) FeedParser() *feed.FeedParser { 44 | return c.feedParser 45 | } 46 | 47 | func (c *Core) HttpClient() *client.HttpClient { 48 | return c.httpClient 49 | } 50 | 51 | func NewCore( 52 | userStorage storage.User, 53 | contentStorage storage.Content, 54 | sourceStorage storage.Source, 55 | subscriptionStorage storage.Subscription, 56 | parser *feed.FeedParser, 57 | httpClient *client.HttpClient, 58 | ) *Core { 59 | return &Core{ 60 | userStorage: userStorage, 61 | contentStorage: contentStorage, 62 | sourceStorage: sourceStorage, 63 | subscriptionStorage: subscriptionStorage, 64 | feedParser: parser, 65 | httpClient: httpClient, 66 | } 67 | } 68 | 69 | func NewCoreFormConfig() *Core { 70 | var err error 71 | var db *gorm.DB 72 | if config.EnableMysql { 73 | db, err = gorm.Open(mysql.Open(config.GetMysqlDSN())) 74 | } else { 75 | db, err = gorm.Open(sqlite.Open(config.SQLitePath)) 76 | } 77 | if err != nil { 78 | log.Fatalf("connect db failed, err: %+v", err) 79 | return nil 80 | } 81 | 82 | if config.DBLogMode { 83 | db = db.Debug() 84 | } 85 | 86 | sqlDB, err := db.DB() 87 | sqlDB.SetMaxIdleConns(10) 88 | sqlDB.SetMaxOpenConns(50) 89 | 90 | subscriptionStorage := storage.NewSubscriptionStorageImpl(db) 91 | 92 | // httpclient 93 | clientOpts := []client.HttpClientOption{ 94 | client.WithTimeout(10 * time.Second), 95 | } 96 | if config.Socks5 != "" { 97 | clientOpts = append(clientOpts, client.WithProxyURL(fmt.Sprintf("socks5://%s", config.Socks5))) 98 | } 99 | 100 | if config.UserAgent != "" { 101 | clientOpts = append(clientOpts, client.WithUserAgent(config.UserAgent)) 102 | } 103 | httpClient := client.NewHttpClient(clientOpts...) 104 | 105 | // feedParser 106 | feedParser := feed.NewFeedParser(httpClient) 107 | 108 | return NewCore( 109 | storage.NewUserStorageImpl(db), 110 | storage.NewContentStorageImpl(db), 111 | storage.NewSourceStorageImpl(db), 112 | subscriptionStorage, 113 | feedParser, 114 | httpClient, 115 | ) 116 | } 117 | 118 | func (c *Core) Init() error { 119 | if err := c.userStorage.Init(context.Background()); err != nil { 120 | return err 121 | } 122 | if err := c.contentStorage.Init(context.Background()); err != nil { 123 | return err 124 | } 125 | if err := c.sourceStorage.Init(context.Background()); err != nil { 126 | return err 127 | } 128 | if err := c.subscriptionStorage.Init(context.Background()); err != nil { 129 | return err 130 | } 131 | return nil 132 | } 133 | 134 | // GetUserSubscribedSources 获取用户订阅的订阅源 135 | func (c *Core) GetUserSubscribedSources(ctx context.Context, userID int64) ([]*model.Source, error) { 136 | opt := &storage.GetSubscriptionsOptions{Count: -1} 137 | result, err := c.subscriptionStorage.GetSubscriptionsByUserID(ctx, userID, opt) 138 | if err != nil { 139 | return nil, err 140 | } 141 | 142 | var sources []*model.Source 143 | for _, subs := range result.Subscriptions { 144 | source, err := c.sourceStorage.GetSource(ctx, subs.SourceID) 145 | if err != nil { 146 | log.Errorf("get source %d failed, %v", subs.SourceID, err) 147 | continue 148 | } 149 | sources = append(sources, source) 150 | } 151 | return sources, nil 152 | } 153 | 154 | // AddSubscription 添加订阅 155 | func (c *Core) AddSubscription(ctx context.Context, userID int64, sourceID uint) error { 156 | exist, err := c.subscriptionStorage.SubscriptionExist(ctx, userID, sourceID) 157 | if err != nil { 158 | return err 159 | } 160 | 161 | if exist { 162 | return ErrSubscriptionExist 163 | } 164 | 165 | subscription := &model.Subscribe{ 166 | UserID: userID, 167 | SourceID: sourceID, 168 | EnableNotification: 1, 169 | EnableTelegraph: 1, 170 | Interval: config.UpdateInterval, 171 | WaitTime: config.UpdateInterval, 172 | } 173 | return c.subscriptionStorage.AddSubscription(ctx, subscription) 174 | } 175 | 176 | // Unsubscribe 添加订阅 177 | func (c *Core) Unsubscribe(ctx context.Context, userID int64, sourceID uint) error { 178 | exist, err := c.subscriptionStorage.SubscriptionExist(ctx, userID, sourceID) 179 | if err != nil { 180 | return err 181 | } 182 | 183 | if !exist { 184 | return ErrSubscriptionNotExist 185 | } 186 | 187 | // 移除该用户订阅 188 | _, err = c.subscriptionStorage.DeleteSubscription(ctx, userID, sourceID) 189 | if err != nil { 190 | return err 191 | } 192 | 193 | // 获取源的订阅数量 194 | count, err := c.subscriptionStorage.CountSourceSubscriptions(ctx, sourceID) 195 | if err != nil { 196 | return err 197 | } 198 | 199 | if count != 0 { 200 | return nil 201 | } 202 | 203 | // 如果源不再有订阅用户,移除该订阅源 204 | if err := c.removeSource(ctx, sourceID); err != nil { 205 | return err 206 | } 207 | return nil 208 | } 209 | 210 | // removeSource 移除订阅源 211 | func (c *Core) removeSource(ctx context.Context, sourceID uint) error { 212 | if err := c.sourceStorage.Delete(ctx, sourceID); err != nil { 213 | return err 214 | } 215 | 216 | count, err := c.contentStorage.DeleteSourceContents(ctx, sourceID) 217 | if err != nil { 218 | return err 219 | } 220 | log.Infof("remove source %d and %d contents", sourceID, count) 221 | return nil 222 | } 223 | 224 | // GetSourceByURL 获取用户订阅的订阅源 225 | func (c *Core) GetSourceByURL(ctx context.Context, sourceURL string) (*model.Source, error) { 226 | source, err := c.sourceStorage.GetSourceByURL(ctx, sourceURL) 227 | if err != nil { 228 | if err == storage.ErrRecordNotFound { 229 | return nil, ErrSourceNotExist 230 | } 231 | return nil, err 232 | } 233 | return source, nil 234 | } 235 | 236 | // GetSource 获取用户订阅的订阅源 237 | func (c *Core) GetSource(ctx context.Context, id uint) (*model.Source, error) { 238 | source, err := c.sourceStorage.GetSource(ctx, id) 239 | if err != nil { 240 | if err == storage.ErrRecordNotFound { 241 | return nil, ErrSourceNotExist 242 | } 243 | return nil, err 244 | } 245 | return source, nil 246 | } 247 | 248 | // GetSource 获取用户订阅的订阅源 249 | func (c *Core) GetSources(ctx context.Context) ([]*model.Source, error) { 250 | return c.sourceStorage.GetSources(ctx) 251 | } 252 | 253 | // CreateSource 创建订阅源 254 | func (c *Core) CreateSource(ctx context.Context, sourceURL string) (*model.Source, error) { 255 | s, err := c.GetSourceByURL(ctx, sourceURL) 256 | if err == nil { 257 | return s, nil 258 | } 259 | 260 | if err != nil && err != ErrSourceNotExist { 261 | return nil, err 262 | } 263 | 264 | rssFeed, err := c.feedParser.ParseFromURL(ctx, sourceURL) 265 | if err != nil { 266 | log.Errorf("ParseFromURL %s failed, %v", sourceURL, err) 267 | return nil, err 268 | } 269 | 270 | s = &model.Source{ 271 | Title: rssFeed.Title, 272 | Link: sourceURL, 273 | ErrorCount: config.ErrorThreshold + 1, // 避免task更新 274 | } 275 | 276 | if err := c.sourceStorage.AddSource(ctx, s); err != nil { 277 | log.Errorf("add source failed, %v", err) 278 | return nil, err 279 | } 280 | defer c.ClearSourceErrorCount(ctx, s.ID) 281 | 282 | if _, err := c.AddSourceContents(ctx, s, rssFeed.Items); err != nil { 283 | log.Errorf("add source content failed, %v", err) 284 | return nil, err 285 | } 286 | return s, nil 287 | } 288 | 289 | func (c *Core) AddSourceContents( 290 | ctx context.Context, source *model.Source, items []*gofeed.Item, 291 | ) ([]*model.Content, error) { 292 | var wg sync.WaitGroup 293 | var contents []*model.Content 294 | for _, item := range items { 295 | wg.Add(1) 296 | previewURL := "" 297 | if config.EnableTelegraph && len([]rune(item.Content)) > config.PreviewText { 298 | previewURL, _ = tgraph.PublishHtml(source.Title, item.Title, item.Link, item.Content) 299 | } 300 | content := &model.Content{ 301 | Title: strings.Trim(item.Title, " "), 302 | Description: item.Content, //replace all kinds of
tag 303 | SourceID: source.ID, 304 | RawID: item.GUID, 305 | HashID: model.GenHashID(source.Link, item.GUID), 306 | RawLink: item.Link, 307 | TelegraphURL: previewURL, 308 | } 309 | contents = append(contents, content) 310 | go func() { 311 | defer wg.Done() 312 | if err := c.contentStorage.AddContent(ctx, content); err != nil { 313 | log.Errorf("add content %#v failed, %v", content, err) 314 | } 315 | }() 316 | } 317 | wg.Wait() 318 | return contents, nil 319 | } 320 | 321 | // UnsubscribeAllSource 添加订阅 322 | func (c *Core) UnsubscribeAllSource(ctx context.Context, userID int64) error { 323 | sources, err := c.GetUserSubscribedSources(ctx, userID) 324 | if err != nil { 325 | return err 326 | } 327 | 328 | var wg sync.WaitGroup 329 | for i := range sources { 330 | wg.Add(1) 331 | i := i 332 | go func() { 333 | defer wg.Done() 334 | if err := c.Unsubscribe(ctx, userID, sources[i].ID); err != nil { 335 | log.Errorf("user %d unsubscribe %d failed, %v", userID, sources[i].ID, err) 336 | } 337 | }() 338 | } 339 | wg.Wait() 340 | return nil 341 | } 342 | 343 | // GetSubscription 获取订阅 344 | func (c *Core) GetSubscription(ctx context.Context, userID int64, sourceID uint) (*model.Subscribe, error) { 345 | subscription, err := c.subscriptionStorage.GetSubscription(ctx, userID, sourceID) 346 | if err != nil { 347 | if err == storage.ErrRecordNotFound { 348 | return nil, ErrSubscriptionNotExist 349 | } 350 | return nil, err 351 | } 352 | return subscription, nil 353 | } 354 | 355 | // SetSubscriptionTag 设置订阅标签 356 | func (c *Core) SetSubscriptionTag(ctx context.Context, userID int64, sourceID uint, tags []string) error { 357 | subscription, err := c.GetSubscription(ctx, userID, sourceID) 358 | if err != nil { 359 | return err 360 | } 361 | 362 | subscription.Tag = "#" + strings.Join(tags, " #") 363 | return c.subscriptionStorage.UpdateSubscription(ctx, userID, sourceID, subscription) 364 | } 365 | 366 | // SetSubscriptionInterval 367 | func (c *Core) SetSubscriptionInterval(ctx context.Context, userID int64, sourceID uint, interval int) error { 368 | subscription, err := c.GetSubscription(ctx, userID, sourceID) 369 | if err != nil { 370 | return err 371 | } 372 | 373 | subscription.Interval = interval 374 | return c.subscriptionStorage.UpdateSubscription(ctx, userID, sourceID, subscription) 375 | } 376 | 377 | // EnableSourceUpdate 开启订阅源更新 378 | func (c *Core) EnableSourceUpdate(ctx context.Context, sourceID uint) error { 379 | return c.ClearSourceErrorCount(ctx, sourceID) 380 | } 381 | 382 | // DisableSourceUpdate 关闭订阅源更新 383 | func (c *Core) DisableSourceUpdate(ctx context.Context, sourceID uint) error { 384 | source, err := c.GetSource(ctx, sourceID) 385 | if err != nil { 386 | return err 387 | } 388 | 389 | source.ErrorCount = config.ErrorThreshold + 1 390 | return c.sourceStorage.UpsertSource(ctx, sourceID, source) 391 | } 392 | 393 | // ClearSourceErrorCount 清空订阅源错误计数 394 | func (c *Core) ClearSourceErrorCount(ctx context.Context, sourceID uint) error { 395 | source, err := c.GetSource(ctx, sourceID) 396 | if err != nil { 397 | return err 398 | } 399 | 400 | source.ErrorCount = 0 401 | return c.sourceStorage.UpsertSource(ctx, sourceID, source) 402 | } 403 | 404 | // SourceErrorCountIncr 增加订阅源错误计数 405 | func (c *Core) SourceErrorCountIncr(ctx context.Context, sourceID uint) error { 406 | source, err := c.GetSource(ctx, sourceID) 407 | if err != nil { 408 | return err 409 | } 410 | 411 | source.ErrorCount += 1 412 | return c.sourceStorage.UpsertSource(ctx, sourceID, source) 413 | } 414 | 415 | func (c *Core) ToggleSubscriptionNotice(ctx context.Context, userID int64, sourceID uint) error { 416 | subscription, err := c.GetSubscription(ctx, userID, sourceID) 417 | if err != nil { 418 | return err 419 | } 420 | if subscription.EnableNotification == 1 { 421 | subscription.EnableNotification = 0 422 | } else { 423 | subscription.EnableNotification = 1 424 | } 425 | return c.subscriptionStorage.UpsertSubscription(ctx, userID, sourceID, subscription) 426 | } 427 | 428 | func (c *Core) ToggleSourceUpdateStatus(ctx context.Context, sourceID uint) error { 429 | source, err := c.GetSource(ctx, sourceID) 430 | if err != nil { 431 | return err 432 | } 433 | 434 | if source.ErrorCount < config.ErrorThreshold { 435 | source.ErrorCount = config.ErrorThreshold + 1 436 | } else { 437 | source.ErrorCount = 0 438 | } 439 | return c.sourceStorage.UpsertSource(ctx, sourceID, source) 440 | } 441 | 442 | func (c *Core) ToggleSubscriptionTelegraph(ctx context.Context, userID int64, sourceID uint) error { 443 | subscription, err := c.GetSubscription(ctx, userID, sourceID) 444 | if err != nil { 445 | return err 446 | } 447 | if subscription.EnableTelegraph == 1 { 448 | subscription.EnableTelegraph = 0 449 | } else { 450 | subscription.EnableTelegraph = 1 451 | } 452 | return c.subscriptionStorage.UpsertSubscription(ctx, userID, sourceID, subscription) 453 | } 454 | 455 | func (c *Core) GetSourceAllSubscriptions( 456 | ctx context.Context, sourceID uint, 457 | ) ([]*model.Subscribe, error) { 458 | opt := &storage.GetSubscriptionsOptions{ 459 | Count: -1, 460 | } 461 | result, err := c.subscriptionStorage.GetSubscriptionsBySourceID(ctx, sourceID, opt) 462 | if err != nil { 463 | return nil, err 464 | } 465 | return result.Subscriptions, nil 466 | } 467 | 468 | func (c *Core) ContentHashIDExist( 469 | ctx context.Context, hashID string, 470 | ) (bool, error) { 471 | result, err := c.contentStorage.HashIDExist(ctx, hashID) 472 | if err != nil { 473 | return false, err 474 | } 475 | return result, nil 476 | } 477 | -------------------------------------------------------------------------------- /internal/core/core_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/golang/mock/gomock" 9 | "github.com/stretchr/testify/assert" 10 | 11 | "github.com/indes/flowerss-bot/internal/model" 12 | "github.com/indes/flowerss-bot/internal/storage" 13 | "github.com/indes/flowerss-bot/internal/storage/mock" 14 | ) 15 | 16 | type mockStorage struct { 17 | User *mock.MockUser 18 | Content *mock.MockContent 19 | Source *mock.MockSource 20 | Subscription *mock.MockSubscription 21 | Ctrl *gomock.Controller 22 | } 23 | 24 | func getTestCore(t *testing.T) (*Core, *mockStorage) { 25 | ctrl := gomock.NewController(t) 26 | 27 | s := &mockStorage{ 28 | Subscription: mock.NewMockSubscription(ctrl), 29 | User: mock.NewMockUser(ctrl), 30 | Content: mock.NewMockContent(ctrl), 31 | Source: mock.NewMockSource(ctrl), 32 | Ctrl: ctrl, 33 | } 34 | c := NewCore(s.User, s.Content, s.Source, s.Subscription, nil, nil) 35 | return c, s 36 | } 37 | 38 | func TestCore_AddSubscription(t *testing.T) { 39 | c, s := getTestCore(t) 40 | defer s.Ctrl.Finish() 41 | ctx := context.Background() 42 | 43 | userID := int64(1) 44 | sourceID := uint(101) 45 | t.Run( 46 | "exist error", func(t *testing.T) { 47 | s.Subscription.EXPECT().SubscriptionExist(ctx, userID, sourceID).Return(false, errors.New("err")).Times(1) 48 | err := c.AddSubscription(ctx, userID, sourceID) 49 | assert.Error(t, err) 50 | }, 51 | ) 52 | 53 | t.Run( 54 | "exist subscription", func(t *testing.T) { 55 | s.Subscription.EXPECT().SubscriptionExist(ctx, userID, sourceID).Return(true, nil).Times(1) 56 | err := c.AddSubscription(ctx, userID, sourceID) 57 | assert.Equal(t, ErrSubscriptionExist, err) 58 | }, 59 | ) 60 | 61 | t.Run( 62 | "subscribe fail", func(t *testing.T) { 63 | s.Subscription.EXPECT().SubscriptionExist(ctx, userID, sourceID).Return(false, nil).Times(1) 64 | s.Subscription.EXPECT().AddSubscription(ctx, gomock.Any()).Return(errors.New("err")).Times(1) 65 | 66 | err := c.AddSubscription(ctx, userID, sourceID) 67 | assert.Error(t, err) 68 | }, 69 | ) 70 | 71 | t.Run( 72 | "subscribe ok", func(t *testing.T) { 73 | s.Subscription.EXPECT().SubscriptionExist(ctx, userID, sourceID).Return(false, nil).Times(1) 74 | s.Subscription.EXPECT().AddSubscription(ctx, gomock.Any()).Return(nil).Times(1) 75 | 76 | err := c.AddSubscription(ctx, userID, sourceID) 77 | assert.Nil(t, err) 78 | }, 79 | ) 80 | } 81 | 82 | func TestCore_GetUserSubscribedSources(t *testing.T) { 83 | c, s := getTestCore(t) 84 | defer s.Ctrl.Finish() 85 | ctx := context.Background() 86 | 87 | userID := int64(1) 88 | sourceID1 := uint(101) 89 | sourceID2 := uint(102) 90 | subscriptionsResult := &storage.GetSubscriptionsResult{ 91 | Subscriptions: []*model.Subscribe{ 92 | &model.Subscribe{SourceID: sourceID1}, 93 | &model.Subscribe{SourceID: sourceID2}, 94 | }, 95 | } 96 | t.Run( 97 | "subscription err", func(t *testing.T) { 98 | s.Subscription.EXPECT().GetSubscriptionsByUserID(ctx, userID, gomock.Any()).Return( 99 | nil, errors.New("err"), 100 | ) 101 | 102 | sources, err := c.GetUserSubscribedSources(ctx, userID) 103 | assert.Error(t, err) 104 | assert.Nil(t, sources) 105 | }, 106 | ) 107 | 108 | t.Run( 109 | "source err", func(t *testing.T) { 110 | s.Subscription.EXPECT().GetSubscriptionsByUserID(ctx, userID, gomock.Any()).Return( 111 | subscriptionsResult, nil, 112 | ) 113 | 114 | s.Source.EXPECT().GetSource(ctx, sourceID1).Return( 115 | nil, errors.New("err"), 116 | ).Times(1) 117 | s.Source.EXPECT().GetSource(ctx, gomock.Any()).Return( 118 | &model.Source{}, nil, 119 | ) 120 | 121 | sources, err := c.GetUserSubscribedSources(ctx, userID) 122 | assert.Nil(t, err) 123 | assert.Equal(t, len(subscriptionsResult.Subscriptions)-1, len(sources)) 124 | }, 125 | ) 126 | 127 | t.Run( 128 | "source success", func(t *testing.T) { 129 | s.Subscription.EXPECT().GetSubscriptionsByUserID(ctx, userID, gomock.Any()).Return( 130 | subscriptionsResult, nil, 131 | ) 132 | 133 | s.Source.EXPECT().GetSource(ctx, gomock.Any()).Return( 134 | &model.Source{}, nil, 135 | ).Times(len(subscriptionsResult.Subscriptions)) 136 | 137 | sources, err := c.GetUserSubscribedSources(ctx, userID) 138 | assert.Nil(t, err) 139 | assert.Equal(t, len(subscriptionsResult.Subscriptions), len(sources)) 140 | }, 141 | ) 142 | } 143 | 144 | func TestCore_Unsubscribe(t *testing.T) { 145 | c, s := getTestCore(t) 146 | defer s.Ctrl.Finish() 147 | ctx := context.Background() 148 | 149 | userID := int64(1) 150 | sourceID1 := uint(101) 151 | 152 | t.Run( 153 | "SubscriptionExist err", func(t *testing.T) { 154 | s.Subscription.EXPECT().SubscriptionExist(ctx, userID, sourceID1).Return( 155 | false, errors.New("err"), 156 | ).Times(1) 157 | err := c.Unsubscribe(ctx, userID, sourceID1) 158 | assert.Error(t, err) 159 | }, 160 | ) 161 | 162 | t.Run( 163 | "subscription not exist", func(t *testing.T) { 164 | s.Subscription.EXPECT().SubscriptionExist(ctx, userID, sourceID1).Return( 165 | false, nil, 166 | ).Times(1) 167 | err := c.Unsubscribe(ctx, userID, sourceID1) 168 | assert.Equal(t, ErrSubscriptionNotExist, err) 169 | }, 170 | ) 171 | 172 | s.Subscription.EXPECT().SubscriptionExist(ctx, gomock.Any(), gomock.Any()).Return( 173 | true, nil, 174 | ).AnyTimes() 175 | 176 | t.Run( 177 | "unsubscribe failed", func(t *testing.T) { 178 | s.Subscription.EXPECT().DeleteSubscription(ctx, userID, sourceID1).Return( 179 | int64(1), errors.New("err"), 180 | ).Times(1) 181 | err := c.Unsubscribe(ctx, userID, sourceID1) 182 | assert.Error(t, err) 183 | }, 184 | ) 185 | 186 | s.Subscription.EXPECT().DeleteSubscription(ctx, gomock.Any(), gomock.Any()).Return( 187 | int64(1), nil, 188 | ).AnyTimes() 189 | 190 | t.Run( 191 | "count subs", func(t *testing.T) { 192 | s.Subscription.EXPECT().CountSourceSubscriptions(ctx, sourceID1).Return( 193 | int64(1), errors.New("err"), 194 | ).Times(1) 195 | err := c.Unsubscribe(ctx, userID, sourceID1) 196 | assert.Error(t, err) 197 | 198 | s.Subscription.EXPECT().CountSourceSubscriptions(ctx, sourceID1).Return( 199 | int64(1), nil, 200 | ).Times(1) 201 | err = c.Unsubscribe(ctx, userID, sourceID1) 202 | assert.Nil(t, err) 203 | }, 204 | ) 205 | 206 | s.Subscription.EXPECT().CountSourceSubscriptions(ctx, gomock.Any()).Return( 207 | int64(0), nil, 208 | ).AnyTimes() 209 | 210 | t.Run( 211 | "remove source", func(t *testing.T) { 212 | s.Source.EXPECT().Delete(ctx, sourceID1).Return( 213 | errors.New("err"), 214 | ).Times(1) 215 | 216 | err := c.Unsubscribe(ctx, userID, sourceID1) 217 | assert.Error(t, err) 218 | 219 | s.Source.EXPECT().Delete(ctx, sourceID1).Return(nil).AnyTimes() 220 | 221 | s.Content.EXPECT().DeleteSourceContents(ctx, sourceID1).Return(int64(0), errors.New("err")).Times(1) 222 | err = c.Unsubscribe(ctx, userID, sourceID1) 223 | assert.Error(t, err) 224 | 225 | s.Content.EXPECT().DeleteSourceContents(ctx, sourceID1).Return(int64(1), nil).Times(1) 226 | err = c.Unsubscribe(ctx, userID, sourceID1) 227 | assert.Nil(t, err) 228 | }, 229 | ) 230 | } 231 | 232 | func TestCore_GetSourceByURL(t *testing.T) { 233 | c, s := getTestCore(t) 234 | defer s.Ctrl.Finish() 235 | ctx := context.Background() 236 | sourceURL := "http://google.com" 237 | 238 | t.Run( 239 | "source err", func(t *testing.T) { 240 | s.Source.EXPECT().GetSourceByURL(ctx, sourceURL).Return( 241 | nil, errors.New("err"), 242 | ).Times(1) 243 | got, err := c.GetSourceByURL(ctx, sourceURL) 244 | assert.Error(t, err) 245 | assert.Nil(t, got) 246 | }, 247 | ) 248 | 249 | t.Run( 250 | "source not exist", func(t *testing.T) { 251 | s.Source.EXPECT().GetSourceByURL(ctx, sourceURL).Return( 252 | nil, storage.ErrRecordNotFound, 253 | ).Times(1) 254 | got, err := c.GetSourceByURL(ctx, sourceURL) 255 | assert.Equal(t, ErrSourceNotExist, err) 256 | assert.Nil(t, got) 257 | }, 258 | ) 259 | 260 | t.Run( 261 | "ok", func(t *testing.T) { 262 | s.Source.EXPECT().GetSourceByURL(ctx, sourceURL).Return( 263 | &model.Source{}, nil, 264 | ).Times(1) 265 | got, err := c.GetSourceByURL(ctx, sourceURL) 266 | assert.Nil(t, err) 267 | assert.NotNil(t, got) 268 | }, 269 | ) 270 | } 271 | 272 | func TestCore_GetSource(t *testing.T) { 273 | c, s := getTestCore(t) 274 | defer s.Ctrl.Finish() 275 | ctx := context.Background() 276 | sourceID := uint(1) 277 | 278 | t.Run( 279 | "source err", func(t *testing.T) { 280 | s.Source.EXPECT().GetSource(ctx, sourceID).Return( 281 | nil, errors.New("err"), 282 | ).Times(1) 283 | got, err := c.GetSource(ctx, sourceID) 284 | assert.Error(t, err) 285 | assert.Nil(t, got) 286 | }, 287 | ) 288 | 289 | t.Run( 290 | "source not exist", func(t *testing.T) { 291 | s.Source.EXPECT().GetSource(ctx, sourceID).Return( 292 | nil, storage.ErrRecordNotFound, 293 | ).Times(1) 294 | got, err := c.GetSource(ctx, sourceID) 295 | assert.Equal(t, ErrSourceNotExist, err) 296 | assert.Nil(t, got) 297 | }, 298 | ) 299 | 300 | t.Run( 301 | "ok", func(t *testing.T) { 302 | s.Source.EXPECT().GetSource(ctx, sourceID).Return( 303 | &model.Source{}, nil, 304 | ).Times(1) 305 | got, err := c.GetSource(ctx, sourceID) 306 | assert.Nil(t, err) 307 | assert.NotNil(t, got) 308 | }, 309 | ) 310 | } 311 | 312 | func TestCore_GetSubscription(t *testing.T) { 313 | c, s := getTestCore(t) 314 | defer s.Ctrl.Finish() 315 | ctx := context.Background() 316 | userID := int64(101) 317 | sourceID := uint(1) 318 | 319 | t.Run( 320 | "subscription err", func(t *testing.T) { 321 | s.Subscription.EXPECT().GetSubscription(ctx, userID, sourceID).Return( 322 | nil, errors.New("err"), 323 | ).Times(1) 324 | got, err := c.GetSubscription(ctx, userID, sourceID) 325 | assert.Error(t, err) 326 | assert.Nil(t, got) 327 | }, 328 | ) 329 | 330 | t.Run( 331 | "subscription not exist", func(t *testing.T) { 332 | s.Subscription.EXPECT().GetSubscription(ctx, userID, sourceID).Return( 333 | nil, storage.ErrRecordNotFound, 334 | ).Times(1) 335 | got, err := c.GetSubscription(ctx, userID, sourceID) 336 | assert.Equal(t, ErrSubscriptionNotExist, err) 337 | assert.Nil(t, got) 338 | }, 339 | ) 340 | 341 | t.Run( 342 | "ok", func(t *testing.T) { 343 | s.Subscription.EXPECT().GetSubscription(ctx, userID, sourceID).Return( 344 | &model.Subscribe{}, nil, 345 | ).Times(1) 346 | got, err := c.GetSubscription(ctx, userID, sourceID) 347 | assert.Nil(t, err) 348 | assert.NotNil(t, got) 349 | }, 350 | ) 351 | } 352 | 353 | func TestCore_DisableSourceUpdate(t *testing.T) { 354 | c, s := getTestCore(t) 355 | defer s.Ctrl.Finish() 356 | ctx := context.Background() 357 | sourceID := uint(1) 358 | 359 | t.Run( 360 | "get source err", func(t *testing.T) { 361 | s.Source.EXPECT().GetSource(ctx, sourceID).Return( 362 | nil, errors.New("err"), 363 | ).Times(1) 364 | err := c.DisableSourceUpdate(ctx, sourceID) 365 | assert.Error(t, err) 366 | }, 367 | ) 368 | 369 | t.Run( 370 | "update source err", func(t *testing.T) { 371 | s.Source.EXPECT().GetSource(ctx, sourceID).Return( 372 | &model.Source{}, nil, 373 | ).Times(1) 374 | 375 | s.Source.EXPECT().UpsertSource(ctx, sourceID, gomock.Any()).Return( 376 | errors.New("err"), 377 | ).Times(1) 378 | err := c.DisableSourceUpdate(ctx, sourceID) 379 | assert.Error(t, err) 380 | }, 381 | ) 382 | 383 | t.Run( 384 | "update source err", func(t *testing.T) { 385 | s.Source.EXPECT().GetSource(ctx, sourceID).Return( 386 | &model.Source{}, nil, 387 | ).Times(1) 388 | 389 | s.Source.EXPECT().UpsertSource(ctx, sourceID, gomock.Any()).Return( 390 | nil, 391 | ).Times(1) 392 | err := c.DisableSourceUpdate(ctx, sourceID) 393 | assert.Nil(t, err) 394 | }, 395 | ) 396 | } 397 | 398 | func TestCore_ClearSourceErrorCount(t *testing.T) { 399 | c, s := getTestCore(t) 400 | defer s.Ctrl.Finish() 401 | ctx := context.Background() 402 | sourceID := uint(1) 403 | 404 | t.Run( 405 | "get source err", func(t *testing.T) { 406 | s.Source.EXPECT().GetSource(ctx, sourceID).Return( 407 | nil, errors.New("err"), 408 | ).Times(1) 409 | err := c.ClearSourceErrorCount(ctx, sourceID) 410 | assert.Error(t, err) 411 | }, 412 | ) 413 | 414 | t.Run( 415 | "update source err", func(t *testing.T) { 416 | s.Source.EXPECT().GetSource(ctx, sourceID).Return( 417 | &model.Source{}, nil, 418 | ).Times(1) 419 | 420 | s.Source.EXPECT().UpsertSource(ctx, sourceID, gomock.Any()).Return( 421 | errors.New("err"), 422 | ).Times(1) 423 | err := c.ClearSourceErrorCount(ctx, sourceID) 424 | assert.Error(t, err) 425 | }, 426 | ) 427 | 428 | t.Run( 429 | "update source err", func(t *testing.T) { 430 | s.Source.EXPECT().GetSource(ctx, sourceID).Return( 431 | &model.Source{}, nil, 432 | ).Times(1) 433 | 434 | s.Source.EXPECT().UpsertSource(ctx, sourceID, gomock.Any()).Return( 435 | nil, 436 | ).Times(1) 437 | err := c.ClearSourceErrorCount(ctx, sourceID) 438 | assert.Nil(t, err) 439 | }, 440 | ) 441 | } 442 | 443 | func TestCore_ToggleSubscriptionNotice(t *testing.T) { 444 | c, s := getTestCore(t) 445 | defer s.Ctrl.Finish() 446 | ctx := context.Background() 447 | userID := int64(123) 448 | sourceID := uint(1) 449 | 450 | t.Run( 451 | "get subscription err", func(t *testing.T) { 452 | s.Subscription.EXPECT().GetSubscription(ctx, userID, sourceID).Return( 453 | nil, errors.New("err"), 454 | ).Times(1) 455 | err := c.ToggleSubscriptionNotice(ctx, userID, sourceID) 456 | assert.Error(t, err) 457 | }, 458 | ) 459 | 460 | t.Run( 461 | "update subscription err", func(t *testing.T) { 462 | s.Subscription.EXPECT().GetSubscription(ctx, userID, sourceID).Return( 463 | &model.Subscribe{}, nil, 464 | ).Times(1) 465 | 466 | s.Subscription.EXPECT().UpsertSubscription(ctx, userID, sourceID, gomock.Any()).Return( 467 | errors.New("err"), 468 | ).Times(1) 469 | 470 | err := c.ToggleSubscriptionNotice(ctx, userID, sourceID) 471 | assert.Error(t, err) 472 | }, 473 | ) 474 | 475 | t.Run( 476 | "ok", func(t *testing.T) { 477 | s.Subscription.EXPECT().GetSubscription(ctx, userID, sourceID).Return( 478 | &model.Subscribe{}, nil, 479 | ).Times(1) 480 | 481 | s.Subscription.EXPECT().UpsertSubscription(ctx, userID, sourceID, gomock.Any()).Return( 482 | nil, 483 | ).Times(1) 484 | 485 | err := c.ToggleSubscriptionNotice(ctx, userID, sourceID) 486 | assert.Nil(t, err) 487 | }, 488 | ) 489 | } 490 | 491 | func TestCore_ToggleSourceUpdateStatus(t *testing.T) { 492 | c, s := getTestCore(t) 493 | defer s.Ctrl.Finish() 494 | ctx := context.Background() 495 | sourceID := uint(1) 496 | 497 | t.Run( 498 | "get source err", func(t *testing.T) { 499 | s.Source.EXPECT().GetSource(ctx, sourceID).Return( 500 | nil, errors.New("err"), 501 | ).Times(1) 502 | err := c.ToggleSourceUpdateStatus(ctx, sourceID) 503 | assert.Error(t, err) 504 | }, 505 | ) 506 | 507 | t.Run( 508 | "update source err", func(t *testing.T) { 509 | s.Source.EXPECT().GetSource(ctx, sourceID).Return( 510 | &model.Source{}, nil, 511 | ).Times(1) 512 | 513 | s.Source.EXPECT().UpsertSource(ctx, sourceID, gomock.Any()).Return( 514 | errors.New("err"), 515 | ).Times(1) 516 | err := c.ToggleSourceUpdateStatus(ctx, sourceID) 517 | assert.Error(t, err) 518 | }, 519 | ) 520 | 521 | t.Run( 522 | "ok", func(t *testing.T) { 523 | s.Source.EXPECT().GetSource(ctx, sourceID).Return( 524 | &model.Source{}, nil, 525 | ).Times(1) 526 | 527 | s.Source.EXPECT().UpsertSource(ctx, sourceID, gomock.Any()).Return( 528 | nil, 529 | ).Times(1) 530 | err := c.ToggleSourceUpdateStatus(ctx, sourceID) 531 | assert.Nil(t, err) 532 | }, 533 | ) 534 | } 535 | 536 | func TestCore_ToggleSubscriptionTelegraph(t *testing.T) { 537 | c, s := getTestCore(t) 538 | defer s.Ctrl.Finish() 539 | ctx := context.Background() 540 | userID := int64(123) 541 | sourceID := uint(1) 542 | 543 | t.Run( 544 | "get subscription err", func(t *testing.T) { 545 | s.Subscription.EXPECT().GetSubscription(ctx, userID, sourceID).Return( 546 | nil, errors.New("err"), 547 | ).Times(1) 548 | err := c.ToggleSubscriptionTelegraph(ctx, userID, sourceID) 549 | assert.Error(t, err) 550 | }, 551 | ) 552 | 553 | t.Run( 554 | "update subscription err", func(t *testing.T) { 555 | s.Subscription.EXPECT().GetSubscription(ctx, userID, sourceID).Return( 556 | &model.Subscribe{}, nil, 557 | ).Times(1) 558 | 559 | s.Subscription.EXPECT().UpsertSubscription(ctx, userID, sourceID, gomock.Any()).Return( 560 | errors.New("err"), 561 | ).Times(1) 562 | 563 | err := c.ToggleSubscriptionTelegraph(ctx, userID, sourceID) 564 | assert.Error(t, err) 565 | }, 566 | ) 567 | 568 | t.Run( 569 | "ok", func(t *testing.T) { 570 | s.Subscription.EXPECT().GetSubscription(ctx, userID, sourceID).Return( 571 | &model.Subscribe{}, nil, 572 | ).Times(1) 573 | 574 | s.Subscription.EXPECT().UpsertSubscription(ctx, userID, sourceID, gomock.Any()).Return( 575 | nil, 576 | ).Times(1) 577 | 578 | err := c.ToggleSubscriptionTelegraph(ctx, userID, sourceID) 579 | assert.Nil(t, err) 580 | }, 581 | ) 582 | } 583 | -------------------------------------------------------------------------------- /internal/storage/mock/storage_mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: storage.go 3 | 4 | // Package mock is a generated GoMock package. 5 | package mock 6 | 7 | import ( 8 | context "context" 9 | reflect "reflect" 10 | 11 | gomock "github.com/golang/mock/gomock" 12 | model "github.com/indes/flowerss-bot/internal/model" 13 | storage "github.com/indes/flowerss-bot/internal/storage" 14 | ) 15 | 16 | // MockStorage is a mock of Storage interface. 17 | type MockStorage struct { 18 | ctrl *gomock.Controller 19 | recorder *MockStorageMockRecorder 20 | } 21 | 22 | // MockStorageMockRecorder is the mock recorder for MockStorage. 23 | type MockStorageMockRecorder struct { 24 | mock *MockStorage 25 | } 26 | 27 | // NewMockStorage creates a new mock instance. 28 | func NewMockStorage(ctrl *gomock.Controller) *MockStorage { 29 | mock := &MockStorage{ctrl: ctrl} 30 | mock.recorder = &MockStorageMockRecorder{mock} 31 | return mock 32 | } 33 | 34 | // EXPECT returns an object that allows the caller to indicate expected use. 35 | func (m *MockStorage) EXPECT() *MockStorageMockRecorder { 36 | return m.recorder 37 | } 38 | 39 | // Init mocks base method. 40 | func (m *MockStorage) Init(ctx context.Context) error { 41 | m.ctrl.T.Helper() 42 | ret := m.ctrl.Call(m, "Init", ctx) 43 | ret0, _ := ret[0].(error) 44 | return ret0 45 | } 46 | 47 | // Init indicates an expected call of Init. 48 | func (mr *MockStorageMockRecorder) Init(ctx interface{}) *gomock.Call { 49 | mr.mock.ctrl.T.Helper() 50 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Init", reflect.TypeOf((*MockStorage)(nil).Init), ctx) 51 | } 52 | 53 | // MockUser is a mock of User interface. 54 | type MockUser struct { 55 | ctrl *gomock.Controller 56 | recorder *MockUserMockRecorder 57 | } 58 | 59 | // MockUserMockRecorder is the mock recorder for MockUser. 60 | type MockUserMockRecorder struct { 61 | mock *MockUser 62 | } 63 | 64 | // NewMockUser creates a new mock instance. 65 | func NewMockUser(ctrl *gomock.Controller) *MockUser { 66 | mock := &MockUser{ctrl: ctrl} 67 | mock.recorder = &MockUserMockRecorder{mock} 68 | return mock 69 | } 70 | 71 | // EXPECT returns an object that allows the caller to indicate expected use. 72 | func (m *MockUser) EXPECT() *MockUserMockRecorder { 73 | return m.recorder 74 | } 75 | 76 | // CrateUser mocks base method. 77 | func (m *MockUser) CrateUser(ctx context.Context, user *model.User) error { 78 | m.ctrl.T.Helper() 79 | ret := m.ctrl.Call(m, "CrateUser", ctx, user) 80 | ret0, _ := ret[0].(error) 81 | return ret0 82 | } 83 | 84 | // CrateUser indicates an expected call of CrateUser. 85 | func (mr *MockUserMockRecorder) CrateUser(ctx, user interface{}) *gomock.Call { 86 | mr.mock.ctrl.T.Helper() 87 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CrateUser", reflect.TypeOf((*MockUser)(nil).CrateUser), ctx, user) 88 | } 89 | 90 | // GetUser mocks base method. 91 | func (m *MockUser) GetUser(ctx context.Context, id int64) (*model.User, error) { 92 | m.ctrl.T.Helper() 93 | ret := m.ctrl.Call(m, "GetUser", ctx, id) 94 | ret0, _ := ret[0].(*model.User) 95 | ret1, _ := ret[1].(error) 96 | return ret0, ret1 97 | } 98 | 99 | // GetUser indicates an expected call of GetUser. 100 | func (mr *MockUserMockRecorder) GetUser(ctx, id interface{}) *gomock.Call { 101 | mr.mock.ctrl.T.Helper() 102 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUser", reflect.TypeOf((*MockUser)(nil).GetUser), ctx, id) 103 | } 104 | 105 | // Init mocks base method. 106 | func (m *MockUser) Init(ctx context.Context) error { 107 | m.ctrl.T.Helper() 108 | ret := m.ctrl.Call(m, "Init", ctx) 109 | ret0, _ := ret[0].(error) 110 | return ret0 111 | } 112 | 113 | // Init indicates an expected call of Init. 114 | func (mr *MockUserMockRecorder) Init(ctx interface{}) *gomock.Call { 115 | mr.mock.ctrl.T.Helper() 116 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Init", reflect.TypeOf((*MockUser)(nil).Init), ctx) 117 | } 118 | 119 | // MockSource is a mock of Source interface. 120 | type MockSource struct { 121 | ctrl *gomock.Controller 122 | recorder *MockSourceMockRecorder 123 | } 124 | 125 | // MockSourceMockRecorder is the mock recorder for MockSource. 126 | type MockSourceMockRecorder struct { 127 | mock *MockSource 128 | } 129 | 130 | // NewMockSource creates a new mock instance. 131 | func NewMockSource(ctrl *gomock.Controller) *MockSource { 132 | mock := &MockSource{ctrl: ctrl} 133 | mock.recorder = &MockSourceMockRecorder{mock} 134 | return mock 135 | } 136 | 137 | // EXPECT returns an object that allows the caller to indicate expected use. 138 | func (m *MockSource) EXPECT() *MockSourceMockRecorder { 139 | return m.recorder 140 | } 141 | 142 | // AddSource mocks base method. 143 | func (m *MockSource) AddSource(ctx context.Context, source *model.Source) error { 144 | m.ctrl.T.Helper() 145 | ret := m.ctrl.Call(m, "AddSource", ctx, source) 146 | ret0, _ := ret[0].(error) 147 | return ret0 148 | } 149 | 150 | // AddSource indicates an expected call of AddSource. 151 | func (mr *MockSourceMockRecorder) AddSource(ctx, source interface{}) *gomock.Call { 152 | mr.mock.ctrl.T.Helper() 153 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddSource", reflect.TypeOf((*MockSource)(nil).AddSource), ctx, source) 154 | } 155 | 156 | // Delete mocks base method. 157 | func (m *MockSource) Delete(ctx context.Context, id uint) error { 158 | m.ctrl.T.Helper() 159 | ret := m.ctrl.Call(m, "Delete", ctx, id) 160 | ret0, _ := ret[0].(error) 161 | return ret0 162 | } 163 | 164 | // Delete indicates an expected call of Delete. 165 | func (mr *MockSourceMockRecorder) Delete(ctx, id interface{}) *gomock.Call { 166 | mr.mock.ctrl.T.Helper() 167 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockSource)(nil).Delete), ctx, id) 168 | } 169 | 170 | // GetSource mocks base method. 171 | func (m *MockSource) GetSource(ctx context.Context, id uint) (*model.Source, error) { 172 | m.ctrl.T.Helper() 173 | ret := m.ctrl.Call(m, "GetSource", ctx, id) 174 | ret0, _ := ret[0].(*model.Source) 175 | ret1, _ := ret[1].(error) 176 | return ret0, ret1 177 | } 178 | 179 | // GetSource indicates an expected call of GetSource. 180 | func (mr *MockSourceMockRecorder) GetSource(ctx, id interface{}) *gomock.Call { 181 | mr.mock.ctrl.T.Helper() 182 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSource", reflect.TypeOf((*MockSource)(nil).GetSource), ctx, id) 183 | } 184 | 185 | // GetSourceByURL mocks base method. 186 | func (m *MockSource) GetSourceByURL(ctx context.Context, url string) (*model.Source, error) { 187 | m.ctrl.T.Helper() 188 | ret := m.ctrl.Call(m, "GetSourceByURL", ctx, url) 189 | ret0, _ := ret[0].(*model.Source) 190 | ret1, _ := ret[1].(error) 191 | return ret0, ret1 192 | } 193 | 194 | // GetSourceByURL indicates an expected call of GetSourceByURL. 195 | func (mr *MockSourceMockRecorder) GetSourceByURL(ctx, url interface{}) *gomock.Call { 196 | mr.mock.ctrl.T.Helper() 197 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSourceByURL", reflect.TypeOf((*MockSource)(nil).GetSourceByURL), ctx, url) 198 | } 199 | 200 | // GetSources mocks base method. 201 | func (m *MockSource) GetSources(ctx context.Context) ([]*model.Source, error) { 202 | m.ctrl.T.Helper() 203 | ret := m.ctrl.Call(m, "GetSources", ctx) 204 | ret0, _ := ret[0].([]*model.Source) 205 | ret1, _ := ret[1].(error) 206 | return ret0, ret1 207 | } 208 | 209 | // GetSources indicates an expected call of GetSources. 210 | func (mr *MockSourceMockRecorder) GetSources(ctx interface{}) *gomock.Call { 211 | mr.mock.ctrl.T.Helper() 212 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSources", reflect.TypeOf((*MockSource)(nil).GetSources), ctx) 213 | } 214 | 215 | // Init mocks base method. 216 | func (m *MockSource) Init(ctx context.Context) error { 217 | m.ctrl.T.Helper() 218 | ret := m.ctrl.Call(m, "Init", ctx) 219 | ret0, _ := ret[0].(error) 220 | return ret0 221 | } 222 | 223 | // Init indicates an expected call of Init. 224 | func (mr *MockSourceMockRecorder) Init(ctx interface{}) *gomock.Call { 225 | mr.mock.ctrl.T.Helper() 226 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Init", reflect.TypeOf((*MockSource)(nil).Init), ctx) 227 | } 228 | 229 | // UpsertSource mocks base method. 230 | func (m *MockSource) UpsertSource(ctx context.Context, sourceID uint, newSource *model.Source) error { 231 | m.ctrl.T.Helper() 232 | ret := m.ctrl.Call(m, "UpsertSource", ctx, sourceID, newSource) 233 | ret0, _ := ret[0].(error) 234 | return ret0 235 | } 236 | 237 | // UpsertSource indicates an expected call of UpsertSource. 238 | func (mr *MockSourceMockRecorder) UpsertSource(ctx, sourceID, newSource interface{}) *gomock.Call { 239 | mr.mock.ctrl.T.Helper() 240 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertSource", reflect.TypeOf((*MockSource)(nil).UpsertSource), ctx, sourceID, newSource) 241 | } 242 | 243 | // MockSubscription is a mock of Subscription interface. 244 | type MockSubscription struct { 245 | ctrl *gomock.Controller 246 | recorder *MockSubscriptionMockRecorder 247 | } 248 | 249 | // MockSubscriptionMockRecorder is the mock recorder for MockSubscription. 250 | type MockSubscriptionMockRecorder struct { 251 | mock *MockSubscription 252 | } 253 | 254 | // NewMockSubscription creates a new mock instance. 255 | func NewMockSubscription(ctrl *gomock.Controller) *MockSubscription { 256 | mock := &MockSubscription{ctrl: ctrl} 257 | mock.recorder = &MockSubscriptionMockRecorder{mock} 258 | return mock 259 | } 260 | 261 | // EXPECT returns an object that allows the caller to indicate expected use. 262 | func (m *MockSubscription) EXPECT() *MockSubscriptionMockRecorder { 263 | return m.recorder 264 | } 265 | 266 | // AddSubscription mocks base method. 267 | func (m *MockSubscription) AddSubscription(ctx context.Context, subscription *model.Subscribe) error { 268 | m.ctrl.T.Helper() 269 | ret := m.ctrl.Call(m, "AddSubscription", ctx, subscription) 270 | ret0, _ := ret[0].(error) 271 | return ret0 272 | } 273 | 274 | // AddSubscription indicates an expected call of AddSubscription. 275 | func (mr *MockSubscriptionMockRecorder) AddSubscription(ctx, subscription interface{}) *gomock.Call { 276 | mr.mock.ctrl.T.Helper() 277 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddSubscription", reflect.TypeOf((*MockSubscription)(nil).AddSubscription), ctx, subscription) 278 | } 279 | 280 | // CountSourceSubscriptions mocks base method. 281 | func (m *MockSubscription) CountSourceSubscriptions(ctx context.Context, sourceID uint) (int64, error) { 282 | m.ctrl.T.Helper() 283 | ret := m.ctrl.Call(m, "CountSourceSubscriptions", ctx, sourceID) 284 | ret0, _ := ret[0].(int64) 285 | ret1, _ := ret[1].(error) 286 | return ret0, ret1 287 | } 288 | 289 | // CountSourceSubscriptions indicates an expected call of CountSourceSubscriptions. 290 | func (mr *MockSubscriptionMockRecorder) CountSourceSubscriptions(ctx, sourceID interface{}) *gomock.Call { 291 | mr.mock.ctrl.T.Helper() 292 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountSourceSubscriptions", reflect.TypeOf((*MockSubscription)(nil).CountSourceSubscriptions), ctx, sourceID) 293 | } 294 | 295 | // CountSubscriptions mocks base method. 296 | func (m *MockSubscription) CountSubscriptions(ctx context.Context) (int64, error) { 297 | m.ctrl.T.Helper() 298 | ret := m.ctrl.Call(m, "CountSubscriptions", ctx) 299 | ret0, _ := ret[0].(int64) 300 | ret1, _ := ret[1].(error) 301 | return ret0, ret1 302 | } 303 | 304 | // CountSubscriptions indicates an expected call of CountSubscriptions. 305 | func (mr *MockSubscriptionMockRecorder) CountSubscriptions(ctx interface{}) *gomock.Call { 306 | mr.mock.ctrl.T.Helper() 307 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountSubscriptions", reflect.TypeOf((*MockSubscription)(nil).CountSubscriptions), ctx) 308 | } 309 | 310 | // DeleteSubscription mocks base method. 311 | func (m *MockSubscription) DeleteSubscription(ctx context.Context, userID int64, sourceID uint) (int64, error) { 312 | m.ctrl.T.Helper() 313 | ret := m.ctrl.Call(m, "DeleteSubscription", ctx, userID, sourceID) 314 | ret0, _ := ret[0].(int64) 315 | ret1, _ := ret[1].(error) 316 | return ret0, ret1 317 | } 318 | 319 | // DeleteSubscription indicates an expected call of DeleteSubscription. 320 | func (mr *MockSubscriptionMockRecorder) DeleteSubscription(ctx, userID, sourceID interface{}) *gomock.Call { 321 | mr.mock.ctrl.T.Helper() 322 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteSubscription", reflect.TypeOf((*MockSubscription)(nil).DeleteSubscription), ctx, userID, sourceID) 323 | } 324 | 325 | // GetSubscription mocks base method. 326 | func (m *MockSubscription) GetSubscription(ctx context.Context, userID int64, sourceID uint) (*model.Subscribe, error) { 327 | m.ctrl.T.Helper() 328 | ret := m.ctrl.Call(m, "GetSubscription", ctx, userID, sourceID) 329 | ret0, _ := ret[0].(*model.Subscribe) 330 | ret1, _ := ret[1].(error) 331 | return ret0, ret1 332 | } 333 | 334 | // GetSubscription indicates an expected call of GetSubscription. 335 | func (mr *MockSubscriptionMockRecorder) GetSubscription(ctx, userID, sourceID interface{}) *gomock.Call { 336 | mr.mock.ctrl.T.Helper() 337 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubscription", reflect.TypeOf((*MockSubscription)(nil).GetSubscription), ctx, userID, sourceID) 338 | } 339 | 340 | // GetSubscriptionsBySourceID mocks base method. 341 | func (m *MockSubscription) GetSubscriptionsBySourceID(ctx context.Context, sourceID uint, opts *storage.GetSubscriptionsOptions) (*storage.GetSubscriptionsResult, error) { 342 | m.ctrl.T.Helper() 343 | ret := m.ctrl.Call(m, "GetSubscriptionsBySourceID", ctx, sourceID, opts) 344 | ret0, _ := ret[0].(*storage.GetSubscriptionsResult) 345 | ret1, _ := ret[1].(error) 346 | return ret0, ret1 347 | } 348 | 349 | // GetSubscriptionsBySourceID indicates an expected call of GetSubscriptionsBySourceID. 350 | func (mr *MockSubscriptionMockRecorder) GetSubscriptionsBySourceID(ctx, sourceID, opts interface{}) *gomock.Call { 351 | mr.mock.ctrl.T.Helper() 352 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubscriptionsBySourceID", reflect.TypeOf((*MockSubscription)(nil).GetSubscriptionsBySourceID), ctx, sourceID, opts) 353 | } 354 | 355 | // GetSubscriptionsByUserID mocks base method. 356 | func (m *MockSubscription) GetSubscriptionsByUserID(ctx context.Context, userID int64, opts *storage.GetSubscriptionsOptions) (*storage.GetSubscriptionsResult, error) { 357 | m.ctrl.T.Helper() 358 | ret := m.ctrl.Call(m, "GetSubscriptionsByUserID", ctx, userID, opts) 359 | ret0, _ := ret[0].(*storage.GetSubscriptionsResult) 360 | ret1, _ := ret[1].(error) 361 | return ret0, ret1 362 | } 363 | 364 | // GetSubscriptionsByUserID indicates an expected call of GetSubscriptionsByUserID. 365 | func (mr *MockSubscriptionMockRecorder) GetSubscriptionsByUserID(ctx, userID, opts interface{}) *gomock.Call { 366 | mr.mock.ctrl.T.Helper() 367 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSubscriptionsByUserID", reflect.TypeOf((*MockSubscription)(nil).GetSubscriptionsByUserID), ctx, userID, opts) 368 | } 369 | 370 | // Init mocks base method. 371 | func (m *MockSubscription) Init(ctx context.Context) error { 372 | m.ctrl.T.Helper() 373 | ret := m.ctrl.Call(m, "Init", ctx) 374 | ret0, _ := ret[0].(error) 375 | return ret0 376 | } 377 | 378 | // Init indicates an expected call of Init. 379 | func (mr *MockSubscriptionMockRecorder) Init(ctx interface{}) *gomock.Call { 380 | mr.mock.ctrl.T.Helper() 381 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Init", reflect.TypeOf((*MockSubscription)(nil).Init), ctx) 382 | } 383 | 384 | // SubscriptionExist mocks base method. 385 | func (m *MockSubscription) SubscriptionExist(ctx context.Context, userID int64, sourceID uint) (bool, error) { 386 | m.ctrl.T.Helper() 387 | ret := m.ctrl.Call(m, "SubscriptionExist", ctx, userID, sourceID) 388 | ret0, _ := ret[0].(bool) 389 | ret1, _ := ret[1].(error) 390 | return ret0, ret1 391 | } 392 | 393 | // SubscriptionExist indicates an expected call of SubscriptionExist. 394 | func (mr *MockSubscriptionMockRecorder) SubscriptionExist(ctx, userID, sourceID interface{}) *gomock.Call { 395 | mr.mock.ctrl.T.Helper() 396 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubscriptionExist", reflect.TypeOf((*MockSubscription)(nil).SubscriptionExist), ctx, userID, sourceID) 397 | } 398 | 399 | // UpdateSubscription mocks base method. 400 | func (m *MockSubscription) UpdateSubscription(ctx context.Context, userID int64, sourceID uint, newSubscription *model.Subscribe) error { 401 | m.ctrl.T.Helper() 402 | ret := m.ctrl.Call(m, "UpdateSubscription", ctx, userID, sourceID, newSubscription) 403 | ret0, _ := ret[0].(error) 404 | return ret0 405 | } 406 | 407 | // UpdateSubscription indicates an expected call of UpdateSubscription. 408 | func (mr *MockSubscriptionMockRecorder) UpdateSubscription(ctx, userID, sourceID, newSubscription interface{}) *gomock.Call { 409 | mr.mock.ctrl.T.Helper() 410 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateSubscription", reflect.TypeOf((*MockSubscription)(nil).UpdateSubscription), ctx, userID, sourceID, newSubscription) 411 | } 412 | 413 | // UpsertSubscription mocks base method. 414 | func (m *MockSubscription) UpsertSubscription(ctx context.Context, userID int64, sourceID uint, newSubscription *model.Subscribe) error { 415 | m.ctrl.T.Helper() 416 | ret := m.ctrl.Call(m, "UpsertSubscription", ctx, userID, sourceID, newSubscription) 417 | ret0, _ := ret[0].(error) 418 | return ret0 419 | } 420 | 421 | // UpsertSubscription indicates an expected call of UpsertSubscription. 422 | func (mr *MockSubscriptionMockRecorder) UpsertSubscription(ctx, userID, sourceID, newSubscription interface{}) *gomock.Call { 423 | mr.mock.ctrl.T.Helper() 424 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertSubscription", reflect.TypeOf((*MockSubscription)(nil).UpsertSubscription), ctx, userID, sourceID, newSubscription) 425 | } 426 | 427 | // MockContent is a mock of Content interface. 428 | type MockContent struct { 429 | ctrl *gomock.Controller 430 | recorder *MockContentMockRecorder 431 | } 432 | 433 | // MockContentMockRecorder is the mock recorder for MockContent. 434 | type MockContentMockRecorder struct { 435 | mock *MockContent 436 | } 437 | 438 | // NewMockContent creates a new mock instance. 439 | func NewMockContent(ctrl *gomock.Controller) *MockContent { 440 | mock := &MockContent{ctrl: ctrl} 441 | mock.recorder = &MockContentMockRecorder{mock} 442 | return mock 443 | } 444 | 445 | // EXPECT returns an object that allows the caller to indicate expected use. 446 | func (m *MockContent) EXPECT() *MockContentMockRecorder { 447 | return m.recorder 448 | } 449 | 450 | // AddContent mocks base method. 451 | func (m *MockContent) AddContent(ctx context.Context, content *model.Content) error { 452 | m.ctrl.T.Helper() 453 | ret := m.ctrl.Call(m, "AddContent", ctx, content) 454 | ret0, _ := ret[0].(error) 455 | return ret0 456 | } 457 | 458 | // AddContent indicates an expected call of AddContent. 459 | func (mr *MockContentMockRecorder) AddContent(ctx, content interface{}) *gomock.Call { 460 | mr.mock.ctrl.T.Helper() 461 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddContent", reflect.TypeOf((*MockContent)(nil).AddContent), ctx, content) 462 | } 463 | 464 | // DeleteSourceContents mocks base method. 465 | func (m *MockContent) DeleteSourceContents(ctx context.Context, sourceID uint) (int64, error) { 466 | m.ctrl.T.Helper() 467 | ret := m.ctrl.Call(m, "DeleteSourceContents", ctx, sourceID) 468 | ret0, _ := ret[0].(int64) 469 | ret1, _ := ret[1].(error) 470 | return ret0, ret1 471 | } 472 | 473 | // DeleteSourceContents indicates an expected call of DeleteSourceContents. 474 | func (mr *MockContentMockRecorder) DeleteSourceContents(ctx, sourceID interface{}) *gomock.Call { 475 | mr.mock.ctrl.T.Helper() 476 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteSourceContents", reflect.TypeOf((*MockContent)(nil).DeleteSourceContents), ctx, sourceID) 477 | } 478 | 479 | // HashIDExist mocks base method. 480 | func (m *MockContent) HashIDExist(ctx context.Context, hashID string) (bool, error) { 481 | m.ctrl.T.Helper() 482 | ret := m.ctrl.Call(m, "HashIDExist", ctx, hashID) 483 | ret0, _ := ret[0].(bool) 484 | ret1, _ := ret[1].(error) 485 | return ret0, ret1 486 | } 487 | 488 | // HashIDExist indicates an expected call of HashIDExist. 489 | func (mr *MockContentMockRecorder) HashIDExist(ctx, hashID interface{}) *gomock.Call { 490 | mr.mock.ctrl.T.Helper() 491 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HashIDExist", reflect.TypeOf((*MockContent)(nil).HashIDExist), ctx, hashID) 492 | } 493 | 494 | // Init mocks base method. 495 | func (m *MockContent) Init(ctx context.Context) error { 496 | m.ctrl.T.Helper() 497 | ret := m.ctrl.Call(m, "Init", ctx) 498 | ret0, _ := ret[0].(error) 499 | return ret0 500 | } 501 | 502 | // Init indicates an expected call of Init. 503 | func (mr *MockContentMockRecorder) Init(ctx interface{}) *gomock.Call { 504 | mr.mock.ctrl.T.Helper() 505 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Init", reflect.TypeOf((*MockContent)(nil).Init), ctx) 506 | } 507 | --------------------------------------------------------------------------------