develop_plugin_ja - noonworks/Nursery GitHub Wiki

プラグイン制作

1. ビルド環境

  • Visual Studio 2017
  • .NET Framework 4.6.1
  • C#
  • ソースからのビルドを参照し、本体をビルドできるようにしておいてください

2. プラグインの基本

C#によるプログラミングの基本は理解している前提で説明します。

2-1. プラグイン・クラス

  • プラグインはIPluginインターフェースを実装したクラスです。
  • ひとつのdllファイルの中に複数のプラグイン・クラスがあってもかまいません。
    • 関連するプラグインはまとめてひとつのdllにした方がよいでしょう。
    • 例:SoundEffectPlugin.dllには、音声の再生、停止、リスト表示、再読み込みをする4つのコマンド・プラグインがまとめられています。

2-2. プラグインの読み込み

Nursery本体のPluginManagerは、起動時に以下のようにプラグイン・クラスの読み込みと初期化を行います。

  1. pluginsフォルダ内のdllファイルを読み込み
  2. dllファイル内に定義されている型のうち、IPluginにキャストできるものを読み込み
  3. plugins\plugins.configpluginsに記載されている順番で配列に格納
  4. 各プラグインのInitializeメソッドを呼び出し

2-3. 基本形

// 何もしないプラグインのサンプル
using Nursery.Plugins;

namespace MyPlugin.SamplePlugins {
    public class DoNothingCommand : IPlugin {
        public string Name { get; } = "MyPlugins.SamplePlugins.DoNothingCommand";
        public string HelpText { get; } = "Command to do nothing.";
        Plugins.Type IPlugin.Type => Plugins.Type.Command;

        public void Initialize(IPluginManager loader, IPlugin[] plugins) { }

        public bool Execute(IBot bot, IMessage message) {
            return false;
        }
    }
}
  • namespace MyPlugins.SamplePluginには、自分のプラグインに合った名前をつけてください。
  • クラス名に制限はありませんが、プラグイン名と同じにするといいでしょう。

2-3-1. Name

  • プラグインの名前を定義します。設定ファイルなどで使われる値です。
  • 他との重複を避けるために、namespace.classという形式の文字列にするといいでしょう。
  • この値は国際化対応をしないようにしてください。

2-3-2. HelpText

  • プラグインのヘルプテキストです。helpコマンドで使われます。
  • 国際化対応(後述)をするとよりわかりやすくなるでしょう。

2-3-3. Type

  • Commandは、「ユーザーがコマンドを送信し、それに応じた処理が行われる」プラグインです。
    • @nursery-bot comeのように、Botへのメンション形式で送信されることが多いプラグインです。
    • ただし、必ずメンションでないといけないわけではありません。例えばSEプラグインのSoundEffectCommandは、メンション以外のキーワードに反応してSEを鳴らします。
  • Filterは、「メッセージ加工処理を行う」プラグインです。
    • パターンなどに応じてメッセージの文字列を加工する処理を行います。
    • また、条件に一致したメッセージを無視する(読み上げない)といった判断も行います。
  • Schedulerは、「スケジュールを登録する」プラグインです。
    • 監視機能やタイマー機能などを登録します。

実際のところ、CommandFilterSchedulerに厳密な区別はありません。 目安としては、「ユーザーが実行を意図して送信するものはコマンド」「スケジュールを登録するものはスケジューラ」「それ以外はフィルタ」という区分でよいでしょう。

2-3-4. Initialize

  • プラグインの初期化処理を行います。
  • 典型的には、プラグイン用の設定ファイルを読み込みます。
  • スケジューラの中には、初期化時にスケジュールを登録するだけで機能が完結するものもあり得ます。
// シンプルな設定ファイルを読み込むプラグインのサンプル
using Newtonsoft.Json;
using Nursery.Plugins;
using Nursery.Utility;

namespace MyPlugin.SamplePlugins {
    // 設定ファイルのクラス。詳細はNewtonsoft.Jsonのドキュメントを参照。
    // 設定ファイルが読めなかったときのために、デフォルト値を設定するようにしましょう
    [JsonObject("MyPlugin.SamplePlugins.DoNothingCommandConfig")]
    public class DoNothingCommandConfig {
        [JsonProperty("name")]
        public string Name { get; set; } = "SampleName";
        [JsonProperty("age")]
        public int Age { get; set; } = 20;
    }

    public class DoNothingCommand : IPlugin {
        public string Name { get; } = "MyPlugins.SamplePlugins.DoNothingCommand";
        public string HelpText { get; } = "Command to do nothing.";
        Plugins.Type IPlugin.Type => Plugins.Type.Command;

        // 設定を保持するメンバ
        private DoNothingCommandConfig config = null;

        public void Initialize(IPluginManager loader, IPlugin[] plugins) {
            try {
                // 設定の読み込み
                this.config = loader.GetPluginSetting<DoNothingCommandConfig>(this.Name);
            } catch (System.Exception e) {
                // 設定ファイルがなくても気にしない場合は、エラーのログだけ出す
                Logger.DebugLog(e.ToString());
                this.config = null;
                // エラーとしてプログラムの起動を失敗させたい場合はこの例外をキャッチしなければよい
            }
            if (this.config == null) {
                // デフォルトの設定を使う
                this.config = new DoNothingCommandConfig();
            }
        }

        public bool Execute(IBot bot, IMessage message) {
            return false;
        }
    }
}

2-3-4-1. IPluginManager.GetPluginSetting<T>(string Name)

  • JSON形式の設定ファイルを読み込むヘルパーメソッドです。
  • プラグインディレクトリにある、Nameに指定した文字列に.jsonをつけたファイルを読み込みます。通常はプラグイン名を使うといいでしょう。
  • Tは設定ファイルを読み込む型です。

2-3-4-2. 引数 IPlugin[] plugins

  • 読み込まれたプラグインの配列です。
  • 配列内の各プラグインは まだInitializeが行われていない可能性がある ので注意してください。
    • 例えば「動作の前提として他のプラグインが必要なので、そのプラグインが読み込まれているか確認だけする」処理に使用します。
    • ただし、「他のプラグインがInitializeで読み込んだ設定ファイルの値を利用する」ことは できません。 この時点では、まだそのプラグインのInitializeが完了している保証がありません。
    • 他のプラグインの設定ファイルを参照したい場合、プラグインの最初のExecuteの際に読むようにしてください。SoundEffectPluginSoundEffectReloadなどのコードを参考にしてください。

2-3-5. Execute

  • プラグインのメイン処理部分です。
  • スケジューラの中には、初期化時にスケジュールを登録するだけで、メイン処理は何も行わないものもあり得ます。
// メッセージの最後に文字列を付け加えるプラグインのサンプル
using Nursery.Plugins;

namespace MyPlugin.SamplePlugins {
    public class AddDokabenFilter : IPlugin {
        public string Name { get; } = "MyPlugins.SamplePlugins.AddDokabenFilter";
        public string HelpText { get; } = "Add dokaben to message.";
        Plugins.Type IPlugin.Type => Plugins.Type.Filter;

        public void Initialize(IPluginManager loader, IPlugin[] plugins) {}

        public bool Execute(IBot bot, IMessage message) {
            if (message.Content.Length == 0) {
                // 本文が空だったらこのプラグインは適用しない
                return false;
            }
            // 本文が空でなかったら、末尾に文字列を追加
            message.Content = message.Content + " ドカベン";
            // このプラグインを適用したことを明示
            message.AppliedPlugins.Add(this.Name);
            return true;
        }
    }
}

2-3-5-1. 引数 IBot bot

  • Botのクラスです。Botの情報を取得したり、Botを入退室させたり、システムメッセージを発言させることができます。
  • メンバはIBotインターフェースを参照してください。

2-3-5-2. 引数 IMessage message

  • 送信されたメッセージのクラスです。
  • メンバはIMessageインターフェースを参照してください。

2-3-5-3. IMessage.Contentの書き換え

  • 読み上げる文字列を加工する場合は、IMessage.Contentを書き換えてください。
  • IMessage.Contentは、自分より前に他のプラグインによって書き換えられている可能性があります。
  • また、自分より後に他のプラグインによって書き換えられる可能性もあります。
    • これを防ぎたい場合は、後述のTerminatedの使用を検討してください。
  • IMessage.Original.Contentは「プラグインによる書き換えが行われていない元のメッセージ本文」です。

