Add bot boilerplate

This commit is contained in:
Rob Watson 2021-02-22 20:39:22 +01:00
parent e86b70de21
commit b89a25f739
10 changed files with 321 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/esbot

11
README.md Normal file
View File

@ -0,0 +1,11 @@
# esbot
## Usage
```
INSECURE_TLS=no LISTEN_ADDR=0.0.0.0:8888 ACCESS_TOKEN=hackme go run .
```
## Licence
MIT

7
go.mod Normal file
View File

@ -0,0 +1,7 @@
module git.netflux.io/rob/esbot
go 1.15
require (
github.com/rs/zerolog v1.20.0
)

14
go.sum Normal file
View File

@ -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=

53
internal/http/http.go Normal file
View File

@ -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))
}

36
internal/http/logging.go Normal file
View File

@ -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")
})
}

7
internal/http/types.go Normal file
View File

@ -0,0 +1,7 @@
package http
import "git.netflux.io/rob/esbot/internal/matrix"
type EventsRequest struct {
RawEvents []*matrix.RawEvent `json:"events"`
}

139
internal/matrix/events.go Normal file
View File

@ -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<Command>[a-zA-Z]+)[[:space:]]+(?P<Arg>[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
}

29
internal/matrix/types.go Normal file
View File

@ -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"`
}

24
main.go Normal file
View File

@ -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))
}