Add bot boilerplate
This commit is contained in:
parent
e86b70de21
commit
b89a25f739
|
@ -0,0 +1 @@
|
||||||
|
/esbot
|
|
@ -0,0 +1,11 @@
|
||||||
|
# esbot
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
INSECURE_TLS=no LISTEN_ADDR=0.0.0.0:8888 ACCESS_TOKEN=hackme go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
MIT
|
|
@ -0,0 +1,7 @@
|
||||||
|
module git.netflux.io/rob/esbot
|
||||||
|
|
||||||
|
go 1.15
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/rs/zerolog v1.20.0
|
||||||
|
)
|
|
@ -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=
|
|
@ -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))
|
||||||
|
}
|
|
@ -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")
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package http
|
||||||
|
|
||||||
|
import "git.netflux.io/rob/esbot/internal/matrix"
|
||||||
|
|
||||||
|
type EventsRequest struct {
|
||||||
|
RawEvents []*matrix.RawEvent `json:"events"`
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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"`
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
Loading…
Reference in New Issue