GoとGinとLambdaでSlack botを作成する

社内でgoの勉強会みたいなのをやっておりまして、それの事前調査的な意味でのやってみた系blog記事となります。

# やりたいこと

こんな感じです。

- goで何かつくる
- 何かフレームワーク使う(gin)
- Lambdaとapi gateway使う
- deployはsam使う
- slack botとして動かす

# 環境

- 普通のmac(m1じゃない)

# まずはhello world

なんか公式っぽいライブラリがあります。
https://github.com/awslabs/aws-lambda-go-api-proxy

これ使えばgoとlambdaとginとsamが一気にやれる見込みです。
ひとまずhelloworldしてみましょう。
とは言ってもREADME通りにやるだけ。

まずは当然これ。
```git clone https://github.com/awslabs/aws-lambda-go-api-proxy```

あとはこんな感じで一気に。
ただ、makeの時にbuildのオプションがないとLambdaで`exec format error: PathError null`みたいなエラーになったので下記にならってMakefileを修正しています。(`GOBUILD=GOOS=linux GOARCH=amd64 $(GOCMD) build`みたいに)
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/golang-package.html

```bash
$ cd aws-lambda-go-api-proxy
$ go get github.com/aws/aws-lambda-go/events
$ go get github.com/aws/aws-lambda-go/lambda
$ go get github.com/awslabs/aws-lambda-go-api-proxy/...
$ make
$ export YOUR_DEPLOYMENT_BUCKET=hoge
$ aws cloudformation package --template-file sam.yaml --output-template-file output-sam.yaml --s3-bucket YOUR_DEPLOYMENT_BUCKET
export YOUR_STACK_NAME=hoge 
$ aws cloudformation deploy --template-file output-sam.yaml --stack-name $YOUR_STACK_NAME --capabilities "CAPABILITY_IAM"
Waiting for changeset to be created..
Waiting for stack create/update to complete
Successfully created/updated stack - hoge
```

これであっさりとdeployが始まったっぽいです。
cloud formationのstackが作られて、deployが進むわけですが、進捗は下記のコマンドで確認できます。

```bash
$ aws cloudformation describe-stacks --stack-name $YOUR_STACK_NAME --query 'Stacks[]'
```

`"StackStatus": "CREATE_COMPLETE",` みたいに出力されていれば正常終了しています。
で、`"OutputValue"` という所にapi gatewayのendpointが出力されています。
これをcurlで叩いてみますね。

```bash
$ curl -XGET https://xxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/pets -H 'application/json'  | jq '.[0,1,2]'
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  1225  100  1225    0     0   2097      0 --:--:-- --:--:-- --:--:--  2097
{
  "id": "b4a89cf0-6136-4507-af13-023df7cb5e39",
  "breed": "Dalmatian",
  "name": "Cody",
  "dateOfBirth": "2008-07-02T11:20:25Z"
}
{
  "id": "f7a7a1d4-e499-4e16-9130-27de6176587f",
  "breed": "Norwegian Elkhound",
  "name": "Buster",
  "dateOfBirth": "2012-03-27T15:53:16Z"
}
{
  "id": "84852a28-b48d-49b1-9465-075ab8dd7305",
  "breed": "Jack Russell Terrier",
  "name": "Charlie",
  "dateOfBirth": "2012-07-30T20:47:20Z"
}
```

あっさり成功。
GET /pets/:id でペットを取得できたり、POST /petsでペットを登録できたり、
get /petsでペットのリストを取得できたりと、ペットを大量に飼育しているご家庭におすすめのサービスです。
なお、GET /pets 内では`getRamdomPet`っていうメソッドが呼ばれていて、登録してなくてもランダムでペットが生まれてきます。
いや、/pet/:idでもランダムでした。現状だと `createPet`は意味ないのかな。

```
curl -XGET https://xxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/pets/1 -H 'application/json' 
{"id":"1","breed":"Beagle","name":"Buster","dateOfBirth":"2021-01-09T03:33:19Z"}%
```


# slack bot

めんどくさいのでこのexampleをそのまま使ったれ。
前提として、botを動かすためのslackのワークスペースおよび、botを登録する権限は必要です。

## とりあえずbotを作る

まずは深く考えずにbotを作ります。

https://slack.com/intl/ja-jp/help/articles/115005265703-%E3%83%AF%E3%83%BC%E3%82%AF%E3%82%B9%E3%83%9A%E3%83%BC%E3%82%B9%E3%81%A7%E5%88%A9%E7%94%A8%E3%81%99%E3%82%8B%E3%83%9C%E3%83%83%E3%83%88%E3%81%AE%E4%BD%9C%E6%88%90

1. まだ作成していない場合には、Slack アプリを作成します。作成してある場合には、既存のアプリを選択してください。

https://api.slack.com/apps?new_app=1

