🗣️

コミュニティを1年盛り上げ続けるdiscord botの設計を解説してみる

2024/12/03に公開

🎯 あらすじ

私は約1年前に、現在所属している30〜40人のエンジニアコミュニティで「身内のミームを寄せ集めたdiscord bot」を開発することになりました。
現場ゲーム開発のノウハウを活かして、ノリや流行りに合わせた様々な機能変更を行えるよう運用してきたため、その実例を紹介します。

▼ 誕生秘話はこちら

https://www.2riniar.com/pages/c1yyo3dlsu

💎 「自然さ」と「不完全さ」が文化を生み出す

今回は、メンバーから親しまれている当コミュニティの創設者を模したパロディbotを作成しました。その実装機能を一部紹介します。

  • メインチャンネルに何かを送ったとき、5%の確率でランダムな定型文を返信してくる
  • botをメンションしつつ「ガチャ」を含む文言を送信すると、上記のランダム返信を10回試行する(10連ガチャ)
  • 上記の「5%」という確率や定型文ごとの出やすさは毎日変動し、その日の0時にメインチャンネルに告知される

このbotはリリースして間もなく、日々会話のネタになるくらいコミュニティの文化として定着しました。その裏側には、過去のノウハウを活かした仕様決定がありました。

🌿 自然な導入

開発初期のネタ選定の話です。

文化はコントロールできないので、今回は元々みんなが親しんでいる

  • 創造者のパロディ

という形を取り、自然に馴染むことを狙いました。この意識は、過去にぽっと出で導入したbotが全然浸透しなかったという失敗経験がもとになっています。

また、

  • 機能的にシンプルにすること
  • メンバーの能動的なアクションを不要にすること

も優先しました。過去にCRUDコマンドを叩く形式のbotを作ったことがあるのですが、それがほぼ使われなかったためです。

それに対し、今回のbotは 「会話をしている時に低確率で割り込んでくるだけ」 だったので、メンバーが普段通り過ごす中に自然と溶け込んでいきました。

🔥 あえて整えない

細かな挙動の話です。

今回作りたいものは文化であってシステムではないので、完璧である必要はありません。むしろ、適度な粗や制約があった方が、メンバーは盛り上がります。

以下、今回の具体例です。

  • embedを使わずに、メッセージを直接送信する
    ┗ システムを使ってる感を出さない
  • コマンドパーサを作らずに、メンション+部分一致 でコマンドを実行
    ┗ メンションミス、コマンドミスを誘発する(ちなみに、コマンドミスをすると煽ってくる)
    ┗ コマンドの起動方法にバリエーションが出る
  • ヘルプはあえて実装しない
    ┗ みんなの認知が追いつくように機能を追加する
  • 同じイベントに複数の機能をまとめちゃう
    ┗ 例)「今月の課金額」は、ガチャを回した時しか表示できない
  • 仕様的な理不尽は許容
    ┗ 例)コマンドミスをすると「今月の課金額」がめっちゃ増える
  • ただし、不具合放置などの管理不足感は見せない

実装後にみんながどういう反応をして、どういう遊びが生まれるかをしっかり想像することが大事と実感しました。

⭐️ そのほか

ほかにも色々紹介したいですが、長くなるので箇条書きで失礼します。

  • ちょっとでもネタを見つけたら「この機能欲しいね」と素振り
    ┗ なんならその場ですぐ実装してデプロイしちゃう
    ┗ 逆に不要っぽい機能はしれっと削除
  • 面白さを出すために、確率や蓄積の挙動を積極的に取り入れる
    ┗ 蓄積系は、週や月単位でリセットすると良い
    ┗ 例)5%の確率で会話に乱入
    ┗ 例)10連ガチャを引くたびに「今月の課金額」が増えていく
  • 定期実行は文化の根底要素になるので、しつこくならない程度に使う
    ┗ 例)毎日0時に今日の確率分布を投稿
    ┗ 例)毎月1日に「今月の課金額」ランキングを投稿

ここからは、上記の仕様を実現する技術設計について解説します。

📐 ゲーム開発を参考にした技術選定と設計

コミュニティ盛り上げ系のbotは、

  • 様々な外部サービスを利用する可能性がある
    ┗ OpenAIを使うかもしれないし、GitHubのAPIを叩くかもしれない
  • 仕様が流動的
    ┗ イベントの発火条件、発火されたイベントの挙動、保持しておきたい状態

といった特徴があり、これは一般的なシステム開発よりもゲーム開発に近いです。そのため、今回はゲーム開発を参考に設計を行いました。

🎲 シンプルで、柔軟なプロジェクトに

今回のプロジェクトの実装方針は、表題にある通り

  • 「シンプル」
    固いルールを排除し、自分たちの手で作ってる感を出せるように。
  • 「柔軟」
    やりたいと思ったことを、その場のノリで叶えられるように。

の2つです。
これをもとに、以下の技術選定を行いました。

  • 開発言語は C# を採用。癖がなくて読みやすく、ツールチェインが整っていて、Unityでの設計を流用できるから。ただしDIフレームワークは使わない。
  • 個人のVPSに手動でデプロイする。環境差異が面倒なのでDockerコンテナ化だけしておく。
  • マスタデータ(=アプリのパラメータ設定)をGoogleスプレッドシートから読み込めるようにしておく。これにより、誰でもアプリのパラメータをスプシ上から調節できる。
  • 永続化は最低限でいいので、SQLiteを使う。

それでは、もっと詳細な設計を見ていきましょう。

🧩 設計詳細

大まかに、全体のフォルダ構成はこのようになっています。

- Common/
    ┣ Discord/
    ┃   ┗ DiscordManager.cs
    ┣ Master/
    ┃   ┗ MasterManager.cs
    ┣ Scheduler/
    ┃   ┗ SchedulerManager.cs
    ┗ Models/
        ┗ User.cs

- Events/
    ┣ RareReplyPresenter.cs
    ┣ GachaCommandPresenter.cs
    ┗ DailyResetPresenter.cs

- Program.cs

全体のイメージとしては、

  • エントリーポイントである Program.csすべてのイベント登録が書かれている
  • Events/ 以下にイベントの挙動(Presenter)が実装されている
  • Common/ 以下は汎用機能があり、基本的にシングルトン化されたマネージャークラスがいるのでそれを使えば良い

という設計思想です。つまり、新機能を追加したければ

  • Events に新規Presenterを追加し、各Managerの使いたい機能を使い、Program.csに登録する

という流れになります。

この設計は、ゲーム開発の現場から着想を得ました。 非常にシンプルでありながら、現場レベルの複雑で大規模な仕様にも耐えることができます。

それでは、各部分についてソースコードを交えて解説していきます。

🔵 /Events

Eventsの中には、1つのイベントが 〇〇Presenter という名前のクラスに該当しています。その1つ1つは、イベント発火時に MainAsync() が呼ばれるだけの簡単な実装です。

例として、「10連ガチャを回す」コマンドの実装を紹介します。

// 10連ガチャを回すコマンドで発火するイベント
public class GachaCommandPresenter : PresenterBase
{
    // 発火時にここが実行される
    protected override async Task MainAsync()
    {
        // 現在の「ユーザー」と「ガチャ」の状態を取得する
        await using var app = AppService.CreateSession();
        var user = await app.FindOrCreateUserAsync(Message.Author.Id);
        var gacha = await app.GetDefaultGachaAsync();

        // 10連ガチャを回して保存
        var results = user.RollGachaTenTimes(gacha);
        await app.SaveChangesAsync();

        // メッセージを返信する
        await SendReplyAsync(user, results);
    }
    /* 省略 */
}

🔵 /Common/Discord

ここには、Discordに関する機能を提供する DiscordManager があります。メッセージ送信をトリガーとしたイベントは、ここから実行されます。

public class DiscordManager : Singleton<DiscordManager>
{
    // 初期化
    public static async Task InitializeAsync() { /* 省略 */ }
    // メインチャンネルを取得する
    public static SocketTextChannel GetMainChannel() { /* 省略 */ }
    // メッセージ送信をトリガーとしたイベントを実行する
    public static async Task ExecuteAsync<T>(SocketUserMessage message, Func<T, Task>? onInitializeAsync = null) { /* 省略 */ }
}

🔵 /Common/Scheduler

同じく、定期実行を管理する SchedulerManager です。実行タイミングと実行するイベントを指定することで、簡単に定期イベントが実現できます。

