生成AIにLINE Botを作ってもらう Claude3.5 Sonnet編 その2

# はじめに

[前回](https://qiita.com/bathtimefish/items/d0fde98456294c94011e)のつづきです。

## Bad Requestの原因を検証する

前回はClaude3が生成したコードをそのまま実行したらWebサーバーはエラー無く起動ましたが、LINE Developers ConsoleからWebhook URLの検証を行うとBad Requestが返ってきました。まずこの原因を検証してみたいと思います。

生成されたコードにはご丁寧にHTTP GETでシンプルなレスポンスを返すパスも記述されていました。

```main.ts
// Health check endpoint
router.get('/health', (ctx) => {
  ctx.response.body = 'OK';
});
```

とりあえずGETで疎通確認ができるのは便利ですね。やってみます。

```shell
$ curl https://line-bot.bathtimefish.com:3000/health
OK%                                                               aws btf$ curl -v https://line-bot.bathtimefish.com:3000/health
*   Trying 18.237.4.43:3000...
* Connected to line-bot.bathtimefish.com (18.237.4.43) port 3000
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
*  CAfile: /etc/ssl/cert.pem
*  CApath: none
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-CHACHA20-POLY1305-SHA256
* ALPN: server did not agree on a protocol. Uses default.
* Server certificate:
*  subject: CN=line-bot.bathtimefish.com
*  start date: Jan 26 08:15:16 2025 GMT
*  expire date: Apr 26 08:15:15 2025 GMT
*  subjectAltName: host "line-bot.bathtimefish.com" matched cert's "line-bot.bathtimefish.com"
*  issuer: C=US; O=Let's Encrypt; CN=R10
*  SSL certificate verify ok.
* using HTTP/1.x
> GET /health HTTP/1.1
> Host: line-bot.bathtimefish.com:3000
> User-Agent: curl/8.4.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< content-type: text/plain; charset=UTF-8
< vary: Accept-Encoding
< content-length: 2
< date: Sun, 26 Jan 2025 09:19:04 GMT
< 
* Connection #0 to host line-bot.bathtimefish.com left intact
OK%
```

正常に結果が返ってきました。TLSも問題なさげです。前回の最後に

> コードをよく見ると、httpsOptionsが実質使われていないなど、なんかおかしな点がありますね。ここだけ見ても certFile: httpsOptions.cert にするべきじゃないでしょうか。

と書きましたが、問題なかったようです。逆に `httpsOptions` 自体がこのコードに必要ないですね。

さて、Webサーバー自体は問題なく稼働しているとなると次に疑うべきは `/webhook`パスの処理内容ですね。ちょっとブロック内の一行目にデバッグ出力を仕込んでみます。

```main.ts
// Webhook endpoint
router.post('/webhook', validateLineSignatureMiddleware, async (ctx) => {
  console.log(ctx) // debug!
  const body = await ctx.request.body().value;
  const events: WebhookEvent[] = body.events;

  await Promise.all(events.map((event) => bot.handleEvent(event)));
  
  ctx.response.status = 200;
  ctx.response.body = 'OK';
});
```

LINE Developer consoleからのwebhook検証時のエラー内容は以下のとおりです。

```
The webhook returned an HTTP status code other than 200.(400 Bad Request)

Confirm that your bot server returns status code 200 in response to the HTTP POST request sent from the LINE Platform. For more information, see Response in the Messaging API Reference.
```

そして、検証時のWebサーバーのログはなにも出力されません。

```shell
t$ deno run --allow-net --allow-env --allow-read main.ts
Server starting on https://localhost:3000

```

というわけで、`/webhook` ルーターまでリクエストが渡ってないようです。ということは `validateLineSignatureMiddleware` が怪しいです。

```main.ts
// Middleware for LINE signature validation
async function validateLineSignatureMiddleware(ctx: Context, next: () => Promise<void>) {
  const signature = ctx.request.headers.get('x-line-signature');
  if (!signature) {
    ctx.response.status = 400;
    ctx.response.body = 'Missing signature';
    return;
  }

  const body = await ctx.request.body().value;
  const bodyStr = JSON.stringify(body);

  if (!validateSignature(bodyStr, config.channelSecret, signature)) {
    ctx.response.status = 400;
    ctx.response.body = 'Invalid signature';
    return;
  }

  await next();
}
```

そもそもここでシグネチャがinvalidだったとき400を返すようにしてますね。ちょっとそれっぽい箇所にデバッグ出力を仕込んでみます。

```main.ts
// Middleware for LINE signature validation
async function validateLineSignatureMiddleware(ctx: Context, next: () => Promise<void>) {
  console.log("Request Headers:", ctx.request.headers);  // debug!
  const signature = ctx.request.headers.get('x-line-signature');
  console.log("Signature:", signature);
  if (!signature) {
    ctx.response.status = 400;
    ctx.response.body = 'Missing signature';
    return;
  }

  const body = await ctx.request.body().value;
  const bodyStr = JSON.stringify(body);

  console.log("validateSignature():", validateSignature(bodyStr, config.channelSecret, signature));  // debug!

  if (!validateSignature(bodyStr, config.channelSecret, signature)) {
    ctx.response.status = 400;
    ctx.response.body = 'Invalid signature';
    return;
  }

  await next();
}
```

webhookの検証を行ったあとのサーバー側の出力は以下です。

```shell
Request Headers: Headers {
  "content-length": "63",
  "content-type": "application/json; charset=utf-8",
  host: "line-bot.bathtimefish.com:3000",
  "user-agent": "LineBotWebhook/2.0",
  "x-line-signature": "vfG8E(中略)Gfzw="
}
Signature: vfG8E(中略)Gfzw=
validateSignature(): false
```

シグネチャはちゃんと受信してるが、`validateSignature()`がfalseを返しています。これが原因ですね。

## Craudeに修正させてみる

とりあえずこっちで原因を特定したよ、と言ったうえで修正方法を考えてみてもらいます。

### プロンプト

上記のコードを実行したところ、エラー無くWebサーバーが起動しましたが、メッセージをポストするとHTTP Response 400が返ってきました。コードを調べたところ、どうやら `if (!validateSignature(bodyStr, config.channelSecret, signature))`がFalseを返してるのが原因です。これに基づいてコードを修正してください。


### 回答

LINE Messaging APIの署名検証の問題を修正します。主な原因は、リクエストボディの文字列化方法にあります。LINE Bot Server with DenoClick to open code修正のポイント：

リクエストボディを生のテキストとして取得
再度JSON.stringifyしないことで、LINE SDKの署名検証と一致させる

このコードで署名検証が正しく機能するはずです。それでも問題が続く場合は、実際のリクエストボディとヘッダーの内容を確認させていただけますでしょうか。

```main.ts
// deps.ts
import { 
  Application,
  Router,
  Context
} from "https://deno.land/x/oak@v12.6.1/mod.ts";
import { 
  Client as LineClient,
  validateSignature,
  WebhookEvent
} from "npm:@line/bot-sdk@8.0.0";

// config.ts
export interface Config {
  channelAccessToken: string;
  channelSecret: string;
  port: number;
  certFile: string;
  keyFile: string;
}

// Create config from environment variables
export const config: Config = {
  channelAccessToken: Deno.env.get("LINE_CHANNEL_ACCESS_TOKEN") || "",
  channelSecret: Deno.env.get("LINE_CHANNEL_SECRET") || "",
  port: parseInt(Deno.env.get("PORT") || "3000"),
  certFile: Deno.env.get("CERT_FILE") || "./cert/server.crt",
  keyFile: Deno.env.get("KEY_FILE") || "./cert/server.key",
};

// lineBot.ts
export class LineBot {
  private client: LineClient;

  constructor(config: Config) {
    this.client = new LineClient({
      channelAccessToken: config.channelAccessToken,
      channelSecret: config.channelSecret,
    });
  }

  // Handle webhook events
  async handleEvent(event: WebhookEvent): Promise<void> {
    if (event.type !== 'message' || event.message.type !== 'text') {
      return;
    }

    const { replyToken } = event;
    const { text } = event.message;

    // Echo the received message
    await this.client.replyMessage(replyToken, {
      type: 'text',
      text: `受信したメッセージ: ${text}`,
    });
  }
}

// server.ts
const app = new Application();
const router = new Router();
const bot = new LineBot(config);

// Middleware for LINE signature validation
async function validateLineSignatureMiddleware(ctx: Context, next: () => Promise<void>) {
  const signature = ctx.request.headers.get('x-line-signature');
  if (!signature) {
    ctx.response.status = 400;
    ctx.response.body = 'Missing signature';
    return;
  }

  const rawBody = await ctx.request.body({ type: 'text' }).value;
  const bodyStr = rawBody;

  if (!validateSignature(bodyStr, config.channelSecret, signature)) {
    ctx.response.status = 400;
    ctx.response.body = 'Invalid signature';
    return;
  }

  await next();
}

// Webhook endpoint
router.post('/webhook', validateLineSignatureMiddleware, async (ctx) => {
  const body = await ctx.request.body().value;
  const events: WebhookEvent[] = body.events;

  await Promise.all(events.map((event) => bot.handleEvent(event)));
  
  ctx.response.status = 200;
  ctx.response.body = 'OK';
});

// Health check endpoint
router.get('/health', (ctx) => {
  ctx.response.body = 'OK';
});

app.use(router.routes());
app.use(router.allowedMethods());

// Start the server
const httpsOptions = {
  cert: await Deno.readTextFile(config.certFile),
  key: await Deno.readTextFile(config.keyFile),
};

console.log(`Server starting on https://localhost:${config.port}`);
await app.listen({
  port: config.port,
  secure: true,
  certFile: config.certFile,
  keyFile: config.keyFile,
});
```

## 実行してみる

生成されたコードを実行します

```shell
deno run --allow-net --allow-env --allow-read main.ts
```

Webhook検証を行ったらエラーが出ました。前回と同様です。

![スクリーンショット 2025-01-26 19.27.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/61995/3d60f1da-b846-5d6c-0229-4c0bfbca8345.png)

`validateSignature()`をデバッグしてみたところ、`false`でした。状況は変わっていません。

```shell
deno run --allow-net --allow-env --allow-read main.ts
Server starting on https://localhost:3000
validateSignature: false
```

# おわりに

まずここまでの感想として。まだ正常に動いていないですが初回に生成されたコードの品質は明らかに[生成AIにLINE Botを作ってもらう GPT4o編](https://qiita.com/bathtimefish/items/56113209a1ad3586dea3)で試したGPT4oより上です。あの時より若干プロンプトの精度が上がっているとはいえ、一発でエラー無くサーバーが上がっていること、コードの全体を見渡してもLinebot SDK的に違和感ないコードになっているところがすごいです。

今回はある程度人間が原因を特定してから修正指示を与えてみましたが、状況は改善されませんでした。実はぼく自身はどう修正すべきか心当たりがあるのですが。。。次回はあえて教えずにがんばらせてみようと思います。コードに強いと評判のClaude3にがんばってもらいたい！