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 | +} | |