commit ff5abbad6ce8994e62c27dd3e9b172a4b1b742ec Author: Peter Stuifzand Date: Sun Jul 29 16:50:01 2018 +0200 My first Stride bot diff --git a/config.go b/config.go new file mode 100644 index 0000000..3155b55 --- /dev/null +++ b/config.go @@ -0,0 +1,45 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "time" +) + +// Config is the configuration for the bot +type Config struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + Token Token + TokenCreated time.Time +} + +func loadConfig(filename string) (*Config, error) { + f, err := os.Open(filename) + if err != nil { + return nil, fmt.Errorf("can't open: %q: %q", filename, err) + } + defer f.Close() + + var conf Config + err = json.NewDecoder(f).Decode(&conf) + if err != nil { + return nil, fmt.Errorf("can't read json: %q: %q", filename, err) + } + + return &conf, nil +} + +func saveConfig(filename string, conf *Config) error { + f, err := os.Create(filename) + if err != nil { + return err + } + defer f.Close() + err = json.NewEncoder(f).Encode(&conf) + if err != nil { + return err + } + return nil +} diff --git a/config.json.sample b/config.json.sample new file mode 100644 index 0000000..4ce073e --- /dev/null +++ b/config.json.sample @@ -0,0 +1,4 @@ +{ + "client_id": "", + "client_secret": "", +} diff --git a/descriptor.json.sample b/descriptor.json.sample new file mode 100644 index 0000000..e532a39 --- /dev/null +++ b/descriptor.json.sample @@ -0,0 +1,55 @@ +{ + "baseUrl": "https://example.com/", + "key": "maester", + "lifecycle": { + "installed": "/installed", + "uninstalled": "/uninstalled" + }, + "modules": { + "chat:bot": [ + { + "key": "bot-1", + "mention": { + "url": "/bot/mention" + }, + "directMessage": { + "url": "/bot/dm" + } + } + ], + "chat:bot:messages": [ + { + "key": "bot-2", + "pattern": ".*", + "url":"/bot/msg" + } + ], + "chat:glance": [ + { + "key": "bot-glance", + "name": { + "value": "Reminders" + }, + "label": { + "value": "Reminders" + }, + "icon": { + "url": "/icon.png", + "url@2x": "/icon.png" + }, + "target": "bot-sidebar", + "queryUrl": "/glance" + } + ], + "chat:sidebar": [ + { + "key":"bot-sidebar", + "name": { + "value": "Reminders" + }, + "url": "/sidebar", + "authentication": "jwt" + } + ] + } +} diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..80b4cb8 Binary files /dev/null and b/icon.png differ diff --git a/main.go b/main.go new file mode 100644 index 0000000..d01929f --- /dev/null +++ b/main.go @@ -0,0 +1,331 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + jwt "github.com/dgrijalva/jwt-go" + "io" + "log" + "math/rand" + "net/http" + "os" + "regexp" + "time" +) + +type echoHandler struct { + Config Config +} + +type msgHandler struct { + Counts map[string]int + Config Config + MessageSender chan simpleMessage +} + +type glanceHandler struct { + Config Config + + Glances map[glanceState]glanceState +} +type sidebarHandler struct { + Config Config +} + +type conversation struct { + ID string + AvatarURL string + IsArchived bool + Name string + Privacy string + Topic string + Type string + Modified time.Time + Created time.Time +} + +type messagePart struct { + Type string + Content []messagePart + Text string +} +type sender struct { + ID string +} +type message struct { + ID string + Body struct { + messagePart + Version int + } + Text string + Sender sender + Time time.Time `json:"ts"` +} + +type outgoingMessage struct { + Body bodyType +} + +type bodyType struct { + Version int + Type string + Content []messagePart + Text string +} + +type incomingMessage struct { + CloudID string + Conversation conversation + Message message + Recipients []sender + Sender sender + Type string +} + +type glanceState struct { + CloudID string + ConversationID string +} +type glanceStateData struct { + Label string `json:"label"` + Metadata map[string]string `json:"metadata"` +} + +func (h *echoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + log.Println(r.Header) + log.Println(*r.URL) + + log.Println("MESSAGE:") + _, err := io.Copy(os.Stdout, r.Body) + if err != nil { + log.Println(err) + } + fmt.Fprintf(w, "ok\n") +} + +type contextClaim struct { + Context contextClaims `json:"context"` + jwt.StandardClaims +} +type contextClaims struct { + CloudID string `json:"cloudId"` + ResourceID string `json:"resourceID"` + ResourceType string `json:"resourceType"` +} + +func (h *glanceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + tokenString := r.URL.Query().Get("jwt") + + token, err := jwt.ParseWithClaims(tokenString, &contextClaim{}, func(token *jwt.Token) (interface{}, error) { + return []byte(h.Config.ClientSecret), nil + }) + if err != nil { + w.WriteHeader(401) + fmt.Fprintf(w, "wrong jwt: %q", err) + return + } + if claims, ok := token.Claims.(*contextClaim); ok && token.Valid { + if _, e := h.Glances[glanceState{claims.Context.CloudID, claims.Context.ResourceID}]; !e { + gs := glanceState{claims.Context.CloudID, claims.Context.ResourceID} + h.Glances[gs] = gs + } + log.Println(h.Glances) + } else { + w.WriteHeader(401) + fmt.Fprintf(w, "wrong jwt: %q", err) + return + } + + w.Header().Add("Content-Type", "application/json") + fmt.Fprint(w, `{"label":"Value of the glance","metadata":{"count":"1"}}`) +} + +func (h *sidebarHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "text/html") + fmt.Fprint(w, ` + + + + + +

Hello

+ + + + `) +} + +var ( + karmaRegex = regexp.MustCompile(`(\S+)([+-]{2})`) + karmaStatsRegex = regexp.MustCompile(`karma\s+(\S+)`) +) + +func (h *msgHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + log.Println(r.Header) + log.Println(*r.URL) + + var msg incomingMessage + dec := json.NewDecoder(r.Body) + err := dec.Decode(&msg) + if err != nil { + log.Println(err) + } + log.Printf("MESSAGE: %#v\n", msg) + + if res := karmaRegex.FindAllStringSubmatch(msg.Message.Text, -1); len(res) >= 1 { + for _, u := range res { + count := 1 + username := u[1] + if u[2] == "--" { + count = -1 + } + if c, e := h.Counts[username]; e { + count += c + } + h.Counts[username] = count + + // Reply in the same room, to the same user + text := fmt.Sprintf("%s has %d karma\n", username, count) + h.MessageSender <- simpleMessage{text, msg.CloudID, msg.Conversation.ID} + + f, err := os.Create("counts.json") + if err != nil { + log.Println(err) + } + + json.NewEncoder(f).Encode(&h.Counts) + } + } else if matches := karmaStatsRegex.FindAllStringSubmatch(msg.Message.Text, -1); len(matches) >= 1 { + go func(msg incomingMessage) { + username := matches[0][1] + var text string + if c, e := h.Counts[username]; e { + text = fmt.Sprintf("%s has %d karma\n", username, c) + } else { + text = fmt.Sprintf("%s has no karma, add with %s++\n", username, username) + } + h.MessageSender <- simpleMessage{text, msg.CloudID, msg.Conversation.ID} + }(msg) + } + + fmt.Fprintf(w, "ok\n") +} + +type simpleMessage struct { + Message string + CloudID string + ConversationID string +} + +func main() { + config, err := loadConfig("config.json") + if err != nil { + log.Fatalf("error while loading config: %s", err) + } + + config.Token, err = getToken(config) + if err != nil { + log.Fatalf("error while getting token: %q", err) + } + + config.TokenCreated = time.Now() + + saveConfig("config.json", config) + + messageSender := make(chan simpleMessage) + + go func() { + for { + select { + case msg := <-messageSender: + var b bytes.Buffer + b.WriteString(msg.Message) + sendMessage(&b, msg.CloudID, msg.ConversationID, config.Token.AccessToken) + break + } + } + }() + + eh := &echoHandler{Config: *config} + mh := &msgHandler{Config: *config, MessageSender: messageSender} + gh := &glanceHandler{Config: *config} + sh := &sidebarHandler{Config: *config} + + mh.Counts = make(map[string]int) + + f, err := os.Open("counts.json") + if err != nil { + log.Fatalf("missing file: counts.json: %q", err) + } + + json.NewDecoder(f).Decode(&mh.Counts) + + gh.Glances = make(map[glanceState]glanceState) + + ticker := time.Tick(20 * time.Second) + go func() { + for { + select { + case <-ticker: + client := http.Client{} + for _, gs := range gh.Glances { + log.Println("Updating glances") + var b bytes.Buffer + u := fmt.Sprintf( + "https://api.atlassian.com/site/%s/conversation/%s/app/module/chat:glance/%s/state", + gs.CloudID, + gs.ConversationID, + "bot-glance", + ) + var data glanceStateData + data.Label = fmt.Sprintf("%d items", rand.Intn(10)+1) + data.Metadata = make(map[string]string) + data.Metadata["count"] = "8" + err = json.NewEncoder(&b).Encode(&data) + if err != nil { + log.Println(err) + return + } + req, err := http.NewRequest("PUT", u, &b) + if err != nil { + log.Println(err) + return + } + + req.Header.Add("Authorization", "Bearer "+config.Token.AccessToken) + req.Header.Add("Content-Type", "application/json") + + res, err := client.Do(req) + if err != nil { + log.Println(err) + return + } + + defer res.Body.Close() + io.Copy(os.Stderr, res.Body) + } + break + } + } + }() + + http.Handle("/installed", eh) + http.Handle("/uninstalled", eh) + http.Handle("/bot/mention", mh) + http.Handle("/bot/dm", mh) + http.Handle("/bot/msg", mh) + http.Handle("/glance", gh) + http.Handle("/sidebar", sh) + + http.Handle("/", http.FileServer(http.Dir("."))) + log.Fatal(http.ListenAndServe(":8080", nil)) +} diff --git a/message.go b/message.go new file mode 100644 index 0000000..3ef78f0 --- /dev/null +++ b/message.go @@ -0,0 +1,32 @@ +package main + +import ( + "fmt" + "io" + "log" + "net/http" + "os" +) + +func sendMessage(b io.Reader, cloudID, conversationID string, token string) { + u := fmt.Sprintf("https://api.atlassian.com/site/%s/conversation/%s/message", cloudID, conversationID) + client := http.Client{} + + req, err := http.NewRequest("POST", u, b) + if err != nil { + log.Println(err) + return + } + + req.Header.Add("Authorization", "Bearer "+token) + req.Header.Add("Content-Type", "text/plain") + + res, err := client.Do(req) + if err != nil { + log.Println(err) + return + } + + defer res.Body.Close() + io.Copy(os.Stderr, res.Body) +} diff --git a/token.go b/token.go new file mode 100644 index 0000000..a888f90 --- /dev/null +++ b/token.go @@ -0,0 +1,58 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "log" + "net/http" + "os" +) + +type Token struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` + TokenType string `json:"token_type"` +} + +type TokenRequest struct { + GrantType string `json:"grant_type"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` +} + +func getToken(conf *Config) (Token, error) { + var tok Token + client := http.Client{} + var b bytes.Buffer + var tokReq TokenRequest + tokReq.ClientID = conf.ClientID + tokReq.ClientSecret = conf.ClientSecret + tokReq.GrantType = "client_credentials" + + err := json.NewEncoder(&b).Encode(&tokReq) + if err != nil { + return tok, err + } + + req, err := http.NewRequest("POST", "https://api.atlassian.com/oauth/token", &b) + if err != nil { + return tok, err + } + req.Header.Add("Content-Type", "application/json") + + res, err := client.Do(req) + if err != nil { + return tok, err + } + defer res.Body.Close() + + err = json.NewDecoder(io.TeeReader(res.Body, os.Stdout)).Decode(&tok) + if err != nil { + return tok, err + } + log.Println(tok) + + return tok, nil +}