├── .gitignore ├── LICENSE ├── README.md ├── app.yaml.example ├── cron.yaml ├── intset.go ├── main.go ├── queue.yaml ├── story.go └── structs.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build with `go test -c` 8 | *.test 9 | 10 | # Output of the go coverage tool, specifically when used with LiteIDE 11 | *.out 12 | 13 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 14 | .glide/ 15 | 16 | app.yaml 17 | .vscode/ 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Yuchen Ying 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # yegle-bots 2 | Some Telegram bots 3 | 4 | ## Yet-Another Hacker News Channel 5 | 6 | - Link to join: 7 | -------------------------------------------------------------------------------- /app.yaml.example: -------------------------------------------------------------------------------- 1 | runtime: go 2 | api_version: go1 3 | 4 | handlers: 5 | - url: /_ah/queue/go/delay 6 | login: admin 7 | script: _go_app 8 | - url: /.* 9 | script: _go_app 10 | 11 | env_variables: 12 | BOT_KEY: 'FILL_IN_YOUR_BOT_KEY' 13 | 14 | instance_class: F1 15 | automatic_scaling: 16 | max_concurrent_requests: 80 17 | max_idle_instances: 0 18 | max_pending_latency: 15s 19 | min_idle_instances: 0 20 | min_pending_latency: 15s 21 | -------------------------------------------------------------------------------- /cron.yaml: -------------------------------------------------------------------------------- 1 | cron: 2 | - description: Check the top stories on Hacker News 3 | url: /poll 4 | target: default 5 | schedule: every 10 mins 6 | - description: Delete old stories from channel 7 | url: /cleanup 8 | target: default 9 | schedule: every 10 mins 10 | -------------------------------------------------------------------------------- /intset.go: -------------------------------------------------------------------------------- 1 | package bots 2 | 3 | // IntSet is an int64 set type. 4 | type IntSet map[int64]struct{} 5 | 6 | var v = struct{}{} 7 | 8 | // Add add an int64 number to the set. Return false if the number already exists in the set. 9 | func (set IntSet) Add(i int64) bool { 10 | if _, ok := set[i]; ok { 11 | return false 12 | } 13 | set[i] = v 14 | return true 15 | } 16 | 17 | // AddAll add a slice of ints to the set. 18 | func (set IntSet) AddAll(xs []int64) { 19 | for _, i := range xs { 20 | set[i] = v 21 | } 22 | } 23 | 24 | // Max returns the max number in the set. O(n). 25 | func (set IntSet) Max() int64 { 26 | var ret *int64 27 | for i := range set { 28 | if ret == nil || i > *ret { 29 | ret = &i 30 | } 31 | } 32 | return *ret 33 | } 34 | 35 | // Min returns the min number in the set. O(n). 36 | func (set IntSet) Min() int64 { 37 | var ret *int64 38 | for i := range set { 39 | if ret == nil || i < *ret { 40 | ret = &i 41 | } 42 | } 43 | return *ret 44 | } 45 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package bots 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | "strconv" 10 | "sync" 11 | "time" 12 | 13 | "github.com/pkg/errors" 14 | "google.golang.org/appengine" 15 | "google.golang.org/appengine/datastore" 16 | "google.golang.org/appengine/delay" 17 | "google.golang.org/appengine/log" 18 | "google.golang.org/appengine/urlfetch" 19 | ) 20 | 21 | // TelegramAPIBase is the API base of telegram API. 22 | const TelegramAPIBase = `https://api.telegram.org/` 23 | 24 | // BatchSize is the number of top stories to fetch from Hacker News. 25 | const BatchSize = 30 26 | 27 | // NumCommentsThreshold is the threshold for number of comments. Story with less 28 | // than this threshold will not be posted in the channel. 29 | const NumCommentsThreshold = 5 30 | 31 | // ScoreThreshold is the threshold for the score. Story with less than this 32 | // threshold will not be posted in the channel. 33 | const ScoreThreshold = 50 34 | 35 | // DefaultTimeout is the default URLFetch timeout. 36 | const DefaultTimeout = 9 * time.Minute 37 | 38 | // DefaultChatID is the default chat ID. 39 | const DefaultChatID = `@yahnc` 40 | 41 | func loge(ctx context.Context, err error) { 42 | log.Errorf(ctx, "%+v", err) 43 | } 44 | 45 | var editMessageFunc = delay.Func("editMessage", func(ctx context.Context, itemID int64, messageID int64) { 46 | log.Infof(ctx, "editing message: id %d, message id %d", itemID, messageID) 47 | story := Story{ID: itemID, MessageID: messageID} 48 | err := story.EditMessage(ctx) 49 | if err != nil { 50 | if errors.Cause(err) != ErrIgnoredItem { 51 | loge(ctx, err) 52 | } 53 | return 54 | } 55 | key := GetKey(ctx, itemID) 56 | if _, err := datastore.Put(ctx, key, &story); err != nil { 57 | loge(ctx, err) 58 | } 59 | }) 60 | 61 | var sendMessageFunc = delay.Func("sendMessage", func(ctx context.Context, itemID int64) { 62 | log.Infof(ctx, "sending message: id %d", itemID) 63 | story := Story{ID: itemID} 64 | err := story.SendMessage(ctx) 65 | if err != nil { 66 | if errors.Cause(err) != ErrIgnoredItem { 67 | loge(ctx, err) 68 | } 69 | return 70 | } 71 | key := GetKey(ctx, itemID) 72 | if _, err := datastore.Put(ctx, key, &story); err != nil { 73 | loge(ctx, err) 74 | } 75 | }) 76 | 77 | var deleteMessageFunc = delay.Func("deleteMessage", func(ctx context.Context, itemID int64, messageID int64) { 78 | log.Infof(ctx, "deleting message: id %d, message id %d", itemID, messageID) 79 | story := Story{ID: itemID, MessageID: messageID} 80 | if err := story.DeleteMessage(ctx); err != nil { 81 | loge(ctx, err) 82 | } 83 | }) 84 | 85 | func init() { 86 | http.HandleFunc("/poll", handler) 87 | http.HandleFunc("/cleanup", cleanUpHandler) 88 | } 89 | 90 | // TelegramAPI is a helper function to get the Telegram API endpoint. 91 | func TelegramAPI(method string) string { 92 | return TelegramAPIBase + os.Getenv("BOT_KEY") + "/" + method 93 | } 94 | 95 | // NewsURL is a helper function to get the URL to the story's HackerNews page. 96 | func NewsURL(id int64) string { 97 | return `https://news.ycombinator.com/item?id=` + strconv.FormatInt(id, 10) 98 | } 99 | 100 | // ItemURL is a helper function to get the API of an item. 101 | func ItemURL(id int64) string { 102 | return fmt.Sprintf(`https://hacker-news.firebaseio.com/v0/item/%d.json`, id) 103 | } 104 | 105 | // GetTopStoryURL is a helper function to get the 106 | func GetTopStoryURL() string { 107 | return fmt.Sprintf(`https://hacker-news.firebaseio.com/v0/topstories.json?orderBy="$key"&limitToFirst=%d`, BatchSize) 108 | } 109 | 110 | // GetKey get a datastore key for the given item ID. 111 | func GetKey(ctx context.Context, i int64) *datastore.Key { 112 | root := datastore.NewKey(ctx, "TopStory", "Root", 0, nil) 113 | return datastore.NewKey(ctx, "Story", "", i, root) 114 | } 115 | 116 | func handler(w http.ResponseWriter, r *http.Request) { 117 | ctx := appengine.NewContext(r) 118 | 119 | topStories, err := getTopStories(ctx, BatchSize) 120 | if err != nil { 121 | loge(ctx, err) 122 | return 123 | } 124 | 125 | var keys []*datastore.Key 126 | 127 | for _, story := range topStories { 128 | keys = append(keys, GetKey(ctx, story)) 129 | } 130 | 131 | savedStories := make([]Story, BatchSize, BatchSize) 132 | 133 | err = datastore.GetMulti(ctx, keys, savedStories) 134 | var wg sync.WaitGroup 135 | defer wg.Wait() 136 | if err == nil { 137 | log.Infof(ctx, "no unknown news") 138 | wg.Add(len(keys)) 139 | for i, key := range keys { 140 | go func(id, messageID int64) { 141 | defer wg.Done() 142 | editMessageFunc.Call(ctx, id, messageID) 143 | }(key.IntID(), savedStories[i].MessageID) 144 | } 145 | return 146 | } 147 | 148 | multiErr, ok := err.(appengine.MultiError) 149 | 150 | if !ok { 151 | log.Debugf(ctx, "%v", errors.Wrap(err, "in func handler() from datastore.GetMulti()")) 152 | return 153 | } 154 | 155 | for i, err := range multiErr { 156 | switch { 157 | case err == nil: 158 | wg.Add(1) 159 | go func(id, messageID int64) { 160 | defer wg.Done() 161 | editMessageFunc.Call(ctx, id, messageID) 162 | }(keys[i].IntID(), savedStories[i].MessageID) 163 | case err == datastore.ErrNoSuchEntity: 164 | wg.Add(1) 165 | go func(id int64) { 166 | defer wg.Done() 167 | sendMessageFunc.Call(ctx, id) 168 | }(keys[i].IntID()) 169 | default: 170 | loge(ctx, err) 171 | } 172 | } 173 | } 174 | 175 | func getTopStories(ctx context.Context, limit int) ([]int64, error) { 176 | resp, err := myHTTPClient(ctx).Get(GetTopStoryURL()) 177 | if err != nil { 178 | return nil, errors.Wrap(err, "getTopStories -> http.Client.Get") 179 | } 180 | defer resp.Body.Close() 181 | 182 | var ret []int64 183 | if err := json.NewDecoder(resp.Body).Decode(&ret); err != nil { 184 | return nil, errors.Wrap(err, "in getTopStories from json.Decoder.Decode()") 185 | } 186 | 187 | return ret, nil 188 | } 189 | 190 | func myHTTPClient(ctx context.Context) *http.Client { 191 | withTimeout, _ := context.WithTimeout(ctx, DefaultTimeout) 192 | return urlfetch.Client(withTimeout) 193 | } 194 | 195 | func cleanUpHandler(w http.ResponseWriter, r *http.Request) { 196 | ctx := appengine.NewContext(r) 197 | var allStories []Story 198 | 199 | now := time.Now() 200 | oneDayAgo := now.Add(-24 * time.Hour) 201 | _, err := datastore.NewQuery("Story").Filter("LastSave <=", oneDayAgo).GetAll(ctx, &allStories) 202 | if err != nil { 203 | loge(ctx, err) 204 | return 205 | } 206 | 207 | var wg sync.WaitGroup 208 | defer wg.Wait() 209 | 210 | for _, story := range allStories { 211 | wg.Add(1) 212 | go func(id, messageID int64) { 213 | defer wg.Done() 214 | deleteMessageFunc.Call(ctx, id, messageID) 215 | }(story.ID, story.MessageID) 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /queue.yaml: -------------------------------------------------------------------------------- 1 | queue: 2 | - name: default 3 | rate: 3/m 4 | -------------------------------------------------------------------------------- /story.go: -------------------------------------------------------------------------------- 1 | package bots 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "time" 11 | 12 | "github.com/pkg/errors" 13 | "google.golang.org/appengine/datastore" 14 | "google.golang.org/appengine/log" 15 | ) 16 | 17 | // Hot is the sign for a hot story, either because it has high score or it has 18 | // large number of discussions. 19 | const Hot = "🔥" 20 | 21 | // Story is a struct represents an item stored in datastore. 22 | // Part of the fields will be saved to datastore. 23 | type Story struct { 24 | ID int64 `json:"id"` 25 | URL string `json:"url"` 26 | Title string `json:"title"` 27 | Descendants int64 `json:"descendants"` 28 | Score int64 `json:"score"` 29 | MessageID int64 `json:"-"` 30 | LastSave time.Time `json:"-"` 31 | Type string `json:"type"` 32 | missingFieldsLoaded bool 33 | } 34 | 35 | // NewFromDatastore create a Story from datastore. 36 | func NewFromDatastore(ctx context.Context, id int64) (Story, error) { 37 | var story Story 38 | if err := datastore.Get(ctx, GetKey(ctx, id), &story); err != nil { 39 | return story, errors.WithStack(err) 40 | } 41 | return story, nil 42 | } 43 | 44 | // Load implements the PropertyLoadSaver interface. 45 | func (s *Story) Load(ps []datastore.Property) error { 46 | return datastore.LoadStruct(s, ps) 47 | } 48 | 49 | // Save implements the PropertyLoadSaver interface. 50 | func (s *Story) Save() ([]datastore.Property, error) { 51 | return []datastore.Property{ 52 | { 53 | Name: "MessageID", 54 | Value: s.MessageID, 55 | }, 56 | { 57 | Name: "ID", 58 | Value: s.ID, 59 | }, 60 | { 61 | Name: "LastSave", 62 | Value: time.Now(), 63 | }, 64 | }, nil 65 | } 66 | 67 | // FillMissingFields is used to fill the missing story data from HN API. 68 | func (s *Story) FillMissingFields(ctx context.Context) error { 69 | resp, err := myHTTPClient(ctx).Get(ItemURL(s.ID)) 70 | if err != nil { 71 | return errors.WithStack(err) 72 | } 73 | defer resp.Body.Close() 74 | 75 | err = json.NewDecoder(resp.Body).Decode(s) 76 | if err != nil { 77 | return errors.WithStack(err) 78 | } 79 | s.missingFieldsLoaded = true 80 | return nil 81 | } 82 | 83 | // ShouldIgnore is a filter for story. 84 | func (s *Story) ShouldIgnore() bool { 85 | return s.Type != "story" || 86 | s.Score < ScoreThreshold || 87 | s.Descendants < NumCommentsThreshold || 88 | s.URL == "" 89 | } 90 | 91 | // ToSendMessageRequest will return a new SendMessageRequest object 92 | func (s *Story) ToSendMessageRequest() SendMessageRequest { 93 | return SendMessageRequest{ 94 | ChatID: DefaultChatID, 95 | Text: fmt.Sprintf("%s %s", s.Title, s.URL), 96 | ParseMode: "HTML", 97 | ReplyMarkup: s.GetReplyMarkup(), 98 | DisableNotification: true, 99 | } 100 | } 101 | 102 | // ToEditMessageTextRequest will return a new EditMessageTextRequest object 103 | func (s *Story) ToEditMessageTextRequest() EditMessageTextRequest { 104 | return EditMessageTextRequest{ 105 | ChatID: DefaultChatID, 106 | MessageID: s.MessageID, 107 | Text: fmt.Sprintf("%s %s", s.Title, s.URL), 108 | ParseMode: "HTML", 109 | ReplyMarkup: s.GetReplyMarkup(), 110 | } 111 | } 112 | 113 | // GetReplyMarkup will return the markup for the story. 114 | func (s *Story) GetReplyMarkup() InlineKeyboardMarkup { 115 | var scoreSuffix, commentSuffix string 116 | if s.Score > 100 { 117 | scoreSuffix = " " + Hot 118 | } 119 | if s.Descendants > 100 { 120 | commentSuffix = " " + Hot 121 | } 122 | return InlineKeyboardMarkup{ 123 | InlineKeyboard: [][]InlineKeyboardButton{ 124 | { 125 | { 126 | Text: fmt.Sprintf("Score: %d+%s", s.Score, scoreSuffix), 127 | URL: s.URL, 128 | }, 129 | { 130 | Text: fmt.Sprintf("Comments: %d+%s", s.Descendants, commentSuffix), 131 | URL: NewsURL(s.ID), 132 | }, 133 | }, 134 | }, 135 | } 136 | } 137 | 138 | // ToDeleteMessageRequest returns a DeleteMessageRequest. 139 | func (s *Story) ToDeleteMessageRequest() DeleteMessageRequest { 140 | return DeleteMessageRequest{ 141 | ChatID: DefaultChatID, 142 | MessageID: s.MessageID, 143 | } 144 | } 145 | 146 | // EditMessage send a request to edit a message. 147 | func (s *Story) EditMessage(ctx context.Context) error { 148 | if !s.missingFieldsLoaded { 149 | if err := s.FillMissingFields(ctx); err != nil { 150 | return errors.WithStack(err) 151 | } 152 | } 153 | if s.ShouldIgnore() { 154 | return errors.WithStack(ErrIgnoredItem) 155 | } 156 | 157 | req := s.ToEditMessageTextRequest() 158 | jsonBytes, err := json.Marshal(req) 159 | if err != nil { 160 | return errors.WithStack(err) 161 | } 162 | 163 | resp, err := myHTTPClient(ctx).Post(TelegramAPI("editMessageText"), "application/json", bytes.NewBuffer(jsonBytes)) 164 | if err != nil { 165 | return errors.WithStack(err) 166 | } 167 | defer resp.Body.Close() 168 | io.Copy(ioutil.Discard, resp.Body) 169 | return nil 170 | } 171 | 172 | // InDatastore checks if the story is already in datastore. 173 | func (s *Story) InDatastore(ctx context.Context) bool { 174 | log.Infof(ctx, "calling InDatastore") 175 | key := GetKey(ctx, s.ID) 176 | q := datastore.NewQuery("Story").Filter("__key__ =", key).KeysOnly() 177 | keys, _ := q.GetAll(ctx, nil) 178 | return len(keys) != 0 179 | } 180 | 181 | // SendMessage send a request to send a new message. 182 | func (s *Story) SendMessage(ctx context.Context) error { 183 | if !s.missingFieldsLoaded { 184 | if err := s.FillMissingFields(ctx); err != nil { 185 | return errors.WithStack(err) 186 | } 187 | } 188 | 189 | if s.ShouldIgnore() { 190 | return ErrIgnoredItem 191 | } else if s.InDatastore(ctx) { 192 | return errors.WithStack(fmt.Errorf("story already posted: %#v", s)) 193 | } 194 | req := s.ToSendMessageRequest() 195 | jsonBytes, err := json.Marshal(req) 196 | if err != nil { 197 | return errors.WithStack(err) 198 | } 199 | 200 | resp, err := myHTTPClient(ctx).Post(TelegramAPI("sendMessage"), "application/json", bytes.NewBuffer(jsonBytes)) 201 | if err != nil { 202 | return errors.WithStack(err) 203 | } 204 | defer resp.Body.Close() 205 | 206 | var response SendMessageResponse 207 | 208 | err = json.NewDecoder(resp.Body).Decode(&response) 209 | if err != nil { 210 | return errors.WithStack(err) 211 | } 212 | s.MessageID = response.Result.MessageID 213 | return nil 214 | } 215 | 216 | // DeleteMessage delete a message from telegram Channel and from channel. 217 | func (s *Story) DeleteMessage(ctx context.Context) error { 218 | req := s.ToDeleteMessageRequest() 219 | jsonBytes, err := json.Marshal(req) 220 | if err != nil { 221 | return errors.WithStack(err) 222 | } 223 | 224 | resp, err := myHTTPClient(ctx).Post(TelegramAPI("deleteMessage"), "application/json", bytes.NewBuffer(jsonBytes)) 225 | if err != nil { 226 | return errors.WithStack(err) 227 | } 228 | defer resp.Body.Close() 229 | 230 | var response DeleteMessageResponse 231 | 232 | err = json.NewDecoder(resp.Body).Decode(&response) 233 | if err != nil { 234 | return errors.WithStack(err) 235 | } 236 | 237 | if !response.OK { 238 | if !response.ShouldIgnoreError() { 239 | return errors.WithStack(fmt.Errorf("%#v", response)) 240 | } 241 | log.Warningf(ctx, "ignoring %#v", response) 242 | } 243 | 244 | key := GetKey(ctx, s.ID) 245 | if err := datastore.Delete(ctx, key); err != nil { 246 | return errors.WithStack(err) 247 | } 248 | log.Infof(ctx, "%d (messageID: %d) deleted", s.ID, s.MessageID) 249 | return nil 250 | } 251 | -------------------------------------------------------------------------------- /structs.go: -------------------------------------------------------------------------------- 1 | package bots 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | ) 7 | 8 | // ErrIgnoredItem is returned when the story should be ignored. 9 | var ErrIgnoredItem = errors.New("item ignored") 10 | 11 | // SendMessageRequest is a struct that maps to a sendMessage request. 12 | type SendMessageRequest struct { 13 | ChatID string `json:"chat_id"` 14 | Text string `json:"text"` 15 | ParseMode string `json:"parse_mode,omitempty"` 16 | ReplyMarkup InlineKeyboardMarkup `json:"reply_markup,omitempty"` 17 | DisableNotification bool `json:"disable_notification,omitempty"` 18 | } 19 | 20 | // InlineKeyboardMarkup type. 21 | type InlineKeyboardMarkup struct { 22 | InlineKeyboard [][]InlineKeyboardButton `json:"inline_keyboard,omitempty"` 23 | } 24 | 25 | // InlineKeyboardButton type. 26 | type InlineKeyboardButton struct { 27 | Text string `json:"text,omitempty"` 28 | URL string `json:"url,omitempty"` 29 | } 30 | 31 | // SendMessageResponse is the response from sendMessage request. 32 | type SendMessageResponse struct { 33 | OK bool `json:"ok"` 34 | Result Result `json:"result"` 35 | } 36 | 37 | // Result is a submessage in SendMessageResponse. We only care the MessageID for now. 38 | type Result struct { 39 | MessageID int64 `json:"message_id"` 40 | } 41 | 42 | // EditMessageTextRequest is the request to editMessageText method. 43 | type EditMessageTextRequest struct { 44 | ChatID string `json:"chat_id"` 45 | MessageID int64 `json:"message_id"` 46 | Text string `json:"text"` 47 | ParseMode string `json:"parse_mode,omitempty"` 48 | ReplyMarkup InlineKeyboardMarkup `json:"reply_markup,omitempty"` 49 | } 50 | 51 | // DeleteMessageRequest is the request to deleteMessage method. 52 | type DeleteMessageRequest struct { 53 | ChatID string `json:"chat_id"` 54 | MessageID int64 `json:"message_id"` 55 | } 56 | 57 | // DeleteMessageResponse is the response to deleteMessage method. 58 | type DeleteMessageResponse struct { 59 | OK bool `json:"ok"` 60 | ErrorCode int64 `json:"error_code"` 61 | Description string `json:"description"` 62 | } 63 | 64 | // ShouldIgnoreError return true if the message contains an error but should be ignored. 65 | func (r *DeleteMessageResponse) ShouldIgnoreError() bool { 66 | return (r.ErrorCode == 400 && 67 | // Someone manually deleted the message from the channel 68 | (strings.Contains(r.Description, "message to delete not found") || 69 | // Story was on top 30 list for > 24 hours but Telegram API only allow 70 | // deleting messages that were posted in <48 hours. 71 | // It should be fine to just ignore this error, and leave these stories in 72 | // channel forever. 73 | strings.Contains(r.Description, "message can't be deleted"))) 74 | } 75 | --------------------------------------------------------------------------------