package main import ( "bytes" "context" "fmt" "net/url" "os" "os/exec" "os/signal" "runtime" "strings" "syscall" "time" "github.com/denisbrodbeck/striphtmltags" log "github.com/go-pkgz/lgr" flags "github.com/jessevdk/go-flags" "git.lattuga.net/boyska/rss2twitter/app/rss" ) type opts struct { Refresh time.Duration `short:"r" long:"refresh" env:"REFRESH" default:"30s" description:"refresh interval"` TimeOut time.Duration `short:"t" long:"timeout" env:"TIMEOUT" default:"5s" description:"rss feed timeout"` Feed string `short:"f" long:"feed" env:"FEED" required:"true" description:"rss feed url"` Dry bool `long:"dry" env:"DRY" description:"dry mode"` Dbg bool `long:"dbg" env:"DEBUG" description:"debug mode"` Command struct { Name string Args []string } `positional-args:"yes"` } var revision = "unknown" type notifier interface { Go(ctx context.Context) <-chan rss.Event } type Publisher interface { Publish(event rss.Event, formatter func(rss.Event) string) error } // Stdout implements publisher.Interface and sends to stdout type Stdout struct{} // Publish to logger func (s Stdout) Publish(event rss.Event, formatter func(rss.Event) string) error { log.Printf("[INFO] event - %s", formatter(event)) return nil } type CommandPublisher struct { Command []string } func (p CommandPublisher) Publish(event rss.Event, formatter func(rss.Event) string) error { log.Printf("[INFO] lancio - %s ", strings.Join(p.Command, " ")) cmd := exec.Command(p.Command[0], p.Command[1:]...) enclosuresUrls := []string{} for _, enclosure := range event.Item.Enclosures { enclosuresUrls = append(enclosuresUrls, url.QueryEscape(enclosure.URL)) } cmd.Env = []string{"RSS=1", fmt.Sprintf("RSS_TITLE=%s", event.Title), fmt.Sprintf("RSS_GUID=%s", event.GUID), fmt.Sprintf("RSS_LINK=%s", event.Link), fmt.Sprintf("RSS_PUBDATE=%s", event.Item.Published), fmt.Sprintf("RSS_CATEGORIES=%s", strings.Join(event.Item.Categories, ";")), fmt.Sprintf("RSS_DESCRIPTION=%s", event.Item.Description), fmt.Sprintf("RSS_ENCLOSURES=%s", strings.Join(enclosuresUrls, ";")), // metadata that apply to the whole feed fmt.Sprintf("RSS_CHAN_TITLE=%s", event.Feed.Title), fmt.Sprintf("RSS_CHAN_LINK=%s", event.Feed.Link), fmt.Sprintf("RSS_CHAN_LANGUAGE=%s", event.Feed.Language), } if event.Item.Author != nil { cmd.Env = append(cmd.Env, fmt.Sprintf("RSS_AUTHOR=%s", event.Item.Author.Name), fmt.Sprintf("RSS_AUTHOR_EMAIL=%s", event.Item.Author.Email), ) } if event.Feed.Author != nil { cmd.Env = append(cmd.Env, fmt.Sprintf("RSS_CHAN_AUTHOR=%s", event.Feed.Author.Name)) } if event.Item.Image != nil { cmd.Env = append(cmd.Env, fmt.Sprintf("RSS_IMAGE=%s", event.Item.Image.URL)) } textbuf := bytes.NewBuffer([]byte(event.Item.Content)) cmd.Stdin = textbuf cmd.Stdout = os.Stderr cmdErr := cmd.Run() // TODO: move to goroutine? return cmdErr } func main() { fmt.Printf("rss2cmd - %s\n", revision) o := opts{} var err error if _, err = flags.Parse(&o); err != nil { os.Exit(2) } fmt.Println(o) fmt.Println(o.Command) if o.Command.Name != "" && o.Dry { fmt.Fprintln(os.Stderr, "Error: if you specify --dry you can't specify a command") os.Exit(2) } if o.Command.Name == "" && !o.Dry { fmt.Fprintln(os.Stderr, "Error: you must specify a command! (or use --dry)") os.Exit(2) } if o.Dbg { log.Setup(log.Debug) } notif, pub, err := setup(o) if err != nil { log.Printf("[PANIC] failed to setup, %v", err) } ctx, cancel := context.WithCancel(context.Background()) go func() { // catch SIGTERM signal and invoke graceful termination stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) <-stop log.Printf("[WARN] interrupt signal") cancel() }() do(ctx, notif, pub) log.Print("[INFO] terminated") } func setup(o opts) (n notifier, pub Publisher, err error) { n = &rss.Notify{Feed: o.Feed, Duration: o.Refresh, Timeout: o.TimeOut} if o.Dry { pub = Stdout{} } else { cmd := append([]string{o.Command.Name}, o.Command.Args...) pub = CommandPublisher{Command: cmd} fmt.Println(cmd) } return n, pub, nil } // do runs event loop getting rss events and publishing them func do(ctx context.Context, notif notifier, pub Publisher) { ch := notif.Go(ctx) for event := range ch { err := pub.Publish(event, func(r rss.Event) string { return "" }) if err != nil { log.Printf("[WARN] failed to publish, %s", err) } } } // format cleans text (removes html tags) and shrinks result func format(inp string, max int) string { res := striphtmltags.StripTags(inp) if len([]rune(res)) > max { snippet := []rune(res)[:max] // go back in snippet and found first space for i := len(snippet) - 1; i >= 0; i-- { if snippet[i] == ' ' { snippet = snippet[:i] break } } res = string(snippet) + " ..." } return res } // getDump reads runtime stack and returns as a string func getDump() string { maxSize := 5 * 1024 * 1024 stacktrace := make([]byte, maxSize) length := runtime.Stack(stacktrace, true) if length > maxSize { length = maxSize } return string(stacktrace[:length]) } func init() { // catch SIGQUIT and print stack traces sigChan := make(chan os.Signal) go func() { for range sigChan { log.Printf("[INFO] SIGQUIT detected, dump:\n%s", getDump()) } }() signal.Notify(sigChan, syscall.SIGQUIT) }