├── .gitignore ├── docs └── img │ ├── memo-slack-config.png │ ├── memo-slack-screenshot.png │ ├── configure-grafana-for-memo.png │ └── memo-in-grafana-from-slack.png ├── service ├── service.go ├── discord │ └── discord.go └── slack │ └── slack.go ├── store ├── init.go └── grafana.go ├── var └── upstart-memo.conf ├── .dockerignore ├── config-default.toml ├── GOVERNANCE.md ├── cmd ├── memo-cli │ ├── csv_string_var.go │ └── main.go └── memod │ └── main.go ├── go.mod ├── Dockerfile ├── cfg └── cfg.go ├── memo.go ├── daemon └── daemon.go ├── parser ├── parser_test.go └── parser.go ├── .github └── workflows │ └── docker-publish.yml ├── go.sum ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | memo-cli 2 | memod 3 | config.toml 4 | -------------------------------------------------------------------------------- /docs/img/memo-slack-config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/memo/HEAD/docs/img/memo-slack-config.png -------------------------------------------------------------------------------- /docs/img/memo-slack-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/memo/HEAD/docs/img/memo-slack-screenshot.png -------------------------------------------------------------------------------- /docs/img/configure-grafana-for-memo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/memo/HEAD/docs/img/configure-grafana-for-memo.png -------------------------------------------------------------------------------- /docs/img/memo-in-grafana-from-slack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grafana/memo/HEAD/docs/img/memo-in-grafana-from-slack.png -------------------------------------------------------------------------------- /service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | // Service for receiving memo messages 4 | type Service interface { 5 | Name() string 6 | } 7 | -------------------------------------------------------------------------------- /store/init.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import "github.com/grafana/memo" 4 | 5 | // Store 6 | type Store interface { 7 | // Save stores the memo in the storage engine 8 | Save(memo memo.Memo) error 9 | } 10 | -------------------------------------------------------------------------------- /var/upstart-memo.conf: -------------------------------------------------------------------------------- 1 | description "memo bot" 2 | start on filesystem or runlevel [2345] 3 | stop on runlevel [!2345] 4 | respawn 5 | console log # log stdout/stderr to /var/log/upstart/ 6 | exec /usr/bin/memod 7 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # changes to self should not clear cache 2 | Dockerfile 3 | 4 | # old / external artifacts are unrequired 5 | memod 6 | memo-cli 7 | 8 | # documentation is not required within the build container 9 | *.md 10 | LICENSE -------------------------------------------------------------------------------- /config-default.toml: -------------------------------------------------------------------------------- 1 | # one of; trace debug info warn error fatal panic 2 | log_level = "info" 3 | 4 | [slack] 5 | enabled = true 6 | app_token = "" 7 | bot_token = "" 8 | 9 | [discord] 10 | enabled = true 11 | bot_token = "" 12 | 13 | [grafana] 14 | api_key = "" 15 | api_url = "http://localhost/api/" 16 | # tls_key = "" 17 | # tls_cert = "" 18 | -------------------------------------------------------------------------------- /GOVERNANCE.md: -------------------------------------------------------------------------------- 1 | This is a toy project for now. 2 | 3 | It follows the spirit of 4 | 5 | * https://github.com/grafana/grafana/blob/master/GOVERNANCE.md 6 | * https://github.com/grafana/grafana/blob/master/CODE_OF_CONDUCT.md 7 | * https://github.com/grafana/grafana/blob/master/WORKFLOW.md 8 | 9 | with @dieterbe being the default maintainer for *. 10 | -------------------------------------------------------------------------------- /cmd/memo-cli/csv_string_var.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "strings" 4 | 5 | // CsvStringVar 6 | type CsvStringVar []string 7 | 8 | // String 9 | func (c *CsvStringVar) String() string { 10 | ct := *c 11 | 12 | // trim the extra spaces 13 | temp := []string{} 14 | for _, cv := range ct { 15 | temp = append(temp, strings.TrimSpace(cv)) 16 | } 17 | 18 | return strings.Join(temp, ",") 19 | } 20 | 21 | // Set 22 | func (c *CsvStringVar) Set(value string) error { 23 | *c = strings.Split(value, ",") 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/grafana/memo 2 | 3 | go 1.14 4 | 5 | require ( 6 | github.com/BurntSushi/toml v0.3.1 7 | github.com/benbjohnson/clock v1.0.3 8 | github.com/bwmarrin/discordgo v0.26.1 9 | github.com/gorilla/websocket v1.5.0 // indirect 10 | github.com/mitchellh/go-homedir v1.1.0 11 | github.com/raintank/dur v0.0.0-20181019115741-955e3a77c6a8 12 | github.com/sirupsen/logrus v1.6.0 13 | github.com/slack-go/slack v0.11.3 14 | github.com/stretchr/testify v1.4.0 // indirect 15 | golang.org/x/sys v0.1.0 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.19-alpine AS builder 2 | 3 | ADD . /opt/memo 4 | 5 | WORKDIR /opt/memo 6 | 7 | RUN apk --update add --no-cache ca-certificates openssl git tzdata && \ 8 | update-ca-certificates 9 | 10 | RUN go get -v && \ 11 | GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o memod cmd/memod/* && \ 12 | GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o memo-cli cmd/memo-cli/* && \ 13 | chmod +x memod memo-cli 14 | 15 | FROM alpine:latest 16 | 17 | COPY --from=builder /opt/memo/memod /bin/memod 18 | COPY --from=builder /opt/memo/memo-cli /bin/memo-cli 19 | 20 | CMD [ "memod" ] -------------------------------------------------------------------------------- /cfg/cfg.go: -------------------------------------------------------------------------------- 1 | package cfg 2 | 3 | type Config struct { 4 | LogLevel string `toml:"log_level"` 5 | Slack Slack 6 | Discord Discord 7 | Grafana Grafana 8 | } 9 | 10 | type Slack struct { 11 | Enabled bool `toml:"enabled"` 12 | BotToken string `toml:"bot_token"` 13 | AppToken string `toml:"app_token"` 14 | } 15 | 16 | type Discord struct { 17 | Enabled bool `toml:"enabled"` 18 | BotToken string `toml:"bot_token"` 19 | } 20 | 21 | type Grafana struct { 22 | ApiKey string `toml:"api_key"` 23 | ApiUrl string `toml:"api_url"` 24 | TLSKey string `toml:"tls_key"` 25 | TLSCert string `toml:"tls_cert"` 26 | } 27 | -------------------------------------------------------------------------------- /memo.go: -------------------------------------------------------------------------------- 1 | package memo 2 | 3 | import ( 4 | "errors" 5 | "sort" 6 | "time" 7 | ) 8 | 9 | // ErrEmpty used to return consistent error message for empty memo 10 | var ErrEmpty = errors.New("empty message") 11 | 12 | // HelpMessage used to return consistent help message 13 | var HelpMessage = "Hi. I only support memo requests. See https://github.com/grafana/memo/blob/master/README.md#message-format" 14 | 15 | // Memo 16 | type Memo struct { 17 | // Date 18 | Date time.Time 19 | // Desc 20 | Desc string 21 | // Tags 22 | Tags []string 23 | } 24 | 25 | // BuildTags takes the base tags (hardcoded), and extra tags (user specified) 26 | // it validates the user is not trying to override the built in tags, 27 | // merges them and sorts them 28 | func (m *Memo) BuildTags(extra []string) { 29 | base := []string{ 30 | "memo", 31 | } 32 | 33 | base = append(base, extra...) 34 | sort.Strings(base) 35 | 36 | m.Tags = base 37 | } 38 | -------------------------------------------------------------------------------- /cmd/memod/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/BurntSushi/toml" 7 | "github.com/grafana/memo/cfg" 8 | "github.com/grafana/memo/daemon" 9 | "github.com/grafana/memo/store" 10 | log "github.com/sirupsen/logrus" 11 | ) 12 | 13 | var configFile = "/etc/memo.toml" 14 | 15 | // main 16 | func main() { 17 | if len(os.Args) > 2 { 18 | log.Fatal("usage: memo [path-to-config]") 19 | } 20 | if len(os.Args) == 2 { 21 | configFile = os.Args[1] 22 | } 23 | 24 | var config cfg.Config 25 | _, err := toml.DecodeFile(configFile, &config) 26 | if err != nil { 27 | log.Fatalf("Invalid config file %q: %s", configFile, err.Error()) 28 | } 29 | 30 | lvl, err := log.ParseLevel(config.LogLevel) 31 | if err != nil { 32 | log.Fatalf("failed to parse log-level %q: %s", config.LogLevel, err.Error()) 33 | } 34 | 35 | log.SetLevel(lvl) 36 | log.SetOutput(os.Stdout) 37 | 38 | store, err := store.NewGrafana(config.Grafana.ApiKey, config.Grafana.ApiUrl, config.Grafana.TLSKey, config.Grafana.TLSCert) 39 | if err != nil { 40 | log.Fatalf("failed to create Grafana store: %s", err.Error()) 41 | } 42 | err = store.Check() 43 | if err != nil { 44 | log.Fatalf("Grafana store is unhealthy: %s", err.Error()) 45 | } 46 | 47 | daemon := daemon.New(config, store) 48 | 49 | daemon.Run() 50 | } 51 | -------------------------------------------------------------------------------- /daemon/daemon.go: -------------------------------------------------------------------------------- 1 | package daemon 2 | 3 | import ( 4 | "os" 5 | "os/signal" 6 | "syscall" 7 | 8 | "github.com/grafana/memo/cfg" 9 | "github.com/grafana/memo/parser" 10 | discordService "github.com/grafana/memo/service/discord" 11 | slackService "github.com/grafana/memo/service/slack" 12 | "github.com/grafana/memo/store" 13 | log "github.com/sirupsen/logrus" 14 | ) 15 | 16 | // Daemon 17 | type Daemon struct { 18 | // store 19 | store store.Store 20 | // config 21 | config cfg.Config 22 | // parser 23 | parser parser.Parser 24 | } 25 | 26 | // New 27 | func New(config cfg.Config, store store.Store) *Daemon { 28 | d := Daemon{ 29 | store: store, 30 | config: config, 31 | parser: parser.New(), 32 | } 33 | 34 | return &d 35 | } 36 | 37 | // Run 38 | func (d *Daemon) Run() { 39 | log.Info("Memo starting") 40 | 41 | if d.config.Slack.Enabled { 42 | log.Info("slack enabled") 43 | _, err := slackService.New( 44 | d.config.Slack, 45 | d.parser, 46 | d.store, 47 | ) 48 | 49 | if err != nil { 50 | log.Fatalf("Could not initialise Slack Handler") 51 | } 52 | } 53 | 54 | if d.config.Discord.Enabled { 55 | log.Info("discord enabled") 56 | _, err := discordService.New( 57 | d.config.Discord, 58 | d.parser, 59 | d.store, 60 | ) 61 | 62 | if err != nil { 63 | log.Fatalf("could not initialise discord handler") 64 | } 65 | } 66 | 67 | var gracefulStop = make(chan os.Signal, 1) 68 | signal.Notify(gracefulStop, syscall.SIGTERM) 69 | signal.Notify(gracefulStop, syscall.SIGINT) 70 | 71 | // hold the process open until we panic or cancel 72 | <-gracefulStop 73 | 74 | log.Info("shutting down") 75 | os.Exit(0) 76 | } 77 | -------------------------------------------------------------------------------- /service/discord/discord.go: -------------------------------------------------------------------------------- 1 | package discord 2 | 3 | import ( 4 | "fmt" 5 | 6 | log "github.com/sirupsen/logrus" 7 | 8 | "github.com/bwmarrin/discordgo" 9 | mem "github.com/grafana/memo" 10 | "github.com/grafana/memo/cfg" 11 | "github.com/grafana/memo/parser" 12 | "github.com/grafana/memo/service" 13 | "github.com/grafana/memo/store" 14 | ) 15 | 16 | // DiscordService 17 | type DiscordService struct { 18 | // config 19 | config cfg.Discord 20 | 21 | // parser takes the memo and extracts the values from it 22 | parser parser.Parser 23 | // store puts the memo in the defined store 24 | store store.Store 25 | 26 | // client for communicating with discord API 27 | client *discordgo.Session 28 | } 29 | 30 | // Name returns the basic name of this service 31 | func (d DiscordService) Name() string { 32 | return "discord" 33 | } 34 | 35 | // handleMessage takes the discord message event and creates the memo, to pass 36 | // to the store for storing the memo 37 | func (d *DiscordService) handleMessage(s *discordgo.Session, m *discordgo.MessageCreate) { 38 | if m.Author.Bot { 39 | return 40 | } 41 | 42 | log.Debugf("new discord message: %v", m.Content) 43 | memo, err := d.parser.Parse(m.Content) 44 | if err != nil { 45 | if err.Error() != mem.ErrEmpty.Error() { 46 | d.client.ChannelMessageSend(m.ChannelID, fmt.Sprintf("memo failed: %s", err.Error())) 47 | } 48 | 49 | return 50 | } 51 | 52 | if memo == nil { 53 | return 54 | } 55 | 56 | tags := []string{ 57 | "author:" + m.Author.Username, 58 | "chan:" + m.ChannelID, 59 | "source:discord", 60 | } 61 | 62 | memo.BuildTags(tags) 63 | 64 | err = d.store.Save(*memo) 65 | if err != nil { 66 | d.client.ChannelMessageSend(m.ChannelID, fmt.Sprintf("memo failed: %s", err.Error())) 67 | return 68 | } 69 | 70 | d.client.ChannelMessageSend(m.ChannelID, "Memo saved!") 71 | } 72 | 73 | // New creates a new instance of this service 74 | func New(config cfg.Discord, parser parser.Parser, store store.Store) (service.Service, error) { 75 | client, err := discordgo.New("Bot " + config.BotToken) 76 | if err != nil { 77 | log.Fatalf("error connecting to discord: %s", err.Error()) 78 | } 79 | 80 | d := DiscordService{ 81 | config: config, 82 | parser: parser, 83 | store: store, 84 | client: client, 85 | } 86 | 87 | d.client.AddHandler(d.handleMessage) 88 | d.client.Identify.Intents |= discordgo.IntentsGuildMessages 89 | d.client.Identify.Intents |= discordgo.IntentMessageContent 90 | 91 | go func() { 92 | err := d.client.Open() 93 | if err != nil { 94 | log.Fatalf("discord connection failed: %s", err.Error()) 95 | } 96 | }() 97 | 98 | return d, nil 99 | } 100 | -------------------------------------------------------------------------------- /parser/parser_test.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | "testing" 7 | "time" 8 | 9 | "github.com/benbjohnson/clock" 10 | "github.com/grafana/memo" 11 | ) 12 | 13 | func TestParse(t *testing.T) { 14 | mock := clock.NewMock() 15 | mock.Add(10 * time.Hour) // clock is now 1970-01-01 10:00:00 +0000 UTC 16 | 17 | cases := []struct { 18 | msg string 19 | expErr error 20 | expDate time.Time 21 | expDesc string 22 | expTags []string 23 | expNil bool 24 | }{ 25 | // test empty cases 26 | { 27 | msg: "", 28 | expErr: memo.ErrEmpty, 29 | }, 30 | { 31 | msg: " ", 32 | expErr: memo.ErrEmpty, 33 | }, 34 | { 35 | msg: " ", 36 | expErr: memo.ErrEmpty, 37 | }, 38 | // test not for us cases 39 | { 40 | msg: "this is just a message in chat", 41 | expNil: true, 42 | }, 43 | // standard case 44 | { 45 | msg: "memo message", 46 | expDate: time.Unix(10*60*60-25, 0), 47 | expDesc: "message", 48 | expTags: []string{"memo"}, 49 | }, 50 | // standard with extraneous whitespace 51 | { 52 | msg: " memo some message ", 53 | expDate: time.Unix(10*60*60-25, 0), 54 | expDesc: "some message", 55 | expTags: []string{"memo"}, 56 | }, 57 | // override default offset 58 | { 59 | msg: "memo 0 some message", 60 | expDate: time.Unix(10*60*60, 0), 61 | expDesc: "some message", 62 | expTags: []string{"memo"}, 63 | }, 64 | // custom offset 65 | { 66 | msg: "memo 1 some message", 67 | expDate: time.Unix(10*60*60-1, 0), 68 | expDesc: "some message", 69 | expTags: []string{"memo"}, 70 | }, 71 | // more interesting timespec 72 | { 73 | msg: "memo 5min3s some message", 74 | expDate: time.Unix(10*60*60-5*60-3, 0), 75 | expDesc: "some message", 76 | expTags: []string{"memo"}, 77 | }, 78 | // same, but combined with extra tag 79 | { 80 | msg: "memo 5min3s some message some:tag", 81 | expDate: time.Unix(10*60*60-5*60-3, 0), 82 | expDesc: "some message", 83 | expTags: []string{"memo", "some:tag"}, 84 | }, 85 | // full date-time spec and extra tag 86 | { 87 | msg: "memo 1970-01-01T12:34:56Z some message some:tag xyz:tag", 88 | expDate: time.Unix(12*3600+34*60+56, 0).UTC(), 89 | expDesc: "some message", 90 | expTags: []string{"memo", "some:tag", "xyz:tag"}, 91 | }, 92 | } 93 | 94 | parser := New() 95 | parser.SetClock(mock) 96 | 97 | for i, c := range cases { 98 | m, err := parser.Parse(c.msg) 99 | if !errors.Is(err, c.expErr) { 100 | t.Errorf("case %d: bad err output\ninput: %#v\nexp %v\ngot %v", i, c.msg, c.expErr, err) 101 | } 102 | 103 | if err != nil { 104 | continue 105 | } 106 | 107 | if m == nil { 108 | if !c.expNil { 109 | t.Errorf("we are expecting a memo, but received none") 110 | } 111 | 112 | continue 113 | } 114 | 115 | m.Date = m.Date.Round(time.Second) 116 | 117 | if m.Date != c.expDate || m.Desc != c.expDesc || !reflect.DeepEqual(c.expTags, m.Tags) { 118 | t.Errorf("case %d: bad output\ninput: %#v\nexp date=%s, desc=%q, tags=%v\ngot date=%s, desc=%q, tags=%v\n", i, c.msg, c.expDate, c.expDesc, c.expTags, m.Date, m.Desc, m.Tags) 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /cmd/memo-cli/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "os/user" 8 | "time" 9 | 10 | "github.com/BurntSushi/toml" 11 | "github.com/grafana/memo" 12 | "github.com/grafana/memo/cfg" 13 | "github.com/grafana/memo/store" 14 | "github.com/mitchellh/go-homedir" 15 | ) 16 | 17 | // configFile 18 | var configFile string 19 | 20 | // timestamp 21 | var timestamp int 22 | 23 | // extraTags 24 | var extraTags CsvStringVar 25 | 26 | // message 27 | var message string 28 | 29 | // main 30 | func main() { 31 | flag.IntVar(×tamp, "ts", int(time.Now().Unix()), "unix timestamp. always defaults to 'now'") 32 | flag.Var(&extraTags, "tags", "One or more comma-separated tags to submit, in addition to 'memo', 'user:' and 'host:'") 33 | flag.StringVar(&message, "msg", "", "message to submit") 34 | flag.StringVar(&configFile, "config", "~/.memo.toml", "config file location") 35 | flag.Parse() 36 | 37 | if message == "" { 38 | fmt.Fprintln(os.Stderr, "message cannot be empty") 39 | os.Exit(2) 40 | } 41 | 42 | usr, err := user.Current() 43 | if err != nil { 44 | fmt.Fprintf(os.Stderr, "Failed to get current user: %s\n", err.Error()) 45 | os.Exit(2) 46 | } 47 | 48 | hostname, err := os.Hostname() 49 | if err != nil { 50 | fmt.Fprintf(os.Stderr, "Failed to get current hostname: %s\n", err.Error()) 51 | os.Exit(2) 52 | } 53 | 54 | configFile, err = homedir.Expand(configFile) 55 | if err != nil { 56 | fmt.Fprintf(os.Stderr, "Failed to get path to config file (%s): %s\n", configFile, err.Error()) 57 | os.Exit(2) 58 | } 59 | 60 | var config cfg.Config 61 | _, err = toml.DecodeFile(configFile, &config) 62 | if err != nil { 63 | fmt.Fprintf(os.Stderr, "Invalid config file %q: %s\n", configFile, err.Error()) 64 | os.Exit(2) 65 | } 66 | 67 | var tlsKey string 68 | var tlsCert string 69 | if config.Grafana.TLSKey != "" { 70 | tlsKey, err = homedir.Expand(config.Grafana.TLSKey) 71 | if err != nil { 72 | fmt.Fprintf(os.Stderr, "Failed to read tls_key (%s): %s\n", config.Grafana.TLSKey, err.Error()) 73 | os.Exit(2) 74 | } 75 | } 76 | if config.Grafana.TLSCert != "" { 77 | tlsCert, err = homedir.Expand(config.Grafana.TLSCert) 78 | if err != nil { 79 | fmt.Fprintf(os.Stderr, "Failed to read tls_cert (%s): %s\n", config.Grafana.TLSCert, err.Error()) 80 | os.Exit(2) 81 | } 82 | } 83 | 84 | store, err := store.NewGrafana(config.Grafana.ApiKey, config.Grafana.ApiUrl, tlsKey, tlsCert) 85 | if err != nil { 86 | fmt.Fprintf(os.Stderr, "failed to create Grafana store: %s\n", err.Error()) 87 | os.Exit(2) 88 | } 89 | 90 | memo := memo.Memo{ 91 | Date: time.Unix(int64(timestamp), 0), 92 | Desc: message, 93 | } 94 | 95 | tags := []string{ 96 | "memo", 97 | "user:" + usr.Username, 98 | "host:" + hostname, 99 | "source:cli", 100 | } 101 | 102 | memo.BuildTags(tags) 103 | memo.BuildTags(extraTags) 104 | 105 | if err != nil { 106 | fmt.Fprintf(os.Stderr, "failed to set tags: %s\n", err.Error()) 107 | os.Exit(2) 108 | } 109 | 110 | err = store.Save(memo) 111 | if err != nil { 112 | fmt.Fprintf(os.Stderr, "failed to save memo in store: %s\n", err.Error()) 113 | os.Exit(2) 114 | } 115 | 116 | fmt.Println("memo saved") 117 | } 118 | -------------------------------------------------------------------------------- /parser/parser.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | "strings" 7 | "time" 8 | 9 | "github.com/benbjohnson/clock" 10 | "github.com/grafana/memo" 11 | "github.com/raintank/dur" 12 | 13 | log "github.com/sirupsen/logrus" 14 | ) 15 | 16 | // Parser 17 | type Parser struct { 18 | // re 19 | re *regexp.Regexp 20 | 21 | // for mocking times in tests 22 | clock clock.Clock 23 | } 24 | 25 | // Parse takes a message and returns a memo with the fields extracted 26 | func (p *Parser) Parse(message string) (*memo.Memo, error) { 27 | message = strings.TrimSpace(message) 28 | 29 | if len(message) == 0 { 30 | return nil, memo.ErrEmpty 31 | } 32 | 33 | m := memo.Memo{} 34 | 35 | // does not detect "isForUs" validly 36 | ok, err := p.isForUs(message) 37 | 38 | // regex match fail 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | // not for us 44 | if !ok { 45 | return nil, nil 46 | } 47 | 48 | words := strings.Fields(message) 49 | if len(words) == 0 { 50 | return nil, memo.ErrEmpty 51 | } 52 | 53 | // [1:] strips out the "memo" trigger 54 | words, ts := p.extractTimestamp(words[1:]) 55 | 56 | m.Date = ts 57 | m.Desc = strings.Join(words, " ") 58 | 59 | pos := len(words) - 1 // pos of the last word that is not a tag 60 | for strings.Contains(words[pos], ":") { 61 | pos-- 62 | if pos < 0 { 63 | return &m, nil 64 | } 65 | } 66 | 67 | extraTags := words[pos+1:] 68 | m.BuildTags(extraTags) 69 | 70 | m.Desc = strings.Join(words[:pos+1], " ") 71 | 72 | return &m, nil 73 | } 74 | 75 | // isForUs returns if this message has been identified as a memo 76 | func (p *Parser) isForUs(message string) (bool, error) { 77 | out := p.re.FindStringSubmatch(message) 78 | if len(out) == 0 { 79 | if strings.HasPrefix(message, "memo:") || strings.HasPrefix(message, "mrbot:") || strings.HasPrefix(message, "memobot:") { 80 | log.Debugf("A user seems to direct a message `%q` to us, but we don't understand it. so sending help message back", message) 81 | return false, errors.New("message could not be understood") 82 | } 83 | 84 | // we're in a channel. don't spam in it. the message was probably not meant for us. 85 | log.Tracef("Received message `%q`, not for us. ignoring", message) 86 | return false, nil 87 | } 88 | 89 | return true, nil 90 | } 91 | 92 | // SetClock allows injection of a benbjohnson/clock clock.Clock 93 | // interface, for mocking within tests. 94 | func (p *Parser) SetClock(clock clock.Clock) { 95 | p.clock = clock 96 | } 97 | 98 | // extractTimestamp takes a timestamp at the start of the memo 99 | // (after memo phrase itself) written in RFC3339 format or time 100 | // strings compatible with [https://pkg.go.dev/github.com/raintank/dur#ParseDuration] 101 | // We make use of benbjohnson/clock to enable mocking of time for tests 102 | func (p *Parser) extractTimestamp(words []string) ([]string, time.Time) { 103 | // parse time offset out of message (if applicable) and set timestamp 104 | ts := p.clock.Now().Add(-25 * time.Second) 105 | dur, err := dur.ParseDuration(words[0]) 106 | if err == nil { 107 | ts = p.clock.Now().Add(-time.Duration(dur) * time.Second) 108 | words = words[1:] 109 | } else { 110 | parsed, err := time.Parse(time.RFC3339, words[0]) 111 | if err == nil { 112 | ts = parsed 113 | words = words[1:] 114 | } 115 | } 116 | 117 | return words, ts 118 | } 119 | 120 | // New returns a new instance of Parser 121 | func New() Parser { 122 | return Parser{ 123 | re: regexp.MustCompile("^memo(?::)? (.*)"), 124 | clock: clock.New(), 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | push: 10 | branches: [ "master" ] 11 | # Publish semver tags as releases. 12 | tags: [ 'v*.*.*' ] 13 | pull_request: 14 | branches: [ "master" ] 15 | workflow_dispatch: 16 | 17 | env: 18 | # Use docker.io for Docker Hub if empty 19 | REGISTRY: ghcr.io 20 | # github.repository as / 21 | IMAGE_NAME: ${{ github.repository }} 22 | 23 | 24 | jobs: 25 | build: 26 | 27 | runs-on: ubuntu-latest 28 | permissions: 29 | contents: read 30 | packages: write 31 | # This is used to complete the identity challenge 32 | # with sigstore/fulcio when running outside of PRs. 33 | id-token: write 34 | 35 | steps: 36 | - name: Checkout repository 37 | uses: actions/checkout@v3 38 | 39 | # Install the cosign tool except on PR 40 | # https://github.com/sigstore/cosign-installer 41 | - name: Install cosign 42 | if: github.event_name != 'pull_request' 43 | uses: sigstore/cosign-installer@v3.3.0 44 | with: 45 | cosign-release: 'v2.2.3' 46 | 47 | 48 | # Workaround: https://github.com/docker/build-push-action/issues/461 49 | - name: Setup Docker buildx 50 | uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf 51 | 52 | # Login against a Docker registry except on PR 53 | # https://github.com/docker/login-action 54 | - name: Log into registry ${{ env.REGISTRY }} 55 | if: github.event_name != 'pull_request' 56 | uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c 57 | with: 58 | registry: ${{ env.REGISTRY }} 59 | username: ${{ github.actor }} 60 | password: ${{ secrets.GITHUB_TOKEN }} 61 | 62 | # Extract metadata (tags, labels) for Docker 63 | # https://github.com/docker/metadata-action 64 | - name: Extract Docker metadata 65 | id: meta 66 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 67 | with: 68 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 69 | 70 | # Build and push Docker image with Buildx (don't push on PR) 71 | # https://github.com/docker/build-push-action 72 | - name: Build and push Docker image 73 | id: build-and-push 74 | uses: docker/build-push-action@ac9327eae2b366085ac7f6a2d02df8aa8ead720a 75 | with: 76 | context: . 77 | push: ${{ github.event_name != 'pull_request' }} 78 | tags: ${{ steps.meta.outputs.tags }} 79 | labels: ${{ steps.meta.outputs.labels }} 80 | cache-from: type=gha 81 | cache-to: type=gha,mode=max 82 | 83 | 84 | # Sign the resulting Docker image digest except on PRs. 85 | # This will only write to the public Rekor transparency log when the Docker 86 | # repository is public to avoid leaking data. If you would like to publish 87 | # transparency data even for private images, pass --force to cosign below. 88 | # https://github.com/sigstore/cosign 89 | - name: Sign the published Docker image 90 | if: ${{ github.event_name != 'pull_request' }} 91 | env: 92 | COSIGN_EXPERIMENTAL: "true" 93 | # This step uses the identity token to provision an ephemeral certificate 94 | # against the sigstore community Fulcio instance. 95 | run: echo "${{ steps.meta.outputs.tags }}" | xargs -I {} cosign sign --yes {}@${{ steps.build-and-push.outputs.digest }} 96 | 97 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/benbjohnson/clock v1.0.3 h1:vkLuvpK4fmtSCuo60+yC63p7y0BmQ8gm5ZXGuBCJyXg= 4 | github.com/benbjohnson/clock v1.0.3/go.mod h1:bGMdMPoPVvcYyt1gHDf4J2KE153Yf9BuiUKYMaxlTDM= 5 | github.com/bwmarrin/discordgo v0.26.1 h1:AIrM+g3cl+iYBr4yBxCBp9tD9jR3K7upEjl0d89FRkE= 6 | github.com/bwmarrin/discordgo v0.26.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= 11 | github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= 12 | github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= 13 | github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= 14 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 15 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 16 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 17 | github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= 18 | github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 19 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 20 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 21 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 22 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 23 | github.com/raintank/dur v0.0.0-20181019115741-955e3a77c6a8 h1:NMoWRRKQbUzTETQmWU1RH4TIRHDStAxr3prUgdcQwGk= 24 | github.com/raintank/dur v0.0.0-20181019115741-955e3a77c6a8/go.mod h1:7BB8LeqBvE3vEKv3ZbgA324vRXhUVO9pT9UrKnYDnx8= 25 | github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I= 26 | github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= 27 | github.com/slack-go/slack v0.11.3 h1:GN7revxEMax4amCc3El9a+9SGnjmBvSUobs0QnO6ZO8= 28 | github.com/slack-go/slack v0.11.3/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= 29 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 30 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 31 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 32 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 33 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= 34 | golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= 35 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 36 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 37 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 38 | golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= 39 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 40 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 41 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 42 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 43 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 44 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 45 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 46 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 47 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 48 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 49 | -------------------------------------------------------------------------------- /service/slack/slack.go: -------------------------------------------------------------------------------- 1 | package slack 2 | 3 | import ( 4 | "fmt" 5 | llog "log" 6 | "os" 7 | 8 | "github.com/grafana/memo/cfg" 9 | "github.com/grafana/memo/parser" 10 | "github.com/grafana/memo/service" 11 | "github.com/grafana/memo/store" 12 | 13 | "github.com/slack-go/slack" 14 | "github.com/slack-go/slack/slackevents" 15 | "github.com/slack-go/slack/socketmode" 16 | 17 | log "github.com/sirupsen/logrus" 18 | ) 19 | 20 | // SlackService 21 | type SlackService struct { 22 | // botToken xoxb- 23 | botToken string 24 | 25 | // appToken xapp- 26 | appToken string 27 | 28 | // parser takes the memo and extracts the values from it 29 | parser parser.Parser 30 | // store puts the memo in the defined store 31 | store store.Store 32 | 33 | // api client for talking to the slack API 34 | api *slack.Client 35 | // socket client connected to the slack websocket API 36 | socket *socketmode.Client 37 | 38 | // see https://github.com/nlopes/slack/issues/532 39 | // chanIdToNameCache 40 | chanIdToNameCache map[string]string 41 | // userIdToNameCache 42 | userIdToNameCache map[string]string 43 | } 44 | 45 | // Name returns the basic name of this service 46 | func (s SlackService) Name() string { 47 | return "slack" 48 | } 49 | 50 | // chanIdToName gets and stores the channel name from the id 51 | func (s *SlackService) chanIdToName(id string) string { 52 | name, ok := s.chanIdToNameCache[id] 53 | if ok { 54 | return name 55 | } 56 | g, err := s.api.GetConversationInfo(id, false) 57 | if err != nil { 58 | log.Debugf("GetChannelInfo error: %s", err.Error()) 59 | return "null" 60 | } 61 | s.chanIdToNameCache[id] = g.Name 62 | return g.Name 63 | } 64 | 65 | // userIdToName gets and stores the user name from the id 66 | func (s *SlackService) userIdToName(id string) string { 67 | name, ok := s.userIdToNameCache[id] 68 | if ok { 69 | return name 70 | } 71 | u, err := s.api.GetUserInfo(id) 72 | if err != nil { 73 | log.Errorf("GetUserInfo error, getting `%s`: %s (You probably don't have the `users:read` scope)", id, err.Error()) 74 | return "null" 75 | } 76 | s.userIdToNameCache[id] = u.Name 77 | return u.Name 78 | } 79 | 80 | // handleMessage takes the slack message event and creates the memo, to pass 81 | // to the store for storing the memo 82 | func (s *SlackService) handleMessage(msg *slackevents.MessageEvent) error { 83 | ch := s.chanIdToName(msg.Channel) 84 | usr := s.userIdToName(msg.User) 85 | 86 | memo, err := s.parser.Parse(msg.Text) 87 | if err != nil { 88 | s.api.PostMessage(msg.Channel, slack.MsgOptionPostEphemeral(msg.User), slack.MsgOptionText(err.Error(), false)) 89 | return err 90 | } 91 | 92 | // message is not for us, but not an error, just bail out 93 | if memo == nil { 94 | return nil 95 | } 96 | 97 | tags := []string{ 98 | "author:" + usr, 99 | "chan:" + ch, 100 | "source: slack", 101 | } 102 | 103 | memo.BuildTags(tags) 104 | 105 | err = s.store.Save(*memo) 106 | if err != nil { 107 | s.api.PostMessage(msg.Channel, slack.MsgOptionPostEphemeral(msg.User), slack.MsgOptionText("memo failed: "+err.Error(), false)) 108 | return err 109 | } 110 | 111 | s.api.PostMessage(msg.Channel, slack.MsgOptionPostEphemeral(msg.User), slack.MsgOptionText("Memo saved", false)) 112 | return nil 113 | } 114 | 115 | // New creates a new instance of this service 116 | func New(config cfg.Slack, parser parser.Parser, store store.Store) (service.Service, error) { 117 | s := SlackService{ 118 | botToken: config.BotToken, 119 | appToken: config.AppToken, 120 | 121 | parser: parser, 122 | store: store, 123 | 124 | chanIdToNameCache: make(map[string]string), 125 | userIdToNameCache: make(map[string]string), 126 | } 127 | 128 | s.api = slack.New( 129 | s.botToken, 130 | slack.OptionAppLevelToken(s.appToken), 131 | ) 132 | 133 | s.socket = socketmode.New( 134 | s.api, 135 | socketmode.OptionDebug(false), 136 | socketmode.OptionLog(llog.New(os.Stdout, "slack", llog.Lshortfile|llog.LstdFlags)), 137 | ) 138 | 139 | go func() { 140 | for evt := range s.socket.Events { 141 | switch evt.Type { 142 | case socketmode.EventTypeConnecting: 143 | log.Info("Connecting to slack") 144 | case socketmode.EventTypeConnectionError: 145 | log.Errorf("Connection error: %v", evt) 146 | case socketmode.EventTypeConnected: 147 | log.Info("Socket connected") 148 | case socketmode.EventTypeDisconnect: 149 | log.Info("Socket disconnected") 150 | case socketmode.EventTypeIncomingError: 151 | log.Errorf("Connection error: %v", evt) 152 | case socketmode.EventTypeHello: 153 | log.Info("Received hello from slack, hi!") 154 | case socketmode.EventTypeEventsAPI: 155 | eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) 156 | if !ok { 157 | fmt.Printf("Ignored %+v\n", evt) 158 | 159 | continue 160 | } 161 | 162 | log.Debugf("Received event: %+v", eventsAPIEvent) 163 | 164 | s.socket.Ack(*evt.Request) 165 | switch eventsAPIEvent.Type { 166 | case slackevents.CallbackEvent: 167 | innerEvent := eventsAPIEvent.InnerEvent 168 | switch ev := innerEvent.Data.(type) { 169 | case *slackevents.MessageEvent: 170 | log.Debugf("Inner event: %+v", ev) 171 | s.handleMessage(ev) 172 | } 173 | } 174 | default: 175 | fmt.Fprintf(os.Stderr, "Unexpected event type received: %s\n", evt.Type) 176 | } 177 | } 178 | }() 179 | 180 | go func() { 181 | err := s.socket.Run() 182 | log.Fatalf("slack socket closed: %s", err.Error()) 183 | }() 184 | 185 | return s, nil 186 | } 187 | -------------------------------------------------------------------------------- /store/grafana.go: -------------------------------------------------------------------------------- 1 | package store 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "encoding/json" 7 | "fmt" 8 | "io/ioutil" 9 | "net/http" 10 | "net/url" 11 | "path" 12 | 13 | "github.com/grafana/memo" 14 | log "github.com/sirupsen/logrus" 15 | ) 16 | 17 | // Grafana 18 | type Grafana struct { 19 | // apiKey generated in Grafana's settings, service accounts work too 20 | apiKey string 21 | // apiUrl is your grafana instance URI with /api appended 22 | // e.g. http://localhost/api/ 23 | apiUrl string 24 | 25 | // tlsKey for when you are using self signed certificates 26 | tlsKey string 27 | // tlsCert 28 | tlsCert string 29 | 30 | // bearerHeader is an internal cache of the header with the apiKey present 31 | bearerHeader string 32 | // apiUrlAnnotations is an internal cache of the url for the API 33 | apiUrlAnnotations string 34 | // apiUrlHealth is an internal cache of the url for the API health page 35 | apiUrlHealth string 36 | } 37 | 38 | // NewGrafana returns a new grafana instance 39 | func NewGrafana(apiKey, apiUrl, tlsKey, tlsCert string) (Grafana, error) { 40 | u, err := url.Parse(apiUrl) 41 | if err != nil { 42 | return Grafana{}, err 43 | } 44 | 45 | urlAnnotations := *u 46 | urlAnnotations.Path = path.Join(u.Path, "annotations") 47 | 48 | urlHealth := *u 49 | urlHealth.Path = path.Join(u.Path, "health") 50 | 51 | g := Grafana{ 52 | apiKey: apiKey, 53 | apiUrl: apiUrl, 54 | tlsKey: tlsKey, 55 | tlsCert: tlsCert, 56 | 57 | bearerHeader: fmt.Sprintf("Bearer %s", apiKey), 58 | apiUrlAnnotations: urlAnnotations.String(), 59 | apiUrlHealth: urlHealth.String(), 60 | } 61 | return g, nil 62 | } 63 | 64 | // GrafanaHealthResp 65 | type GrafanaHealthResp struct { 66 | // Commit 67 | Commit string 68 | // Database 69 | Database string 70 | // Version 71 | Version string 72 | } 73 | 74 | // httpClient returns the client for communicating with Grafana 75 | func (g Grafana) httpClient() (*http.Client, error) { 76 | client := &http.Client{} 77 | 78 | if g.tlsKey != "" || g.tlsCert != "" { 79 | // Load client cert 80 | cert, err := tls.LoadX509KeyPair(g.tlsCert, g.tlsKey) 81 | if err != nil { 82 | return nil, err 83 | } 84 | tlsConfig := &tls.Config{ 85 | Certificates: []tls.Certificate{cert}, 86 | } 87 | transport := &http.Transport{TLSClientConfig: tlsConfig} 88 | client.Transport = transport 89 | } 90 | return client, nil 91 | } 92 | 93 | // Check ensures the API is healthy 94 | func (g Grafana) Check() error { 95 | client, err := g.httpClient() 96 | if err != nil { 97 | return err 98 | } 99 | 100 | req, err := http.NewRequest("GET", g.apiUrlHealth, nil) 101 | if err != nil { 102 | return fmt.Errorf("grafana creation of request failed: %s", err) 103 | } 104 | 105 | req.Header.Set("Authorization", g.bearerHeader) 106 | 107 | resp, err := client.Do(req) 108 | if err != nil { 109 | return fmt.Errorf("grafana health check fail: %s", err) 110 | } 111 | defer resp.Body.Close() 112 | 113 | data, err := ioutil.ReadAll(resp.Body) 114 | if err != nil { 115 | return fmt.Errorf("grafana failed to read body: %s", err) 116 | } 117 | 118 | if resp.StatusCode != http.StatusOK { 119 | return fmt.Errorf("Grafana replied with http %d and body %s", resp.StatusCode, string(data)) 120 | } 121 | 122 | var gaResp GrafanaHealthResp 123 | err = json.Unmarshal(data, &gaResp) 124 | if err != nil { 125 | return fmt.Errorf("grafana failed to unmarshal grafana response: %s. The body was: %s", err, string(data)) 126 | } 127 | log.Infof("Can talk to Grafana version %s - its database is %s", gaResp.Version, gaResp.Database) 128 | return nil 129 | } 130 | 131 | // GrafanaAnnotationReq 132 | type GrafanaAnnotationReq struct { 133 | // Time unix ts in ms 134 | Time int64 `json:"time"` 135 | // IsRegion 136 | IsRegion bool `json:"isRegion"` 137 | // Tags 138 | Tags []string `json:"tags"` 139 | // Text 140 | Text string `json:"text"` 141 | } 142 | 143 | // GrafanaAnnotationResp 144 | type GrafanaAnnotationResp struct { 145 | // Message 146 | Message string `json:"message"` 147 | // Id 148 | Id int `json:"id"` 149 | // EndId 150 | EndId int `json:"endId"` 151 | } 152 | 153 | // Save stores the memo in the API 154 | func (g Grafana) Save(memo memo.Memo) error { 155 | ga := GrafanaAnnotationReq{ 156 | Time: memo.Date.Unix() * 1000, 157 | IsRegion: false, 158 | Tags: memo.Tags, 159 | Text: memo.Desc, 160 | } 161 | jsonValue, _ := json.Marshal(ga) 162 | 163 | client, err := g.httpClient() 164 | if err != nil { 165 | return err 166 | } 167 | 168 | req, err := http.NewRequest("POST", g.apiUrlAnnotations, bytes.NewBuffer(jsonValue)) 169 | if err != nil { 170 | return fmt.Errorf("grafana creation of request failed: %s", err) 171 | } 172 | 173 | req.Header.Set("Content-Type", "application/json") 174 | req.Header.Set("Authorization", g.bearerHeader) 175 | 176 | resp, err := client.Do(req) 177 | if err != nil { 178 | return fmt.Errorf("grafana post fail: %s", err) 179 | } 180 | defer resp.Body.Close() 181 | 182 | data, err := ioutil.ReadAll(resp.Body) 183 | if err != nil { 184 | return fmt.Errorf("grafana failed to read body: %s", err) 185 | } 186 | 187 | if resp.StatusCode != http.StatusOK { 188 | return fmt.Errorf("Grafana replied with http %d and body %s", resp.StatusCode, string(data)) 189 | } 190 | 191 | var gaResp GrafanaAnnotationResp 192 | err = json.Unmarshal(data, &gaResp) 193 | if err != nil { 194 | return fmt.Errorf("grafana failed to unmarshal grafana response: %s. The body was: %s", err, string(data)) 195 | } 196 | if gaResp.Message != "Annotation added" { 197 | return fmt.Errorf("Grafana replied with http %d and unexpected message %q", resp.StatusCode, gaResp.Message) 198 | } 199 | return nil 200 | } 201 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Memo 2 | 3 | inspired by https://github.com/Dieterbe/anthracite/ but now 4 | as part of your chatops, chatdev, chatmarketing, chatWhatever workflow! 5 | 6 | Comes with 2 programs: 7 | 8 | * memo-cli: submit grafana annotations from the cli 9 | * memod: slack bot, so you can can submit annotations from slack 10 | 11 | ## Huh? 12 | 13 | Turn a slack message like this ... 14 | ![usage in slack](./docs/img/memo-slack-screenshot.png) 15 | ... into an annotation like this: 16 | ![usage in slack](./docs/img/memo-in-grafana-from-slack.png) 17 | Luckily somebody shared this memo on slack, otherwise somebody might freak out if they see this chart! 18 | 19 | ## memo-cli 20 | 21 | ``` 22 | Usage of ./memo-cli: 23 | -config string 24 | config file location (default "~/.memo.toml") 25 | -msg string 26 | message to submit 27 | -tags value 28 | One or more comma-separated tags to submit, in addition to 'memo', 'user:' and 'host:' 29 | -ts int 30 | unix timestamp. always defaults to 'now' (default 1557953985) 31 | ``` 32 | 33 | ## memod 34 | 35 | Connects to slack and listens for "memo" messages which - if correctly formatted - will result in an annotation on the configured Grafana server 36 | 37 | #### Message format 38 | 39 | ``` 40 | memo [timespec] [tags] 41 | ``` 42 | 43 | `[foo]` denotes that `foo` is optional. 44 | 45 | 46 | #### timespec 47 | 48 | defaults to `25`, so by default it assumes your message is about 25 seconds after the actual event happened. 49 | 50 | It can have the following formats: 51 | 52 | * `` like 0 (seconds), 10 (seconds), 30s, 1min20s, 2h, etc. see https://github.com/raintank/dur denotes how long ago the event took place 53 | * `` like `2013-06-05T14:10:43Z` 54 | 55 | #### msg 56 | 57 | free-form text message, but if the first word looks like a timespec it will be interpreted as such. Any words at the end with `:` in them will be interpreted as tags. 58 | 59 | #### tags 60 | 61 | default tags included: 62 | 63 | * `memo` 64 | * `chan:slack channel (if not a PM)` 65 | * `author:slack username` 66 | 67 | you can extend these. any words at the end of the command that have `:` will be used as key-value tags. 68 | But you cannot override any of the default tags 69 | 70 | # Installation 71 | 72 | ## Configure Slack (only for memod) 73 | 74 | 1. Create a [new slack app](https://api.slack.com/apps) 75 | 1. Go to OAuth & Permissions and enable the bot token scopes listed [below](#oauth-scopes-required) 76 | 1. Click "Install App" to then connect it to your workspace and generate an `xoxb-xxxxx` token, which is the `bot_token` in the `[slack]` section 77 | 1. Enable socket mode on your application, which will then generate an `xapp-xxxxx` token, which is the `app_token` in the `[slack]` section 78 | 1. Enable event subscriptions 79 | 1. Subscribe to bot events: `message.channels` and `message.im` 80 | 81 | ### OAuth scopes required: 82 | - channels:history 83 | - channels:read 84 | - chat:write 85 | - im:history 86 | - im:read 87 | - users:read 88 | 89 | ## Configure Grafana 90 | 91 | 1. Log into your Grafana instance, eg https://something.grafana.net 92 | 1. Click into Administration > Users and access > Service accounts 93 | 1. Create a service account with the roles; `Annotations:Writer`, `Annotations:Dashboard annotation writer` & `Annotations:Organization annotation writer` 94 | 1. Add a new service account token and store that in your config.toml under `api_key` in the `[grafana]` section 95 | 96 | ## Install the program 97 | 98 | Currently we don't publish distribution packages, docker images etc. 99 | So for now, you need to build the binary/binaries from source 100 | 101 | First, [install golang](https://golang.org/dl/) 102 | Then, run any of these commands to download the source code and build the binaries: 103 | 104 | ``` 105 | go get github.com/grafana/memo/cmd/memod # only memod, the slack bot 106 | go get github.com/grafana/memo/cmd/memo-cli # only memo-cli, the command line tool 107 | go get github.com/grafana/memo/cmd/... # both 108 | ``` 109 | 110 | You will then have the binaries in `$HOME/bin` or in `$GOPATH/bin` if you have a custom GOPATH set. 111 | 112 | ## config file for memo-cli 113 | 114 | Put this file in `~/.memo.toml` 115 | 116 | ``` 117 | [grafana] 118 | api_key = "" 119 | api_url = "https:///api/" 120 | ``` 121 | 122 | ## config file for memod 123 | 124 | Put a config file like below in `/etc/memo.toml`. 125 | 126 | ``` 127 | # one of trace debug info warn error fatal panic 128 | log_level = "info" 129 | 130 | [slack] 131 | enabled = true 132 | bot_token = "" 133 | app_token = "" 134 | 135 | [discord] 136 | enabled = true 137 | bot_token = "" 138 | 139 | [grafana] 140 | api_key = "" 141 | api_url = "http://localhost/api/" 142 | ``` 143 | 144 | ## auto-starting memod 145 | 146 | If you use upstart, you need to create an init file and put it in /etc/init/memo.conf 147 | For your convenience you can use our [example upstart config file](./var/upstart-memo.conf) 148 | In this case also copy the binary to `/usr/bin/memod`. 149 | 150 | ## Set up the Grafana integration 151 | 152 | You need to create a new annotation query on your applicable dashboards. 153 | Make sure to set it to the Grafana datasource and use filtering by tag, you can use tags like `memo` and `chan:` or any other tags of your choosing. 154 | 155 | ![Grafana annotation query](./docs/img/configure-grafana-for-memo.png) 156 | 157 | # Docker 158 | 159 | A docker image is compiled for convenience: 160 | 161 | ``` 162 | # memod 163 | docker run -v "${PWD}/config.toml:/etc/memo.toml" ghcr.io/grafana/memo:master 164 | 165 | # memo-cli 166 | docker run -v "${PWD}/config.toml:/etc/memo.toml" ghcr.io/grafana/memo:master memo-cli -config /etc/memo.toml -msg "test" 167 | ``` 168 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2015 Grafana Labs 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | --------------------------------------------------------------------------------