├── .env.skeleton ├── internal ├── util │ ├── collection.go │ └── collection_test.go ├── setting │ ├── setting.go │ └── setting_test.go └── standup │ ├── standup_test.go │ └── standup.go ├── .gitignore ├── package.json ├── .github └── workflows │ └── ci.yml ├── go.mod ├── resources.yml ├── Makefile ├── .realize.yaml ├── cmd ├── start │ └── main.go ├── interactive │ └── main.go ├── send_questions │ └── main.go ├── slash │ └── main.go └── webhook │ └── main.go ├── serverless.yml └── go.sum /.env.skeleton: -------------------------------------------------------------------------------- 1 | GO111MODULE=on 2 | SLACK_TOKEN= 3 | SLACK_BOT_TOKEN= 4 | AWS_REGION= 5 | AWS_PROFILE= 6 | -------------------------------------------------------------------------------- /internal/util/collection.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | func Map(vs []string, f func(string) string) []string { 4 | vsm := make([]string, len(vs)) 5 | for i, v := range vs { 6 | vsm[i] = f(v) 7 | } 8 | return vsm 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Serverless directories 2 | .serverless 3 | 4 | # golang output binary directory 5 | bin 6 | 7 | node_modules 8 | template.yaml 9 | 10 | # Allow `$ go mod vendor` 11 | /vendor 12 | 13 | # For direnv 14 | .env 15 | .env.dev 16 | .env.prod 17 | .envrc 18 | -------------------------------------------------------------------------------- /internal/util/collection_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func TestMapSuccess(t *testing.T) { 10 | in := []string{" 1 ", "2 ", " 3"} 11 | want := []string{"1", "2", "3"} 12 | got := Map(in, strings.TrimSpace) 13 | 14 | if !reflect.DeepEqual(got, want) { 15 | t.Fatalf("Want %q, got %q", want, got) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-daily-standup-bot", 3 | "private": true, 4 | "scripts": { 5 | "deploy": "sls deploy --verbose", 6 | "remove": "sls remove", 7 | "invoke": "sls invoke", 8 | "logs": "sls logs", 9 | "sam-export": "sls sam export --output template.yaml" 10 | }, 11 | "devDependencies": { 12 | "serverless": "^1.63.0", 13 | "serverless-sam": "^0.2.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: push 3 | jobs: 4 | 5 | build: 6 | name: Run tests 7 | runs-on: ubuntu-latest 8 | steps: 9 | 10 | - name: Set up Go 1.12 11 | uses: actions/setup-go@v1 12 | with: 13 | go-version: 1.12 14 | id: go 15 | 16 | - name: Check out code into the Go module directory 17 | uses: actions/checkout@v1 18 | 19 | - name: Run tests 20 | run: go test -v ./... 21 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tsub/serverless-daily-standup-bot 2 | 3 | require ( 4 | github.com/aws/aws-lambda-go v1.13.3 5 | github.com/aws/aws-sdk-go v1.25.44 6 | github.com/fnproject/fdk-go v0.0.0-20180522161022-1eb29530716f 7 | github.com/gorilla/websocket v1.4.0 // indirect 8 | github.com/guregu/dynamo v1.4.1 9 | github.com/lestrrat-go/pdebug v0.0.0-20180220043849-39f9a71bcabe // indirect 10 | github.com/lestrrat-go/slack v0.0.0-20180726073730-18d3cce844c0 11 | github.com/nlopes/slack v0.5.0 12 | github.com/pkg/errors v0.8.0 // indirect 13 | github.com/tsub/slack v0.0.0-20180902133210-f7a41612f75f 14 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be // indirect 15 | google.golang.org/appengine v1.2.0 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /internal/setting/setting.go: -------------------------------------------------------------------------------- 1 | package setting 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/guregu/dynamo" 7 | ) 8 | 9 | var settingsTable = os.Getenv("SETTINGS_TABLE") 10 | 11 | type Setting struct { 12 | TargetChannelID string `dynamo:"target_channel_id"` 13 | Questions []string `dynamo:"questions"` 14 | UserIDs []string `dynamo:"user_ids,set"` 15 | } 16 | 17 | func Get(db *dynamo.DB, targetChannelID string) (*Setting, error) { 18 | table := db.Table(settingsTable) 19 | 20 | var s Setting 21 | if err := table.Get("target_channel_id", targetChannelID).One(&s); err != nil { 22 | return nil, err 23 | } 24 | 25 | return &s, nil 26 | } 27 | 28 | func Initial(db *dynamo.DB, targetChannelID string, questions []string, userIDs []string) error { 29 | table := db.Table(settingsTable) 30 | 31 | s := Setting{ 32 | TargetChannelID: targetChannelID, 33 | Questions: questions, 34 | UserIDs: userIDs, 35 | } 36 | if err := table.Put(s).Run(); err != nil { 37 | return err 38 | } 39 | 40 | return nil 41 | 42 | } 43 | -------------------------------------------------------------------------------- /resources.yml: -------------------------------------------------------------------------------- 1 | Resources: 2 | DynamoDBStandUpsTable: 3 | Type: AWS::DynamoDB::Table 4 | # DeletionPolicy: Retain # TODO: uncomment when released 5 | Properties: 6 | KeySchema: 7 | - AttributeName: user_id 8 | KeyType: HASH 9 | - AttributeName: date 10 | KeyType: RANGE 11 | AttributeDefinitions: 12 | - AttributeName: user_id 13 | AttributeType: S 14 | - AttributeName: date 15 | AttributeType: S 16 | BillingMode: PAY_PER_REQUEST 17 | TableName: ${self:custom.resourcePrefix}-standups 18 | StreamSpecification: 19 | StreamViewType: NEW_IMAGE 20 | 21 | DynamoDBSettingsTable: 22 | Type: AWS::DynamoDB::Table 23 | # DeletionPolicy: Retain # TODO: uncomment when released 24 | Properties: 25 | KeySchema: 26 | - AttributeName: target_channel_id 27 | KeyType: HASH 28 | AttributeDefinitions: 29 | - AttributeName: target_channel_id 30 | AttributeType: S 31 | BillingMode: PAY_PER_REQUEST 32 | TableName: ${self:custom.resourcePrefix}-settings 33 | 34 | StartLambdaFunctionPermission: 35 | Type: AWS::Lambda::Permission 36 | Properties: 37 | Action: lambda:InvokeFunction 38 | FunctionName: 39 | Fn::GetAtt: 40 | - StartLambdaFunction 41 | - Arn 42 | Principal: events.amazonaws.com 43 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | UNAME = ${shell uname} 2 | 3 | .PHONY: build 4 | build: 5 | env GOOS=linux go build -ldflags="-s -w" -o bin/webhook cmd/webhook/main.go 6 | env GOOS=linux go build -ldflags="-s -w" -o bin/start cmd/start/main.go 7 | env GOOS=linux go build -ldflags="-s -w" -o bin/send_questions cmd/send_questions/main.go 8 | env GOOS=linux go build -ldflags="-s -w" -o bin/slash cmd/slash/main.go 9 | env GOOS=linux go build -ldflags="-s -w" -o bin/interactive cmd/interactive/main.go 10 | 11 | .PHONY: test 12 | test: 13 | go test ./... 14 | 15 | .PHONY: watch 16 | watch: 17 | realize start 18 | 19 | .PHONY: clean 20 | clean: 21 | rm -rf ./bin 22 | rm -rf ./.serverless 23 | 24 | .PHONY: deploy 25 | deploy: clean build 26 | npm install 27 | npm run deploy 28 | 29 | .PHONY: deploy-prod 30 | deploy-prod: clean build 31 | npm install 32 | npm run deploy -- -s prod 33 | 34 | .PHONY: start-api 35 | start-api: clean build 36 | npm install 37 | npm run sam-export --skip-pull-image 38 | unzip -u -d .serverless .serverless/daily-standup-bot.zip 39 | 40 | if [ $(UNAME) = Linux ]; then\ 41 | sed -i 's/Dynamodb/DynamoDB/' template.yaml;\ 42 | sed -i 's/daily-standup-bot.zip//' template.yaml;\ 43 | elif [ $(UNAME) = Darwin ]; then\ 44 | sed -i '' 's/Dynamodb/DynamoDB/' template.yaml;\ 45 | sed -i '' 's/daily-standup-bot.zip//' template.yaml;\ 46 | fi 47 | 48 | docker pull lambci/lambda:go1.x 49 | sam local start-api --skip-pull-image 50 | -------------------------------------------------------------------------------- /.realize.yaml: -------------------------------------------------------------------------------- 1 | settings: 2 | legacy: 3 | force: false 4 | interval: 0s 5 | 6 | schema: 7 | - name: sendQuestions 8 | path: ./cmd/send_questions/ 9 | env: 10 | GOOS: linux 11 | commands: 12 | build: 13 | status: true 14 | args: 15 | - -ldflags=-s 16 | - -ldflags=-w 17 | - -o ../../.serverless/bin/send_questions 18 | watcher: &watcher 19 | extensions: 20 | - go 21 | paths: 22 | - / 23 | ignore: 24 | paths: 25 | - .git 26 | - .realize 27 | - vendor 28 | 29 | - name: start 30 | path: ./cmd/start/ 31 | commands: 32 | build: 33 | status: true 34 | args: 35 | - -ldflags=-s 36 | - -ldflags=-w 37 | - -o ../../.serverless/bin/start 38 | watcher: *watcher 39 | 40 | - name: webhook 41 | path: ./cmd/webhook/ 42 | commands: 43 | build: 44 | status: true 45 | args: 46 | - -ldflags=-s 47 | - -ldflags=-w 48 | - -o ../../.serverless/bin/webhook 49 | watcher: *watcher 50 | 51 | - name: slash 52 | path: ./cmd/slash/ 53 | commands: 54 | build: 55 | status: true 56 | args: 57 | - -ldflags=-s 58 | - -ldflags=-w 59 | - -o ../../.serverless/bin/slash 60 | watcher: *watcher 61 | 62 | - name: interactive 63 | path: ./cmd/interactive/ 64 | commands: 65 | build: 66 | status: true 67 | args: 68 | - -ldflags=-s 69 | - -ldflags=-w 70 | - -o ../../.serverless/bin/interactive 71 | watcher: *watcher 72 | -------------------------------------------------------------------------------- /internal/standup/standup_test.go: -------------------------------------------------------------------------------- 1 | package standup 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/aws/aws-sdk-go/aws" 8 | "github.com/aws/aws-sdk-go/aws/request" 9 | "github.com/aws/aws-sdk-go/service/dynamodb" 10 | "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbiface" 11 | "github.com/guregu/dynamo" 12 | ) 13 | 14 | type mockedDynamo struct { 15 | dynamodbiface.DynamoDBAPI 16 | Resp *Standup 17 | } 18 | 19 | func (m *mockedDynamo) PutItemWithContext(context aws.Context, input *dynamodb.PutItemInput, options ...request.Option) (*dynamodb.PutItemOutput, error) { 20 | for _, responseAnswer := range m.Resp.Answers { 21 | for _, inputAnswer := range input.Item["answers"].SS { 22 | if *inputAnswer != responseAnswer.Text { 23 | return nil, 24 | fmt.Errorf("Mismatch answers, input: %q, response: %q", 25 | *inputAnswer, 26 | responseAnswer) 27 | } 28 | } 29 | } 30 | 31 | return &dynamodb.PutItemOutput{}, nil 32 | } 33 | 34 | func TestAppendAnswerSuccess(t *testing.T) { 35 | want := &Standup{ 36 | UserID: "user", 37 | Answers: []Answer{ 38 | Answer{Text: "answer1"}, 39 | Answer{Text: "answer2"}, 40 | }, 41 | } 42 | 43 | standup := &Standup{ 44 | UserID: "user", 45 | Answers: []Answer{ 46 | Answer{Text: "answer1"}, 47 | }, 48 | } 49 | 50 | mockedClient := &mockedDynamo{Resp: want} 51 | db := dynamo.NewFromIface(mockedClient) 52 | 53 | err := standup.AppendAnswer(db, Answer{Text: "answer2"}) 54 | if err != nil { 55 | t.Fatalf("%q", err) 56 | } 57 | } 58 | 59 | func TestCancelSuccess(t *testing.T) { 60 | want := &Standup{ 61 | UserID: "user", 62 | Answers: []Answer{ 63 | Answer{Text: "none"}, 64 | Answer{Text: "none"}, 65 | }, 66 | } 67 | 68 | standup := &Standup{ 69 | UserID: "user", 70 | Answers: []Answer{ 71 | Answer{Text: "answer1"}, 72 | }, 73 | } 74 | 75 | mockedClient := &mockedDynamo{Resp: want} 76 | db := dynamo.NewFromIface(mockedClient) 77 | 78 | err := standup.Cancel(db) 79 | if err != nil { 80 | t.Fatalf("%q", err) 81 | } 82 | } 83 | 84 | func TestGetSuccess(t *testing.T) {} 85 | func TestInitialSuccess(t *testing.T) {} 86 | -------------------------------------------------------------------------------- /cmd/start/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | 8 | "github.com/aws/aws-lambda-go/lambda" 9 | "github.com/aws/aws-sdk-go/aws/session" 10 | "github.com/guregu/dynamo" 11 | "github.com/nlopes/slack" 12 | "github.com/tsub/serverless-daily-standup-bot/internal/setting" 13 | "github.com/tsub/serverless-daily-standup-bot/internal/standup" 14 | ) 15 | 16 | type input struct { 17 | TargetChannelID string `json:"target_channel_id"` 18 | } 19 | 20 | var slackToken = os.Getenv("SLACK_TOKEN") 21 | 22 | // Handler is our lambda handler invoked by the `lambda.Start` function call 23 | func Handler(ctx context.Context, input input) error { 24 | if input.TargetChannelID == "" { 25 | log.Println("There is no target_channel_id") 26 | return nil 27 | } 28 | 29 | db := dynamo.New(session.New()) 30 | 31 | s, err := setting.Get(db, input.TargetChannelID) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | ctx, cancel := context.WithCancel(context.Background()) 37 | defer cancel() 38 | 39 | cl := slack.New(slackToken) 40 | 41 | var initialRequireUserIDs []string 42 | for _, userID := range s.UserIDs { 43 | resp, err := cl.GetUserInfoContext(ctx, userID) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | _, err = standup.Get(db, resp.TZ, userID, false) 49 | if err != nil { 50 | // To skip "dynamo: no item found" error 51 | initialRequireUserIDs = append(initialRequireUserIDs, userID) 52 | } 53 | } 54 | 55 | if len(initialRequireUserIDs) == 0 { 56 | log.Println("Skip since it has already been executed today.") 57 | return nil 58 | } 59 | 60 | for _, userID := range initialRequireUserIDs { 61 | resp, err := cl.GetUserInfoContext(ctx, userID) 62 | if err != nil { 63 | return err 64 | } 65 | 66 | questions := make([]standup.Question, len(s.Questions)) 67 | for i, text := range s.Questions { 68 | questions[i] = standup.Question{Text: text} 69 | } 70 | 71 | if err := standup.Initial(db, resp.TZ, userID, questions, s.TargetChannelID); err != nil { 72 | return err 73 | } 74 | } 75 | 76 | return nil 77 | } 78 | 79 | func main() { 80 | lambda.Start(Handler) 81 | } 82 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: daily-standup-bot 2 | 3 | frameworkVersion: ">=1.28.0 <2.0.0" 4 | 5 | provider: 6 | name: aws 7 | region: ${env:AWS_REGION, 'us-east-1'} 8 | runtime: go1.x 9 | iamRoleStatements: 10 | - Effect: Allow 11 | Action: 12 | - dynamodb:GetItem 13 | - dynamodb:PutItem 14 | Resource: 15 | - arn:aws:dynamodb:${self:provider.region}:*:table/${self:custom.resourcePrefix}-standups 16 | - arn:aws:dynamodb:${self:provider.region}:*:table/${self:custom.resourcePrefix}-settings 17 | - Effect: Allow 18 | Action: 19 | - events:DescribeRule 20 | - events:PutRule 21 | - events:PutTargets 22 | Resource: 23 | - "*" 24 | 25 | custom: 26 | currentStage: ${opt:stage, self:provider.stage} 27 | resourcePrefix: ${self:service}-${self:custom.currentStage} 28 | 29 | package: 30 | exclude: 31 | - ./** 32 | include: 33 | - ./bin/** 34 | 35 | functions: 36 | webhook: 37 | handler: bin/webhook 38 | events: 39 | - http: 40 | path: webhook 41 | method: post 42 | environment: 43 | STANDUPS_TABLE: ${self:custom.resourcePrefix}-standups 44 | SLACK_TOKEN: ${env:SLACK_TOKEN} 45 | SLACK_BOT_TOKEN: ${env:SLACK_BOT_TOKEN} 46 | start: 47 | handler: bin/start 48 | environment: 49 | STANDUPS_TABLE: ${self:custom.resourcePrefix}-standups 50 | SETTINGS_TABLE: ${self:custom.resourcePrefix}-settings 51 | SLACK_TOKEN: ${env:SLACK_TOKEN} 52 | send-questions: 53 | handler: bin/send_questions 54 | events: 55 | - stream: 56 | type: dynamodb 57 | arn: 58 | Fn::GetAtt: 59 | - DynamoDBStandUpsTable 60 | - StreamArn 61 | startingPosition: TRIM_HORIZON 62 | environment: 63 | STANDUPS_TABLE: ${self:custom.resourcePrefix}-standups 64 | SLACK_TOKEN: ${env:SLACK_TOKEN} 65 | SLACK_BOT_TOKEN: ${env:SLACK_BOT_TOKEN} 66 | slash: 67 | handler: bin/slash 68 | events: 69 | - http: 70 | path: slash 71 | method: post 72 | environment: 73 | SETTINGS_TABLE: ${self:custom.resourcePrefix}-settings 74 | SLACK_BOT_TOKEN: ${env:SLACK_BOT_TOKEN} 75 | RESOURCE_PREFIX: ${self:custom.resourcePrefix} 76 | interactive: 77 | handler: bin/interactive 78 | events: 79 | - http: 80 | path: interactive 81 | method: post 82 | environment: 83 | SETTINGS_TABLE: ${self:custom.resourcePrefix}-settings 84 | SLACK_BOT_TOKEN: ${env:SLACK_BOT_TOKEN} 85 | RESOURCE_PREFIX: ${self:custom.resourcePrefix} 86 | START_FUNCTION_ARN: 87 | Fn::Join: 88 | - ":" 89 | - - "arn:aws:lambda:${self:provider.region}" 90 | - Ref: "AWS::AccountId" 91 | - "function:${self:custom.resourcePrefix}-start" 92 | 93 | resources: ${file(resources.yml)} 94 | 95 | plugins: 96 | - serverless-sam 97 | -------------------------------------------------------------------------------- /internal/setting/setting_test.go: -------------------------------------------------------------------------------- 1 | package setting 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/aws/aws-sdk-go/aws" 9 | "github.com/aws/aws-sdk-go/aws/request" 10 | "github.com/aws/aws-sdk-go/service/dynamodb" 11 | "github.com/aws/aws-sdk-go/service/dynamodb/dynamodbiface" 12 | "github.com/guregu/dynamo" 13 | ) 14 | 15 | type mockedDynamo struct { 16 | dynamodbiface.DynamoDBAPI 17 | Resp *Setting 18 | } 19 | 20 | func (m *mockedDynamo) GetItemWithContext(context aws.Context, input *dynamodb.GetItemInput, options ...request.Option) (*dynamodb.GetItemOutput, error) { 21 | item := map[string]*dynamodb.AttributeValue{ 22 | "target_channel_id": &dynamodb.AttributeValue{S: &m.Resp.TargetChannelID}, 23 | "questions": &dynamodb.AttributeValue{SS: []*string{&m.Resp.Questions[0]}}, 24 | "user_ids": &dynamodb.AttributeValue{SS: []*string{&m.Resp.UserIDs[0]}}, 25 | } 26 | 27 | return &dynamodb.GetItemOutput{ 28 | Item: item, 29 | }, nil 30 | } 31 | 32 | func (m *mockedDynamo) PutItemWithContext(context aws.Context, input *dynamodb.PutItemInput, options ...request.Option) (*dynamodb.PutItemOutput, error) { 33 | if *input.Item["target_channel_id"].S != m.Resp.TargetChannelID { 34 | return nil, 35 | fmt.Errorf("Mismatch target_channel_id, input: %q, response: %q", 36 | *input.Item["target_channel_id"].S, 37 | m.Resp.TargetChannelID) 38 | } 39 | 40 | for _, responseQuestion := range m.Resp.Questions { 41 | for _, inputQuestion := range input.Item["questions"].SS { 42 | if *inputQuestion != responseQuestion { 43 | return nil, 44 | fmt.Errorf("Mismatch questions, input: %q, response: %q", 45 | *inputQuestion, 46 | responseQuestion) 47 | } 48 | } 49 | } 50 | 51 | for _, responseUserID := range m.Resp.UserIDs { 52 | for _, inputUserID := range input.Item["user_ids"].SS { 53 | if *inputUserID != responseUserID { 54 | return nil, 55 | fmt.Errorf("Mismatch user_ids, input: %q, response: %q", 56 | *inputUserID, 57 | responseUserID) 58 | } 59 | } 60 | } 61 | 62 | return &dynamodb.PutItemOutput{}, nil 63 | } 64 | 65 | func TestGetSuccess(t *testing.T) { 66 | want := &Setting{ 67 | TargetChannelID: "channelID", 68 | Questions: []string{"q1"}, 69 | UserIDs: []string{"user1"}, 70 | } 71 | 72 | mockedClient := &mockedDynamo{Resp: want} 73 | db := dynamo.NewFromIface(mockedClient) 74 | 75 | got, err := Get(db, want.TargetChannelID) 76 | if err != nil { 77 | t.Fatalf("%q", err) 78 | } 79 | 80 | if !reflect.DeepEqual(got, want) { 81 | t.Fatalf("Want %q, got %q", want, got) 82 | } 83 | } 84 | 85 | func TestInitialSuccess(t *testing.T) { 86 | want := &Setting{ 87 | TargetChannelID: "channelID", 88 | Questions: []string{"q1"}, 89 | UserIDs: []string{"user1"}, 90 | } 91 | 92 | mockedClient := &mockedDynamo{Resp: want} 93 | db := dynamo.NewFromIface(mockedClient) 94 | 95 | err := Initial(db, want.TargetChannelID, want.Questions, want.UserIDs) 96 | if err != nil { 97 | t.Fatalf("%q", err) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /internal/standup/standup.go: -------------------------------------------------------------------------------- 1 | package standup 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "time" 7 | 8 | "github.com/guregu/dynamo" 9 | ) 10 | 11 | var standupsTable = os.Getenv("STANDUPS_TABLE") 12 | 13 | type Standup struct { 14 | UserID string `dynamo:"user_id"` 15 | Date string `dynamo:"date"` 16 | Questions []Question `dynamo:"questions"` 17 | Answers []Answer `dynamo:"answers"` 18 | TargetChannelID string `dynamo:"target_channel_id"` 19 | FinishedAt string `dynamo:"finished_at"` 20 | } 21 | 22 | type Answer struct { 23 | Text string `dynamo:"text"` 24 | PostedAt string `dynamo:"posted_at"` 25 | } 26 | 27 | type Question struct { 28 | Text string `dynamo:"text"` 29 | PostedAt string `dynamo:"posted_at"` 30 | } 31 | 32 | func (s *Standup) AppendAnswer(db *dynamo.DB, answer Answer) error { 33 | table := db.Table(standupsTable) 34 | 35 | s.Answers = append(s.Answers, answer) 36 | if err := table.Put(s).Run(); err != nil { 37 | return err 38 | } 39 | 40 | return nil 41 | } 42 | 43 | func (s *Standup) UpdateAnswer(db *dynamo.DB, updateAnswer Answer) error { 44 | table := db.Table(standupsTable) 45 | 46 | for i, answer := range s.Answers { 47 | if answer.PostedAt == updateAnswer.PostedAt { 48 | s.Answers[i] = updateAnswer 49 | 50 | if err := table.Put(s).Run(); err != nil { 51 | return err 52 | } 53 | 54 | return nil 55 | } 56 | } 57 | 58 | return errors.New("Target answer is not found.") 59 | } 60 | 61 | func (s *Standup) SentQuestion(db *dynamo.DB, questionIndex int, postedAt string) error { 62 | table := db.Table(standupsTable) 63 | 64 | s.Questions[questionIndex].PostedAt = postedAt 65 | 66 | if err := table.Put(s).Run(); err != nil { 67 | return err 68 | } 69 | 70 | return nil 71 | } 72 | 73 | func (s *Standup) Finish(db *dynamo.DB, finishedAt string) error { 74 | table := db.Table(standupsTable) 75 | 76 | s.FinishedAt = finishedAt 77 | if err := table.Put(s).Run(); err != nil { 78 | return err 79 | } 80 | 81 | return nil 82 | } 83 | 84 | func (s *Standup) Cancel(db *dynamo.DB) error { 85 | table := db.Table(standupsTable) 86 | 87 | var cancels []Answer 88 | for range s.Questions { 89 | cancels = append(cancels, Answer{Text: "none"}) 90 | } 91 | s.Answers = cancels 92 | 93 | if err := table.Put(s).Run(); err != nil { 94 | return err 95 | } 96 | 97 | return nil 98 | } 99 | 100 | func Get(db *dynamo.DB, tz string, userID string, consistent bool) (*Standup, error) { 101 | locate, err := time.LoadLocation(tz) 102 | if err != nil { 103 | return nil, err 104 | } 105 | 106 | table := db.Table(standupsTable) 107 | today := time.Now().In(locate).Format("2006-01-02") 108 | 109 | var s Standup 110 | if err = table.Get("user_id", userID).Range("date", dynamo.Equal, today).Consistent(consistent).One(&s); err != nil { 111 | return nil, err 112 | } 113 | 114 | return &s, nil 115 | } 116 | 117 | func Initial(db *dynamo.DB, tz string, userID string, questions []Question, targetChannelID string) error { 118 | locate, err := time.LoadLocation(tz) 119 | if err != nil { 120 | return err 121 | } 122 | 123 | table := db.Table(standupsTable) 124 | today := time.Now().In(locate).Format("2006-01-02") 125 | 126 | s := Standup{ 127 | UserID: userID, 128 | Date: today, 129 | Questions: questions, 130 | Answers: []Answer{}, 131 | TargetChannelID: targetChannelID, 132 | } 133 | if err := table.Put(s).Run(); err != nil { 134 | return err 135 | } 136 | 137 | return nil 138 | } 139 | -------------------------------------------------------------------------------- /cmd/interactive/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net/url" 9 | "os" 10 | "strings" 11 | 12 | "github.com/aws/aws-lambda-go/events" 13 | "github.com/aws/aws-lambda-go/lambda" 14 | "github.com/aws/aws-sdk-go/aws" 15 | "github.com/aws/aws-sdk-go/aws/session" 16 | "github.com/aws/aws-sdk-go/service/cloudwatchevents" 17 | "github.com/guregu/dynamo" 18 | "github.com/nlopes/slack" 19 | "github.com/tsub/serverless-daily-standup-bot/internal/setting" 20 | "github.com/tsub/serverless-daily-standup-bot/internal/util" 21 | ) 22 | 23 | // Response is of type APIGatewayProxyResponse since we're leveraging the 24 | // AWS Lambda Proxy Request functionality (default behavior) 25 | // 26 | // https://serverless.com/framework/docs/providers/aws/events/apigateway/#lambda-proxy-integration 27 | type Response events.APIGatewayProxyResponse 28 | 29 | type Input struct { 30 | TargetChannelID string `json:"target_channel_id"` 31 | } 32 | 33 | var botSlackToken = os.Getenv("SLACK_BOT_TOKEN") 34 | var startFunctionArn = os.Getenv("START_FUNCTION_ARN") 35 | var resourcePrefix = os.Getenv("RESOURCE_PREFIX") 36 | 37 | func initialSettings(payload slack.DialogCallback) (Response, error) { 38 | sess := session.New() 39 | db := dynamo.New(sess) 40 | cwe := cloudwatchevents.New(sess) 41 | 42 | targetChannelID := payload.Submission["target_channel_id"] 43 | questions := util.Map(strings.Split(payload.Submission["questions"], "\n"), strings.TrimSpace) 44 | userIDs := util.Map(strings.Split(payload.Submission["user_ids"], "\n"), strings.TrimSpace) 45 | scheduleExpression := strings.TrimSpace(payload.Submission["schedule_expression"]) 46 | teamID := payload.Team.ID 47 | replyChannelID := payload.Channel.ID 48 | 49 | err := setting.Initial(db, targetChannelID, questions, userIDs) 50 | if err != nil { 51 | return Response{StatusCode: 500}, err 52 | } 53 | 54 | ruleName := fmt.Sprintf("%s-%s-%s", resourcePrefix, teamID, targetChannelID) 55 | 56 | putRuleInput := &cloudwatchevents.PutRuleInput{ 57 | Name: aws.String(ruleName), 58 | ScheduleExpression: aws.String(scheduleExpression), 59 | } 60 | _, err = cwe.PutRule(putRuleInput) 61 | if err != nil { 62 | return Response{StatusCode: 500}, err 63 | } 64 | 65 | input, err := json.Marshal(Input{TargetChannelID: targetChannelID}) 66 | if err != nil { 67 | return Response{StatusCode: 500}, err 68 | } 69 | 70 | putTargetsInput := &cloudwatchevents.PutTargetsInput{ 71 | Rule: aws.String(ruleName), 72 | Targets: []*cloudwatchevents.Target{ 73 | &cloudwatchevents.Target{ 74 | Id: aws.String("1"), 75 | Arn: aws.String(startFunctionArn), 76 | Input: aws.String(string(input[:])), 77 | }, 78 | }, 79 | } 80 | _, err = cwe.PutTargets(putTargetsInput) 81 | if err != nil { 82 | return Response{StatusCode: 500}, err 83 | } 84 | 85 | ctx, cancel := context.WithCancel(context.Background()) 86 | defer cancel() 87 | cl := slack.New(botSlackToken) 88 | 89 | params := slack.NewPostMessageParameters() 90 | _, _, err = cl.PostMessageContext( 91 | ctx, 92 | replyChannelID, 93 | slack.MsgOptionText("Setting finished", false), 94 | slack.MsgOptionPostMessageParameters(params), 95 | ) 96 | if err != nil { 97 | return Response{StatusCode: 500}, err 98 | } 99 | 100 | return Response{StatusCode: 200}, nil 101 | } 102 | 103 | func handlePayload(payload slack.DialogCallback) (resp Response, err error) { 104 | // for debug 105 | log.Printf("payload: %v", payload) 106 | 107 | switch payload.CallbackID { 108 | case "setting": 109 | resp, err = initialSettings(payload) 110 | if err != nil { 111 | return resp, err 112 | } 113 | default: 114 | resp = Response{StatusCode: 200} 115 | } 116 | 117 | return resp, nil 118 | } 119 | 120 | // Handler is our lambda handler invoked by the `lambda.Start` function call 121 | func Handler(ctx context.Context, request events.APIGatewayProxyRequest) (Response, error) { 122 | query, err := url.ParseQuery(request.Body) 123 | if err != nil { 124 | return Response{StatusCode: 400}, nil 125 | } 126 | 127 | var payload slack.DialogCallback 128 | err = json.Unmarshal([]byte(query.Get("payload")), &payload) 129 | 130 | return handlePayload(payload) 131 | } 132 | 133 | func main() { 134 | lambda.Start(Handler) 135 | } 136 | -------------------------------------------------------------------------------- /cmd/send_questions/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "log" 7 | "os" 8 | 9 | "github.com/aws/aws-lambda-go/events" 10 | "github.com/aws/aws-lambda-go/lambda" 11 | "github.com/aws/aws-sdk-go/aws/session" 12 | "github.com/guregu/dynamo" 13 | "github.com/nlopes/slack" 14 | "github.com/tsub/serverless-daily-standup-bot/internal/standup" 15 | ) 16 | 17 | var slackToken = os.Getenv("SLACK_TOKEN") 18 | var botSlackToken = os.Getenv("SLACK_BOT_TOKEN") 19 | 20 | // Handler is our lambda handler invoked by the `lambda.Start` function call 21 | func Handler(ctx context.Context, e events.DynamoDBEvent) error { 22 | jsonEvent, err := json.Marshal(e) 23 | if err != nil { 24 | return err 25 | } 26 | log.Printf("event: %s", jsonEvent) 27 | 28 | ctx, cancel := context.WithCancel(context.Background()) 29 | defer cancel() 30 | 31 | botcl := slack.New(botSlackToken) 32 | cl := slack.New(slackToken) 33 | 34 | for _, record := range e.Records { 35 | if record.Change.NewImage == nil { 36 | // Skip if item deleted 37 | continue 38 | } 39 | 40 | questions := record.Change.NewImage["questions"].List() 41 | answers := record.Change.NewImage["answers"].List() 42 | userID := record.Change.Keys["user_id"].String() 43 | targetChannelID := record.Change.NewImage["target_channel_id"].String() 44 | 45 | db := dynamo.New(session.New()) 46 | 47 | userInfoResp, err := cl.GetUserInfoContext(ctx, userID) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | s, err := standup.Get(db, userInfoResp.TZ, userID, true) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | if len(questions)-len(answers) > 0 { 58 | // Send a next question if haven't answered all questions yet 59 | nextQuestionIndex := len(answers) 60 | 61 | if _, ok := questions[nextQuestionIndex].Map()["posted_at"]; ok { 62 | // Skip if already send a next question 63 | continue 64 | } 65 | 66 | question := standup.Question{ 67 | Text: questions[nextQuestionIndex].Map()["text"].String(), 68 | } 69 | 70 | _, postMessageTimestamp, err := botcl.PostMessageContext( 71 | ctx, 72 | userID, 73 | slack.MsgOptionText(question.Text, false), 74 | slack.MsgOptionAsUser(true), 75 | ) 76 | if err != nil { 77 | return err 78 | } 79 | 80 | if err = s.SentQuestion(db, nextQuestionIndex, postMessageTimestamp); err != nil { 81 | return err 82 | } 83 | 84 | continue 85 | } 86 | 87 | if len(answers) != len(questions) { 88 | // Skip if unintended state 89 | log.Printf("unintended state in user: %s", userID) 90 | continue 91 | } 92 | 93 | // Send message summary if finished 94 | log.Printf("finished user: %s", userID) 95 | 96 | profile, err := cl.GetUserProfileContext(ctx, userID, false) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | var fields []slack.AttachmentField 102 | for i := range questions { 103 | if answers[i].Map()["text"].String() == "none" { 104 | continue 105 | } 106 | 107 | fields = append(fields, (slack.AttachmentField{ 108 | Title: questions[i].Map()["text"].String(), 109 | Value: answers[i].Map()["text"].String(), 110 | Short: false, 111 | })) 112 | } 113 | 114 | if len(fields) == 0 { 115 | // Skip if unintended state 116 | log.Printf("unintended state in user: %s", userID) 117 | continue 118 | } 119 | 120 | attachment := slack.Attachment{ 121 | AuthorName: profile.RealName, 122 | AuthorIcon: profile.Image32, 123 | Fields: fields, 124 | } 125 | 126 | if s.FinishedAt == "" { 127 | _, postMessageTimestamp, err := botcl.PostMessageContext( 128 | ctx, 129 | targetChannelID, 130 | slack.MsgOptionAttachments(attachment), 131 | slack.MsgOptionAsUser(true), 132 | ) 133 | if err != nil { 134 | return err 135 | } 136 | 137 | if err = s.Finish(db, postMessageTimestamp); err != nil { 138 | return err 139 | } 140 | } else { 141 | _, _, _, err := botcl.UpdateMessageContext( 142 | ctx, 143 | targetChannelID, 144 | s.FinishedAt, 145 | slack.MsgOptionAttachments(attachment), 146 | slack.MsgOptionAsUser(true), 147 | ) 148 | if err != nil { 149 | return err 150 | } 151 | } 152 | } 153 | 154 | return nil 155 | } 156 | 157 | func main() { 158 | lambda.Start(Handler) 159 | } 160 | -------------------------------------------------------------------------------- /cmd/slash/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net/url" 8 | "os" 9 | "strings" 10 | 11 | "github.com/aws/aws-lambda-go/events" 12 | "github.com/aws/aws-lambda-go/lambda" 13 | "github.com/aws/aws-sdk-go/aws" 14 | "github.com/aws/aws-sdk-go/aws/session" 15 | "github.com/aws/aws-sdk-go/service/cloudwatchevents" 16 | "github.com/guregu/dynamo" 17 | "github.com/nlopes/slack" 18 | "github.com/tsub/serverless-daily-standup-bot/internal/setting" 19 | ) 20 | 21 | // Response is of type APIGatewayProxyResponse since we're leveraging the 22 | // AWS Lambda Proxy Request functionality (default behavior) 23 | // 24 | // https://serverless.com/framework/docs/providers/aws/events/apigateway/#lambda-proxy-integration 25 | type Response events.APIGatewayProxyResponse 26 | 27 | var botSlackToken = os.Getenv("SLACK_BOT_TOKEN") 28 | var resourcePrefix = os.Getenv("RESOURCE_PREFIX") 29 | 30 | func startSetting(query url.Values) (Response, error) { 31 | sess := session.New() 32 | db := dynamo.New(sess) 33 | cwe := cloudwatchevents.New(sess) 34 | 35 | var userIDs string 36 | var questions string 37 | // Don't handle error to skip "dynamo: no item found" error 38 | s, _ := setting.Get(db, query.Get("channel_id")) 39 | if s != nil { 40 | userIDs = strings.Join(s.UserIDs, "\n") 41 | questions = strings.Join(s.Questions, "\n") 42 | } 43 | 44 | var scheduleExpression string 45 | ruleName := fmt.Sprintf("%s-%s-%s", resourcePrefix, query.Get("team_id"), query.Get("channel_id")) 46 | describeRuleInput := &cloudwatchevents.DescribeRuleInput{ 47 | Name: aws.String(ruleName), 48 | } 49 | // Don't handle error to skip if you haven't set rule yet 50 | resp, _ := cwe.DescribeRule(describeRuleInput) 51 | if resp.ScheduleExpression != nil { 52 | scheduleExpression = *resp.ScheduleExpression 53 | } 54 | 55 | ctx, cancel := context.WithCancel(context.Background()) 56 | defer cancel() 57 | 58 | cl := slack.New(botSlackToken) 59 | 60 | dialog := slack.Dialog{ 61 | CallbackID: "setting", 62 | Title: "Setting", 63 | Elements: []slack.DialogElement{ 64 | slack.TextInputElement{ 65 | Value: userIDs, 66 | Hint: "Please type user ID (not username)", 67 | DialogInput: slack.DialogInput{ 68 | Type: "textarea", 69 | Label: "Members", 70 | Name: "user_ids", 71 | Placeholder: ` 72 | W012A3CDE 73 | W034B4FGH`, 74 | }, 75 | }, 76 | slack.TextInputElement{ 77 | Value: questions, 78 | Hint: "Please write multiple questions in multiple lines", 79 | DialogInput: slack.DialogInput{ 80 | Type: "textarea", 81 | Label: "Questions", 82 | Name: "questions", 83 | Placeholder: ` 84 | What did you do yesterday? 85 | What will you do today? 86 | Anything blocking your progress?`, 87 | }}, 88 | slack.DialogInputSelect{ 89 | Value: query.Get("channel_id"), 90 | DataSource: "channels", 91 | DialogInput: slack.DialogInput{ 92 | Type: "select", 93 | Label: "Target channel", 94 | Name: "target_channel_id", 95 | Placeholder: "Choose a channel", 96 | }, 97 | }, 98 | slack.TextInputElement{ 99 | Value: scheduleExpression, 100 | Hint: "https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html", 101 | DialogInput: slack.DialogInput{ 102 | Type: "text", 103 | Label: "Execution schedule", 104 | Name: "schedule_expression", 105 | Placeholder: "cron(0 1 ? * MON-FRI *)", 106 | }, 107 | }, 108 | }, 109 | } 110 | triggerID := query.Get("trigger_id") 111 | 112 | err := cl.OpenDialogContext(ctx, triggerID, dialog) 113 | if err != nil { 114 | return Response{StatusCode: 500}, err 115 | } 116 | 117 | return Response{StatusCode: 200}, nil 118 | } 119 | 120 | func handleQuery(query url.Values) (resp Response, err error) { 121 | // for debug 122 | log.Printf("query: %v", query) 123 | 124 | switch query.Get("text") { 125 | case "setting": 126 | resp, err = startSetting(query) 127 | if err != nil { 128 | return resp, err 129 | } 130 | default: 131 | // TODO: Show help 132 | resp = Response{StatusCode: 200} 133 | } 134 | 135 | return resp, nil 136 | } 137 | 138 | // Handler is our lambda handler invoked by the `lambda.Start` function call 139 | func Handler(ctx context.Context, request events.APIGatewayProxyRequest) (Response, error) { 140 | query, err := url.ParseQuery(request.Body) 141 | if err != nil { 142 | return Response{StatusCode: 400}, nil 143 | } 144 | 145 | return handleQuery(query) 146 | } 147 | 148 | func main() { 149 | lambda.Start(Handler) 150 | } 151 | -------------------------------------------------------------------------------- /cmd/webhook/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "log" 7 | "os" 8 | 9 | "github.com/aws/aws-lambda-go/events" 10 | "github.com/aws/aws-lambda-go/lambda" 11 | "github.com/aws/aws-sdk-go/aws/session" 12 | "github.com/guregu/dynamo" 13 | "github.com/tsub/serverless-daily-standup-bot/internal/standup" 14 | "github.com/tsub/slack" 15 | ) 16 | 17 | // Response is of type APIGatewayProxyResponse since we're leveraging the 18 | // AWS Lambda Proxy Request functionality (default behavior) 19 | // 20 | // https://serverless.com/framework/docs/providers/aws/events/apigateway/#lambda-proxy-integration 21 | type Response events.APIGatewayProxyResponse 22 | 23 | type envelope struct { 24 | APIAppID string `json:"api_app_id"` 25 | AuthedUsers []string `json:"authed_users"` 26 | Challenge string `json:"challenge"` 27 | Event event `json:"event"` 28 | EventID string `json:"event_id"` 29 | EventTime int `json:"event_time"` 30 | TeamID string `json:"team_id"` 31 | Token string `json:"token"` 32 | Type string `json:"type"` 33 | } 34 | 35 | type event struct { 36 | Channel string `json:"channel"` 37 | ChannelType string `json:"channel_type"` 38 | ClientMessageID string `json:"client_msg_id"` 39 | EventTimestamp string `json:"event_ts"` 40 | Hidden bool `json:"hidden"` 41 | Message message `json:"message"` 42 | PreviousMessage message `json:"previous_message"` 43 | Subtype string `json:"subtype"` 44 | Text string `json:"text"` 45 | Timestamp string `json:"ts"` 46 | Type string `json:"type"` 47 | User string `json:"user"` 48 | } 49 | 50 | type message struct { 51 | ClientMessageID string `json:"client_msg_id"` 52 | Edited edited `json:"edited"` 53 | SourceTeam string `json:"source_team"` 54 | Team string `json:"team"` 55 | Text string `json:"text"` 56 | Timestamp string `json:"ts"` 57 | Type string `json:"type"` 58 | User string `json:"user"` 59 | UserTeam string `json:"user_team"` 60 | } 61 | 62 | type edited struct { 63 | Timestamp string `json:"ts"` 64 | User string `json:"user"` 65 | } 66 | 67 | var slackToken = os.Getenv("SLACK_TOKEN") 68 | var botSlackToken = os.Getenv("SLACK_BOT_TOKEN") 69 | 70 | // Handler is our lambda handler invoked by the `lambda.Start` function call 71 | func Handler(ctx context.Context, request events.APIGatewayProxyRequest) (Response, error) { 72 | var envelope envelope 73 | 74 | log.Printf("raw request body: %s", request.Body) 75 | 76 | if err := json.Unmarshal([]byte(request.Body), &envelope); err != nil { 77 | return Response{StatusCode: 400}, err 78 | } 79 | 80 | jsonEnvelope, err := json.Marshal(envelope) 81 | if err != nil { 82 | return Response{StatusCode: 500}, err 83 | } 84 | log.Printf("envelope: %s", jsonEnvelope) 85 | 86 | switch envelope.Type { 87 | case "url_verification": 88 | return Response{ 89 | StatusCode: 200, 90 | IsBase64Encoded: false, 91 | Body: envelope.Challenge, 92 | Headers: map[string]string{ 93 | "Content-Type": "text/plain", 94 | }, 95 | }, nil 96 | case "event_callback": 97 | if envelope.Event.Type != "message" { 98 | return Response{StatusCode: 200}, nil 99 | } 100 | 101 | ctx, cancel := context.WithCancel(context.Background()) 102 | defer cancel() 103 | 104 | botcl := slack.New(botSlackToken) 105 | 106 | authTestResp, err := botcl.Auth().Test().Do(ctx) 107 | if err != nil { 108 | return Response{StatusCode: 500}, err 109 | } 110 | 111 | var user string 112 | var answer standup.Answer 113 | 114 | switch envelope.Event.Subtype { 115 | case "message_changed": 116 | user = envelope.Event.Message.User 117 | answer = standup.Answer{ 118 | Text: envelope.Event.Message.Text, 119 | PostedAt: envelope.Event.Message.Timestamp, 120 | } 121 | case "": // new message 122 | user = envelope.Event.User 123 | answer = standup.Answer{ 124 | Text: envelope.Event.Text, 125 | PostedAt: envelope.Event.Timestamp, 126 | } 127 | default: 128 | // unsupported subtype 129 | // see https://api.slack.com/events/message#message_subtypes 130 | log.Printf("unsupported subtype: %s", envelope.Event.Subtype) 131 | return Response{StatusCode: 200}, nil 132 | } 133 | 134 | // Skip self event 135 | if user == authTestResp.UserID { 136 | return Response{StatusCode: 200}, nil 137 | } 138 | 139 | cl := slack.New(slackToken) 140 | 141 | usersInfoResp, err := cl.Users().Info(user).Do(ctx) 142 | if err != nil { 143 | return Response{StatusCode: 500}, err 144 | } 145 | 146 | db := dynamo.New(session.New()) 147 | 148 | s, err := standup.Get(db, usersInfoResp.TZ, user, true) 149 | if err != nil { 150 | return Response{StatusCode: 404}, err 151 | } 152 | 153 | if envelope.Event.Subtype != "message_changed" && len(s.Answers) >= len(s.Questions) { 154 | return Response{StatusCode: 200}, nil 155 | } 156 | 157 | if envelope.Event.Subtype != "message_changed" && answer.Text == "cancel" { 158 | if err := s.Cancel(db); err != nil { 159 | return Response{StatusCode: 400}, err 160 | } 161 | 162 | postMessageResp, err := botcl.Chat().PostMessage(user).Text("Stand-up canceled.").AsUser(true).Do(ctx) 163 | if err != nil { 164 | return Response{StatusCode: 500}, err 165 | } 166 | 167 | log.Println(postMessageResp) 168 | 169 | return Response{StatusCode: 200}, nil 170 | } 171 | 172 | if envelope.Event.Subtype == "message_changed" { 173 | if err := s.UpdateAnswer(db, answer); err != nil { 174 | return Response{StatusCode: 400}, err 175 | } 176 | } else { 177 | if err := s.AppendAnswer(db, answer); err != nil { 178 | return Response{StatusCode: 400}, err 179 | } 180 | } 181 | 182 | return Response{StatusCode: 200}, nil 183 | default: 184 | return Response{StatusCode: 200}, nil 185 | } 186 | } 187 | 188 | func main() { 189 | lambda.Start(Handler) 190 | } 191 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/aws/aws-lambda-go v1.6.0 h1:T+u/g79zPKw1oJM7xYhvpq7i4Sjc0iVsXZUaqRVVSOg= 3 | github.com/aws/aws-lambda-go v1.6.0/go.mod h1:zUsUQhAUjYzR8AuduJPCfhBuKWUaDbQiPOG+ouzmE1A= 4 | github.com/aws/aws-lambda-go v1.12.0 h1:CgKAMdFIWExd4U6c9DUE+ax8N0fsmkYirqcfmReRCeo= 5 | github.com/aws/aws-lambda-go v1.12.0/go.mod h1:050MeYvnG0NozqUw+ljHH9x0SwxeBnbxHVhcjn9nJFA= 6 | github.com/aws/aws-lambda-go v1.12.1 h1:rMToYOcPFYDixQ7VNNPg78LmiqPgWD5f8zdLL+EsDAk= 7 | github.com/aws/aws-lambda-go v1.12.1/go.mod h1:z4ywteZ5WwbIEzG0tXizIAUlUwkTNNknX4upd5Z5XJM= 8 | github.com/aws/aws-lambda-go v1.13.0 h1:yjvZBGAxmrVQnakZ6/SE2S6L7Iwyx4CkJEcCQCc7WtU= 9 | github.com/aws/aws-lambda-go v1.13.0/go.mod h1:z4ywteZ5WwbIEzG0tXizIAUlUwkTNNknX4upd5Z5XJM= 10 | github.com/aws/aws-lambda-go v1.13.1 h1:qVIOD3UrEUo4amwgEBu6AI0CfnBsp71XJEYU05RbQ1k= 11 | github.com/aws/aws-lambda-go v1.13.1/go.mod h1:z4ywteZ5WwbIEzG0tXizIAUlUwkTNNknX4upd5Z5XJM= 12 | github.com/aws/aws-lambda-go v1.13.2 h1:8lYuRVn6rESoUNZXdbCmtGB4bBk4vcVYojiHjE4mMrM= 13 | github.com/aws/aws-lambda-go v1.13.2/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= 14 | github.com/aws/aws-lambda-go v1.13.3 h1:SuCy7H3NLyp+1Mrfp+m80jcbi9KYWAs9/BXwppwRDzY= 15 | github.com/aws/aws-lambda-go v1.13.3/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU= 16 | github.com/aws/aws-sdk-go v1.15.26 h1:p9fZ5yY1JnB3T0iLQQsLrFY70Pyfft6WcNANHtGOyYc= 17 | github.com/aws/aws-sdk-go v1.15.26/go.mod h1:mFuSZ37Z9YOHbQEwBWztmVzqXrEkub65tZoCYDt7FT0= 18 | github.com/aws/aws-sdk-go v1.15.45 h1:AZQmbZllwfzmoGWnCufXcPEObmdOh4Jb9EiIweh/4Sg= 19 | github.com/aws/aws-sdk-go v1.18.5/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 20 | github.com/aws/aws-sdk-go v1.19.18/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 21 | github.com/aws/aws-sdk-go v1.21.8 h1:Lv6hW2twBhC6mGZAuWtqplEpIIqtVctJg02sE7Qn0Zw= 22 | github.com/aws/aws-sdk-go v1.21.8/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 23 | github.com/aws/aws-sdk-go v1.21.10 h1:lTRdgyxraKbnNhx7kWeoW/Uow1TKnSNDpQGTtEXJQgk= 24 | github.com/aws/aws-sdk-go v1.21.10/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 25 | github.com/aws/aws-sdk-go v1.22.4 h1:Mcq67g9mZEBvBuj/x7mF9KCyw5M8/4I/cjQPkdCsq0I= 26 | github.com/aws/aws-sdk-go v1.22.4/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 27 | github.com/aws/aws-sdk-go v1.23.4 h1:F6f/iQRhuSfrpUdy80q29898H0NYN27pX+95tkJ+BIY= 28 | github.com/aws/aws-sdk-go v1.23.4/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 29 | github.com/aws/aws-sdk-go v1.23.9 h1:UYWPGrBMlrW5VCYeWMbog1T/kqZzkvvheUDQaaUAhqI= 30 | github.com/aws/aws-sdk-go v1.23.9/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 31 | github.com/aws/aws-sdk-go v1.23.13 h1:l/NG+mgQFRGG3dsFzEj0jw9JIs/zYdtU6MXhY1WIDmM= 32 | github.com/aws/aws-sdk-go v1.23.13/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 33 | github.com/aws/aws-sdk-go v1.23.18 h1:ADU/y1EO8yPzUJJYjcvJ0V9/suezxPh0u6hb5bSYIGQ= 34 | github.com/aws/aws-sdk-go v1.23.18/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 35 | github.com/aws/aws-sdk-go v1.24.4 h1:te5/T3qKtk6gFQUvdOJukVYX2zIUBVpJJ2tYiF3qM/s= 36 | github.com/aws/aws-sdk-go v1.24.4/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 37 | github.com/aws/aws-sdk-go v1.25.2 h1:y13oPwCkhayDvc1GyKCSUUWC2vIv1FOCqPc4nwPEXH0= 38 | github.com/aws/aws-sdk-go v1.25.2/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 39 | github.com/aws/aws-sdk-go v1.25.7 h1:MRnnec09yF/nL/lfpMsYqHHyXUUt4P9LofFZA2D93PE= 40 | github.com/aws/aws-sdk-go v1.25.7/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 41 | github.com/aws/aws-sdk-go v1.25.12 h1:a4h2FxoUJq9h+hajSE/dsRiqoOniIh6BkzhxMjkepzY= 42 | github.com/aws/aws-sdk-go v1.25.12/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 43 | github.com/aws/aws-sdk-go v1.25.16 h1:k7Fy6T/uNuLX6zuayU/TJoP7yMgGcJSkZpF7QVjwYpA= 44 | github.com/aws/aws-sdk-go v1.25.16/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 45 | github.com/aws/aws-sdk-go v1.25.20 h1:oetJZA4n5GJPgOuoTYVoDo5/EIBwTVkqpb7xPA1C2Mw= 46 | github.com/aws/aws-sdk-go v1.25.20/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 47 | github.com/aws/aws-sdk-go v1.25.26 h1:hMrxW3feteaGcP32oKaFdCQsCEWYf9zF12g73C0AcbI= 48 | github.com/aws/aws-sdk-go v1.25.26/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 49 | github.com/aws/aws-sdk-go v1.25.32 h1:GhqlDvuPXnlW46VoKvfLZkJj5IA6jGLO+/TUPCJSYOY= 50 | github.com/aws/aws-sdk-go v1.25.32/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 51 | github.com/aws/aws-sdk-go v1.25.37 h1:gBtB/F3dophWpsUQKN/Kni+JzYEH2mGHF4hWNtfED1w= 52 | github.com/aws/aws-sdk-go v1.25.37/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 53 | github.com/aws/aws-sdk-go v1.25.41 h1:/hj7nZ0586wFqpwjNpzWiUTwtaMgxAZNZKHay80MdXw= 54 | github.com/aws/aws-sdk-go v1.25.41/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 55 | github.com/aws/aws-sdk-go v1.25.44 h1:n9ahFoiyn66smjF34hYr3tb6/ZdBcLuFz7BCDhHyJ7I= 56 | github.com/aws/aws-sdk-go v1.25.44/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= 57 | github.com/cenkalti/backoff v2.0.0+incompatible h1:5IIPUHhlnUZbcHQsQou5k1Tn58nJkeJL9U+ig5CHJbY= 58 | github.com/cenkalti/backoff v2.0.0+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= 59 | github.com/cenkalti/backoff v2.1.1+incompatible h1:tKJnvO2kl0zmb/jA5UKAt4VoEVw1qxKWjE/Bpp46npY= 60 | github.com/cenkalti/backoff v2.1.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= 61 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 62 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 63 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 64 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 65 | github.com/fnproject/fdk-go v0.0.0-20180522161022-1eb29530716f h1:uGU7fQh/8CM5m9t7g2Nnq6S6Coq2XnWdSMfq8oeD2SM= 66 | github.com/fnproject/fdk-go v0.0.0-20180522161022-1eb29530716f/go.mod h1:hzkP3qqXx+1pRBh2QVKr1I+jJ+5xrHIlh5z59XKZ/k0= 67 | github.com/go-ini/ini v1.25.4 h1:Mujh4R/dH6YL8bxuISne3xX2+qcQ9p0IxKAP6ExWoUo= 68 | github.com/go-ini/ini v1.25.4/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= 69 | github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE= 70 | github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 71 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= 72 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 73 | github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= 74 | github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 75 | github.com/guregu/dynamo v1.0.0 h1:N/z3OK/SmaUynhSsySZu0s45hvGWIjp4IX2r5Pbgk1Y= 76 | github.com/guregu/dynamo v1.0.0/go.mod h1:VmV4PHy8bHJm8xhMD00CdejubOf3wVQxXyLLl2NLC9M= 77 | github.com/guregu/dynamo v1.3.1 h1:6Er2ymWOnLezka13fihPJO/VhWivpjbHmzU3p2EWoaM= 78 | github.com/guregu/dynamo v1.3.1/go.mod h1:ZS3tuE64ykQlCnuGfOnAi+ztGZlq0Wo/z5EVQA1fwFY= 79 | github.com/guregu/dynamo v1.4.1 h1:FN3uX0ezH32AKCov7+5GEANK7R9J5RbrnAeeupxdUx8= 80 | github.com/guregu/dynamo v1.4.1/go.mod h1:mNKn9Gwq5KlrPIqGx+M0lHXtNmdam7TH1t7oKrRbqZk= 81 | github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8 h1:12VvqtR6Aowv3l/EQUlocDHW2Cp4G9WJVH7uyH8QFJE= 82 | github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= 83 | github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM= 84 | github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= 85 | github.com/lestrrat-go/pdebug v0.0.0-20180220043849-39f9a71bcabe h1:S7XSBlgc/eI2v47LkPPVa+infH3FuTS4tPJbqCtJovo= 86 | github.com/lestrrat-go/pdebug v0.0.0-20180220043849-39f9a71bcabe/go.mod h1:zvUY6gZZVL2nu7NM+/3b51Z/hxyFZCZxV0hvfZ3NJlg= 87 | github.com/lestrrat-go/slack v0.0.0-20180726073730-18d3cce844c0 h1:TTSRmu0G64dJURJ+6v6jA6bCuHuRQs9Mlrm4z78cneM= 88 | github.com/lestrrat-go/slack v0.0.0-20180726073730-18d3cce844c0/go.mod h1:rZAJYlLtmM0bn20eDQqMdZHObR+hCyfgyJsSWJsgi+U= 89 | github.com/nlopes/slack v0.3.0 h1:jCxvaS8wC4Bb1jnbqZMjCDkOOgy4spvQWcrw/TF0L0E= 90 | github.com/nlopes/slack v0.3.0/go.mod h1:jVI4BBK3lSktibKahxBF74txcK2vyvkza1z/+rRnVAM= 91 | github.com/nlopes/slack v0.5.0 h1:NbIae8Kd0NpqaEI3iUrsuS0KbcEDhzhc939jLW5fNm0= 92 | github.com/nlopes/slack v0.5.0/go.mod h1:jVI4BBK3lSktibKahxBF74txcK2vyvkza1z/+rRnVAM= 93 | github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= 94 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 95 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 96 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 97 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 98 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 99 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 100 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 101 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 102 | github.com/tsub/slack v0.0.0-20180902133210-f7a41612f75f h1:MWqfUrN/XFXt/RFZz0Fk1B4BJpB32vtuydFBwvVYA9E= 103 | github.com/tsub/slack v0.0.0-20180902133210-f7a41612f75f/go.mod h1:XFp04sIYJJKcFGjejku4Tuwd8RjfI+552gplVAGbweg= 104 | github.com/urfave/cli v1.21.0/go.mod h1:lxDj6qX9Q6lWQxIrbrT0nwecwUtRnhVZAJjJZrVUZZQ= 105 | github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 106 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 107 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 108 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d h1:g9qWBGx4puODJTMVyoPrpoxPFgVGd+z1DZwjfRu4d0I= 109 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 110 | golang.org/x/net v0.0.0-20190318221613-d196dffd7c2b h1:ZWpVMTsK0ey5WJCu+vVdfMldWq7/ezaOcjnKWIHWVkE= 111 | golang.org/x/net v0.0.0-20190318221613-d196dffd7c2b/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 112 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs= 113 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 114 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 115 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 116 | google.golang.org/appengine v1.2.0 h1:S0iUepdCWODXRvtE+gcRDd15L+k+k1AiHlMiMjefH24= 117 | google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 118 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 119 | gopkg.in/urfave/cli.v1 v1.20.0/go.mod h1:vuBzUtMdQeixQj8LVd+/98pzhxNGQoyuPBlsXHOQNO0= 120 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 121 | --------------------------------------------------------------------------------