はじめに
表題通り、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でコールされる関数を定義します。message
ortext
の場合、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)}`);
}
}
}