atelier:mitsuba

i love UI/UX, Blend, XAML, Behavior, P5, oF, Web, Tangible Bits and Physical computing. なにかあればお気軽にご連絡ください。atelier@c-mitsuba.com

メメントモリの所属ギルドを支える技術、あるいはDiscord botをC#とAzureで作った話。後編。

この内容で3/3にオンラインでお話します。
meetupapp.connpass.com


中編の続き。
c-mitsuba.hatenablog.com


今回の要件として、
・もうちょっとbotを拡張しやすくしたい。
・定期メッセージを実行したい。
・SlashCommandを実装したい。

まず、Program.csを不変のファイルと機能拡張する時に変更するファイルでpartial分けた。

以下が変わらないファイル。

using Discord;
using Discord.WebSocket;
using System.Globalization;
using System.Reflection;
using uminekobot.Common;
using uminekobot.SlashCommands;

namespace uminekobot;

internal partial class Program
{
    #region 初期化

    private readonly DiscordSocketClient _client;

    private static void Main()
    {
        new Program()
            .MainAsync()
            .GetAwaiter()
            .GetResult();
    }

    /// <summary>
    ///     初期化
    /// </summary>
    public Program()
    {
        var config = new DiscordSocketConfig
        {
            GatewayIntents = GatewayIntents.AllUnprivileged | GatewayIntents.MessageContent |
                             GatewayIntents.GuildMessages
        };
        _client = new DiscordSocketClient(config);
        _client.Log += LogAsync;
        _client.Ready += ReadyAsync;
        _client.MessageReceived += MessageReceivedAsync;
        _client.SlashCommandExecuted += SlashCommandRegister.SlashCommandHandler;
    }


    private static Task LogAsync(LogMessage log)
    {
        Console.WriteLine(log.ToString());
        return Task.CompletedTask;
    }

    private async Task ReadyAsync()
    {
        await SlashCommandRegister.RegisterSlashCommandAsync(_client);
        Console.WriteLine($"{_client.CurrentUser} is connected!");
    }


    public async Task MainAsync()
    {
        await _client.LoginAsync(TokenType.Bot, DiscordKeys.Current.Credential);
        await _client.StartAsync();

        InitializeTimerMessage();
        await Task.Delay(Timeout.Infinite);
    }

    #endregion 初期化
}


肝心なのは、SlashCommandの初期化をしているのと、

    private async Task ReadyAsync()
    {
        await SlashCommandRegister.RegisterSlashCommandAsync(_client);
        Console.WriteLine($"{_client.CurrentUser} is connected!");
    }

定期メッセージのTimerの初期化してるとこ。

    public async Task MainAsync()
    {
        await _client.LoginAsync(TokenType.Bot, DiscordKeys.Current.Credential);
        await _client.StartAsync();

        InitializeTimerMessage();
        await Task.Delay(Timeout.Infinite);
    }


で、次にメッセージ周りに拡張のために書き足す部分。

using System.Reactive.Linq;
using Discord.WebSocket;
using uminekobot.Common;
using uminekobot.MessageReactions;
using uminekobot.SlashCommands;

namespace uminekobot;

internal partial class Program
{
    /// <summary>
    ///     定期実行の登録
    /// </summary>
    private void InitializeTimerMessage()
    {
        Observable.Interval(TimeSpan.FromMinutes(1)).Subscribe(OnNext);

        async void OnNext(long _)
        {
            var now = DateTime.UtcNow.ToJst();

            await NotifyMessage.RefreshShopMessage(_client, now);
            await NotifyMessage.StartTempleMessage(_client, now);
            await NotifyMessage.StartGuildBattleMessage(_client, now);
            await NotifyMessage.EndBattleLeagueMessage(_client, now);
            await NotifyMessage.BaseChallengeNotifyMessage(_client, now);
        }
    }

