Initial import.
This commit is contained in:
11
conn/config.go
Normal file
11
conn/config.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package conn
|
||||
|
||||
import (
|
||||
"kas/conn/twilio"
|
||||
"kas/conn/xmpp"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
XMPP *xmpp.Config `yaml:"xmpp"`
|
||||
Twilio *twilio.Config `yaml:"twilio"`
|
||||
}
|
||||
30
conn/http/server.go
Normal file
30
conn/http/server.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Main router
|
||||
// Twilio handler
|
||||
|
||||
var server = &struct {
|
||||
router *http.ServeMux
|
||||
lock sync.Mutex
|
||||
}{
|
||||
router: http.NewServeMux(),
|
||||
}
|
||||
|
||||
// AddRoute is used to set up routes.
|
||||
//
|
||||
// NB: no checking is done yet for duplicate patterns.
|
||||
func AddRoute(pattern string, handler func(http.ResponseWriter, *http.Request)) {
|
||||
server.lock.Lock()
|
||||
defer server.lock.Unlock()
|
||||
server.router.HandleFunc(pattern, handler)
|
||||
}
|
||||
|
||||
func Start(addr string) {
|
||||
go log.Print(http.ListenAndServe(addr, server.router))
|
||||
}
|
||||
83
conn/twilio/config.go
Normal file
83
conn/twilio/config.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package twilio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Contacts []string `yaml:"contacts"`
|
||||
contacts map[string]bool
|
||||
AccountSID string `yaml:"account_sid"`
|
||||
AuthToken string `yaml:"auth_token"`
|
||||
Number string `yaml:"telno"` // kas' telno
|
||||
}
|
||||
|
||||
func (cfg *Config) postURL() string {
|
||||
return fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json",
|
||||
cfg.AccountSID)
|
||||
}
|
||||
|
||||
func (cfg *Config) buildContacts() {
|
||||
cfg.contacts = map[string]bool{}
|
||||
for _, telno := range cfg.Contacts {
|
||||
cfg.contacts[telno] = true
|
||||
}
|
||||
}
|
||||
|
||||
func (cfg *Config) NumberAuthorized(s string) bool {
|
||||
if cfg.contacts == nil || len(cfg.contacts) != len(cfg.Contacts) {
|
||||
cfg.buildContacts()
|
||||
}
|
||||
|
||||
return cfg.contacts[s]
|
||||
}
|
||||
|
||||
func validate(cfg *Config) error {
|
||||
if len(cfg.Contacts) == 0 {
|
||||
return errors.New("twilio: no authorized numbers (you won't be able to receive any messages)")
|
||||
}
|
||||
|
||||
if cfg.AccountSID == "" {
|
||||
return errors.New("twilio: missing account SID")
|
||||
}
|
||||
|
||||
if cfg.AuthToken == "" {
|
||||
return errors.New("twilio: missing auth token")
|
||||
}
|
||||
|
||||
if cfg.Number == "" {
|
||||
return errors.New("twilio: no number configured")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var config *Config
|
||||
|
||||
func SetConfig(cfg *Config) error {
|
||||
if err := validate(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
config = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
func Send(to string, message string) error {
|
||||
form := url.Values{
|
||||
"Body": {message},
|
||||
"From": {config.Number},
|
||||
"To": {to},
|
||||
}
|
||||
|
||||
resp, err := http.PostForm(config.postURL(), form)
|
||||
if err != nil {
|
||||
log.Printf("twilio send: %s", err)
|
||||
}
|
||||
|
||||
resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
116
conn/twilio/message.go
Normal file
116
conn/twilio/message.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package twilio
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrInvalidMessage is returned when a bad message is sent to the Twilio
|
||||
// receive hook; it tries to clarify why the message is invalid.
|
||||
type ErrInvalidMessage struct {
|
||||
missing []string
|
||||
cause string
|
||||
}
|
||||
|
||||
// Error satisfies the error interface.
|
||||
func (err *ErrInvalidMessage) Error() string {
|
||||
errParts := []string{}
|
||||
if len(err.missing) > 0 {
|
||||
errParts = append(errParts, "missing="+strings.Join(err.missing, ","))
|
||||
}
|
||||
|
||||
if err.cause != "" {
|
||||
errParts = append(errParts, "cause="+err.cause)
|
||||
}
|
||||
|
||||
return strings.Join(errParts, ", ")
|
||||
}
|
||||
|
||||
// isInvalidMessage returns an error given a list of missing fields and an
|
||||
// underlying cause, returning nil if the message is valid.
|
||||
func isInvalidMessage(missing []string, cause string) error {
|
||||
if len(missing) == 0 && cause == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &ErrInvalidMessage{
|
||||
missing: missing,
|
||||
cause: cause,
|
||||
}
|
||||
}
|
||||
|
||||
// Message represents an incoming Twilio message.
|
||||
type Message struct {
|
||||
Source string
|
||||
To string
|
||||
Body string
|
||||
When time.Time
|
||||
MediaURL string
|
||||
ASID string
|
||||
MSID string
|
||||
Price float64
|
||||
Mime string
|
||||
}
|
||||
|
||||
// MessageFromValues returns a Message from a set of form values.
|
||||
func MessageFromValues(v url.Values) (*Message, error) {
|
||||
missing := []string{}
|
||||
cause := ""
|
||||
|
||||
m := &Message{
|
||||
Source: v.Get("From"),
|
||||
To: v.Get("To"),
|
||||
Body: v.Get("Body"),
|
||||
MediaURL: v.Get("MediaUrl0"),
|
||||
ASID: v.Get("AccountSid"),
|
||||
MSID: v.Get("MessageSid"),
|
||||
When: time.Now().UTC(),
|
||||
Price: 0.0075,
|
||||
}
|
||||
|
||||
if m.Source == "" {
|
||||
missing = append(missing, "From")
|
||||
}
|
||||
|
||||
if m.To == "" {
|
||||
missing = append(missing, "To")
|
||||
}
|
||||
|
||||
numMediaValue := v.Get("NumMedia")
|
||||
if numMediaValue == "" {
|
||||
numMediaValue = "0"
|
||||
}
|
||||
|
||||
nMedia, err := strconv.Atoi(numMediaValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if nMedia == 0 && m.Body == "" {
|
||||
cause = "message has no body or media attachment"
|
||||
} else if nMedia != 0 {
|
||||
if m.MediaURL == "" {
|
||||
missing = append(missing, "MediaUrl0")
|
||||
}
|
||||
m.Price = 0.01
|
||||
if m.Mime = v.Get("MediaContentType0"); m.Mime == "" {
|
||||
missing = append(missing, "MediaContentType0")
|
||||
}
|
||||
}
|
||||
|
||||
if sent := v.Get("DateSent"); sent != "" {
|
||||
t, err := time.Parse(time.RFC1123Z, sent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.When = t.UTC()
|
||||
}
|
||||
|
||||
if err := isInvalidMessage(missing, cause); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
49
conn/twilio/twilio.go
Normal file
49
conn/twilio/twilio.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package twilio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"kas/cps"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func handler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
log.Printf("twilio receive hook: received %s request; only %s requests are supported",
|
||||
r.Method, http.MethodPost)
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
log.Printf("twilio receive hook: couldn't parse form contents: %s", err)
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
message, err := MessageFromValues(r.Form)
|
||||
if err != nil {
|
||||
log.Printf("twilio receive hook: received an invalid message: %s", err)
|
||||
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !config.NumberAuthorized(message.Source) {
|
||||
log.Printf("twilio receive hook: received message from unknown sender %v", message)
|
||||
http.Error(w, "not authorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.Write([]byte("accepted"))
|
||||
|
||||
response, err := cps.Handle(context.Background(), message.Body)
|
||||
if err != nil {
|
||||
log.Printf("twilio receive hook: %s", err)
|
||||
}
|
||||
|
||||
err = Send(message.Source, response.String())
|
||||
if err != nil {
|
||||
log.Printf("twilio receive hook: %s", err)
|
||||
}
|
||||
}
|
||||
29
conn/xmpp/config.go
Normal file
29
conn/xmpp/config.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package xmpp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gosrc.io/xmpp"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Account string `yaml:"account"`
|
||||
Password string `yaml:"password"`
|
||||
}
|
||||
|
||||
func (c *Config) xmppConfig() (*xmpp.Config, error) {
|
||||
parts := strings.SplitN(c.Account, "@", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("xmpp: invalid account '%s'", c.Account)
|
||||
}
|
||||
|
||||
server := parts[1] + ":5222"
|
||||
return &xmpp.Config{
|
||||
TransportConfiguration: xmpp.TransportConfiguration{
|
||||
Address: server,
|
||||
},
|
||||
Jid: c.Account,
|
||||
Credential: xmpp.Password(c.Password),
|
||||
}, nil
|
||||
}
|
||||
78
conn/xmpp/xmpp.go
Normal file
78
conn/xmpp/xmpp.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package xmpp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"kas/cps"
|
||||
"log"
|
||||
|
||||
"gosrc.io/xmpp"
|
||||
"gosrc.io/xmpp/stanza"
|
||||
)
|
||||
|
||||
func messageHandler(s xmpp.Sender, p stanza.Packet) {
|
||||
msg, ok := p.(stanza.Message)
|
||||
if !ok {
|
||||
log.Print("xmpp: ignore packet %T", p)
|
||||
return
|
||||
}
|
||||
|
||||
response, err := cps.Handle(context.Background(), msg.Body)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("xmpp message handler: %w", err)
|
||||
log.Print(err)
|
||||
}
|
||||
|
||||
reply := stanza.Message{
|
||||
Attrs: stanza.Attrs{
|
||||
To: msg.From,
|
||||
},
|
||||
Body: response.String(),
|
||||
}
|
||||
|
||||
err = s.Send(reply)
|
||||
if err != nil {
|
||||
log.Printf("xmpp: failed to send reply to %s: %s",
|
||||
msg.From, err)
|
||||
}
|
||||
}
|
||||
|
||||
func logError(err error) {
|
||||
err = fmt.Errorf("xmpp: %w", err)
|
||||
log.Print(err)
|
||||
}
|
||||
|
||||
func Start(cfg *Config) error {
|
||||
log.Printf("starting XMPP server for %s", cfg.Account)
|
||||
config, err := cfg.xmppConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
// TODO: what happens if we ask the bot to stop?
|
||||
// Best answer: systemd service.
|
||||
runClient(config)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runClient(cfg *xmpp.Config) {
|
||||
router := xmpp.NewRouter()
|
||||
router.HandleFunc("message", messageHandler)
|
||||
|
||||
client, err := xmpp.NewClient(cfg, router, logError)
|
||||
if err != nil {
|
||||
logError(err)
|
||||
return
|
||||
}
|
||||
|
||||
// If you pass the client to a connection manager, it will handle the reconnect policy
|
||||
// for you automatically.
|
||||
cm := xmpp.NewStreamManager(client, nil)
|
||||
|
||||
log.Print(cm.Run())
|
||||
}
|
||||
Reference in New Issue
Block a user