Unified · Split
| @@ -0,0 +1 @@ | |||
| 1 | ircrss | ||
| @@ -0,0 +1 @@ | |||
| 1 | Public domain. | ||
| @@ -0,0 +1,14 @@ | |||
| 1 | # ircrss | ||
| 2 | |||
| 3 | An extremely simple RSS-to-IRC bot. | ||
| 4 | |||
| 5 | ## Running | ||
| 6 | |||
| 7 | Multiple URLs may be used. | ||
| 8 | |||
| 9 | ircrss -server irc.libera.chat \ | ||
| 10 | -channel '#my-channel' \ | ||
| 11 | -seen ~/my-channel-rss-links \ | ||
| 12 | -nick rssclanker \ | ||
| 13 | https://git.sr.ht/user/repo/log/rss.xml | ||
| 14 | ... | ||
| @@ -0,0 +1,16 @@ | |||
| 1 | module git.sr.ht/~ft/ircrss | ||
| 2 | |||
| 3 | go 1.26.5 | ||
| 4 | |||
| 5 | require ( | ||
| 6 | github.com/fluffle/goirc v1.3.5 | ||
| 7 | github.com/mmcdole/gofeed v1.4.0 | ||
| 8 | ) | ||
| 9 | |||
| 10 | require ( | ||
| 11 | github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect | ||
| 12 | github.com/golang/mock v1.6.0 // indirect | ||
| 13 | github.com/mmcdole/goxpp/v2 v2.0.0 // indirect | ||
| 14 | golang.org/x/net v0.57.0 // indirect | ||
| 15 | golang.org/x/text v0.40.0 // indirect | ||
| 16 | ) | ||
| @@ -0,0 +1,37 @@ | |||
| 1 | github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= | ||
| 2 | github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= | ||
| 3 | github.com/fluffle/goirc v1.3.5 h1:MKs1hzIwKfquqsCYk/U1Z7OFlQzgTJss/2ZlWckJXgM= | ||
| 4 | github.com/fluffle/goirc v1.3.5/go.mod h1:u5jzFHvmESPQ6pzcT/OVSKKd/WOh5pKDM28XJn15cO0= | ||
| 5 | github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= | ||
| 6 | github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= | ||
| 7 | github.com/mmcdole/gofeed v1.4.0 h1:+efDmI/yJXJgTfa8we5zg9GAKsU+2d7tnpt9QZwvjLQ= | ||
| 8 | github.com/mmcdole/gofeed v1.4.0/go.mod h1:ngV5MTB7UJko6fH3/fG5AkB/ABUGK1ZTePF9iRhzu/c= | ||
| 9 | github.com/mmcdole/goxpp/v2 v2.0.0 h1:HrSCflxerUEqZQNq3u7ldtmE/XkwnTx4Zpq2DW4i5rQ= | ||
| 10 | github.com/mmcdole/goxpp/v2 v2.0.0/go.mod h1:CUduYMnO9JB6Z/uqDn9Ormk/r8E9BsLQxHPWDZ961Os= | ||
| 11 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= | ||
| 12 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | ||
| 13 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | ||
| 14 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | ||
| 15 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= | ||
| 16 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= | ||
| 17 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= | ||
| 18 | golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= | ||
| 19 | golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= | ||
| 20 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||
| 21 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||
| 22 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||
| 23 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||
| 24 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||
| 25 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||
| 26 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||
| 27 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= | ||
| 28 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||
| 29 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||
| 30 | golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= | ||
| 31 | golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= | ||
| 32 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | ||
| 33 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= | ||
| 34 | golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= | ||
| 35 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||
| 36 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||
| 37 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||
| @@ -0,0 +1,113 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bufio" | ||
| 5 | "crypto/md5" | ||
| 6 | "errors" | ||
| 7 | "flag" | ||
| 8 | "fmt" | ||
| 9 | "io" | ||
| 10 | "log" | ||
| 11 | "os" | ||
| 12 | "sort" | ||
| 13 | "strings" | ||
| 14 | "time" | ||
| 15 | |||
| 16 | irc "github.com/fluffle/goirc/client" | ||
| 17 | "github.com/mmcdole/gofeed" | ||
| 18 | ) | ||
| 19 | |||
| 20 | var ( | ||
| 21 | server = flag.String("server", "", "IRC server") | ||
| 22 | channel = flag.String("channel", "", "IRC channel") | ||
| 23 | nick = flag.String("nick", "rssbot", "IRC nickname") | ||
| 24 | period = flag.String("period", "10m", "Update period") | ||
| 25 | seenPath = flag.String("seen", "", "\"Seen\" entries store") | ||
| 26 | ) | ||
| 27 | |||
| 28 | func main() { | ||
| 29 | flag.Parse() | ||
| 30 | if !flag.Parsed() || *server == "" || *channel == "" || *nick == "" || *seenPath == "" || len(flag.Args()) == 0 { | ||
| 31 | flag.Usage() | ||
| 32 | os.Exit(1) | ||
| 33 | } | ||
| 34 | |||
| 35 | updatePeriod, err := time.ParseDuration(*period) | ||
| 36 | if err != nil { | ||
| 37 | fmt.Fprintln(os.Stderr, "update period: %s", err) | ||
| 38 | os.Exit(1) | ||
| 39 | } | ||
| 40 | |||
| 41 | seenFile, err := os.OpenFile(*seenPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644) | ||
| 42 | if err != nil { | ||
| 43 | fmt.Fprintln(os.Stderr, "seen file: %s", err) | ||
| 44 | os.Exit(1) | ||
| 45 | } | ||
| 46 | defer seenFile.Close() | ||
| 47 | seenReader := bufio.NewReader(seenFile) | ||
| 48 | seen := make(map[[md5.Size]byte]struct{}) | ||
| 49 | for { | ||
| 50 | if url, err := seenReader.ReadString('\n'); err != nil { | ||
| 51 | if errors.Is(err, io.EOF) { | ||
| 52 | break | ||
| 53 | } | ||
| 54 | fmt.Fprintln(os.Stderr, "seen file: %s", err) | ||
| 55 | os.Exit(1) | ||
| 56 | } else { | ||
| 57 | url := []byte(strings.TrimSpace(url)) | ||
| 58 | seen[md5.Sum(url)] = struct{}{} | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | cfg := irc.NewConfig(*nick, "ircrssbot", "RSS bot") | ||
| 63 | c := irc.Client(cfg) | ||
| 64 | c.EnableStateTracking() | ||
| 65 | c.HandleFunc(irc.CONNECTED, func(*irc.Conn, *irc.Line) { | ||
| 66 | log.Printf("irc: connected") | ||
| 67 | c.Join(*channel) | ||
| 68 | }) | ||
| 69 | c.HandleFunc(irc.DISCONNECTED, func(*irc.Conn, *irc.Line) { | ||
| 70 | log.Printf("irc: disconnected") | ||
| 71 | os.Exit(1) | ||
| 72 | }) | ||
| 73 | |||
| 74 | joinedChan := make(chan bool, 1) | ||
| 75 | c.HandleFunc(irc.JOIN, func(c *irc.Conn, line *irc.Line) { | ||
| 76 | if c.Me().Nick == line.Nick { | ||
| 77 | log.Printf("irc: joined") | ||
| 78 | close(joinedChan) | ||
| 79 | } | ||
| 80 | }) | ||
| 81 | if err := c.ConnectTo(*server); err != nil { | ||
| 82 | fmt.Fprintln(os.Stderr, "irc: %s", err) | ||
| 83 | os.Exit(1) | ||
| 84 | } | ||
| 85 | |||
| 86 | <-joinedChan | ||
| 87 | for { | ||
| 88 | for _, url := range flag.Args() { | ||
| 89 | p := gofeed.NewParser() | ||
| 90 | if feed, err := p.ParseURL(url); err != nil { | ||
| 91 | log.Printf("%s: %s", url, err) | ||
| 92 | } else { | ||
| 93 | sort.Sort(feed) | ||
| 94 | for _, item := range feed.Items { | ||
| 95 | sum := md5.Sum([]byte(item.Link)) | ||
| 96 | if _, have := seen[sum]; !have { | ||
| 97 | if _, err := fmt.Fprintln(seenFile, item.Link); err != nil { | ||
| 98 | log.Printf("seen file: %s", err) | ||
| 99 | os.Exit(1) | ||
| 100 | } | ||
| 101 | seenFile.Sync() | ||
| 102 | s := fmt.Sprintf("%s | %s\n", item.Title, item.Link) | ||
| 103 | c.Privmsgln(*channel, s) | ||
| 104 | seen[sum] = struct{}{} | ||
| 105 | } | ||
| 106 | } | ||
| 107 | } | ||
| 108 | } | ||
| 109 | time.Sleep(updatePeriod) | ||
| 110 | } | ||
| 111 | log.Printf("quitting") | ||
| 112 | c.Quit() | ||
| 113 | } | ||