Discord BotをAzure Functions + Azure Open AIで作った

# Discord BotをAzure Functions + Azure Open AIで作った
正直ChatGPTを使えば1日かからないと思っていたが、予想より大分面倒だった。自分が作ろうと思ったタイミング（2024年9月）では、ネットの情報も古いものが多く、ChatGPTもおそらくその情報をベースに答えているので、結構細かく何度もつまづいた。特に安定運用まで持っていくのに地味に時間が削られた。同じ苦労をする人もいるかもしれないので、メモを残す

# Discord Application
Discord Botを作るにはまず[Discordのアプリケーション](https://discord.com/developers/applications
)を作る必要がある。これを作ると、好きなDiscordのサーバーにBotをインストールすることができる。基本GUIで設定するだけだが、この情報も古いものが多く苦労した。古い記事ではサイトのUIも結構違っていて、自分が見た記事はほぼ黒ベースだった（現在は白ベース）。ボットの応答部分はトークンを介して別途プログラムで作る必要あるので、このDiscordアプリケーションはアイコンや権限を設定をしてサーバーにメンバーとして追加するためするものと思えばOKだ

# Discord Bot - Bot
- Discordの開発者用のアプリケーションの[サイト](https://discord.com/developers/applications)に行く
- アカウントがない場合は作る。ある場合はログインする
- New Applicationを押す
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100916/4b729639-225c-89a9-3c47-0f7840ba9263.png)

- NAMEを入れてCreateを押下
> Discordが入っている名前はNG
(AzureFunctionsDiscordBotと言う名前にしようとしたら付けられなかった)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100916/4ee5701d-0a5b-96f0-4a24-0226e14ccdd3.png)
- 次にBOTのTOKENを取得する。Reset Tokenを押すと表示される。このトークンをメモしておく
> 最初作った直後ならそのまま書いてあると説明しているHPもあったが現在は書いてない
自分は何故かReset Token1回目は表示されず、2回Reset Tokenしたら表示された
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100916/7ae38ddb-72e4-2ac9-beeb-921baca7ea7a.png)
- 必要であればアイコンも設定する（しなくても問題ない）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100916/401225dd-f57c-9505-7861-dc3cee0ea370.png)

# Discord Bot - Installation
次にInstallationを設定する。ボットの権限設定だ。何も設定していないとボットがメッセージを書き込むことも読み込むことも出来ない。(この辺も古い内容と結構変わっているので結構トライアンドエラーした)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100916/36dd1111-ec05-7df7-3883-8d9cef947360.png)
- Installation Contexts の Guide Installのチェックを外す
- Default Install Settings → Guild Install →　Scopes　に 「bot」を足す
- Permissionsに設定を足す。一番簡単なのは「Adminisotrator」を足す。これなら出来ないことはほぼないので細かいことを気にする必要はない
- Adminisotrator権限を与えることに抵抗があれば、自分は「Mention Everyone」「Read Message History」「Send Messages」「View Channels」の設定をすればボットとやり取りできた
- ボットの対応に独自のものを入れると他の権限も必要になる可能性もある
- ココでやらなくてもDiscord側のサーバー設定でも権限を変更することは可能
- この状態でInstall LinkをコピーしてそのURLを叩けば自分のサーバーにBotをインストールすることが可能（この時点でインストールしておいて問題ないが、当然次からの対応をしないと応答はしない）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100916/ee9c4824-873e-ffb1-275e-053558f28dc9.png)
- 先ほど設定した権限は以下。権限はDiscordで手動でも変更可能
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100916/c63ec225-319d-08ef-bb55-652e905e1d22.png)

# ボットの応答
次にボットの応答を作る。Discordのトークンを使って定期的にメッセージを拾って、そのメッセージに対して応答をするコードを書く。具体的にはAzure Functions + Azure Open AIを使った。

## Azure Open AI
- 最初に Azure Open AIを作る
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100916/92fe91cc-9780-475f-978f-44e92000469c.png)
- 作ったらキーとエンドポイントをコピーする
- Azure OpenAI Studioからモデルを作る
- 共有リソース→デプロイ→「モデルのデプロイ」ボタン→モデルを選んで確認を押下
- 選んだモデルの名前が後から必要。自分は「gpt-4o」を作った
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100916/110c51cc-ee47-faa0-df8c-8ecbad51fdd2.png)

