subscribe.go 9.2 KB

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