Browse Source

Call IFTTT Maker Webhooks when presence or absence is detected

Douglas Thrift 1 year ago
parent
commit
c144651b88

+ 17 - 1
cmd/presence/detect.go

@@ -1,6 +1,7 @@
 package main
 
 import (
+	"net/http"
 	"os"
 	"os/signal"
 	"syscall"
@@ -9,6 +10,7 @@ import (
 	"goa.design/clue/log"
 
 	"douglasthrift.net/presence"
+	"douglasthrift.net/presence/ifttt"
 	"douglasthrift.net/presence/neighbors"
 )
 
@@ -30,8 +32,13 @@ func (d *Detect) Run(cli *CLI) error {
 		log.Fatal(ctx, err, log.KV{K: "msg", V: "error finding dependencies"})
 	}
 
+	client, err := ifttt.NewClient(http.DefaultClient, config.IFTTT.BaseURL, config.IFTTT.Key, config.IFTTT.Events.Present, config.IFTTT.Events.Absent, cli.Debug)
+	if err != nil {
+		log.Fatal(ctx, err, log.KV{K: "msg", V: "error creating IFTTT client"})
+	}
+
 	var (
-		detector = presence.NewDetector(config, arp)
+		detector = presence.NewDetector(config, arp, client)
 		ticker   = time.NewTicker(config.Interval)
 		stop     = make(chan os.Signal, 1)
 		reload   = make(chan os.Signal, 1)
@@ -79,9 +86,18 @@ func (d *Detect) Run(cli *CLI) error {
 			config, err = presence.ParseConfigWithContext(ctx, cli.Config, wNet)
 			if err != nil {
 				log.Error(ctx, err, log.KV{K: "msg", V: "error parsing config"}, log.KV{K: "config", V: cli.Config})
+			} else if client, err = ifttt.NewClient(http.DefaultClient, config.IFTTT.BaseURL, config.IFTTT.Key, config.IFTTT.Events.Present, config.IFTTT.Events.Absent, cli.Debug); err != nil {
+				log.Error(ctx, err, log.KV{K: "msg", V: "error creating IFTTT client"})
 			} else {
 				arp.Count(config.PingCount)
 				detector.Config(config)
+				detector.Client(client)
+
+				err = detector.Detect(ctx)
+				if err != nil {
+					log.Error(ctx, err, log.KV{K: "msg", V: "error detecting presence"})
+				}
+
 				ticker.Reset(config.Interval)
 			}
 		}

+ 54 - 4
config.go

@@ -4,7 +4,10 @@ import (
 	"context"
 	"fmt"
 	"net"
+	"net/url"
 	"os"
+	"regexp"
+	"strings"
 	"time"
 
 	"goa.design/clue/log"
@@ -19,7 +22,29 @@ type (
 		Interfaces   []string      `yaml:"interfaces"`
 		MACAddresses []string      `yaml:"mac_addresses"`
 		PingCount    uint          `yaml:"ping_count"`
+		IFTTT        IFTTT         `yaml:"ifttt"`
 	}
+
+	IFTTT struct {
+		BaseURL string `yaml:"base_url"`
+		Key     string `yaml:"key"`
+		Events  Events `yaml:"events"`
+	}
+
+	Events struct {
+		Present string `yaml:"present"`
+		Absent  string `yaml:"absent"`
+	}
+)
+
+const (
+	defaultBaseURL      = "https://maker.ifttt.com"
+	defaultPresentEvent = "presence_detected"
+	defaultAbsentEvent  = "absence_detected"
+)
+
+var (
+	eventName = regexp.MustCompile("^[_a-zA-Z]+$")
 )
 
 func ParseConfig(name string, wNet wrap.Net) (*Config, error) {
@@ -47,6 +72,7 @@ func ParseConfigWithContext(ctx context.Context, name string, wNet wrap.Net) (*C
 	} else if c.Interval == 0 {
 		c.Interval = 30 * time.Second
 	}
+	log.Print(ctx, log.KV{K: "msg", V: "interval"}, log.KV{K: "value", V: c.Interval})
 
 	if len(c.Interfaces) == 0 {
 		ifs, err := wNet.Interfaces()
@@ -66,6 +92,7 @@ func ParseConfigWithContext(ctx context.Context, name string, wNet wrap.Net) (*C
 			}
 		}
 	}
+	log.Print(ctx, log.KV{K: "msg", V: "interfaces"}, log.KV{K: "value", V: c.Interfaces})
 
 	if len(c.MACAddresses) == 0 {
 		return nil, fmt.Errorf("no MAC addresses")
@@ -84,15 +111,38 @@ func ParseConfigWithContext(ctx context.Context, name string, wNet wrap.Net) (*C
 		as[a] = true
 		c.MACAddresses[i] = a
 	}
+	log.Print(ctx, log.KV{K: "msg", V: "MAC addresses"}, log.KV{K: "value", V: c.MACAddresses})
 
 	if c.PingCount == 0 {
 		c.PingCount = 1
 	}
-
-	log.Print(ctx, log.KV{K: "msg", V: "interval"}, log.KV{K: "value", V: c.Interval})
-	log.Print(ctx, log.KV{K: "msg", V: "interfaces"}, log.KV{K: "value", V: c.Interfaces})
-	log.Print(ctx, log.KV{K: "msg", V: "MAC addresses"}, log.KV{K: "value", V: c.MACAddresses})
 	log.Print(ctx, log.KV{K: "msg", V: "ping count"}, log.KV{K: "value", V: c.PingCount})
 
+	if c.IFTTT.BaseURL == "" {
+		c.IFTTT.BaseURL = defaultBaseURL
+	} else if _, err := url.Parse(c.IFTTT.BaseURL); err != nil {
+		return nil, fmt.Errorf("IFTTT base URL: %w", err)
+	}
+	log.Print(ctx, log.KV{K: "msg", V: "IFTTT base URL"}, log.KV{K: "value", V: c.IFTTT.BaseURL})
+
+	if c.IFTTT.Key == "" {
+		return nil, fmt.Errorf("no IFTTT key")
+	}
+	log.Print(ctx, log.KV{K: "msg", V: "IFTTT key"}, log.KV{K: "value", V: strings.Repeat("*", len(c.IFTTT.Key))})
+
+	if c.IFTTT.Events.Present == "" {
+		c.IFTTT.Events.Present = defaultPresentEvent
+	} else if !eventName.MatchString(c.IFTTT.Events.Present) {
+		return nil, fmt.Errorf("invalid IFTTT present event name: %#v", c.IFTTT.Events.Present)
+	}
+	log.Print(ctx, log.KV{K: "msg", V: "IFTTT present event"}, log.KV{K: "value", V: c.IFTTT.Events.Present})
+
+	if c.IFTTT.Events.Absent == "" {
+		c.IFTTT.Events.Absent = defaultAbsentEvent
+	} else if !eventName.MatchString(c.IFTTT.Events.Absent) {
+		return nil, fmt.Errorf("invalid IFTTT absent event name: %#v", c.IFTTT.Events.Absent)
+	}
+	log.Print(ctx, log.KV{K: "msg", V: "IFTTT absent event"}, log.KV{K: "value", V: c.IFTTT.Events.Absent})
+
 	return c, nil
 }

+ 48 - 0
config_test.go

@@ -31,6 +31,14 @@ func TestParseConfig(t *testing.T) {
 				Interfaces:   []string{"eth0", "eth1"},
 				MACAddresses: []string{"00:00:00:00:00:0a", "00:00:00:00:00:0b"},
 				PingCount:    5,
+				IFTTT: IFTTT{
+					BaseURL: "https://example.com",
+					Key:     "abcdef123456",
+					Events: Events{
+						Present: "event_presence_detected",
+						Absent:  "event_absence_detected",
+					},
+				},
 			},
 		},
 		{
@@ -44,6 +52,14 @@ func TestParseConfig(t *testing.T) {
 				Interfaces:   []string{"eth0", "eth1", "lo"},
 				MACAddresses: []string{"00:00:00:00:00:01", "00:00:00:00:00:02"},
 				PingCount:    1,
+				IFTTT: IFTTT{
+					BaseURL: defaultBaseURL,
+					Key:     "xyz7890!@#",
+					Events: Events{
+						Present: defaultPresentEvent,
+						Absent:  defaultAbsentEvent,
+					},
+				},
 			},
 		},
 		{
@@ -101,6 +117,38 @@ func TestParseConfig(t *testing.T) {
 			},
 			err: "duplicate MAC address (00:00:00:00:00:0e)",
 		},
+		{
+			name: "invalid IFTTT base URL",
+			file: "invalid_ifttt_base_url.yml",
+			setup: func(wNet *mockwrap.Net) {
+				wNet.Mock.On("InterfaceByName", "eth0").Return(&net.Interface{}, nil)
+			},
+			err: `IFTTT base URL: parse "%": invalid URL escape "%"`,
+		},
+		{
+			name: "no IFTTT key",
+			file: "no_ifttt_key.yml",
+			setup: func(wNet *mockwrap.Net) {
+				wNet.Mock.On("InterfaceByName", "eth0").Return(&net.Interface{}, nil)
+			},
+			err: "no IFTTT key",
+		},
+		{
+			name: "invalid IFTTT present event name",
+			file: "invalid_ifttt_present_event_name.yml",
+			setup: func(wNet *mockwrap.Net) {
+				wNet.Mock.On("InterfaceByName", "eth0").Return(&net.Interface{}, nil)
+			},
+			err: `invalid IFTTT present event name: "$"`,
+		},
+		{
+			name: "invalid IFTTT absent event name",
+			file: "invalid_ifttt_absent_event_name.yml",
+			setup: func(wNet *mockwrap.Net) {
+				wNet.Mock.On("InterfaceByName", "eth0").Return(&net.Interface{}, nil)
+			},
+			err: `invalid IFTTT absent event name: "^"`,
+		},
 	}
 
 	for _, tc := range cases {

+ 15 - 2
detector.go

@@ -5,6 +5,7 @@ import (
 
 	"goa.design/clue/log"
 
+	"douglasthrift.net/presence/ifttt"
 	"douglasthrift.net/presence/neighbors"
 )
 
@@ -12,6 +13,7 @@ type (
 	Detector interface {
 		Detect(ctx context.Context) error
 		Config(config *Config)
+		Client(client ifttt.Client)
 	}
 
 	detector struct {
@@ -20,14 +22,16 @@ type (
 		interfaces neighbors.Interfaces
 		state      neighbors.State
 		states     neighbors.HardwareAddrStates
+		client     ifttt.Client
 	}
 )
 
-func NewDetector(config *Config, arp neighbors.ARP) Detector {
+func NewDetector(config *Config, arp neighbors.ARP, client ifttt.Client) Detector {
 	d := &detector{
 		arp:    arp,
 		state:  neighbors.NewState(),
 		states: make(neighbors.HardwareAddrStates, len(config.MACAddresses)),
+		client: client,
 	}
 	d.Config(config)
 	return d
@@ -47,7 +51,12 @@ func (d *detector) Detect(ctx context.Context) error {
 
 	log.Print(ctx, log.KV{K: "msg", V: "detected presence"}, log.KV{K: "present", V: d.state.Present()}, log.KV{K: "changed", V: d.state.Changed()})
 	if d.state.Changed() {
-		// TODO IFTTT
+		event, err := d.client.Trigger(ctx, d.state.Present())
+		if err != nil {
+			d.state.Reset()
+			return err
+		}
+		log.Print(ctx, log.KV{K: "msg", V: "triggered IFTTT"}, log.KV{K: "event", V: event})
 	}
 
 	return nil
@@ -77,3 +86,7 @@ func (d *detector) Config(config *Config) {
 		}
 	}
 }
+
+func (d *detector) Client(client ifttt.Client) {
+	d.client = client
+}

+ 4 - 1
go.mod

@@ -7,21 +7,24 @@ require (
 	github.com/magefile/mage v1.13.0
 	github.com/stretchr/testify v1.7.5
 	goa.design/clue v0.7.0
+	goa.design/goa/v3 v3.7.5
 	gopkg.in/yaml.v3 v3.0.1
 )
 
 require (
 	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/dimfeld/httptreemux/v5 v5.4.0 // indirect
 	github.com/go-logfmt/logfmt v0.5.1 // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
 	github.com/google/go-cmp v0.5.8 // indirect
+	github.com/google/uuid v1.3.0 // indirect
+	github.com/gorilla/websocket v1.5.0 // indirect
 	github.com/kr/text v0.2.0 // indirect
 	github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/stretchr/objx v0.4.0 // indirect
 	go.opentelemetry.io/otel v1.7.0 // indirect
 	go.opentelemetry.io/otel/trace v1.7.0 // indirect
-	goa.design/goa/v3 v3.7.5 // indirect
 	golang.org/x/net v0.0.0-20220531201128-c960675eff93 // indirect
 	golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect
 	golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect

+ 86 - 0
ifttt/client.go

@@ -0,0 +1,86 @@
+package ifttt
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+
+	goahttp "goa.design/goa/v3/http"
+)
+
+type (
+	Client interface {
+		Trigger(ctx context.Context, present bool) (event string, err error)
+	}
+
+	client struct {
+		c                         *http.Client
+		presentEvent, absentEvent string
+		presentURL, absentURL     *url.URL
+		debug                     bool
+	}
+)
+
+func NewClient(c *http.Client, baseURL, key, presentEvent, absentEvent string, debug bool) (Client, error) {
+	u, err := url.Parse(baseURL)
+	if err != nil {
+		return nil, err
+	}
+	presentURL, absentURL := *u, *u
+	presentURL.Path = "/trigger/" + presentEvent + "/with/key/" + key
+	absentURL.Path = "/trigger/" + absentEvent + "/with/key/" + key
+
+	return &client{
+		c:            c,
+		presentEvent: presentEvent,
+		absentEvent:  absentEvent,
+		presentURL:   &presentURL,
+		absentURL:    &absentURL,
+		debug:        debug,
+	}, nil
+}
+
+func (c *client) Trigger(ctx context.Context, present bool) (event string, err error) {
+	var u *url.URL
+	if present {
+		event = c.presentEvent
+		u = c.presentURL
+	} else {
+		event = c.absentEvent
+		u = c.absentURL
+	}
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), nil)
+	if err != nil {
+		return
+	}
+
+	doer := goahttp.Doer(c.c)
+	if c.debug {
+		doer = goahttp.NewDebugDoer(doer)
+	}
+
+	resp, err := doer.Do(req)
+	if err != nil {
+		return
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		var b []byte
+		b, err = io.ReadAll(resp.Body)
+		if err != nil {
+			err = fmt.Errorf("%v: <failed to read body: %w>", resp.Status, err)
+			return
+		} else if len(b) == 0 {
+			b = []byte("<empty body>")
+		}
+
+		err = fmt.Errorf("%v: %s", resp.Status, b)
+		return
+	}
+
+	return
+}

+ 157 - 0
ifttt/client_test.go

@@ -0,0 +1,157 @@
+package ifttt
+
+import (
+	"context"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"goa.design/clue/log"
+)
+
+const (
+	presentEvent = "presence_detected"
+	absentEvent  = "absence_detected"
+)
+
+func TestNewClient(t *testing.T) {
+	t.Run("invalid base URL", func(t *testing.T) {
+		_, err := NewClient(http.DefaultClient, "%", "key", presentEvent, absentEvent, false)
+		assert.ErrorContains(t, err, `parse "%": invalid URL escape "%"`)
+	})
+}
+
+func TestClient_Trigger(t *testing.T) {
+	ctx := log.Context(context.Background(), log.WithDebug())
+
+	cases := []struct {
+		name, key, event, err string
+		ctx                   context.Context
+		present, noDebug      bool
+		handler               func(t *testing.T) http.HandlerFunc
+	}{
+		{
+			name:    "preset",
+			key:     "key",
+			ctx:     ctx,
+			present: true,
+			handler: func(t *testing.T) http.HandlerFunc {
+				return func(w http.ResponseWriter, r *http.Request) {
+					assert := assert.New(t)
+
+					assert.Equal(http.MethodPost, r.Method)
+					assert.Equal("/trigger/"+presentEvent+"/with/key/key", r.URL.Path)
+				}
+			},
+			event: presentEvent,
+		},
+		{
+			name:    "absent",
+			key:     "key",
+			ctx:     ctx,
+			present: false,
+			handler: func(t *testing.T) http.HandlerFunc {
+				return func(w http.ResponseWriter, r *http.Request) {
+					assert := assert.New(t)
+
+					assert.Equal(http.MethodPost, r.Method)
+					assert.Equal("/trigger/"+absentEvent+"/with/key/key", r.URL.Path)
+				}
+			},
+			event: absentEvent,
+		},
+		{
+			name: "nil context",
+			ctx:  nil,
+			key:  "key",
+			handler: func(t *testing.T) http.HandlerFunc {
+				return func(w http.ResponseWriter, r *http.Request) {}
+			},
+			err: "net/http: nil Context",
+		},
+		{
+			name: "closed connection",
+			ctx:  ctx,
+			key:  "key",
+			handler: func(t *testing.T) http.HandlerFunc {
+				return func(w http.ResponseWriter, r *http.Request) {
+					assert := assert.New(t)
+
+					hj, ok := w.(http.Hijacker)
+					if !assert.Equal(true, ok) {
+						assert.FailNow("server doesn't support hijacking")
+					}
+
+					conn, _, err := hj.Hijack()
+					if !assert.NoError(err) {
+						assert.FailNow("error hijacking")
+					}
+
+					conn.Close()
+				}
+			},
+			err: "EOF",
+		},
+		{
+			name: "unauthorized",
+			ctx:  ctx,
+			key:  "key",
+			handler: func(t *testing.T) http.HandlerFunc {
+				return func(w http.ResponseWriter, r *http.Request) {
+					w.WriteHeader(http.StatusUnauthorized)
+					_, _ = w.Write([]byte(`{"errors":[{"message":"You sent an invalid key."}]}`))
+				}
+			},
+			err: `401 Unauthorized: {"errors":[{"message":"You sent an invalid key."}]}`,
+		},
+		{
+			name: "empty body",
+			ctx:  ctx,
+			key:  "key",
+			handler: func(t *testing.T) http.HandlerFunc {
+				return func(w http.ResponseWriter, r *http.Request) {
+					w.WriteHeader(http.StatusInternalServerError)
+				}
+			},
+			err: "500 Internal Server Error: <empty body>",
+		},
+		{
+			name:    "failed to read body",
+			ctx:     ctx,
+			key:     "key",
+			noDebug: true, // goahttp.DebugDoer interferes with this test
+			handler: func(t *testing.T) http.HandlerFunc {
+				return func(w http.ResponseWriter, r *http.Request) {
+					w.Header().Set("Content-Length", "1")
+					w.WriteHeader(http.StatusBadGateway)
+				}
+			},
+			err: "502 Bad Gateway: <failed to read body: unexpected EOF>",
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			assert := assert.New(t)
+
+			ts := httptest.NewTLSServer(tc.handler(t))
+			defer ts.Close()
+
+			c, err := NewClient(ts.Client(), ts.URL, tc.key, presentEvent, absentEvent, !tc.noDebug)
+			assert.NoError(err)
+
+			event, err := c.Trigger(tc.ctx, tc.present)
+			if tc.err != "" {
+				assert.ErrorContains(err, tc.err)
+			} else {
+				assert.NoError(err)
+				assert.Equal(tc.event, event)
+			}
+		})
+	}
+}