    /// <summary>
    ///     メッセージ受信イベント
    /// </summary>
    /// <param name="message"></param>
    /// <returns></returns>
    private async Task MessageReceivedAsync(SocketMessage message)
    {
        if (message.Author.Id == _client.CurrentUser.Id) return;

        //メンション検知
        if (message.CleanContent.StartsWith("@摘-つみ#6462") || message.CleanContent.StartsWith("@dev-tumi#5486"))
        {
            if (message.CleanContent.Contains("ありがと") || message.CleanContent.Contains("有難う") ||
                message.CleanContent.Contains("有り難う"))
            {
                if (await ResponseMessage.ThanksAsync(message)) return;
            }
            else
            {
                if (await ResponseMessage.GreetingAsync(message)) return;
            }
        }

        //自由投稿用
        if (message.CleanContent.StartsWith("!talk ") && message.Author.Username.Contains("mitsuba"))
            if (await ResponseMessage.FreeTalkAsync(_client, message.CleanContent.Replace("!talk ", "")))
                return;

        //テスト用
        if (message.CleanContent == "!ping") await message.Channel.SendMessageAsync("なんですの。");
    }
}

メッセージ受信イベントが割と泥臭くって、古典的にメッセージをテキストで分解して処理する。
今回はメンションでありがとーって言われたら、こちらこそありがとーみたいな、メッセージを返したり、

         if (message.CleanContent.Contains("ありがと") || message.CleanContent.Contains("有難う") ||
                message.CleanContent.Contains("有り難う"))
            {
                if (await ResponseMessage.ThanksAsync(message)) return;
            }

特に何も分岐処理に引っかからずにメンションされたときは、ごきげんようって挨拶を返したりする。

     else
            {
                if (await ResponseMessage.GreetingAsync(message)) return;
            }

あとは隠しチャンネルで送信したメッセージをそのままbotが送信したり、ちゃんと起きてるかテスト用にpingしたり。

        //自由投稿用
        if (message.CleanContent.StartsWith("!talk ") && message.Author.Username.Contains("mitsuba"))
            if (await ResponseMessage.FreeTalkAsync(_client, message.CleanContent.Replace("!talk ", "")))
                return;

        //テスト用
        if (message.CleanContent == "!ping") await message.Channel.SendMessageAsync("なんですの。");

リプライメッセージに関する処理はこっちのファイルさえいじれば後は機能拡張できて楽。



次に定期メッセージ。
該当コードがこれ。

    /// <summary>
    ///     定期実行の登録
    /// </summary>
    private void InitializeTimerMessage()
    {
        Observable.Interval(TimeSpan.FromMinutes(1)).Subscribe(OnNext);

        async void OnNext(long _)
        {
            var now = DateTime.UtcNow.ToJst();

            await NotifyMessage.RefreshShopMessage(_client, now);
            await NotifyMessage.StartTempleMessage(_client, now);
            await NotifyMessage.StartGuildBattleMessage(_client, now);
            await NotifyMessage.EndBattleLeagueMessage(_client, now);
            await NotifyMessage.BaseChallengeNotifyMessage(_client, now);
        }
    }

Observable.Intervalで1分ごとにそのメッセージを送るべきかどうか判定してる。
で、そのメッセージがこれ。

using Discord;
using Discord.WebSocket;
using uminekobot.Common;

namespace uminekobot.MessageReactions;

public static class NotifyMessage
{
    /// <summary>
    ///     ショップ更新通知
    /// </summary>
    /// <param name="client"></param>
    /// <param name="now"></param>
    /// <returns></returns>
    public static async Task RefreshShopMessage(DiscordSocketClient client, DateTime now)
    {
        var shop = new List<int> { 9, 12, 15, 18 };
        if (!shop.Contains(now.Hour) || now.Minute != 0) return;

        var channel = (IMessageChannel)client.GetChannel(DiscordKeys.Current.ChannelId);
        var mentionRole = MentionUtils.MentionRole(DiscordKeys.Current.FriendsRoll);

        var message = new Random().Next(2) == 0
            ? $"{mentionRole} {now.Hour}時ですの。 ショップが更新されたのですの。雫、買ってほしいの。"
            : $"{mentionRole} {now.Hour}時ですの。 ショップが更新されたのですの!一緒にお買い物いきましょですの!";

        await channel.SendMessageAsync(message);
    }

