3
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?

Discordで日本語↔英語を自動翻訳するBotを作ってみた

3
Posted at

はじめに

今回、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)

処理フロー

  1. ユーザーが日本語チャンネルにメッセージを投稿
  2. BotがmessageCreateイベントを検知
  3. DeepL APIで英語に翻訳
  4. Webhook経由で英語チャンネルに投稿
  5. メッセージ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のセットアップ

プロジェクトの作成

  1. Supabaseでアカウントを作成
  2. 新規プロジェクトを作成
  3. リージョンで「Tokyo (ap-northeast-1)」を選択
  4. データベースパスワードを設定

接続文字列の取得

プロジェクト作成後、「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インスタンスの作成

  1. XServer VPSでアカウントを作成
  2. 2GBプランを選択
  3. OSイメージで「Ubuntu 24.04」を選択
  4. サーバーを起動

デプロイ手順

# 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

.env
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 で得意不得意があったので、使い分けも楽しんでみてください。

3
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
3
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?