11
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

N高等学校Advent Calendar 2018

Day 18

DiscordのしりとりbotをNode.js+Discord.jsで作る。

Last updated at Posted at 2018-12-19

どうも。最近pythonを触りだしたN高生のshow0317swです。
N高アドカレ18日目になります。
今回はDiscordでしりとりできるbotを作ったので紹介したいと思います。

#はじめに
なんでしりとりのbotを作ろうと思ったのかというのは、先にこちらさかいさんが書いてくださった記事をご覧ください。

僕もしりとりbotを作ってみたいと思い、Node.jsで開発することにしました。この他メンバーには他の言語で開発されている方も居ます。
Luaで作った@plageojさんの記事はこちら

そして、現在もGitHubで開発がが進められています。チーム開発楽しいですね!

今回作るのはNode.js版でGitHubはここです。

#環境前提

Ubuntu 18.04 LTS
Node.js v10.14.2

Discord.js v11.4.2
MeCabが使える環境
mecab-ipadic-neologd Periodic data update on 2018-12-10(Mon)
mecab-async v0.1.2

#仕様
どんなbotにするのかメンバーで話し合って決めた仕様と開発者独自の仕様があります。紹介するのはNode.js版で実装されたもののみとなります。
なお、これらは執筆当時での決定です。今後、新たな機能などを追加していく案が進行中です。

  • しりとり用として決められたチャンネルのみで動作する。
  • 1単語、名詞、"ン"で終わらない、直前の単語とつながっている、今までに答えられていない、これらに該当するもの以外をルール違反とする。
  • コマンドが使える
    • チャンネルをしりとり用に設定
    • 過去のログを削除
    • ヘルプ
  • コメントができる

#botを準備する
DiscordのデベロッパーページでAPPを作成し、APP内で新たにBotを作成します。
詳しくはググってください。
今回必要になるのはBotのトークンのみです。

#データモデルを設計する
正直DB使わなくてもできますがデータベース大好きなので使います。
今回は以下のように設計しました。

channelテーブル :しりとり用のチャンネルを保存する。

カラム名 データ型 属性 内容
id STRING PRIMARY KEY しりとり用のチャンネルID
created_at DATE 追加日時

message.jsテーブル :送られたメッセージの中で有効な単語を保存する。

カラム名 データ型 属性 内容
id STRING PRIMARY KEY
channel_id STRING 発言されたチャンネルID
message STRING メッセージの内容
reading STRING メッセージの読み
created_at DATE 追加日時

運用としては、しりとり用チャンネルを追加/削除するコマンドでchannelテーブルに追加/削除する。判定を通過した単語のみがmessage.jsテーブルに追記チャンネルIDと共に追記される。といった感じになります。

#実装する

###ライブラリをインストール
プロジェクトを作成して必要なライブラリをインストールします。

yarnの場合
$ yarn init -y
$ yarn add discord.js sequelize mecab-async
npmの場合
$ npm init -y
$ npm i -S discord.js sequelize mecab-async

###botと接続する
botを起動するファイルindex.jsを作成し、以下のように実装します。

index.js
'use strict';

/* モジュールの読み込み */
const Discord = require('discord.js');

/* clientインスタンス作成 */
const client = new Discord.Client();

/* 起動時の処理 */
client.on('ready', ()=>{
  /* ログ出力 */
  console.log(`Logged in as ${client.user.tag}`);
})

/* ログイン */
const token = 'TOKEN'; //ここにTOKENを入れる
client.login(token);

これでbotと接続できるようになりました。起動してみます。

$ node index.js

エラーが出ず、Logged in as <butの名前>のようなログが出れば問題ありません。

###モデルを実装する

sequelizeを使ってぱぱっとやります。

特別なことしてないけど長いのでしまっておきます。

modelsフォルダを作成して以下を実装します。

_sequelize-loader.js
'use strict';
const Sequelize = require('sequelize');
const sequelize = new Sequelize(
'postgres://postgres:postgres@localhost/u20_shiritori_bot_nodejs',
{logging: true, operatorsAliases: false, timezone: 'UTC'});

