subscribe.go 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. package cmd
  2. import (
  3. "errors"
  4. "fmt"
  5. "github.com/urfave/cli/v2"
  6. "gopkg.in/yaml.v2"
  7. "heckel.io/ntfy/client"
  8. "heckel.io/ntfy/util"
  9. "log"
  10. "os"
  11. "os/exec"
  12. "os/user"
  13. "strings"
  14. )
  15. var cmdSubscribe = &cli.Command{
  16. Name: "subscribe",
  17. Aliases: []string{"sub"},
  18. Usage: "Subscribe to one or more topics on a ntfy server",
  19. UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]",
  20. Action: execSubscribe,
  21. Flags: []cli.Flag{
  22. &cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "config file"},
  23. &cli.StringFlag{Name: "exec", Aliases: []string{"e"}, Usage: "execute command for each message event"},
  24. &cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since (Unix timestamp, or all)"},
  25. &cli.BoolFlag{Name: "from-config", Aliases: []string{"C"}, Usage: "read subscriptions from config file (service mode)"},
  26. &cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"},
  27. &cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"},
  28. &cli.BoolFlag{Name: "verbose", Aliases: []string{"v"}, Usage: "print verbose output"},
  29. },
  30. Description: `Subscribe to a topic from a ntfy server, and either print or execute a command for
  31. every arriving message. There are 3 modes in which the command can be run:
  32. ntfy subscribe TOPIC
  33. This prints the JSON representation of every incoming message. It is useful when you
  34. have a command that wants to stream-read incoming JSON messages. Unless --poll is passed,
  35. this command stays open forever.
  36. Examples:
  37. ntfy subscribe mytopic # Prints JSON for incoming messages for ntfy.sh/mytopic
  38. ntfy sub home.lan/backups # Subscribe to topic on different server
  39. ntfy sub --poll home.lan/backups # Just query for latest messages and exit
  40. ntfy subscribe TOPIC COMMAND
  41. This executes COMMAND for every incoming messages. The message fields are passed to the
  42. command as environment variables:
  43. Variable Aliases Description
  44. --------------- --------------- -----------------------------------
  45. $NTFY_ID $id Unique message ID
  46. $NTFY_TIME $time Unix timestamp of the message delivery
  47. $NTFY_TOPIC $topic Topic name
  48. $NTFY_MESSAGE $message, $m Message body
  49. $NTFY_TITLE $title, $t Message title
  50. $NTFY_PRIORITY $priority, $p Message priority (1=min, 5=max)
  51. $NTFY_TAGS $tags, $ta Message tags (comma separated list)
  52. Examples:
  53. ntfy sub mytopic 'notify-send "$m"' # Execute command for incoming messages
  54. ntfy sub topic1 /my/script.sh # Execute script for incoming messages
  55. ntfy subscribe --from-config
  56. Service mode (used in ntfy-client.service). This reads the config file (/etc/ntfy/client.yml
  57. or ~/.config/ntfy/client.yml) and sets up subscriptions for every topic in the "subscribe:"
  58. block (see config file).
  59. Examples:
  60. ntfy sub --from-config # Read topics from config file
  61. ntfy sub --config=/my/client.yml --from-config # Read topics from alternate config file
  62. `,
  63. }
  64. func execSubscribe(c *cli.Context) error {
  65. fmt.Fprintln(c.App.ErrWriter, "\x1b[1;33mThis command is incubating. The interface may change without notice.\x1b[0m")
  66. // Read config and options
  67. conf, err := loadConfig(c)
  68. if err != nil {
  69. return err
  70. }
  71. cl := client.New(conf)
  72. since := c.String("since")
  73. poll := c.Bool("poll")
  74. scheduled := c.Bool("scheduled")
  75. fromConfig := c.Bool("from-config")
  76. topic := c.Args().Get(0)
  77. command := c.Args().Get(1)
  78. if !fromConfig {
  79. conf.Subscribe = nil // wipe if --from-config not passed
  80. }
  81. var options []client.SubscribeOption
  82. if since != "" {
  83. options = append(options, client.WithSince(since))
  84. }
  85. if poll {
  86. options = append(options, client.WithPoll())
  87. }
  88. if scheduled {
  89. options = append(options, client.WithScheduled())
  90. }
  91. if topic == "" && len(conf.Subscribe) == 0 {
  92. return errors.New("must specify topic, or have at least one topic defined in config")
  93. }
  94. // Execute poll or subscribe
  95. if poll {
  96. return execPoll(c, cl, conf, topic, command, options...)
  97. }
  98. return execSubscribeInternal(c, cl, conf, topic, command, options...)
  99. }
  100. func execPoll(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error {
  101. for _, s := range conf.Subscribe { // may be nil
  102. if err := execPollSingle(c, cl, s.Topic, s.Command, options...); err != nil {
  103. return err
  104. }
  105. }
  106. if topic != "" {
  107. if err := execPollSingle(c, cl, topic, command, options...); err != nil {
  108. return err
  109. }
  110. }
  111. return nil
  112. }
  113. func execPollSingle(c *cli.Context, cl *client.Client, topic, command string, options ...client.SubscribeOption) error {
  114. messages, err := cl.Poll(topic, options...)
  115. if err != nil {
  116. return err
  117. }
  118. for _, m := range messages {
  119. printMessageOrRunCommand(c, m, command)
  120. }
  121. return nil
  122. }
  123. func execSubscribeInternal(c *cli.Context, cl *client.Client, conf *client.Config, topic, command string, options ...client.SubscribeOption) error {
  124. commands := make(map[string]string)
  125. for _, s := range conf.Subscribe { // May be nil
  126. topicURL := cl.Subscribe(s.Topic, options...)
  127. commands[topicURL] = s.Command
  128. }
  129. if topic != "" {
  130. topicURL := cl.Subscribe(topic, options...)
  131. commands[topicURL] = command
  132. }
  133. for m := range cl.Messages {
  134. command, ok := commands[m.TopicURL]
  135. if !ok {
  136. continue
  137. }
  138. printMessageOrRunCommand(c, m, command)
  139. }
  140. return nil
  141. }
  142. func printMessageOrRunCommand(c *cli.Context, m *client.Message, command string) {
  143. if m.Event != client.MessageEvent {
  144. return
  145. }
  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)", collapseTopicURL(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", "ta")...)
  191. return env
  192. }
  193. func envVar(value string, vars ...string) []string {
  194. env := make([]string, 0)
  195. for _, v := range vars {
  196. env = append(env, fmt.Sprintf("%s=%s", v, value))
  197. }
  198. return env
  199. }
  200. func loadConfig(c *cli.Context) (*client.Config, error) {
  201. filename := c.String("config")
  202. if filename != "" {
  203. return loadConfigFromFile(filename)
  204. }
  205. u, _ := user.Current()
  206. configFile := defaultClientRootConfigFile
  207. if u.Uid != "0" {
  208. configFile = util.ExpandHome(defaultClientUserConfigFile)
  209. }
  210. if s, _ := os.Stat(configFile); s != nil {
  211. return loadConfigFromFile(configFile)
  212. }
  213. return client.NewConfig(), nil
  214. }
  215. func loadConfigFromFile(filename string) (*client.Config, error) {
  216. b, err := os.ReadFile(filename)
  217. if err != nil {
  218. return nil, err
  219. }
  220. c := client.NewConfig()
  221. if err := yaml.Unmarshal(b, c); err != nil {
  222. return nil, err
  223. }
  224. return c, nil
  225. }