![スクリーンショット 2021-06-19 21.04.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/b360718a-cbac-8dcd-6a4f-e22337abb176.png)
`From app an manifest`って何だろう？なんかmanifest使えるっぽいですね。
とりあえず、これで適当なやつを作っておいてみよう。

```
_metadata:
  major_version: 1
  minor_version: 1
display_information:
  name: "俺の亀"
  description: An example app
  background_color: "#da3a79"
features:
  bot_user:
    display_name: "orekame"
settings:
  interactivity:
    is_enabled: true
    request_url: https://example.com/slack/message_action
oauth_config:
  scopes:
    bot:
      - channels:read
      - chat:write
      - chat:write.public
  redirect_urls:
    - https://example.com/slack/auth
```

OK。
とりあえずさっくりとはできた。細かい設定はまた後で。
※なおredirect_urlsはいらなかった

![スクリーンショット 2021-06-19 21.28.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/5fbb3d4b-40f9-e8c2-0db2-3e61821a5b56.png)


## incoming webhookの利用

https://slack.com/intl/ja-jp/help/articles/115005265063-Slack-%E3%81%A7%E3%81%AE-Incoming-Webhook-%E3%81%AE%E5%88%A9%E7%94%A8

まずは下記をやります。

1. 「機能」ページで「Incoming Webhook をアクティブにする」をオンにします。
1. アプリの投稿先となるチャンネルを選択して「許可する」をクリックします。

![スクリーンショット 2021-06-19 21.28.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/3a562347-6369-7d87-c2ad-e64d9116d280.png)

![スクリーンショット 2021-06-19 21.36.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/f48fd3ee-61a1-1509-a1cd-77c24aa8aa45.png)


で、
3. Incoming Webhook URL を使用して Slack に投稿します。 

https://api.slack.com/messaging/webhooks#posting_with_webhooks


```
$ curl -X POST -H 'Content-type: application/json' --data '{"text":"Hello, World!"}' https://hooks.slack.com/services/xxx/xxx/xxx
```

みたいにやると外部からslackへの投稿は確認できました。

![スクリーンショット 2021-06-19 21.40.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/89ff621d-3986-a66e-a0c3-e14ff6289ff1.png)

Lambdaからのレスポンスはこれを使おう。


## Event Subscriptions

以前はOutgoings Webhooksってのがあったけど廃止されたみたいです。けっこうslack周りはupdate早いんすよね。。

ご参考：
https://qiita.com/risto24/items/342256f6ed6cb504059a

