subscribe.go 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  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. },
  29. Description: `Subscribe to a topic from a ntfy server, and either print or execute a command for
  30. every arriving message. There are 3 modes in which the command can be run:
  31. ntfy subscribe TOPIC
  32. This prints the JSON representation of every incoming message. It is useful when you
  33. have a command that wants to stream-read incoming JSON messages. Unless --poll is passed,
  34. this command stays open forever.
  35. Examples:
  36. ntfy subscribe mytopic # Prints JSON for incoming messages for ntfy.sh/mytopic
  37. ntfy sub home.lan/backups # Subscribe to topic on different server
  38. ntfy sub --poll home.lan/backups # Just query for latest messages and exit
  39. ntfy subscribe TOPIC COMMAND
  40. This executes COMMAND for every incoming messages. The message fields are passed to the
  41. command as environment variables:
  42. Variable Aliases Description
  43. --------------- --------------- -----------------------------------
  44. $NTFY_ID $id Unique message ID
  45. $NTFY_TIME $time Unix timestamp of the message delivery
  46. $NTFY_TOPIC $topic Topic name
  47. $NTFY_MESSAGE $message, $m Message body
  48. $NTFY_TITLE $title, $t Message title
  49. $NTFY_PRIORITY $priority, $p Message priority (1=min, 5=max)
  50. $NTFY_TAGS $tags, $ta Message tags (comma separated list)
  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. `,
  62. }
  63. func execSubscribe(c *cli.Context) error {
  64. fromConfig := c.Bool("from-config")
  65. if fromConfig {
  66. return execSubscribeFromConfig(c)
  67. }
  68. return execSubscribeWithoutConfig(c)
  69. }
  70. func execSubscribeFromConfig(c *cli.Context) error {
  71. conf, err := loadConfig(c)
  72. if err != nil {
  73. return err
  74. }
  75. cl := client.New(conf)
  76. commands := make(map[string]string)
  77. for _, s := range conf.Subscribe {
  78. topicURL := cl.Subscribe(s.Topic)
  79. commands[topicURL] = s.Exec
  80. }
  81. for m := range cl.Messages {
  82. command, ok := commands[m.TopicURL]
  83. if !ok {
  84. continue
  85. }
  86. _ = dispatchMessage(c, command, m)
  87. }
  88. return nil
  89. }
  90. func execSubscribeWithoutConfig(c *cli.Context) error {
  91. if c.NArg() < 1 {
  92. return errors.New("topic missing")
  93. }
  94. fmt.Fprintln(c.App.ErrWriter, "\x1b[1;33mThis command is incubating. The interface may change without notice.\x1b[0m")
  95. conf, err := loadConfig(c)
  96. if err != nil {
  97. return err
  98. }
  99. cl := client.New(conf)
  100. since := c.String("since")
  101. poll := c.Bool("poll")
  102. scheduled := c.Bool("scheduled")
  103. topic := c.Args().Get(0)
  104. command := c.Args().Get(1)
  105. var options []client.SubscribeOption
  106. if since != "" {
  107. options = append(options, client.WithSince(since))
  108. }
  109. if poll {
  110. options = append(options, client.WithPoll())
  111. }
  112. if scheduled {
  113. options = append(options, client.WithScheduled())
  114. }
  115. if poll {
  116. messages, err := cl.Poll(topic, options...)
  117. if err != nil {
  118. return err
  119. }
  120. for _, m := range messages {
  121. _ = dispatchMessage(c, command, m)
  122. }
  123. } else {
  124. cl.Subscribe(topic, options...)
  125. for m := range cl.Messages {
  126. _ = dispatchMessage(c, command, m)
  127. }
  128. }
  129. return nil
  130. }
  131. func dispatchMessage(c *cli.Context, command string, m *client.Message) error {
  132. if command != "" {
  133. return execCommand(c, command, m)
  134. }
  135. fmt.Println(m.Raw)
  136. return nil
  137. }
  138. func execCommand(c *cli.Context, command string, m *client.Message) error {
  139. if m.Event == client.OpenEvent {
  140. log.Printf("[%s] Connection opened, subscribed to topic", collapseTopicURL(m.TopicURL))
  141. } else if m.Event == client.MessageEvent {
  142. if err := runCommandInternal(c, command, m); err != nil {
  143. log.Printf("[%s] Command failed: %s", collapseTopicURL(m.TopicURL), err.Error())
  144. }
  145. }
  146. return nil
  147. }
  148. func runCommandInternal(c *cli.Context, command string, m *client.Message) error {
  149. scriptFile, err := createTmpScript(command)
  150. if err != nil {
  151. return err
  152. }
  153. defer os.Remove(scriptFile)
  154. log.Printf("[%s] Executing: %s (for message: %s)", collapseTopicURL(m.TopicURL), command, m.Raw)
  155. cmd := exec.Command("sh", "-c", scriptFile)
  156. cmd.Stdin = c.App.Reader
  157. cmd.Stdout = c.App.Writer
  158. cmd.Stderr = c.App.ErrWriter
  159. cmd.Env = envVars(m)
  160. return cmd.Run()
  161. }
  162. func createTmpScript(command string) (string, error) {
  163. scriptFile := fmt.Sprintf("%s/ntfy-subscribe-%s.sh.tmp", os.TempDir(), util.RandomString(10))
  164. script := fmt.Sprintf("#!/bin/sh\n%s", command)
  165. if err := os.WriteFile(scriptFile, []byte(script), 0700); err != nil {
  166. return "", err
  167. }
  168. return scriptFile, nil
  169. }
  170. func envVars(m *client.Message) []string {
  171. env := os.Environ()
  172. env = append(env, envVar(m.ID, "NTFY_ID", "id")...)
  173. env = append(env, envVar(m.Topic, "NTFY_TOPIC", "topic")...)
  174. env = append(env, envVar(fmt.Sprintf("%d", m.Time), "NTFY_TIME", "time")...)
  175. env = append(env, envVar(m.Message, "NTFY_MESSAGE", "message", "m")...)
  176. env = append(env, envVar(m.Title, "NTFY_TITLE", "title", "t")...)
  177. env = append(env, envVar(fmt.Sprintf("%d", m.Priority), "NTFY_PRIORITY", "priority", "prio", "p")...)
  178. env = append(env, envVar(strings.Join(m.Tags, ","), "NTFY_TAGS", "tags", "ta")...)
  179. return env
  180. }
  181. func envVar(value string, vars ...string) []string {
  182. env := make([]string, 0)
  183. for _, v := range vars {
  184. env = append(env, fmt.Sprintf("%s=%s", v, value))
  185. }
  186. return env
  187. }
  188. func loadConfig(c *cli.Context) (*client.Config, error) {
  189. filename := c.String("config")
  190. if filename != "" {
  191. return loadConfigFromFile(filename)
  192. }
  193. u, _ := user.Current()
  194. configFile := defaultClientRootConfigFile
  195. if u.Uid != "0" {
  196. configFile = util.ExpandHome(defaultClientUserConfigFile)
  197. }
  198. if s, _ := os.Stat(configFile); s != nil {
  199. return loadConfigFromFile(configFile)
  200. }
  201. return client.NewConfig(), nil
  202. }
  203. func loadConfigFromFile(filename string) (*client.Config, error) {
  204. b, err := os.ReadFile(filename)
  205. if err != nil {
  206. return nil, err
  207. }
  208. c := client.NewConfig()
  209. if err := yaml.Unmarshal(b, c); err != nil {
  210. return nil, err
  211. }
  212. return c, nil
  213. }