【LINE WORKS】rubyでオウム返しBotを作る

# はじめに
前回、AWS SAMのサンプルを実行してたので、今回はそれを利用してline worksのオウム返しbotを構築した備忘録になります

前回の記事は[コチラ](https://qiita.com/youfuku/items/38f582889a05027f713f)

## 初期設定
まずは、環境の新規構築を行います
```shell:コマンド
$ sam init
$ 省略
Project name [sam-app]: oumu
$ 省略
$ cd oumu
$ mv hello_world/ oumu/
```

## テンプレート改修
```diff_yaml:oumu/template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  oumu

  Sample SAM Template for oumu

# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
  Function:
    Timeout: 3
    MemorySize: 128

Resources:
- HelloWorldFunction:
+ OumuFunction:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
-     CodeUri: hello_world/
+     CodeUri: oumu/
      Handler: app.lambda_handler
      Runtime: ruby3.2
      Architectures:
        - x86_64
      Events:
-       HelloWorld:
+       OumuApi:
          Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
          Properties:
-           Path: /hello
+           Path: /oumu
-           Method: get
+           Method: post
            
Outputs:
  # ServerlessRestApi is an implicit API created out of Events key under Serverless::Function
  # Find out more about other implicit resources you can reference within SAM
  # https://github.com/awslabs/serverless-application-model/blob/master/docs/internals/generated_resources.rst#api
- HelloWorldApi:
+ OumuApi:
-   Description: "API Gateway endpoint URL for Prod stage for Hello World function"
+   Description: "API Gateway endpoint URL for Prod stage for oumu function"
-   Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"
+   Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/oumu/"
- HelloWorldFunction:
+ OumuFunction:
-   Description: "Hello World Lambda Function ARN"
+   Description: "oumu Lambda Function ARN"
-   Value: !GetAtt HelloWorldFunction.Arn
+   Value: !GetAtt OumuFunction.Arn
- HelloWorldFunctionIamRole:
+ OumuFunctionIamRole:
-   Description: "Implicit IAM Role created for Hello World function"
+   Description: "Implicit IAM Role created for oumu function"
-   Value: !GetAtt HelloWorldFunctionRole.Arn
+   Value: !GetAtt OumuFunctionRole.Arn
```

## デプロイ
正常に動くか動作検証がてらにデプロイを実施します
```shell:コマンド
$ sam build
$ sam deploy --guided

省略

Successfully created/updated stack - oumu in us-west-2
```

動作確認
```shell:コマンド
$ curl -X POST 'デプロイされたエンドポイント'
{"message":"Hello World!"}
```
これで、postのAPIが正常に動いてるのが分かります
エンドポイントは次でも利用するのでメモしといてください

## line worksの準備
こちらの記事に詳細が書いてあるので、こちらを参考にしてください（botの発行などは割愛）

https://qiita.com/mmclsntr/items/3da41a9d03d6dba16290

### bot作成時にコールバックを設定
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632357/6fcf1075-8a9b-d66e-f4bb-688b21117eaa.png)

こちらに、samでデプロイ時に出力されたエンドポイントを登録してください


# 開発開始
```shell:コマンド
$ sam sync --watch --profile hogehoge

省略

Stack update succeeded. Sync infra completed.            
CodeTrigger not created as CodeUri or DefinitionUri is missing for ServerlessRestApi.
Infra sync completed.
```
※開発時は同期してると楽なので、ここでは同期コマンド「sam sync」を利用しています。

準備が整ったので、ここから本格的に開発になります。
まず、ログが出力できるように修正します
```ruby:/oumu/oumu/app.rb
# require 'httparty'
require 'json'
require 'logger'

def logger
  @logger ||= Logger.new($stdout, level: Logger::Severity::DEBUG)
end

def lambda_handler(event:, context:)
  logger.debug(event)
  logger.debug(context)

  { statusCode: 200 }
end
```

line works botからlambdaにpostが届いているか確認します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632357/9a786e81-9d85-2be6-e8ee-04822bb928c3.png)

cloudWatchで確認すると、ログが出力されていることが分かります
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632357/abaf88d0-f5bf-ebea-b8b8-27f7c346627e.png)

### line works bot api
```ruby:oumu/oumu/Gemfile
source 'https://rubygems.org'

gem 'httparty'

ruby '~> 3.2.0'

gem 'jwt'
```

line works bot の api を別ファイルで作成します。
CLIENT_ID、CLIENT_SECRET、SERVICE_ACCOUNT_ID、PRIVATE_KEY、BOT_IDは自分が発行したものに変更してから利用してください。

```oumu/oumu/line_works_send.rb
require 'jwt'

CLIENT_ID = 'hogehoge'
CLIENT_SECRET = 'hogehoge'
SERVICE_ACCOUNT_ID = 'hogehoge'
PRIVATE_KEY = "hogehoge"
SCOPE = 'bot'
BOT_ID = '1'

def jwt
  current_time = Time.now.to_i
  rsa_private = OpenSSL::PKey::RSA.new(PRIVATE_KEY)
  payload = { iss: CLIENT_ID, sub: SERVICE_ACCOUNT_ID, iat: current_time, exp: current_time + 3600 }
  JWT.encode(payload, rsa_private, 'RS256') # 暗号化
end

def create_access_token
  url = 'https://auth.worksmobile.com/oauth2/v2.0/token'
  headers = { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }
  params = {
    assertion: jwt, grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
    client_id: CLIENT_ID, client_secret: CLIENT_SECRET, scope: SCOPE
  }
  response = Net::HTTP.post(URI.parse(url), URI.encode_www_form(params), headers)
  JSON.parse(response.body)
end

def lw_api_headers_params
  access_token = create_access_token
  {
    'Content-type': 'application/json',
    Authorization: "#{access_token['token_type']} #{access_token['access_token']}"
  }
end

def lw_api_message_params(message)
  {
    content: {
      type: 'text',
      text: message.to_s
    }
  }
end

def bot_send_message(user_id, message)
  url = "https://www.worksapis.com/v1.0/bots/#{BOT_ID}/users/#{user_id}/messages"
  params = lw_api_message_params(message)
  response = Net::HTTP.post(URI.parse(url), params.to_json, lw_api_headers_params)
  response.code
end
```

上記で作ったものを呼び出し、lambda_handlerに設定します
```ruby:oumu/oumu/app.rb
# require 'httparty'
require 'json'
require 'logger'
require './line_works_send'

def logger
  @logger ||= Logger.new($stdout, level: Logger::Severity::DEBUG)
end

def lambda_handler(event:, context:)
  line_post = JSON.parse(event['body'])
  logger.debug(line_post)
  user_id = line_post.dig('source', 'userId')
  logger.debug(user_id)
  message = line_post.dig('content', 'text')
  logger.debug(message)
  s_code = bot_send_message(user_id, message)
  logger.debug(s_code)
  { statusCode: s_code }
end
```

## 動作検証
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632357/5f71a192-25d3-6d59-3bb7-b119a4496d44.png)

Bot に話しかけると、オウム返しされます。

# 署名検証
[公式ページ](URL 'https://developers.worksmobile.com/jp/docs/bot-callback')を見ると、セキュリティの観点から署名検証をするように記載がありますので、追加します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632357/5daa7030-bd86-76fa-bc92-b3e2d1c72246.png)


公式に記載されているBot Secretと比較するために、lambdaに渡す必要があるので、テンプレートを修正します
```diff_yaml:oumu/template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  oumu

  Sample SAM Template for oumu

# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
  Function:
    Timeout: 3
    MemorySize: 128

+ Parameters:
+   LineWorksBotSecret:
+     Type: String
+     Description: Line Works Bot Secret

Resources:
  OumuFunction:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      CodeUri: oumu/
      Handler: app.lambda_handler
      Runtime: ruby3.2
+     Environment:
+       Variables:
+         LINE_WORKS_BOT_SECRET: !Ref LineWorksBotSecret
      Architectures:
        - x86_64
      Events:
        OumuApi:
          Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
          Properties:
            Path: /oumu
            Method: post

Outputs:
  # ServerlessRestApi is an implicit API created out of Events key under Serverless::Function
  # Find out more about other implicit resources you can reference within SAM
  # https://github.com/awslabs/serverless-application-model/blob/master/docs/internals/generated_resources.rst#api
  OumuApi:
    Description: "API Gateway endpoint URL for Prod stage for oumu function"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/oumu/"
  OumuFunction:
    Description: "oumu Lambda Function ARN"
    Value: !GetAtt OumuFunction.Arn
  OumuFunctionIamRole:
    Description: "Implicit IAM Role created for oumu function"
    Value: !GetAtt OumuFunctionRole.Arn
```

反映させます

```
$ sam build
$ sam deploy --guided
```
すると、デプロイ時にパラメータを聞かれますので、bot画面のbot secretを入力します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632357/6687371d-018c-8d34-0cff-c09bfffd896f.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632357/64b8f6dd-0353-eb87-95e5-92e4af9880d1.png)


```diff_ruby:oumu/oumu/app.rb
# require 'httparty'
require 'json'
require 'logger'
require './line_works_send'
# require 'rack'
require 'openssl'
require 'base64'

def logger
  @logger ||= Logger.new($stdout, level: Logger::Severity::DEBUG)
end

+ def verify_signature(event)
+   hmac = OpenSSL::HMAC.digest(OpenSSL::Digest.new('sha256'), ENV.fetch('LINE_WORKS_BOT_SECRET'), event['body'])
+   hash2 = Base64.strict_encode64(hmac)
+   hash2 != event.dig('headers', 'x-works-signature')
+ end

def lambda_handler(event:, context:)
+   return { statusCode: 200 } if verify_signature(event) # 違う場合は、何もせずに200を返す

  request_body = JSON.parse(event['body'])
  user_id = request_body.dig('source', 'userId')
  message = request_body.dig('content', 'text')
  s_code = user_send_message(user_id, message)
  { statusCode: s_code }
end
```

これで完成となります

:::note warn
再デプロイを行いますのでエンドポイントも変わります。bot側の変更も必要なのでご注意ください
:::

# さいごに
今回は簡単なオウムbotを作成しました。
lambda側でopenAIなどと繋げば色々な応用が作れそうとは思っています。
が、もう少し学習が必要だと感じていますので、少しずつ何かを作っていこうと考えております。
拙いですが、最後までご拝読頂けますと幸いです。