main.go (11347B)
1 package main 2 3 import ( 4 "crypto/sha256" 5 "encoding/hex" 6 "encoding/json" 7 "fmt" 8 "io" 9 "log" 10 "os" 11 "strconv" 12 "strings" 13 "time" 14 15 "github.com/lrstanley/girc" 16 "github.com/mmcdole/gofeed" 17 "github.com/spf13/viper" 18 ) 19 20 type News struct { 21 Origin string `json:"origin"` 22 Title string `json:"title"` 23 Link string `json:"link"` 24 Date string `json:"date"` 25 Hash string `json:"hash"` 26 } 27 28 var newsList []News 29 var pendingList []News 30 31 // Send an IRC ircMessage, unless in dry-run mode 32 func ircMessage(client *girc.Client, target, message string) { 33 if viper.GetBool("irc.dry") { 34 log.Printf("ircMessage to %s: %s", target, message) 35 } else { 36 client.Cmd.Message(target, message) 37 } 38 } 39 40 // Create a hash out of link 41 func mkHash(s string) string { 42 h := sha256.Sum256([]byte(s)) 43 hash := hex.EncodeToString(h[:]) 44 return hash[:8] 45 } 46 47 // Checks if news exists by searching its hash 48 func newsExists(news News) bool { 49 for _, n := range newsList { 50 if n.Hash == news.Hash { 51 return true 52 } 53 } 54 return false 55 } 56 57 func saveNews(news News) { 58 if len(newsList) < viper.GetInt("feeds.ringsize") { 59 newsList = append(newsList, news) 60 } else { 61 newsList = append(newsList[1:], news) 62 } 63 } 64 65 // Retrieve a news by its hash 66 func getNewsByHash(hash string) News { 67 hash = strings.ReplaceAll(hash, "#", "") 68 for _, n := range newsList { 69 if n.Hash == hash { 70 return n 71 } 72 } 73 return News{} 74 } 75 76 // Retrieve news from a certain origin 77 func getNewsByOrigin(origin string) []News { 78 resNews := []News{} 79 80 for _, n := range newsList { 81 if n.Origin == origin { 82 resNews = append(resNews, n) 83 } 84 } 85 return resNews 86 } 87 88 func fmtNews(news News) string { 89 colorReset := girc.Fmt("{r}") 90 colorOrigin := girc.Fmt(fmt.Sprintf("{%s}", viper.GetString("irc.colors.origin"))) 91 colorTitle := girc.Fmt(fmt.Sprintf("{%s}", viper.GetString("irc.colors.title"))) 92 colorLink := girc.Fmt(fmt.Sprintf("{%s}", viper.GetString("irc.colors.link"))) 93 colorHash := girc.Fmt(fmt.Sprintf("{%s}", viper.GetString("irc.colors.hash"))) 94 95 return fmt.Sprintf("[%s%s%s] %s%s%s %s%s%s %s#%s%s", 96 colorOrigin, news.Origin, colorReset, 97 colorTitle, news.Title, colorReset, 98 colorLink, news.Link, colorReset, 99 colorHash, news.Hash, colorReset) 100 } 101 102 // Fetch and post news from RSS feeds 103 func newsFetch(client *girc.Client, channel string) { 104 105 newsList = make([]News, 0) 106 107 feedFile := channel + "-feed.json" 108 // load saved news 109 f, err := os.OpenFile(feedFile, os.O_CREATE|os.O_RDWR, 0o644) 110 if err != nil { 111 log.Fatalf("can't open %s: %v", feedFile, err) 112 } 113 defer f.Close() 114 decoder := json.NewDecoder(f) 115 if err := decoder.Decode(&newsList); err != nil { 116 log.Println("could not load news list, empty?") 117 } 118 119 for { 120 // Only check chanlist if target is not a query 121 if client.IsConnected() && (!strings.HasPrefix(channel, "#") || len(client.ChannelList()) != 0) { 122 break 123 } 124 log.Printf("%v, not connected, waiting...\n", client.ChannelList()) 125 126 time.Sleep(viper.GetDuration("feeds.frequency")) 127 } 128 129 for { 130 131 if viper.GetBool("irc.secondary") { 132 // Post news from previous round if still fresh 133 for _, news := range pendingList { 134 if newsExists(news) { 135 log.Printf("already caught-up %s (%s)\n", news.Title, news.Hash) 136 continue 137 } 138 139 ircMessage(client, channel, fmtNews(news)) 140 time.Sleep(viper.GetDuration("irc.delay")) 141 saveNews(news) 142 } 143 pendingList = pendingList[:0] 144 } 145 146 for _, feedURL := range viper.GetStringSlice("feeds.urls") { 147 log.Printf("fetching %s...\n", feedURL) 148 fp := gofeed.NewParser() 149 feed, err := fp.ParseURL(feedURL) 150 if err != nil { 151 log.Printf("Failed to fetch feed '%s': %s", feedURL, err) 152 continue 153 } 154 155 i := 0 // number of posted news 156 for _, item := range feed.Items { 157 news := News{ 158 Origin: feed.Title, 159 Title: item.Title, 160 Link: item.Link, 161 Date: item.PublishedParsed.String(), 162 Hash: mkHash(item.Link), 163 } 164 // Check if item was already posted 165 if newsExists(news) { 166 log.Printf("already posted %s (%s)\n", item.Title, news.Hash) 167 continue 168 } 169 // don't paste news older than feeds.maxage 170 if time.Since(*item.PublishedParsed) > viper.GetDuration("feeds.maxage") { 171 log.Printf("news too old (%s)\n", item.Published) 172 continue 173 } 174 i++ 175 if i > viper.GetInt("feeds.maxnews") { 176 log.Println("too many lines to post") 177 break 178 } 179 180 if viper.GetBool("irc.secondary") { 181 // Hold the news for one whole cycle 182 pendingList = append(pendingList, news) 183 continue 184 } 185 186 ircMessage(client, channel, fmtNews(news)) 187 time.Sleep(viper.GetDuration("irc.delay")) 188 saveNews(news) 189 } 190 } 191 // save news list to disk to avoid repost when restarting 192 if err := f.Truncate(0); err != nil { 193 log.Fatal(err) 194 } 195 if _, err = f.Seek(0, 0); err != nil { 196 log.Fatal(err) 197 } 198 encoder := json.NewEncoder(f) 199 if err = encoder.Encode(newsList); err != nil { 200 ircMessage(client, channel, "could not write newsList") 201 } 202 time.Sleep(viper.GetDuration("feeds.frequency")) 203 } 204 } 205 206 func confDefault() { 207 kv := map[string]interface{}{ 208 "irc.server": "irc.libera.chat", 209 "irc.nick": "gruik", 210 "irc.channel": "goaste", 211 "irc.sasl": true, 212 "irc.xchannels": []string{"goaste2"}, 213 "irc.debug": false, 214 "irc.dry": false, 215 "irc.secondary": false, 216 "irc.port": 6667, 217 "irc.delay": "2s", 218 "irc.colors.origin": "pink", 219 "irc.colors.title": "bold", 220 "irc.colors.link": "lightblue", 221 "irc.colors.hash": "lightgrey", 222 "feeds.urls": []string{}, 223 "feeds.maxnews": 10, 224 "feeds.maxage": "1h", 225 "feeds.frequency": "10m", 226 "feeds.ringsize": 100, 227 } 228 229 for k, v := range kv { 230 if !viper.IsSet(k) { 231 viper.Set(k, v) 232 } 233 } 234 } 235 236 func isOp(nick string) bool { 237 for _, op := range viper.GetStringSlice("irc.ops") { 238 if nick == op { 239 return true 240 } 241 } 242 return false 243 } 244 245 // Get the second part of a command 246 func getParam(s string) string { 247 if !strings.Contains(s, " ") { 248 return "" 249 } 250 return s[strings.LastIndex(s, " ")+1:] 251 } 252 253 func main() { 254 config := "config" 255 if len(os.Args) > 1 { 256 config = os.Args[1] 257 } 258 259 viper.SetConfigName(config) 260 viper.SetConfigType("yaml") 261 viper.AddConfigPath(".") 262 viper.WatchConfig() 263 264 if err := viper.ReadInConfig(); err != nil { 265 log.Fatalf("Failed to read configuration file: %s", err) 266 } 267 268 nick := viper.GetString("irc.nick") 269 password := viper.GetString("irc.password") 270 name := nick 271 user := strings.ToLower(nick) 272 channel := viper.GetString("irc.channel") 273 debug := io.Discard 274 if viper.GetBool("irc.debug") { 275 debug = os.Stdout 276 } 277 278 confDefault() // load defaults for unset parameters 279 280 client := girc.New(girc.Config{ 281 Server: viper.GetString("irc.server"), 282 Nick: nick, 283 Port: viper.GetInt("irc.port"), 284 Debug: debug, 285 User: user, 286 Name: name, 287 AllowFlood: true, 288 }) 289 if len(password) > 0 { 290 if viper.GetBool("irc.sasl") == true { 291 client.Config.SASL = &girc.SASLPlain{ 292 User: user, 293 Pass: password, 294 } 295 } else { 296 client.Config.ServerPass = password 297 } 298 299 } 300 301 client.Handlers.Add(girc.CONNECTED, func(c *girc.Client, e girc.Event) { 302 if strings.HasPrefix(channel, "#") { 303 c.Cmd.Join(channel) 304 } 305 306 // join secondary channels for xposting 307 for _, xchan := range viper.GetStringSlice("irc.xchannels") { 308 if strings.HasPrefix(xchan, "#") { 309 c.Cmd.Join(xchan) 310 } 311 } 312 }) 313 client.Handlers.Add(girc.PRIVMSG, func(c *girc.Client, e girc.Event) { 314 dest := channel 315 316 if len(e.Params) > 0 && e.Params[0] != channel { 317 dest = e.Source.Name 318 } 319 320 if viper.GetBool("irc.secondary") { 321 for _, suffix, found := strings.Cut(e.Last(), "#"); found && len(suffix) >= 8; _, suffix, found = strings.Cut(suffix, "#") { 322 if strings.Trim(suffix[:8], "0123456789abcdef") == "" { 323 log.Printf("Received hash %s from %s", suffix[:8], e.Source.Name) 324 news := News{Hash: suffix[:8]} 325 if !newsExists(news) { 326 saveNews(news) 327 } 328 } 329 } 330 } 331 332 if strings.HasPrefix(e.Last(), "!lsfeeds") { 333 for i, f := range viper.GetStringSlice("feeds.urls") { 334 n := strconv.Itoa(i + 1) 335 ircMessage(c, dest, n+". "+f) 336 time.Sleep(viper.GetDuration("irc.delay")) 337 } 338 } 339 if strings.HasPrefix(e.Last(), "!xpost") && e.Params[0] == channel && !viper.GetBool("irc.secondary") { 340 requestedHash := getParam(e.Last()) 341 if requestedHash == "" { 342 return 343 } 344 if news := getNewsByHash(requestedHash); news.Hash != "" { 345 post := fmt.Sprintf(" {r}(from %s on %s)", e.Source.Name, channel) 346 message := fmtNews(news) + girc.Fmt(post) 347 for _, xchan := range viper.GetStringSlice("irc.xchannels") { 348 ircMessage(c, xchan, message) 349 time.Sleep(viper.GetDuration("irc.delay")) 350 } 351 } 352 } 353 if strings.HasPrefix(e.Last(), "!latest") && e.Params[0] != channel { 354 args := strings.SplitN(e.Last(), " ", 3) 355 if len(args) < 2 { 356 ircMessage(c, dest, "usage: !latest <number> [origin]") 357 time.Sleep(viper.GetDuration("irc.delay")) 358 return 359 } 360 361 // n == number of news to show 362 n, err := strconv.Atoi(args[1]) 363 if err != nil || n <= 0 { 364 ircMessage(c, dest, "conversion error") 365 time.Sleep(viper.GetDuration("irc.delay")) 366 return 367 } 368 369 // default to all news 370 showNews := newsList 371 numNews := len(showNews) 372 373 // there was a second parameter, specific origin 374 if len(args) > 2 { 375 showNews = getNewsByOrigin(args[2]) 376 numNews = len(showNews) 377 } 378 379 // check if some news are available 380 if numNews < 1 { 381 ircMessage(c, dest, "no news available") 382 time.Sleep(viper.GetDuration("irc.delay")) 383 return 384 } 385 386 // user gave a greater number that we have news 387 if n > numNews { 388 n = numNews 389 } 390 numNews-- 391 for i := 0; i < n; i++ { 392 fmt.Println(i) 393 ircMessage(c, dest, fmtNews(showNews[numNews-i])) 394 time.Sleep(viper.GetDuration("irc.delay")) 395 } 396 } 397 398 // All commands below requires OP 399 if !isOp(e.Source.Name) { 400 return 401 } 402 403 if strings.HasPrefix(e.Last(), "!die") { 404 c.Close() 405 } 406 if strings.HasPrefix(e.Last(), "!addfeed") { 407 url := getParam(e.Last()) 408 if url == "" { 409 return 410 } 411 feeds := viper.GetStringSlice("feeds.urls") 412 for _, feed := range feeds { 413 if feed == url { 414 return 415 } 416 } 417 feeds = append(feeds, url) 418 viper.Set("feeds.urls", feeds) 419 c.Cmd.ReplyTo(e, girc.Fmt("feed {b}{green}added{c}{b}")) 420 if err := viper.WriteConfig(); err != nil { 421 c.Cmd.ReplyTo(e, girc.Fmt("adding feed {b}{red}failed{c}{b}")) 422 } 423 } 424 if strings.HasPrefix(e.Last(), "!rmfeed") { 425 feeds := viper.GetStringSlice("feeds.urls") 426 s := getParam(e.Last()) 427 if s == "" { 428 return 429 } 430 i, err := strconv.Atoi(s) 431 if err != nil { 432 c.Cmd.ReplyTo(e, "index conversion failed") 433 return 434 } 435 if i < 1 || i > len(feeds) { 436 c.Cmd.ReplyTo(e, "bad index number") 437 return 438 } 439 feeds = append(feeds[:i-1], feeds[i:]...) 440 viper.Set("feeds.urls", feeds) 441 c.Cmd.ReplyTo(e, girc.Fmt("feed {b}{green}removed{c}{b}")) 442 if err := viper.WriteConfig(); err != nil { 443 c.Cmd.ReplyTo(e, girc.Fmt("removing feed {b}{red}failed{c}{b}")) 444 } 445 } 446 }) 447 448 go newsFetch(client, channel) 449 450 for { 451 if err := client.Connect(); err != nil { 452 log.Printf("Failed to connect to IRC server: %s", err) 453 log.Println("reconnecting in 30 seconds...") 454 time.Sleep(30 * time.Second) 455 } else { 456 return 457 } 458 } 459 }