subscribe.go 10 KB

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