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