subscribe.go 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237
  1. package cmd
  2. import (
  3. "errors"
  4. "fmt"
  5. "github.com/urfave/cli/v2"
  6. "heckel.io/ntfy/client"
  7. "heckel.io/ntfy/util"
  8. "log"
  9. "os"
  10. "os/exec"
  11. "os/user"
  12. "strings"
  13. )
  14. var cmdSubscribe = &cli.Command{
  15. Name: "subscribe",
  16. Aliases: []string{"sub"},
  17. Usage: "Subscribe to one or more topics on a ntfy server",
  18. UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]",
  19. Action: execSubscribe,
  20. Flags: []cli.Flag{
  21. &cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
  22. &cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
  23. &cli.BoolFlag{Name: "from-config", Aliases: []string{"C"}, Usage: "read subscriptions from config file (service mode)"},
  24. &cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"},
  25. &cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"},
  26. &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "print verbose output"},
  27. },
  28. Description: `Subscribe to a topic from a ntfy server, and either print or execute a command for
  29. every arriving message. There are 3 modes in which the command can be run:
  30. ntfy subscribe TOPIC
  31. This prints the JSON representation of every incoming message. It is useful when you
  32. have a command that wants to stream-read incoming JSON messages. Unless --poll is passed,
  33. this command stays open forever.
  34. Examples:
  35. ntfy subscribe mytopic # Prints JSON for incoming messages for ntfy.sh/mytopic
  36. ntfy sub home.lan/backups # Subscribe to topic on different server
  37. ntfy sub --poll home.lan/backups # Just query for latest messages and exit
  38. ntfy subscribe TOPIC COMMAND
  39. This executes COMMAND for every incoming messages. The message fields are passed to the
  40. command as environment variables:
  41. Variable Aliases Description
  42. --------------- --------------------- -----------------------------------
  43. $NTFY_ID $id Unique message ID
  44. $NTFY_TIME $time Unix timestamp of the message delivery
  45. $NTFY_TOPIC $topic Topic name
  46. $NTFY_MESSAGE $message, $m Message body
  47. $NTFY_TITLE $title, $t Message title
  48. $NTFY_PRIORITY $priority, $prio, $p Message priority (1=min, 5=max)
  49. $NTFY_TAGS $tags, $tag, $ta Message tags (comma separated list)
  50. $NTFY_RAW $raw Raw JSON message
  51. Examples:
  52. ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages
  53. ntfy sub topic1 /my/script.sh # Execute script for incoming messages
  54. ntfy subscribe --from-config
  55. Service mode (used in ntfy-client.service). This reads the config file (/etc/ntfy/client.yml
  56. or ~/.config/ntfy/client.yml) and sets up subscriptions for every topic in the "subscribe:"
  57. block (see config file).
  58. Examples:
  59. ntfy sub --from-config # Read topics from config file
  60. ntfy sub --config=/my/client.yml --from-config # Read topics from alternate config file
  61. The default config file for all client commands is /etc/ntfy/client.yml (if root user),
  62. or ~/.config/ntfy/client.yml for all other users.`,
  63. }
  64. func execSubscribe(c *cli.Context) error {
  65. // Read config and options
  66. conf, err := loadConfig(c)
  67. if err != nil {
  68. return err
  69. }
  70. cl := client.New(conf)
  71. since := c.String("since")
  72. poll := c.Bool("poll")
  73. scheduled := c.Bool("scheduled")
  74. fromConfig := c.Bool("from-config")
  75. topic := c.Args().Get(0)
  76. command := c.Args().Get(1)
  77. if !fromConfig {
  78. conf.Subscribe = nil // wipe if --from-config not passed
  79. }
  80. var options []client.SubscribeOption
  81. if since != "" {
  82. options = append(options, client.WithSince(since))
  83. }
  84. if poll {
  85. options = append(options, client.WithPoll())
  86. }
  87. if scheduled {
  88. options = append(options, client.WithScheduled())
  89. }
  90. if topic == "" && len(conf.Subscribe) == 0 {
  91. return errors.New("must specify topic, type 'ntfy subscribe --help' for help")
  92. }
  93. // Execute poll or subscribe
  94. if poll {
  95. return doPoll(c, cl, conf, topic, command, options...)
  96. }
  97. return doSubscribe(c, cl, conf, topic, command, options...)
  98. }
  99. func doPoll(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error {
  100. for _, s := range conf.Subscribe { // may be nil
  101. if err := doPollSingle(c, cl, s.Topic, s.Command, options...); err != nil {
  102. return err
  103. }
  104. }
  105. if topic != "" {
  106. if err := doPollSingle(c, cl, topic, command, options...); err != nil {
  107. return err
  108. }
  109. }
  110. return nil
  111. }
  112. func doPollSingle(c *cli.Context, cl *client.Client, topic, command string, options ...client.SubscribeOption) error {
  113. messages, err := cl.Poll(topic, options...)
  114. if err != nil {
  115. return err
  116. }
  117. for _, m := range messages {
  118. printMessageOrRunCommand(c, m, command)
  119. }
  120. return nil
  121. }
  122. func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error {
  123. commands := make(map[string]string) // Subscription ID -> command
  124. for _, s := range conf.Subscribe { // May be nil
  125. topicOptions := append(make([]client.SubscribeOption, 0), options...)
  126. for filter, value := range s.If {
  127. topicOptions = append(topicOptions, client.WithFilter(filter, value))
  128. }
  129. subscriptionID := cl.Subscribe(s.Topic, topicOptions...)
  130. commands[subscriptionID] = s.Command
  131. }
  132. if topic != "" {
  133. subscriptionID := cl.Subscribe(topic, options...)
  134. commands[subscriptionID] = command
  135. }
  136. for m := range cl.Messages {
  137. command, ok := commands[m.SubscriptionID]
  138. if !ok {
  139. continue
  140. }
  141. printMessageOrRunCommand(c, m, command)
  142. }
  143. return nil
  144. }
  145. func printMessageOrRunCommand(c *cli.Context, m *client.Message, command string) {
  146. if command != "" {
  147. runCommand(c, command, m)
  148. } else {
  149. fmt.Fprintln(c.App.Writer, m.Raw)
  150. }
  151. }
  152. func runCommand(c *cli.Context, command string, m *client.Message) {
  153. if err := runCommandInternal(c, command, m); err != nil {
  154. fmt.Fprintf(c.App.ErrWriter, "Command failed: %s\n", err.Error())
  155. }
  156. }
  157. func runCommandInternal(c *cli.Context, command string, m *client.Message) error {
  158. scriptFile, err := createTmpScript(command)
  159. if err != nil {
  160. return err
  161. }
  162. defer os.Remove(scriptFile)
  163. verbose := c.Bool("verbose")
  164. if verbose {
  165. log.Printf("[%s] Executing: %s (for message: %s)", util.ShortTopicURL(m.TopicURL), command, m.Raw)
  166. }
  167. cmd := exec.Command("sh", "-c", scriptFile)
  168. cmd.Stdin = c.App.Reader
  169. cmd.Stdout = c.App.Writer
  170. cmd.Stderr = c.App.ErrWriter
  171. cmd.Env = envVars(m)
  172. return cmd.Run()
  173. }
  174. func createTmpScript(command string) (string, error) {
  175. scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.sh.tmp", os.TempDir(), util.RandomString(10))
  176. script := fmt.Sprintf("#!/bin/sh\n%s", command)
  177. if err := os.WriteFile(scriptFile, []byte(script), 0700); err != nil {
  178. return "", err
  179. }
  180. return scriptFile, nil
  181. }
  182. func envVars(m *client.Message) []string {
  183. env := os.Environ()
  184. env = append(env, envVar(m.ID, "NTFY_ID", "id")...)
  185. env = append(env, envVar(m.Topic, "NTFY_TOPIC", "topic")...)
  186. env = append(env, envVar(fmt.Sprintf("%d", m.Time), "NTFY_TIME", "time")...)
  187. env = append(env, envVar(m.Message, "NTFY_MESSAGE", "message", "m")...)
  188. env = append(env, envVar(m.Title, "NTFY_TITLE", "title", "t")...)
  189. env = append(env, envVar(fmt.Sprintf("%d", m.Priority), "NTFY_PRIORITY", "priority", "prio", "p")...)
  190. env = append(env, envVar(strings.Join(m.Tags, ","), "NTFY_TAGS", "tags", "tag", "ta")...)
  191. env = append(env, envVar(m.Raw, "NTFY_RAW", "raw")...)
  192. return env
  193. }
  194. func envVar(value string, vars ...string) []string {
  195. env := make([]string, 0)
  196. for _, v := range vars {
  197. env = append(env, fmt.Sprintf("%s=%s", v, value))
  198. }
  199. return env
  200. }
  201. func loadConfig(c *cli.Context) (*client.Config, error) {
  202. filename := c.String("config")
  203. if filename != "" {
  204. return client.LoadConfig(filename)
  205. }
  206. u, _ := user.Current()
  207. configFile := defaultClientRootConfigFile
  208. if u.Uid != "0" {
  209. configFile = util.ExpandHome(defaultClientUserConfigFile)
  210. }
  211. if s, _ := os.Stat(configFile); s != nil {
  212. return client.LoadConfig(configFile)
  213. }
  214. return client.NewConfig(), nil
  215. }