first commit

This commit is contained in:
hina ntki 2024-08-03 20:18:28 +09:00
commit cdb89d5264
26 changed files with 1934 additions and 0 deletions

181
.gitignore vendored Normal file
View File

@ -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

20
Dockerfile Normal file
View File

@ -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"]

2
README.md Normal file
View File

@ -0,0 +1,2 @@
# メモ
- FFmpegが動作できる環境が必要

175
app/bot.py Normal file
View File

@ -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"])

View File

@ -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)

View File

@ -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")

View File

@ -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)

View File

@ -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

View File

@ -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}"

View File

@ -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}")

10
app/config.py Normal file
View File

@ -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)

18
app/utils/file_utils.py Normal file
View File

@ -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()

21
app/web_server.py Normal file
View File

@ -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)

12
config/_config.dev.json Normal file
View File

@ -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
}

11
config/_config.prod.json Normal file
View File

@ -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
}

25
docker-compose.yml Normal file
View File

@ -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

18
entrypoint.sh Normal file
View File

@ -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

8
requirements.txt Normal file
View File

@ -0,0 +1,8 @@
pygame
py-cord
PyNaCl
openai==0.28
requests
discord.py
aiohttp
flask

1
web/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

33
web/Dockerfile Normal file
View File

@ -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"]

8
web/README.md Normal file
View File

@ -0,0 +1,8 @@
```
npm install
npm run dev
```
```
open http://localhost:3000
```

659
web/package-lock.json generated Normal file
View File

@ -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"
}
}
}

21
web/package.json Normal file
View File

@ -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"
}

78
web/public/index.html Normal file
View File

@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Discord Bot デバッグモード</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #f0f0f0;
}
.container {
text-align: center;
background-color: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
button {
margin: 10px;
padding: 10px 20px;
font-size: 16px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.enable {
background-color: #4caf50;
color: white;
}
.disable {
background-color: #f44336;
color: white;
}
</style>
</head>
<body>
<div class="container">
<h1>Discord Bot デバッグモード</h1>
<button class="enable" onclick="toggleDebug(true)">デバッグモードを有効にする</button>
<button class="disable" onclick="toggleDebug(false)">デバッグモードを無効にする</button>
<p id="status"></p>
</div>
<script>
function toggleDebug(enable) {
fetch('/debug', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ enable })
})
.then(response => response.json())
.then(data => {
const status = document.getElementById('status');
if (data.success) {
status.textContent = `デバッグモードが${enable ? '有効' : '無効'}になりました。`;
status.style.color = enable ? 'green' : 'red';
} else {
status.textContent = `デバッグモードの切り替えに失敗しました。`;
status.style.color = 'red';
}
})
.catch(error => {
const status = document.getElementById('status');
status.textContent = `エラー: ${error}`;
status.style.color = 'red';
});
}
</script>
</body>
</html>

37
web/src/index.ts Normal file
View File

@ -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
})

14
web/tsconfig.json Normal file
View File

@ -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"]
}