    /// <summary>
    ///     神殿開始通知
    /// </summary>
    /// <param name="client"></param>
    /// <param name="now"></param>
    /// <returns></returns>
    public static async Task StartTempleMessage(DiscordSocketClient client, DateTime now)
    {
        if (now is { Hour: 12, Minute: 30 } or { Hour: 19, Minute: 30 })
        {
            var channel = (IMessageChannel)client.GetChannel(DiscordKeys.Current.ChannelId);
            var mentionRole = MentionUtils.MentionRole(DiscordKeys.Current.FriendsRoll);

            var message = new Random().Next(1) switch
            {
                0 => $"{mentionRole} 神殿はじまったですの!ルーンにするですの?秘薬ですの?それとも...なんでもないですの!"
            };
            await channel.SendMessageAsync(message);
        }
    }

    /// <summary>
    ///     ギルバト開始通知
    /// </summary>
    /// <param name="client"></param>
    /// <param name="now"></param>
    /// <returns></returns>
    public static async Task StartGuildBattleMessage(DiscordSocketClient client, DateTime now)
    {
        if (now is { Hour: 20, Minute: 40 })
        {
            var channel = (IMessageChannel)client.GetChannel(DiscordKeys.Current.ChannelId);
            var mentionRole = MentionUtils.MentionRole(DiscordKeys.Current.FriendsRoll);

            var message = new Random().Next(3) switch
            {
                0 => $"{mentionRole} もうすぐギルドバトルはじまりますの!ちゃんと守って、いっぱい攻めますの!!",
                1 => $"{mentionRole} もうすぐギルドバトルはじまりますの。あんな子、早くやっちゃうのですの...",
                2 => $"{mentionRole} もうすぐギルドバトルはじまりますの!ルーンのつけ忘れは、めっ!ですの!!"
            };
            await channel.SendMessageAsync(message);
        }
    }


    /// <summary>
    ///     バトルリーグ終了通知
    /// </summary>
    /// <param name="client"></param>
    /// <param name="now"></param>
    /// <returns></returns>
    public static async Task EndBattleLeagueMessage(DiscordSocketClient client, DateTime now)
    {
        if (now is { Hour: 20, Minute: 25 })
        {
            var channel = (IMessageChannel)client.GetChannel(DiscordKeys.Current.ChannelId);
            var mentionRole = MentionUtils.MentionRole(DiscordKeys.Current.FriendsRoll);

            var message = $"{mentionRole} もうすぐバトルリーグがおわりますの!!順位チェックしておくといいですの!";
            await channel.SendMessageAsync(message);
        }
    }

    /// <summary>
    ///     布告前通知
    /// </summary>
    /// <param name="client"></param>
    /// <param name="now"></param>
    /// <returns></returns>
    public static async Task BaseChallengeNotifyMessage(DiscordSocketClient client, DateTime now)
    {
        if (now is { Hour: 7, Minute: 42 })
        {
            var channel = (IMessageChannel)client.GetChannel(DiscordKeys.Current.ChannelId);
            var mentionRole = MentionUtils.MentionRole(DiscordKeys.Current.FriendsRoll);

            var message = $"{mentionRole} 布告はじまるですの!はやくはやく!!";
            await channel.SendMessageAsync(message);
        }
    }
}


・RefreshShopMessageは前編で書いたように、レアアイテムが買える機会を逃さないように、ショップの更新を通知してくれたり(公式は一切してくれない)
・StartTempleMessageはレイドバトルの始まりを通知してくれたり(公式は5分遅れてやってくる)
・StartGuildBattleMessageは毎日のギルドバトルが始まるのを通知してくれたり(公式は一切してくれない)
・BaseChallengeNotifyMessageはその毎日のギルドバトルで攻めれる拠点の早押しバトルの開始を通知してくれたり(公式は一切してくれない)
・EndBattleLeagueMessageは個人リーグのランキング確定前の最終駆け込みタイミングを通知してくれたり(公式は一切してくれない)

みたいなことをやってくれる。
基本的にはどれも毎分呼び出されて、頭のifで通知すべきかどうか判定させてるかんじ。
これで通知用のメソッド書いて、InitializeTimerMessageで呼び出すようにしておけば実行されるから簡単に機能追加できる。



最後にSlashCommand。
上のリプライだったり定期実行はまぁ自分で実装すればいいやってレベルの内容だけど、SlashCommandはDiscord特有の機能なので、そのお作法に乗ってあげる必要がある。
で、作ったのがこんなかんじ。

まずはRegisterから。

using Discord.Net;
using Discord.WebSocket;
using Newtonsoft.Json;
using uminekobot.Common;
using uminekobot.SlashCommands.Command;

namespace uminekobot.SlashCommands;

public static class SlashCommandRegister
{
    private static readonly Dictionary<string, BaseSlashCommand> SlashCommands = new();

    /// <summary>
    ///     スラッシュコマンド登録
    /// </summary>
    /// <param name="client"></param>
    /// <returns></returns>
    public static async Task RegisterSlashCommandAsync(DiscordSocketClient client)
    {
        var guildId = DiscordKeys.Current.ServerId;

        try
        {
            await CreateGuildCommand<SelectModelSlashCommand>(client, guildId);
            await CreateGuildCommand<SuperChatSlashCommand>(client, guildId);
            await CreateGuildCommand<TalkDictionarySlashCommand>(client, guildId);
            await CreateGuildCommand<RegisterDictionarySlashCommand>(client, guildId);
            await CreateGuildCommand<MoveVoiceChatSlashCommand>(client, guildId);
            await CreateGuildCommand<VersionSlashCommand>(client, guildId);
        }
        catch (HttpException exception)
        {
            var json = JsonConvert.SerializeObject(exception.Errors, Formatting.Indented);
            Console.WriteLine(json);
        }
    }

    private static async Task CreateGuildCommand<T>(DiscordSocketClient client, ulong guildId)
        where T : BaseSlashCommand, new()
    {
        var command = new T();
        SlashCommands.Add(command.CommandName, command);
        await client.Rest.CreateGuildCommand(command.CommandBuilder().Build(), guildId);
    }

    /// <summary>
    ///     スラッシュコマンドの実行ハンドラ
    /// </summary>
    /// <param name="command"></param>
    /// <returns></returns>
    public static async Task SlashCommandHandler(SocketSlashCommand command)
    {
        if (!SlashCommands.ContainsKey(command.Data.Name)) return;
        await SlashCommands[command.Data.Name].Execute(command);
    }
}


とりあえずいろいろ作ったSlashCommandをClientに登録しないといけない。

        await client.Rest.CreateGuildCommand(command.CommandBuilder().Build(), guildId);

で、そのSlashCommandが呼ばれたかどうかはコマンドの名前で飛んでくる。
名前で判定して実行しないといけない。

    public Program()
    {
        var config = new DiscordSocketConfig
        {
            GatewayIntents = GatewayIntents.AllUnprivileged | GatewayIntents.MessageContent |
                             GatewayIntents.GuildMessages
        };
        _client = new DiscordSocketClient(config);
        _client.Log += LogAsync;
        _client.Ready += ReadyAsync;
        _client.MessageReceived += MessageReceivedAsync;
        _client.SlashCommandExecuted += SlashCommandRegister.SlashCommandHandler;
    }
    public static async Task SlashCommandHandler(SocketSlashCommand command)
    {
        if (!SlashCommands.ContainsKey(command.Data.Name)) return;
        await SlashCommands[command.Data.Name].Execute(command);
    }

その判定をいっこいっこ処理していくのはあまりにもめんどくさいので、Baseクラスを作ってDictionaryに全部ほりこんだ。

using Discord;
using Discord.WebSocket;

namespace uminekobot.SlashCommands.Command;

public abstract class BaseSlashCommand
{
    public abstract string CommandName { get; }

    /// <summary>
    ///     コマンドビルダー
    /// </summary>
    /// <returns></returns>
    public abstract SlashCommandBuilder CommandBuilder();

    /// <summary>
    ///     実行コマンド
    /// </summary>
    /// <param name="command"></param>
    /// <returns></returns>
    public abstract Task Execute(SocketSlashCommand command);
}

あとはそれぞれにコマンドのコードを書いていけばOK。
CommandBuilderはお決まりの構文でいいし、Executeには実際に処理するコードを書けばいい。

例えばSlashCommandで疑似スパチャ投げるコードがこんな感じ。

using Discord;
using Discord.WebSocket;

namespace uminekobot.SlashCommands.Command;

public class SuperChatSlashCommand : BaseSlashCommand
{
    public override string CommandName => "スパチャ";


    /// <summary>
    ///     コマンドビルダー
    /// </summary>
    /// <returns></returns>
    public override SlashCommandBuilder CommandBuilder()
    {
        var slashCommandBuilder = new SlashCommandBuilder()
            .WithName(CommandName)
            .WithDescription("摘のおこづかい。")
            .AddOption("ダイヤ", ApplicationCommandOptionType.Number, "いくつ?", true);

        return slashCommandBuilder;
    }

    /// <summary>
    ///     スパチャコマンド
    /// </summary>
    /// <param name="command"></param>
    /// <returns></returns>
    public override async Task Execute(SocketSlashCommand command)
    {
        var elemdisplay = (command.User as SocketGuildUser)?.DisplayName;
        if (elemdisplay == null) return;

        if (elemdisplay.EndsWith("さん") || elemdisplay.EndsWith("やん")) elemdisplay = elemdisplay[..^2];
        elemdisplay += "ちゃま。";

        var diamond = command.Data.Options.FirstOrDefault(o => o.Name == "ダイヤ")?.Value;

        if (diamond == null || int.Parse(diamond.ToString()!) < 1)
        {
            await command.RespondAsync("いじわる。");
            return;
        }

        await command.RespondAsync($"{elemdisplay}\n{diamond}ダイヤのスパチャありがとですの!");
    }
}

こういうお遊び機能もいいけど、便利機能として特定のボイチャから特定のボイチャへ一斉移動させてみたりとか。

using Discord;
using Discord.WebSocket;

namespace uminekobot.SlashCommands.Command;

public class MoveVoiceChatSlashCommand : BaseSlashCommand
{
    public override string CommandName => "ボイチャ移動";

    public override SlashCommandBuilder CommandBuilder()
    {
        var slashCommandBuilder = new SlashCommandBuilder()
            .WithName(CommandName)
            .WithDescription("ボイチャメンバーをていやって移動するですの。")
            .AddOption("移動元", ApplicationCommandOptionType.Channel, "どこから?", true,
                channelTypes: new List<ChannelType>
                {
                    ChannelType.Voice
                })
            .AddOption("移動先", ApplicationCommandOptionType.Channel, "どこへ?", true,
                channelTypes: new List<ChannelType>
                {
                    ChannelType.Voice
                });


        return slashCommandBuilder;
    }

    public override async Task Execute(SocketSlashCommand command)
    {
        if (command.Data.Options.FirstOrDefault(o => o.Name == "移動元")?.Value is not IVoiceChannel from ||
            command.Data.Options.FirstOrDefault(o => o.Name == "移動先")?.Value is not IVoiceChannel to)
        {
            await command.RespondAsync("なにかがおかしいの。");
            return;
        }

        await command.RespondAsync("移動するのですの!");
        await foreach (var users in from.GetUsersAsync())
            Parallel.ForEach(users, async user =>
            {
                try
                {
                    await user.ModifyAsync(o => o.Channel = new Optional<IVoiceChannel>(to));
                }
                catch (Exception e)
                {
                    Console.WriteLine(e);
                }
            });
    }
}

ほかにもギルドメンバーの面白発言をBlobに登録して同じワードが出たら反応させたりとか、新しい人が来たらチャンネルの説明をしてくれたりとか、今日配信の使うモデルを選んでくれたりとかいろいろ。


こんな感じでbot作ってる。
なかなか便利だし、可愛がられてるしで、楽しい。


おまけに、このbotのコードを切り出して、テンプレート化してみました。
基本的にメンテはしませんが、好きにしてもよいですよ!

dev.azure.com