## Azure Functions
- 色々作り方はあるが、自分はVisual Studioのテンプレートをベースに作った
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100916/fb467f68-bee4-9167-9a7c-0894b1be8d85.png)
- ```Azure.AI.OpenAI```と```Discord.Net```をNugetからインストール
```cs
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.WebJobs;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Discord.WebSocket;
using Discord;
using Azure;
using Azure.AI.OpenAI;
using OpenAI.Chat;

namespace AzureFunctionsDiscordBot;

public static class DiscordBotFunction
{
    private static DiscordSocketClient _client;
    private static readonly string AzureAIEndpoint = Environment.GetEnvironmentVariable("AzureAIEndpoint");
    private static readonly string AzureAPIKey = Environment.GetEnvironmentVariable("AzureAPIKey");
    private static readonly string DiscordBotToken = Environment.GetEnvironmentVariable("DiscordBotToken");
    private static readonly string DeploymentName = "gpt-4o";

    [FunctionName("DiscordBotFunction")]
    public static async Task<IActionResult> RunHttpTrigger([HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req, ILogger log)
    {
        await RunDiscordBotAsync(log);
        return new OkObjectResult("This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response.");
    }

    public static async Task RunDiscordBotAsync(ILogger log)
    {
        if (_client != null) return;

        _client = new DiscordSocketClient();
        _client.Log += (msg) =>
        {
            return Task.CompletedTask;
        };
        await _client.LoginAsync(TokenType.Bot, DiscordBotToken);
        await _client.StartAsync();
        _client.MessageReceived += HandleMessageAsync;
    }

    private static async Task HandleMessageAsync(SocketMessage message)
    {
         if (message.Author.IsBot) return;

        string response = await GetAzureAIResponse(message.Content);
        if (string.IsNullOrEmpty(response)) return;

        await message.Channel.SendMessageAsync(response);
    }

    private static async Task<string> GetAzureAIResponse(string input)
    {
        var credential = new AzureKeyCredential(AzureAPIKey);
        var client = new AzureOpenAIClient(new Uri(AzureAIEndpoint), credential);
        var chatClient = client.GetChatClient(DeploymentName);
        var completion = await chatClient.CompleteChatAsync(new SystemChatMessage(""), new UserChatMessage(input));
        if (completion.Value.Content != null && completion.Value.Content.Count > 0)
        {
            return completion.Value.Content[0].Text;
        }
        else
        {
            return "";
        }
    }
}

```
- HttpTriggerが起動されたら
```cs
    public static async Task<IActionResult> RunHttpTrigger([HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)] HttpRequest req, ILogger log)
    {
        await RunDiscordBotAsync(log);
        return new OkObjectResult("This HTTP triggered function executed successfully. Pass a name in the query string or in the request body for a personalized response.");
    }
```
- 初回だけDiscordBotにログインしてスタートして、メッセージを受け取ったら処理をする
```cs
    public static async Task RunDiscordBotAsync(ILogger log)
    {
        if (_client != null) return;                               //初回以外は処理を返す

        _client = new DiscordSocketClient();
        _client.Log += (msg) =>
        {
            return Task.CompletedTask;
        };
        await _client.LoginAsync(TokenType.Bot, DiscordBotToken);  //ログイン
        await _client.StartAsync();                                //スタート
        _client.MessageReceived += HandleMessageAsync;             //イベントを登録
    }
```
- HandleMessageAsyncはボットがオンラインになっているスレッドの発言は全て飛んでくる 
- SocketMessageのプロパティを見て必要な情報にフィルタリングする。とりあえずBOT自体の発言は弾く
```cs
    private static async Task HandleMessageAsync(SocketMessage message)
    {
         if (message.Author.IsBot) return;                           //Bot自身の発言は弾く

        string response = await GetAzureAIResponse(message.Content); //発言から返答を取得
        if (string.IsNullOrEmpty(response)) return;

        await message.Channel.SendMessageAsync(response);            //チャンネルに返答を送る
    }
```
- 発言をAzureOpenAIに投げると返答が返ってくるのでそのメッセージを返す
```cs
    private static async Task<string> GetAzureAIResponse(string input)
    {
        var credential = new AzureKeyCredential(AzureAPIKey);
        var client = new AzureOpenAIClient(new Uri(AzureAIEndpoint), credential);
        var chatClient = client.GetChatClient(DeploymentName);
        var completion = await chatClient.CompleteChatAsync(new SystemChatMessage(""), new UserChatMessage(input));
        if (completion.Value.Content != null && completion.Value.Content.Count > 0)
        {
            return completion.Value.Content[0].Text;
        }
        else
        {
            return "";
        }
    }
```
- APIキーやトークンをコードに直接書くのは良くないので、```local.settings.json```に書いて```Environment.GetEnvironmentVariable```から読みだす
```cs
    private static readonly string AzureAIEndpoint = Environment.GetEnvironmentVariable("AzureAIEndpoint");
    private static readonly string AzureAPIKey = Environment.GetEnvironmentVariable("AzureAPIKey");
    private static readonly string DiscordBotToken = Environment.GetEnvironmentVariable("DiscordBotToken");
```

