first commit

This commit is contained in:
hina ntki 2024-08-03 19:51:18 +09:00
commit 7b6702af9c
14 changed files with 872 additions and 0 deletions

176
.gitignore vendored Normal file
View File

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

18
app/Dockerfile Normal file
View File

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

26
app/bot.py Normal file
View File

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

0
app/classes/__init__.py Normal file
View File

89
app/classes/database.py Normal file
View File

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

29
app/classes/instance.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

14
app/classes/voicepeak.py Normal file
View File

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

View File

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

7
app/requirements.txt Normal file
View File

@ -0,0 +1,7 @@
discord.py
discord-py-interactions
python-dotenv
peewee
psycopg2-binary
watchdog
pynacl

35
docker-compose.yml Normal file
View File

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