TwitterBotを開発してみたけど、閉じるしかなさそう〜開発内容をさらけだす〜

# はじめに
私はリプランボットというTwitterのボットを趣味で個人的に開発運用しています。
https://twitter.com/ReplyRank
<img width="400px" alt="Screen Shot 2023-02-06 at 14.16.50.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81962/435620d9-ed3f-bf3b-60b1-39aace95e04d.png">


そんな中、ここ最近[Twitter APIが有料](https://www.watch.impress.co.jp/docs/news/1475811.html)になるというニュースが飛び込んできました。
お金をかけずに運用してきた身としては、有料（しかも$100/月）となったら運用を止めるしかありません。
ですので、ちょっとでも爪痕を残すべく、どのように開発したのか、どのように運用してきているのかを残しておこうと思います。

# リプランボットとは
リプランボットはツイートに対するリプライや引用ツイートの中から、反応がたくさんあった（いいねの数やリツイートの数から計算）ツイートを計算して、ランキングするボットです。

<img width="300px" alt="Screen Shot 2023-02-03 at 21.38.25.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81962/0dac7d73-3c31-95e7-1b93-f07c9b82fc72.png">

これにより、あるツイートに対して反応の良かったリプライや引用ツイートを発見することができます。
利用用途としては、大喜利ツイートや、ボケてツイートがあります。 お題に回答しているリプライや引用ツイートの中から、反応が一番あった（多分一番面白い・役に立つ）ツイートを発掘することができます。

## 使い方
ランキングして欲しいツイートがあるときは、以下のようにそのツイートのリプライ欄に「@ReplyRank ランキングして」と返信してください。
<img width="300px" alt="Screen Shot 2023-02-03 at 21.39.34.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81962/e25ddb1d-9fba-1f38-768f-a0baa86030fa.png">

すると、約５分程度で集計して、お知らせいたします。
<img width="500px" alt="Screen Shot 2023-02-03 at 21.40.13.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81962/09237d0c-d36d-16ac-5bea-6d9070831405.png">

簡単に説明するとこんなボットです。

# 機能一覧
大きな機能としては以下になります。

## 1. メンションランキング機能
上記の使い方にあるとおり、「ランキングして」とメンションされたら、それを検知してランキングを行う機能です。
## 2. 自動ランキング機能
自動で特定のユーザーを監視して、勝手にランキングする機能です。主力機能です。
## 3. ランキング除外機能
たまに、「勝手にランキングに載せるな」という反応をいただくので、そのユーザーをランキングから除外する機能

# アーキテクチャー
[Serverless Framework](https://www.serverless.com/)を利用して、AWS上に構築しています。TypeScriptを使用しています。
アーキテクチャ図は以下のとおりです。
![twitter-rank-bot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81962/27653df3-4297-bdaa-7e6a-e97108c420b5.png)

AWS LambdaとDynamoDBでほぼ構成されています。両方とも従量課金であり、無料枠があるので採用しました。サーバーコストを0円にするのを目標にしており、AWSの費用は実際にかかっていません。

APIGatewayから起動される認証フロー系以外のLambda関数は、全てEventBridgeのRuleでcron実行されています。

使っているライブラリとしては、tsyringeというDIコンテナ用のライブラリを用いています。他に変わったライブラリは使っていないと思います。
```json: package.json
  "name": "twitter-rank-bot-serverless",
  "version": "1.0.0",
  "description": "Serverless aws-nodejs-typescript template",
  "main": "serverless.ts",
  "scripts": {
    "format": "prettier --write './**/*.ts'",
    "lint": "eslint --fix './src/**/*.{js,ts}'",
    "test": "jest",
  },
  "engines": {
    "node": ">=14.15.0"
  },
  "dependencies": {
    "@aws-sdk/client-dynamodb": "^3.170.0",
    "@middy/core": "^2.5.3",
    "@middy/http-json-body-parser": "^2.5.3",
    "aws-embedded-metrics": "^2.0.6",
    "log4js": "^6.6.0",
    "openid-client": "^5.1.8",
    "reflect-metadata": "^0.1.13",
    "tsyringe": "^4.7.0",
    "twitter-api-v2": "^1.12.0"
  },
```


# Serverless フォルダ構成
<img width="400px" alt="Screen Shot 2023-02-03 at 22.45.25.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81962/aeb00b30-0175-12b4-8620-be4f7ed5cd16.png">

functions以下には、lambdaの関数が並んでいます。
libsの中には、各functionから共通利用されているソースを入れてあります。

以降は、各処理フローの説明になります。

# ランキング対象ツイート検出フロー
<img width="400px" alt="Screen Shot 2023-02-06 at 11.49.43.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81962/4ce44519-64e9-0475-0539-8a5980f7069b.png">

## polling mention
自分自身のボットにメンションがされているかどうかを定期的に（2分ごと）にポーリングします。
メンション内容に「ランキングして」という文字列が含まれていたら、RankTargetテーブルに登録します。

## auto target
UserListテーブルに自動ランキング対象のTwitterユーザー一覧が入っているので、そちらをScanで取得します。対象ユーザーの全ツイートを全部ランキングするわけにはいかないので、自動ランキングはかなり気を使っています。

まず、自動ランキング対象ユーザーは以下のようにUserListテーブルで管理しています。
| Attribute | Type | Sample |
|:-----------|:------------|:------------|
| userId（PK）       | string        | 12345678         |
| followers     | number      | ユーザーのフォロワー数（例:1000）       |
| autoTargetHour       | number        | 毎日何時にチェックするか(例：15)         |
| mentionScoreRate         | number          | 何%の反応があったらランキングするか(例：5)           |

このauto target関数は1時間に1回（毎時30分）cronで動いています。
### 処理フロー
1. autoTargetHourが実行時間に該当するユーザーを抽出する
2. 1で抽出したユーザーの過去24~48時間のツイートを抽出する
3. 2で抽出したツイートの中から、「いいね + リツイート + 引用リツイート + コメント」の総数がfollowers × mentionScoreRate/100 以上のツイートを自動ランキング対象のツイートとする
4. 3で抽出したツイートをRankTargetテーブルに登録する

以上のロジックで自動ランキングするツイートを決定しています。自動で監視しているユーザーは、主に大喜利問題を出しているユーザーであり、フォロワー数から計算して一定数以上（mentionScoreRate）の反応を得ているツイートは大喜利のお題が出ているツイートだろうと仮定しています。

例えば以下のようなツイートが自動ランキング対象になります。このユーザーのフォロワー数は1.1万人で、この大喜利出題ツイートは「いいね + リツイート + 引用リツイート + コメント」の総数が1,077件とフォロワー数の9.7%が反応していますので、自動ランキング対象となります。
<img width="400px" alt="Screen Shot 2023-02-06 at 11.58.54.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81962/e6c137c0-f847-8921-3971-1fa375e43627.png">

このmentionScoreRateやautoTargetHourの設定は、各ユーザーの過去の反応やツイートの傾向を見て、たまに調整を入れています。

# ユーザーリスト登録フロー
<img width="400px" alt="Screen Shot 2023-02-06 at 11.50.12.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81962/be88b250-f6b6-a004-84cc-9ebe52388560.png">


## list member register
ランキング対象ツイート検出フローで触れました、UserListテーブルにどのように自動ランキング対象ユーザーを登録するのかという話です。もちろんDynamoDBに直接手動で登録することもできますが、こちらも自動化しています。
Twitterには「リスト」というユーザーをまとめる機能があり、こちらを利用しています。
<img width="400px" alt="Screen Shot 2023-02-06 at 11.29.08.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81962/aa295758-33d7-d703-89d8-5b37b954e696.png" >

私がTwitterを徘徊して、大喜利出題ユーザーを探して、予め定めたリストにユーザーを追加していきます。それを１日１回 listMemberRegister関数がリスト内のユーザーを抽出して、新規ユーザーをUserListテーブルに登録するという流れです。

## exclude list member regsiter
こちらの関数は、自動でランキング発表されたくないユーザーを登録しておく関数です。
たまに「おめでとうございます。あなたのツイートは1位でした」とランキングした時に、「勝手にランキングするな」という反応をもらうことがあります。この場合は、すみやかに自動ランキング除外リストにユーザー登録をして、今後はランキング対象にしないようにしています。なお、除外ユーザーは数件と少数なので、UserListテーブルで管理せずに、KeyValテーブルでカンマ区切りで保存・管理しています。

# ランキングフロー
<img width="400px" alt="Screen Shot 2023-02-06 at 11.49.01.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81962/6298050a-64a5-b902-2ee5-d24ccfbf0794.png">

## Calculate Rank
こちらはRankTargetテーブルに登録されているツイートのランキング集計及び、返信を行う関数です。2分に1回動いています。
### 処理フロー
1. RankTargetから対象ツイートを抽出
2. 対象ツイート関連情報（返信と引用ツイートそれぞれ1,000件まで）を取得
3. ランキング計算
4. 「ランキングしました」という旨を引用ツイートの形で返信をする 
5. 4の引用ツイートに返信する形で、ランキング結果を返信する
6. ランキングの結果をテーブル(Conversation)に保存
7. RankTarget削除

### 工夫点
1. 「ランキングして」とメンション・依頼された場合と、自動ランキングの場合とでは処理フローが少々異なるので、Strategyパターンを用いて、ロジックをまとめた
2. 並列処理を行い、パフォーマンスを向上させている 
3. TwitterのAPI制限（読み込みツイート数）があるので、書き込み用と読み込み用のアカウントを分けて、API制限にかかりずらくした。読み込みのAPI制限は多少スケールして解決するように設計されていますが、バーストすると困るので「返信と引用ツイートそれぞれ1,000件まで」という制限を課しています。

<img width="400px" alt="Screen Shot 2023-02-06 at 11.44.31.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81962/8a9be921-7825-430e-e4a4-7add8cd61d52.png">

# Twitterの認証フロー
Twitterの書き込みをするためにはOAuthフローを通して、ユーザーを認証する必要があります（Twitter API v2）。openid-clientライブラリーを使って、OAuthフローに乗っかります。
この記事はOAuthの記事ではないので、詳細は省きます。

<img width="400px" alt="creen Shot 2023-02-03 at 22.51.16.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81962/0b3522ad-032f-6822-28f9-4c874e3ebcbc.png">

## auth
authのエンドポイントにブラウザからアクセスして、ユーザーの認証を行います。

このAPIGatewayのエンドポイントを叩くと、openid-clientのライブラリを利用して認証用のURLを生成して、リダイレクトを行います。生成時に利用したstate, codeVerifierの値はDynamoDBのKeyValテーブル（Keyを指定してValueを保存する用のテーブル）に保存しておきます。

```typescript
  async handle(): Promise<string> {
    const state = generators.state();
    const codeVerifier = generators.codeVerifier();
    const challenge = generators.codeChallenge(codeVerifier);
    this.logger.debug(`state=${state}, codeVerifier=${codeVerifier}, codeChallenge=${challenge}`);
    const client = new TwitterAuthCredentials().getAuthClient();
    const url = client.authorizationUrl({
      redirect_uri: TW_CREDENTIALS.redirect_uri,
      response_type: 'code',
      scope: 'tweet.read tweet.write users.read like.read list.read follows.read offline.access',
      state,
      code_challenge: challenge,
      code_challenge_method: 'S256',
    });
    const C = KEY_VAL_CONST; // alias
    await this.dynamo.putKeyVal(C.KEY_STATE, state, C.CAT_AUTH);
    await this.dynamo.putKeyVal(C.KEY_CODE_VERIFIER, codeVerifier, C.CAT_AUTH);
    await this.dynamo.putKeyVal(C.KEY_CODE_CHALLENGE, challenge, C.CAT_AUTH);
    this.logger.info(`redirect url=${url}`);
    return url;
  }
```


なおこのエンドポイントは公開されているので、一応セキュリティ上のチェックをロジック側で行っています。

<img width="300px" alt="Screen Shot 2023-02-03 at 22.01.03.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81962/b966d5bb-ef26-1c71-2c49-092a4b8acfad.png">

## auth-cb 
認証を行うと、Callbackのエンドポイントにリダイレクトされて、auth-cbのLambda関数が実行されます。auth関数で保存したstateとcodeVerifierの値と、Callbackが呼ばれた際のqueryParamを利用して、TwitterAPIからアクセストークンとリフレッシュトークンを取得します。これらの値はKeyValテーブルに保存しておきます。
今後はTwitterAPIで書き込みリクエストを行う際は、ここで取得したアクセストークンを利用します。

```typescript
  async handle(queryParams: APIGatewayProxyEventQueryStringParameters): Promise<TwTokenSet> {
    const token = await this.getTokenSet(queryParams);
    await this.updateToken(token);
    return Promise.resolve(token);
  }

  private async getTokenSet(
    queryParams: APIGatewayProxyEventQueryStringParameters
  ): Promise<TwTokenSet> {
    const state = await this.dynamo.getKeyVal(KEY_VAL_CONST.KEY_STATE);
    const codeVerifier = await this.dynamo.getKeyVal(KEY_VAL_CONST.KEY_CODE_VERIFIER);
    const params: CallbackParamsType = {
      state: queryParams.state,
      code: queryParams.code,
    };

    const token = (await this.twitterAuthCredentials.oauthCallback(
      TW_CREDENTIALS.redirect_uri,
      params,
      { code_verifier: codeVerifier, state },
      { exchangeBody: { client_id: TW_CREDENTIALS.client_id } }
    )) as TwTokenSet;

    this.logger.info(`Succeeded to get tokenSet, access_token=${token.access_token}`);
    return token;
  }

  private async updateToken(token: TwTokenSet): Promise<void> {
    const C = KEY_VAL_CONST;
    await this.dynamo.putKeyVal(C.KEY_ACCESS_TOKEN, token.access_token, C.CAT_AUTH);
    await this.dynamo.putKeyVal(C.KEY_REFRESH_TOKEN, token.refresh_token, C.CAT_AUTH);
    await this.dynamo.putKeyVal(C.KEY_EXPIRES_AT, token.expires_at?.toString(), C.CAT_AUTH);
  }
```

## auth-refresh
アクセストークンには有効期限があるので、リフレッシュトークンを用いて、定期的にアクセストークンのリフレッシュを行います。EventBridgeのRuleを用いて、cron実行しています。

```typescript
async handle(): Promise<TwTokenSet> {
    const refreshToken = await this.dynamo.getKeyVal(KEY_VAL_CONST.KEY_REFRESH_TOKEN);
    const tokenSet = await this.twitterAuthCredentials.refreshToken(refreshToken);
    if (tokenSet.expired()) {
      throw new Error('Refresh Token has been expired');
    } else {
      await this.updateToken(tokenSet);
      this.logger.info('Succeeded to refresh token');
      return Promise.resolve(tokenSet);
    }
  }

  private async updateToken(token: TwTokenSet): Promise<void> {
    const C = KEY_VAL_CONST;
    await this.dynamo.putKeyVal(C.KEY_ACCESS_TOKEN, token.access_token, C.CAT_AUTH);
    await this.dynamo.putKeyVal(C.KEY_REFRESH_TOKEN, token.refresh_token, C.CAT_AUTH);
    await this.dynamo.putKeyVal(C.KEY_EXPIRES_AT, token.expires_at?.toString(), C.CAT_AUTH);
  }
```


# CI/CD
CI/CDはGitHub Actionで行っています

## Pre Merge
PRを作成すると自動的にマージチェックが実行されます。
```yaml: pre-merge.yaml
    steps:
      ...
      - name: Merge ${{ github.head_ref }} into ${{ github.base_ref }} branch
        run: git merge origin/${{ github.base_ref }}
      - name: Install npm
        run: npm ci
      - name: Lint code
        run: npm run lint
      - name: Format code
        run: npm run format
      - name: Check diff exists
        run: |
          git add -N .
          git diff
          line=`git diff | wc -l`
          if [ $line -gt 0 ]; then
            echo "You need to format before commit"
            git diff
            exit -1
          fi
      - name: Run tests
        run: npm run test:cov
```

## 開発環境にリリース
vから始まるtagをうつと、自動で開発環境としてデプロイされます。
```yaml: release-dev.yaml
name: Deploy Development

on:
  push:
    tags:
      - v*

jobs:
  deploy:
    name: deploy development
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [14.x]
    steps:
      - uses: actions/checkout@v3
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - name: serverless deploy
        uses: serverless/github-action@v3.1
        with:
          args: deploy --stage dev
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.XXX }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.XXX }}
```

## 本番環境にリリース
release tagをうつと、自動で本番環境としてデプロイされます。
```yaml: release-prod.yaml
name: Deploy Production

on:
  release:
    types: [published]

jobs:
  deploy:
    name: deploy production
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [14.x]
    steps:
      - uses: actions/checkout@v3
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
      - run: npm ci
      - name: serverless deploy
        uses: serverless/github-action@v3.1
        with:
          args: deploy --stage prod
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.XXX }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.XXX }}
```


# 運用について
## 日々の運用
完全自動で動いているので、開発してリリースして以降、定期的な作業はありません。
３ヶ月に１度くらい、RefreshTokenが失敗するので、再認証を行うくらいです。

## 監視
### アラート
Lambda関数が失敗した場合には、AWS SNSサービスと連携して、メール送信するようにしています。
### メトリクス
メトリクスの監視にはCloudwatch Metricsで件数のデータを集めています。
![Screen Shot 2023-02-06 at 12.09.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/81962/6123e4c0-6910-70e9-c1d2-9eb2dc4c2e75.png)
### Athena
DynamoDBを検索したくなることがあるので、その場合はAthenaを利用しています。件数が少ないので、Federated Queryの機能を利用しています。


## 各種メトリクス結果

| 項目 | 2022/08 |2022/09 |2022/10 |2022/11 |2022/12 |2023/01 |
|:------|:--|:--|:--|:--|:--|:--|
| ツイート| 2,290|4,498|4,748|3,625|2,859|1,192|
| 自動ランキング| 776|770|660|507|395|141|
| 依頼ランキング| 24|44|30|9|3|1|
| インプレッション|322,627|604,135|446,109|298,035|205,720|177,211|
| プロフィールアクセス|20,829|54,659|14,726|8,761|5,207|1,592|
| @ツイート|496|540|486|328|241|77|
| 新しいフォロワー|28|57|50|14|19|13|

2022年8月に本格リリースしてからのTwitter Analyticsなどの結果です。リリースしてすぐは伸びてきていたのですが、すぐに萎んでしまいました。大喜利出題件数が少なくなってきたり、出題を停止していた影響を受けていると思います。
9月から10月にかけては、ランキングの集計を第3位から第5位まで拡大したので、ツイート数が伸びています。
フォロワーの伸びもすぐに止みまして、最終的に200くらいまでしか増えませんでした。そしてほぼミュートされていると思います。このボットは大量のツイートをするので、ウザくなってくること必死です。

# おわりに

ランキングされたことに対して、かなり好意的な反応をたくさんもらえたことが嬉しかったです。
1日100件近くは、ランキング結果に反応が付きました。
コメントも毎日ようにありまして
「嬉しい〜」「ありがとうございます」だけでなく、一部ユーザーには
「リプランボットで🥉第3位🥉やったでー😄」「よっしゃああああああ！」
のように、すでにリプランボットを認知した上での反応も得られて純粋に嬉しかったです。

近いうちにこのボットも閉鎖することになるかもしれませんが、個人的な開発物で、小さいですが成果を出すことができたので、今後もなにかネタを見つけて個人開発をしてみたいと思っています。


以上