CloudflareでLinebotを作成する(OpenRouterを利用する)

## はじめに
表題通り、Cloudflareにデプロイし、Linebotを作成します。今回はAI Chatbotを作成したいため、OpenRouterを利用します！

## 成果物

## ディレクトリ構造

```shell:ディレクトリ構造
src/
├── index.ts           # アプリケーションのエントリーポイント
├── core/             # コア層
│   └── app.ts        # アプリケーションの初期化と設定
├── presentation/     # プレゼンテーション層
│   └── api/
│       ├── router.ts # ルーティング
│       └── handlers/
│           └── webhookHandler.ts
├── application/       # アプリケーション層
│   ├── chat/
│   │   └── commands/
│   │       └── processMessageCommand.ts
│   └── shared/
├── domain/           # ドメイン層
│   ├── chat/
│   │   ├── service/
│   │   │   └── chatService.ts
│   │   ├── entity/
│   │   │   └── conversation.ts
│   │   └── value/
│   │       └── message.ts
│   └── shared/
│       ├── interfaces/
│       │   └── aiProvider.ts
│       └── types/
│           └── env.ts
└── infrastructure/   # インフラストラクチャ層
    ├── ai/
    │   └── openRouterClient.ts
    └── line/
        └── client.ts
```

## ソースコード
```ts:src/index.ts
import { Router } from "@presentation/api/router";
import type { Env } from "@shared/types/env";

export default {
	async fetch(request: Request, env: Env): Promise<Response> {
		const router = new Router(env);
		return router.handle(request);
	},
};
```

```ts:src/presentation/api/router.ts
import { WebhookHandler } from "./handlers/webhookHandler";
import type { Env } from "@shared/types/env";

/**
 * アプリケーションのルーティングを管理するクラス
 * 各エンドポイントへのリクエストを適切なハンドラーに振り分ける
 */
export class Router {
	private webhookHandler: WebhookHandler;

	/**
	 * Routerのコンストラクタ
	 * @param {Env} env - 環境変数
	 */
	constructor(env: Env) {
		this.webhookHandler = new WebhookHandler(env);
	}

	/**
	 * リクエストを処理し、適切なレスポンスを返す
	 * @param {Request} request - HTTPリクエスト
	 * @returns {Promise<Response>} HTTPレスポンス
	 */
	async handle(request: Request): Promise<Response> {
		const url = new URL(request.url);

		switch (url.pathname) {
			case "/health":
				return this.handleHealth();

			case "/webhook":
				return this.webhookHandler.handle(request);

			default:
				return new Response("Not Found", { status: 404 });
		}
	}

	/**
	 * ヘルスチェックエンドポイントのハンドラー
	 * @returns {Response} 200 OKレスポンス
	 */
	private handleHealth(): Response {
		return new Response("OK", { status: 200 });
	}
}

```
:::note info
ここでルーティングを制御します。
:::

```ts:src/presentation/api/handlers/webhookHandler.ts
import { ProcessMessageCommand } from "../../../application/chat/commands/processMessageCommand";
import { ChatService } from "../../../domain/chat/service/chatService";
import { OpenRouterClient } from "../../../infrastructure/ai/openRouterClient";
import { LineClient } from "../../../infrastructure/line/client";
import type { Env } from "../../../domain/shared/types/env";
import type { WebhookEvent } from "@line/bot-sdk";

const SYSTEM_PROMPT = `あなたはLINEユーザーのコンシェルジュです。
以下の指針に従って応答してください：

1. 丁寧で親しみやすい口調を使用
2. 簡潔で分かりやすい説明を心がける
3. 必要に応じて具体例を提示
4. ユーザーの質問や要望を注意深く理解
5. 適切な提案やアドバイスを提供
6. 専門用語を使用する場合は分かりやすく説明

また、以下の点に注意してください：
- 不適切な内容や誤解を招く情報は提供しない
- 確信が持てない情報は提供を控える
- ユーザーの個人情報は慎重に扱う
- 必要に応じてフォローアップ質問をする`;

export class WebhookHandler {
	private readonly processMessageCommand: ProcessMessageCommand;

	constructor(env: Env) {
		const lineClient = new LineClient({
			channelAccessToken: env.LINE_CHANNEL_ACCESS_TOKEN,
			channelSecret: env.LINE_CHANNEL_SECRET,
		});

		const aiClient = new OpenRouterClient({
			apiKey: env.OPENROUTER_API_KEY,
		});

		const chatService = new ChatService(aiClient, SYSTEM_PROMPT);
		this.processMessageCommand = new ProcessMessageCommand(
			chatService,
			lineClient,
		);
	}

	async handle(request: Request): Promise<Response> {
		try {
			const body = await request.json<{ events: WebhookEvent[] }>();

			for (const event of body.events) {
				if (event.type === "message" && event.message.type === "text") {
					await this.processMessageCommand.execute(
						event.message.text,
						event.replyToken,
					);
				}
			}

			return new Response("OK", { status: 200 });
		} catch (error) {
			console.error("Error processing webhook:", error);
			return new Response("Internal Server Error", { status: 500 });
		}
	}
}
```
:::note info
ここでLinebotでコールされる関数を定義します。`message`or`text`の場合、AIの処理をかけます。
それ以外は200のステータスを返却します
:::

```ts:src/application/chat/commands/processMessageCommand.ts
import type { ChatService } from "@domain/chat/service/chatService";
import type { MessageHandler } from "@application/shared/interfaces/messageHandler";

/**
 * メッセージ処理コマンドクラス
 * チャットサービスを使用してメッセージを処理し、応答を送信する
 */
export class ProcessMessageCommand {
	/**
	 * ProcessMessageCommandのコンストラクタ
	 * @param {ChatService} chatService - チャットサービスのインスタンス
	 * @param {MessageHandler} messageHandler - メッセージ送信ハンドラのインスタンス
	 */
	constructor(
		private readonly chatService: ChatService,
		private readonly messageHandler: MessageHandler,
	) {}

	/**
	 * メッセージを処理し、応答を送信する
	 * @param {string} message - ユーザーからのメッセージ
	 * @param {string} replyToken - LINE応答用のトークン
	 * @returns {Promise<void>}
	 */
	public async execute(message: string, replyToken: string): Promise<void> {
		try {
			const response = await this.chatService.processUserMessage(message);
			await this.messageHandler.reply(replyToken, response);
		} catch (error) {
			console.error("Error processing message:", error);
			await this.messageHandler.reply(
				replyToken,
				"申し訳ありません。メッセージの処理中にエラーが発生しました。",
			);
		}
	}
}
```

```ts:src/application/shared/interfaces/messageHandler.ts
export interface MessageHandler {
	reply(replyToken: string, message: string): Promise<void>;
}

```

```ts:src/domain/chat/service/chatService.ts
import { Conversation } from "../entity/conversation";
import type { AIProvider } from "../../shared/interfaces/aiProvider";

export class ChatService {
	private conversation: Conversation;

	constructor(
		private readonly aiProvider: AIProvider,
		systemPrompt: string,
	) {
		this.conversation = new Conversation(systemPrompt);
	}

	public async processUserMessage(message: string): Promise<string> {
		this.conversation.addUserMessage(message);

		const response = await this.aiProvider.generateResponse(
			this.conversation.getMessages(),
		);

		this.conversation.addAssistantMessage(response);
		return response;
	}
}
```

```ts:src/domain/chat/entity/conversation.ts
import { Message } from "../value/message";

export class Conversation {
	private messages: Message[] = [];
	private readonly maxHistorySize = 10;

	constructor(private readonly systemPrompt: string) {
		this.addSystemMessage(systemPrompt);
	}

	public addUserMessage(content: string): void {
		this.addMessage(new Message("user", content));
	}

	public addAssistantMessage(content: string): void {
		this.addMessage(new Message("assistant", content));
	}

	private addSystemMessage(content: string): void {
		this.addMessage(new Message("system", content));
	}

	private addMessage(message: Message): void {
		this.messages.push(message);
		this.trimHistory();
	}

	private trimHistory(): void {
		if (this.messages.length > this.maxHistorySize) {
			// システムメッセージを保持しつつ、古いメッセージを削除
			const systemMessage = this.messages[0];
			this.messages = [
				systemMessage,
				...this.messages.slice(-this.maxHistorySize + 1),
			];
		}
	}

	public getMessages(): Message[] {
		return [...this.messages];
	}
}

```

```ts:src/domain/chat/service/chatService.ts
import { Conversation } from "../entity/conversation";
import type { AIProvider } from "../../shared/interfaces/aiProvider";

export class ChatService {
	private conversation: Conversation;

	constructor(
		private readonly aiProvider: AIProvider,
		systemPrompt: string,
	) {
		this.conversation = new Conversation(systemPrompt);
	}

	public async processUserMessage(message: string): Promise<string> {
		this.conversation.addUserMessage(message);

		const response = await this.aiProvider.generateResponse(
			this.conversation.getMessages(),
		);

		this.conversation.addAssistantMessage(response);
		return response;
	}
}

```

```ts:src/domain/chat/value/message.ts
export type MessageRole = "system" | "user" | "assistant";

export class Message {
	constructor(
		private readonly role: MessageRole,
		private readonly content: string,
	) {
		if (!content.trim()) {
			throw new Error("Message content cannot be empty");
		}
	}

	public getRole(): MessageRole {
		return this.role;
	}

	public getContent(): string {
		return this.content;
	}

	public toJSON() {
		return {
			role: this.role,
			content: this.content,
		};
	}
}
```

```ts:src/domain/shared/interfaces/aiProvider.ts
import type { Message } from "../../chat/value/message";

export interface AIProvider {
	generateResponse(messages: Message[]): Promise<string>;
}
```

```ts:src/domain/shared/types/env.ts
export interface Env {
	LINE_CHANNEL_SECRET: string;
	LINE_CHANNEL_ACCESS_TOKEN: string;
	OPENROUTER_API_KEY: string;
}

```

```ts:src/infrastructure/ai/openRouterClient.ts
import type { AIProvider } from "@shared/interfaces/aiProvider";
import type { Message } from "@domain/chat/value/message";

/**
 * OpenRouter APIからのレスポンスの型定義
 * @interface OpenRouterResponse
 */
interface OpenRouterResponse {
	choices: Array<{
		message: {
			content: string;
		};
	}>;
}

/**
 * OpenRouterクライアントの設定オプション
 * @interface OpenRouterConfig
 * @property {string} apiKey - OpenRouter APIキー
 * @property {string} [model] - 使用するAIモデル名（オプション）
 */
export interface OpenRouterConfig {
	apiKey: string;
	model?: string;
}

/**
 * OpenRouter APIクライアントの実装
 * @implements {AIProvider}
 */
export class OpenRouterClient implements AIProvider {
	private readonly baseUrl = "https://openrouter.ai/api/v1";
	private readonly model: string;
	private readonly apiKey: string;

	/**
	 * OpenRouterClientのコンストラクタ
	 * @param {OpenRouterConfig} config - クライアントの設定
	 */
	constructor(private readonly config: OpenRouterConfig) {
		this.model = config.model || "google/gemini-2.0-flash-exp:free";
		this.apiKey = config.apiKey.trim();
	}

	/**
	 * メッセージ履歴から応答を生成
	 * @param {Message[]} messages - 会話履歴のメッセージ配列
	 * @returns {Promise<string>} 生成された応答テキスト
	 * @throws {Error} API呼び出しに失敗した場合
	 */
	async generateResponse(messages: Message[]): Promise<string> {
		try {
			const headers = new Headers();
			headers.set("Content-Type", "application/json");
			headers.set("Authorization", `Bearer ${this.apiKey}`);
			headers.set("HTTP-Referer", "対象のURLを設定");
			headers.set("X-Title", "LINE Bot Concierge");
			headers.set("OpenRouter-Auth", `Bearer ${this.apiKey}`);
			const requestBody = {
				model: this.model,
				messages: messages.map((m) => m.toJSON()),
				temperature: 0.7,
				max_tokens: 1000,
			};

			const response = await fetch(`${this.baseUrl}/chat/completions`, {
				method: "POST",
				headers,
				body: JSON.stringify(requestBody),
			});

			if (!response.ok) {
				const errorData = await response.json();
				console.error("OpenRouter API error response:", errorData);
				throw new Error(`OpenRouter API error: ${JSON.stringify(errorData)}`);
			}

			const data = (await response.json()) as OpenRouterResponse;
			return data.choices[0].message.content;
		} catch (error) {
			console.error("Error generating AI response:", error);
			throw error;
		}
	}
}

```

```ts:src/infrastructure/line/client.ts
import type { MessageHandler } from "../../application/shared/interfaces/messageHandler";

export interface LineConfig {
	channelAccessToken: string;
	channelSecret: string;
}

export class LineClient implements MessageHandler {
	private readonly baseUrl = "https://api.line.me/v2/bot";

	constructor(private readonly config: LineConfig) {}

	public async reply(replyToken: string, message: string): Promise<void> {
		const response = await fetch(`${this.baseUrl}/message/reply`, {
			method: "POST",
			headers: {
				"Content-Type": "application/json",
				Authorization: `Bearer ${this.config.channelAccessToken}`,
			},
			body: JSON.stringify({
				replyToken,
				messages: [
					{
						type: "text",
						text: message,
					},
				],
			}),
		});

		if (!response.ok) {
			const error = await response.json();
			throw new Error(`LINE API error: ${JSON.stringify(error)}`);
		}
	}
}
```