はじめに
今回、Discord上で日本語チャンネルと英語チャンネル間のメッセージを自動翻訳するBotを開発しました。本記事では、その実装の詳細について解説していきたいと思います。
この記事で分かること
- Webhookを使った元の投稿者情報を保持したメッセージ送信
- 無限ループを防ぐ実装方法
- DeepL APIを使った翻訳の実装
- メッセージの編集・削除を同期する方法
- Supabase + XServer VPSでの運用
技術スタック
以下の技術を使用して開発しました。
| 技術 | 用途 |
|---|---|
| TypeScript | 開発言語 |
| Node.js 22 | ランタイム |
| discord.js v14 | Discord Botフレームワーク |
| DeepL API | 翻訳エンジン |
| Supabase (PostgreSQL) | データベース |
| Drizzle ORM | ORM |
| Docker | コンテナ化 |
| XServer VPS | デプロイ先 |
システム構成
基本的なシステム構成は以下の通りです。
Discordサーバー
├─ 日本語チャンネル
└─ 英語チャンネル
↕
Translation Bot
↓
Supabase (PostgreSQL)
処理フロー
- ユーザーが日本語チャンネルにメッセージを投稿
- Botが
messageCreateイベントを検知 - DeepL APIで英語に翻訳
- Webhook経由で英語チャンネルに投稿
- メッセージIDの紐付けをデータベースに保存
ディレクトリ構成
src/
├── config/ # 環境変数管理
│ └── env.ts
├── core/ # ビジネスロジック層
│ ├── entities/
│ ├── events/ # Discordイベントハンドラ
│ └── repositories/
├── infrastructure/ # インフラ層
│ ├── database/
│ └── discord.ts
├── translation/ # 翻訳サービス
│ ├── translationService.ts
│ └── translator.ts
├── webhook/ # Webhook管理
│ ├── webhookManager.ts
│ └── webhookSender.ts
├── utils/
│ ├── constants.ts
│ └── loopPrevention.ts
└── index.ts
実装の詳細
1. Webhookを使った投稿者情報の保持
Botが通常の方法でメッセージを送信すると、Bot自身の名前とアイコンで投稿されます。
元の投稿者の情報を保持するため、Webhookを使用しました。
// Webhook経由でメッセージを送信
const displayName = message.member?.displayName ?? message.author.username;
const avatarURL = message.author.displayAvatarURL({ size: 256 });
await webhook.send({
content: translatedText,
username: displayName,
avatarURL: avatarURL,
});
Webhookのキャッシュ管理
毎回Webhookを作成するとAPIのレート制限に達するため、チャンネルごとに1つのWebhookを作成して再利用します。
const webhookCache = new Map<string, Webhook>();
async function getOrCreateWebhook(channel: TextChannel): Promise<Webhook> {
// キャッシュから取得
if (webhookCache.has(channel.id)) {
return webhookCache.get(channel.id)!;
}
// 既存のWebhookを検索
const webhooks = await channel.fetchWebhooks();
let webhook = webhooks.find(wh => wh.name === 'TranslatorBot');
// なければ新規作成
if (!webhook) {
webhook = await channel.createWebhook({ name: 'TranslatorBot' });
}
webhookCache.set(channel.id, webhook);
return webhook;
}
2. 無限ループの防止
翻訳されたメッセージを再度翻訳してしまうと無限ループが発生します。
Webhook経由で送信されたメッセージにはwebhookIdプロパティが存在するため、これをチェックしてスキップします。
client.on('messageCreate', async (message) => {
// Botのメッセージはスキップ
if (message.author.bot) return;
// Webhookメッセージはスキップ
if (message.webhookId) return;
// システムメッセージはスキップ
if (message.system) return;
// 翻訳処理を実行
await translateAndSend(message);
});
3. メッセージの編集・削除の同期
元のメッセージと翻訳先のメッセージを紐付けるため、データベースに保存します。
データベーススキーマ
export const messageMirrors = pgTable("message_mirrors", {
id: serial("id").primaryKey(),
originalMessageId: varchar("original_message_id", { length: 255 }).notNull(),
mirroredMessageId: varchar("mirrored_message_id", { length: 255 }).notNull(),
originalChannelId: varchar("original_channel_id", { length: 255 }).notNull(),
mirroredChannelId: varchar("mirrored_channel_id", { length: 255 }).notNull(),
webhookId: varchar("webhook_id", { length: 255 }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
});
メッセージ送信時の処理
// 翻訳して送信
const sentMessage = await webhook.send({
content: translatedText,
username: displayName,
avatarURL: avatarURL,
});
// データベースに紐付けを保存
await db.insert(messageMirrors).values({
originalMessageId: message.id,
mirroredMessageId: sentMessage.id,
originalChannelId: message.channelId,
mirroredChannelId: targetChannel.id,
webhookId: webhook.id,
});
メッセージ編集時の処理
client.on('messageUpdate', async (oldMessage, newMessage) => {
if (newMessage.webhookId) return;
// データベースから紐付けを取得
const [mirror] = await db
.select()
.from(messageMirrors)
.where(eq(messageMirrors.originalMessageId, newMessage.id))
.limit(1);
if (!mirror) return;
// 新しい内容を翻訳
const translatedText = await translateText(newMessage.content);
// Webhook経由で編集
const webhook = await getWebhook(targetChannel);
await webhook.editMessage(mirror.mirroredMessageId, translatedText);
});
メッセージ削除時の処理
client.on('messageDelete', async (message) => {
if (message.webhookId) return;
// データベースから紐付けを取得
const [mirror] = await db
.select()
.from(messageMirrors)
.where(eq(messageMirrors.originalMessageId, message.id))
.limit(1);
if (!mirror) return;
// Webhook経由で削除
const webhook = await getWebhook(targetChannel);
await webhook.deleteMessage(mirror.mirroredMessageId);
});
4. DeepL APIによる翻訳
DeepL APIを使用して翻訳を行います。
npm install deepl-node
import * as deepl from 'deepl-node';
const translator = new deepl.Translator(process.env.DEEPL_API_KEY!);
// 日本語から英語への翻訳
export async function translateToEnglish(text: string): Promise<string> {
const result = await translator.translateText(text, 'ja', 'en-US');
return result.text;
}
// 英語から日本語への翻訳
export async function translateToJapanese(text: string): Promise<string> {
const result = await translator.translateText(text, null, 'ja');
return result.text;
}
5. 環境変数のバリデーション
起動時に必須の環境変数が設定されているかをZodで検証します。
import { z } from 'zod';
const envSchema = z.object({
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
JP_CHANNEL_ID: z.string().min(1, "JP_CHANNEL_ID is required"),
EN_CHANNEL_ID: z.string().min(1, "EN_CHANNEL_ID is required"),
DEEPL_API_KEY: z.string().min(1, "DEEPL_API_KEY is required"),
DATABASE_URL: z.string().min(1, "DATABASE_URL is required"),
});
export const env = envSchema.parse(process.env);
Supabaseのセットアップ
プロジェクトの作成
- Supabaseでアカウントを作成
- 新規プロジェクトを作成
- リージョンで「Tokyo (ap-northeast-1)」を選択
- データベースパスワードを設定
接続文字列の取得
プロジェクト作成後、「Project Settings」→「Database」から接続文字列を取得します。
postgresql://postgres.[project-ref]:[password]@[aws-0-ap-northeast-1.pooler.supabase.com:5432/postgres](https://aws-0-ap-northeast-1.pooler.supabase.com:5432/postgres)
マイグレーションの実行
Drizzle ORMを使用してマイグレーションを実行します。
# マイグレーションファイルの生成
npm run db:generate
# マイグレーションの実行
export DATABASE_URL="postgresql://..." && npm run db:migrate
Dockerによるコンテナ化
Dockerfile
マルチステージビルドを使用してイメージサイズを削減します。
# ビルドステージ
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 本番ステージ
FROM node:22-alpine AS production
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/drizzle ./drizzle
COPY --from=builder /app/src ./src
CMD ["npm", "run", "start"]
docker-compose.prod.yml
services:
bot:
build:
context: .
dockerfile: Dockerfile
target: production
env_file:
- .env.prod
restart: unless-stopped
XServer VPSへのデプロイ
VPSインスタンスの作成
- XServer VPSでアカウントを作成
- 2GBプランを選択
- OSイメージで「Ubuntu 24.04」を選択
- サーバーを起動
デプロイ手順
# VPSにSSH接続
ssh root@<VPSのIPアドレス>
# リポジトリをクローン
git clone [https://github.com/your-username/translator-bot.git](https://github.com/your-username/translator-bot.git)
cd translator-bot
# 環境変数ファイルを作成
nano .env.prod
DISCORD_TOKEN=your_discord_bot_token
JP_CHANNEL_ID=123456789012345678
EN_CHANNEL_ID=987654321098765432
DEEPL_API_KEY=your_deepl_api_key
DATABASE_URL=postgresql://...
# Dockerイメージをビルド
sudo docker compose -f docker-compose.prod.yml build
# コンテナを起動
sudo docker compose -f docker-compose.prod.yml up -d
# ログを確認
sudo docker compose -f docker-compose.prod.yml logs -f
自動起動の設定
VPS再起動時に自動的にBotが起動するようsystemdサービスを設定します。
sudo nano /etc/systemd/system/translator-bot.service
[Unit]
Description=Discord Translation Bot
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/root/translator-bot
ExecStart=/usr/bin/docker compose -f docker-compose.prod.yml up -d
ExecStop=/usr/bin/docker compose -f docker-compose.prod.yml down
[Install]
WantedBy=multi-user.target
# サービスを有効化
sudo systemctl enable translator-bot.service
sudo systemctl start translator-bot.service
# ステータスを確認
sudo systemctl status translator-bot.service
まとめ
本記事では、Discord上で日本語チャンネルと英語チャンネル間のメッセージを自動翻訳するBotの実装について紹介しました。WebhookとREST API で得意不得意があったので、使い分けも楽しんでみてください。