My first Stride bot
This commit is contained in:
commit
ff5abbad6c
45
config.go
Normal file
45
config.go
Normal 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
4
config.json.sample
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"client_id": "<copy from app page>",
|
||||||
|
"client_secret": "<copy from app page>",
|
||||||
|
}
|
55
descriptor.json.sample
Normal file
55
descriptor.json.sample
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
331
main.go
Normal file
331
main.go
Normal 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
32
message.go
Normal 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
58
token.go
Normal 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
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user