module.exports = {
  database: sequelize,
  Sequelize: Sequelize
};
channel.js
'use strict';
const loader = require('./_sequelize-loader');
const Sequelize = loader.Sequelize;

const Channel = loader.database.define('channel', {
  id: {
    type: Sequelize.STRING,
    primaryKey: true
  },
  created_at:Sequelize.DATE
}, {
  freezeTableName: true,
  timestamps: true,
  createdAt: 'created_at',
  updatedAt: false,
});

module.exports = Channel;
message.js
'use strict';
const loader = require('./_sequelize-loader');
const Sequelize = loader.Sequelize;

const Message = loader.database.define('messages', {
  id: {
    type: Sequelize.INTEGER,
    primaryKey: true,
    autoIncrement: true
  },
  channel_id: {
    type: Sequelize.STRING,
    allowNull: false
  },
  message: {
    type: Sequelize.STRING,
    allowNull: false
  },
  reading: {
    type: Sequelize.STRING,
    allowNull: false
  },
  created_at:Sequelize.DATE
}, {
  freezeTableName: true,
  timestamps: true,
  createdAt: 'created_at',
  updatedAt: false,
});

module.exports = Message;

###メッセージを処理する
次にメッセージを受け取って処理できるようにします。

index.js
/* メッセージを受け取ったときの処理 */
client.on('message', message=>{
  /* bot自身の発言を無視 */
  if(message.author.bot) return;
});

clientインスタンスのmessageイベントに反応するようにします。
message.author.botにboolean値が入っているのでbotのメッセージかどうかを判断できます。

###ルーティングする
今回のbotは処理するメッセージにいくつかの種類があります。

  • しりとりの単語
  • コマンド
  • コメント

これらを適切に処理する必要があります。
しりとりチャンネルは指定チャンネルのみに限られているのでこれに該当しないチャンネルからの発言は無視しなければなりません。しかし、新たにしりとり用とするチャンネルでコマンドを実行するために特定のコマンドは使いたいです。

しりとり用単語とコマンドの処理はモジュール化することにして、最終的にindex.jsは以下のようにしました。

'use strict';

/* モジュールの読み込み */
const Discord = require('discord.js');
const shiritori = require('./lib/shiritori');
const command = require('./lib/command');

/* clientインスタンス作成 */
const client = new Discord.Client();

/* モデルの読み込み */
const Message = require('./models/message');
const Channel = require('./models/channel');
Message.sync();
Channel.sync();

/* メッセージを受け取ったときの処理 */
client.on('message', message=>{
  /* bot自身の発言を無視 */
  if(message.author.bot) return;
  /* しりとり用チャンネル以外の発言を無視 */
  Channel.findOne({
    where: {
      id: message.channel.id
    }
  }).then(channel=>{
    /* しりとりチャンネルでの発言の場合 */
    if(channel !== null){
      /* '//'から始まる発言を無視 */
      if(message.content.startsWith('//')) return;
      /* 最新の単語の最初の文字が'!'の場合 コマンド実行 */
      if(message.content.startsWith('!')){
        command(message);
        return;
      }
      shiritori(message);
    }else
    /* 最新の単語の最初の文字が'!add'の場合 コマンド実行 */
    if(message.content.startsWith('!add')){
      command(message);
    }
  });
});

最初にチャンネル判定を行っています。
もし、しりとり用のチャンネルの発言だった場合は、

  • しりとりの単語
    • '!'から始まらないメッセージの場合command()を実行
  • コマンド
    • '!'から始まるメッセージの場合command()を実行
  • コメント
    • //から始まるメッセージをスキップ

と、こんなルーティングでそれぞれの機能を実行しています。
しりとり用チャンネル以外の発言は!addコマンドのみ許可しており、それ以外では反応しません。

###コマンドを処理する
libフォルダを作ってその中にモジュールを実装していきます。
まずはコマンドを処理するcommand.jsを作ります。

command.js
'use strict';

/* モジュールの読み込み */
const Discord = require('discord.js');

/* モデルの読み込み */
const Message = require('../models/message');
const Channel = require('../models/channel');

/* '!xxxx'で実行されるコマンドを処理する関数 */
module.exports = function command(message){
  switch (message.content) {
    case '!add':
      if(message.author.id === message.guild.owner.guild.ownerID){
        Channel.upsert({
          id: message.channel.id
        }).then(()=>{
          sendRichEmbed(
            message,
            '00FF00',
            `#${message.channel.name} チャンネルをしりとり用チャンネルに設定しました`,
            '`!help`でコマンド一覧を表示できます。'
          );
        });
      }else{
        sendRichEmbed(
          message,
          'FF0000',
          `しりとりチャンネルの追加に失敗しました。`,
          'チャンネルをしりとり用に追加できるのはサーバーのオーナーのみです。サーバーのオーナーに連絡してください。\n`!help`でコマンド一覧を表示できます。'
        );
      }
      break;
    case '!remove':
      if(message.author.id === message.guild.owner.guild.ownerID){
        Channel.destroy({
          where: {
            id: message.channel.id
          }
        }).then(()=>{
          sendRichEmbed(
            message,
            '00FF00',
            `#${message.channel.name} チャンネルをしりとり用チャンネルにから削除しました`,
            'もう一度しりとり用に設定するには`!add`を実行してください。'
          );
        });
      }else{
        sendRichEmbed(
          message,
          'FF0000',
          `しりとりチャンネルの削除に失敗しました。`,
          'チャンネルをしりとり用から削除できるのはサーバーのオーナーのみです。サーバーのオーナーに連絡してください。\n`!help`でコマンド一覧を表示できます。'
        );
      }
      break;
    case '!reset':
      Message.destroy({
        where: {
          channel_id: message.channel.id
        }
      }).then(()=>{
        sendRichEmbed(
          message,
          '0000FF',
          `過去の記録を削除しました`,
          '次の発言から記録が再開されます'
        );
      });
      break;
    case '!help':
      sendRichEmbed(
        message,
        '00FF00',
        `コマンド一覧`,
        '`!add` : 発言されたチャンネルをしりとり用に設定します。\n`!remove` : 発言されたチャンネルをしりとり用から設定解除します。\n`!reset` : 今までのしりとりの内容を削除します。\n`!help` : コマンド一覧を表示します。'
      );
      break;
    default:
      message.channel.send(message.content + 'コマンドは存在しません。`!help`でコマンド一覧を表示する。');
      break;
  }
};

/* RichEmbedを送信する関数 */
function sendRichEmbed(message, color, title, description){
  const embed = new Discord.RichEmbed()
    .setColor(color)
    .setTitle(title)
    .setDescription(description);
  message.channel.send(embed);
}

たいへん見にくいファイルですね。メッセージは別ファイルに書きたいところです。いつかやります。

まずここで処理するのはコマンドのみです。最初から形式が分かっているので単純にswitchします。
チャンネルを追加/削除するコマンドについてはサーバーオーナーのみが行えるようにしています。(いつでもどこでもしりとりされたら邪魔で仕方ないですね。

最後のsendRichEmbed関数ではDiscordの埋め込みを利用して送信しています。なぜ普通に送信しないのかと言うと、Discord内がbotの発言で汚染されないようにするために、しりとり自体のbotの発言は1つにしたかったからです。

コマンドのメッセージは重要な上に頻繁にしようするものでは無いので埋め込みとして区別するようにしました。

###しりとりを処理する

次は、shiritori.jsでしりとりの処理を書いていきます。

しりとりをするには当たり前ですが単語の読み方や区切り方を知っている必要があります。そういう言語処理を形態素解析と言うらしいです。コンピューターに形態素解析をさせるために様々なライブラリやAPIがありますが、僕は初めて関わる分野なのでぐぐったら一番最初に出てきたMeCabとというライブラリを使いたいと思います。

なお、MeCabの使い方については先人がトライアンドエラーを報告してくれていますのでそちらをググって参考にしてください。
僕も何度か導入に失敗しましたが、ubuntu消して入れ直したら動きました!!何も解決してない

MeCabが使える前提で実装してきます。そして、利用する辞書はNEologdです。

判定ごとに処理を分けました。細かい関数はGitHubからコードを見てください。

shiritori.js
/* しりとり用チャンネルにメッセージが送信されたときの処理 A ~ F */
module.exports = async function shiritori(message) {
  const [judg, reading, previousData] = await judgMessage(message);
  switch (judg) {
    /* OK */
    case 'A':
      addMessage(message, reading);
      sendMessage(message, `:o:「 **${message.content}** 」 はOKです\n次は 「 **${reading.slice(-1)}** 」 から始まる単語です`);
      break;
    /* NG 文章 */
    case 'B':
      sendMessage(message, `:x:「 **${message.content}** 」 は文章のためNGです\n単語で答えてください${toggleFirstMessage(previousData)}`);
      break;
    /* NG 名詞以外 */
    case 'C':
      sendMessage(message, `:x:「 **${message.content}** 」 は名詞以外のためNGです\n名詞を答えてください${toggleFirstMessage(previousData)}`);
      break;
    /* NG 'ン'で終わる */
    case 'D':
      sendMessage(message, `:x:「 **${message.content}** 」 は'ン'で終っているためNGです\n'ン'以外で終わる単語を答えてください${toggleFirstMessage(previousData)}`);
      break;
    /* NG 直前の単語とつながっていない */
    case 'E':
      sendMessage(message, `:x:「 **${message.content}** 」 は直前の単語とつながっていないためNGです\n 「 **${previousData.message}** 」 の 「 **${previousData.reading.slice(-1)}** 」 から始まる単語を答えてください${toggleFirstMessage(previousData)}`);
      break;
    /* NG 既出 */
    case 'F':
      sendMessage(message, `:x:「 **${message.content}** 」 は既に答えられているためNGです\nまだ答えられていない単語を答えてください${toggleFirstMessage(previousData)}`);
      break;
  }
}

1番最初に呼び出しているjudgMessage()関数がメッセージを判定する関数です。判定と読み方が返ってきます。previousDataは直前の発言のデータなんですがよく考えたら判定の関数から渡すの変ですね。あとで直します。

このjudgMessage()関数が今回の肝なんですが、判定ごとに5つのフェーズに分かれています。

  1. 単語か文章かを判定する
  2. 名詞かそれ以外かを判定する
  3. 読みの最後の文字を判定する
  4. 直前の単語とつながっているかどうかを判定する
  5. 既出かどうかを判定する

1から順に判定して失敗した時点で判定が終了します。

####単語か文章かを判定する

1番目の処理はmecab-asyncからMeCabを使ってメッセージを解析しています。結果が単語ごとの配列で返ってくるので、それが1つのときは判定を**Aとして次のフェーズに渡します。2つ以上の場合は判定をB**として終了します。

1.単語か文章かを判定する関数
function judgMessage(message){
  return new Promise(async(resolve)=>{
    const parsedContent = mecab.parseSync(message.content);
    let judg = 'A', reading = parsedContent[0][8], previousData = await getPreviousData(message);
    /* 1単語の場合 OK */
    if(parsedContent.length === 1){
      [judg, previousData] = await judgMessage2(message, judg, parsedContent[0], previousData);
    }
    /* 文章の場合 NG */
    else{
      judg = 'B';
    }
    resolve([judg, reading, previousData]);
  });
}

####名詞かそれ以外かを判定する

1の判定結果をもとに名詞かどうかを判別しています。名詞なら次のフェーズに渡し、名詞以外なら判定を**C**として終了します。

1.名詞かそれ以外かを判定する関数
function judgMessage2(message, judg, parsedContent, previousData){
  console.log('2次判定処理');
  return new Promise(async(resolve)=>{
    /* 名詞の場合 OK */
    if(parsedContent[1] === '名詞'){
      [judg, previousData] = await judgMessage3(message, judg, parsedContent, previousData);
    }
    /* 名詞以外の場合 NG */
    else{
      judg = 'C';
    }
    resolve([judg, previousData]);
  });
}

####読みの最後の文字を判定する

1の結果をもとに読みの最後が'ン'かどうかを判別します。'ン'で終わるなら判定を**D**として終了します。

1.読みの最後の文字を判定する関数
function judgMessage3(message, judg, parsedContent, previousData){
  console.log('3次判定処理');
  return new Promise(async(resolve)=>{

    // TODO "ー"で終わる単語の処理

    /* 'ン'で終わらない場合 OK */
    if(parsedContent[8].slice(-1) !== ''){
      [judg, previousData] = await judgMessage4(message, judg, parsedContent, previousData);
    }
    /* 'ン'で終わる場合 NG */
    else{
      judg = 'D';
    }
    resolve([judg, previousData]);
  });
}

####直前の単語とつながっているかどうかを判定する

データベースからそのチャンネルで最後に発言された有効な単語と比較します。直前の単語とつながっていない場合判定を**E**として終了します。

1.直前の単語とつながっているかどうかを判定する関数
function judgMessage4(message, judg, parsedContent, previousData){
  console.log('4次判定処理');
  return new Promise((resolve)=>{
    Message.findOne({
      where: {
        channel_id: message.channel.id
      },
      order:[['id','DESC']]
    }).then(async(previousMessage)=>{
      /* DBに単語が保存されていない場合 OK */
      if(previousMessage === null){
        judg = judg;
      }
      /* 直前の単語とつながっている場合 OK */
      else if(previousMessage.dataValues.reading.slice(-1) === parsedContent[8].slice(0, 1)){
        judg = await judgMessage5(message, judg);
        previousData = previousMessage.dataValues;
      }
      /* 直前の単語とつながっていない場合 NG */
      else{
        judg = 'E';
      }
      resolve([judg, previousData]);
    });
  });
}

####既出かどうかを判定する

そのチャンネルで同じ発言が無かったかを判別しています。新規単語の場合は**Aを、既出単語n場合はF**を返して終了します。

1.既出かどうかを判定する関数
function judgMessage5(message, judg){
  console.log('5次判定処理');
  return new Promise((resolve)=>{
    Message.findOne({
      where: {
        channel_id: message.channel.id,
        message: message.content
      }
    }).then(Previously=>{
      /* 新規単語の場合 OK */
      if(Previously === null){
        judg = judg;
      }
      /* 既出単語の場合 NG */
      else{
        judg = 'F'
      }
      resolve(judg);
    });
  });
}

###メッセージを送信する

今回はしりとりに関するbotのメッセージは最新1つのみにします。
チャンネルのメッセージ履歴を取得し削除して、新たに送信しています。無い場合は送信するだけです。

function sendMessage(message, text){
  message.channel.fetchMessages({limit: 100}).then(messages=>{
    let botMessages = messages.filter(m=>m.author.bot && m.embeds !== [] && m.content !== '');
    if(Array.from(botMessages)[0]){
      let botMessage = Array.from(botMessages)[0][1];
      message.channel.send(text).then(botMessage.delete());
    }else{
      message.channel.send(text);
    }
  });
}

#実際の動作

簡単に動画を作ったのでこちらをご覧ください。

#おわりに

初めてDiscordのbotを作った上にMeCabを使うのも初めてだったので結構手こずりました。
ラボのメンバーの方々にもアドバイスをいただき、ここまでできました。ありがとうございます。

そしてMeCabが意外と楽しかったです。チャットボットとかも作れそうなのでアイディアが広がります。また何か作ったらQiita書きます。

11
9
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
11
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?