commit 7b6702af9cc7f8adabc8848b63859c9ec16e1522 Author: hina ntki Date: Sat Aug 3 19:51:18 2024 +0900 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90f8fbb --- /dev/null +++ b/.gitignore @@ -0,0 +1,176 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python +# Edit at https://www.toptal.com/developers/gitignore?templates=python + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +# End of https://www.toptal.com/developers/gitignore/api/python \ No newline at end of file diff --git a/app/Dockerfile b/app/Dockerfile new file mode 100644 index 0000000..039171c --- /dev/null +++ b/app/Dockerfile @@ -0,0 +1,18 @@ +# ベースイメージ +FROM python:3.9-slim + +# 作業ディレクトリの設定 +WORKDIR /app + +# 必要なパッケージのインストール +RUN apt-get update && apt-get install -y ffmpeg + +# 必要なPythonライブラリのインストール +COPY requirements.txt . +RUN pip install -r requirements.txt + +# Pythonスクリプトのコピー +COPY . . + +# BOTの起動 +CMD ["bash", "-c", "watchmedo auto-restart --directory=./ --pattern=*.py --recursive -- python bot.py"] diff --git a/app/bot.py b/app/bot.py new file mode 100644 index 0000000..c01f35c --- /dev/null +++ b/app/bot.py @@ -0,0 +1,26 @@ +import os +import logging +from dotenv import load_dotenv +import discord +from discord.ext import commands +from classes.my_discord_bot import setup_bot + +# .env ファイルを読み込む(開発環境用) +load_dotenv() + +if __name__ == "__main__": + # 環境変数名を生成 + bot_number = os.getenv('BOT_NUMBER') + TOKEN_ENV = f'DISCORD_TOKEN_{bot_number}' + + # 環境変数からトークンを取得 + TOKEN = os.getenv(TOKEN_ENV) + + intents = discord.Intents.default() + intents.guilds = True + intents.messages = True + intents.voice_states = True + intents.message_content = True + + bot = setup_bot(intents) + bot.run(TOKEN) diff --git a/app/classes/__init__.py b/app/classes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/classes/database.py b/app/classes/database.py new file mode 100644 index 0000000..e70c52d --- /dev/null +++ b/app/classes/database.py @@ -0,0 +1,89 @@ +from peewee import * +import os +import random +import logging +from .voicepeak import Narrator + +# 環境変数からデータベースの設定を取得 +db_name = os.getenv('POSTGRES_DB', 'mydatabase') +db_user = os.getenv('POSTGRES_USER', 'myuser') +db_password = os.getenv('POSTGRES_PASSWORD', 'mysecurepassword') +db_host = os.getenv('POSTGRES_HOST', 'db') +db_port = os.getenv('POSTGRES_PORT', '5432') + +# データベースの設定 +db = PostgresqlDatabase(db_name, user=db_user, password=db_password, host=db_host, port=db_port) + +class BaseModel(Model): + class Meta: + database = db + +class ReadingEntry(BaseModel): + word = CharField(unique=True) + reading = CharField() + + class Meta: + table_name = 'reading_entries' + +class UserSetting(BaseModel): + user_id = BigIntegerField(unique=True) + reading_speaker = CharField(default="SEKAI") + reading_speed = IntegerField(default=100) + reading_pitch = IntegerField(default=100) + reading_emotion_happy = IntegerField(default=0) + reading_emotion_sad = IntegerField(default=0) + reading_emotion_angry = IntegerField(default=0) + reading_emotion_fun = IntegerField(default=0) + + class Meta: + table_name = 'user_setting' + +def initialize_database(): + """データベースの初期化""" + if db.is_closed(): + db.connect() + db.create_tables([ReadingEntry, UserSetting]) + +def get_all_words(): + """現在の辞書情報を辞書型配列で取得する""" + try: + words = ReadingEntry.select() + dictionary = {word.word: word.reading for word in words} + return dictionary + except Exception as e: + logging.error(f"辞書情報取得中にエラーが発生しました: {e}") + return {} + +def get_user_setting(user_id): + """ユーザーごとの読み上げ設定を取得し、存在しない場合は生成する""" + try: + user_setting = UserSetting.get_or_none(UserSetting.user_id == user_id) + if user_setting: + return user_setting + else: + new_user_setting = UserSetting( + user_id=user_id, + reading_speaker=Narrator.random().name, + reading_speed=random.randint(50, 200), + reading_pitch=random.randint(-300, 300), + reading_emotion_happy=random.randint(0, 100), + reading_emotion_sad=random.randint(0, 100), + reading_emotion_angry=random.randint(0, 100), + reading_emotion_fun=random.randint(0, 100) + ) + new_user_setting.save() + return new_user_setting + except Exception as e: + logging.error(f"ユーザー設定取得中にエラーが発生しました: {e}") + +def delete_word(word): + """辞書から指定された単語を削除する""" + try: + query = ReadingEntry.get_or_none(ReadingEntry.word == word) + if query: + query.delete_instance() + return True + return False + except Exception as e: + logging.error(f"単語削除中にエラーが発生しました: {e}") + return False diff --git a/app/classes/instance.py b/app/classes/instance.py new file mode 100644 index 0000000..c47994a --- /dev/null +++ b/app/classes/instance.py @@ -0,0 +1,29 @@ +class Instance: + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super(Instance, cls).__new__(cls) + cls._instance.clear() + return cls._instance + + def set_text_channel(self, channel_id): + """テキストチャンネルIDを設定する""" + self.text_channel_id = channel_id + + def get_text_channel(self): + """現在接続中のテキストチャンネルIDを取得する""" + return self.text_channel_id + + def set_name_reading(self, is_reading): + """名前の読み上げ設定を行う""" + self.is_name_reading = is_reading + + def get_name_reading(self): + """名前の読み上げ設定を取得する""" + return self.is_name_reading + + def clear(self): + """初期化処理""" + self.text_channel_id = None + self.is_name_reading = False diff --git a/app/classes/message_reader.py b/app/classes/message_reader.py new file mode 100644 index 0000000..f062f4d --- /dev/null +++ b/app/classes/message_reader.py @@ -0,0 +1,101 @@ +import uuid +import discord +import os +import re +import asyncio +import logging +from .word_dictionary import convert_text +from .instance import Instance + +class MessageReader: + # 半角カタカナと数字と全角へのマッピング + half_width = "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲンァィゥェォッャュョー" + full_width = "アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲンアイウエオッヤユヨー" + translation_table = str.maketrans(half_width, full_width) + + # 濁点・半濁点を含む半角カタカナの変換マッピング + dakuten_map = { + 'ガ': 'ガ', 'ギ': 'ギ', 'グ': 'グ', 'ゲ': 'ゲ', 'ゴ': 'ゴ', + 'ザ': 'ザ', 'ジ': 'ジ', 'ズ': 'ズ', 'ゼ': 'ゼ', 'ゾ': 'ゾ', + 'ダ': 'ダ', 'ヂ': 'ヂ', 'ヅ': 'ヅ', 'デ': 'デ', 'ド': 'ド', + 'バ': 'バ', 'ビ': 'ビ', 'ブ': 'ブ', 'ベ': 'ベ', 'ボ': 'ボ', + 'パ': 'パ', 'ピ': 'ピ', 'プ': 'プ', 'ペ': 'ペ', 'ポ': 'ポ' + } + + def __init__(self, bot, voice_synthesizer): + self.bot = bot + self.voice_synthesizer = voice_synthesizer + self.is_reading = False + self.read_timeout = 10 + self.queue = asyncio.Queue() + self.instance = Instance() + + async def read_message(self, message): + """メッセージを読み上げるための処理を開始する""" + await self.queue.put(message) + if not self.is_reading: + await self._process_queue() + + async def _process_queue(self): + """メッセージキューを処理する""" + self.is_reading = True + while not self.queue.empty(): + message = await self.queue.get() + try: + # タイムアウトを設定してメッセージを読み上げる + await asyncio.wait_for(self._synthesize_and_play(message), timeout=self.read_timeout) + except asyncio.TimeoutError: + logging.error(f"Message read timeout: {message}") + 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): + """メッセージの音声合成を行い、再生する""" + if self.instance.get_name_reading() == 1: + 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 + + + if voice_client and voice_client.is_connected(): + script = convert_text(play_message) + if len(script) > 140: + script = script[:140] + + output_path = f"{id}.wav" + await self.voice_synthesizer.synthesize(message.author.id, script, 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="/usr/bin/ffmpeg", source=output_path) + volume_adjusted_source = discord.PCMVolumeTransformer(audio_source) + 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/my_discord_bot.py b/app/classes/my_discord_bot.py new file mode 100644 index 0000000..5c89ef7 --- /dev/null +++ b/app/classes/my_discord_bot.py @@ -0,0 +1,304 @@ +import os +import signal +import discord +import asyncio +import logging +from discord.ext import commands +from discord import app_commands +from dotenv import load_dotenv +from .instance import Instance +from .database import initialize_database, get_user_setting, ReadingEntry, get_all_words, delete_word +from .message_reader import MessageReader +from .voice_synthesizer import VoiceSynthesizer +from .voice_channel_manager import VoiceChannelManager +from .voicepeak import Narrator + +logging.basicConfig(level=logging.INFO) + +class MyDiscordBot(commands.Bot): + def __init__(self, command_prefix, intents): + super().__init__(command_prefix=command_prefix, intents=intents) + self.instance = Instance() + self.voice_synthesizer = VoiceSynthesizer(os.getenv('VOICEPEAK_API_URL')) + self.message_reader = MessageReader(self, self.voice_synthesizer) + self.voice_channel_manager = VoiceChannelManager() + self.command_prefix = command_prefix + logging.info("command_prefix: " + command_prefix) + + # シグナルハンドラを設定 + signal.signal(signal.SIGINT, self.shutdown) + signal.signal(signal.SIGTERM, self.shutdown) + + async def setup_hook(self): + initialize_database() + await self.tree.sync() + logging.info("コマンドツリーを同期しました") + + async def on_ready(self): + """ボットが準備完了時に実行されるイベントハンドラ""" + await self.set_bot_status(f"タイキチュウ…") + logging.info(f'{self.user} がログインしました') + + async def on_message(self, message): + """メッセージ受信時に実行されるイベントハンドラ""" + if message.author.bot: + return + if message.content.startswith(self.command_prefix): + cmd, *args = message.content[len(self.command_prefix):].split() + if cmd == "join": + await self.join_cmd(message) + elif cmd == "disconnect": + await self.disconnect_cmd(message) + elif cmd == "dictionary_add": + if len(args) != 2: + await self.send_message(message, "引数が足りません") + else: + await self.dictionary_add_cmd(message, args[0], args[1]) + elif cmd == "dictionary_remove": + if len(args) != 1: + await self.send_message(message, "引数が足りません") + else: + await self.dictionary_remove_cmd(message, args[0]) + elif cmd == "dictionary_list": + await self.dictionary_list_cmd(message) + elif cmd == "set_speaker": + if len(args) != 7: + await self.send_message(message, "引数が足りません") + else: + await self.set_speaker_cmd(message, args[0], int(args[1]), int(args[2]), int(args[3]), int(args[4]), int(args[5]), int(args[6])) + elif cmd == "help": + await self.help_cmd(message) + return + if message.channel.id != self.instance.get_text_channel(): + return + await self.message_reader.read_message(message) + + async def on_voice_state_update(self, member, before, after): + """ボイスチャンネルの状態が更新された時に実行されるイベントハンドラ""" + try: + connected_channel = self.voice_channel_manager.get_connected_channel(member.guild) + + if before.channel and before.channel == connected_channel: + members = [m for m in before.channel.members if not m.bot] + if not members: + await self.voice_channel_manager.leave_voice_channel(before.channel.guild) + await self.set_bot_status(f"タイキチュウ…") + self.instance.clear() + except Exception as e: + logging.error(f"Failed to update voice state: {e}") + raise + + 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) + + def shutdown(self, signum, frame): + """シグナルを受け取ってシャットダウン処理を行う""" + logging.info("シャットダウン中...") + asyncio.create_task(self.cleanup()) + + async def cleanup(self): + """クリーンアップ処理""" + await self.close() + + async def send_message(self, ctx, message): + """コンテキストに応じてメッセージを送信する""" + if isinstance(ctx, discord.Message): + await ctx.reply(message) + else: + await ctx.response.send_message(message) + + async def get_user_id(self, ctx): + """コンテキストに応じてユーザーIDを取得する""" + if isinstance(ctx, discord.Message): + return ctx.author.id + else: + return ctx.user.id + + async def join_cmd(self, ctx): + """ボイスチャンネルに接続する""" + voice_channel = ctx.author.voice.channel if isinstance(ctx, discord.Message) else ctx.user.voice.channel + connected_voice_channel = self.voice_channel_manager.get_connected_channel(ctx.guild) + if voice_channel and not connected_voice_channel: + self.instance.set_text_channel(ctx.channel.id) + await voice_channel.connect() + await self.send_message(ctx, f"ボイスチャンネルに接続しました: {voice_channel.name}") + await self.set_bot_status(f"オシゴトチュウ…") + elif connected_voice_channel: + await self.send_message(ctx, f"既にボイスチャンネルに接続しています: {connected_voice_channel.name}") + elif not voice_channel: + await self.send_message(ctx, "ボイスチャンネルに接続できませんでした。\nボイスチャンネルに参加してから再度実行してください。") + else: + await self.send_message(ctx, "ボイスチャンネルに接続できませんでした。") + + async def disconnect_cmd(self, ctx): + """ボイスチャンネルから切断する""" + voice_client = ctx.guild.voice_client + if voice_client and voice_client.is_connected(): + await voice_client.disconnect() + await self.send_message(ctx, "ボイスチャンネルから切断しました。") + await self.set_bot_status(f"タイキチュウ…") + self.instance.clear() + else: + await self.send_message(ctx, "ボイスチャンネルに接続していません。") + + async def dictionary_add_cmd(self, ctx, word: str, reading: str): + """読み上げ辞書に単語を追加または更新する""" + try: + reading_entry, created = ReadingEntry.get_or_create(word=word, defaults={'reading': reading}) + if not created: + reading_entry.reading = reading + reading_entry.save() + await self.send_message(ctx, f"単語を更新しました: {word} -> {reading}") + else: + reading_entry.reading = reading + reading_entry.save() + await self.send_message(ctx, f"単語を追加しました: {word} -> {reading}") + except Exception as e: + logging.error(f"単語追加中にエラーが発生しました: {e}") + await self.send_message(ctx, "単語追加中にエラーが発生しました。") + + async def dictionary_remove_cmd(self, ctx, word: str): + """読み上げ辞書から単語を削除する""" + try: + if delete_word(word): + await self.send_message(ctx, f"単語を削除しました: {word}") + else: + await self.send_message(ctx, f"指定された単語は辞書に存在しません: {word}") + except Exception as e: + logging.error(f"単語削除中にエラーが発生しました: {e}") + await self.send_message(ctx, "単語削除中にエラーが発生しました") + + async def dictionary_list_cmd(self, ctx): + """読み上げ辞書の内容を表示する""" + try: + dictionary = get_all_words() + if dictionary: + message = "読み上げ辞書の内容:\n" + for word, reading in dictionary.items(): + message += f"{word} -> {reading}\n" + await self.send_message(ctx, message) + else: + await self.send_message(ctx, "読み上げ辞書には現在、単語が登録されていません。") + except Exception as e: + logging.error(f"辞書内容の表示中にエラーが発生しました: {e}") + await self.send_message(ctx, "辞書内容の表示中にエラーが発生しました") + + async def set_speaker_cmd(self, ctx, speaker: str, speed: int, pitch: int, emotion_happy: int, emotion_sad: int, emotion_angry: int, emotion_fun: int): + """読み上げ設定を行う""" + if speaker not in Narrator.__members__: + message = ":rotating_light: speakerの値は以下の中から選んでください\n" + for narrator in Narrator.list(): + message += f"> {narrator}\n" + await self.send_message(ctx, message) + return + if speed < 50 or speed > 200: + await self.send_message(ctx, ":rotating_light: speedの値は50から200の間で指定してください") + return + if pitch < -300 or pitch > 300: + await self.send_message(ctx, ":rotating_light: pitchの値は-300から300の間で指定してください") + return + if emotion_happy < 0 or emotion_happy > 100: + await self.send_message(ctx, ":rotating_light: emotion_happyの値は0から100の間で指定してください") + return + if emotion_sad < 0 or emotion_sad > 100: + await self.send_message(ctx, ":rotating_light: emotion_sadの値は0から100の間で指定してください") + return + if emotion_angry < 0 or emotion_angry > 100: + await self.send_message(ctx, ":rotating_light: emotion_angryの値は0から100の間で指定してください") + return + if emotion_fun < 0 or emotion_fun > 100: + await self.send_message(ctx, ":rotating_light: emotion_funの値は0から100の間で指定してください") + return + + try: + user_id = await self.get_user_id(ctx) + setting = get_user_setting(user_id) + setting.reading_speaker = speaker + setting.reading_speed = speed + setting.reading_pitch = pitch + setting.reading_emotion_happy = emotion_happy + setting.reading_emotion_sad = emotion_sad + setting.reading_emotion_angry = emotion_angry + setting.reading_emotion_fun = emotion_fun + setting.save() + await self.send_message(ctx, "読み上げ設定を更新しました") + except Exception as e: + logging.error(f"読み上げ設定中にエラーが発生しました: {e}") + await self.send_message(ctx, "読み上げ設定中にエラーが発生しました") + + async def help_cmd(self, ctx): + """ヘルプコマンドを表示する""" + message = ( + "コマンド一覧\n" + "スラッシュコマンドまたはプレフィックスコマンドで以下を使用できます\n\n" + "**ボイスチャンネルに接続**\n" + f"/join または {self.command_prefix}join\n\n" + "**ボイスチャンネルから切断**\n" + f"/disconnect または {self.command_prefix}disconnect\n\n" + "**辞書に単語を追加/更新**\n" + f"/dictionary_add または {self.command_prefix}dictionary_add\n\n" + "**辞書から単語を削除**\n" + f"/dictionary_remove または {self.command_prefix}dictionary_remove\n\n" + "**辞書の内容を表示**\n" + f"/dictionary_list または {self.command_prefix}dictionary_list\n\n" + "**読み上げ設定を行う**\n" + f"/set_speaker または {self.command_prefix}set_speaker\n\n" + ) + + usage_example = ( + "**ボイスチャンネルに接続**\n" + f"`/join` または `{self.command_prefix}join`\n\n" + "**ボイスチャンネルから切断**\n" + f"`/disconnect` または `{self.command_prefix}disconnect`\n\n" + "**辞書に単語を追加/更新**\n" + f"`/dictionary_add <単語> <読み>` または `{self.command_prefix}dictionary_add <単語> <読み>`\n\n" + "**辞書から単語を削除**\n" + f"`/dictionary_remove <単語>` または `{self.command_prefix}dictionary_remove <単語>`\n\n" + "**辞書の内容を表示**\n" + f"`/dictionary_list` または `{self.command_prefix}dictionary_list`\n\n" + "**読み上げ設定を行う**\n" + f"`/set_speaker <話者> <速度> <ピッチ> <感情>`\n" + f"または `{self.command_prefix}set_speaker <話者> <速度> <ピッチ> <感情>`" + ) + + await self.send_message(ctx, message) + await self.send_message(ctx, usage_example) + +def setup_bot(intents): + bot = MyDiscordBot(command_prefix=os.getenv('PREFIX'), intents=intents) + + @bot.tree.command(name="join", description="ボイスチャンネルに接続します") + async def join(interaction: discord.Interaction): + """ボイスチャンネルに接続する""" + await bot.join_cmd(interaction) + + @bot.tree.command(name="disconnect", description="ボイスチャンネルから切断します") + async def disconnect(interaction: discord.Interaction): + """ボイスチャンネルから切断する""" + await bot.disconnect_cmd(interaction) + + @bot.tree.command(name="dictionary_add", description="読み上げ辞書に単語を追加または更新します") + async def dictionary_add(interaction: discord.Interaction, word: str, reading: str): + """読み上げ辞書に単語を追加または更新する""" + await bot.dictionary_add_cmd(interaction, word, reading) + + @bot.tree.command(name="dictionary_remove", description="読み上げ辞書から単語を削除します") + async def dictionary_remove(interaction: discord.Interaction, word: str): + """読み上げ辞書から単語を削除する""" + await bot.dictionary_remove_cmd(interaction, word) + + @bot.tree.command(name="dictionary_list", description="読み上げ辞書の内容を表示します") + async def dictionary_list(interaction: discord.Interaction): + """読み上げ辞書の内容を表示する""" + await bot.dictionary_list_cmd(interaction) + + @bot.tree.command(name="set_speaker", description="読み上げ設定を行います") + async def set_speaker(interaction: discord.Interaction, speaker: str, speed: int, pitch: int, emotion_happy: int, emotion_sad: int, emotion_angry: int, emotion_fun: int): + """読み上げ設定を行う""" + await bot.set_speaker_cmd(interaction, speaker, speed, pitch, emotion_happy, emotion_sad, emotion_angry, emotion_fun) + + return bot diff --git a/app/classes/voice_channel_manager.py b/app/classes/voice_channel_manager.py new file mode 100644 index 0000000..db4a103 --- /dev/null +++ b/app/classes/voice_channel_manager.py @@ -0,0 +1,27 @@ +import logging +from .instance import Instance + +class VoiceChannelManager: + def __init__(self): + self.instance = Instance() + + 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): + """ボイスチャンネルから切断する""" + try: + if guild.voice_client is not None: + await guild.voice_client.disconnect() + except Exception as e: + logging.info(f"Failed to leave voice channel: {e}") + raise + + def get_connected_channel(self, guild): + """ボットが接続しているボイスチャンネルを取得する""" + return guild.voice_client.channel if guild.voice_client else None diff --git a/app/classes/voice_synthesizer.py b/app/classes/voice_synthesizer.py new file mode 100644 index 0000000..ec9eaf5 --- /dev/null +++ b/app/classes/voice_synthesizer.py @@ -0,0 +1,37 @@ +import aiohttp +import logging +from .database import get_user_setting + +class VoiceSynthesizer: + def __init__(self, voicepeak_api_url): + self.voicepeak_api_url = voicepeak_api_url + + async def synthesize(self, user_id, script, output_path): + """スクリプトの音声合成を行う""" + user_setting = get_user_setting(user_id) + if not user_setting: + return + + headers = { + "Content-Type": "application/json" + } + + data = { + "text": script, + "narrator": user_setting.reading_speaker, + "emotion_happy": user_setting.reading_emotion_happy, + "emotion_sad": user_setting.reading_emotion_sad, + "emotion_angry": user_setting.reading_emotion_angry, + "emotion_fun": user_setting.reading_emotion_fun, + "pitch": user_setting.reading_pitch, + "speed": user_setting.reading_speed + } + + async with aiohttp.ClientSession() as session: + async with session.post(self.voicepeak_api_url, headers=headers, json=data) as response: + if response.status == 200: + content = await response.read() + with open(output_path, 'wb') as f: + f.write(content) + else: + logging.error(f"Failed to generate voice: {response.status} - {await response.text()}") diff --git a/app/classes/voicepeak.py b/app/classes/voicepeak.py new file mode 100644 index 0000000..cc0c255 --- /dev/null +++ b/app/classes/voicepeak.py @@ -0,0 +1,14 @@ +from enum import Enum +import random + +class Narrator(Enum): + SEKAI = "SEKAI" + RIME = "RIME" + + @classmethod + def random(cls): + return random.choice(list(cls)) + + @classmethod + def list(cls): + return [speaker.name for speaker in cls] \ No newline at end of file diff --git a/app/classes/word_dictionary.py b/app/classes/word_dictionary.py new file mode 100644 index 0000000..6a0bac7 --- /dev/null +++ b/app/classes/word_dictionary.py @@ -0,0 +1,9 @@ +from .database import get_all_words + +def convert_text(text): + """テキスト内の単語を辞書を使って変換する""" + dictionary = get_all_words() + for word in sorted(dictionary, key=len, reverse=True): + reading = dictionary[word] + text = text.replace(word, reading) + return text diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..43b9b0f --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,7 @@ +discord.py +discord-py-interactions +python-dotenv +peewee +psycopg2-binary +watchdog +pynacl diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c42aaa7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +services: + discord_bot_1: + environment: + - BOT_NUMBER=1 + - PREFIX=q! + build: ./app + env_file: ./app/.env + restart: always + depends_on: + - db + command: bash -c "watchmedo auto-restart --directory=./ --pattern=*.py --recursive -- python bot.py" + + # discord_bot_2: + # environment: + # - BOT_NUMBER=2 + # - PREFIX=d! + # build: ./app + # env_file: ./app/.env + # restart: always + # depends_on: + # - db + # command: bash -c "watchmedo auto-restart --directory=./ --pattern=*.py --recursive -- python bot.py" + + db: + image: postgres:latest + environment: + POSTGRES_USER: service + POSTGRES_PASSWORD: W8s36Pmh + POSTGRES_DB: discord_peak + PGDATA: /var/lib/postgresql/data/pgdata + volumes: + - pgdata:/var/lib/postgresql/data/pgdata + +volumes: + pgdata: