diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5bc872 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/esbot diff --git a/README.md b/README.md new file mode 100644 index 0000000..65c9ead --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# esbot + +## Usage + +``` +INSECURE_TLS=no LISTEN_ADDR=0.0.0.0:8888 ACCESS_TOKEN=hackme go run . +``` + +## Licence + +MIT diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5aebf07 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module git.netflux.io/rob/esbot + +go 1.15 + +require ( + github.com/rs/zerolog v1.20.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..901692a --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/gologme/log v1.2.0 h1:Ya5Ip/KD6FX7uH0S31QO87nCCSucKtF44TLbTtO7V4c= +github.com/gologme/log v1.2.0/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= +github.com/rs/zerolog v1.20.0 h1:38k9hgtUBdxFwE34yS8rTHmHBa4eN16E4DJlv177LNs= +github.com/rs/zerolog v1.20.0/go.mod h1:IzD0RJ65iWH0w97OQQebJEvTZYvsCUm9WVLWBQrJRjo= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/http/http.go b/internal/http/http.go new file mode 100644 index 0000000..05a2f3c --- /dev/null +++ b/internal/http/http.go @@ -0,0 +1,53 @@ +package http + +import ( + "crypto/tls" + "encoding/json" + "net/http" + "os" + + "git.netflux.io/rob/esbot/internal/matrix" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +type handler struct { + logger zerolog.Logger + ep matrix.EventProcessor +} + +func (h *handler) handleTransactions(w http.ResponseWriter, r *http.Request) { + if r.Method != "PUT" { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + var payload EventsRequest + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + if err := h.ep.ProcessEvents(payload.RawEvents); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte("{}")) +} + +func ListenAndServe(addr, accessToken string, insecureTLS bool) error { + http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: insecureTLS} + logger := log.Output(zerolog.ConsoleWriter{Out: os.Stdout}) + + h := handler{ + logger: logger, + ep: matrix.NewProcessor(accessToken, logger), + } + + mux := http.NewServeMux() + mux.HandleFunc("/transactions/", h.handleTransactions) + + return http.ListenAndServe(addr, loggerMiddleware(logger, mux)) +} diff --git a/internal/http/logging.go b/internal/http/logging.go new file mode 100644 index 0000000..15e44d6 --- /dev/null +++ b/internal/http/logging.go @@ -0,0 +1,36 @@ +package http + +import ( + "net/http" + "time" + + "github.com/rs/zerolog" +) + +type responseWriter struct { + http.ResponseWriter + status int + wroteHeader bool +} + +func wrapResponseWriter(w http.ResponseWriter) *responseWriter { + return &responseWriter{ResponseWriter: w} +} + +func (w *responseWriter) WriteHeader(status int) { + if w.wroteHeader { + return + } + w.status = status + w.ResponseWriter.WriteHeader(status) + w.wroteHeader = true +} + +func loggerMiddleware(logger zerolog.Logger, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + wrapped := wrapResponseWriter(w) + next.ServeHTTP(wrapped, r) + logger.Info().Int("status", wrapped.status).Dur("dur", time.Since(start)).Str("path", r.URL.Path).Str("method", r.Method).Str("path", r.URL.EscapedPath()).Msg("request processed") + }) +} diff --git a/internal/http/types.go b/internal/http/types.go new file mode 100644 index 0000000..15dd8e9 --- /dev/null +++ b/internal/http/types.go @@ -0,0 +1,7 @@ +package http + +import "git.netflux.io/rob/esbot/internal/matrix" + +type EventsRequest struct { + RawEvents []*matrix.RawEvent `json:"events"` +} diff --git a/internal/matrix/events.go b/internal/matrix/events.go new file mode 100644 index 0000000..818ac5f --- /dev/null +++ b/internal/matrix/events.go @@ -0,0 +1,139 @@ +package matrix + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + + "github.com/rs/zerolog" +) + +var cmdRegex = regexp.MustCompile("[[:space:]]*!(?P[a-zA-Z]+)[[:space:]]+(?P[a-zA-Z]+)") + +type EventProcessor interface { + ProcessEvents(events []*RawEvent) error +} + +type processor struct { + accessToken string + httpclient *http.Client + processed map[string]bool + logger zerolog.Logger +} + +func NewProcessor(accessToken string, logger zerolog.Logger) EventProcessor { + return &processor{ + accessToken: accessToken, + httpclient: &http.Client{}, + processed: make(map[string]bool), + logger: logger, + } +} + +func (p *processor) ProcessEvents(events []*RawEvent) error { + for _, e := range events { + if p.processed[e.Id] { + p.logger.Debug().Str("event_id", e.Id).Msg("already processed event") + continue + } + + if e.StateKey != "" { + if e.Content.Membership == "invite" { + if err := p.acceptInvite(e); err != nil { + return err + } + } + + p.processed[e.Id] = true + continue + } + + if !cmdRegex.MatchString(e.Content.Body) { + p.processed[e.Id] = true + continue + } + + match := cmdRegex.FindStringSubmatch(e.Content.Body) + cmd := match[1] + arg := match[2] + + switch cmd { + case "verb": + if err := p.handleVerb(e, cmd, arg); err != nil { + return err + } + default: + p.logger.Debug().Str("command", cmd).Str("arg", arg).Msg("unrecognized command") + } + + p.processed[e.Id] = true + } + + return nil +} + +func (p *processor) acceptInvite(e *RawEvent) error { + url := fmt.Sprintf("https://synapse.local/_matrix/client/r0/rooms/%s/join", e.RoomId) + req, _ := p.buildRequest("POST", url, bytes.NewReader([]byte("{}"))) + resp, err := p.httpclient.Do(req) + if err != nil { + return fmt.Errorf("http error accepting invitation: %v", err) + } + if resp.StatusCode != http.StatusOK { + return p.handleErrorResponse(e, resp) + } + p.logger.Info().Str("room_id", e.RoomId).Str("sender", e.Sender).Msg("accepted invite") + return nil +} + +func (p *processor) handleVerb(e *RawEvent, cmd, arg string) error { + replyBody := sendBody{ + Body: fmt.Sprintf("rcvd command %q, arg %q", cmd, arg), + MsgType: "text", + } + + encodedBody, err := json.Marshal(replyBody) + if err != nil { + return fmt.Errorf("error encoding message: %v", err) + } + + url := fmt.Sprintf("https://synapse.local/_matrix/client/r0/rooms/%s/send/m.room.message", e.RoomId) + req, _ := p.buildRequest("POST", url, bytes.NewReader(encodedBody)) + resp, err := p.httpclient.Do(req) + if err != nil { + return fmt.Errorf("http error sending message: %v", err) + } + if resp.StatusCode != http.StatusOK { + return p.handleErrorResponse(e, resp) + } + + return nil +} + +func (p *processor) handleErrorResponse(e *RawEvent, resp *http.Response) error { + defer resp.Body.Close() + var er errorResponse + if err := json.NewDecoder(resp.Body).Decode(&er); err != nil { + return fmt.Errorf("could not decode response: %v", err) + } + if er.Code == "M_FORBIDDEN" || er.Code == "M_UNKNOWN_TOKEN" { + p.processed[e.Id] = true + p.logger.Warn().Str("errcode", er.Code).Str("event_id", e.Id).Str("room_id", e.RoomId).Msg("forbidden, ignoring event") + return nil + } + p.logger.Error().Str("errcode", er.Code).Str("error", er.Error).Msg("error sending message") + return fmt.Errorf("synapse error: %v", er.Error) +} + +func (p *processor) buildRequest(url, method string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequest(url, method, body) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", p.accessToken)) + return req, nil +} diff --git a/internal/matrix/types.go b/internal/matrix/types.go new file mode 100644 index 0000000..0e1ba9c --- /dev/null +++ b/internal/matrix/types.go @@ -0,0 +1,29 @@ +package matrix + +type RawEvent struct { + Type string `json:"type"` + Id string `json:"event_id"` + RoomId string `json:"room_id"` + Sender string `json:"sender"` + StateKey string `json:"state_key"` + OriginTimeStamp int64 `json:"origin_server_ts"` + Content struct { + Body string `json:"body"` + MsgType string `json:"msg_type"` + Membership string `json:"membership"` + AvatarURL string `json:"avatar_url"` + DisplayName string `json:"displayname"` + Format string `json:"format"` + FormattedBody string `json:"formatted_body"` + } `json:"content"` +} + +type sendBody struct { + MsgType string `json:"msgtype"` + Body string `json:"body"` +} + +type errorResponse struct { + Code string `json:"errcode"` + Error string `json:"error"` +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..0e1de08 --- /dev/null +++ b/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "log" + "os" + + "git.netflux.io/rob/esbot/internal/http" +) + +func main() { + accessToken := os.Getenv("ACCESS_TOKEN") + if accessToken == "" { + log.Fatal("missing ACCESS_TOKEN") + } + + listenAddr := os.Getenv("LISTEN_ADDR") + if listenAddr == "" { + log.Fatal("missing LISTEN_ADDR") + } + + insecureTLS := os.Getenv("INSECURE_TLS") == "yes" + + log.Fatal(http.ListenAndServe(listenAddr, accessToken, insecureTLS)) +}