From 7e580d75d07a963c0ce169868a056772df8dba3a Mon Sep 17 00:00:00 2001 From: hina ntki Date: Sat, 3 Aug 2024 19:29:46 +0900 Subject: [PATCH] first commit --- .gitignore | 176 +++++++++++++++++ bot/Dockerfile | 19 ++ bot/app.py | 174 +++++++++++++++++ bot/bot.py | 24 +++ bot/main.py | 16 ++ bot/requirements.txt | 4 + docker-compose.yml | 18 ++ web/Dockerfile | 22 +++ web/app.py | 343 +++++++++++++++++++++++++++++++++ web/auth.py | 64 ++++++ web/db.py | 30 +++ web/handlers.py | 142 ++++++++++++++ web/init_account.py | 28 +++ web/models.py | 31 +++ web/requirements.txt | 5 + web/templates/files.html | 136 +++++++++++++ web/templates/index.html | 307 +++++++++++++++++++++++++++++ web/templates/login.html | 17 ++ web/templates/quiz_master.html | 273 ++++++++++++++++++++++++++ 19 files changed, 1829 insertions(+) create mode 100644 .gitignore create mode 100644 bot/Dockerfile create mode 100644 bot/app.py create mode 100644 bot/bot.py create mode 100644 bot/main.py create mode 100644 bot/requirements.txt create mode 100644 docker-compose.yml create mode 100644 web/Dockerfile create mode 100644 web/app.py create mode 100644 web/auth.py create mode 100644 web/db.py create mode 100644 web/handlers.py create mode 100644 web/init_account.py create mode 100644 web/models.py create mode 100644 web/requirements.txt create mode 100644 web/templates/files.html create mode 100644 web/templates/index.html create mode 100644 web/templates/login.html create mode 100644 web/templates/quiz_master.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90f8fbb --- /dev/null +++ b/.gitignore @@ -0,0 +1,176 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python +# Edit at https://www.toptal.com/developers/gitignore?templates=python + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +# End of https://www.toptal.com/developers/gitignore/api/python \ No newline at end of file diff --git a/bot/Dockerfile b/bot/Dockerfile new file mode 100644 index 0000000..ec61f13 --- /dev/null +++ b/bot/Dockerfile @@ -0,0 +1,19 @@ +# ベースイメージを指定 +FROM python:3.9-slim + +# 作業ディレクトリを設定 +WORKDIR /bot + +# 必要なパッケージをインストール +RUN apt-get update && \ + apt-get install -y gcc libc-dev ffmpeg + +# 依存関係をインストール +COPY requirements.txt requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +# アプリケーションのコピー +COPY . . + +# アプリケーションの実行 +CMD ["python", "main.py"] diff --git a/bot/app.py b/bot/app.py new file mode 100644 index 0000000..658fabb --- /dev/null +++ b/bot/app.py @@ -0,0 +1,174 @@ +from flask import Flask, request, jsonify +import logging +import discord +from discord.ext import commands, tasks +from discord import VoiceChannel +import asyncio + +app = Flask(__name__) +app.config['DEBUG'] = True + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Discord Botインスタンスを保持するための変数 +bot_instance = None +quiz_message_id = None + +def create_response(error_message=None, success_message=None): + if error_message: + logger.error(error_message) + return jsonify({"error": error_message}), 500 + if success_message: + logger.info(success_message) + return jsonify({"message": success_message}), 200 + +@app.route('/join', methods=['POST']) +def join_via_api(): + if bot_instance is None: + return create_response(error_message="BOTが初期化されていません") + + channel_id = request.json.get('channel_id') + logger.info(f"リクエストされたチャンネルID: {channel_id}") + + if not channel_id: + return create_response(error_message="チャンネルIDが必要です") + + try: + channel_id = int(channel_id) + except ValueError: + return create_response(error_message=f"無効なチャンネルIDです: {channel_id}") + + channel = bot_instance.get_channel(channel_id) + if channel: + logger.info(f"チャンネルが見つかりました: {channel.name}") + if isinstance(channel, VoiceChannel): + bot_instance.loop.create_task(channel.connect()) + return create_response(success_message=f'{channel.name}に参加します') + else: + return create_response(error_message=f"ID {channel_id} はボイスチャンネルではありません") + else: + return create_response(error_message="指定されたチャンネルが見つかりません") + +@app.route('/leave', methods=['POST']) +def leave_via_api(): + if bot_instance is None: + return create_response(error_message="BOTが初期化されていません") + + if bot_instance.voice_clients: + for vc in bot_instance.voice_clients: + bot_instance.loop.create_task(vc.disconnect()) + return create_response(success_message="ボイスチャンネルから切断しました") + return create_response(error_message="ボイスチャンネルに接続されていません") + +@app.route('/play', methods=['POST']) +def play_via_api(): + if bot_instance is None: + return create_response(error_message="BOTが初期化されていません") + + filename = request.json.get('filename') + volume = request.json.get('volume', 100) + try: + volume = float(volume) / 100.0 + except ValueError: + return create_response(error_message="無効な音量値です") + + if bot_instance.voice_clients: + for vc in bot_instance.voice_clients: + audio_source = discord.FFmpegPCMAudio(f'bucket/{filename}', options=f"-filter:a 'volume={volume}'") + vc.play(audio_source) + return create_response(success_message=f'再生中: {filename}') + return create_response(error_message="ボイスチャンネルに接続されていません") + +@app.route('/start_quiz', methods=['POST']) +def start_quiz(): + if bot_instance is None: + return create_response(error_message="BOTが初期化されていません") + + data = request.json + intro_audio_name = data.get('intro_audio_name') + volume = data.get('volume', 100) + if not intro_audio_name: + return create_response(error_message="イントロ音源名が必要です") + + try: + volume = float(volume) / 100.0 + except ValueError: + return create_response(error_message="無効な音量値です") + + if bot_instance.voice_clients: + for vc in bot_instance.voice_clients: + if not vc.is_connected(): + return create_response(error_message="ボイスチャンネルに接続されていません") + + # クイズ回答用のメッセージをVCチャットに送信 + asyncio.run_coroutine_threadsafe(send_quiz_message(vc.channel, intro_audio_name, volume), bot_instance.loop) + return create_response(success_message=f'イントロ音源再生準備中: {intro_audio_name}') + return create_response(error_message="ボイスチャンネルに接続されていません") + +@app.route('/stop_audio', methods=['POST']) +def stop_audio(): + if bot_instance is None: + return create_response(error_message="BOTが初期化されていません") + + if bot_instance.voice_clients: + for vc in bot_instance.voice_clients: + if vc.is_playing(): + vc.stop() + return create_response(success_message="再生を停止しました") + return create_response(error_message="再生中の音源がありません") + +async def send_quiz_message(channel, intro_audio_name, volume): + global quiz_message_id + button = discord.ui.Button(label="回答", custom_id="quiz_answer") + view = discord.ui.View() + view.add_item(button) + message = await channel.send("クイズが開始されました!", view=view) + quiz_message_id = message.id + + # インターバルを設ける + await asyncio.sleep(5) # 5秒間待機 + + # イントロ音源を再生 + for vc in bot_instance.voice_clients: + if vc.channel == channel: + audio_source = discord.FFmpegPCMAudio(f'bucket/{intro_audio_name}', options=f"-filter:a 'volume={volume}'") + vc.play(audio_source) + + await disable_button_after_timeout(message) + +async def disable_button_after_timeout(message): + await asyncio.sleep(20) + view = discord.ui.View() + for action_row in message.components: + for item in action_row.children: + item.disabled = True + view.add_item(discord.ui.Button.from_component(item)) + await message.edit(view=view) + await message.delete() + +def run_app(): + app.run(host='0.0.0.0', port=80, use_reloader=False) + +def set_bot_instance(bot): + global bot_instance + bot_instance = bot + + @bot.event + async def on_interaction(interaction): + global quiz_message_id + global bot_instance + if interaction.data.get('custom_id') == "quiz_answer": + if interaction.message.id == quiz_message_id: + quiz_message_id = None + await interaction.response.send_message(f"{interaction.user.mention}が回答ボタンを押しました!", ephemeral=False) + if bot_instance.voice_clients: + for vc in bot_instance.voice_clients: + if vc.is_playing(): + vc.stop() + view = discord.ui.View() + for action_row in interaction.message.components: + for item in action_row.children: + item.disabled = True + view.add_item(discord.ui.Button.from_component(item)) + await interaction.message.edit(view=view) diff --git a/bot/bot.py b/bot/bot.py new file mode 100644 index 0000000..e065332 --- /dev/null +++ b/bot/bot.py @@ -0,0 +1,24 @@ +import discord +from discord.ext import commands +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +intents = discord.Intents.default() +intents.messages = True +intents.guilds = True +intents.voice_states = True +intents.message_content = True +intents.reactions = True +bot = commands.Bot(command_prefix='!', intents=intents) + +@bot.event +async def on_ready(): + logger.info(f'{bot.user.name} がログインしました') + +def run_bot(token): + bot.run(token) + +def get_bot(): + return bot diff --git a/bot/main.py b/bot/main.py new file mode 100644 index 0000000..544f3a5 --- /dev/null +++ b/bot/main.py @@ -0,0 +1,16 @@ +import os +import threading +from app import run_app, set_bot_instance +from bot import run_bot, get_bot + +if __name__ == '__main__': + bot_instance = get_bot() + set_bot_instance(bot_instance) + + threading.Thread(target=run_app).start() + + token = os.getenv('DISCORD_TOKEN') + if not token: + raise ValueError("Discord BOT トークンが設定されていません") + + run_bot(token) diff --git a/bot/requirements.txt b/bot/requirements.txt new file mode 100644 index 0000000..d7a38bd --- /dev/null +++ b/bot/requirements.txt @@ -0,0 +1,4 @@ +discord.py +flask +flask-cors +pynacl diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..24bfeed --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + bot: + build: ./bot + volumes: + - ./bot:/bot + - ./bucket:/bot/bucket + environment: + - DISCORD_TOKEN=MTIyNjIwMTA4ODg1NjgyMTg4MQ.GH0q-p.5A8k4UCGXK5UT6g4uCksFNHhAky9EvRQLuZckQ + web: + build: ./web + volumes: + - ./web:/web + - ./bucket:/web/bucket + - ./web/data:/web/data + ports: + - "5000:80" + links: + - bot diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..28e3e4f --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,22 @@ +# Pythonベースのイメージを使用 +FROM python:3.9-slim + +# 作業ディレクトリを作成 +WORKDIR /web + +# 必要なパッケージをインストール +COPY requirements.txt requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +# アプリケーションコードをコピー +COPY . . + +# Flaskのホットリロードのための環境変数設定 +ENV FLASK_ENV=development +ENV FLASK_APP=app.py + +# Flaskサーバーの起動コマンド +CMD ["python", "app.py"] + +# 標準出力と標準エラー出力を一つに統合 +CMD ["sh", "-c", "python app.py > /proc/1/fd/1 2>&1"] diff --git a/web/app.py b/web/app.py new file mode 100644 index 0000000..8a55ebb --- /dev/null +++ b/web/app.py @@ -0,0 +1,343 @@ +import os +import sys +import traceback +import secrets +import random +import requests +from flask import Flask, session, redirect, url_for, g, request, render_template, jsonify, flash +import logging +from datetime import timedelta +from db import get_db, close_connection, initialize_db, reset_active_sessions +from auth import login_required, check_login, login, logout, create_account +from handlers import handle_file_upload, delete_file, play_file, join_channel, leave_channel, index, get_files, get_quiz_master, update_quiz_master, add_quiz_master, delete_quiz_master, get_unique_keywords +from init_account import create_initial_account +from models import ActiveSession, QuizMaster +from sqlalchemy.sql import func +import csv + +app = Flask(__name__) +app.config['UPLOAD_FOLDER'] = 'bucket' +app.config['SECRET_KEY'] = secrets.token_hex(16) +app.config['DATABASE'] = '/web/data/database.db' +app.config['BOT_API_URL'] = 'http://bot:80' +app.config['SESSION_TIMEOUT_MINUTES'] = 30 + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +drawn_quiz_ids = set() + +if not os.path.exists(app.config['UPLOAD_FOLDER']): + os.makedirs(app.config['UPLOAD_FOLDER']) + logger.info(f"{app.config['UPLOAD_FOLDER']}ディレクトリを作成しました") + +with app.app_context(): + create_initial_account(app.config['DATABASE']) + reset_active_sessions(logger) + initialize_db(logger) + +@app.teardown_appcontext +def close_db_connection(exception): + close_connection(exception) + +@app.before_request +def before_request(): + if 'session_id' not in session: + session['session_id'] = secrets.token_hex(16) + logger.info(f"新しいセッションIDが生成されました: {session['session_id']}") + #else: + #logger.info(f"既存のセッションIDが存在します: {session['session_id']}") + + session.permanent = True + app.permanent_session_lifetime = timedelta(minutes=app.config['SESSION_TIMEOUT_MINUTES']) + session.modified = True + + db = get_db() + if session.get('logged_in'): + active_session = db.query(ActiveSession).filter_by(session_id=session['session_id']).first() + if active_session: + active_session.last_active = func.now() + db.commit() + elif request.endpoint not in ('login_route', 'static'): + return redirect(url_for('login_route')) + +@app.route('/login', methods=['GET', 'POST']) +def login_route(): + return login() + +@app.route('/logout', methods=['POST']) +def logout_route(): + return logout() + +@app.route('/create_account', methods=['POST']) +def create_account_route(): + return create_account() + +@app.route('/') +@login_required +def index_route(): + return index() + +@app.route('/upload', methods=['POST']) +@login_required +def upload_file_route(): + return handle_file_upload() + +@app.route('/uploaded_files', methods=['GET']) +@login_required +def uploaded_files_route(): + return render_template('files.html') + +@app.route('/uploaded_files_list', methods=['GET']) +@login_required +def uploaded_files_list_route(): + files = os.listdir(app.config['UPLOAD_FOLDER']) + return jsonify(files) + +@app.route('/used_files_list', methods=['GET']) +@login_required +def used_files_list_route(): + db = get_db() + used_files = db.query(QuizMaster.full_audio_name, QuizMaster.intro_audio_name).all() + return jsonify([file for sublist in used_files for file in sublist]) + +@app.route('/delete/', methods=['POST']) +@login_required +def delete_file_route(filename): + return delete_file(filename) + +@app.route('/play/', methods=['POST']) +@login_required +def play_file_route(filename): + volume = request.json.get('volume', 100) + return play_file(filename, volume) + +@app.route('/stop_audio', methods=['POST']) +@login_required +def stop_audio_route(): + response = requests.post(f"{app.config['BOT_API_URL']}/stop_audio") + return jsonify(response.json()) + +@app.route('/join', methods=['POST']) +@login_required +def join_channel_route(): + return join_channel() + +@app.route('/leave', methods=['POST']) +@login_required +def leave_channel_route(): + return leave_channel() + +@app.route('/files', methods=['GET']) +@login_required +def get_files_route(): + return get_files() + +@app.route('/quiz_master_page', methods=['GET']) +@login_required +def quiz_master_page_route(): + return render_template('quiz_master.html') + +@app.route('/quiz_master', methods=['GET']) +@login_required +def get_quiz_master_route(): + return get_quiz_master() + +@app.route('/unique_keywords', methods=['GET']) +@login_required +def unique_keywords_route(): + return get_unique_keywords() + +@app.route('/update_quiz_master', methods=['POST']) +@login_required +def update_quiz_master_route(): + return update_quiz_master() + +@app.route('/add_quiz_master', methods=['POST']) +@login_required +def add_quiz_master_route(): + return add_quiz_master() + +@app.route('/delete_quiz_master', methods=['POST']) +@login_required +def delete_quiz_master_route(): + return delete_quiz_master() + +@app.route('/random_quiz', methods=['POST']) +@login_required +def random_quiz_route(): + data = request.json + keyword = data.get('keyword') + db = get_db() + quiz_master_data = db.query(QuizMaster).filter( + (QuizMaster.keyword1 == keyword) | + (QuizMaster.keyword2 == keyword) | + (QuizMaster.keyword3 == keyword) + ).all() + + if not quiz_master_data: + return jsonify({'error': '指定されたキーワードでクイズマスターが見つかりません'}), 404 + + available_quizzes = [quiz for quiz in quiz_master_data if quiz.id not in drawn_quiz_ids] + + if not available_quizzes: + return jsonify({'error': '指定されたキーワードで利用可能なクイズが見つかりません'}), 404 + + selected_quiz = random.choice(available_quizzes) + drawn_quiz_ids.add(selected_quiz.id) + + return jsonify({ + 'id': selected_quiz.id, + 'title': selected_quiz.title, + 'full_audio_name': selected_quiz.full_audio_name, + 'intro_audio_name': selected_quiz.intro_audio_name, + 'keyword1': selected_quiz.keyword1, + 'keyword2': selected_quiz.keyword2, + 'keyword3': selected_quiz.keyword3 + }) + +@app.route('/reset_quiz', methods=['POST']) +@login_required +def reset_quiz_route(): + global drawn_quiz_ids + drawn_quiz_ids = set() + return jsonify({'message': 'クイズがリセットされました'}) + +@app.route('/start_quiz', methods=['POST']) +@login_required +def start_quiz_route(): + data = request.json + quiz_id = data.get('quiz_id') + intro_audio_name = data.get('intro_audio_name') + volume = data.get('volume', 100) + if not quiz_id or not intro_audio_name: + return jsonify({'error': 'クイズIDとイントロ音源名が必要です'}), 400 + + response = requests.post(f"{app.config['BOT_API_URL']}/start_quiz", json={'quiz_id': quiz_id, 'intro_audio_name': intro_audio_name, 'volume': volume}) + if response.status_code == 200: + return jsonify({'message': 'クイズを開始しました'}) + else: + return jsonify({'error': 'クイズの開始に失敗しました'}), response.status_code + +@app.route('/search_quiz', methods=['GET']) +@login_required +def search_quiz_route(): + title = request.args.get('title', '') + db = get_db() + results = db.query(QuizMaster).filter(QuizMaster.title.like(f'%{title}%')).all() + quizzes = [{'id': quiz.id, 'title': quiz.title, 'full_audio_name': quiz.full_audio_name, 'intro_audio_name': quiz.intro_audio_name, + 'keyword1': quiz.keyword1, 'keyword2': quiz.keyword2, 'keyword3': quiz.keyword3} for quiz in results] + return jsonify(quizzes) + +@app.route('/select_quiz', methods=['POST']) +@login_required +def select_quiz_route(): + data = request.json + quiz_id = data.get('quiz_id') + db = get_db() + quiz = db.query(QuizMaster).filter_by(id=quiz_id).first() + if not quiz: + return jsonify({'error': '指定されたIDのクイズが見つかりません'}), 404 + selected_quiz = { + 'id': quiz.id, + 'title': quiz.title, + 'full_audio_name': quiz.full_audio_name, + 'intro_audio_name': quiz.intro_audio_name, + 'keyword1': quiz.keyword1, + 'keyword2': quiz.keyword2, + 'keyword3': quiz.keyword3 + } + return jsonify(selected_quiz) + +@app.route('/upload_quiz_master', methods=['POST']) +@login_required +def upload_quiz_master_route(): + global uploaded_file_path + try: + if 'file' not in request.files: + logger.debug('No file part in the request') + flash('No file part') + return redirect(request.url) + file = request.files['file'] + if file.filename == '': + logger.debug('No selected file') + flash('No selected file') + return redirect(request.url) + if file and file.filename.endswith('.csv'): + uploaded_file_path = os.path.join(app.config['UPLOAD_FOLDER'], file.filename) + file.save(uploaded_file_path) + logger.debug(f'File {file.filename} uploaded successfully to {uploaded_file_path}') + flash('File uploaded successfully') + return redirect(url_for('quiz_master_page_route')) + logger.debug('Invalid file format') + flash('Invalid file format') + return redirect(request.url) + except Exception as e: + logger.error(f'Error during file upload: {str(e)}') + flash('Error during file upload') + return redirect(request.url) + +@app.route('/import_quiz_master', methods=['POST']) +@login_required +def import_quiz_master_route(): + global uploaded_file_path + try: + if uploaded_file_path and os.path.exists(uploaded_file_path): + logger.debug(f'Starting import of file {uploaded_file_path}') + process_csv(uploaded_file_path) + logger.debug('File imported successfully') + flash('File imported successfully') + uploaded_file_path = None # Reset after processing + return redirect(url_for('quiz_master_page_route')) + logger.debug('No file uploaded to import') + flash('No file uploaded to import') + return redirect(url_for('quiz_master_page_route')) + except Exception as e: + logger.error(f'Error during file import: {str(e)}') + flash('Error during file import') + return redirect(url_for('quiz_master_page_route')) + +def process_csv(file_path): + try: + with open(file_path, newline='', encoding='utf-8') as csvfile: + reader = csv.DictReader(csvfile) + db = get_db() + for row in reader: + # BOT格納状況が「格納済」のデータをスキップ + if row['BOT格納状況'] == '格納済': + logger.debug(f'Skipping row due to BOT格納状況 being 格納済: {row}') + continue + # 必要な情報がそろっているかチェック + if not (row['曲名'] and row['フル音源名'] and row['イントロ音源名']): + logger.debug(f'Skipping row due to missing information: {row}') + continue + # キーワード1からキーワード3のうちどれか1つが入力されているかチェック + if not (row['キーワード1(歌唱)'] or row['キーワード2(種別)'] or row['キーワード3']): + logger.debug(f'Skipping row due to missing keywords: {row}') + continue + # データベースに保存 + quiz_master = QuizMaster( + title=row['曲名'], + full_audio_name=f"{row['フル音源名']}.mp3", + intro_audio_name=f"{row['イントロ音源名']}.mp3", + keyword1=row['キーワード1(歌唱)'], + keyword2=row['キーワード2(種別)'], + keyword3=row['キーワード3'] + ) + db.add(quiz_master) + logger.debug(f'Added row to database: {row}') + db.commit() + logger.debug('CSV file processed and committed to database') + except Exception as e: + logger.error(f'Error processing CSV file: {str(e)}') + +if __name__ == "__main__": + try: + app.run(debug=True, host='0.0.0.0', port=80) + except ImportError as e: + logger.error(f"ImportErrorが発生しました: {e}") + traceback.print_exc(file=sys.stderr) + except Exception as e: + logger.error(f"例外が発生しました: {e}") + traceback.print_exc(file=sys.stderr) + diff --git a/web/auth.py b/web/auth.py new file mode 100644 index 0000000..c66ceda --- /dev/null +++ b/web/auth.py @@ -0,0 +1,64 @@ +from flask import request, session, redirect, url_for, render_template, g, jsonify +import bcrypt +import secrets +from db import get_db +from models import Account, ActiveSession +import logging + +logger = logging.getLogger(__name__) + +def login_required(f): + def decorated_function(*args, **kwargs): + if not check_login(session, logger): + return redirect(url_for('login_route')) + return f(*args, **kwargs) + decorated_function.__name__ = f.__name__ + return decorated_function + +def check_login(session, logger): + if 'logged_in' not in session or not session['logged_in']: + logger.info("セッションにログイン情報がありません") + return False + + db = get_db() + active_session_count = db.query(ActiveSession).filter_by(session_id=session['session_id']).count() + if active_session_count != 1: + logger.info(f"セッションID {session['session_id']} のアクティブセッション数: {active_session_count}") + return False + return True + +def login(): + if request.method == 'POST': + user_id = request.form['user_id'] + password = request.form['password'].encode('utf-8') + db = get_db() + account = db.query(Account).filter_by(user_id=user_id).first() + if account and bcrypt.checkpw(password, account.password.encode('utf-8')): + active_sessions = db.query(ActiveSession).count() + if active_sessions >= 1: + return "他のユーザーが既にログインしています。", 403 + session['logged_in'] = True + new_session = ActiveSession(session_id=session['session_id']) + db.add(new_session) + db.commit() + return redirect(url_for('index_route')) + else: + return "Invalid credentials", 403 + return render_template('login.html') + +def logout(): + db = get_db() + db.query(ActiveSession).filter_by(session_id=session['session_id']).delete() + db.commit() + session.clear() + return redirect(url_for('login_route')) + +def create_account(): + user_id = secrets.token_hex(8) + password = secrets.token_hex(16) + hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()) + db = get_db() + new_account = Account(user_id=user_id, password=hashed_password.decode('utf-8')) + db.add(new_account) + db.commit() + return jsonify({'message': 'アカウントが作成されました', 'user_id': user_id, 'password': password}) diff --git a/web/db.py b/web/db.py new file mode 100644 index 0000000..cc66732 --- /dev/null +++ b/web/db.py @@ -0,0 +1,30 @@ +import os +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, scoped_session +from flask import g, current_app +from models import Base, Account, ActiveSession, ChannelId, QuizMaster + +def get_engine(): + return create_engine(f"sqlite:///{current_app.config['DATABASE']}") + +def get_db(): + if 'db' not in g: + engine = get_engine() + g.db = scoped_session(sessionmaker(bind=engine)) + return g.db + +def close_connection(exception): + db = g.pop('db', None) + if db is not None: + db.remove() + +def initialize_db(logger): + engine = get_engine() + Base.metadata.create_all(engine) + logger.info("データベースの初期化が完了しました") + +def reset_active_sessions(logger): + db = get_db() + db.query(ActiveSession).delete() + db.commit() + logger.info("アクティブセッションをリセットしました") diff --git a/web/handlers.py b/web/handlers.py new file mode 100644 index 0000000..b4b6765 --- /dev/null +++ b/web/handlers.py @@ -0,0 +1,142 @@ +import os +import urllib.parse +from flask import request, jsonify, session, render_template, current_app +import requests +from db import get_db +from models import ChannelId, QuizMaster + +def handle_file_upload(): + if 'files' not in request.files: + return jsonify({'error': 'ファイルが見つかりません'}) + files = request.files.getlist('files') + if not files or files[0].filename == '': + return jsonify({'error': '選択されたファイルがありません'}) + + for file in files: + filename = file.filename + file.save(os.path.join(current_app.config['UPLOAD_FOLDER'], filename)) + + return jsonify({'message': 'ファイルがアップロードされました'}) + +def delete_file(filename): + filename = urllib.parse.unquote(filename) + file_path = os.path.join(current_app.config['UPLOAD_FOLDER'], filename) + if os.path.exists(file_path): + os.remove(file_path) + return jsonify({'message': 'ファイルが削除されました'}) + return jsonify({'error': 'ファイルが見つかりません'}) + +def play_file(filename, volume): + filename = urllib.parse.unquote(filename) + response = requests.post(f"{current_app.config['BOT_API_URL']}/play", json={'filename': filename, 'volume': volume}) + return jsonify(response.json()) + +def join_channel(): + channel_id = request.json.get('channel_id') + db = get_db() + new_channel = ChannelId(channel_id=channel_id) + db.add(new_channel) + db.commit() + response = requests.post(f"{current_app.config['BOT_API_URL']}/join", json={'channel_id': channel_id}) + return jsonify(response.json()) + +def leave_channel(): + response = requests.post(f"{current_app.config['BOT_API_URL']}/leave") + return jsonify(response.json()) + +def index(): + files = os.listdir(current_app.config['UPLOAD_FOLDER']) + db = get_db() + last_channel_id = db.query(ChannelId).order_by(ChannelId.id.desc()).first() + last_channel_id = last_channel_id.channel_id if last_channel_id else "" + return render_template('index.html', files=files, last_channel_id=last_channel_id) + +def get_files(): + files = os.listdir(current_app.config['UPLOAD_FOLDER']) + return jsonify(files) + +def get_quiz_master(): + db = get_db() + quiz_master_data = db.query(QuizMaster).all() + result = [] + for qm in quiz_master_data: + result.append({ + 'id': qm.id, + 'title': qm.title, + 'full_audio_name': qm.full_audio_name, + 'intro_audio_name': qm.intro_audio_name, + 'keyword1': qm.keyword1, + 'keyword2': qm.keyword2, + 'keyword3': qm.keyword3 + }) + return jsonify(result) + +def get_unique_keywords(): + db = get_db() + keywords = set() + quiz_master_data = db.query(QuizMaster).all() + for qm in quiz_master_data: + if qm.keyword1: + keywords.add(qm.keyword1) + if qm.keyword2: + keywords.add(qm.keyword2) + if qm.keyword3: + keywords.add(qm.keyword3) + sorted_keywords = sorted(keywords) + return jsonify(sorted_keywords) + +def update_quiz_master(): + data = request.json + db = get_db() + quiz_master = db.query(QuizMaster).filter_by(id=data['id']).first() + if quiz_master: + quiz_master.title = data['title'] + quiz_master.full_audio_name = data['full_audio_name'] + quiz_master.intro_audio_name = data['intro_audio_name'] + quiz_master.keyword1 = data['keyword1'] + quiz_master.keyword2 = data['keyword2'] + quiz_master.keyword3 = data['keyword3'] + db.commit() + return jsonify({'message': '更新が完了しました'}) + +def add_quiz_master(): + data = request.json + db = get_db() + new_quiz_master = QuizMaster( + title=data['title'], + full_audio_name=data['full_audio_name'], + intro_audio_name=data['intro_audio_name'], + keyword1=data['keyword1'], + keyword2=data['keyword2'], + keyword3=data['keyword3'] + ) + db.add(new_quiz_master) + db.commit() + return jsonify({'message': '追加が完了しました'}) + +def delete_quiz_master(): + data = request.json + db = get_db() + db.query(QuizMaster).filter_by(id=data['id']).delete() + db.commit() + return jsonify({'message': '削除が完了しました'}) + +def draw_random_quiz(): + data = request.json + keyword = data.get('keyword') + selected_quiz = random_quiz(keyword) + + if not selected_quiz: + return jsonify({'error': '利用可能なクイズがありません'}) + + result = { + 'id': selected_quiz.id, + 'title': selected_quiz.title, + 'full_audio_name': selected_quiz.full_audio_name, + 'intro_audio_name': selected_quiz.intro_audio_name, + 'keyword1': selected_quiz.keyword1, + 'keyword2': selected_quiz.keyword2, + 'keyword3': selected_quiz.keyword3 + } + + return jsonify(result) diff --git a/web/init_account.py b/web/init_account.py new file mode 100644 index 0000000..8268128 --- /dev/null +++ b/web/init_account.py @@ -0,0 +1,28 @@ +import os +import secrets +import bcrypt +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from models import Base, Account, ActiveSession, ChannelId + +def create_initial_account(db_path): + if not os.path.exists(os.path.dirname(db_path)): + os.makedirs(os.path.dirname(db_path), exist_ok=True) + + engine = create_engine(f"sqlite:///{db_path}") + Base.metadata.create_all(engine) + Session = sessionmaker(bind=engine) + session = Session() + + if session.query(Account).count() == 0: + user_id = secrets.token_hex(8) + password = secrets.token_hex(16) + hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()) + new_account = Account(user_id=user_id, password=hashed_password.decode('utf-8')) + session.add(new_account) + session.commit() + + with open("/web/data/initial_account.txt", "w") as f: + f.write(f"User ID: {user_id}\nPassword: {password}\n") + + session.close() diff --git a/web/models.py b/web/models.py new file mode 100644 index 0000000..196da50 --- /dev/null +++ b/web/models.py @@ -0,0 +1,31 @@ +from sqlalchemy import Column, Integer, String, TIMESTAMP +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() + +class Account(Base): + __tablename__ = 'accounts' + id = Column(Integer, primary_key=True) + user_id = Column(String) + password = Column(String) + +class ActiveSession(Base): + __tablename__ = 'active_sessions' + id = Column(Integer, primary_key=True) + session_id = Column(String) + last_active = Column(TIMESTAMP) + +class ChannelId(Base): + __tablename__ = 'channel_ids' + id = Column(Integer, primary_key=True) + channel_id = Column(String) + +class QuizMaster(Base): + __tablename__ = 'quiz_master' + id = Column(Integer, primary_key=True, autoincrement=True) + title = Column(String) + full_audio_name = Column(String) + intro_audio_name = Column(String) + keyword1 = Column(String) + keyword2 = Column(String) + keyword3 = Column(String) diff --git a/web/requirements.txt b/web/requirements.txt new file mode 100644 index 0000000..e51f5aa --- /dev/null +++ b/web/requirements.txt @@ -0,0 +1,5 @@ +Flask +werkzeug +requests +bcrypt +sqlalchemy diff --git a/web/templates/files.html b/web/templates/files.html new file mode 100644 index 0000000..0c515e2 --- /dev/null +++ b/web/templates/files.html @@ -0,0 +1,136 @@ + + + + + アップロードされたファイル一覧 + + + + +

