My first Stride bot

This commit is contained in:
Peter Stuifzand 2018-07-29 16:50:01 +02:00
commit ff5abbad6c
7 changed files with 525 additions and 0 deletions

45
config.go Normal file
View File

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

4
config.json.sample Normal file
View File

@ -0,0 +1,4 @@
{
"client_id": "<copy from app page>",
"client_secret": "<copy from app page>",
}

55
descriptor.json.sample Normal file
View File

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

BIN
icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

331
main.go Normal file
View File

@ -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, `
<html>
<head>
<script src='https://dev-lib.stride.com/javascript/simple-xdm.js'></script>
</head>
<body>
<h1>Hello</h1>
<script>
AP.register({
"message-received": function(data) {console.log(data);},
"glance-update": function(data) {console.log(data);}
});
</script>
</body>
</html>
`)
}
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))
}

32
message.go Normal file
View File

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

58
token.go Normal file
View File

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