gruik

GCU-Squad's RSS-to-IRC bridge with proposed improvements
git clone https://git.instinctive.eu/gruik.git
Log | Files | Refs | README | LICENSE

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 }