gruik

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

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 }