├── 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 | [](https://github.com/indes/flowerss-bot/actions?query=workflow%3ARelease)
4 | [](https://github.com/indes/flowerss-bot/actions?query=workflow%3ATest)
5 | 
6 | [](https://goreportcard.com/report/github.com/indes/flowerss-bot)
7 | 
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 |
--------------------------------------------------------------------------------