# Debug
- Discord BotのInstall Linkからボットをインストールしていればこの時点でローカルでも確認は出来る
- この状態で設定が正しければHandleMessageAsyncが飛んでくる
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100916/fc244562-2eaf-9829-e6c9-3125fe3fd777.png)
- 返答をDiscordに投げると表示される
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100916/de4b71b3-54fe-e18f-1072-2ceb91805c85.png)
- 実際にDiscordに表示されるとかなり出来た感じがする

# デプロイ
- デプロイはVSから設定するのが簡単
- 最初テスト的に手動で発行して上手くいったらGithub Actionsでやるのがおススメ
  - Github Actionsのymlは手動で調べながら作ってみたがここで自動で作ってもらうのが一番お手軽
  - Github上のseceret keyまで自動で設定してくれる
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100916/86ab7cfd-7819-43f8-4d4e-31cf00507438.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100916/a7efa6f9-8029-5f67-f4d2-a7503b7ff167.png)
- デプロイしたらAzureのFucntionsの環境設定にコードで設定したキーやトークンの設定が必要
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100916/ed45a97a-e45b-ae08-e716-f60abc773c96.png)

# 常駐起動
- この状態で、ボットは使用可能だが、Azure Functionsは常時起動でないためしばらくするとオフラインになってしまう
- オフライン中はMessageReceivedのイベントが起きない
- 以前はFuctionsのPremiumプランで常時接続出来たようだが、現状常時接続にはならない
- FuctionsのApp Service プランで常時接続 設定を有効にするのがおそらく簡単
- ただFunctionsのためだけにApp Service プランにするのはコスト面でもデメリット
- 自分はFunctionsのタイマーで5分毎に起動するようにした

```cs
    [FunctionName("KeepAliveFunction")]
    public static async Task RunTimerTrigger(
        [TimerTrigger("0 */5 * * * *")] TimerInfo myTimer, ILogger log)
    {
        log.LogInformation($"Keep alive function executed at: {DateTime.Now}");
        await RunDiscordBotAsync(log);
    }
```
- Functionsは[通常5分は起動](https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-scale)しているようなので、タイマーを5分ごとにすれば常時起動になるはず

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100916/902fdd6c-b51b-acd4-5849-e3c37b6e4a9d.png)
- この辺もネットの情報が古く苦労したポイント
 
# BOTの運用ルール
- BOTは特に設定しなければサーバーの全チャンネルに常駐することになる
- 管理者であればBANしたりも出来るので例えば特定のチャンネルだけ置いておいても良い
- Functionsの中で条件によって応答しなくすることも出来るし、特定のコマンドの場合だけ応答することも出来る
- 自分は@でアプリ名を付けた発言の場合だけ応答するようにした
- この運用のメリットは複数サーバーに入れていても同じルールで運用できること
```cs
    private static async Task HandleMessageAsync(SocketMessage message)
    {
        if (message.Author.IsBot) return;

        var botUserId = _client.CurrentUser.Id;
        // @BOT名でないメッセージは弾く
        if (message.MentionedUsers.Any(user => user.Id == botUserId))
        {
            // @BOT名はOpenAIに投げる時にはいらない情報なので消す
            var content = message.Content.Replace($"<@{botUserId}>", "").Trim();
            string response = await GetAzureAIResponse(content);
            if (string.IsNullOrEmpty(response)) return;

            await message.Channel.SendMessageAsync(response);
        }
    }
```

# まとめ
- ボット自体をどうやって使っていくかはまだ思考中。とりあえず作ってみたいから作った感じ
- 当たり前のことだがChatGPTであっても世の中に古い情報しかないと、あまり正確なことは言わないと思った。早くシンギュラリティが来たら良いのに
- 今回Githubのプロジェクトの名前とDiscordのアプリの名前とAzureのリソースグループの名前合わせにも地味に時間がかかった（特にDiscordにDiscordの名前が使えないので完全統一名にしようとするならアプリ名はちゃんと考えた方が良い）。今回は妥協もした
- まあ、でも作るのは面白かった