Last active
June 23, 2020 17:20
-
-
Save mndrix/6966f79e0834fbfb1926f9c6afb6e22e to your computer and use it in GitHub Desktop.
SMS over IRC
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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