Skip to content

Instantly share code, notes, and snippets.

@mndrix
Last active June 23, 2020 17:20
Show Gist options
  • Save mndrix/6966f79e0834fbfb1926f9c6afb6e22e to your computer and use it in GitHub Desktop.
Save mndrix/6966f79e0834fbfb1926f9c6afb6e22e to your computer and use it in GitHub Desktop.
SMS over IRC
// A proxy for sending/receiving SMS via IRC
//
// This code is part of our family IRC server whose code is available at
// https://gist.github.com/mndrix/7947009178e4a18c247b4bd25821661f
//
// This file won't compile by itself because it's only one file from
// my larger family server (movie hosting, Asterisk dialplan, Git
// hosting, personal assistant, etc).
//
// Copyright 2018 Michael Hendricks
//
// Permission is granted to use this work for any purpose with or without
// fee, and with or without attribution.
package main
// code in this file translates between our SMS provider (currently
// Twilio) and our internal IRC network
import (
"fmt"
"log"
"regexp"
"sync"
)
var michaelRealPhone Phone
var smsMtx sync.Mutex
var smsOrigin = make(map[string]Message) // Twilio message ID -> IRC Message
var nick2phone map[string]Phone
var phone2nick map[Phone]string
func init() {
michaelRealPhone, _ = ParsePhone("307-555-1212")
aliases := map[string]string{
"dad": "303-555-1212",
"mom": "720-555-1212",
"sheryl": "307-555-1111",
}
nick2phone = make(map[string]Phone)
phone2nick = make(map[Phone]string)
for nick, num := range aliases {
phone, ok := ParsePhone(num)
if !ok {
panic("Bad alias number: " + num)
}
nick2phone[nick] = phone
phone2nick[phone] = nick
}
}
type Phone interface {
E164() string
String() string
}
// North American Numbering Plan
type nanp struct {
area string
exchange string
num string
}
type Sms struct {
To Phone // number to which message was/will-be sent
From Phone // number that sent/is-sending the message
Body string // content of the message
}
func (n nanp) E164() string {
return fmt.Sprintf("+1%s%s%s", n.area, n.exchange, n.num)
}
func (n nanp) String() string {
return fmt.Sprintf("%s-%s-%s", n.area, n.exchange, n.num)
}
var rxPunct = regexp.MustCompile(`[-+.() ]`)
var rxNanp = regexp.MustCompile(`^(1?)(\d{3})(\d{3})(\d{4})$`)
// ParsePhone returns a representation of the given phone number, or
// false if the number is invalid.
func ParsePhone(raw string) (num Phone, ok bool) {
raw = rxPunct.ReplaceAllString(raw, "")
if matches := rxNanp.FindStringSubmatch(raw); len(matches) > 0 {
ok = true
num = nanp{matches[2], matches[3], matches[4]}
} else if num, ok = nick2phone[raw]; ok {
// aliased IRC nicks parse as the corresponding number
return
}
return
}
// code in twilio.go calls SmsReceive when an SMS arrives so we can convert it into an IRC message
func SmsReceive(from Phone, to Phone, text string) {
log.Printf("SmsReceive: %s -> %s, %q", from.String(), to.String(), text)
nick := from.String()
if n, ok := phone2nick[from]; ok {
nick = n
}
cl := &client{nick: nick, device: "sms"}
msg := NewMessage("PRIVMSG", "michael", text).From(cl)
commandPrivmsg(cl, msg)
}
// IRC server calls this when I send a message to a nick that looks like a phone number
// or a nick that's aliased to a phone number
func SmsSend(msg Message, phone Phone, text string) {
id, err := TwilioSmsSend(&Sms{
To: phone,
From: michaelRealPhone,
Body: text,
})
if err != nil {
log.Printf("Error sending SMS: %s", err)
return
}
log.Printf("mapping %s to %s for response", id, msg.from.nick)
smsMtx.Lock()
defer smsMtx.Unlock()
smsOrigin[id] = msg
}
// send IRC message about the status of an outgoing SMS
func SmsIs(id string, status string) {
smsMtx.Lock()
defer smsMtx.Unlock()
icon := ""
if status == "sent" {
icon = "\u231b" // hourglass
} else if status == "delivered" {
icon = "\u2713" // check mark
}
if icon != "" {
// which IRC message triggered this SMS?
origin, ok := smsOrigin[id]
if !ok {
return
}
if status == "delivered" {
delete(smsOrigin, id)
}
log.Printf("SMS is %s", icon)
cl := &client{
nick: origin.params[0], // reply from origin's target nick
}
out := Message{
command: "PRIVMSG",
params: []string{origin.from.nick, icon},
}
commandPrivmsg(cl, out)
}
}
// Copyright 2018 Michael Hendricks
//
// Permission is granted to use this work for any purpose with or without
// fee, and with or without attribution.
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"github.com/pkg/errors"
)
const twilioPrefix = "/twilio"
const twilioAccountSid = "AC..."
const twilioKeySid = "SK..."
const twilioKeySecret = "..."
const twilioUser = "twilio-username"
const twilioPass = "twilio-password"
// describe the structure of the JSON that Twilio sends to us
type twilioApiResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Sid string `json:"sid"`
Flags []string `json:"flags"`
}
// Twilio sends notices, via HTTP, to this handler
func Twilio(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
// convert Twilio's status updates into IRC messages
if status := r.FormValue("MessageStatus"); status != "" {
if id := r.FormValue("MessageSid"); id != "" {
SmsIs(id, status)
fmt.Fprintln(w, "ok")
return
}
}
// force Twilio to authenticate
user, pass, ok := r.BasicAuth()
if !(ok && user == twilioUser && pass == twilioPass) {
w.Header().Set("WWW-Authenticate", "Basic realm=\"example.org\"")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintln(w, "Not authorized")
return
}
log.Printf("Twilio notifying about incoming SMS")
from, ok := ParsePhone(r.FormValue("From"))
if !ok {
log.Printf("Twilio Error: %q invalid From", r.FormValue("From"))
return
}
to, ok := ParsePhone(r.FormValue("To"))
if !ok {
log.Printf("Twilio Error: %q invalid To", r.FormValue("To"))
return
}
body := r.FormValue("Body")
SmsReceive(from, to, body)
}
// run a single API request against Twilio
func twilioDo(service string, form url.Values) (*twilioApiResponse, error) {
url := "https://api.twilio.com/2010-04-01/Accounts/" +
twilioAccountSid + "/" + service + ".json"
req, err := http.NewRequest("POST", url, strings.NewReader(form.Encode()))
if err != nil {
return nil, errors.Wrap(err, "building HTTP request")
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(twilioKeySid, twilioKeySecret)
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, errors.Wrap(err, "running HTTP request")
}
if res.StatusCode < 200 || res.StatusCode > 299 {
return nil, fmt.Errorf("unexpected status: %s", res.Status)
}
// parse response
tRes := &twilioApiResponse{}
defer res.Body.Close()
err = json.NewDecoder(res.Body).Decode(tRes)
if err != nil {
return nil, errors.Wrap(err, "parsing Twilio response")
}
// was the message queued for delivery?
if tRes.Status != "queued" {
msg := fmt.Errorf("unexpected status from Twilio API (%s): %s",
tRes.Status, tRes.Message)
return nil, msg
}
return tRes, nil
}
func TwilioSmsSend(sms *Sms) (string, error) {
form := make(url.Values)
form.Set("To", sms.To.E164())
form.Set("From", sms.From.E164())
form.Set("Body", sms.Body)
form.Set("StatusCallback", "https://example.org"+twilioPrefix)
res, err := twilioDo("Messages", form)
if err != nil {
return "", err
}
return res.Sid, nil
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment