Closed26

Chatwork API を試してみる

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ドキュメントに一通り目を通したが何から手をつけて良いのかわからない

LINE のドキュメントが素晴らしかったので落差が激しい

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ワークスペースの準備

コマンド
nest new -p npm hello-chatwork
cd hello-chatwork
npm install dotenv
touch .env

.gitignore に .env を忘れずに追加する

.gitignore
.env
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

テストコードの作成

コマンド
touch test/chatwork-api.e2e-spec.ts
npm install node-fetch@2
npm install --save-dev @types/node-fetch
test/chatwork-api.e2e-spec.ts
import fetch from 'node-fetch';

describe('Chatwork API', () => {
  it('/me', async () => {
    const fetchUrl = 'https://api.chatwork.com/v2/me';
    const fetchResponse = await fetch(fetchUrl, {
      headers: {
        'X-Chatworktoken': process.env.CHATWORK_API_TOKEN,
      },
    });

    console.log(fetchResponse.status);
    console.log(await fetchResponse.text());
  });
});
コマンド
npm run test:e2e -- chatwork-api --setupFiles dotenv/config

実行結果の例は下記の通り

実行結果の例
{
  "account_id": 1111111,
  "room_id": 222222222,
  "name": "\u8584\u7530 \u9054\u54c9",
  "chatwork_id": "xxxxxxxxxxxxx",
  "organization_id": 3333333,
  "organization_name": "",
  "department": "",
  "title": "",
  "url": "",
  "introduction": "",
  "mail": "",
  "tel_organization": "",
  "tel_extension": "",
  "tel_mobile": "",
  "skype": "",
  "facebook": "",
  "twitter": "",
  "avatar_image_url": "https://appdata.chatwork.com/avatar/xArw08pG7D.rsz.jpg",
  "login_mail": "susukida@example.com"
}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

メッセージの送信

まずはグループチャットを新規作成する

新規作成したらルーム ID を確認する

ルーム ID はグループチャットの歯車アイコン > グループチャットの設定から確認できる

ルーム ID を .env ファイルに追記する

.env
ROOM_ID="000000000"

続いてテストコードを編集する

test/chatwork-api.e2e-spec.ts
import fetch from 'node-fetch';
import { URLSearchParams } from 'url';

describe('Chatwork API', () => {
  it('/me', async () => {
    const fetchUrl = 'https://api.chatwork.com/v2/me';
    const fetchResponse = await fetch(fetchUrl, {
      method: 'GET',
      headers: {
        'X-Chatworktoken': process.env.CHATWORK_API_TOKEN,
      },
    });

    console.log(fetchResponse.status);
    console.log(await fetchResponse.text());
  });

  it('/rooms/{room_id}/messages', async () => {
    const fetchUrl = `https://api.chatwork.com/v2/rooms/${process.env.ROOM_ID}/messages`;
    const fetchResponse = await fetch(fetchUrl, {
      method: 'POST',
      headers: {
        'X-Chatworktoken': process.env.CHATWORK_API_TOKEN,
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: new URLSearchParams({
        self_unread: '0',
        body: 'メッセージ本文',
      }).toString(),
    });

    console.log(fetchResponse.status);
    console.log(await fetchResponse.text());
  });
});
コマンド
npm run test:e2e -- chatwork-api --setupFiles dotenv/config

成功すると Chatwork のタイムライン上に「メッセージ本文」と投稿される

API から返送されるレスポンス内容の例は下記の通り

API から返送されるレスポンス内容の例
{"message_id":"0000000000000000000"}
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

リクエストの署名検証

まずは .env から環境変数を読み込めるようにする

コマンド
npm install --save @nestjs/config
src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [ConfigModule.forRoot()],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

.env に PORT, BASE_URL, WEBHOOK_TOKEN の 3 点を追加する

.env
PORT="3000"
BASE_URL="http://localhost:3000"

CHATWORK_API_TOKEN="00000000000000000000000000000000"
ROOM_ID="000000000"
WEBHOOK_TOKEN="XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"

続いてモジュールを編集してリクエストの生データにアクセスできるようにする

src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    rawBody: true,
  });
  await app.listen(3000);
}
bootstrap();

続いてコントローラーを編集する、相変わらずサービスを使っていなくて行儀が悪い

src/app.controller.ts
import { Controller, Post, RawBodyRequest, Req, Res } from '@nestjs/common';
import { createHmac, timingSafeEqual } from 'crypto';
import { Request, Response } from 'express';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  /**
   * Chatwork Webhook 用のエンドポイントです。
   * @param req Express リクエストオブジェクト
   * @param res Express レスポンスオブジェクト
   */
  @Post('api/v1/chatwork/webhook')
  onApiV1ChatworkWebhook(
    @Req() req: RawBodyRequest<Request>,
    @Res() res: Response,
  ) {
    // リクエスト署名を検証します。
    const webhookToken = process.env.WEBHOOK_TOKEN;
    const privateKey = Buffer.from(webhookToken).toString('base64');
    const actualSignature = createHmac('sha256', privateKey)
      .update(req.rawBody)
      .digest('base64');

    const expectedSignature = req.headers[
      'x-chatworkwebhooksignature'
    ] as string;

    const isSignatureVerified = timingSafeEqual(
      Buffer.from(actualSignature),
      Buffer.from(expectedSignature),
    );

    if (!isSignatureVerified) {
      res.status(400).send('Bad Request: !isSignatureVerified');
      return;
    }

    res.status(200).end('OK');
  }
}

最後にテストコードを作成する

コマンド
touch test/chatwork-webhook.e2e-spec.ts
test/chatwork-webhook.e2e-spec.ts
import { createHmac } from 'crypto';
import fetch from 'node-fetch';

describe('Chatwork Webhook', () => {
  it('/api/v1/chatwork/webhook', async () => {
    const requestBody = JSON.stringify({
      webhook_setting_id: '12345',
      webhook_event_type: 'mention_to_me',
      webhook_event_time: 1498028130,
      webhook_event: {
        from_account_id: 123456,
        to_account_id: 1484814,
        room_id: 567890123,
        message_id: '789012345',
        body: '[To:1484814]おかずはなんですか?',
        send_time: 1498028125,
        update_time: 0,
      },
    });

    const webhookToken = process.env.WEBHOOK_TOKEN;
    const privateKey = Buffer.from(webhookToken).toString('base64');
    const webhookSignature = createHmac('sha256', privateKey)
      .update(requestBody)
      .digest('base64');

    const fetchUrl = process.env.BASE_URL + '/api/v1/chatwork/webhook';
    const fetchResponse = await fetch(fetchUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'User-Agent': 'Chatwork-Webhook/1.0.0',
        'X-Chatworkwebhooksignature': webhookSignature,
      },
      body: requestBody,
    });

    console.log(fetchResponse.status);
    console.log(await fetchResponse.text());
  });
});

最後に実行

コマンド
npm run test:e2e -- chatwork-webhook --setupFiles dotenv/config
実行結果
> hello-chatwork@0.0.1 test:e2e
> jest --config ./test/jest-e2e.json

  console.log
    200

      at Object.<anonymous> (chatwork-webhook.e2e-spec.ts:38:13)

  console.log
    OK

      at Object.<anonymous> (chatwork-webhook.e2e-spec.ts:39:13)

 PASS  test/chatwork-webhook.e2e-spec.ts
  Chatwork Webhook
    ✓ /api/v1/chatwork/webhook (51 ms)

Test Suites: 1 passed, 1 total
Tests:       1 passed, 1 total
Snapshots:   0 total
Time:        1.825 s
Ran all test suites matching /chatwork-webhook/i.

ちゃんと動いている様子

Cloud Run にデプロイする前に署名検証失敗時のログ出力処理を追加しておいた方が良さそう

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

リクエストボディをそのまま表示する

ついでにリクエスト署名の検証失敗時のログ出力処理を追加した

src/app.controller.ts
import { Controller, Post, RawBodyRequest, Req, Res } from '@nestjs/common';
import { createHmac, timingSafeEqual } from 'crypto';
import { Request, Response } from 'express';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  /**
   * Chatwork Webhook 用のエンドポイントです。
   * @param req Express リクエストオブジェクト
   * @param res Express レスポンスオブジェクト
   */
  @Post('api/v1/chatwork/webhook')
  onApiV1ChatworkWebhook(
    @Req() req: RawBodyRequest<Request>,
    @Res() res: Response,
  ) {
    // リクエスト署名を検証します。
    const webhookToken = process.env.WEBHOOK_TOKEN;
    const privateKey = Buffer.from(webhookToken).toString('base64');
    const actualSignature = createHmac('sha256', privateKey)
      .update(req.rawBody)
      .digest('base64');

    const expectedSignature = req.headers[
      'x-chatworkwebhooksignature'
    ] as string;

    const isSignatureVerified = timingSafeEqual(
      Buffer.from(actualSignature),
      Buffer.from(expectedSignature),
    );

    if (!isSignatureVerified) {
      res.status(400).send('Bad Request: !isSignatureVerified');
      console.warn(JSON.stringify({ actualSignature, expectedSignature }));
      return;
    }

    // リクエストの内容を出力します。
    console.info(req.rawBody.toString());

    res.status(200).end('OK');
  }
}

PORT 環境変数の読み込みを忘れていた

src/app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [ConfigModule.forRoot()],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

テストコードは変更なしで実行する

コマンド
npm run test:e2e -- chatwork-webhook --setupFiles dotenv/config

下記はテストコードの実行結果ではなくサーバー側のログ出力

サーバー側の出力
{"webhook_setting_id":"12345","webhook_event_type":"mention_to_me","webhook_event_time":1498028130,"webhook_event":{"from_account_id":123456,"to_account_id":1484814,"room_id":567890123,"message_id":"789012345","body":"[To:1484814]おかずはなんですか?","send_time":1498028125,"update_time":0}}

期待した通りに動作している、良かった

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Cloud Run へデプロイ

コマンド
touch .gcloudignore
.gcloudignore
/.git/
/dist/
/node_modules/
/test/
/.env
/README.md

アップロードされるファイルを確認する

コマンド
gcloud meta list-files-for-upload
実行結果
nest-cli.json
.gitignore
package-lock.json
package.json
tsconfig.build.json
.prettierrc
.eslintrc.js
tsconfig.json
.gcloudignore
src/main.ts
src/app.service.ts
src/app.module.ts
src/app.controller.spec.ts
src/app.controller.ts

続いて package.json を編集する

package.json
{
  "scripts": {
    "gcp-build": "npm run build",
    "start": "node dist/main",
    "deploy": "gcloud run deploy hello-chatwork --source . --platform managed --region asia-northeast1 --allow-unauthenticated"
}

デプロイコマンドを実行する

コマンド
npm run deploy

デプロイが完了したら CLI か GCP Web コンソールから環境変数を設定する

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Chatwork Webhook の登録

下記の URL にアクセスする

https://www.chatwork.com/service/packages/chatwork/subpackages/webhook/list.php

入力する必要があるのは下記 3 点

  • Webhook 名
  • Webhook URL
  • イベント

Webhook 名はわかりやすい名前であれば何でも良い

Webhook URL は Cloud Run の URL + /api/v1/chatwork/webhook

イベントはアカウントイベントとルームイベントの 2 つがある

アカウントイベントは自分がメンションされた時(多分 @ とかを使ったメッセージが送信された時)に Webhook が実行される

ルームイベントは指定したルームでやり取りされる全てのメッセージが対象になる

ルームイベントはルーム ID を指定しなければならないので複数のルームを対象にするには Webhook を複数作成する必要がある、これはユースケースによっては厄介だがそういう仕様なので仕方ない

今回はアカウントイベントを使ってみる

作成ボタンを押すとトークンが表示されるので WEBHOOK_TOKEN として Cloud Run 環境変数に登録する

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Chatwork でメンションする場合は Slack みたいに @ を使うのではなく、メッセージ入力部の左上にある TO ボタンを押します。

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

自分で自分にメンションする

グループチャットに自分一人だとメンションできない(する必要がない)

下記のようにメッセージを送ればメンションできるのかな

メンションの形式
[To:0000000]薄田 達哉さん

0000000 のアカウント ID は環境設定 > Chatworkについてから確認できる

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

メッセージを送ってみたが反応がない

まだ反映されていないのかな?

BASE_URL を変更してテストコードを実行して確認してみる

コマンド
BASE_URL="https://hello-chatwork-xxxxxxxxxx-an.a.run.app" npm run test:e2e -- chatwork-webhook --setupFiles dotenv/config

テストコードは失敗したが Cloud Run のログではアクセスの記録を確認できた

自分で自分にメンションした場合はダメなのかな?

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ルームイベントに変更してみる

もう一回試してみたけど結果は変わらなかったのでルームイベントに変更してみる

ルーム ID は URL の #rid 以下からコピーするかグループチャットの設定(歯車アイコン)から確認できる

変更したらログが表示された!

ただし、署名の検証に失敗している様子、やはりログを仕込んでおいて良かった

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

原因はすぐわかった

Chatwork のドキュメントを読み直したらすぐ分かった、下記が関連箇所

https://developer.chatwork.com/docs/webhook#リクエストの署名検証

トークンをBase64デコードしたバイト列を秘密鍵とします(トークンはWebhook編集画面で確認できます)

Base64 デコードではなくエンコードしていた笑

なんで Base64 らしきものをもう一回エンコードするんだろうと疑問だったが解決した

ソースコードとテストコードを下記の通り修正する

src/app.controller.ts
import { Controller, Post, RawBodyRequest, Req, Res } from '@nestjs/common';
import { createHmac, timingSafeEqual } from 'crypto';
import { Request, Response } from 'express';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  /**
   * Chatwork Webhook 用のエンドポイントです。
   * @param req Express リクエストオブジェクト
   * @param res Express レスポンスオブジェクト
   */
  @Post('api/v1/chatwork/webhook')
  onApiV1ChatworkWebhook(
    @Req() req: RawBodyRequest<Request>,
    @Res() res: Response,
  ) {
    // リクエスト署名を検証します。
    const webhookToken = process.env.WEBHOOK_TOKEN;
    const privateKey = Buffer.from(webhookToken, 'base64');
    const actualSignature = createHmac('sha256', privateKey)
      .update(req.rawBody)
      .digest('base64');

    const expectedSignature = req.headers[
      'x-chatworkwebhooksignature'
    ] as string;

    const isSignatureVerified = timingSafeEqual(
      Buffer.from(actualSignature),
      Buffer.from(expectedSignature),
    );

    if (!isSignatureVerified) {
      res.status(400).send('Bad Request: !isSignatureVerified');
      console.warn(JSON.stringify({ actualSignature, expectedSignature }));
      return;
    }

    // リクエストの内容を出力します。
    console.info(req.rawBody.toString());

    res.status(200).end('OK');
  }
}
test/chatwork-webhook.e2e-spec.ts
import { createHmac } from 'crypto';
import fetch from 'node-fetch';