2-3-5-4. IMessage.AppliedPlugins

  • IMessage.AppliedPluginsは、「今までにこのメッセージに適用されたプラグインの名前」のリストです。
  • これを利用して「〇〇プラグインが適用されている場合は処理をしない」などの条件分岐が可能です。
  • 自分のプラグインで処理を行ったときは、message.AppliedPlugins.Add(this.Name);で自分のプラグインの名前を追加します。

2-3-5-5. IMessage.Terminated

  • IMessage.Terminatedtrueにした場合、次以降のプラグインの処理が行われなくなります。
  • JoinCommandなどのコマンドでは、コマンドを実行したらそれ以降の処理(読み上げるべきかを判断する、文字列を変換するなど)は不要になります。そういった場合にTerminatedを使用します。
public bool Execute(IBot bot, IMessage message) {
    //
    // (処理は省略)
    //
    // このプラグインを適用したことを明示
    message.AppliedPlugins.Add(this.Name);
    // これ以降のプラグイン処理を実行させない
    message.Terminated = true;
    return true;
}

2-3-5-6. 戻り値

プラグインを適用した場合はtrue、適用しなかった場合はfalseを返します。

3. スケジュール

3-1. スケジュールの概要

スケジュールは、メッセージの受信とは異なるタイミングで実行可能な処理です。スケジュールを使うと以下のような機能を実現できます。

  • Nurseryの状態やDiscordの状態を監視し、状態が変化したら何かをする。
  • 特定の日時に何かをする。

3-2. スケジュールタスクとタイマーの概要

  • スケジュールタスクは、条件と処理内容を持ったオブジェクトです。
  • Nurseryのbotは、スケジュールタスクのリストを持っています。
  • Nurseryのbotは、100ミリ秒ごとに起動するタイマーを持っています。
  • タイマーが起動すると、リスト内のスケジュールタスクが順に起動されます。
  • スケジュールタスクはその中で任意の処理を行います。

3-3. スケジュールタスクの登録

プラグインからは、IBotもしくはIPluginManagerAddScheduleメソッドを使用して、botにスケジュールタスクを登録します。

public interface IBot {
    void AddSchedule(IScheduledTask schedule);
}
public interface IPluginManager {
    void AddSchedule(IScheduledTask schedule);
}

3-4. IScheduledTaskインターフェース

スケジュールタスクが最低限備えなくてはならないインターフェースです。

public interface IScheduledTask {
    string Name { get; }
    bool Finished { get; }
    IScheduledTask[] Execute(IBot bot);
}
  • Nameプロパティ : スケジュールタスクに任意の名前をつけます。識別のために使用します。
  • Finishedプロパティ : スケジュールタスクが完了したかどうかを示します。
    • Finishedtrueのものは、スケジュールタスクのリストから消去されます。
    • Finishedfalseのものは、たとえ処理が実行されたとしても、リストに残り続けます。
  • Executeメソッド : タイマーから呼ばれるメソッドです。
    • 引数にはbotのインスタンスが渡されます。
    • 戻り値は「新しく登録するスケジュールタスクの配列」です。
      • スケジュールタスク内で新しいスケジュールタスクを生成し、それを登録したい場合に使います。
      • スケジュールタスク内ではIBotAddScheduleメソッドを使用しないでください。
      • 空の配列もしくはnullの場合、新しいスケジュールタスクを追加しません。

3-5. Nursery.Plugins.Schedules.ScheduledTaskBaseクラス

スケジュールタスクでよく使う機能を実装したヘルパー抽象クラスです。

  • ExecuteメソッドをCheckDoExecuteに分割し、条件と処理を分けて記述できるようにしている
  • Checkで現在日時を使用できるようにしている
  • botのメッセージ送信に使えるヘルパーメソッドSendを実装している

一般的なスケジュールタスクは、IScheduledTaskインターフェースをそのまま実装するより、このヘルパークラスを継承した方が簡単に作成できます。

3-5-1. 継承後に実装する必要のあるメソッド

public abstract class ScheduledTaskBase : IScheduledTask {
    public ScheduledTaskBase(string Name);
    abstract protected bool DoCheck(IBot bot);
    abstract protected IScheduledTask[] DoExecute(IBot bot);
}

実装例としてはNursery.BasicPlugins.WelcomeTaskを参考にしてください。

3-5-1-1. ScheduledTaskBase(コンストラクタ)

サブクラスからこのクラスのコンストラクタを呼ぶようにしてください。

public class SampleTask : ScheduledTaskBase {
    public SampleTask(): base("MyPlugin.SampleTask") {}
}

3-5-1-2. DoCheckメソッド

  • スケジュールタスクを実行するか判定するメソッドです。
  • 引数にはbotのインスタンスが渡されます。
  • 戻り値にはDoExecuteを実行するかどうかを返します。trueの場合は実行します。falseの場合実行しません。

3-5-1-3. DoExecuteメソッド

  • スケジュールタスクの主な処理を行うメソッドです。
  • 引数にはbotのインスタンスが渡されます。
  • 戻り値は「新しく登録するスケジュールタスクの配列」です。
    • スケジュールタスク内で新しいスケジュールタスクを生成し、それを登録したい場合に使います。
    • スケジュールタスク内ではIBotAddScheduleメソッドを使用しないでください。
    • 空の配列もしくはnullの場合、新しいスケジュールタスクを追加しません。

3-5-2. その他のヘルパーメソッドとメンバ変数

public abstract class ScheduledTaskBase : IScheduledTask {
    protected DateTime CheckedAt { get; set; }
    protected void Send(ScheduledMessage[] Messages, IBot bot);
}

3-5-2-1. メンバ変数CheckedAt

Checkメソッドが呼び出された日時≒Executeメソッドが呼び出された日時が保存されています。

3-5-2-2. Sendメソッド

  • 「botによるDiscordへのメッセージ送信」もしくは「botによる棒読みちゃんへの読み上げメッセージ送信」を行うメソッドです。
  • DoExecuteの処理中に呼び出すことができます。
  • 送信するメッセージはScheduledMessageクラスのインスタンスで指定します。
public class ScheduledMessage {
    public ScheduledMessageType Type = ScheduledMessageType.DoNothing;
    public string Content = "";
    public string[] TextChannelIds = new string[] { };
    public bool CutIfTooLong = true;
}
public enum ScheduledMessageType {
    DoNothing,
    SendMessage,
    Talk,
}
  • ScheduledMessageType : メッセージの種類を指定します。
    • ScheduledMessageType.DoNothing : メッセージを送りません。
    • ScheduledMessageType.SendMessage : Discordへメッセージを送信します。
    • ScheduledMessageType.Talk : 棒読みちゃんへ音読メッセージを送信します。
  • Content : メッセージのテキストを指定します。特殊書式が使用できます。
    • 特殊書式の変換処理はSendメソッド内で行われるので、事前に変換する必要はありません。
  • TextChannelIds : Discordへメッセージを送信する場合、送信先のテキストチャンネルのIDをstring[]で指定します。
    • nullもしくは空の配列の場合、デフォルトのテキストチャンネルに送信されます。
  • CutIfTooLong : Discordへメッセージを送信する場合で、テキストが長すぎたとき(2000文字を超えたとき)の処理を指定します。
    • trueの場合、最大文字数から溢れたメッセージを削除します。
    • falseの場合、メッセージを複数に分けてすべて送信します。

4. ヘルパークラス

4-1. AbstractMentionKeywordCommandクラス

  • コマンドプラグインによくある、@nursery-bot keyword形式のメンションとキーワードの判定を簡単に行うヘルパークラスです。
  • 継承して使います。
// メンション・キーワード形式のコマンドサンプル
using Nursery.Plugins;

namespace MyPlugin.SamplePlugins {
    public class DokabenCommand : AbstractMentionKeywordCommand {
        public string Name { get; } = "MyPlugins.SamplePlugins.DokabenCommand";
        public string HelpText { get; } = "Say dokaben command.";
        // TypeはCommandに設定済み

        // キーワード
        public JoinCommand() : base(new string[] { "dokaben", "yamada", "tarou" }) { }

