Go
AWS
GoogleCalendar
serverless

slack slash custom command + google calendar api + aws lambda/apigateway + golang(1)

タイトルクソ長い。
8億番煎じ感はあるけどちょいちょい細かいところでハマッたりしたので同じ理由で時間を無駄にする人がもう出ないようにここに残す。
「画面のどこにそれがあるかわからんかった」って自分で躓いたもの以外は基本stringオンリー。

何をしたいんだ

slackでコマンド打ち込むとgoogle calendar apiとキャッキャできる

構成

slackからpostされたらapigateway通してlambda関数動かす。

社内ツールだし、高い頻度で使うわけでもない。
が、使いたいときに動かなかったら困る。
=> ec2を24365はもったいないからサーバレスでいこう!!

AWS SAM

頑張ったけどだめだった。詳しくは後述。

googleCalendarAPIの有効化

これは色々なところで説明されてるので割愛。
自分の場合、Oauthがどうにもうまくいかなかったのでサービスアカウント(json)を選択。
(最終段階で「この画面アクセスして許可を出せ」って言われるんだけど、許可ボタン何度押してもアカンかった)

json保存してサービスアカウントにカレンダーへの書き込みの権限与えたら終了。

lambda用に先にroleを作っておく

lambdaの画面からも飛べたり出来るけど、なんかうまく反映されなかったりしたときがあったから最初に作ってしまう。

ポリシーは

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "arn:aws:logs:*:*:*"
        }
    ]
}

こんなんで。なんか警告出るけど無視。どうしても気になるなら良くはないけどResourceを"*"にすれば出なくはなる。
あとはロールから新しく空っぽのロールを作り、上のポリシーをインラインポリシーでアタッチ。
(別にインラインである必要は無いですが)
とりあえず名前は lambda-slack-gcal にしといた。

lambda

一から作成を選択
適当な名前をつけ、ランタイムは Go 1.x ロールはさっきこしらえたやつを選択して作成ボタンを押下。
(slack command用のテンプレートもありますが、今回は全て1からやります)

Designer って書いてあるところにCloudwatch Logsがいるはずなので一応クリックしてポリシーがちゃんとくっついてるか確認。

関数コードは ランタイムがGo 1.x になってるのを確認。ハンドラは「実行可能なバイナリファイル名」と一致させます。

GOOS=linux GOARCH=amd64 go build -o [[実行可能なバイナリファイル名]]

こういうことね。
で、自分が東京リージョンでやってないからかもしれないんですが、zipで固めたあとのサイズが3Mくらいなのにローカルからアップロードだとタイムアウトしまくりやがったので、コードエントリタイプはs3からにしときます。

環境変数は slash command追加したときに出てくるtokenの値を設定。
kms暗号化かけたい場合は別途ぐぐってください(すぐ出てくる)
ちなみに当たり前といえば当たり前ですが、kms暗号化された値を設定して読み出しても当然暗号化されたままなので、
プログラム内でdecryptしてやる必要があります。

他はタイムアウトの設定をデフォルトの3秒から2分くらいに伸ばしてみたり。
(ここは各自の要件に合わせれば良いと思いますが、自分の場合だと中でやることが他APIとの通信なので3秒だと苦しい)

で、一度保存。

apigateway

新しいAPI作ります。エンドポイントタイプは地域で良いでしょう。

リソース 画面中央上部のアクションボタンからメソッドの作成をします。
slack commandだし無難にpostにしときましょう。また、特にコマンドを増やす予定もないのでリソース作成もしません。
(なのでこの場合パスは / になります)

Lambda関数
統合の使用はチェックしない
リージョンはさっきlambda関数作ったリージョンにしときましょう
Lambda関数のところはさっき作った関数名の頭何文字か打つと候補がヌッと出てくると思うので選択
デフォルトタイムアウトのまま(maxが指定されてる)

で、保存。権限付与するよーとかダイアログ出ますんで許可。
すると簡易なフロー図が出てくるかと思うので、「統合リクエスト」をクリック。
マッピングテンプレート のところを

テンプレートが定義されていない場合 選んでマッピングテンプレートの追加。
Content-typeは application/x-www-form-urlencoded
中身は

{ "body": $input.json("$") }

これはslack slach commandがjsonではなくform-urlencodedでリクエスト投げてくるからです。
こうすることで、lambda関数内でパラメータ受け取ったときにbodyってキーにクエリストリングがゴシャッと入ってる状態となります。(多分これキー名bodyでなくてもいける気がするけど試してはない 別にbodyってキーが気に入らないわけでもないし)

メソッドレスポンスと統合レスポンスは、lambdaからの戻り値をslack上でいい感じに見せたい場合とかちゃんとステータスコードごとに何かしたいとかなら触る必要あるかと思いますが今は必要ないのでスルー。

簡易フロー図みたいな画面のほぼド真ん中にあるテストボタンおして、そのまんまテスト押下。
Unsupported Media Type が返ってきたらとりあえずマッピングルールの適用は出来てるとして

ここでいったん保存。

golang(lambda+slack部分)

コード貼って終わります。
今回はtokenチェックだけ通れば良いと思うのでそれ以外のパラメータは利用しません。

package main

import (
    "context"
    "errors"
    "log"
    "net/url"
    "os"

    "github.com/aws/aws-lambda-go/lambda"
)

type response struct {
    Message string `json:"message"`
}

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

func HandleRequest(_ context.Context, rawParams interface{}) (interface{}, error) {
    envToken := os.Getenv("lambdaに設定した環境変数のキー名")
    // kms暗号化してる場合decrypt

    params, err := parseParams(rawParams)
    if err != nil {
        log.Print(err)
        return response{"toriaezu fail"}, nil
    }
    if envToken != params.token {
        return response{"invalid token"}, nil
    }

    return response{"ok"}, nil
}

type slackParams struct {
    token       string
    teamID      string
    teamDomain  string
    channelID   string
    channelName string
    command     string
    userID      string
    userName    string
    text        string
    responseUrl string
}

func parseParams(rawParams interface{}) (result *slackParams, err error) {
    result = &slackParams{}

    tmp := rawParams.(map[string]interface{})
    if _, ok := tmp["body"]; !ok {
        err = errors.New("params body does not exists")
        return
    }
    rawQueryString := tmp["body"].(string)
    parsed, err := url.QueryUnescape(rawQueryString)
    if err != nil {
        err = errors.New("params body unescape failed. body: " + rawQueryString)
        return
    }
    params, err := url.ParseQuery(parsed)
    if err != nil {
        err = errors.New("params body parse failed. body: " + rawQueryString)
        return
    }

    result.token = params["token"][0]

    return
}

本当に使う場合はパラメータチェックをもうちょっと丁寧にやろうね
こいつをビルド、できあがったバイナリに実行権限を付与し、zipで固めたらs3の適当なところにおきます。

再びlambda

恐らく関数作ったときにはなかったはずのAPI Gatewayが Designerに出てきてると思います。
出てなかったらapigatewayで関数選ぶときになんかミスッてます。
ここは出現確認だけ。

関数コード から、s3リンクURLにさっきs3にうpしたzipファイルのurlを入れます。s3://じゃなくてhttps://なようなので注意。入力したら右上の保存押します。保存押さないとプログラム本体が反映されません。
しばらく待つと保存完了と言われるので、保存の隣にあるテスト押下。
テストテンプレートはHelloWorldのままで良いです、名前だけつけてあげて保存。
で、テスト実施しましょう。

ちゃんといってれば上のコードそのまま使った場合、toriaezu failが返ってるかと思います。
返ってる場合、lambda単体の疎通はokです。
返ってなかったらがんばれ

再びapigateway

迷わず リソース アクション から APIのデプロイ をします。
ステージは適当に決めましょう。自分はメンテ時に多少死んだところで問題ない状態なのでprodって名前つけてこれ一本で良いかなと。デプロイが無事完了するとエンドポイントがもらえるので、そいつをクリップボードにそっと載せておきます。

slack

slash commandsの設定方法は割愛します。さっき載せたエンドポイントを設定。
設定したらコマンド打ってみましょう。
上の通りのコードなら {"message":"ok"} こんなんが返るはずです。

Cloudwatch log

ロググループにこしらえたlambdaのやつがいるかどうか確認します。
なお、デフォルトだとログが永久的に保存される設定になっているので確認ついでに期間短くしときましょう。
保存料かかるはずなので。
ちゃんとログが出てることを確認したらおわり。

SAM あきらめた 理由

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: descdescdescdesc
Resources:
  PostFunc:
    Type: AWS::Serverless::Function
    Properties:
      Handler: binary
      Runtime: go1.x
      CodeUri: s3://bucket/key
      Environment:
        Variables:
          token: tokentokentokentokentokentoken
      Events:
        PostResource:
          Type: Api
          Properties:
            Path: /
            Method: post

  PostFuncLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub /aws/lambda/${PostFunc}
      RetentionInDays: 5

  PostFuncApiGwMapping:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: 
        Ref: わからん
      ResourceId: 
        Fn::GetAtt: 
          - わからん
          - わからん
      HttpMethod: POST
      AuthorizationType: NONE
      Integration: 
        Type: AWS
        Uri: !GetAtt PostFunc.Arn
        IntegrationHttpMethod: POST
        RequestTemplates:
          application/x-www-form-urlencoded: "{\"body\": $input.json(\"$\")}"

AWS::Serverless::Function でEvent指定したとき、なんか暗黙的にそのリソースの情報が定義されるとか書いてあったんだけど https://github.com/awslabs/serverless-application-model/blob/master/docs/internals/generated_resources.rst#api

うまく取れなかったんですよね…
近いうちまた試すか…

google calendar

あまりに記事が長くなったので分けます。といってもこの部分は書くことそんなにないですけど。