hswaw/smsgw: implement

The SMS gateway service allows consumers to subscribe to SMS messages
received by a Twilio phone number.

This is useful for receiving SMS auth messages.

Change-Id: Ib02a4306ad0d856dd10c7ca9241d9163809e7084
diff --git a/hswaw/smsgw/main.go b/hswaw/smsgw/main.go
new file mode 100644
index 0000000..a0a6a07
--- /dev/null
+++ b/hswaw/smsgw/main.go
@@ -0,0 +1,226 @@
+package main
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"net/http"
+	"regexp"
+	"strings"
+	"time"
+
+	"code.hackerspace.pl/hscloud/go/mirko"
+	"github.com/golang/glog"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+
+	pb "code.hackerspace.pl/hscloud/hswaw/smsgw/proto"
+)
+
+var (
+	flagTwilioSID           string
+	flagTwilioToken         string
+	flagTwilioFriendlyPhone string
+
+	flagWebhookListen string
+	flagWebhookPublic string
+)
+
+func init() {
+	flag.Set("logtostderr", "true")
+}
+
+type server struct {
+	dispatcher *dispatcher
+}
+
+func ourPhoneNumber(ctx context.Context, t *twilio, friendly string) (*incomingPhoneNumber, error) {
+	ipn, err := t.getIncomingPhoneNumbers(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, pn := range ipn {
+		if pn.FriendlyName == friendly {
+			return &pn, nil
+		}
+	}
+
+	return nil, fmt.Errorf("requested phone number %q not in list", friendly)
+}
+
+func ensureWebhook(ctx context.Context, t *twilio) {
+	pn, err := ourPhoneNumber(ctx, t, flagTwilioFriendlyPhone)
+	if err != nil {
+		glog.Exitf("could not get our phone number: %v", err)
+	}
+
+	url := fmt.Sprintf("%ssms", flagWebhookPublic)
+
+	// first setup.
+	if pn.SMSMethod != "POST" || pn.SMSURL != url {
+		glog.Infof("Updating webhook (is %s %q, want %s %q)", pn.SMSMethod, pn.SMSURL, "POST", url)
+		if err := t.updateIncomingPhoneNumberSMSWebhook(ctx, pn.SID, "POST", url); err != nil {
+			glog.Exitf("could not set webhook: %v")
+		}
+
+		// try again to check that it's actually set
+		for {
+			pn, err = ourPhoneNumber(ctx, t, flagTwilioFriendlyPhone)
+			if err != nil {
+				glog.Exitf("could not get our phone number: %v", err)
+			}
+			if pn.SMSMethod == "POST" || pn.SMSURL == url {
+				break
+			}
+			glog.Infof("Webhook not yet ready, currently %s %q", pn.SMSMethod, pn.SMSURL)
+			time.Sleep(5 * time.Second)
+		}
+		glog.Infof("Webhook verifier")
+	} else {
+		glog.Infof("Webhook up to date")
+	}
+
+	// now keep checking to make sure that nobody takes over our webhook
+	tick := time.NewTicker(30 * time.Second)
+	for {
+		select {
+		case <-ctx.Done():
+			return
+		case <-tick.C:
+			pn, err = ourPhoneNumber(ctx, t, flagTwilioFriendlyPhone)
+			if err != nil {
+				glog.Exitf("could not get our phone number: %v", err)
+			}
+			if pn.SMSMethod != "POST" || pn.SMSURL != url {
+				glog.Exitf("Webhook got deconfigured, not %s %q", pn.SMSMethod, pn.SMSURL)
+			}
+		}
+	}
+}
+
+func (s *server) webhookHandler(w http.ResponseWriter, r *http.Request) {
+	if err := r.ParseForm(); err != nil {
+		glog.Errorf("webhook body parse error: %v", err)
+		return
+	}
+
+	accountSID := r.PostForm.Get("AccountSid")
+	if accountSID != flagTwilioSID {
+		glog.Errorf("webhook got wrong account sid, got %q, wanted %q", accountSID, flagTwilioSID)
+		return
+	}
+
+	body := r.PostForm.Get("Body")
+	if body == "" {
+		return
+	}
+
+	from := r.PostForm.Get("From")
+
+	glog.Infof("Got SMS from %q, body %q", from, body)
+
+	s.dispatcher.publish(&sms{
+		from:      from,
+		body:      body,
+		timestamp: time.Now(),
+	})
+
+	w.WriteHeader(200)
+}
+
+func main() {
+	flag.StringVar(&flagTwilioSID, "twilio_sid", "", "Twilio account SID")
+	flag.StringVar(&flagTwilioToken, "twilio_token", "", "Twilio auth token")
+	flag.StringVar(&flagTwilioFriendlyPhone, "twilio_friendly_phone", "", "Twilio friendly phone number")
+
+	flag.StringVar(&flagWebhookListen, "webhook_listen", "127.0.0.1:5000", "Listen address for webhook handler")
+	flag.StringVar(&flagWebhookPublic, "webhook_public", "", "Public address for webhook handler (wg. http://proxy.q3k.org/smsgw/)")
+	flag.Parse()
+
+	if flagTwilioSID == "" || flagTwilioToken == "" {
+		glog.Exitf("twilio_sid and twilio_token must be set")
+	}
+
+	if flagTwilioFriendlyPhone == "" {
+		glog.Exitf("twilio_friendly_phone must be set")
+	}
+
+	if flagWebhookPublic == "" {
+		glog.Exitf("webhook_public must be set")
+	}
+
+	if !strings.HasSuffix(flagWebhookPublic, "/") {
+		flagWebhookPublic += "/"
+	}
+
+	s := &server{
+		dispatcher: newDispatcher(),
+	}
+
+	m := mirko.New()
+	if err := m.Listen(); err != nil {
+		glog.Exitf("Listen(): %v", err)
+	}
+
+	webhookMux := http.NewServeMux()
+	webhookMux.HandleFunc("/sms", s.webhookHandler)
+	webhookSrv := http.Server{
+		Addr:    flagWebhookListen,
+		Handler: webhookMux,
+	}
+	go func() {
+		if err := webhookSrv.ListenAndServe(); err != nil {
+			glog.Exitf("webhook ListenAndServe: %v", err)
+		}
+	}()
+
+	t := &twilio{
+		accountSID:   flagTwilioSID,
+		accountToken: flagTwilioToken,
+	}
+	go ensureWebhook(m.Context(), t)
+	go s.dispatcher.run(m.Context())
+
+	pb.RegisterSMSGatewayServer(m.GRPC(), s)
+
+	if err := m.Serve(); err != nil {
+		glog.Exitf("Serve(): %v", err)
+	}
+
+	<-m.Done()
+}
+
+func (s *server) Messages(req *pb.MessagesRequest, stream pb.SMSGateway_MessagesServer) error {
+	re := regexp.MustCompile(".*")
+	if req.FilterBody != "" {
+		var err error
+		re, err = regexp.Compile(req.FilterBody)
+		if err != nil {
+			return status.Errorf(codes.InvalidArgument, "filter regexp error: %v", err)
+		}
+	}
+
+	data := make(chan *sms)
+	cancel := make(chan struct{})
+	defer func() {
+		close(cancel)
+		close(data)
+	}()
+
+	s.dispatcher.subscribe(&subscriber{
+		re:     re,
+		data:   data,
+		cancel: cancel,
+	})
+
+	for d := range data {
+		stream.Send(&pb.MessagesResponse{
+			Sender:    d.from,
+			Body:      d.body,
+			Timestamp: d.timestamp.UnixNano(),
+		})
+	}
+
+	return nil
+}