diff --git a/.github/workflows/publish-image-test.yml b/.github/workflows/publish-image-test.yml index cc060f5..dc0695f 100644 --- a/.github/workflows/publish-image-test.yml +++ b/.github/workflows/publish-image-test.yml @@ -1,14 +1,14 @@ name: Build Test Docker on: - push: - branches: [ develop ] pull_request: branches: [ develop ] jobs: build_test: runs-on: ubuntu-latest + env: + IMAGE_NAME: minimaid steps: - name: checkout uses: actions/checkout@v2 @@ -16,10 +16,32 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v1 - - name: Build Test + - name: Set up Docker Buildx Builder run: | docker run --rm --privileged multiarch/qemu-user-static --reset -p yes docker buildx create --name multiarch --driver docker-container --use docker buildx inspect --bootstrap - docker buildx build \ - --platform linux/amd64 . + + - name: Cache Docker layers + uses: actions/cache@v2 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + + - name: Build Test + uses: docker/build-push-action@v2 + with: + platforms: linux/amd64 + context: . + push: false + tags: | + ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:latest + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new + + - name: Move cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index 713d0fb..c2687b1 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -2,8 +2,10 @@ name: Build and Publish Docker on: push: - branches: - - master + branches-ignore: + - '**' + tags: + - '*' jobs: build_and_push: @@ -14,6 +16,10 @@ jobs: - name: checkout uses: actions/checkout@v2 + - name: Get tag + id: tag + uses: dawidd6/action-get-tag@v1 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v1 @@ -30,13 +36,27 @@ jobs: docker buildx create --name multiarch --driver docker-container --use docker buildx inspect --bootstrap + - name: Cache Docker layers + uses: actions/cache@v2 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + - name: Build and push uses: docker/build-push-action@v2 with: - platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 + platforms: linux/amd64,linux/arm64,linux/arm/v7 context: . push: true tags: | ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:latest - ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:1.2.3 + ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${{steps.tag.outputs.tag}} + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache-new + - name: Move cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache diff --git a/bot.py b/bot.py index 5e05a90..2c39375 100644 --- a/bot.py +++ b/bot.py @@ -21,6 +21,10 @@ def __init__(self) -> None: ) self.db = Database() + async def on_ready(self) -> None: + prefix = environ["PREFIX"] + await self.change_presence(activity=discord.Game(name=f"prefix: {prefix}")) + async def on_error(self, event_method: str, *args: Any, **kwargs: Any) -> None: _, err, _ = sys.exc_info() if isinstance(err, MiniMaidException) and event_method == "on_message": diff --git a/cogs/audio.py b/cogs/audio.py index e7f1405..f854e7b 100644 --- a/cogs/audio.py +++ b/cogs/audio.py @@ -278,6 +278,9 @@ async def replay_audio(self, ctx: Context) -> None: try: await ctx.success("30秒前からのクリップを作成します...") file = await ctx.voice_client.replay() + if file is None: + await ctx.error("エラーが発生しました。もしエラーが再発するようであれば再接続してください。") + return timestamp = datetime.utcnow().timestamp() file.seek(0) await ctx.send("作成終了しました。", file=discord.File(file, f"{timestamp}.wav")) @@ -327,6 +330,9 @@ async def record_start(self, ctx: Context) -> None: try: await ctx.success("録音開始します...") file = await ctx.voice_client.record() + if file is None: + await ctx.error("エラーが発生しました。もしエラーが再発するようであれば再接続してください。") + return await ctx.success("録音終了しました。") timestamp = datetime.utcnow().timestamp() file.seek(0) diff --git a/cogs/help.py b/cogs/help.py new file mode 100644 index 0000000..6ccdeeb --- /dev/null +++ b/cogs/help.py @@ -0,0 +1,29 @@ +from typing import TYPE_CHECKING + +from discord.ext.commands import ( + Cog, + group +) + +from lib.context import Context +from lib.embed import help_embed + +if TYPE_CHECKING: + from bot import MiniMaid + + +class HelpCog(Cog): + def __init__(self, bot: 'MiniMaid') -> None: + self.bot = bot + + @group(name="help", invoke_without_command=True) + async def help_command(self, ctx: Context) -> None: + await ctx.embed(help_embed()) + + @group(name="ping", invoke_without_command=True) + async def ping(self, ctx: Context) -> None: + await ctx.success("pong!") + + +def setup(bot: 'MiniMaid') -> None: + return bot.add_cog(HelpCog(bot)) diff --git a/lib/audio.py b/lib/audio.py index d3184d0..70d25ef 100644 --- a/lib/audio.py +++ b/lib/audio.py @@ -8,7 +8,12 @@ import asyncio -def remove_header(content: bytes) -> io.BytesIO: +def make_pcm(content: bytes) -> io.BytesIO: + """ + wavのファイルからヘッダーを取り除き、フレームレートなどを合わせます。 + :param content: wavのデータ + :return: 出力するPCM + """ with wave.open(io.BytesIO(content)) as wav: bit = wav.getsampwidth() pcm = wav.readframes(wav.getnframes()) @@ -24,6 +29,11 @@ def remove_header(content: bytes) -> io.BytesIO: def mp3_to_pcm(raw: bytes) -> io.BytesIO: + """ + MP3のデータをPCMに変換します。 + :param raw: MP3のデータ + :return: 出力するPCM + """ mp3 = Mpg123() mp3.feed(raw) rate, channels, encoding = mp3.get_format() @@ -42,12 +52,25 @@ def __init__(self, loop: asyncio.AbstractEventLoop) -> None: self.executor = ThreadPoolExecutor() async def to_pcm(self, raw: bytes, filetype: str) -> io.BytesIO: + """ + データをPCMに変換します。 + + :param raw: 変換するデータ + :param filetype: 変換するデータのタイプ + :return: 出力するPCM + """ if filetype == "mp3": return await self.loop.run_in_executor(self.executor, partial(mp3_to_pcm, raw)) else: - return await self.loop.run_in_executor(self.executor, partial(remove_header, raw)) + return await self.loop.run_in_executor(self.executor, partial(make_pcm, raw)) async def create_source(self, attachment: discord.Attachment) -> discord.AudioSource: + """ + Attachmentからdiscord.PCMAudioを作成します。 + + :param attachment: 変換するアタッチメント + :return: 出力するPCMAudio + """ raw = await attachment.read() data = await self.to_pcm(raw, "mp3" if attachment.filename.endswith(".mp3") else "wav") diff --git a/lib/checks.py b/lib/checks.py index f3175d3..bb64022 100644 --- a/lib/checks.py +++ b/lib/checks.py @@ -8,6 +8,11 @@ def bot_connected_only() -> Any: + """ + BotがVCに接続しているかのチェック + + :return: check + """ def predicate(ctx: Context) -> bool: if ctx.voice_client is None: raise BotNotConnected() @@ -17,6 +22,11 @@ def predicate(ctx: Context) -> bool: def user_connected_only() -> Any: + """ + コマンドを打ったユーザーがVCに接続しているかのチェック + + :return: check + """ def predicate(ctx: Context) -> bool: if ctx.author.voice is None or ctx.author.voice.channel is None: raise UserNotConnected() @@ -26,6 +36,11 @@ def predicate(ctx: Context) -> bool: def voice_channel_only() -> Any: + """ + ユーザーが接続しているチャンネルがVCであるかのチェック + + :return: check + """ def predicate(ctx: Context) -> bool: if isinstance(ctx.author.voice.channel, discord.StageChannel): raise NoStageChannel() diff --git a/lib/context.py b/lib/context.py index 5f6a4fb..373755a 100644 --- a/lib/context.py +++ b/lib/context.py @@ -9,6 +9,13 @@ def __init__(self, **kwargs: dict) -> None: super(Context, self).__init__(**kwargs) async def error(self, content: str, description: Optional[str] = None) -> discord.Message: + """ + エラー表示のための関数 + + :param content: エラーのタイトル + :param description: エラーの詳細 + :return: 送信したメッセージ + """ embed = discord.Embed(title=f"\U000026a0 {content}", color=0xffc107) if description is not None: embed.description = description @@ -16,6 +23,13 @@ async def error(self, content: str, description: Optional[str] = None) -> discor return await self.send(embed=embed) async def success(self, content: str, description: Optional[str] = None) -> discord.Message: + """ + コマンドの実行などに成功したときのための関数 + + :param content: タイトル + :param description: 内容 + :return: 送信したメッセージ + """ embed = discord.Embed(title=f"\U00002705 {content}", colour=discord.Colour.green()) if description is not None: embed.description = description @@ -23,4 +37,10 @@ async def success(self, content: str, description: Optional[str] = None) -> disc return await self.send(embed=embed) async def embed(self, embed: discord.Embed) -> discord.Message: + """ + Embedのみを送信する関数 + + :param embed: 送信するEmbed + :return: 送信したメッセージ + """ return await self.send(embed=embed) diff --git a/lib/discord/buffer_decoder.py b/lib/discord/buffer_decoder.py index 0014053..ab811a4 100644 --- a/lib/discord/buffer_decoder.py +++ b/lib/discord/buffer_decoder.py @@ -8,8 +8,11 @@ import time from collections import defaultdict from itertools import zip_longest +import logging -from .opus import Decoder +from .opus import Decoder, OpusError + +logger = logging.getLogger(__name__) class PacketBase: @@ -51,8 +54,12 @@ def calc_extention_header_length(self, data: bytes) -> None: continue offset += 1 + (0b1111 & (byte_ >> 4)) - if self.decrypted[offset + 1] in [0, 2]: - offset += 1 + try: + if self.decrypted[offset + 1] in [0, 2]: + offset += 1 + except IndexError: + self.decrypted = None + return self.decrypted = data[offset + 1:] @property @@ -135,11 +142,10 @@ def is_speaker(self, ssrc: int) -> bool: return ssrc in self.ssrc.keys() def add_ssrc(self, data: dict) -> None: - if len(self.ssrc) >= 15: - return self.ssrc[data["ssrc"]] = data["user_id"] async def decode(self): + file = BytesIO() wav = wave.open(file, "wb") wav.setnchannels(Decoder.CHANNELS) @@ -151,7 +157,11 @@ async def decode(self): if c > 15: break queue = PacketQueue(packets) - pcm: ResultPCM = await self.decode_one(queue) + try: + pcm: ResultPCM = await self.decode_one(queue) + except OpusError: + wav.close() + return None pcm_list.append(pcm) c += 1 pcm_list.sort(key=lambda x: x.start_time) @@ -241,6 +251,12 @@ async def decode_one(self, queue: PacketQueue): else: start_time = min(packet.real_time, start_time) + if packet.decrypted is None: + data = decoder.decode_float(packet.decrypted) + pcm += data + last_timestamp = packet.timestamp + continue + if len(packet.decrypted) < 10: last_timestamp = packet.timestamp continue @@ -251,7 +267,12 @@ async def decode_one(self, queue: PacketQueue): margin = [0] * 2 * int(Decoder.SAMPLE_SIZE * (elapsed - 0.02) * Decoder.SAMPLING_RATE) # await self.loop.run_in_executor(self.executor, partial(pcm.append, margin)) pcm += margin - data = decoder.decode_float(packet.decrypted) + try: + data = decoder.decode_float(packet.decrypted) + except Exception: + logger.error(f"{packet.cc=}") + logger.error(f"{packet.extend=}") + raise pcm += data last_timestamp = packet.timestamp diff --git a/lib/discord/voice_client.py b/lib/discord/voice_client.py index a6265c7..5c239e5 100644 --- a/lib/discord/voice_client.py +++ b/lib/discord/voice_client.py @@ -1,4 +1,5 @@ from io import BytesIO +from typing import Optional from discord import VoiceClient from lib.discord.websocket import MiniMaidVoiceWebSocket @@ -13,8 +14,8 @@ async def connect_websocket(self) -> MiniMaidVoiceWebSocket: self._connected.set() return ws - async def record(self) -> BytesIO: + async def record(self) -> Optional[BytesIO]: return await self.ws.record(self.client) - async def replay(self) -> BytesIO: + async def replay(self) -> Optional[BytesIO]: return await self.ws.replay() diff --git a/lib/discord/websocket.py b/lib/discord/websocket.py index 8394ff9..5c6b9d9 100644 --- a/lib/discord/websocket.py +++ b/lib/discord/websocket.py @@ -69,14 +69,15 @@ async def receive_audio_packet(self) -> None: while True: recv = await self.loop.sock_recv(state.socket, 2 ** 16) if not self.is_recording: - if 200 <= recv[1] < 205: + if 200 <= recv[1] <= 204: continue seq, timestamp, ssrc = struct.unpack_from('>HII', recv, 2) self.ring_buffer.append(ssrc, dict(time=time.time(), data=recv)) continue decrypt_fn = getattr(self, f'decrypt_{state.mode}') header, data = decrypt_fn(recv) - if 200 <= header[1] <= 204: + print(len(recv)) + if 200 <= recv[1] <= 204: continue packet = RTPPacket(header, data) packet.calc_extention_header_length(data) diff --git a/lib/embed.py b/lib/embed.py index 2e2be9d..3672046 100644 --- a/lib/embed.py +++ b/lib/embed.py @@ -40,9 +40,22 @@ poll limited 2 hidden 緯度が日本より上の国の2つはどれか? 🇮🇹 イタリア 🇬🇧 イギリス 🇩🇪 ドイツ 🇫🇷 フランス ``` """ +HELP_MESSAGE = """ +[コマンド一覧](https://github.com/sizumita/MiniMaid/blob/master/docs/Commands.md) +""" + + +def help_embed() -> Embed: + return Embed(title="MiniMaid Help", description=HELP_MESSAGE, colour=Colour.blue()) def make_poll_help_embed(ctx: Context) -> Embed: + """ + 投票機能の説明のEmbedを生成します。 + + :param ctx: Context + :return: 生成したEmbed + """ embed = Embed( title="投票機能の使い方", colour=Colour.teal() @@ -70,6 +83,12 @@ def make_poll_help_embed(ctx: Context) -> Embed: def make_poll_reserve_embed(ctx: Context) -> Embed: + """ + 投票の作成中のEmbedを生成します。 + + :param ctx: Context + :return: 生成したEmbed + """ embed = Embed( title="投票を作成中です", description="しばらくお待ちください。" @@ -79,6 +98,13 @@ def make_poll_reserve_embed(ctx: Context) -> Embed: def make_poll_embed(ctx: Context, poll: Poll) -> Embed: + """ + 投票のEmbedを作成します。 + + :param ctx: Context + :param poll: 生成する投票 + :return: 生成したEmbed + """ description = f"{poll.limit}個まで投票できます。\n\n" if poll.limit is not None else "" for choice in poll.choices: if choice.emoji == choice.value: @@ -100,6 +126,15 @@ def make_poll_embed(ctx: Context, poll: Poll) -> Embed: def make_poll_result_embed(bot: 'MiniMaid', ctx: Context, poll: Poll, choices: list) -> Embed: + """ + 投票結果のEmbedを生成します。 + + :param bot: Botのインスタンス + :param ctx: Context + :param poll: 生成する投票 + :param choices: 表示する票数 (選択肢, 個数, パーセント) + :return: 生成したEmbed + """ message_url = MESSAGE_URL_BASE.format(poll.guild_id, poll.channel_id, poll.message_id) user = bot.get_user(poll.owner_id) embed = Embed( @@ -122,11 +157,25 @@ def make_poll_result_embed(bot: 'MiniMaid', ctx: Context, poll: Poll, choices: l def change_footer(embed: Embed, text: str) -> Embed: + """ + Embedのfooterを変更します。 + + :param embed: 変更するEmbed + :param text: 変更先の文字 + :return: 生成したEmbed + """ embed.set_footer(text=text) return embed def user_voice_preference_embed(ctx: Context, preference: UserVoicePreference) -> Embed: + """ + 音声設定の表示用のEmbedを生成します。 + + :param ctx: Context + :param preference: 表示する設定 + :return: 生成したEmbed + """ embed = Embed( title=f"{ctx.author}さんのボイス設定", colour=Colour.blue() @@ -156,10 +205,23 @@ def user_voice_preference_embed(ctx: Context, preference: UserVoicePreference) - def yesno(v: bool) -> str: + """ + 真偽値をはいかいいえに変換します。 + + :param v: 変換する値 + :return: はい か いいえ + """ return "はい" if v else "いいえ" def guild_voice_preference_embed(ctx: Context, preference: GuildVoicePreference) -> Embed: + """ + ギルドの設定を表示するEmbedを生成します。 + + :param ctx: Context + :param preference: 表示する設定 + :return: 生成したEmbed + """ embed = Embed( title=f"{ctx.guild.name}のボイス設定", colour=Colour.blue() @@ -199,6 +261,13 @@ def guild_voice_preference_embed(ctx: Context, preference: GuildVoicePreference) def voice_dictionaries_embed(ctx: Context, dictionaries: List[VoiceDictionary]) -> Embed: + """ + 読み上げの辞書を表示するEmbedを生成します。 + + :param ctx: Context + :param dictionaries: 表示する辞書のリスト + :return: 生成したEmbed + """ embed = Embed( title=f"{ctx.guild.name}の読み上げ用辞書一覧", description="\n".join([f"{dic.before} : {dic.after}" for dic in dictionaries])[:2000] diff --git a/lib/fake.py b/lib/fake.py index 2a30145..ce7bf51 100644 --- a/lib/fake.py +++ b/lib/fake.py @@ -1,3 +1,6 @@ +""" +テスト用のFakeクラス +""" from typing import Optional, Any import discord @@ -7,6 +10,7 @@ class FakeEmoji(discord.Emoji): def __init__(self, _id: int) -> None: self.id = _id + discord.Message._state def __eq__(self, other: Any) -> bool: if isinstance(other, FakeEmoji): diff --git a/lib/jtalk.py b/lib/jtalk.py index b6a8322..fbb05c8 100644 --- a/lib/jtalk.py +++ b/lib/jtalk.py @@ -13,7 +13,7 @@ c_short ) import platform -from typing import Optional +from typing import Optional, Any class HtsVoiceFilelist(Structure): @@ -114,7 +114,13 @@ def _check_openjtalk_object(self) -> None: if self.h is None: raise Exception("Internal Error: OpenJTalk pointer is NULL") - def generate_pcm(self, text: str) -> Optional[list]: + def generate_pcm(self, text: str) -> Any: + """ + PCMの合成音声を生成します。 + + :param text: 生成するテキスト + :return: 生成したPCM + """ data = c_void_p() length = c_size_t() r = self.jtalk.openjtalk_generatePCM(self.h, text.encode('utf-8'), byref(data), byref(length)) diff --git a/main.py b/main.py index 52c8e9c..6831910 100644 --- a/main.py +++ b/main.py @@ -13,7 +13,8 @@ "cogs.tts.tts", "cogs.tts.preference", "cogs.audio", - "cogs.rss" + "cogs.rss", + "cogs.help" ] for extension in extensions: