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?

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

Posted at

はじめに

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

成果物

ディレクトリ構造

ディレクトリ構造
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

ソースコード

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);
	},
};
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 });
	}
}

ここでルーティングを制御します。

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 });
		}
	}
}

ここでLinebotでコールされる関数を定義します。messageortextの場合、AIの処理をかけます。
それ以外は200のステータスを返却します

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,
				"申し訳ありません。メッセージの処理中にエラーが発生しました。",
			);
		}
	}
}
src/application/shared/interfaces/messageHandler.ts
export interface MessageHandler {
	reply(replyToken: string, message: string): Promise<void>;
}

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;
	}
}
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];
	}
}

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;
	}
}

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,
		};
	}
}
src/domain/shared/interfaces/aiProvider.ts
import type { Message } from "../../chat/value/message";

export interface AIProvider {
	generateResponse(messages: Message[]): Promise<string>;
}
src/domain/shared/types/env.ts
export interface Env {
	LINE_CHANNEL_SECRET: string;
	LINE_CHANNEL_ACCESS_TOKEN: string;
	OPENROUTER_API_KEY: string;
}

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;
		}
	}
}

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)}`);
		}
	}
}
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?