2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LINEWORKS BOT とAmazon Bedrock(Claude 3)を接続してみた

Posted at

はじめに

LINEWORKSのBOTを利用して、Claude 3と会話してみたい!と思った人向け
以前、LINEで同様なことをしてます
今回は、LINEWORKS(企業向けLINE)の記事です
個人向けLINEで、利用する場合は、下記をご覧ください

LINEWORKSとLINEってどう違うの?

LINEWORKSとLINEの違いについて

「LINE WORKS」は、情報や予定を共有しあって活動する、組織・チームのためのコミュニケーションツール。
企業や企業内プロジェクト、特定の活動をする団体などに最適です。

LINE⇒個人向け
LINEWORKS⇒企業(組織・チーム)向け
の位置づけのようです

企業にフォーカスしているため、
アカウント管理やトークの監査機能など、組織として必要な機能が実装されています
また、掲示板機能なども実装されています
プランによっては、メール機能も使えるみたいです

LINEWORKS BOTの作成

では、さっそくLINEWORKS BOTを作成していきます

作業内容について

  • LINEWORKS Developerの準備
    • BOT作成
    • API作成
  • AWS側のリソース作成
    • Lambda Layerの作成(Cloud9)
    • Lambda / APIGateway作成
  • APIGatewayとLINEWORKSBOTの連携
  • LINEWORKSでBOT公開
  • 動作確認

ざっくり、こんなかんじです

前提

  • LINEWORKSのアカウントをすでに持っている
  • BOT権限を管理者から付与されている
  • AWSアカウントを所有している
  • AWSの利用リージョンはバージニア(us-east-1)

LINEWORKS Developerの準備

下記のサイトにログインし、BOTとAPIの設定を行います。
https://developers.worksmobile.com/jp/

image.png

ログイン後、TOP画面に戻るため、consoleへ進みます
image.png

API作成

APIを作成します
image.png

アプリ名を入力し、同意して利用するを押します
image.png

OAuth Scopesの設定を行います。
権限はbotのみでOKです
image.png
image.png
image.png

Client ID / Client Secretをコピーします
Service Accountの発行を行います

image.png

Service Accountの発行ができると、アカウントIDが表示されるので、コピーします
Private Keyも利用しますので、発行し、Key情報がダウンロードされるので、保存します。

image.png

APIの作成はこれで終わりです。

BOT作成

Botから登録画面へ飛びます
image.png

Bot名、説明、管理者を指定して作成(保存)します
image.png

AWS側のリソース作成

Lambda Layerの作成(Cloud9)

LambdaでLINEWORKS BOTへPOSTする為、アクセストークンを取得する必要があります。

Service Account 認証 (JWT)

Lambda(Python)で、JWTを取り扱うにはLayerを追加する必要があります
Layerの作り方については、別記事を作成して公開していますので、
こちらをご覧ください

Lambda / APIGateway作成

AWS上にLambdaとAPIGatewayを作成していきます

Lambda

image.png

アーキテクチャーの指定に気を付けてください
Cloud9で作ったLayer環境と同じアーキテクチャーを指定しましょう

import json
import os
import jwt
import boto3
from datetime import datetime
import urllib
import requests
from cryptography.hazmat.primitives.serialization import load_pem_private_key

BASE_API_URL = "https://www.worksapis.com/v1.0"
BASE_AUTH_URL = "https://auth.worksmobile.com/oauth2/v2.0"

class AuthManager:
    def __init__(self):
        self.client_id, self.client_secret, self.service_account_id, self.private_key = self._get_env_vars()

    def _get_env_vars(self):
        client_id = os.environ.get("LW_API_20_CLIENT_ID")
        client_secret = os.environ.get("LW_API_20_CLIENT_SECRET")
        service_account_id = os.environ.get("LW_API_20_SERVICE_ACCOUNT_ID")
        private_key_str = os.environ['LW_API_20_PRIVATEKEY']
        private_key_str = f"-----BEGIN RSA PRIVATE KEY-----\n{private_key_str}\n-----END RSA PRIVATE KEY-----"
        privatekey = load_pem_private_key(private_key_str.encode(), password=None)
        return client_id, client_secret, service_account_id, privatekey

    def _generate_jwt(self):
        current_time = datetime.now().timestamp()
        iss = self.client_id
        sub = self.service_account_id
        iat = current_time
        exp = current_time + (60 * 60)  # 1時間

        jws = jwt.encode(
            {
                "iss": iss,
                "sub": sub,
                "iat": iat,
                "exp": exp
            }, self.private_key, algorithm="RS256")

        return jws

    def get_access_token(self, scope):
        jws = self._generate_jwt()
        url = f'{BASE_AUTH_URL}/token'
        headers = {'Content-Type': 'application/x-www-form-urlencoded'}
        params = {
            "assertion": jws,
            "grant_type": urllib.parse.quote("urn:ietf:params:oauth:grant-type:jwt-bearer"),
            "client_id": self.client_id,
            "client_secret": self.client_secret,
            "scope": scope,
        }
        r = requests.post(url=url, data=params, headers=headers)
        return json.loads(r.text)

    def refresh_access_token(self, refresh_token):
        url = f'{BASE_AUTH_URL}/token'
        headers = {'Content-Type': 'application/x-www-form-urlencoded'}
        params = {
            "refresh_token": refresh_token,
            "grant_type": "refresh_token",
            "client_id": self.client_id,
            "client_secret": self.client_secret,
        }
        r = requests.post(url=url, data=params, headers=headers)
        return json.loads(r.text)

class MessageSender:
    def __init__(self, bot_id, user_id):
        self.bot_id = bot_id
        self.user_id = user_id

    def send_message(self, content, access_token):
        url = f"{BASE_API_URL}/bots/{self.bot_id}/users/{self.user_id}/messages"
        headers = {
            'Content-Type': 'application/json',
            'Authorization': f"Bearer {access_token}"
        }
        form_data = json.dumps(content)
        r = requests.post(url=url, data=form_data, headers=headers)
        r.raise_for_status()

def lambda_handler(event, context):
    try:
        # メッセージを受信
        if event['type'] == 'message':
            # メッセージタイプがテキストの場合
            if event['content']['type'] == 'text':
                # 受信メッセージ
                messageText = event['content']['text']
                # 送信元ユーザーID
                user_id = event['source']['userId']

                bot_id = os.environ.get("LW_API_20_BOT_ID")
                auth_manager = AuthManager()
                message_sender = MessageSender(bot_id, user_id)
                scope = "bot"
                res = auth_manager.get_access_token(scope)
                access_token = res["access_token"]

                # 利用者がBOTを利用開始したタイミングの処理(postback: startが含まれているかチェック)
                if "postback" in event['content'] and event['content']['postback'] == "start":
                    response_text = "ClaudeBOTへ問い合わせありがとうございます\nなんでも聞いてくださいね!"

                    # メッセージを返信(Amazon BedRock Claude 3 Sonnetからの応答の中身を返す)
                    content = {
                        "content": {
                            "type": "text",
                            "text": response_text
                        }
                    }
                    message_sender.send_message(content, access_token)
                else:
                    bedrock_runtime = boto3.client(service_name='bedrock-runtime', region_name='us-east-1')
                    model_id = 'anthropic.claude-3-sonnet-20240229-v1:0'
                    max_tokens = 2000

                    body = json.dumps(
                        {
                            "anthropic_version": "bedrock-2023-05-31",
                            "max_tokens": max_tokens,
                            "messages": [{"role": "user", "content": messageText}]
                        }
                    )

                    # Amazon BedRock Claude 3 Sonnetに質問を送信
                    response = bedrock_runtime.invoke_model(body=body, modelId=model_id)
                    response_body = response['body'].read().decode('utf-8')

                    # Amazon BedRockからの応答の中身のみを取り出す
                    response_text = json.loads(response_body)['content'][0]['text']

                    # メッセージを返信(Amazon BedRock Claude 3 Sonnetからの応答の中身を返す)
                    content = {
                        "content": {
                            "type": "text",
                            "text": response_text
                        }
                    }
                    message_sender.send_message(content, access_token)

    # エラーが起きた場合
    except Exception as e:
        print(f"エラー: {e}")
        return {'statusCode': 500, 'body': json.dumps(f'Exception occurred: {str(e)}')}

    return {'statusCode': 200, 'body': json.dumps('Reply ended normally.')}

Layerの追加

requestsとjwt用のlayerを追加します

arn:aws:lambda:us-east-1:770693421928:layer:Klayers-p312-requests:3
image.png

jwtのlayer作成は、こちらで完了しているはずなので、省略します
image.png

設定

