rss2hook.go 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. // rss2hook is a simple utility which will make HTTP POST
  2. // requests to remote web-hooks or execute commands when
  3. // new items appear in an RSS feed.
  4. //
  5. // Steve
  6. // poesty
  7. //
  8. package main
  9. import (
  10. "bytes"
  11. "encoding/json"
  12. "flag"
  13. "io"
  14. "log"
  15. "net/http"
  16. "os"
  17. "os/exec"
  18. "os/signal"
  19. "reflect"
  20. "syscall"
  21. "time"
  22. "github.com/SlyMarbo/rss"
  23. "github.com/robfig/cron"
  24. )
  25. // RSSEntry describes a single RSS feed and the corresponding hook
  26. type RSSEntry struct {
  27. // The URL of the RSS/Atom feed
  28. Url string `json:"url,omitempty"`
  29. // The hook method: webhook/command
  30. Method string `json:"method,omitempty"`
  31. // The retry count
  32. Retry int `json:"retry,omitempty"`
  33. // The hook end-point
  34. Hook string `json:"hook,omitempty"`
  35. }
  36. // Loaded contains the loaded feeds + hooks, as read from the specified
  37. // configuration file
  38. var Loaded []RSSEntry
  39. // Timeout is the (global) timeout we use when loading remote RSS
  40. // feeds.
  41. var Timeout time.Duration
  42. // loadConfig loads the named configuration file and populates our
  43. // `Loaded` list of RSS-feeds & Webhook addresses
  44. func loadConfig(filename string) {
  45. file, err := os.Open(filename)
  46. if err != nil {
  47. log.Fatalf("loadConfig: Error opening %s - %s\n", filename, err.Error())
  48. }
  49. defer file.Close()
  50. err = json.NewDecoder(file).Decode(&Loaded)
  51. if err != nil {
  52. log.Fatalf("loadConfig: Error decoding %s - %s\n", filename, err.Error())
  53. }
  54. log.Printf("Loaded %d feeds\n", len(Loaded))
  55. }
  56. // checkFeeds is our work-horse.
  57. //
  58. // For each available feed it looks for new entries, and when founds
  59. // triggers `notify` upon the resulting entry
  60. func checkFeeds(feeds []rss.Feed) {
  61. //
  62. // For each thing we're monitoring
  63. //
  64. for i, feed := range feeds {
  65. // skip empty feed, probably because:
  66. // 1. feed initialization failed
  67. // 2. retry count exceeded
  68. if reflect.ValueOf(feed).IsZero() {
  69. continue
  70. }
  71. // Fetch the feed-contents
  72. err := feed.Update()
  73. if err != nil {
  74. log.Printf("checkFeeds: Error fetching %s - %s\n",
  75. Loaded[i].Url, err.Error())
  76. if Loaded[i].Retry--; Loaded[i].Retry == 0 {
  77. log.Printf("checkFeeds: Temporally diable feed - %s\n", Loaded[i].Url)
  78. feeds[i] = rss.Feed{}
  79. }
  80. // log.Printf("checkFeeds: refresh time - %s\n", feed.Refresh.Format(time.RFC3339))
  81. continue
  82. }
  83. if feed.Unread != 0 {
  84. notify(i, feed.Items[len(feed.Items)-1])
  85. feed.Unread = 0
  86. }
  87. }
  88. }
  89. // notify submits the specified item to the remote webhook
  90. // or execute custom commands.
  91. // The RSS-item is submitted as a JSON-object.
  92. func notify(i int, item *rss.Item) {
  93. if Loaded[i].Method == "command" {
  94. cmd := exec.Command(Loaded[i].Hook)
  95. err := cmd.Start()
  96. if err != nil {
  97. log.Fatalf("notify: Failed to start command: %s\n", err.Error()) // TODO: retry?
  98. }
  99. // TODO: write outputs to files, and graceful shutdown
  100. } else if Loaded[i].Method == "webhook" {
  101. // We'll post the item as a JSON object.
  102. // So first of all encode it.
  103. jsonValue, err := json.Marshal(item)
  104. if err != nil {
  105. log.Fatalf("notify: Failed to encode JSON:%s\n", err.Error())
  106. }
  107. //
  108. // Post to the specified hook URL.
  109. //
  110. res, err := http.Post(Loaded[i].Hook,
  111. "application/json",
  112. bytes.NewBuffer(jsonValue))
  113. if err != nil {
  114. log.Printf("notify: Failed to POST to %s - %s\n",
  115. Loaded[i].Hook, err.Error()) // TODO: retry?
  116. return
  117. }
  118. //
  119. // OK now we've submitted the post.
  120. //
  121. // We should retrieve the status-code + body, if the status-code
  122. // is "odd" then we'll show them.
  123. //
  124. defer res.Body.Close()
  125. _, err = io.ReadAll(res.Body)
  126. if err != nil {
  127. log.Printf("notify: Failed to read response from %s - %s\n",
  128. Loaded[i].Hook, err.Error())
  129. return
  130. }
  131. status := res.StatusCode
  132. if status != 200 {
  133. log.Printf("notify: Warning - Status code was not 200: %d\n", status)
  134. }
  135. }
  136. }
  137. // main is our entry-point
  138. func main() {
  139. // Parse the command-line flags
  140. config := flag.String("config", "config.json", "The path to the configuration-file to read")
  141. timeout := flag.Duration("timeout", 5*time.Second, "The timeout used for fetching the remote feeds")
  142. schedule := flag.String("schedule", "@every 5m", "The cron schedule for fetching the remote feeds")
  143. flag.Parse()
  144. // Setup the default timeout and TTL
  145. // Remember that we respect spec, like `ttl` field in RSS 2.0
  146. rss.DefaultRefreshInterval = 10 * time.Second
  147. rss.DefaultFetchFunc = func(url string) (*http.Response, error) {
  148. client := &http.Client{Timeout: *timeout}
  149. return client.Get(url)
  150. }
  151. //
  152. // Load the configuration file
  153. //
  154. loadConfig(*config)
  155. //
  156. // Show the things we're monitoring
  157. //
  158. // for _, ent := range Loaded {
  159. // log.Printf("Monitoring feed %s\nPosting to %s\n\n",
  160. // ent.Feed, ent.Hook)
  161. // }
  162. //
  163. // Make the initial scan of feeds immediately to avoid waiting too
  164. // long for the first time.
  165. //
  166. feeds := make([]rss.Feed, len(Loaded))
  167. for i, ent := range Loaded {
  168. go func(i int, ent RSSEntry) {
  169. feed, err := rss.Fetch(ent.Url)
  170. if err != nil {
  171. log.Printf("main: Error fetching %s - %s\n",
  172. ent.Url, err.Error())
  173. return
  174. }
  175. // TODO: validiy checks (webhook/command)
  176. // feed.Unread = 0
  177. feeds[i] = *feed
  178. }(i, ent)
  179. }
  180. //
  181. // Now repeat that every five minutes or in custom schedule.
  182. //
  183. c := cron.New()
  184. c.AddFunc(*schedule, func() { checkFeeds(feeds) })
  185. c.Start()
  186. //
  187. // Now we can loop waiting to be terminated via ctrl-c, etc.
  188. //
  189. sigs := make(chan os.Signal, 1)
  190. done := make(chan bool, 1)
  191. signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
  192. go func() {
  193. <-sigs
  194. done <- true
  195. }()
  196. <-done
  197. }