アップロードされたファイル一覧

+
+ +
+ + + +
+ + + + + + + + + + + +
ファイル名アクション
+ + diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..0b920f4 --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,307 @@ + + + + + イントロクイズBOT管理ページ + + + + +

イントロクイズBOT管理ページ

+
+ + +

ボイスチャンネル操作

+
+ + + +
+
+ +
+
+ + +
+
+ + +
+

クイズ出題

+

グループランダム選択

+ + + +
+ +

タイトル検索

+
+
+ +
+
+
+
+ +

ログアウト

+
+ +
+ + diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..dea61f9 --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,17 @@ + + + + + ログイン + + +

ログイン

+
+ +
+ +
+ +
+ + diff --git a/web/templates/quiz_master.html b/web/templates/quiz_master.html new file mode 100644 index 0000000..458cd2f --- /dev/null +++ b/web/templates/quiz_master.html @@ -0,0 +1,273 @@ + + + + + クイズマスタデータ管理 + + + + +
+

クイズマスタデータ管理

+
+ +
+
+ + +
+
+ + + + +
+
+ + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+

CSVアップロード

+
+
+ + +
+
+ +
+
+
+
+ +
+
+
+ +
+

クイズマスタ一覧

+ + + + + + + + + + + + + + + +
タイトルフル音源名イントロ音源名キーワード1キーワード2キーワード3アクション
+
+ + +