一般設定
タイムアウト:10分

アクセス権限:CloudWatchLogs、Bedrockの権限
環境変数:

変数名 内容
LW_API_20_BOT_ID LINEWORKS Developerで登録したBOTID
LW_API_20_CLIENT_ID LINEWORKS Developer APIのClient ID
LW_API_20_CLIENT_SECRET LINEWORKS Developer APIのClient Secret
LW_API_20_PRIVATEKEY LINEWORKS Developer
APIでダウンロードしたkeyの中身※
LW_API_20_SERVICE_ACCOUNT_ID LINEWORKS Developer APIのService Account ID

※プライベートキーは、
先頭(-----BEGIN PRIVATE KEY-----)と末尾(-----END PRIVATE KEY-----)を除いて、
LW_API_20_PRIVATEKEYに入れてください。

image.png

APIGateway

APIGateway(REST API)を作成します
image.png

メソッドを追加します
image.png

作成したLambdaを指定します
また、HTTPリクエストヘッダーとして、X-WORKS-Signatureを必須として、指定します
image.png
image.png

HTTPリクエストヘッダーの設定は、忘れずに設定ください
不要なアタックを遮断するための設定です。

作成したAPIをデプロイします
image.png
image.png

デプロイが完了したら、URLをコピーしておきます
image.png

APIGatewayとLINEWORKSBOTの連携

APIGateway作成後に、コピーしたURLをBOTと紐づけます

image.png
image.png
image.png

LINEWORKSでBOT公開

管理者画面へ行き、BOTを登録します
https://admin.worksmobile.com/service/bot

image.png
image.png

BOTがLINEWORKSに登録できた状態

image.png

公開処理を行います
image.png

使用権限をすべて、公開設定をONに保存します
image.png

公開設定をONにした場合、LINEWORKSユーザー全員へ通知が行きます
ご注意ください

これで、BOTとして利用者に見えるようになりました
image.png

動作確認

image.png
きちんと応答してくれました!(ただ、中身は間違っている)

今回参考にさせていただいたサイト

CloudFormation

上記の内容を、CloudFormation化しました

  • 前提
    • バージニア(us-east-1)で展開
    • JWTのLayerは手動で調整が必要
    • LINEWORKS側のBOT登録作業は別途必要
    • Amazon Bedrockから、Claude 3 Sonnetへリクエストが可能な状態
AWSTemplateFormatVersion: '2010-09-09'
Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      -
        Label:
          default: SYSTEMNAME
        Parameters:
          - NameTag
        Label:
          default: LINEWORKS
        Parameters:
          - LINEWORKSCLIENTID
          - LINEWORKCLIENTSECRET
          - LINEWORKSERVICEACCOUNTID
          - LINEWORKPRIVATEKEY
          - LINEWORKSBOTID
Parameters:
  NameTag:
    Type: String
    Description: 'Common resource name'
    Default: 'LINEWORKSBOT'
  LINEWORKSBOTID:
    Type: String
    Description: 'ChannelAccessToken for LINEBOT'
    Default: '01234567'
  LINEWORKSCLIENTID:
    Type: String
    Description: 'Enter the client ID issued by LINEWORKS Developer'
    Default: 'none'
  LINEWORKCLIENTSECRET:
    Type: String
    Description: 'Enter the client secret issued by LINEWORKS Developer'
    NoEcho: true
  LINEWORKPRIVATEKEY:
    Type: String
    Description: 'Enter the private key issued by LINEWORKS Developer'
    NoEcho: true
  LINEWORKSERVICEACCOUNTID:
    Type: String
    Description: 'Enter the service account ID issued by LINEWORKS Developer'
    Default: 'XXXX.serviceaccount@XXX'

Resources:
  linebotFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub '${NameTag}-lambda'
      Runtime: python3.12
      Handler: index.lambda_handler
      Role: !GetAtt LambdaExecutionRole.Arn
      Code:
        ZipFile: !Sub |
          import json
          import os
          import jwt
          import boto3
          from datetime import datetime
          import urllib
          import requests
          from cryptography.hazmat.primitives.serialization import load_pem_private_key

          BASE_API_URL = "https://www.worksapis.com/v1.0"
          BASE_AUTH_URL = "https://auth.worksmobile.com/oauth2/v2.0"

          class AuthManager:
              def __init__(self):
                  self.client_id, self.client_secret, self.service_account_id, self.private_key = self._get_env_vars()

              def _get_env_vars(self):
                  client_id = os.environ.get("LW_API_20_CLIENT_ID")
                  client_secret = os.environ.get("LW_API_20_CLIENT_SECRET")
                  service_account_id = os.environ.get("LW_API_20_SERVICE_ACCOUNT_ID")
                  private_key_str = os.environ['LW_API_20_PRIVATEKEY']
                  private_key_str = f"-----BEGIN RSA PRIVATE KEY-----\n{private_key_str}\n-----END RSA PRIVATE KEY-----"
                  privatekey = load_pem_private_key(private_key_str.encode(), password=None)
                  return client_id, client_secret, service_account_id, privatekey

              def _generate_jwt(self):
                  current_time = datetime.now().timestamp()
                  iss = self.client_id
                  sub = self.service_account_id
                  iat = current_time
                  exp = current_time + (60 * 60)  # 1時間

                  jws = jwt.encode(
                      {
                          "iss": iss,
                          "sub": sub,
                          "iat": iat,
                          "exp": exp
                      }, self.private_key, algorithm="RS256")

                  return jws

              def get_access_token(self, scope):
                  jws = self._generate_jwt()
                  url = f'{BASE_AUTH_URL}/token'
                  headers = {'Content-Type': 'application/x-www-form-urlencoded'}
                  params = {
                      "assertion": jws,
                      "grant_type": urllib.parse.quote("urn:ietf:params:oauth:grant-type:jwt-bearer"),
                      "client_id": self.client_id,
                      "client_secret": self.client_secret,
                      "scope": scope,
                  }
                  r = requests.post(url=url, data=params, headers=headers)
                  return json.loads(r.text)

              def refresh_access_token(self, refresh_token):
                  url = f'{BASE_AUTH_URL}/token'
                  headers = {'Content-Type': 'application/x-www-form-urlencoded'}
                  params = {
                      "refresh_token": refresh_token,
                      "grant_type": "refresh_token",
                      "client_id": self.client_id,
                      "client_secret": self.client_secret,
                  }
                  r = requests.post(url=url, data=params, headers=headers)
                  return json.loads(r.text)

          class MessageSender:
              def __init__(self, bot_id, user_id):
                  self.bot_id = bot_id
                  self.user_id = user_id

              def send_message(self, content, access_token):
                  url = f"{BASE_API_URL}/bots/{self.bot_id}/users/{self.user_id}/messages"
                  headers = {
                      'Content-Type': 'application/json',
                      'Authorization': f"Bearer {access_token}"
                  }
                  form_data = json.dumps(content)
                  r = requests.post(url=url, data=form_data, headers=headers)
                  r.raise_for_status()

          def lambda_handler(event, context):
              try:
                  # メッセージを受信
                  if event['type'] == 'message':
                      # メッセージタイプがテキストの場合
                      if event['content']['type'] == 'text':
                          # 受信メッセージ
                          messageText = event['content']['text']
                          # 送信元ユーザーID
                          user_id = event['source']['userId']

                          bot_id = os.environ.get("LW_API_20_BOT_ID")
                          auth_manager = AuthManager()
                          message_sender = MessageSender(bot_id, user_id)
                          scope = "bot"
                          res = auth_manager.get_access_token(scope)
                          access_token = res["access_token"]

                          # 利用者がBOTを利用開始したタイミングの処理(postback: startが含まれているかチェック)
                          if "postback" in event['content'] and event['content']['postback'] == "start":
                              response_text = "ClaudeBOTへ問い合わせありがとうございます\nなんでも聞いてくださいね!"

                              # メッセージを返信(Amazon BedRock Claude 3 Sonnetからの応答の中身を返す)
                              content = {
                                  "content": {
                                      "type": "text",
                                      "text": response_text
                                  }
                              }
                              message_sender.send_message(content, access_token)
                          else:
                              bedrock_runtime = boto3.client(service_name='bedrock-runtime', region_name='us-east-1')
                              model_id = 'anthropic.claude-3-sonnet-20240229-v1:0'
                              max_tokens = 2000

                              body = json.dumps(
                                  {
                                      "anthropic_version": "bedrock-2023-05-31",
                                      "max_tokens": max_tokens,
                                      "messages": [{"role": "user", "content": messageText}]
                                  }
                              )

                              # Amazon BedRock Claude 3 Sonnetに質問を送信
                              response = bedrock_runtime.invoke_model(body=body, modelId=model_id)
                              response_body = response['body'].read().decode('utf-8')

                              # Amazon BedRockからの応答の中身のみを取り出す
                              response_text = json.loads(response_body)['content'][0]['text']

                              # メッセージを返信(Amazon BedRock Claude 3 Sonnetからの応答の中身を返す)
                              content = {
                                  "content": {
                                      "type": "text",
                                      "text": response_text
                                  }
                              }
                              message_sender.send_message(content, access_token)

              # エラーが起きた場合
              except Exception as e:
                  print(f"エラー: {e}")
                  return {'statusCode': 500, 'body': json.dumps(f'Exception occurred: {str(e)}')}

              return {'statusCode': 200, 'body': json.dumps('Reply ended normally.')}
      MemorySize: 512
      Timeout: 600
      Architectures: 
        - x86_64
      Environment:
        Variables:
          LW_API_20_BOT_ID: !Ref LINEWORKSBOTID
          LW_API_20_CLIENT_ID: !Ref LINEWORKSCLIENTID
          LW_API_20_CLIENT_SECRET: !Ref LINEWORKCLIENTSECRET
          LW_API_20_PRIVATEKEY: !Ref LINEWORKPRIVATEKEY
          LW_API_20_SERVICE_ACCOUNT_ID: !Ref LINEWORKSERVICEACCOUNTID
      Layers:
        - arn:aws:lambda:us-east-1:770693421928:layer:Klayers-p312-requests:3
      Tags:
        - Key: Name
          Value: !Ref NameTag

  LambdaPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !GetAtt linebotFunction.Arn
      Action: lambda:InvokeFunction
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ApiGateway}/*/POST/
    DependsOn:
      - ApiGateway

  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - 'sts:AssumeRole'
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: AmazonBedrock
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: bedrock:InvokeModel
                Resource: !Sub 'arn:aws:bedrock:${AWS::Region}::foundation-model/anthropic.claude-3-sonnet-20240229-v1:0'
      Tags:
        - Key: Name
          Value: !Ref NameTag

  ApiGateway:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Name: !Sub '${NameTag}-APIGateway'
      EndpointConfiguration:
        Types:
          - REGIONAL
      Tags:
        - Key: Name
          Value: !Ref NameTag
    DependsOn:
      - linebotFunction

  ApiGatewayRootMethod:
    Type: AWS::ApiGateway::Method
    DependsOn:
      - LambdaPermission
    Properties:
      RestApiId: !Ref ApiGateway
      ResourceId: !GetAtt ApiGateway.RootResourceId
      HttpMethod: POST
      AuthorizationType: NONE
      RequestParameters:
        method.request.header.X-WORKS-Signature: true
      MethodResponses:
        - StatusCode: '200'
          ResponseModels:
            application/json: Empty
      Integration:
        Type: AWS
        IntegrationHttpMethod: POST
        Uri: !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${linebotFunction.Arn}/invocations'
        PassthroughBehavior: WHEN_NO_MATCH
        ContentHandling: CONVERT_TO_TEXT
        TimeoutInMillis: 29000
        CacheNamespace: !GetAtt ApiGateway.RootResourceId
        IntegrationResponses:
          - StatusCode: '200'

  ApiGatewayDeployment:
    Type: AWS::ApiGateway::Deployment
    DependsOn:
      - ApiGatewayRootMethod
    Properties:
      RestApiId: !Ref ApiGateway

  ApiGatewayStage:
    Type: AWS::ApiGateway::Stage
    DependsOn:
      - ApiGatewayDeployment
    Properties:
      RestApiId: !Ref ApiGateway
      DeploymentId: !Ref ApiGatewayDeployment
      StageName: prod
      Tags:
        - Key: Name
          Value: !Ref NameTag

さいごに

LINEWORKSのBOTを作成してみました
jwtの部分で大分苦戦しましたが、なんとか動いて良かったです

今回調べていく中、LINEWORKSの記事自体が少なく、見つけても根拠としている、
LINEWORKS Developer側のURLがリンク切れになっていることが多かった印象です
リファレンスの管理って大変だなと感じました

さて、次はRAG周りに挑戦しようと思います。
Claudeにもっとちゃんとした知識を元に回答してくれるようにしたいと思います

それでは、また!

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?