himanago

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

Azure Communication Services のサンプルアプリで LINE Bot とやりとりできるようにしてみた

はじめに

最近発表されて注目されている Azure Communication Services。

これは昨今の情勢でかなり使われるようになった Microsoft Teams の裏側のしくみを、Azure 上で提供してくれるサービスで、これを使うと Teams のようにチャットや Web 会議ができるシステムを独自に構築できます。

ドキュメントもわかりやすくまとまっています。

docs.microsoft.com

その中で、手軽に試せるサンプルアプリが 2 種用意されています。今回はそのうち「グループチャット」ができるサンプルアプリ(ASP.NET Core + React)を題材に、LINE Bot とやりとりができるよう機能を拡張してみます。

docs.microsoft.com

なおこのサンプル、

  • Azure Communication Services のリソースを作成
  • ソースコードを手元に clone し接続文字列を設定ファイルに追記
  • Azure に Web App として発行

だけで簡単に使えるので、そのままでも Azure Communication Services のチャット機能を体験するのにとてもおすすめです。

Azure Communication Services × LINE Bot

機能拡張の概要

Azure Communication Services の「グループチャット」サンプルアプリに、LINE Bot と 1 対多で会話できる機能を追加したサンプルアプリです。元のサンプルと同様 Azure に発行して動かすことができます。

Bot の友だち追加時に新たな会話スレッドが作成され、Web アプリのトップページでリンクが表示されるので、そこからスレッド画面に遷移すると、LINE と会話できます。

問合せ対応などのシナリオで、ユーザーが Bot を介してオペレーター数名と直接やりとりをする…というような場面を想定しています。

LINE Bot 連携機能を追加したコードのリポジトリ

コードは以下のリポジトリにあります。オリジナルのリポジトリを Fork したものにいろいろ追加してます。 github.com

スクリーンショット

サンプルアプリの Web アプリから参加した複数人と、LINE Bot のユーザーと対話できます。

f:id:himanago:20201106100042p:plain

f:id:himanago:20201106101030j:plain

変更内容

コードの変更点はコミットログを見ていただければわかるようになっていますが、いくつか重要な部分を載せておきます。

LINE Bot の Webhook エンドポイント

コントローラーを追加しています。

using LineDC.Messaging.Webhooks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using System;
using System.IO;
using System.Threading.Tasks;

namespace Chat.Controllers
{
    [Route("line/webhook")]
    [ApiController]
    public class LineBotWebhookController : ControllerBase
    {
        private IWebhookApplication App { get; }
        private ILogger Logger { get; }

        public LineBotWebhookController(IWebhookApplication app, ILogger<LineBotWebhookController> logger)
        {
            App = app;
            Logger = logger;
        }

        [HttpPost]
        public async Task<IActionResult> Post()
        {
            using var reader = new StreamReader(Request.Body);
            var body = await reader.ReadToEndAsync();
            var xLineSignature = Request.Headers["x-line-signature"];
            try
            {
                Logger?.LogTrace($"RequestBody: {body}");
                await App.RunAsync(xLineSignature, body);
            }
            catch (Exception ex)
            {
                Logger?.LogError(ex.Message);
            }
            return Ok();
        }
    }
}

Webhook のロジック部分

Messaging API C# SDKWebhookApplication を継承した LineBotApp で動きを定義していますが、ここでは友だち追加時の処理で新規スレッドを作るようにしていて、同時に LINE のユーザーID と Azure Communication Services 側で振られたユーザーID 等との関連を Store (サンプルアプリのデータ保存場所)に保存します。

Bot にテキストメッセージを送ったときの処理では、共通の Store に保存されている情報を覗いて、Azure Communication Services へのチャットメッセージ送信を行っています。

using Azure.Communication;
using Azure.Communication.Chat;
using Azure.Communication.Identity;
using LineDC.Messaging;
using LineDC.Messaging.Webhooks;
using LineDC.Messaging.Webhooks.Events;
using LineDC.Messaging.Webhooks.Messages;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System;
using System.Linq;
using System.Threading.Tasks;

namespace Chat.LineBot
{
    public class LineBotApp : WebhookApplication
    {
        private IUserTokenManager UserTokenManager { get; }
        private IChatAdminThreadStore Store { get; }
        private string ChatGatewayUrl { get; }
        private string ResourceConnectionString { get; }
        private ILogger Logger { get; }

        public LineBotApp(ILineMessagingClient client, LineBotSettings settings, ILogger<LineBotApp> logger,
            IChatAdminThreadStore store, IUserTokenManager userTokenManager, IConfiguration chatConfiguration)
            : base(client, settings.ChannelSecret)
        {
            Store = store;
            UserTokenManager = userTokenManager;
            ChatGatewayUrl = Utils.ExtractApiChatGatewayUrl(chatConfiguration["ResourceConnectionString"]);
            ResourceConnectionString = chatConfiguration["ResourceConnectionString"];
            Logger = logger;
        }

        protected override async Task OnFollowAsync(FollowEvent ev)
        {
            Logger?.LogInformation("OnFollowAsync");

            // 参加済みでなければ友だち追加時に新規スレッドを作成
            if (Store.LineUserIdentityStore.ContainsValue(ev.Source.UserId))
            {
                return;
            }

            var (userMri, token, expiresIn) = await UserTokenManager.GenerateTokenAsync(ResourceConnectionString);
            var moderator = new ContosoChatTokenModel
            {
                identity = userMri,
                token = token,
                expiresIn = expiresIn
            };

            var userCredential = new CommunicationUserCredential(moderator.token);
            var chatClient = new ChatClient(new Uri(ChatGatewayUrl), userCredential);

            // プロフィール取得
            var profile = await Client.GetUserProfileAsync(ev.Source.UserId);

            // LINEの表示名でユーザーを作成
            var chatThreadMember = new ChatThreadMember(new CommunicationUser(moderator.identity))
            {
                DisplayName = profile.DisplayName
            };

            var chatThreadClient = await chatClient.CreateChatThreadAsync(
                topic: $"{profile.DisplayName}さん", members: new[] { chatThreadMember });
            Store.Store.Add(chatThreadClient.Id, moderator);

            Logger.LogInformation($"Thread ID: {chatThreadClient.Id}");
            Logger.LogInformation($"Moderator ID: {moderator.identity}");

            // スレッドIDとLINEユーザーIDのペアを保存
            Store.LineUserIdentityStore.Add(chatThreadClient.Id, ev.Source.UserId);

            Store.UseConfigStore[moderator.identity] = new ContosoUserConfigModel { Emoji = " 👑 " };
        }

        protected override async Task OnMessageAsync(MessageEvent ev)
        {
            switch (ev.Message)
            {
                case TextEventMessage textMessage:
                    // 参加しているスレッドにメッセージを送信
                    if (Store.LineUserIdentityStore.Any(pair => pair.Value == ev.Source.UserId))
                    {
                        // スレッド・ユーザー情報を取得
                        var threadId = Store.LineUserIdentityStore.First(pair => pair.Value == ev.Source.UserId).Key;
                        var moderator = Store.Store[threadId];

                        // メッセージ送信                        
                        var userCredential = new CommunicationUserCredential(moderator.token);
                        var chatClient = new ChatClient(new Uri(ChatGatewayUrl), userCredential);
                        var chatThread = chatClient.GetChatThread(threadId);
                        var chatThreadClient = chatClient.GetChatThreadClient(threadId);
                        await chatThreadClient.SendMessageAsync(textMessage.Text);
                    }
                    break;

                case MediaEventMessage mediaMessage:
                case FileEventMessage fileMessage:
                case LocationEventMessage locationMessage:
                case StickerEventMessage stickerMessage:
                default:
                    await Client.ReplyMessageAsync(ev.ReplyToken, "そのメッセージ形式は対応していません。");
                    break;
            }
        }
    }
}

LINE へのメッセージ送信(クライアントサイド)

React を触ったのが初めてだったので、悩みながらでしたが…

sideEffect.tssendMessage 内に処理を追加しています。

const sendMessage = (messageContent: string) => async (dispatch: Dispatch, getState: () => State) => {
  let state: State = getState();
  let chatClient = state.contosoClient.chatClient;
  if (chatClient === undefined) {
    console.error('Chat Client not created yet');
    return;
  }
  let threadId = state.thread.threadId;
  if (threadId === undefined) {
    console.error('Thread Id not created yet');
    return;
  }
  let displayName = state.contosoClient.user.displayName;
  let userId = state.contosoClient.user.identity;

  let clientMessageId = (Math.floor(Math.random() * MAXIMUM_INT64) + 1).toString(); //generate a random unsigned Int64 number
  let newMessage = {
    content: messageContent,
    clientMessageId: clientMessageId,
    sender: { communicationUserId: userId },
    senderDisplayName: displayName,
    threadId: threadId,
    createdOn: undefined
  };
  let messages = getState().chat.messages;
  messages.push(newMessage);
  dispatch(setMessages(messages));
  await sendMessageHelper(
    await chatClient.getChatThreadClient(threadId),
    threadId,
    messageContent,
    displayName,
    clientMessageId,
    dispatch,
    0,
    getState
  );
  // LINE送信(以下追加)
  await fetch('/line/pushMessage', {
    method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        threadId: threadId,
        userId: state.contosoClient.user.identity,
        request: {
          content: messageContent,
          senderDisplayName: displayName
        }
      })
    });
};

LINE へのメッセージ送信(サーバー処理)

コントローラーを追加しています。メッセージ送信時にクライアントから問答無用で送られてくるので、そのスレッドに LINE Bot のユーザーが参加していれば Push するようになっています。

また、前回の記事でも紹介した Messaging API のアイコン・表示名変更を使い、Web アプリ側からの参加者のアイコン・表示名を変えてます。

Messaging APIリファレンス | LINE Developers

ただし、Web アプリ側はアイコンを絵文字で表現しているので、LINE Bot で扱うためにはこれを画像 URL に変更しなければなりません。

そこで、ちょっと無理やりですが、絵文字画像を Twemoji のリポジトリ から取得するようにしています。

using LineDC.Messaging;
using LineDC.Messaging.Messages;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Chat.Controllers
{
    [Route("line/pushMessage")]
    [ApiController]
    public class LineBotPushMessageController : ControllerBase
    {
        private ILineMessagingClient LineMessagingClient { get; }
        private ILogger Logger { get; }
        private IChatAdminThreadStore Store { get; }

        public LineBotPushMessageController(ILineMessagingClient lineMessagingClient,
            ILogger<LineBotPushMessageController> logger, IChatAdminThreadStore store)
        {
            LineMessagingClient = lineMessagingClient;
            Logger = logger;
            Store = store;
        }

        [HttpPost]
        public async Task<IActionResult> Post()
        {
            using var reader = new StreamReader(Request.Body);
            var requestBody = await reader.ReadToEndAsync();
            dynamic data = JsonConvert.DeserializeObject(requestBody);

            string threadId = data.threadId;
            string userId = data.userId;
            string messageContent = data.request.content;
            string senderDisplayName = data.request.senderDisplayName;

            // ユーザー情報が存在すればLINEへ送信
            if (Store.LineUserIdentityStore.TryGetValue(threadId, out var lineUserId))
            {
                // 絵文字画像URLを生成する
                var emoji = Store.UseConfigStore[userId].Emoji;
                var bytes = new UTF32Encoding(true, false).GetBytes(emoji);
                var result = string.Join(string.Empty, bytes.Select(b => string.Format("{0:x2}", b)));
                var emojiCode = result.Substring(result.Length - 5, 5);
                var emojiUrl = $"https://raw.githubusercontent.com/twitter/twemoji/master/assets/72x72/{emojiCode}.png";

                await LineMessagingClient.PushMessageAsync(lineUserId, new List<ISendMessage>
                {
                    new TextMessage(messageContent, null, new Sender(senderDisplayName, emojiUrl))
                });
            }
            return Ok();
        }
    }
}

既存スレッドの取得

LINE から友だち追加した際にスレッドが追加されますが、それを Web アプリ側からも確認・遷移できるよう機能追加しました。実用面では微妙ですが、サンプルなのでこれくらいで。。

以下のメソッドを ContosoApplicationController.cs に追加し、クライアントから叩いています。

/// <summary>
/// 既存スレッド情報
/// </summary>
/// <returns></returns>
[Route("existingThreadIds")]
[HttpGet]
public List<string> GetExistingThreadIds()
{
    return _store.Store.Keys.ToList();
}

まとめ

Azure Communication Services を使うとチャットアプリが簡単に作れます。そしてそこに LINE Bot を追加のインターフェースとして組み込んでいくのも割と手軽にできました。

チャット機能を自作したあと「LINE でもやりとりしたいな」と思った際にすぐつなげることができるので、Bot 上で疑似的なグループ会話を再現するのに便利な「アイコンおよび表示名の変更」機能とともに、ぜひ試してみてください。