From cdb89d5264136cb1d42409c5ac5ec4b1e58e7f74 Mon Sep 17 00:00:00 2001 From: hina ntki Date: Sat, 3 Aug 2024 20:18:28 +0900 Subject: [PATCH] first commit --- .gitignore | 181 ++++++++ Dockerfile | 20 + README.md | 2 + app/bot.py | 175 ++++++++ app/classes/CommandHandler.py | 154 +++++++ app/classes/IndependenceChat.py | 158 +++++++ app/classes/MessageReader.py | 101 +++++ app/classes/VoiceChannelManager.py | 28 ++ app/classes/VoiceSynthesizer.py | 104 +++++ app/classes/WordDictionary.py | 37 ++ app/config.py | 10 + app/utils/file_utils.py | 18 + app/web_server.py | 21 + config/_config.dev.json | 12 + config/_config.prod.json | 11 + docker-compose.yml | 25 ++ entrypoint.sh | 18 + requirements.txt | 8 + web/.gitignore | 1 + web/Dockerfile | 33 ++ web/README.md | 8 + web/package-lock.json | 659 +++++++++++++++++++++++++++++ web/package.json | 21 + web/public/index.html | 78 ++++ web/src/index.ts | 37 ++ web/tsconfig.json | 14 + 26 files changed, 1934 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/bot.py create mode 100644 app/classes/CommandHandler.py create mode 100644 app/classes/IndependenceChat.py create mode 100644 app/classes/MessageReader.py create mode 100644 app/classes/VoiceChannelManager.py create mode 100644 app/classes/VoiceSynthesizer.py create mode 100644 app/classes/WordDictionary.py create mode 100644 app/config.py create mode 100644 app/utils/file_utils.py create mode 100644 app/web_server.py create mode 100644 config/_config.dev.json create mode 100644 config/_config.prod.json create mode 100644 docker-compose.yml create mode 100644 entrypoint.sh create mode 100644 requirements.txt create mode 100644 web/.gitignore create mode 100644 web/Dockerfile create mode 100644 web/README.md create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/public/index.html create mode 100644 web/src/index.ts create mode 100644 web/tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c60ab4c --- /dev/null +++ b/.gitignore @@ -0,0 +1,181 @@ +# 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 + +config/config.dev.json +config/config.prod.json +config/system_setting.txt +config/word_dictionary.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..887d659 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# Dockerfile +FROM python:3.10-slim + +# 基本パッケージのインストール +RUN apt-get update && apt-get install -y \ + ffmpeg + +# Pythonパッケージのインストール +COPY requirements.txt /app/requirements.txt +RUN pip install -r /app/requirements.txt + +# アプリケーションのコピー +COPY . /app +WORKDIR /app + +# エントリーポイントスクリプトをコピーして実行権限を付与 +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +ENTRYPOINT ["entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..64b0830 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# メモ +- FFmpegが動作できる環境が必要 diff --git a/app/bot.py b/app/bot.py new file mode 100644 index 0000000..ac005b4 --- /dev/null +++ b/app/bot.py @@ -0,0 +1,175 @@ +import discord +import signal +import asyncio +import logging +import os +from utils.file_utils import read_text +from config import Config +from classes.VoiceSynthesizer import VoiceSynthesizer +from classes.WordDictionary import WordDictionary +from classes.CommandHandler import CommandHandler +from classes.VoiceChannelManager import VoiceChannelManager +from classes.MessageReader import MessageReader +from classes.IndependenceChat import IndependenceChat +from threading import Thread +from flask import Flask, request, jsonify + +logging.basicConfig(level=logging.INFO) + +class MyDiscordBot(discord.Client): + def __init__(self, config, intents, system_setting_file_path="config/system_setting.txt"): + super().__init__(intents=intents) + self.config = config + self.system_setting_file_path = system_setting_file_path + self.voice_synthesizer = VoiceSynthesizer( + config["voicepeak_executable_path"], + config["voicevox_url"], + config["default_navigator"] + ) + self.word_dictionary = WordDictionary() + self.prefix = config["prefix"] + self.target_channel_id = None + self.volume = 1.0 + self.emotion_happy = 50 + self.emotion_sad = 50 + self.emotion_angry = 0 + self.emotion_fun = 0 + self.is_name_reading = False + self.is_independence_chat = False + self.independence_chat = IndependenceChat(self) + self.command_handler = CommandHandler(self) + self.voice_channel_manager = VoiceChannelManager() + self.message_reader = MessageReader(self.voice_synthesizer, self) + + self.independence_channel_ids = config["independence_channel_ids"] + self.independence_timers = [] + + # デバッグモードのチェック + self.debug_mode_file = "debug_mode.txt" + self.debug_mode_enabled = os.path.exists(self.debug_mode_file) + + # シグナルハンドラを設定 + signal.signal(signal.SIGINT, self.shutdown) + signal.signal(signal.SIGTERM, self.shutdown) + + # Flaskサーバーを別スレッドで起動 + self.start_flask_server() + + def start_flask_server(self): + app = Flask(__name__) + + @app.route('/debug', methods=['POST']) + def toggle_debug(): + data = request.json + if 'enable' not in data: + return jsonify({'error': 'Invalid request'}), 400 + + if data['enable']: + open(self.debug_mode_file, 'w').close() + self.debug_mode_enabled = True + logging.info("デバッグモードが有効になりました。") + asyncio.run_coroutine_threadsafe(self.independence_chat.start_debug_mode_check(), self.loop) + else: + if os.path.exists(self.debug_mode_file): + os.remove(self.debug_mode_file) + self.debug_mode_enabled = False + logging.info("デバッグモードが無効になりました。") + + return jsonify({'success': True}) + + def run_flask(): + app.run(host='0.0.0.0', port=5000) + + flask_thread = Thread(target=run_flask) + flask_thread.daemon = True + flask_thread.start() + + def reset_emotion(self): + """感情の設定をリセットし、音声合成器に反映させる""" + self.emotion_happy = 50 + self.emotion_sad = 50 + self.emotion_angry = 0 + self.emotion_fun = 0 + self.voice_synthesizer.set_emotion(self.emotion_happy, self.emotion_sad, self.emotion_angry, self.emotion_fun) + self.voice_synthesizer.set_pitch(50) + + async def on_ready(self): + """ボットが準備完了時に実行されるイベントハンドラ""" + self.voice_synthesizer.set_emotion(self.emotion_happy, self.emotion_sad, self.emotion_angry, self.emotion_fun) + self.voice_synthesizer.set_pitch(50) + await self.set_bot_status(f"タイキチュウ… | {self.prefix}help") + logging.info(f'{self.user} がログインしました') + + # 独立チャットモードの自動参加を開始 + await self.independence_chat.start_independence_mode_check() + + async def on_message(self, message): + """メッセージ受信時に実行されるイベントハンドラ""" + await self.command_handler.handle_message(message) + + async def on_voice_state_update(self, member, before, after): + """ボイスチャンネルの状態が更新された時に実行されるイベントハンドラ""" + try: + if before.channel is None and after.channel is not None: + # ユーザーがVCに参加した場合 + if self.is_independence_chat and after.channel == self.voice_channel_manager.get_connected_channel(member.guild): + greeting = await self.independence_chat.generate_greeting(member.display_name) + await self.message_reader.read_message(greeting, self.word_dictionary, self.config["ffmpeg_executable_path"], self.volume) + elif before.channel and not after.channel and before.channel == self.voice_channel_manager.get_connected_channel(member.guild): + # ユーザーがVCから退出した場合 + members = [m for m in before.channel.members if m != self.user] + if not members: + self.target_channel_id = None + await self.voice_channel_manager.leave_voice_channel(before.channel.guild, self) + except Exception as e: + logging.error(f"Failed to update voice state: {e}") + raise + + def is_valid_channel(self, message): + """メッセージが有効なチャンネルであるかどうかを確認する""" + if self.target_channel_id and message.channel.id != self.target_channel_id: + return False + if not message.guild.voice_client or not message.guild.voice_client.is_connected(): + return False + return True + + def enable_independence_chat(self, voice_client): + """独立チャット機能を有効にする""" + system_setting = read_text(self.system_setting_file_path) + self.is_independence_chat = True + self.independence_chat.enable(system_setting, voice_client, self.config["independence_character_name"]) + + def disable_independence_chat(self): + """独立チャット機能を無効にする""" + self.is_independence_chat = False + self.independence_chat.disable() + + def shutdown(self, signum, frame): + """シグナルを受け取ってシャットダウンする""" + logging.info("シャットダウン中...") + asyncio.create_task(self.cleanup()) + + async def cleanup(self): + """クリーンアップ処理""" + for guild in self.guilds: + if guild.voice_client and guild.voice_client.is_connected(): + await self.voice_channel_manager.leave_voice_channel(guild, self) + await self.close() + + async def set_bot_status(self, status): + """ステータスを変更する""" + activity = None + if status: + activity = discord.Activity(type=discord.ActivityType.listening, name=status) + await self.change_presence(activity=activity) + +if __name__ == "__main__": + config = Config.load_config() + intents = discord.Intents.default() + intents.guilds = True + intents.messages = True + intents.message_content = True + intents.voice_states = True + + bot = MyDiscordBot(config, intents) + bot.run(config["discord_token"]) diff --git a/app/classes/CommandHandler.py b/app/classes/CommandHandler.py new file mode 100644 index 0000000..833c438 --- /dev/null +++ b/app/classes/CommandHandler.py @@ -0,0 +1,154 @@ +import logging +from classes.VoiceChannelManager import VoiceChannelManager + +class CommandHandler: + def __init__(self, bot): + """CommandHandlerクラスの初期化を行う""" + self.bot = bot + self.voice_channel_manager = VoiceChannelManager() + + async def handle_message(self, message): + """受信したメッセージを処理する""" + if message.author.bot: + return + + try: + if not message.content.startswith(self.bot.prefix): + if not self.bot.is_valid_channel(message): + return + + if self.bot.is_independence_chat: + input_message = f"{message.author.display_name}: {message.content}" + await self.bot.independence_chat.input_message(input_message) + else: + await self.handle_synthesize(message) + return + + command, *args = message.content[len(self.bot.prefix):].split() + command_handler = getattr(self, f'handle_{command}', None) + if command_handler: + await command_handler(message, args) + else: + await message.reply(f"不明なコマンドです。\n`{self.bot.prefix}help`で使用可能なコマンドを確認してください。") + except Exception as e: + logging.info(f"Failed to process message: {e}") + raise + + async def handle_join(self, message, args): + """ボイスチャンネルに接続する""" + if message.author.voice and message.author.voice.channel: + if message.guild.voice_client and message.guild.voice_client.is_connected(): + ("他のボイスチャンネルに接続しています。") + return + self.bot.target_channel_id = message.channel.id + await self.voice_channel_manager.join_voice_channel(message.author.voice.channel) + await self.bot.set_bot_status("オシゴトチュウ…") + else: + await message.reply("ボイスチャンネルに接続してください。") + + async def handle_ijoin(self, message, args): + """ボイスチャンネルに接続して独立チャットを有効にする""" + await self.handle_join(message, args) + await self.handle_ichat(message, args, log=False) + + async def handle_leave(self, message, args): + """ボイスチャンネルから切断する""" + self.bot.volume = 1.0 + self.bot.reset_emotion() + self.bot.target_channel_id = None + self.bot.is_name_reading = True + self.bot.disable_independence_chat() + await self.bot.set_bot_status(f"タイキチュウ… | {self.bot.prefix}help") + await self.voice_channel_manager.leave_voice_channel(message.guild, self.bot) + + async def handle_volume(self, message, args): + """ボットの音量を設定する""" + try: + if not self.bot.is_valid_channel(message): + return + new_volume = float(args[0]) + self.bot.volume = max(0.0, min(1.0, new_volume)) + await message.reply(f"音量を{self.bot.volume}に設定しました。") + except (IndexError, ValueError): + await message.reply(f"正しい音量を指定してください。例: `{self.bot.prefix}volume 0.5`") + + async def handle_emotion(self, message, args): + """ボットの感情を設定する""" + try: + if not self.bot.is_valid_channel(message): + return + self.bot.emotion_happy = int(args[0]) + self.bot.emotion_sad = int(args[1]) + self.bot.emotion_angry = int(args[2]) + self.bot.emotion_fun = int(args[3]) + self.bot.voice_synthesizer.set_emotion(self.bot.emotion_happy, self.bot.emotion_sad, self.bot.emotion_angry, self.bot.emotion_fun) + await message.reply(f"感情を設定しました。\nhappy={self.bot.emotion_happy}, sad={self.bot.emotion_sad}, angry={self.bot.emotion_angry}, fun={self.bot.emotion_fun}") + except (IndexError, ValueError): + await message.reply(f"正しい感情を指定してください。\n例: `{self.bot.prefix}emotion 100 0 0 0`") + + async def handle_pitch(self, message, args): + """ボットの音程を設定する""" + try: + if not self.bot.is_valid_channel(message): + return + new_pitch = int(args[0]) + self.bot.pitch = max(-300, min(300, new_pitch)) + self.bot.voice_synthesizer.set_pitch(self.bot.pitch) + await message.reply(f"音程を{self.bot.pitch}に設定しました。") + except (IndexError, ValueError): + await message.reply(f"正しい音程を指定してください。\n例: `{self.bot.prefix}pitch 50`") + + async def handle_dictionary(self, message, args): + """単語の辞書登録を行う""" + try: + if not self.bot.is_valid_channel(message): + return + original_word, converted_word = args + self.bot.word_dictionary.register_word(original_word, converted_word) + await message.reply(f"'{original_word}'を'{converted_word}'として登録しました。") + except ValueError: + await message.reply(f"正しい形式で単語を指定してください。\n例: `{self.bot.prefix}dictionary 単語 読み方`") + + async def handle_rname(self, message, args): + """名前の読み上げ機能のオン・オフを切り替える""" + self.bot.is_name_reading = not self.bot.is_name_reading + await message.reply(f"名前の読み上げを{'有効' if self.bot.is_name_reading else '無効'}にしました。") + + async def handle_ichat(self, message, args, log=True): + """独立チャット機能のオン・オフを切り替える""" + if not self.bot.is_valid_channel(message): + return + + bot_admin_user_id = self.bot.config["bot_admin_user_id"] + if not message.author.id == bot_admin_user_id: + await message.reply("権限がありません。") + return + + if self.bot.is_independence_chat: + self.bot.disable_independence_chat() + if log: + await message.reply("独立チャットを無効にしました。") + await self.bot.set_bot_status("オシゴトチュウ…") + else: + self.bot.enable_independence_chat(message.guild.voice_client) + await self.bot.set_bot_status("オハナシチュウ…") + if log: + await message.reply("独立チャットを有効にしました。") + + async def handle_help(self, message, args): + """ヘルプメッセージを表示する""" + help_message = (f"**ボットの使い方**\n" + f"> {self.bot.prefix}join: ボイスチャンネルに接続します。\n" + f"> {self.bot.prefix}leave: ボイスチャンネルから切断します。\n" + f"> {self.bot.prefix}volume: 音量を設定します。\n> \t例: `{self.bot.prefix}volume 0.5`\n" + f"> {self.bot.prefix}emotion: 感情を設定します。\n> \t例: `{self.bot.prefix}emotion 100 0 0 0`\n" + f"> {self.bot.prefix}pitch: 音程を設定します。\n> \t例: `{self.bot.prefix}pitch 50`\n" + f"> {self.bot.prefix}dictionary: 単語の辞書登録を行います。\n> \t例: `{self.bot.prefix}dictionary 単語 読み方`\n" + f"> {self.bot.prefix}rname: 名前の読み上げをするかどうかを変更します。\n" + f"> {self.bot.prefix}help: ヘルプを表示します。\n") + await message.reply(help_message) + + async def handle_synthesize(self, message): + """メッセージの音声合成を行う""" + logging.info(f"Synthesizing message: {message.content}") + await self.bot.message_reader.read_message(message, self.bot.word_dictionary, self.bot.config["ffmpeg_executable_path"], self.bot.volume) diff --git a/app/classes/IndependenceChat.py b/app/classes/IndependenceChat.py new file mode 100644 index 0000000..e5a790a --- /dev/null +++ b/app/classes/IndependenceChat.py @@ -0,0 +1,158 @@ +import openai +import logging +import random +from datetime import datetime, timedelta +import asyncio + +class IndependenceChat: + def __init__(self, bot): + """IndependenceChatクラスの初期化を行う""" + self.bot = bot + openai.api_key = self.bot.config["openai_api_key"] + self.voice_client = None + self.independence_character_name = None + self.system = None + self.input_list = [] + self.logs = [] + self.independence_timers = [] + self.log_file_path = "independence_chat_log.txt" + + def enable(self, system_setting, voice_client, independence_character_name): + """独立チャット機能を有効にする""" + self.system = {"role": "system", "content": system_setting} + self.input_list = [self.system] + self.voice_client = voice_client + self.independence_character_name = independence_character_name + self.log("独立チャットモードを有効にしました。") + + def disable(self): + """独立チャット機能を無効にする""" + self.voice_client = None + self.independence_character_name = None + self.system = None + self.input_list = [] + self.log("独立チャットモードを無効にしました。") + + async def input_message(self, input_text): + """ユーザーからの入力メッセージを処理し、応答を生成する""" + self.input_list.append({"role": "user", "content": input_text}) + self.logs.append(input_text) + self.log(input_text) + + response = openai.ChatCompletion.create( + model="gpt-3.5-turbo", + messages=self.input_list + ) + + # 応答の最初の選択肢を取得し、メッセージ内容を取り出す + result = response.choices[0].message['content'] + surgery_result = f"{self.independence_character_name}: {result}" + self.logs.append(surgery_result) + self.log(surgery_result) + + self.input_list.append({"role": "assistant", "content": result}) + + logging.info("最新の3件の会話ログ:") + for log_entry in self.input_list[-3:]: + logging.info(log_entry) + + await self.bot.message_reader.read_message( + result, self.bot.word_dictionary, self.bot.config["ffmpeg_executable_path"], self.bot.volume + ) + + async def start_independence_mode_check(self): + """独立チャットモードでランダムにVCに参加する処理を開始""" + if self.independence_timers: + for timer in self.independence_timers: + timer.cancel() + self.independence_timers = [] + + # 24時間を時間帯によってランダムにチェックする時間を設定 + now = datetime.now() + for hour in range(24): + if 18 <= hour or hour < 2: # 18時から2時の間 + checks_per_hour = 2 # 1時間に2回チェック + else: + checks_per_hour = 1 # 2時間に1回チェック + + for _ in range(checks_per_hour): + target_time = now.replace(hour=hour, minute=random.randint(0, 59), second=0, microsecond=0) + if target_time < now: + target_time += timedelta(days=1) + delay = (target_time - now).total_seconds() + timer = self.bot.loop.call_later(delay, self.bot.loop.create_task, self.check_independence_mode()) + self.independence_timers.append(timer) + + async def check_independence_mode(self): + """独立チャットモードでランダムにVCに参加する""" + try: + if self.bot.voice_channel_manager.get_connected_channel(self.bot.guilds[0]) is not None: + logging.info("すでにVCに参加しています。自動参加をキャンセルします。") + self.log("VCに参加しようとしましたが、既に他のVCに参加していました。") + return + + channel = random.choice(self.bot.independence_channel_ids) + voice_channel = self.bot.get_channel(channel) + if voice_channel is None or not hasattr(voice_channel, 'members'): + logging.warning("Voice channel not found or has no members attribute") + return + if len(voice_channel.members) == 0: + dummy_message = type('Dummy', (object,), { + "author": type('Author', (object,), { + "voice": type('VoiceState', (object,), {"channel": voice_channel})(), + "display_name": "IndependenceBot" + })(), + "channel": type('Channel', (object,), {"id": None})(), + "guild": voice_channel.guild + })() + await self.bot.command_handler.handle_ijoin(dummy_message, []) + self.bot.loop.create_task(self.leave_if_empty(voice_channel)) + else: + logging.info("Voice channel has members") + self.log("VCに参加しようとしましたが、既に誰かが参加していました。") + finally: + await self.start_independence_mode_check() # タイマーを再設定 + + async def leave_if_empty(self, voice_channel): + """一定時間誰も来なければVCを退出する""" + await asyncio.sleep(600) # 10分待つ + if len(voice_channel.members) == 0: + await self.bot.voice_channel_manager.set_vc_status(self.bot, None) + + def start_debug_mode_check(self): + """デバッグモードでランダムにVCに参加する処理を開始""" + if self.independence_timers: + for timer in self.independence_timers: + timer.cancel() + self.independence_timers = [] + + # 即時にVCに参加する + self.bot.loop.create_task(self.check_independence_mode()) + + async def generate_greeting(self, username): + """ユーザーに対する挨拶メッセージを生成する""" + self.log(f"{username}が会話に参加しました。") + self.input_list.append({"role": "user", "content": f"{username}が会話に参加しました。挨拶メッセージを生成してください。"}) + + response = openai.ChatCompletion.create( + model="gpt-3.5-turbo", + messages=self.input_list + ) + + # 応答の最初の選択肢を取得し、メッセージ内容を取り出す + greeting = response.choices[0].message['content'] + self.logs.append(f"{self.independence_character_name}: {greeting}") + + self.input_list.append({"role": "assistant", "content": greeting}) + self.log(greeting) + + logging.info("最新の3件の会話ログ:") + for log_entry in self.input_list[-3:]: + logging.info(log_entry) + + return greeting + + def log(self, message): + """ログメッセージをファイルに出力する""" + with open(self.log_file_path, "a", encoding="utf-8") as log_file: + log_file.write(f"{datetime.now()}: {message}\n") diff --git a/app/classes/MessageReader.py b/app/classes/MessageReader.py new file mode 100644 index 0000000..f89837d --- /dev/null +++ b/app/classes/MessageReader.py @@ -0,0 +1,101 @@ +import uuid +import discord +import os +import re +import asyncio +import logging + +class MessageReader: + # 半角カタカナと数字と全角へのマッピング + half_width = "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲンァィゥェォッャュョー" + full_width = "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲンアイウエオッヤユヨー" + translation_table = str.maketrans(half_width, full_width) + + # 濁点・半濁点を含む半角カタカナの変換マッピング + dakuten_map = { + 'ガ': 'ガ', 'ギ': 'ギ', 'グ': 'グ', 'ゲ': 'ゲ', 'ゴ': 'ゴ', + 'ザ': 'ザ', 'ジ': 'ジ', 'ズ': 'ズ', 'ゼ': 'ゼ', 'ゾ': 'ゾ', + 'ダ': 'ダ', 'ヂ': 'ヂ', 'ヅ': 'ヅ', 'デ': 'デ', 'ド': 'ド', + 'バ': 'バ', 'ビ': 'ビ', 'ブ': 'ブ', 'ベ': 'ベ', 'ボ': 'ボ', + 'パ': 'パ', 'ピ': 'ピ', 'プ': 'プ', 'ペ': 'ペ', 'ポ': 'ポ' + } + + def __init__(self, voice_synthesizer, bot): + self.voice_synthesizer = voice_synthesizer + self.queue = asyncio.Queue() + self.is_reading = False + self.bot = bot + + async def read_message(self, message, word_dictionary, ffmpeg_executable_path, volume=1.0): + """メッセージを読み上げるための処理を開始する""" + await self.queue.put((message, word_dictionary, ffmpeg_executable_path, volume)) + if not self.is_reading: + await self._process_queue() + + async def _process_queue(self): + """メッセージキューを処理する""" + self.is_reading = True + while not self.queue.empty(): + message, word_dictionary, ffmpeg_executable_path, volume = await self.queue.get() + try: + await self._synthesize_and_play(message, word_dictionary, ffmpeg_executable_path, volume) + except Exception as e: + logging.error(f"Failed to read message: {e}") + self.is_reading = False + + def _convert_to_full_width(self, text): + """テキスト内の半角カタカナを全角カタカナに変換する""" + for half, full in self.dakuten_map.items(): + text = text.replace(half, full) + text = text.translate(self.translation_table) + return text + + async def _synthesize_and_play(self, message, word_dictionary, ffmpeg_executable_path, volume): + """メッセージの音声合成を行い、再生する""" + if not isinstance(message, str): + if self.bot.is_name_reading: + message.content = f'{message.author.display_name}さん、{message.content}' + + for user in message.mentions: + message.content = message.content.replace(f'<@{user.id}>', user.display_name) + message.content = message.content.replace(f'<@!{user.id}>', user.display_name) + + for role in message.role_mentions: + message.content = message.content.replace(f'<@&{role.id}>', role.name) + + for channel in message.channel_mentions: + message.content = message.content.replace(f'<#{channel.id}>', f'#{channel.name}') + + message.content = re.sub(r'https?://[\w/:%#\$&\?\(\)~\.=\+\\-]+', 'URL省略', message.content) + + message.content = re.sub(r'<:[a-zA-Z0-9_]+:[0-9]+>', '', message.content) + + message.content = self._convert_to_full_width(message.content) + + message.content = message.content.replace('\n', ' ') + + voice_client = message.guild.voice_client + play_message = message.content + id = message.id + else: + voice_client = self.bot.independence_chat.voice_client + play_message = message + id = str(uuid.uuid4()) + + if voice_client and voice_client.is_connected(): + script = word_dictionary.convert_text(play_message) + if len(script) > 140: + script = script[:140] + + output_path = f"{id}.wav" + output_path = await self.voice_synthesizer.synthesize(script, output_path=output_path) + + if not os.path.exists(output_path): + logging.error(f"Expected audio file not found: {output_path}") + return + + audio_source = discord.FFmpegPCMAudio(executable=ffmpeg_executable_path, source=output_path) + volume_adjusted_source = discord.PCMVolumeTransformer(audio_source, volume=volume) + voice_client.play(volume_adjusted_source, after=lambda e: os.remove(output_path)) + while voice_client.is_playing(): + await asyncio.sleep(1) diff --git a/app/classes/VoiceChannelManager.py b/app/classes/VoiceChannelManager.py new file mode 100644 index 0000000..6b61569 --- /dev/null +++ b/app/classes/VoiceChannelManager.py @@ -0,0 +1,28 @@ +import logging + +class VoiceChannelManager: + async def join_voice_channel(self, channel): + """ボイスチャンネルに接続する""" + try: + await channel.connect() + except Exception as e: + logging.info(f"Failed to join voice channel: {e}") + raise + + async def leave_voice_channel(self, guild, bot=None): + """ボイスチャンネルから切断する""" + try: + if guild.voice_client is not None: + await guild.voice_client.disconnect() + if bot is not None: + bot.volume = 1.0 + bot.reset_emotion() + except Exception as e: + logging.info(f"Failed to leave voice channel: {e}") + raise + + def get_connected_channel(self, guild): + """ボットが接続しているボイスチャンネルを取得する""" + if guild.voice_client and guild.voice_client.is_connected(): + return guild.voice_client.channel + return None diff --git a/app/classes/VoiceSynthesizer.py b/app/classes/VoiceSynthesizer.py new file mode 100644 index 0000000..4ff2e94 --- /dev/null +++ b/app/classes/VoiceSynthesizer.py @@ -0,0 +1,104 @@ +import os +import aiohttp +import asyncio +import logging +import subprocess + +class VoiceSynthesizer: + def __init__(self, voicepeak_path, voicevox_url, narrator): + self.voicepeak_path = voicepeak_path + self.voicevox_url = voicevox_url + self.narrator = narrator + self.pitch = 50 + self.emotion_happy = 0 + self.emotion_sad = 0 + self.emotion_angry = 0 + self.emotion_fun = 0 + + def set_emotion(self, happy, sad, angry, fun): + """ボットの感情を設定する""" + self.emotion_happy = happy + self.emotion_sad = sad + self.emotion_angry = angry + self.emotion_fun = fun + + def set_pitch(self, pitch): + """ボットの音程を設定する""" + self.pitch = pitch + + async def synthesize(self, script, output_path="output.wav"): + """スクリプトの音声合成を行う""" + if self.voicepeak_path: + return self._synthesize_with_voicepeak(script, output_path) + else: + return await self._synthesize_with_voicevox(script, output_path) + + def _synthesize_with_voicepeak(self, script, output_path): + args = { + 'voicepeak_path': self.voicepeak_path, + 'script': script, + 'narrator': self.narrator, + 'output_path': output_path, + 'emotions': f"happy={self.emotion_happy},sad={self.emotion_sad},angry={self.emotion_angry},fun={self.emotion_fun}", + 'pitch': self.pitch, + } + command_args = self._build_command_args(args) + + try: + subprocess.run(command_args, check=True) + except subprocess.CalledProcessError as e: + error_message = self._format_error_message(e) + logging.error(error_message) + raise RuntimeError(error_message) from e + + if not os.path.exists(output_path): + error_message = f"Expected audio file not found: {output_path}" + logging.error(error_message) + raise FileNotFoundError(error_message) + + return output_path + + async def _synthesize_with_voicevox(self, script, output_path): + async with aiohttp.ClientSession() as session: + url_audio_query = f'{self.voicevox_url}/audio_query?speaker={self.narrator}&text={script}' + + async with session.post(url_audio_query) as response: + if response.status != 200: + logging.error(f"Error in audio_query: {await response.text()}") + return None + query = await response.json() + + url_synthesis = f'{self.voicevox_url}/synthesis' + params_synthesis = { + "speaker": self.narrator + } + headers = { + "Content-Type": "application/json" + } + + async with session.post(url_synthesis, headers=headers, params=params_synthesis, json=query) as response: + if response.status != 200: + logging.error(f"Error in synthesis: {await response.text()}") + return None + audio_data = await response.read() + + with open(output_path, 'wb') as f: + f.write(audio_data) + + return output_path + + def _build_command_args(self, args): + """音声合成コマンドの引数を構築する""" + return [ + args['voicepeak_path'], + "-s", args['script'], + "-n", args['narrator'], + "-o", args['output_path'], + "-e", args['emotions'], + "--pitch", str(args['pitch']), + ] + + def _format_error_message(self, e): + """エラーメッセージをフォーマットする""" + stderr_output = e.stderr.decode('utf-8') if e.stderr else 'No stderr output' + return f"Voicepeak failed with error code {e.returncode}: {stderr_output}" diff --git a/app/classes/WordDictionary.py b/app/classes/WordDictionary.py new file mode 100644 index 0000000..09bc988 --- /dev/null +++ b/app/classes/WordDictionary.py @@ -0,0 +1,37 @@ +import logging +from utils.file_utils import read_json, write_json + + +class WordDictionary: + def __init__(self, dictionary=None, file_path='config/word_dictionary.json'): + """WordDictionaryクラスの初期化を行う""" + self.dictionary = dictionary if dictionary else {} + self.file_path = file_path + self.load_from_json() + + def register_word(self, word, reading): + """単語を辞書に登録する""" + self.dictionary[word] = reading + self.save_to_json() + + def convert_text(self, text): + """テキスト内の単語を辞書を使って変換する""" + for word in sorted(self.dictionary, key=len, reverse=True): + reading = self.dictionary[word] + text = text.replace(word, reading) + return text + + def save_to_json(self): + """辞書をJSONファイルに保存する""" + write_json(self.dictionary, self.file_path) + + def load_from_json(self): + """JSONファイルから辞書を読み込む""" + try: + self.dictionary = read_json(self.file_path) + except FileNotFoundError: + write_json({}, self.file_path) + self.dictionary = {} + except Exception as e: + logging.info(f"Failed to load dictionary from JSON: {e}") + raise Exception(f"Failed to load dictionary from JSON: {e}") diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..1172924 --- /dev/null +++ b/app/config.py @@ -0,0 +1,10 @@ +import json +import os + +class Config: + @staticmethod + def load_config(): + env = os.getenv('ENV', 'dev') + config_file = f"config/config.{env}.json" + with open(config_file, "r", encoding="utf-8") as f: + return json.load(f) diff --git a/app/utils/file_utils.py b/app/utils/file_utils.py new file mode 100644 index 0000000..14f2036 --- /dev/null +++ b/app/utils/file_utils.py @@ -0,0 +1,18 @@ +import json + + +def read_json(file_path): + """指定されたファイルパスからJSONデータを読み込む""" + with open(file_path, 'r', encoding='utf-8') as f: + return json.load(f) + + +def write_json(data, file_path): + """データを指定されたファイルパスにJSON形式で書き込む""" + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=4) + +def read_text(file_path): + """指定されたファイルパスからテキストを読み込んで文字列で返す""" + with open(file_path, 'r', encoding='utf-8') as f: + return f.read() diff --git a/app/web_server.py b/app/web_server.py new file mode 100644 index 0000000..24208d0 --- /dev/null +++ b/app/web_server.py @@ -0,0 +1,21 @@ +from flask import Flask, request, jsonify +import os + +app = Flask(__name__) + +@app.route('/debug', methods=['POST']) +def toggle_debug(): + data = request.json + if 'enable' not in data: + return jsonify({'error': 'Invalid request'}), 400 + + if data['enable']: + open('debug_mode.txt', 'w').close() + else: + if os.path.exists('debug_mode.txt'): + os.remove('debug_mode.txt') + + return jsonify({'success': True}) + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000) diff --git a/config/_config.dev.json b/config/_config.dev.json new file mode 100644 index 0000000..b56f63d --- /dev/null +++ b/config/_config.dev.json @@ -0,0 +1,12 @@ +{ + "prefix": "!", + "voicevox_url": "http://voicevox_engine:50021", + "discord_token": "", + "openai_api_key": "", + "default_navigator": "8", + "ffmpeg_executable_path": "/usr/bin/ffmpeg", + "voicepeak_executable_path": "", + "independence_character_name": "", + "independence_channel_ids": [123456789012345678, 234567890123456789], + "bot_admin_user_id": 123456789012345678 +} diff --git a/config/_config.prod.json b/config/_config.prod.json new file mode 100644 index 0000000..a0722bf --- /dev/null +++ b/config/_config.prod.json @@ -0,0 +1,11 @@ +{ + "prefix": "!", + "discord_token": "", + "openai_api_key": "", + "default_navigator": "", + "ffmpeg_executable_path": "", + "voicepeak_executable_path": "", + "independence_character_name": "", + "independence_channel_ids": [123456789012345678, 234567890123456789], + "bot_admin_user_id": 123456789012345678 +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5cbd6bb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +version: '3.8' + +services: + voicevox_engine: + image: voicevox/voicevox_engine:cpu-ubuntu20.04-latest + ports: + - "50021:50021" + + bot: + build: + context: . + environment: + - ENV=dev + depends_on: + - voicevox_engine + ports: + - "5000:5000" + + web: + build: + context: ./web + ports: + - "3000:3000" + depends_on: + - bot diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..dafcdca --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# エントリーポイントスクリプト +# Voicevoxエンジンの起動 +run_voicevox & + +# ボットの実行 +python /app/app/bot.py & + +# 子プロセス(Voicevoxエンジンとボット)のPIDを取得 +VOICEVOX_PID=$! +BOT_PID=$! + +# シグナルハンドラを設定 +trap "echo 'シャットダウン中...' && kill -SIGTERM $VOICEVOX_PID $BOT_PID && wait $VOICEVOX_PID $BOT_PID" SIGINT SIGTERM + +# 子プロセスが終了するのを待つ +wait $VOICEVOX_PID $BOT_PID diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f001199 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +pygame +py-cord +PyNaCl +openai==0.28 +requests +discord.py +aiohttp +flask diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/web/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..514644e --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,33 @@ +# ベースイメージとしてnodeを使用 +FROM node:20-alpine AS base + +FROM base AS builder + +RUN apk add --no-cache libc6-compat +WORKDIR /app + +COPY package*.json tsconfig.json ./ +COPY src ./src +COPY public ./public + +RUN npm ci && \ + npm run build && \ + npm prune --production + +FROM base AS runner +WORKDIR /app + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 hono + +COPY --from=builder /app/node_modules /app/node_modules +COPY --from=builder /app/dist /app/dist +COPY --from=builder /app/public /app/public +COPY --from=builder /app/package.json /app/package.json + +RUN chown -R hono:nodejs /app + +USER hono +EXPOSE 3000 + +CMD ["node", "/app/dist/index.js"] diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..e12b31d --- /dev/null +++ b/web/README.md @@ -0,0 +1,8 @@ +``` +npm install +npm run dev +``` + +``` +open http://localhost:3000 +``` diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..da27344 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,659 @@ +{ + "name": "web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.11.1", + "axios": "^1.4.0", + "hono": "^4.3.7" + }, + "devDependencies": { + "@types/node": "^20.11.17", + "tsx": "^4.7.1", + "typescript": "^4.5.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@hono/node-server": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.11.1.tgz", + "integrity": "sha512-GW1Iomhmm1o4Z+X57xGby8A35Cu9UZLL7pSMdqDBkD99U5cywff8F+8hLk5aBTzNubnsFAvWQ/fZjNwPsEn9lA==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + } + }, + "node_modules/@types/node": { + "version": "20.12.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz", + "integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", + "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/esbuild": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.5.tgz", + "integrity": "sha512-ZCuZCnlqNzjb4QprAzXKdpp/gh6KTxSJuw3IBsPnV/7fV4NxC9ckB+vPTt8w7fJA0TaSD7c55BR47JD6MEDyDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/hono": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.3.7.tgz", + "integrity": "sha512-GXlsGnCAwGosu+COwYyYC8MwOY2L6Ihg9V1znYdMD8DHCJl+13Nk4o8dsBYJpae4oujjw24jBaITuYWVq2+V8Q==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tsx": { + "version": "4.10.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.10.4.tgz", + "integrity": "sha512-Gtg9qnZWNqC/OtcgiXfoAUdAKx3/cgKOYvEocAsv+m21MV/eKpV/WUjRXe6/sDCaGBl2/v8S6v29BpUnGMCX5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.20.2", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..94bd47f --- /dev/null +++ b/web/package.json @@ -0,0 +1,21 @@ +{ + "name": "web", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "scripts": { + "build": "tsc", + "dev": "tsx watch src/index.ts" + }, + "dependencies": { + "@hono/node-server": "^1.11.1", + "hono": "^4.3.7", + "axios": "^1.4.0" + }, + "devDependencies": { + "@types/node": "^20.11.17", + "typescript": "^4.5.2", + "tsx": "^4.7.1" + }, + "type": "module" +} diff --git a/web/public/index.html b/web/public/index.html new file mode 100644 index 0000000..3eef571 --- /dev/null +++ b/web/public/index.html @@ -0,0 +1,78 @@ + + + + + + Discord Bot デバッグモード + + + +
+

Discord Bot デバッグモード

+ + +

+
+ + + diff --git a/web/src/index.ts b/web/src/index.ts new file mode 100644 index 0000000..e857736 --- /dev/null +++ b/web/src/index.ts @@ -0,0 +1,37 @@ +import { Hono } from 'hono' +import { serve } from '@hono/node-server' +import { serveStatic } from '@hono/node-server/serve-static' +import axios from 'axios' +import { resolve } from 'path' + +const app = new Hono() + +const DISCORD_BOT_API_URL = 'http://bot:5000/debug' + +app.use('/*', serveStatic({ root: './public' })) + +app.post('/debug', async (c) => { + const body = await c.req.json() + if (!('enable' in body)) { + return c.json({ error: 'Invalid request' }, 400) + } + + try { + const response = await axios.post(DISCORD_BOT_API_URL, body) + if (response.status === 200) { + return c.json({ success: true }) + } else { + return c.json({ error: 'Failed to toggle debug mode' }, 500) + } + } catch (error) { + return c.json({ error: 'Failed to toggle debug mode' }, 500) + } +}) + +const port = 3000 +console.log(`Server is running on port ${port}`) + +serve({ + fetch: app.fetch, + port +}) diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..7774b76 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "./dist" + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +}