Skip to content

Commit

Permalink
Call IFTTT Maker Webhooks when presence or absence is detected
Browse files Browse the repository at this point in the history
  • Loading branch information
douglaswth committed Jul 7, 2022
1 parent 781d7f7 commit c144651
Show file tree
Hide file tree
Showing 18 changed files with 479 additions and 9 deletions.
18 changes: 17 additions & 1 deletion cmd/presence/detect.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"net/http"
"os"
"os/signal"
"syscall"
Expand All @@ -9,6 +10,7 @@ import (
"goa.design/clue/log"

"douglasthrift.net/presence"
"douglasthrift.net/presence/ifttt"
"douglasthrift.net/presence/neighbors"
)

Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
}
Expand Down
58 changes: 54 additions & 4 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import (
"context"
"fmt"
"net"
"net/url"
"os"
"regexp"
"strings"
"time"

"goa.design/clue/log"
Expand All @@ -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) {
Expand Down Expand Up @@ -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()
Expand All @@ -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")
Expand All @@ -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 changes: 48 additions & 0 deletions config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
},
},
{
Expand All @@ -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,
},
},
},
},
{
Expand Down Expand Up @@ -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 {
Expand Down
17 changes: 15 additions & 2 deletions detector.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import (

"goa.design/clue/log"

"douglasthrift.net/presence/ifttt"
"douglasthrift.net/presence/neighbors"
)

type (
Detector interface {
Detect(ctx context.Context) error
Config(config *Config)
Client(client ifttt.Client)
}

detector struct {
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -77,3 +86,7 @@ func (d *detector) Config(config *Config) {
}
}
}

func (d *detector) Client(client ifttt.Client) {
d.client = client
}
5 changes: 4 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions ifttt/client.go
Original file line number Diff line number Diff line change
@@ -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
}

0 comments on commit c144651

Please sign in to comment.