public static class SchedulerManager
{
    // 初期化
    public static void Initialize() { /* 省略 */ }
    // 毎日のイベントを登録する
    public static void RegisterDaily<T>(TimeSpan time) { /* 省略 */ }
    // 毎月のイベントを登録する
    public static void RegisterMonthly<T>(int day, TimeSpan time) { /* 省略 */ }
    // 毎年のイベントを登録する
    public static void RegisterYearly<T>(DateTime datetime) { /* 省略 */ }
    // 条件を満たしたタイミングで実行するイベントを登録する
    public static void RegisterOn<T>(Predicate<DateTime> predicate) { /* 省略 */ }
}

🔵 /Common/Master

すべてのマスタデータは、MasterManagerから自由に取得できます。
また、マスタデータの構造を変えたいときは、フィールドを足してAttirbuteを付けるだけでいい感じにデータの格納やパースを行ってくれます。(この内部には、封印された黒魔術があります。)

public class MasterManager : Singleton<MasterManager>
{
    // ランダムに送るメッセージのマスタデータ
    [MasterTable("random_message")] private RandomMessageMaster _randomMessageMaster;
    public static RandomMessageMaster RandomMessageMaster => Instance._randomMessageMaster;

    // イベントの発火を行う文言のマスタデータ
    [MasterTable("trigger_phrase")] private TriggerPhraseMaster _triggerPhraseMaster;
    public static TriggerPhraseMaster TriggerPhraseMaster => Instance._triggerPhraseMaster;

    // 汎用的な設定のマスタデータ
    [field: MasterTable("setting")] private SettingMaster _settingMaster;
    public static SettingMaster SettingMaster => Instance._settingMaster;

    // 再取得
    public static async Task FetchAsync() { /* 省略 */ }
}
// ランダムに送信するメッセージ
public class RandomMessage : MasterRecord<string>
{
    public override string Key => Id;

    [field: MasterStringValue("id")]
    public string Id { get; }

    [field: MasterEnumValue("type", typeof(RandomMessageType))]
    public RandomMessageType Type { get; }

    [field: MasterStringValue("content")]
    public string Content { get; }
}

🔵 /Common/Models

EntityFrameworkを使い、ユーザーなどの状態管理したいオブジェクトを実装しています。AppService からアプリ内のあらゆる状態にアクセスできます。

public class User
{
    // ユーザーのDiscord ID
    [Key] public ulong DiscordId { get; set; }
    // 今月のガチャ課金額
    public int MonthlyGachaPurchasePrice { get; set; }
}
public class AppService : DbContext
{
    // ユーザーテーブル
    public DbSet<User> Users { get; set; }
    // ユーザーを取得する なければ作成する
    public async Task<User> FindOrCreateUserAsync(ulong discordId) { /* 省略 */ }

🔵 Program.cs

このbotのエントリーポイントです。共通基盤を初期化した後、すべてのイベントを登録しています。
つまり、エントリーポイントを見るだけで「どのタイミングで何が実行されるのか」がすべてわかります。

public static class Program
{
    // エントリーポイント
    private static void Main(string[] args)
    {
        InitializeAsync(args).GetAwaiter().GetResult();
    }

    private static async Task InitializeAsync(string[] args)
    {
        // 共通基盤系を初期化する
        await MasterManager.FetchAsync();
        SchedulerManager.Initialize();
        await DiscordManager.InitializeAsync();

        // すべてのイベントを登録する
        SchedulerManager.RegisterDaily<DailyResetPresenter>(TimeManager.DailyResetTime);
        SchedulerManager.RegisterMonthly<MonthlyResetPresenter>(TimeManager.MonthlyResetDay, TimeManager.DailyResetTime);
        /* 以下省略 */

        // 永久に待つ
        await Task.Delay(-1);
    }
}

📒 まとめ

今回は、趣味開発でのコミュニティ盛り上げアプリについて、企画と実装に分けて解説しました。
私はこのような活動をする一方、本職ではゲーム開発者として活動しています。企画も技術も、相互にノウハウが活きているのが非常に面白いです。

今回のプロジェクトは以下のリポジトリで公開されています。よければ実装を見て、参考にしてみてください!
https://github.com/2RiniaR/king-server

Discussion