├── consumer ├── consumer.go └── event-consumer │ └── event-consumer.go ├── README.md ├── deploy └── docker-compose.yaml ├── lib └── e │ └── e.go ├── events ├── type.go └── telegram │ ├── messages.go │ ├── commands.go │ └── telegram.go ├── clients └── telegram │ ├── types.go │ └── telegram.go ├── go.mod ├── config └── config.go ├── main.go └── storage ├── storage.go ├── mongo └── mongo.go └── files └── files.go /consumer/consumer.go: -------------------------------------------------------------------------------- 1 | package consumer 2 | 3 | type Consumer interface { 4 | Start() error 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Read Adviser Bot 2 | 3 | **Read Adviser Bot** - is a bot for Telegram that can save links sent by users and can send you random from them. 4 | 5 | It can be useful for people who often save many links, but always forget about them. -------------------------------------------------------------------------------- /deploy/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | mongo: 3 | image: mongo:5.0.5 4 | restart: always 5 | ports: 6 | - 27017:27017 7 | environment: 8 | MONGO_INITDB_ROOT_USERNAME: root 9 | MONGO_INITDB_ROOT_PASSWORD: example -------------------------------------------------------------------------------- /lib/e/e.go: -------------------------------------------------------------------------------- 1 | package e 2 | 3 | import "fmt" 4 | 5 | func Wrap(msg string, err error) error { 6 | return fmt.Errorf("%s: %w", msg, err) 7 | } 8 | 9 | func WrapIfErr(msg string, err error) error { 10 | if err == nil { 11 | return nil 12 | } 13 | 14 | return Wrap(msg, err) 15 | } 16 | -------------------------------------------------------------------------------- /events/type.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import "context" 4 | 5 | type Fetcher interface { 6 | Fetch(ctx context.Context, limit int) ([]Event, error) 7 | } 8 | 9 | type Processor interface { 10 | Process(ctx context.Context, e Event) error 11 | } 12 | 13 | type Type int 14 | 15 | const ( 16 | Unknown Type = iota 17 | Message 18 | ) 19 | 20 | type Event struct { 21 | Type Type 22 | Text string 23 | Meta interface{} 24 | } 25 | -------------------------------------------------------------------------------- /clients/telegram/types.go: -------------------------------------------------------------------------------- 1 | package telegram 2 | 3 | type UpdatesResponse struct { 4 | Ok bool `json:"ok"` 5 | Result []Update `json:"result"` 6 | } 7 | 8 | type Update struct { 9 | ID int `json:"update_id"` 10 | Message *IncomingMessage `json:"message"` 11 | } 12 | 13 | type IncomingMessage struct { 14 | Text string `json:"text"` 15 | From From `json:"from"` 16 | Chat Chat `json:"chat"` 17 | } 18 | 19 | type From struct { 20 | Username string `json:"username"` 21 | } 22 | 23 | type Chat struct { 24 | ID int `json:"id"` 25 | } 26 | -------------------------------------------------------------------------------- /events/telegram/messages.go: -------------------------------------------------------------------------------- 1 | package telegram 2 | 3 | const msgHelp = `I can save and keep you pages. Also I can offer you them to read. 4 | 5 | In order to save the page, just send me al link to it. 6 | 7 | In order to get a random page from your list, send me command /rnd. 8 | Caution! After that, this page will be removed from your list!` 9 | 10 | const msgHello = "Hi there! 👾\n\n" + msgHelp 11 | 12 | const ( 13 | msgUnknownCommand = "Unknown command 🤔" 14 | msgNoSavedPages = "You have no saved pages 🙊" 15 | msgSaved = "Saved! 👌" 16 | msgAlreadyExists = "You have already have this page in your list 🤗" 17 | ) 18 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module read-adviser-bot 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/go-stack/stack v1.8.0 // indirect 7 | github.com/golang/snappy v0.0.1 // indirect 8 | github.com/klauspost/compress v1.13.6 // indirect 9 | github.com/pkg/errors v0.9.1 // indirect 10 | github.com/xdg-go/pbkdf2 v1.0.0 // indirect 11 | github.com/xdg-go/scram v1.0.2 // indirect 12 | github.com/xdg-go/stringprep v1.0.2 // indirect 13 | github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect 14 | go.mongodb.org/mongo-driver v1.8.1 // indirect 15 | golang.org/x/crypto v0.0.0-20201216223049-8b5274cf687f // indirect 16 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e // indirect 17 | golang.org/x/text v0.3.5 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | ) 7 | 8 | type Config struct { 9 | TgBotToken string 10 | MongoConnectionString string 11 | } 12 | 13 | func MustLoad() Config { 14 | tgBotTokenToken := flag.String( 15 | "tg-bot-token", 16 | "", 17 | "token for access to telegram bot", 18 | ) 19 | 20 | mongoConnectionString := flag.String( 21 | "mongo-connection-string", 22 | "", 23 | "connection string for MongoDB", 24 | ) 25 | 26 | flag.Parse() 27 | 28 | if *tgBotTokenToken == "" { 29 | log.Fatal("token is not specified") 30 | } 31 | if *mongoConnectionString == "" { 32 | log.Fatal("mongo connection string is not specified") 33 | } 34 | 35 | return Config{ 36 | TgBotToken: *tgBotTokenToken, 37 | MongoConnectionString: *mongoConnectionString, 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | tgClient "read-adviser-bot/clients/telegram" 8 | "read-adviser-bot/config" 9 | "read-adviser-bot/consumer/event-consumer" 10 | "read-adviser-bot/events/telegram" 11 | "read-adviser-bot/storage/mongo" 12 | ) 13 | 14 | const ( 15 | tgBotHost = "api.telegram.org" 16 | storagePath = "files_storage" 17 | batchSize = 100 18 | ) 19 | 20 | func main() { 21 | cfg := config.MustLoad() 22 | //storage := files.New(storagePath) 23 | 24 | storage := mongo.New(cfg.MongoConnectionString, 10*time.Second) 25 | 26 | eventsProcessor := telegram.New( 27 | tgClient.New(tgBotHost, cfg.TgBotToken), 28 | storage, 29 | ) 30 | 31 | log.Print("service started") 32 | 33 | consumer := event_consumer.New(eventsProcessor, eventsProcessor, batchSize) 34 | 35 | if err := consumer.Start(); err != nil { 36 | log.Fatal("service is stopped", err) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "crypto/sha1" 6 | "errors" 7 | "fmt" 8 | "io" 9 | 10 | "read-adviser-bot/lib/e" 11 | ) 12 | 13 | type Storage interface { 14 | Save(ctx context.Context, p *Page) error 15 | PickRandom(ctx context.Context, userName string) (*Page, error) 16 | Remove(ctx context.Context, p *Page) error 17 | IsExists(ctx context.Context, p *Page) (bool, error) 18 | } 19 | 20 | var ErrNoSavedPages = errors.New("no saved pages") 21 | 22 | type Page struct { 23 | URL string 24 | UserName string 25 | } 26 | 27 | func (p Page) Hash() (string, error) { 28 | h := sha1.New() 29 | 30 | if _, err := io.WriteString(h, p.URL); err != nil { 31 | return "", e.Wrap("can't calculate hash", err) 32 | } 33 | 34 | if _, err := io.WriteString(h, p.UserName); err != nil { 35 | return "", e.Wrap("can't calculate hash", err) 36 | } 37 | 38 | return fmt.Sprintf("%x", h.Sum(nil)), nil 39 | } 40 | -------------------------------------------------------------------------------- /consumer/event-consumer/event-consumer.go: -------------------------------------------------------------------------------- 1 | package event_consumer 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "time" 7 | 8 | "read-adviser-bot/events" 9 | ) 10 | 11 | type Consumer struct { 12 | fetcher events.Fetcher 13 | processor events.Processor 14 | batchSize int 15 | } 16 | 17 | func New(fetcher events.Fetcher, processor events.Processor, batchSize int) Consumer { 18 | return Consumer{ 19 | fetcher: fetcher, 20 | processor: processor, 21 | batchSize: batchSize, 22 | } 23 | } 24 | 25 | func (c Consumer) Start() error { 26 | for { 27 | gotEvents, err := c.fetcher.Fetch(context.Background(), c.batchSize) 28 | if err != nil { 29 | log.Printf("[ERR] consumer: %s", err.Error()) 30 | 31 | continue 32 | } 33 | 34 | if len(gotEvents) == 0 { 35 | time.Sleep(1 * time.Second) 36 | 37 | continue 38 | } 39 | 40 | if err := c.handleEvents(context.Background(), gotEvents); err != nil { 41 | log.Print(err) 42 | 43 | continue 44 | } 45 | } 46 | } 47 | 48 | func (c *Consumer) handleEvents(ctx context.Context, events []events.Event) error { 49 | for _, event := range events { 50 | log.Printf("got new event: %s", event.Text) 51 | 52 | if err := c.processor.Process(ctx, event); err != nil { 53 | log.Printf("can't handle event: %s", err.Error()) 54 | 55 | continue 56 | } 57 | } 58 | 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /clients/telegram/telegram.go: -------------------------------------------------------------------------------- 1 | package telegram 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "path" 10 | "strconv" 11 | 12 | "read-adviser-bot/lib/e" 13 | ) 14 | 15 | type Client struct { 16 | host string 17 | basePath string 18 | client http.Client 19 | } 20 | 21 | const ( 22 | getUpdatesMethod = "getUpdates" 23 | sendMessageMethod = "sendMessage" 24 | ) 25 | 26 | func New(host string, token string) *Client { 27 | return &Client{ 28 | host: host, 29 | basePath: newBasePath(token), 30 | client: http.Client{}, 31 | } 32 | } 33 | 34 | func newBasePath(token string) string { 35 | return "bot" + token 36 | } 37 | 38 | func (c *Client) Updates(ctx context.Context, offset int, limit int) (updates []Update, err error) { 39 | defer func() { err = e.WrapIfErr("can't get updates", err) }() 40 | 41 | q := url.Values{} 42 | q.Add("offset", strconv.Itoa(offset)) 43 | q.Add("limit", strconv.Itoa(limit)) 44 | 45 | data, err := c.doRequest(ctx, getUpdatesMethod, q) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | var res UpdatesResponse 51 | 52 | if err := json.Unmarshal(data, &res); err != nil { 53 | return nil, err 54 | } 55 | 56 | return res.Result, nil 57 | } 58 | 59 | func (c *Client) SendMessage(ctx context.Context, chatID int, text string) error { 60 | q := url.Values{} 61 | q.Add("chat_id", strconv.Itoa(chatID)) 62 | q.Add("text", text) 63 | 64 | _, err := c.doRequest(ctx, sendMessageMethod, q) 65 | if err != nil { 66 | return e.Wrap("can't send message", err) 67 | } 68 | 69 | return nil 70 | } 71 | 72 | func (c *Client) doRequest(ctx context.Context, method string, query url.Values) (data []byte, err error) { 73 | defer func() { err = e.WrapIfErr("can't do request", err) }() 74 | 75 | u := url.URL{ 76 | Scheme: "https", 77 | Host: c.host, 78 | Path: path.Join(c.basePath, method), 79 | } 80 | 81 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | req.URL.RawQuery = query.Encode() 87 | 88 | resp, err := c.client.Do(req) 89 | if err != nil { 90 | return nil, err 91 | } 92 | defer func() { _ = resp.Body.Close() }() 93 | 94 | body, err := io.ReadAll(resp.Body) 95 | if err != nil { 96 | return nil, err 97 | } 98 | 99 | return body, nil 100 | } 101 | -------------------------------------------------------------------------------- /events/telegram/commands.go: -------------------------------------------------------------------------------- 1 | package telegram 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log" 7 | "net/url" 8 | "strings" 9 | 10 | "read-adviser-bot/lib/e" 11 | "read-adviser-bot/storage" 12 | ) 13 | 14 | const ( 15 | RndCmd = "/rnd" 16 | HelpCmd = "/help" 17 | StartCmd = "/start" 18 | ) 19 | 20 | func (p *Processor) doCmd(ctx context.Context, text string, chatID int, username string) error { 21 | text = strings.TrimSpace(text) 22 | 23 | log.Printf("got new command '%s' from '%s", text, username) 24 | 25 | if isAddCmd(text) { 26 | return p.savePage(ctx, chatID, text, username) 27 | } 28 | 29 | switch text { 30 | case RndCmd: 31 | return p.sendRandom(ctx, chatID, username) 32 | case HelpCmd: 33 | return p.sendHelp(ctx, chatID) 34 | case StartCmd: 35 | return p.sendHello(ctx, chatID) 36 | default: 37 | return p.tg.SendMessage(ctx, chatID, msgUnknownCommand) 38 | } 39 | } 40 | 41 | func (p *Processor) savePage(ctx context.Context, chatID int, pageURL string, username string) (err error) { 42 | defer func() { err = e.WrapIfErr("can't do command: save page", err) }() 43 | 44 | page := &storage.Page{ 45 | URL: pageURL, 46 | UserName: username, 47 | } 48 | 49 | isExists, err := p.storage.IsExists(ctx, page) 50 | if err != nil { 51 | return err 52 | } 53 | if isExists { 54 | return p.tg.SendMessage(ctx, chatID, msgAlreadyExists) 55 | } 56 | 57 | if err := p.storage.Save(ctx, page); err != nil { 58 | return err 59 | } 60 | 61 | if err := p.tg.SendMessage(ctx, chatID, msgSaved); err != nil { 62 | return err 63 | } 64 | 65 | return nil 66 | } 67 | 68 | func (p *Processor) sendRandom(ctx context.Context, chatID int, username string) (err error) { 69 | defer func() { err = e.WrapIfErr("can't do command: can't send random", err) }() 70 | 71 | page, err := p.storage.PickRandom(ctx, username) 72 | if err != nil && !errors.Is(err, storage.ErrNoSavedPages) { 73 | return err 74 | } 75 | if errors.Is(err, storage.ErrNoSavedPages) { 76 | return p.tg.SendMessage(ctx, chatID, msgNoSavedPages) 77 | } 78 | 79 | if err := p.tg.SendMessage(ctx, chatID, page.URL); err != nil { 80 | return err 81 | } 82 | 83 | return p.storage.Remove(ctx, page) 84 | } 85 | 86 | func (p *Processor) sendHelp(ctx context.Context, chatID int) error { 87 | return p.tg.SendMessage(ctx, chatID, msgHelp) 88 | } 89 | 90 | func (p *Processor) sendHello(ctx context.Context, chatID int) error { 91 | return p.tg.SendMessage(ctx, chatID, msgHello) 92 | } 93 | 94 | func isAddCmd(text string) bool { 95 | return isURL(text) 96 | } 97 | 98 | func isURL(text string) bool { 99 | u, err := url.Parse(text) 100 | 101 | return err == nil && u.Host != "" 102 | } 103 | -------------------------------------------------------------------------------- /events/telegram/telegram.go: -------------------------------------------------------------------------------- 1 | package telegram 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "read-adviser-bot/clients/telegram" 8 | "read-adviser-bot/events" 9 | "read-adviser-bot/lib/e" 10 | "read-adviser-bot/storage" 11 | ) 12 | 13 | type Processor struct { 14 | tg *telegram.Client 15 | offset int 16 | storage storage.Storage 17 | } 18 | 19 | type Meta struct { 20 | ChatID int 21 | Username string 22 | } 23 | 24 | var ( 25 | ErrUnknownEventType = errors.New("unknown event type") 26 | ErrUnknownMetaType = errors.New("unknown meta type") 27 | ) 28 | 29 | func New(client *telegram.Client, storage storage.Storage) *Processor { 30 | return &Processor{ 31 | tg: client, 32 | storage: storage, 33 | } 34 | } 35 | 36 | func (p *Processor) Fetch(ctx context.Context, limit int) ([]events.Event, error) { 37 | updates, err := p.tg.Updates(ctx, p.offset, limit) 38 | if err != nil { 39 | return nil, e.Wrap("can't get events", err) 40 | } 41 | 42 | if len(updates) == 0 { 43 | return nil, nil 44 | } 45 | 46 | res := make([]events.Event, 0, len(updates)) 47 | 48 | for _, u := range updates { 49 | res = append(res, event(u)) 50 | } 51 | 52 | p.offset = updates[len(updates)-1].ID + 1 53 | 54 | return res, nil 55 | } 56 | 57 | func (p *Processor) Process(ctx context.Context, event events.Event) error { 58 | switch event.Type { 59 | case events.Message: 60 | return p.processMessage(ctx, event) 61 | default: 62 | return e.Wrap("can't process message", ErrUnknownEventType) 63 | } 64 | } 65 | 66 | func (p *Processor) processMessage(ctx context.Context, event events.Event) error { 67 | meta, err := meta(event) 68 | if err != nil { 69 | return e.Wrap("can't process message", err) 70 | } 71 | 72 | if err := p.doCmd(ctx, event.Text, meta.ChatID, meta.Username); err != nil { 73 | return e.Wrap("can't process message", err) 74 | } 75 | 76 | return nil 77 | } 78 | 79 | func meta(event events.Event) (Meta, error) { 80 | res, ok := event.Meta.(Meta) 81 | if !ok { 82 | return Meta{}, e.Wrap("can't get meta", ErrUnknownMetaType) 83 | } 84 | 85 | return res, nil 86 | } 87 | 88 | func event(upd telegram.Update) events.Event { 89 | updType := fetchType(upd) 90 | 91 | res := events.Event{ 92 | Type: updType, 93 | Text: fetchText(upd), 94 | } 95 | 96 | if updType == events.Message { 97 | res.Meta = Meta{ 98 | ChatID: upd.Message.Chat.ID, 99 | Username: upd.Message.From.Username, 100 | } 101 | } 102 | 103 | return res 104 | } 105 | 106 | func fetchText(upd telegram.Update) string { 107 | if upd.Message == nil { 108 | return "" 109 | } 110 | 111 | return upd.Message.Text 112 | } 113 | 114 | func fetchType(upd telegram.Update) events.Type { 115 | if upd.Message == nil { 116 | return events.Unknown 117 | } 118 | 119 | return events.Message 120 | } 121 | -------------------------------------------------------------------------------- /storage/mongo/mongo.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io" 7 | "log" 8 | "time" 9 | 10 | "go.mongodb.org/mongo-driver/bson" 11 | "go.mongodb.org/mongo-driver/mongo" 12 | "go.mongodb.org/mongo-driver/mongo/options" 13 | 14 | "read-adviser-bot/lib/e" 15 | "read-adviser-bot/storage" 16 | ) 17 | 18 | type Storage struct { 19 | pages Pages 20 | } 21 | 22 | type Pages struct { 23 | *mongo.Collection 24 | } 25 | 26 | type Page struct { 27 | URL string `bson:"url"` 28 | UserName string `bson:"username"` 29 | } 30 | 31 | func New(connectString string, connectTimeout time.Duration) Storage { 32 | ctx, cancel := context.WithTimeout(context.Background(), connectTimeout) 33 | defer cancel() 34 | 35 | client, err := mongo.Connect(ctx, options.Client().ApplyURI(connectString)) 36 | if err != nil { 37 | log.Fatal(err) 38 | } 39 | 40 | if err := client.Ping(ctx, nil); err != nil { 41 | log.Fatal(err) 42 | } 43 | 44 | pages := Pages{ 45 | Collection: client.Database("read-adviser").Collection("pages"), 46 | } 47 | 48 | return Storage{ 49 | pages: pages, 50 | } 51 | } 52 | 53 | func (s Storage) Save(ctx context.Context, page *storage.Page) error { 54 | _, err := s.pages.InsertOne(ctx, Page{ 55 | URL: page.URL, 56 | UserName: page.UserName, 57 | }) 58 | if err != nil { 59 | return e.Wrap("can't save page", err) 60 | } 61 | 62 | return nil 63 | } 64 | 65 | func (s Storage) PickRandom(ctx context.Context, userName string) (page *storage.Page, err error) { 66 | defer func() { err = e.WrapIfErr("can't pick random page", err) }() 67 | 68 | pipe := bson.A{ 69 | bson.M{"$sample": bson.M{"size": 1}}, 70 | } 71 | 72 | cursor, err := s.pages.Aggregate(ctx, pipe) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | var p Page 78 | 79 | cursor.Next(ctx) 80 | 81 | err = cursor.Decode(&p) 82 | switch { 83 | case errors.Is(err, io.EOF): 84 | return nil, storage.ErrNoSavedPages 85 | case err != nil: 86 | return nil, err 87 | } 88 | 89 | return &storage.Page{ 90 | URL: p.URL, 91 | UserName: p.UserName, 92 | }, nil 93 | } 94 | 95 | func (s Storage) Remove(ctx context.Context, storagePage *storage.Page) error { 96 | _, err := s.pages.DeleteOne(ctx, toPage(storagePage).Filter()) 97 | if err != nil { 98 | return e.Wrap("can't remove page", err) 99 | } 100 | 101 | return nil 102 | } 103 | 104 | func (s Storage) IsExists(ctx context.Context, storagePage *storage.Page) (bool, error) { 105 | count, err := s.pages.CountDocuments(ctx, toPage(storagePage).Filter()) 106 | if err != nil { 107 | return false, e.Wrap("can't check if page exists", err) 108 | } 109 | 110 | return count > 0, nil 111 | } 112 | 113 | func toPage(p *storage.Page) Page { 114 | return Page{ 115 | URL: p.URL, 116 | UserName: p.UserName, 117 | } 118 | } 119 | 120 | func (p Page) Filter() bson.M { 121 | return bson.M{ 122 | "url": p.URL, 123 | "username": p.UserName, 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /storage/files/files.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "context" 5 | "encoding/gob" 6 | "errors" 7 | "fmt" 8 | "math/rand" 9 | "os" 10 | "path/filepath" 11 | "time" 12 | 13 | "read-adviser-bot/lib/e" 14 | "read-adviser-bot/storage" 15 | ) 16 | 17 | type Storage struct { 18 | basePath string 19 | } 20 | 21 | const defaultPerm = 0774 22 | 23 | func New(basePath string) Storage { 24 | return Storage{basePath: basePath} 25 | } 26 | 27 | func (s Storage) Save(_ context.Context, page *storage.Page) (err error) { 28 | defer func() { err = e.WrapIfErr("can't save page", err) }() 29 | 30 | fPath := filepath.Join(s.basePath, page.UserName) 31 | 32 | if err := os.MkdirAll(fPath, defaultPerm); err != nil { 33 | return err 34 | } 35 | 36 | fName, err := fileName(page) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | fPath = filepath.Join(fPath, fName) 42 | 43 | file, err := os.Create(fPath) 44 | if err != nil { 45 | return err 46 | } 47 | defer func() { _ = file.Close() }() 48 | 49 | if err := gob.NewEncoder(file).Encode(page); err != nil { 50 | return err 51 | } 52 | 53 | return nil 54 | } 55 | 56 | func (s Storage) PickRandom(_ context.Context, userName string) (page *storage.Page, err error) { 57 | defer func() { err = e.WrapIfErr("can't pick random page", err) }() 58 | 59 | path := filepath.Join(s.basePath, userName) 60 | 61 | files, err := os.ReadDir(path) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | if len(files) == 0 { 67 | return nil, storage.ErrNoSavedPages 68 | } 69 | 70 | rand.Seed(time.Now().UnixNano()) 71 | n := rand.Intn(len(files)) 72 | 73 | file := files[n] 74 | 75 | return s.decodePage(filepath.Join(path, file.Name())) 76 | } 77 | 78 | func (s Storage) Remove(_ context.Context, p *storage.Page) error { 79 | fileName, err := fileName(p) 80 | if err != nil { 81 | return e.Wrap("can't remove page", err) 82 | } 83 | 84 | path := filepath.Join(s.basePath, p.UserName, fileName) 85 | 86 | if err := os.Remove(path); err != nil { 87 | msg := fmt.Sprintf("can't remove page %s", path) 88 | 89 | return e.Wrap(msg, err) 90 | } 91 | 92 | return nil 93 | } 94 | 95 | func (s Storage) IsExists(_ context.Context, p *storage.Page) (bool, error) { 96 | fileName, err := fileName(p) 97 | if err != nil { 98 | return false, e.Wrap("can't check if page exists", err) 99 | } 100 | 101 | path := filepath.Join(s.basePath, p.UserName, fileName) 102 | 103 | switch _, err = os.Stat(path); { 104 | case errors.Is(err, os.ErrNotExist): 105 | return false, nil 106 | case err != nil: 107 | msg := fmt.Sprintf("can't check if file %s exists", path) 108 | 109 | return false, e.Wrap(msg, err) 110 | } 111 | 112 | return true, nil 113 | } 114 | 115 | func (s Storage) decodePage(filePath string) (*storage.Page, error) { 116 | f, err := os.Open(filePath) 117 | if err != nil { 118 | return nil, e.Wrap("can't decode page", err) 119 | } 120 | defer func() { _ = f.Close() }() 121 | 122 | var p storage.Page 123 | 124 | if err := gob.NewDecoder(f).Decode(&p); err != nil { 125 | return nil, e.Wrap("can't decode page", err) 126 | } 127 | 128 | return &p, nil 129 | } 130 | 131 | func fileName(p *storage.Page) (string, error) { 132 | return p.Hash() 133 | } 134 | --------------------------------------------------------------------------------