describe('Chatwork Webhook', () => {
  it('/api/v1/chatwork/webhook', async () => {
    const requestBody = JSON.stringify({
      webhook_setting_id: '12345',
      webhook_event_type: 'mention_to_me',
      webhook_event_time: 1498028130,
      webhook_event: {
        from_account_id: 123456,
        to_account_id: 1484814,
        room_id: 567890123,
        message_id: '789012345',
        body: '[To:1484814]おかずはなんですか?',
        send_time: 1498028125,
        update_time: 0,
      },
    });

    const webhookToken = process.env.WEBHOOK_TOKEN;
    const privateKey = Buffer.from(webhookToken, 'base64');
    const webhookSignature = createHmac('sha256', privateKey)
      .update(requestBody)
      .digest('base64');

    const fetchUrl = process.env.BASE_URL + '/api/v1/chatwork/webhook';
    const fetchResponse = await fetch(fetchUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'User-Agent': 'Chatwork-Webhook/1.0.0',
        'X-Chatworkwebhooksignature': webhookSignature,
      },
      body: requestBody,
    });

    console.log(fetchResponse.status);
    console.log(await fetchResponse.text());
  });
});

もう一回デプロイ

コマンド
npm run deploy
薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

メンションは他のユーザーからしてもらう必要があるのか

署名検証の確認が終わったらアカウントをもう一つ作って試してみよう

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

アカウントイベント Webhook の動作確認

Chatwork アカウントをもう 1 つ作成する

作成したら自分のアカウントに招待メールを送って承諾する

Webhook の編集ページからイベントをルームイベントからアカウントイベントに変更する

新しく作成したアカウントからメンション(TO)付きでメッセージを送ってみる

今度は成功した!

メンションは他のアカウントから送って送ってもらう必要があることがわかった

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

ルーム以外はルーム ID を調べられないと思っていたけど

よく考えたら URL の #!rid 以下がルーム ID なのでこれを使えば良い?

試しにメッセージを送ってみたらちゃんと届いた

良かった、これでユーザーごとに個別のルームを作成する必要がない

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

Webhook を無効にしておく

今のままだとチャットワークでメンションがある度に Webhook が実行されるので Webhook の編集ページからステータスを無効にするか、イベントをルームイベントに変更しておく

薄田達哉 / tatsuyasusukida薄田達哉 / tatsuyasusukida

おわりに

Chatwork の方も無事にメッセージの送受信を検証することができた

不特定多数に使用する場合は LINE と同様にアカウント連携も必要だが、特定少数の場合は管理者側でユーザーとルーム ID を紐付けてもらうことで対応できそう

このスクラップは2023/02/24にクローズされました