Flutter×Agoraのビデオ通話機能に時間制限をつける(Agora Webhook)

ENSPORTS
  • 開発
Ryoya Fukushima Ryoya Fukushima

こんにちは。エンスポーツでエンジニアをしている福島です。

今回は通話機能の開発を技術選定から実装まで担当しました。とくにユーザごとの通話時間に制限を持たせる仕様については検討余地が多く、情報も少なかったためナレッジとして共有させていただきます。

Flutter × Agoraのビデオ通話機能開発における、時間制限の実現方法に関する記事です。

Flutter×Agoraのビデオ通話機能に時間制限をつけることになった

エンスポーツは恋活・婚活マッチングアプリです。ユーザ同士のコミュニケーションを活発化させるため、そして外部SNSなどでコミュニケーションをとって無用なトラブルにあってしまうユーザを減らすために、通話機能の実装が決まりました。

そんな通話の仕組みには、US企業が提供しているAgoraを導入。通話システム自体を自前で実装することも検討したのですが、インフラ管理や安定性担保における工数やリスクを考えて、外部サービスに依存することに決めました。

そんな「通話機能」ですが、マッチングアプリではこの機能自体にお金を払っていただくわけではなく、あくまでサポート機能的な位置付けです。無制限に通話を許可していては採算があいません。

つまりなんらかの時間制限は必要になるということで、以下の考え方で実装方針を固めました。

  1. ユーザはあらかじめ3時間の制限時間をデータとして持つ
  2. 退出に応じて、データの制限時間から一回あたりの接続時間を差し引く
  3. 制限時間が0なら通話へ参加できないようにする。フロントでは操作できないのはもちろん、バックエンドでもエラーハンドリングする(Agoraの場合は、RTC通信用のトークンを生成からストップさせる)
  4. 1日3時間制限は、翌日午前3時にリセットする

Flutter×Agoraで時間制限を実現する方法

そんなAgoraですが、Flutterアプリ側のみでもユーザ退出イベントは取れるようになっており、入退室の時間は取れます。

_engine.registerEventHandler(RtcEngineEventHandler(
      onLeaveChannel: (RtcConnection connection, RtcStats stats) {
        // フロントでやる場合は本イベントで制限時間の更新処理を行う
        // 正常に退出できない場合、ここでは検知されないリスクがある
      },
 ));

簡易に実装するならば、この情報を元に1日の制限時間を更新してもいいのですが、アプリのクラッシュやネット接続がオフラインになった場合にフロント側ではイベントを正確に取れず、1日の制限時間に整合性を持たせることができないリスクがあります。

そこで今回はWebhook連携をするバックエンドサービスの構築が必要であると判断しました。

AgoraのWebHookで時間制限を実現する流れ

これから時間制限を実装する方の助けになると良いなと思い、テスト実装をした際の流れをメモしていきます。

Agora側でWebHookの設定をおこなう

AgoraダッシュボードにあるProject設定のNotification項目を、以下のように設定します。

「Url Endpoint」にはWebHookに使うAPIのURLを設定します。

例: https://api.xxxx.com/webhook

イベントをハンドルする仕組みを書く

ドキュメントを参考に、簡易的にExpress.jsのAPIを実装してみます。Webhookをとりあえずテストするのが目的なので、今回はインフラへの隠蔽等は行っていません。DDDでプロジェクトを構築する人が本実装をする場合は、実装を隠蔽してPortAdapterからサービスで利用する形になるのが理想でしょう。

今回想定しているユーザ同士の通話は、あくまで1-1になります。この場合は「CommunicationMode」のイベントコード「108」をハンドリングする必要があります。

参考資料:https://docs.agora.io/en/video-calling/advanced-features/receive-notifications?platform=android#108-user-leave-channel-with-communication-mode

※Flutterアプリ側で接続Profileの適切な設定が必要です。未設定もしくはProfileに違いがあると、イベントが飛びません。

// flutter app内でAgora Flutter SDKを初期化している場所
await rtcEngine.initialize(RtcEngineContext(
 appId: 'AGORAのプロジェクトAPPID',
 channelProfile: ChannelProfileType.channelProfileCommunication,
));

型を定義する

※バックエンドはTypescriptで書いています。

全てAnyのままでも扱えますが、一応可読性を上げたいので型も定義しました。フィールドは公式ドキュメントを参考に用意しています。

export enum EventType {
  EventUserLeft = 108, // 退出だけ使いたいので108のみ
}

export interface WebhookRequest {
  noticeId: string;
  productId: number;
  eventType: number;
  payload: Payload;
}

export interface Payload {
  // buildTokenWithUserAccountで生成した場合に文字列UserIdが使用可能
  // この場合フロント側はjoinChannelWithUserAccountを使用します
  account: string; 
  clientSeq: number;
  uid: number;
  channelName: string;
  ts: number;
  duration: number | null;
}

Agora Signatureを検証する

Agora Notificationと我々のAPI が安全に通信するには、署名検証が必要不可欠です。

HTTP コールバックが発生する際、Agora から送信されるリクエストには「Agora-Signature」というリクエストヘッダーが含まれています。この署名は、リクエストボディ(実データ)とsecret(Agora 側で事前に共有)を用いて作成されています。

受信側では同じ secret とリクエストボディを用いて署名を再計算し、リクエストヘッダーに含まれる「Agora-Signature」と比較することで、以下の点を検証します。

  • リクエストが改竄されていないこと
  • 送信元が正当なものであること

これにより、安全な通信が保証されます。

import crypto from 'crypto';
import { env } from './env';

export class SignatureVerifyService {

/**
 * calcSignatureV1
 * 与えられたペイロードとSecretを基にHMAC/SHA256署名を計算します。
 * @param secret - WebhookのSecret
 * @param payload - リクエストペイロード
 * @returns - 計算された署名
 */
  private calcSignatureV1(secret: string, payload: string): string {
    const hmac = crypto.createHmac('sha1', secret);
    hmac.update(payload, 'utf8');
    return hmac.digest('hex');
  }

/**
 * verify
 * リクエストボディのHMAC/SHA256署名が指定された署名と一致するかを検証します。
 * @param requestBody - リクエストのペイロード(ボディ)
 * @param signature - 検証対象の署名、リクエストヘッダーに存在
 * @returns - 検証結果 (true: 一致, false: 不一致)
 */
  verify(requestBody: string, signature: string): boolean {
    // agoraのWebHook secretは適宣格納する(envファイルや、環境変数)
    const calculatedSignature = this.calcSignatureV1(env.agora.webhook.secret, requestBody);
    return calculatedSignature === signature;
  }
}

イベントをハンドリングする

あとはイベントを確認するだけです。退出時に出力するように処理を書きました。

export class EventHandler {
  
  private validEventCode(eventType: number): boolean {
    return eventType == EventType.EventUserLeft;
  }

  async handleEvent(webhookRequest: WebhookRequest) {
    const eventType = webhookRequest.eventType;

    try{
      
      if(!this.validEventCode(eventType)) {
        console.log('Invalid event code for this server. join and leave is only supported', eventType);
        return;
      }
      console.log('timestamp', webhookRequest.payload.ts);

      const dateFromTimeStamp = new Date(webhookRequest.payload.ts * 1000);
      const jstDateString = dateFromTimeStamp.toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" });

 
      if(eventType == EventType.EventUserLeft) {
        console.log('EventCode', eventType);
        console.log('User Left at', jstDateString);
        console.log('connectionduration', webhookRequest.payload.duration, 's');

        console.log(webhookRequest)
      }
    } catch (error) {
      console.error('Error handling event:', error);
    }
  }
}

APIとして利用できるようにする

今回はテストということで、仮置きで「/webhook」とします。

import express from 'express';
import http from 'http';
import cors from 'cors';
import { env } from './env';
import { WebhookRequest } from './event_handler';
import { SignatureVerifyService } from './signature_verify_service';
import { EventHandler } from './event_handler';

// 実際にCORSを設定する場合は、リクエストできるOriginの限定は必要です。
const app = express();
app.use(cors());
app.use(express.json());

const server = http.createServer(app);
const PORT = 3000; // Portも環境に応じて変更必要です。
app.get('/', (req, res) => {
  res.send('This agora webhook test api');
});

app.post('/webhook', async (req, res) => {
  const agoraSignature = req.header('Agora-Signature');

  const body = JSON.stringify(req.body);

  const signatureVerifyService = new SignatureVerifyService();
  if (!signatureVerifyService.verify(body, agoraSignature || '')) {
    console.log('Invalid signature');
    res.status(401).send('Invalid signature');
    return;
  }

  let webhookRequest: WebhookRequest;
  try {
      webhookRequest = req.body;
  } catch (error) {
      res.status(400).send('Invalid JSON');
      return;
  }
  
  // debug code
  console.log("Event code: %d Uid: %d Channel: %s ClientSeq: %d\n",
		webhookRequest.eventType, webhookRequest.payload.uid, webhookRequest.payload.channelName, webhookRequest.payload.clientSeq)

  const eventHandler = new EventHandler();
  await eventHandler.handleEvent(webhookRequest);

  res.status(200).send('Ok');
});

server.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});

Ngrokでテスト公開します。先述したUrlEndPointの設定は忘れないでください。

<プロジェクトのroot>% npm start
<プロジェクトのroot>% ngrok http 3000

退出結果を確認する

通話を実装したアプリで切断処理を行った場合、API側で出力が確認できれば動作しています。


Event code: 108 Uid: 1000000002 Channel: testVC001 ClientSeq: 1739786463280

timestamp 1739786466
EventCode 108
User Left at 2025/2/17 19:01:06
connection duration 3 s
{
  noticeId: '1413774833:662946:108',
  notifyMs: 1739786467677,
  eventType: 108,
  sid: '3DBA8534B30F417C8BCE203BD62C9F27',
  payload: {
    account: 'test_user_001',
    clientSeq: 1739786463280,
    uid: 1000000002,
    ts: 1739786466,
    platform: 1,
    reason: 1,
    duration: 3,
    channelName: 'testVC001'
  },
  productId: 1
}

これにてバックエンド側で、ユーザの退出イベントが検出できました。

また下記の場合でも退出イベントとなることを確認しました。

  1. Wifi、モバイルネットワークの切断
  2. アプリのタスクキル

実際のアーキテクチャ図

情報の取得を確認しましたので、こちらを利用してバックエンドを実装していきました。

DBにアクセスして更新できればいいので、こちらはマイクロサービスとして独立化することも可能です。ユーザー退出時のやり取りが以下のようになります。

今回はAgoraのWebhookを使用することで、整合性を保つ形で通話機能の時間制限を実装できました。

時間制御を考慮してアプリに通話機能を取り入れたい方、Webhookを使った連携処理が必要な人の目に留まれば幸いです。

参考文献

Agora Flutter SDK Quick Start

https://docs.agora.io/en/video-calling/get-started/get-started-sdk?platform=flutter

RtcEventHandler

https://api-ref.agora.io/en/voice-sdk/flutter/6.x/API/class_irtcengineeventhandler.html#ariaid-title41

Receive notifications about channel events

https://docs.agora.io/en/video-calling/advanced-features/receive-notifications

カジュアルにお話しませんか?

エンスポーツでは、一緒にはたらく仲間を募集しています。

アプリ開発に携わっていただけるエンジニアはもちろん、広告集客・ライティングが得意なディレクターやマーケターの方も歓迎しています。

ご興味をもっていただけましたら、ぜひ気軽にご連絡ください。

Ryoya Fukushima

記事を書いた人

Ryoya Fukushima

Engineer

高校生時代に興味本位でゲームプログラミング(Unityで2D、3D開発)を始め、熱中して遊んでいるうちにエンジニアになりました。 エンスポーツには自社プロダクト「ENSPORTS」の開発エンジニアとしてJOIN。 よりよいプロダクトを作るためにスクラム開発体制にて業務に取り組んでおります。