先程のapiを再利用しつつ、 `https://xxxx.execute-api.ap-northeast-1.amazonaws.com/Prod/` みたいに登録しておきます。
ただ、challengeってレスポンスを返す仕様である必要があるみたいなので、`main.go` に追記をしておきます。(雑ですまんせん。）

```go:main.go
type Challenge struct {
    Challenge       string    `json:"challenge"`
}

...

                r.POST("/", postChallenge)

...

func postChallenge(c *gin.Context) {
        json := Challenge{}
        err := c.BindJSON(&json)
        if err != nil {
                return
        }
        c.JSON(http.StatusAccepted, json)
}
```

あと、sam.yamlのEndpointも追加が必要でした。

```yaml:sam.yaml
      Events:
        GetResource:
          Type: Api
          Properties:
            Path: /{proxy+}
            Method: any
        PostResource:
          Type: Api
          Properties:
            Path: /
            Method: post
```

そんなわけでエンドポイントは登録できました。
channelに投稿されたメッセージを拾いたいので `message:channels` をsubscribeしています。

![スクリーンショット 2021-06-19 22.49.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/6172cbb3-8b3c-516c-8e6d-0d2ad3cd82e3.png)

## メッセージを拾う

messageはこんな感じみたいです。

https://api.slack.com/types/event

typeに `event_callback` って入ってきて、 event.typeが `message` って入ってるのを拾えばいいっぽい？

```go
        if payload.Type == "url_verification" {
                c.JSON(http.StatusOK, gin.H{"challenge": payload.Challenge, "message": "ok"})
        } else if payload.Type == "event_callback" {
                fmt.Println(payload.Event.Text)
                fmt.Println(payload.Event.Type)

```

こんな感じでメッセージは拾えました。

![スクリーンショット 2021-06-20 0.45.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/ff54e225-363c-2d6b-4eed-ec3c758cd85c.png)

あとはincoming webhookに投げるだけかな。


## slackにレスポンスを返す。

最終的にこんな感じになりました。とりあえず動くだけのレベルのやつ。
`/pets` は消しました。

```go:main.go
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"net/url"
	"strings"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	ginadapter "github.com/awslabs/aws-lambda-go-api-proxy/gin"
	"github.com/gin-gonic/gin"
)

var ginLambda *ginadapter.GinLambda

type Event struct {
	Type string `json:"type"`
	Text string `json:"text"`
}

type Payload struct {
	Challenge string `json:"challenge"`
	Type      string `json:"type"`
	Event     Event  `json:"event"`
}

type ResponseBody struct {
	Text string `json:"text"`
}

// Handler is the main entry point for Lambda. Receives a proxy request and
// returns a proxy response
func Handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	if ginLambda == nil {
		// stdout and stderr are sent to AWS CloudWatch Logs
		log.Printf("Gin cold start")
		r := gin.Default()
		r.POST("/", postChallenge)

		ginLambda = ginadapter.New(r)
	}

	return ginLambda.ProxyWithContext(ctx, req)
}

func main() {
	lambda.Start(Handler)
}

func postChallenge(c *gin.Context) {
	payload := Payload{}
	err := c.ShouldBindJSON(&payload)

	if err != nil {
		return
	}

	fmt.Println(payload.Type)
	if payload.Type == "url_verification" {
		c.JSON(http.StatusOK, gin.H{"challenge": payload.Challenge, "message": "ok"})
	} else if payload.Type == "event_callback" && strings.Contains(payload.Event.Text, "Hello") {
		fmt.Println(payload.Event.Text)
		fmt.Println(payload.Event.Type)
		webhookURL := "https://hooks.slack.com/services/xxxx/xxxx/xxxx"
		p, err := json.Marshal(ResponseBody{Text: "ha?"})
		if err != nil {
			fmt.Println(err)
		}
		resp, err := http.PostForm(webhookURL, url.Values{"payload": {string(p)}})
		if err != nil {
			fmt.Println(err)
		}
		defer resp.Body.Close()

		c.JSON(http.StatusOK, gin.H{"message": "ok"})
	} else {
		fmt.Println(payload.Type)
		c.JSON(http.StatusOK, gin.H{"message": "ok"})
	}
}
```

```yaml:sam.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Example Lambda Gin
Resources:
  SampleFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: main
      CodeUri: main.zip
      Runtime: go1.x
      MemorySize: 128
      Policies: AWSLambdaBasicExecutionRole
      Timeout: 3
      Events:
        PostResource:
          Type: Api
          Properties:
            Path: /
            Method: post


Outputs:
  SampleGinApi:
    Description: URL for application
    Value: !Sub 'https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/'
    Export:
      Name: SampleGinApi
```

![スクリーンショット 2021-06-20 0.41.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/308709/eed1f100-5914-8e5f-3773-615685662389.png)

一度、 `strings.Contains(payload.Event.Text, "Hello") ` とか絞り込みをやらないで無限ループとかやらかしたりはしましたが・・とりあえずの所まではやれました。
あとは自分なりにカスタマイズしていきましょう。

## あとlocalでのテスト

```bash:term1
% sam local start-api -t sam.yaml             
Mounting SampleFunction at http://127.0.0.1:3000/ [POST]
You can now browse to the above endpoints to invoke your functions. You do not need to restart/reload SAM CLI while working on your functions, changes will be reflected instantly/automatically. You only need to restart SAM CLI if you update your AWS SAM template
2021-06-20 01:02:47  * Running on http://127.0.0.1:3000/ (Press CTRL+C to quit)
Invoking main (go1.x)
Decompressing /xxx/sample/main.zip
Failed to download a new amazon/aws-sam-cli-emulation-image-go1.x:rapid-1.0.0 image. Invoking with the already downloaded image.
Mounting /private/var/folders/_f/nyppwytj6cb3_8ggkpwpqj1m0000gp/T/tmpcyochpbp as /var/task:ro,delegated inside runtime container
START RequestId: 270fc1a5-c05f-1d91-d42e-ff60ef331920 Version: $LATEST
Gin cold start
[GIN-debug] [WARNING] Now Gin requires Go 1.6 or later and Go 1.7 will be required soon.

[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:	export GIN_MODE=release
 - using code:	gin.SetMode(gin.ReleaseMode)

[GIN-debug] POST   /                         --> main.postChallenge (3 handlers)
test
test
[GIN] 2021/06/19 - 16:02:55 | 200 |         129µs |                 | POST     /
END RequestId: 270fc1a5-c05f-1d91-d42e-ff60ef331920
REPORT RequestId: 270fc1a5-c05f-1d91-d42e-ff60ef331920	Init Duration: 190.28 ms	Duration: 7.55 ms	Billed Duration: 100 ms	Memory Size: 128 MB	Max Memory Used: 24 MB	
2021-06-20 01:02:55 127.0.0.1 - - [20/Jun/2021 01:02:55] "POST / HTTP/1.1" 200 -
```

```bash:term2
% curl -XPOST localhost:3000 -H 'application/json' -d '{"type":"test"}'
{"message":"ok"}%           
```