+ 50 - 0
ifttt/mocks/client.go

@@ -0,0 +1,50 @@
+// Code generated by mockery v2.14.0. DO NOT EDIT.
+
+package mocks
+
+import (
+	context "context"
+
+	mock "github.com/stretchr/testify/mock"
+)
+
+// Client is an autogenerated mock type for the Client type
+type Client struct {
+	mock.Mock
+}
+
+// Trigger provides a mock function with given fields: ctx, present
+func (_m *Client) Trigger(ctx context.Context, present bool) (string, error) {
+	ret := _m.Called(ctx, present)
+
+	var r0 string
+	if rf, ok := ret.Get(0).(func(context.Context, bool) string); ok {
+		r0 = rf(ctx, present)
+	} else {
+		r0 = ret.Get(0).(string)
+	}
+
+	var r1 error
+	if rf, ok := ret.Get(1).(func(context.Context, bool) error); ok {
+		r1 = rf(ctx, present)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+type mockConstructorTestingTNewClient interface {
+	mock.TestingT
+	Cleanup(func())
+}
+
+// NewClient creates a new instance of Client. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+func NewClient(t mockConstructorTestingTNewClient) *Client {
+	mock := &Client{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 1 - 1
magefiles/magefile.go

@@ -7,7 +7,7 @@ import (
 var (
 	Default = Build // nolint: deadcode
 
-	packagesToMock = []string{"neighbors", "wrap"}
+	packagesToMock = []string{"ifttt", "neighbors", "wrap"}
 )
 
 // Generate generates mock implementations of interfaces.

+ 5 - 0
neighbors/mocks/state.go

@@ -37,6 +37,11 @@ func (_m *State) Present() bool {
 	return r0
 }
 
+// Reset provides a mock function with given fields:
+func (_m *State) Reset() {
+	_m.Called()
+}
+
 // Set provides a mock function with given fields: present
 func (_m *State) Set(present bool) {
 	_m.Called(present)

+ 5 - 0
neighbors/state.go

@@ -5,6 +5,7 @@ type (
 		Present() bool
 		Changed() bool
 		Set(present bool)
+		Reset()
 	}
 
 	state struct {
@@ -34,3 +35,7 @@ func (s *state) Set(present bool) {
 		s.present = present
 	}
 }
+
+func (s *state) Reset() {
+	s.initial = true
+}

+ 6 - 0
neighbors/state_test.go

@@ -133,3 +133,9 @@ func TestState_Set(t *testing.T) {
 		})
 	}
 }
+
+func TestState_Reset(t *testing.T) {
+	s := &state{initial: false}
+	s.Reset()
+	assert.Equal(t, &state{initial: true}, s)
+}

+ 2 - 0
tests/defaults.yml

@@ -1,3 +1,5 @@
 mac_addresses:
   - 00:00:00:00:00:01
   - 00-00-00-00-00-02
+ifttt:
+  key: xyz7890!@#

+ 7 - 0
tests/invalid_ifttt_absent_event_name.yml

@@ -0,0 +1,7 @@
+interfaces: [eth0]
+mac_addresses:
+  - 00:00:00:00:00:12
+ifttt:
+  key: abcdef123456
+  events:
+    absent: ^

+ 6 - 0
tests/invalid_ifttt_base_url.yml

@@ -0,0 +1,6 @@
+interfaces: [eth0]
+mac_addresses:
+  - 00:00:00:00:00:10
+ifttt:
+  base_url: "%"
+  key: abcdef123456

+ 7 - 0
tests/invalid_ifttt_present_event_name.yml

@@ -0,0 +1,7 @@
+interfaces: [eth0]
+mac_addresses:
+  - 00:00:00:00:00:12
+ifttt:
+  key: abcdef123456
+  events:
+    present: $

+ 3 - 0
tests/no_ifttt_key.yml

@@ -0,0 +1,3 @@
+interfaces: [eth0]
+mac_addresses:
+  - 00:00:00:00:00:11

+ 6 - 0
tests/success.yml

@@ -4,3 +4,9 @@ mac_addresses:
   - 00:00:00:00:00:0a
   - 00-00-00-00-00-0b
 ping_count: 5
+ifttt:
+  base_url: https://example.com
+  key: abcdef123456
+  events:
+    present: event_presence_detected
+    absent: event_absence_detected