ticktakclockの日記

技術ポエムを綴ったりします。GitHub idも同じです (@ticktakclock)

Discordのテキスト読み上げBOTを作る

こんにちは、tkyです。

普段自分が利用しているDiscordサーバー用にディスコードのテキスト読み上げBOTを作成したのでBOTを作ってみたいな、作るときにどういったところに気をつけて作ればよいのかと思っている人向けに一部共有します。

テキスト読み上げBOTとは?

Discordチャット欄の文字をボイスチャット上に読み上げてくれるDiscordのBOT機能を利用したアプリケーションです。

javascriptとdiscordjsで作成しています。

  • node v17.0.1
  • npm 8.1.0
  • discordjs 13.2.0 ★メインここの話をします★
  • GCP (Text To Speechに使います)
  • Heroku (サーバーとして利用します)

discord.js.org

あまりdiscordjs v13系で作っている人がいなくて結構苦労しました。

また、テキスト読み上げ(Text To Speech)にGCP、サーバーにHerokuを使っていますが、この辺の導入部分は割愛します。

GCPなくても一応読み上げは作れます(後述)

やること

BOTを作るところだけにフォーカスして話しますが、DiscordBOTの作成などもやる必要があります。

この辺は適宜ググっていただければと・・・

  • DiscordBOTを作成する
  • BOTを作る(コーディング)
  • GCPのTTSを有効にする(今回は使いません)
  • Herokuにアップロードする

npm install するものたち

npm install discord.js @discordjs/builders @discordjs/rest @discordjs/voice google-tts-api dotenv

discordjsとボイスチャット上でしゃべるのに必要なライブラリたちを入れます。

google-tts-api は本来は @google-cloud/text-to-speech 使ったほうが良いのですが、GCP登録とかが面倒でとりあえずBOTを動かしたい人向けに。

dotenvはdiscordの認証情報などをコードに書かないように環境変数から読み込めるようにするために入れています。

実装

解説用にコメント多めに入れておきます。 discordjs v13は過去バージョンv12に比べて使うクラスとかが微妙に違っていたりしたので苦労しました。。

// .envファイルに書かれている環境変数を適用します。
require('dotenv').config();
const { Client, Intents } = require('discord.js');
const { joinVoiceChannel, createAudioPlayer, StreamType,createAudioResource} = require('@discordjs/voice');
const googleTTS = require('google-tts-api'); // GCP使わない場合はこれを使います

// GCP使う場合はこの3つを入れます
const gcpTTS = require('@google-cloud/text-to-speech');
const fs = require('fs');
const util = require('util');

// Discordクライアントです
const client = new Client({ intents: [Intents.FLAGS.GUILDS, Intents.FLAGS.GUILD_MESSAGES, Intents.FLAGS.GUILD_VOICE_STATES] });

// クライアントのイベントハンドラです。
// ログイン処理後1度だけ呼ばれます。基本ログ出すだけです。
client.once('ready', () => {
    console.log(`Logged in as ${client.user.tag}!`);
});


// クライアントのイベントハンドラです。
// BOTが参加しているサーバーのチャット欄にメッセージが書き込まれたら呼ばれます。
client.on('messageCreate', async msg => {
    if (!msg.guild) return;
    try {
        // ここにメッセージに対する処理を書いていきます。
        // ここに書いても良いですが、長くなるので別関数に処理を流します。
        await messageCreateDelegate(msg);
    } catch(e) {
        // メッセージ処理に失敗した時用にtry-catchで括っておきます。
        msg.channel.send('エラーが発生してるよ。管理者に連絡してください。');
        console.log('messageCreateDelegate error :' + e);
    }
});

// ボイスチャットセッション保存用のMapです。
const subscriptions = new Map();
// 読み上げ対象のDicordチャンネル保存用のMapです。
const channels = new Map();


async function messageCreateDelegate(msg) {
    const {guildId, content, member, channel, channelId} = msg;
    let subscription = subscriptions.get(guildId);
    let thread = threads.get(guildId);
    // 次に詳細書きます。
}


// クライアントのイベントハンドラです。
// 接続エラーなどが発生したらログを出すだけです。
client.on('error', console.warn);

// BOTのログイン処理を行います。
// デフォルトで環境変数DISCORD_TOKENを使います。
client.login();

用語説明

  • Discordクライアント
    • Discordに接続するためのクライアントクラスです。
  • ギルド
    • Discordサーバーのことを指します。
    • ギルドIDはサーバーを固有に識別できるIDのことです。
  • チャンネル
    • Discordサーバー内のチャンネル(message/voice)のことを指します。
    • チャンネルIDはチャンネルを固有に識別できるIDのことです。
  • メンバー
    • Discordアカウントをメンバーと呼びます。
    • BOTもメンバーです。
  • ログイン
    • ログインするとBOTアカウントが🟢状態になります。

メッセージ処理の中身

おおよそこのあたりをやれば良さそうです。 結局は特定の文字列に対して何かするだけとなります。

特定のコマンドをトリガーにボイスチャットに参加する

不用意にコマンドに反応しないように $? などを接頭辞にするとよいかと思います

BOTのアプリ名は『ヨシ太郎』なので $yoshi としました。(コマンド指差し確認ヨシ!

async function messageCreateDelegate(msg) {
    const {guildId, content, member, channel, channelId} = msg;
    let subscription = subscriptions.get(guildId);
    let thread = threads.get(guildId);

    if (content.match(/^\$yoshi($| )/)) {
        const command = content.split(' ')[1] || 'join';
        // $yoshiは$yoshi joinと内部的に読み替えてコマンド処理します
        // 単純にそのほうがコマンドがなかった場合を考えなくてよいのとif-elseで全部書けたからです
        if (command === 'join') {
            if (!subscription){
                // yoshi-taroがボイスチャンネルに入っていなければ参加
                if (!member.voice.channelId) {
                    // メンバーがVCにいるかチェック
                    msg.channel.send('ボイチャに参加した状態でコマンド叩いてね😘');
                    return;
                }
                const connection = joinVoiceChannel({
                    selfMute: false,
                    channelId: member.voice.channelId,// メンバーが居るVCのチャンネル
                    guildId: guildId,
                    adapterCreator: msg.member.voice.guild.voiceAdapterCreator,
                });
                subscription = connection.subscribe(createAudioPlayer());
                connection.on('error', console.warn);
                subscriptions.set(guildId, subscription);
                channels.set(guildId, channelId);
                msg.channel.send('ワイはヨシ太郎や!"$yoshi help"で使い方表示するで!');
            }
            return;
        } else if (command === 'bye') {
            if (subscription) {
                subscription.connection.destroy();
                subscriptions.delete(guildId);
                channels.delete(guildId);
                msg.channel.send('お疲れさまでした👋');
            }
            return;
        }
        // コマンドを増やしたい場合は else ifでどんどん増やしていきます
    return;
    }
    // ・・・中略・・・
}

ポイントはコマンドを叩いた人がVCにいることをチェックすることと ボイスチャットセッションを保持しておくことです。

この時、ギルドID(サーバーを一意に特定できるID)に紐付けておくと複数のDiscordサーバーからの同時接続に対応できます。

subscription = connection.subscribe(createAudioPlayer());
connection.on('error', console.warn);
subscriptions.set(guildId, subscription);
channels.set(guildId, channelId);

特定のコマンドをトリガーにボイスチャットから退出する

上記に一緒に書きましたが $yoshi bye とコマンドを打つことで退出できるようにしました。

コマンドの作り方は自由です。ポイントは

ボイスチャットセッションを切って、サーバーのギルドIDに紐付いて保持していたセッション情報とチャンネル情報を消すことです。

subscription.connection.destroy();
subscriptions.delete(guildId);
channels.delete(guildId);

ボイスチャット参加中、特定のメッセージチャンネルの文字を読み上げる

何でもかんでも音声読み上げると困るので自分の発言と、コマンドを受け付けたチャンネル以外のメッセージでは読み上げないようにします

async function messageCreateDelegate(msg) {
    // ・・・中略・・・
    if (member.displayName === 'yoshi_taro') {
        // BOTのメッセージは無視する
        return;
    }
    if (channel != channelId) {
        // BOTが参加したチャンネル以外の発言は無視する
        return;
    }
    // ・・・中略・・・
}

絵文字も読まないようにしています。

上の方で後述しますと書いた部分で、 GCP使う場合も書きましたが、単純に試してみたいだけなら「GCP使わない場合はこっち」の方を使うと程よく楽にTTSが試せると思います。

GCPを使う場合は読み上げの性別や読み上げ速度などを設定できてより細かく調整できます。

async function messageCreateDelegate(msg) {
    // ・・・中略・・・
    const emoji = /[\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF]/ig;

    if (subscription) {
        let url = '';
        if (ttsService === 'gcp') {
            // GCP使う場合はこっち
            url = guildId + '.mp3';
            const request = {
                input: {text: content.replaceAll(emoji, '')},
            };
            // Performs the text-to-speech request
            const [response] = await gcpTTSClient.synthesizeSpeech(request);
            // 音声ファイルを書き出します。
            const writeFile = util.promisify(fs.writeFile);
            await writeFile(url, response.audioContent, 'binary');
        } else if (ttsService === 'free') {
            // GCP使わない場合はこっち
            // 音声ファイルのURLが取得できます。
            url = googleTTS.getAudioUrl(content..replaceAll(emoji, ''), {
                lang: 'ja',
                slow: false,
                host: 'https://translate.google.com',
            });
        } else {
            console.log('no tts service set.');
            return;
        }
        // ボイスチャットセッションの音声プレイヤーに音声ファイルのURLを指定して再生させます。
        const player = subscription.player;
        const resource = createAudioResource(url, {
            inputType: StreamType.Arbitrary,
        });
        player.play(resource);
    }
}

一応これで node index.js すればローカル上でBOT動作確認できるようになります。

※全部コードを載せたわけではないのでビルドエラーなどは適宜取り除いて上げる必要があるかもしれません

Herokuに環境変数を設定する

補足。.envをgit管理に上げるわけには行かないのでherokuの環境変数に - ディスコードBOTトークン - GCPのクレデンシャル(base64化したもの) を保存しています。

GCPのクレデンシャルはjson形式でファイルに置いておく必要があるため環境変数にはbase64化したものを設定して

.profile ファイルにjsonに書き出す処理を入れることでheroku環境にjsonを配置することができます

echo ${GOOGLE_CREDENTIALS} > /app/google-credentials.json

また、herokuはワーカー起動だと20日間しか動作できないため、クレジットカード登録することで24時間稼働できるようになります。

まとめ

  • すべてのコードは載せていないのであしからず
    • ただ主要な実装部分は全部乗っているはずです
  • 案外DiscordBOT作るの楽しかった
  • discordjsのバージョンが変わると実装も変わりそうで大変・・・