Flaskで簡易的なオンライン会議システムを作る

こんにちは！

弊社では毎月、部署の定例会をオンラインで行なってます。
一箇所に集まらなくても開催できるので非常にいいのですが、オンラインにおけるコミュニケーションは、今後も課題があると感じてます。

今回は、オンラインの定例会における要望の一つ、コメントを流せるオンライン会議システムを作ってみます。

# なぜコメント機能が必要なのか
色々と試した結果、コメントが流れる方が盛り上がるからです。
身内での会議限定となりますが、匿名で様々なコメントが流れると、場が和み、より発言しやすくなります。
Google MeetやZoomでもリアクション機能やチャット機能はありますが、チャットだと気付きにくいこともあり、画面上で流れた方が分かりやすいです。

また、一時期cometsというサービスを利用していたのですが、Googleのセキュリティ強化により利用しづらくなった、という背景もあります。

# やりたいこと
- オンライン会議
- コメントを送ったら画面上で流れる

# 環境構築
Pythonの仮想環境を作ります。
```bash
python -m venv online-meeting
cd online-meeting
```
ディレクトリに移動した直後の状態
```bash
$ ls
bin/		include/	lib/		pyvenv.cfg
```
仮想環境の有効化
```bash
source bin/activate
```
次に、必要なライブラリのインストールをします。
```bash
pip install flask
pip install qrcode[pil]
pip install flask-socketio
```
# Flask-SocketIOとは
クライアントとサーバー間の双方向通信を実現し、低レイテンシでのアクセスが可能になるライブラリです。

# ディレクトリ構成
最終的なディレクトリ構成です。
```bash
online-meeting/
├─ app.py
├─ pyvenv.cfg
├─ bin/
├─ include/
├─ lib/
├─ share/
└─ templates/
   ├─ index.html
   └─ meeting.html
```
# コード
今回実装した３ファイルを以下に示します。
QRコードは未完成のため、一旦コメントアウトしてます。
```html:index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>オンライン会議システム</title>
</head>
<body>
    <h1>オンライン会議システムへようこそ</h1>

    <button id="create-meeting">新しい会議を作成</button>

    <h2>会議に参加する</h2>
    <form action="/join_meeting" method="post">
        <label for="meeting-id">会議ID：</label>
        <input type="text" id="meeting-id" name="meeting_id" required>
        <button type="submit">参加</button>
    </form>

    <script>
        document.getElementById('create-meeting').addEventListener('click', function() {
            fetch('/create_meeting', { method: 'POST' })
                .then(response => response.json())
                .then(data => {
                    if (data.meeting_id) {
                        window.location.href = '/meeting/' + data.meeting_id;
                    }
                })
                .catch(error => console.error('Error:', error));
        });
    </script>
</body>
</html>
```
```html:meeting.html
<!-- meeting.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>オンライン会議</title>
    <script src="//cdnjs.cloudflare.com/ajax/libs/socket.io/4.5.0/socket.io.js"></script>
    <style>
        #video-container {
            position: relative;
            width: 640px;
            height: 360px;
            overflow: hidden;
        }

        #video-container video {
            width: 100%;
            height: auto;
        }
    
        #comments-overlay {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            color: white;
            background: rgba(0, 0, 0, 0.5);
        }
    
        .scroll-comment {
            position: relative;
            white-space: nowrap;
            animation: scroll 10s linear infinite;
        }
    
        @keyframes scroll {
            0% { transform: translateX(100%); }
            100% { transform: translateX(-100%); }
        }
    </style>
</head>
<body>
    <h1>オンライン会議</h1>
    <p>会議ID: {{ meeting_id }}</p>
    <div id="video-container" style="position: relative;">
        <div id="comments-overlay"></div>
        <video id="localVideo" autoplay muted></video>
        <video id="remoteVideo" autoplay></video>
    </div>
    
    <!-- <h2>QRコード</h2>
    <img src="data:image/png;base64,{{ qr_img_data }}" alt="QR Code"> -->

    <h2>コメント</h2>
    <form id="comment-form">
        <input type="text" id="comment" name="comment" placeholder="コメントを入力" required>
        <button type="submit">送信</button>
    </form>

    <script>
        // ビデオ要素を取得
        var localVideo = document.getElementById('localVideo');
        var remoteVideo = document.getElementById('remoteVideo');

        var localStream;
        var peerConnection;
        var config = {
            'iceServers': [{'urls': 'stun:stun.l.google.com:19302'}]
        };

        // ユーザーのメディアを取得して、ビデオ要素に設定
        navigator.mediaDevices.getUserMedia({video: true, audio: true})
            .then(function(stream) {
                localVideo.srcObject = stream;
                localStream = stream;
                initializePeerConnection();
            }).catch(function(error) {
                console.log('メディアの取得に失敗しました:', error);
            });

        // WebRTCの接続を初期化
        function initializePeerConnection() {
            peerConnection = new RTCPeerConnection(config);
            localStream.getTracks().forEach(track => {
                peerConnection.addTrack(track, localStream);
            });
            peerConnection.ontrack = function(event) {
                remoteVideo.srcObject = event.streams[0];
            };
        }

        // WebSocketを使ったシグナリング
        var socket = io();

        socket.on('new_comment', function(data) {
            if (data.meeting_id === "{{ meeting_id }}") {
                loadComments([data.comment]);
            }
        });

        // 通信を開始するための処理（offer/answer/candidate）
        socket.on('offer', function(offer) {
            peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
        });
        socket.on('answer', function(answer) {
            peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
        });
        socket.on('candidate', function(candidate) {
            peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
        });

        // 通信のオファーを作成
        function createOffer() {
            peerConnection.createOffer()
                .then(function(offer) {
                    return peerConnection.setLocalDescription(offer);
                })
                .then(function() {
                    // オファーをサーバーに送信
                    socket.emit('offer', peerConnection.localDescription);
                });
        }

        var localVideo = document.getElementById('localVideo');

        navigator.mediaDevices.getUserMedia({ video: true, audio: true })
            .then(function(stream) {
                localVideo.srcObject = stream;
            })
            .catch(function(error) {
                console.log('メディアの取得に失敗しました: ', error);
            });

        // コメントの送信フォームのイベントリスナー
        document.getElementById('comment-form').addEventListener('submit', function(event) {
            event.preventDefault();
            var comment = document.getElementById('comment').value;

            fetch('/comment', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                body: 'meeting_id={{ meeting_id }}&comment=' + encodeURIComponent(comment)
            })
            .then(response => response.json())
            .then(data => {
                if (data.status === 'success') {
                    document.getElementById('comment').value = '';
                }
            })
            .catch(error => console.error('Error:', error));
        });

        function loadComments(newComments) {
            var commentsOverlay = document.getElementById('comments-overlay');

            // 新しいコメントを追加
            newComments.forEach(function(comment, index) {
                var p = document.createElement('p');
                p.textContent = comment;
                p.classList.add('scroll-comment');
                p.style.top = ((commentsOverlay.children.length + index) * 20) % 200 + 'px';
                setTimeout(function() {
                    p.remove();
                }, 5000);
                commentsOverlay.appendChild(p);
            });
        }

        // 初回のコメント読み込み
        fetch('/get_comments/{{ meeting_id }}')
            .then(response => response.json())
            .then(comments => {
                loadComments(comments);
            })
            .catch(error => console.error('Error:', error));

            loadComments();
        </script>
    </body>
</html>
```
```python:app.py
# app.py
from flask import Flask, render_template, request, jsonify
from flask_socketio import SocketIO, send
import uuid
import qrcode
import io
import base64

app = Flask(__name__)
socketio = SocketIO(app, cors_allowed_origins="*")

# 会議データを保持する簡易データベース
meetings = {}

# トップページ
@app.route('/')
def index():
    return render_template('index.html')

# 新しい会議の作成
@app.route('/create_meeting', methods=['POST'])
def create_meeting():
    meeting_id = str(uuid.uuid4())
    meeting_url = request.host_url + 'meeting/' + meeting_id
    meetings[meeting_id] = {'url': meeting_url, 'comments': []}
    return jsonify({'meeting_id': meeting_id, 'meeting_url': meeting_url})

# 会議への参加
@app.route('/meeting/<meeting_id>')
def meeting(meeting_id):
    if meeting_id not in meetings:
        return "Meeting not found", 404

    # 会議URLのQRコード生成
    qr = qrcode.QRCode(
        version=1,
        error_correction=qrcode.constants.ERROR_CORRECT_L,
        box_size=10,
        border=4,
    )
    qr.add_data(meetings[meeting_id]['url'])
    qr.make(fit=True)

    img = qr.make_image(fill_color="black", back_color="white")
    byte_io = io.BytesIO()
    img.save(byte_io, 'PNG')
    byte_io.seek(0)
    qr_img_data = base64.b64encode(byte_io.read()).decode('utf-8')  # Base64エンコード

    return render_template('meeting.html', meeting_id=meeting_id, qr_img_data=qr_img_data)

# コメントの送信
@app.route('/comment', methods=['POST'])
def post_comment():
    meeting_id = request.form['meeting_id']
    comment = request.form['comment']
    if meeting_id in meetings:
        meetings[meeting_id]['comments'].append(comment)
        socketio.emit('new_comment', {'meeting_id': meeting_id, 'comment': comment})
        return jsonify({'status': 'success'})
    return jsonify({'status': 'failed'})

# コメントの取得
@app.route('/get_comments/<meeting_id>')
def get_comments(meeting_id):
    if meeting_id in meetings:
        return jsonify(meetings[meeting_id]['comments'])
    return jsonify([])

# WebSocketイベントハンドラー
@socketio.on('message')
def handle_message(message):
    send(message, broadcast=True)

if __name__ == '__main__':
    app.run(host = '0.0.0.0',debug=True)
```
# 実行結果
それでは、実行してみましょう。
```bash
$ python app.py 
 * Serving Flask app 'app'
 * Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:5000
 * Running on http://192.168.1.6:5000
```
127.0.0.1:5000 にアクセスしてみます。
![スクリーンショット 2023-12-18 0.03.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407770/fc715830-3c3a-c1a8-ea3b-087980078ee0.png)

トップページが表示されました。
次に、新しい会議を作成してみましょう。
![スクリーンショット 2023-12-18 0.06.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407770/4879e62d-c2f8-bd2d-07c6-b6f091262f90.png)

カメラが起動して、画面に自分が映りました。
コメントを流してみましょう。
![スクリーンショット 2023-12-18 0.09.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407770/56cf8e3b-55b0-ee56-dcba-f3d56a303853.png)

ちゃんと流れてますね！
さらに他のブラウザでもアクセスして、双方向からコメントを流してみましょう。
![スクリーンショット 2023-12-18 0.11.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407770/30816b2e-3d87-275b-0e30-e140c8e6c307.png)

ChromeとEdgeからコメントを流してみたところ、ちゃんと確認できました！
基本的な部分はクリアできたようです。

# 今後の改修方針
まだまだ未完成なので、以下を可能なものから少しずつ対応していく予定です。（思いつき含む）
・参加メンバーの枠を表示する
・スマホでも動作するように改修
・リアクション機能
・画面共有機能
・Google認証（組織内だけ通す）
・セキュリティ強化（脆弱性対応など）
・AIの組み込み（文字起こし、翻訳、要約、タスク管理、その他）
・タイムキーパー機能
・ルーレット機能（強制的に意見を求める）
・多数決機能
・表彰機能（派手なやつ）
・逆投げ銭機能（臨時ボーナス的なやつ）
・NFT機能（参加、発表などの記録）
・バーチャル会議室機能（VR/AR）

# まとめ
今回はFlaskでオンライン会議システムを作ってみました。
やりたいことを挙げたらきりがないですが、オンラインの課題を解決できるような、楽しいシステムになるといいな、と思います。

一瞬、Qiitaでもコメントが流れたら、コメントする人が増えて面白いかな、と思いましたが、良し悪しもあると思うので、もう少し考えてみます。

モノづくりって楽しいですね:smiley: