himanago

Azure・C#などのMS系技術やLINE関連技術など、好きな技術について書くブログ

GitHub Codespaces + LINE OpenAPI による C# での LINE Bot 開発がすぐ始められるテンプレートを作ってみた

はじめに

この記事は 「LINE DC Advent Calendar 2023」 19日目の記事です。

Azure には Azure OpenAI Service があり、LINE Pay Bot からも使いたい、そして Azure では C# での開発が何かと便利。

Azure と C# で LINE Bot 開発したくなるのは必然です。

そこで今回は LINE OpenAPI を使って、C# による開発を便利にできないか?ということをちょっと趣向を変えつつ考えてみました。

LINE OpenAPI は、LINE DC のアドカレ4日目の記事でも取り上げられてます。

qiita.com

以前は C# で LINE Bot を開発するときは、コミュニティ SDK を使っていましたが、現在は最新化されておらず…

LINE OpenAPI を使って SDK を最新化するのもよいのですが、最近 GitHub Codespaces を使っていてすごく便利だなと感じているので、今回はそれと絡めて、Codespace の開発環境を立ち上げたときに最新の SDK がその場で自動生成され、すぐに Bot 開発が始められるテンプレートを作る、ということをやってみました。

やりたいこと

GitHub Codespaces にはテンプレート機能というのがあります。

DevContainer に使いたい環境を定義し、あらかじめ用意しておきたいファイルとセットで GitHub にテンプレートリポジトリとして公開しておけば、それを Codespace から開くことができます。

好きな開発環境を展開できるのでとても便利です。

docs.github.com

これを使って、

  • Azure Functions の開発環境(CLIVS Code 拡張機能など)
  • LINE OpenAPI 定義をもとに生成された C# SDK
  • オウム返しをする LINE Bot のサンプル関数クラス

がセットになった環境をすぐ開けるようにおぜん立てします。

C# SDK の生成は、openapi-generator-cli を使用します。

github.com

作ったもの

ということで作ってみたのですが、後述する理由から正直そこまでの実用性のあるものにはなってません。。

github.com

このテンプレートから Codespace を起動すると、起動後に openapi-generator-cli が走り、最新の LINE OpenAPI 定義に基づいて LINE Bot 開発に必要な C# のプロジェクト群が sdk フォルダに作られます。

必要な拡張機能や Azure Functions Core Tools なども最初から入るようにしています。

DevContainer の定義

postCreateCommand のところで、openapi-generator-cli を使って LINE OpenAPI の定義を基にした C# コードを生成するコマンドを書いています。

生成している定義はとりあえず最低限、messaging-api.ymlwebhook.yml です。

{
    "name": "LINE OpenAPI C# Azure Functions Starter",
    "image": "mcr.microsoft.com/devcontainers/javascript-node:1-18-bullseye",
    "features": {
        "ghcr.io/devcontainers/features/dotnet:2": {
            "version": "6.0"
        },
        "ghcr.io/devcontainers/features/java:1": {
            "installMaven": true,
            "version": "latest",
            "jdkDistro": "ms",
            "gradleVersion": "latest",
            "mavenVersion": "latest",
            "antVersion": "latest"
        }
    },

    "postCreateCommand": "npm install @openapitools/openapi-generator-cli -g && npm install -g azure-functions-core-tools@4 && npx @openapitools/openapi-generator-cli generate -i https://raw.githubusercontent.com/line/line-openapi/main/messaging-api.yml -o sdk -g csharp --additional-properties=netCoreProjectFile=true,targetFramework=netstandard2.1,packageName=LineOpenApi.MessagingApi && npx @openapitools/openapi-generator-cli generate -i https://raw.githubusercontent.com/line/line-openapi/main/webhook.yml -o sdk -g csharp --additional-properties=netCoreProjectFile=true,targetFramework=netstandard2.1,packageName=LineOpenApi.Webhook",
    "customizations": {
        "codespaces": {
            "openFiles": ["LineBotFunctions/ReplyFunction.cs"]
        },
        "vscode": {
            "extensions": [
                "ms-azuretools.vscode-azurefunctions",
                "ms-dotnettools.csdevkit"
            ]
        }
    }
}

生成される LINE OpenAPI のコード

自動生成で下記のようなものが作られています。

それっぽいクラス群がコマンド一発で生成されるのはとても気持ちいいですね。

オウム返し用サンプル関数

生成されるコードにあわせて、こんなかんじになりました。

Event オブジェクトを取りまわして分岐させていくところは自分で書かないといけないので、ここはちゃんと作られた SDK には当然劣りますね。

あと、これは仕方ないのですが MessagingApiApi というのがなんともいえない感じです。

[Function("ReplyFunction")]
public async Task<HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req)
{
    try
    {
        var body = await new StreamReader(req.Body).ReadToEndAsync();
        var callbackRequest = JsonConvert.DeserializeObject<CallbackRequest>(body);

        var api = new MessagingApiApi(new Configuration
        {
            AccessToken = Environment.GetEnvironmentVariable("ChannelAccessToken"),
            BasePath = "https://api.line.me"
        });

        foreach (var ev in callbackRequest.Events)
        {
            if (ev is MessageEvent messageEvent && messageEvent.Message is TextMessageContent textMessageContent)
            {
                var replyMessageRequest = new ReplyMessageRequest(messageEvent.ReplyToken, new List<Message>
                {
                    new TextMessage(textMessageContent.Text)
                    {
                        Type = "text"
                    }
                });
                await api.ReplyMessageAsync(replyMessageRequest);
            }
        }
    }
    catch (Exception e)
    {
        _logger.LogError($"Error: {e.Message}");
        _logger.LogError($"Error: {e.StackTrace}");
    }
    
    return req.CreateResponse(HttpStatusCode.OK);
}

困りごと

Webhook で受け取る JSON のデシリアライズC# オブジェクトへの変換)がうまくいかない

今回最もハマったところなのですが、

if (ev is MessageEvent messageEvent && messageEvent.Message is TextMessageContent textMessageContent)

でやっている分岐がうまく動かない、というものでした。

実は生成されたコードでは、MessageEventEvent を継承し、TextMessageContentMessageContent を継承しています。

LINE Messaging API ではさまざまなイベントやメッセージタイプがありますが、それをポリモーフィズムで表現しています。

それぞれのクラス定義では、以下のように継承の親子関係を JSON からデシリアライズする際にわかるように指定がされています。

Event クラスでは、「Type"message" だったら MessageEvent として扱う」などの指定が存在するサブタイプの数だけ指定されています。

    [DataContract(Name = "Event")]
    [JsonConverter(typeof(JsonSubtypes), "Type")]
    [JsonSubtypes.KnownSubType(typeof(AccountLinkEvent), "accountLink")]
    [JsonSubtypes.KnownSubType(typeof(ActivatedEvent), "activated")]
    [JsonSubtypes.KnownSubType(typeof(BeaconEvent), "beacon")]
    [JsonSubtypes.KnownSubType(typeof(BotResumedEvent), "botResumed")]
    [JsonSubtypes.KnownSubType(typeof(BotSuspendedEvent), "botSuspended")]
    [JsonSubtypes.KnownSubType(typeof(DeactivatedEvent), "deactivated")]
    [JsonSubtypes.KnownSubType(typeof(FollowEvent), "follow")]
    [JsonSubtypes.KnownSubType(typeof(JoinEvent), "join")]
    [JsonSubtypes.KnownSubType(typeof(LeaveEvent), "leave")]
    [JsonSubtypes.KnownSubType(typeof(MemberJoinedEvent), "memberJoined")]
    [JsonSubtypes.KnownSubType(typeof(MemberLeftEvent), "memberLeft")]
    [JsonSubtypes.KnownSubType(typeof(MessageEvent), "message")]
    [JsonSubtypes.KnownSubType(typeof(ModuleEvent), "module")]
    [JsonSubtypes.KnownSubType(typeof(PostbackEvent), "postback")]
    [JsonSubtypes.KnownSubType(typeof(ThingsEvent), "things")]
    [JsonSubtypes.KnownSubType(typeof(UnfollowEvent), "unfollow")]
    [JsonSubtypes.KnownSubType(typeof(UnsendEvent), "unsend")]
    [JsonSubtypes.KnownSubType(typeof(VideoPlayCompleteEvent), "videoPlayComplete")]
    public partial class Event : IEquatable<Event>, IValidatableObject
    {

サブタイプである MessageEvent クラスはこのように定義されています。

    [DataContract(Name = "MessageEvent")]
    [JsonConverter(typeof(JsonSubtypes), "Type")]
    public partial class MessageEvent : Event, IEquatable<MessageEvent>, IValidatableObject
    {

これらの設定により、JSON データの中身を見てそれがどのサブタイプにデシリアライズされるべきか見てくれる動きは、「JsonSubTypes」というライブラリによって実現する機能です。

しかし、どういうわけかこれが効かず、MessageEvent になってほしいのに基底クラスの Event としてデシリアライズされてしまうという現象が起きてしまいました。

なんやかんや試行錯誤したり ChatGPT に聞いたりしていたら、解決。

どうやらサブタイプ側の記述が不要だったようです。

解決策

サブタイプのクラスについている [JsonConverter(typeof(JsonSubtypes), "Type")] を消す or コメントアウトします。

    [DataContract(Name = "MessageEvent")]
    // ↓↓↓これを消す↓↓↓
    // [JsonConverter(typeof(JsonSubtypes), "Type")]
    public partial class MessageEvent : Event, IEquatable<MessageEvent>, IValidatableObject
    {

この記述が悪さをして、うまくサブタイプでデシリアライズしてくれなかったようです。(OpenAPI Generator の不具合なのか…??)

これを、Webhook プロジェクト内の Model 内のすべてのサブタイプに対して行う必要がありますが、サンプルのオウム返しを動かすだけなら

  • sdk/src/LineOpenApi.Webhook/Model/MessageEvent.cs
  • sdk/src/LineOpenApi.Webhook/Model/TextMessageContent.cs

だけで大丈夫です。

コメントアウトしたら、無事に動くようになりました。

まとめ

今回は実験的試みでしたが、OpenAPI Generator の癖なども実感することができたので得たものはあったかなというところです。

公式の OpenAPIの活用によるSDK開発の進化:LINE APIの進化の軌跡 の記事でも紹介されていますが、公式の SDK は、GitHub Actions を用いて自動生成やスクリプト実行によって最新状態を保っているようです。公式 SDK の各リポジトリを見るとその仕組みが垣間見れます。

今回はまったく異なるアプローチでの実験でしたが、やはり実用性を考えれば、公式 SDK 同様のアプローチで SDK を作ってあげるのがスマートですね。