        public void Initialize(IPluginManager loader, IPlugin[] plugins) {}

        protected override bool DoExecute(int keywordIndex, IBot bot, IMessage message) {
            // コマンド送信者に返信する
            bot.SendMessageAsync(message.Original.Channel,
                message.Original.Author.Mention + " ドカベン");
            // メッセージ読み上げは無し(=空白)にする
            message.Content = "";
            // このプラグインを適用したことを明示
            message.AppliedPlugins.Add(this.Name);
            // これ以降のプラグイン処理を実行させない
            message.Terminated = true;
            return true;
        }
    }
}

4-1-1. コンストラクタ

ベースクラスのコンストラクタに、文字列の配列でキーワード一覧を渡します。

4-1-2. bool DoExecute(int keywordIndex, IBot bot, IMessage message)

  • ベースクラスのExecuteから呼ばれます。
  • メンションになっていない場合や、キーワードが含まれていなかった場合、DoExecuteは呼ばれません。
  • int keywordIndexは、コンストラクタで渡したキーワードの配列のうち、マッチしたもののインデックスです。
  • それ以外は通常のExecuteと同様に実装します。

4-2. Nursery.Utility.IJSWrapper

  • 文字列をJavaScriptとして実行するためのヘルパークラスのインターフェースです。
  • その実装として、現時点ではJintを使ったJintWrapperが存在します。
  • 実際の使用例としては、SoundEffectPluginUserDefinedPluginを参照してください。

4-3. Nursery.Utility.Logger

  • ログを表示するためのユーティリティです。
  • 通常のログLogと、デバッグログDebugLogが用意されています。
  • 現時点では、ログはただコンソール画面に表示されるだけです。

4-4. その他

Nursery.PluginInterfaceNursery.Utilityのコードを参照してください。

5. 国際化

まず翻訳についてを一読し、Nurseryでの翻訳の扱いを理解しておいてください。

  • 翻訳は必須ではありません。
  • コマンドのヘルプやBotのメッセージは、国際化対応を行うのが好ましいでしょう。
  • ログに表示するメッセージはほとんどの場合ユーザーの目に触れないので、国際化対応する必要性は高くありません。エラーメッセージを翻訳すればユーザーのトラブル解決の手助けにはなるでしょう。

5-1. 翻訳ファイルの単位について

Nurseryはプラグインシステムを導入しており、プラグインのdllを使う・使わないはユーザーに任せられています。そのため、複数のdll用の翻訳データをひとつのファイルにするのは都合がよくありません。

基本的に、dllファイル1つにつき.moファイルが1つになるようにしましょう。

5-2. NGettextの導入

国際化対応を行う場合、自作プラグインのプロジェクトにNGettextを追加します。NuGetで、Nursery本体が使っているNGettextと同じバージョンをインストールしてください。

5-3. T.csのコピー

  • Nursery.Utilityプロジェクト内のT.csを、自作プラグインのプロジェクトにコピーします。
  • 名前空間を修正します。
  • DEFAULT_NAMEの値を変更します。これは.moファイルの名前になるので、自作プラグインのdllと同じ名前にするといいでしょう。

5-4. 翻訳する文字列に関数を適用する

ソースコード中の文字列の中から、翻訳する必要のある文字列を探し、関数を適用していきます。

string text = "Some text.";
// ↓ このように変更
string text = T._("Some text.");

string jp_text = "何らかの文章。";
// ↓ 日本語の文章は、いったん英語に変更
string jp_text = T._("Some text.");

5-5. 翻訳ファイルを作成・編集・ビルドする

ソースコードからpoファイルを作成します。(poファイルの作成・編集手順はpoファイル編集ソフトによって異なります。各自その解説を読んでください。)

5-6. ビルドしたmoファイルを配置して実行

ビルドしたmoファイルは、実行ファイルのフォルダ内のlocale\ja_JP\LC_MESSAGESに配置してください。

5-7. ファイルの配布

  • プラグインを配布する場合は、moファイルも一緒に配布してください。
  • ユーザーがシステムメッセージをカスタマイズできるよう、poファイル(もしくはpotファイル)も配布するといいでしょう。