From 07f8f3e524ede34f5829e2ddca12bef31334c574 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Fri, 24 Sep 2021 23:48:12 +0200 Subject: [PATCH 01/12] Rename stream to play --- src/bot.py | 4 ++-- test/test_bot.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/bot.py b/src/bot.py index 9964019..462cd00 100644 --- a/src/bot.py +++ b/src/bot.py @@ -15,7 +15,7 @@ class Music(commands.Cog): self.bot = bot @commands.command() - async def stream(self, ctx, *, url): + async def play(self, ctx, *, url): async with ctx.typing(): player = await YTDLSource.from_url(url, loop=self.bot.loop, stream=True) ctx.voice_client.play(player, after=lambda e: print(f"Player error: {e}") if e else None) @@ -26,7 +26,7 @@ class Music(commands.Cog): async def stop(self, ctx): await ctx.voice_client.disconnect() - @stream.before_invoke + @play.before_invoke async def ensure_voice(self, ctx): if ctx.voice_client is None: if ctx.author.voice: diff --git a/test/test_bot.py b/test/test_bot.py index 1bbe70d..1f0b6a6 100644 --- a/test/test_bot.py +++ b/test/test_bot.py @@ -39,7 +39,7 @@ async def test_bot_ensure_voice(bot, ctx): @pytest.mark.asyncio -async def test_bot_stream(bot, ctx): +async def test_bot_play(bot, ctx): mbot = Music(bot) with patch('bot.YTDLSource', new_callable=AsyncMock) as ytdl_source: @@ -47,7 +47,7 @@ async def test_bot_stream(bot, ctx): ytdl_source.from_url.return_value = player url = 'A Day To Remember - All I Want' # pylint: disable=too-many-function-args - await mbot.stream(mbot, ctx, url=url) + await mbot.play(mbot, ctx, url=url) assert ytdl_source.from_url.await_args.args == (url,) assert ytdl_source.from_url.await_args.kwargs == {"loop": bot.loop, "stream": True} assert ctx.voice_client.play.call_args.args == (player,) From 002111df8ed18d74cbc8876caff4147b37aa3e26 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 25 Sep 2021 00:42:36 +0200 Subject: [PATCH 02/12] Implement queue --- src/bot.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++-------- src/yt.py | 50 ---------------------------------------------- 2 files changed, 50 insertions(+), 58 deletions(-) delete mode 100644 src/yt.py diff --git a/src/bot.py b/src/bot.py index 462cd00..66d9b19 100644 --- a/src/bot.py +++ b/src/bot.py @@ -1,26 +1,70 @@ import os import sys +import asyncio -from discord.ext import commands +import discord +from discord.ext import commands, tasks from dotenv import load_dotenv +import youtube_dl -from yt import YTDLSource from error import ErrorHandler load_dotenv() +# Suppress noise about console usage from errors +youtube_dl.utils.bug_reports_message = lambda: '' + class Music(commands.Cog): def __init__(self, bot): self.bot = bot + self.queue = asyncio.Queue() + self.queue_lock = asyncio.Lock() + self._ytdl_format_options = { + 'format': 'bestaudio/best', + 'outtmpl': '%(extractor)s-%(id)s-%(title)s.%(ext)s', + 'restrictfilenames': True, + 'noplaylist': True, + 'nocheckcertificate': True, + 'ignoreerrors': False, + 'logtostderr': False, + 'quiet': True, + 'no_warnings': True, + 'default_search': 'auto', + 'source_address': '0.0.0.0' # bind to ipv4 since ipv6 addresses cause issues sometimes + } + self._ffmpeg_options = { + 'options': '-vn' + } + self.ytdl = youtube_dl.YoutubeDL(self._ytdl_format_options) + # pylint: disable=no-member + self.handle_playback.start() + + @tasks.loop() + async def handle_playback(self): + while True: + await self.queue_lock.acquire() + ctx, url, title = await self.queue.get() + audio = discord.FFmpegPCMAudio(url, **self._ffmpeg_options) + + def after(err): + if err: + print(f"Player error: {err}") + self.queue.task_done() + self.queue_lock.release() + ctx.voice_client.play(audio, after=after) + await ctx.send(f"Now playing: {title}") @commands.command() async def play(self, ctx, *, url): async with ctx.typing(): - player = await YTDLSource.from_url(url, loop=self.bot.loop, stream=True) - ctx.voice_client.play(player, after=lambda e: print(f"Player error: {e}") if e else None) - - await ctx.send(f"Now playing: {player.title}") + data = self.ytdl.extract_info(url, download=False) + if 'entries' in data: + data = data['entries'][0] + title = data.get('title') + url = data.get('url') + await self.queue.put((ctx, url, title)) + await ctx.send(f"Queued: {title}") @commands.command() async def stop(self, ctx): @@ -33,8 +77,6 @@ class Music(commands.Cog): await ctx.author.voice.channel.connect() else: raise commands.CommandError("Author not connected to a voice channel.") - elif ctx.voice_client.is_playing(): - ctx.voice_client.stop() if __name__ == "__main__": diff --git a/src/yt.py b/src/yt.py deleted file mode 100644 index 8148bba..0000000 --- a/src/yt.py +++ /dev/null @@ -1,50 +0,0 @@ -import asyncio - -import discord -import youtube_dl - -# Suppress noise about console usage from errors -youtube_dl.utils.bug_reports_message = lambda: '' - - -ytdl_format_options = { - 'format': 'bestaudio/best', - 'outtmpl': '%(extractor)s-%(id)s-%(title)s.%(ext)s', - 'restrictfilenames': True, - 'noplaylist': True, - 'nocheckcertificate': True, - 'ignoreerrors': False, - 'logtostderr': False, - 'quiet': True, - 'no_warnings': True, - 'default_search': 'auto', - 'source_address': '0.0.0.0' # bind to ipv4 since ipv6 addresses cause issues sometimes -} - -ffmpeg_options = { - 'options': '-vn' -} - -ytdl = youtube_dl.YoutubeDL(ytdl_format_options) - - -class YTDLSource(discord.PCMVolumeTransformer): - def __init__(self, source, *, data, volume=0.5): - super().__init__(source, volume) - - self.data = data - - self.title = data.get('title') - self.url = data.get('url') - - @classmethod - async def from_url(cls, url, *, loop=None, stream=False): - loop = loop or asyncio.get_event_loop() - data = await loop.run_in_executor(None, lambda: ytdl.extract_info(url, download=not stream)) - - if 'entries' in data: - # take first item from a playlist - data = data['entries'][0] - - filename = data['url'] if stream else ytdl.prepare_filename(data) - return cls(discord.FFmpegPCMAudio(filename, **ffmpeg_options), data=data) From d14b40d9f7f1a94014294805b6b664040cf6c4d8 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 25 Sep 2021 02:36:47 +0200 Subject: [PATCH 03/12] Only send 'Queued:' if currently playing --- src/bot.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/bot.py b/src/bot.py index 66d9b19..702dd73 100644 --- a/src/bot.py +++ b/src/bot.py @@ -64,7 +64,8 @@ class Music(commands.Cog): title = data.get('title') url = data.get('url') await self.queue.put((ctx, url, title)) - await ctx.send(f"Queued: {title}") + if ctx.voice_client.is_playing(): + await ctx.send(f"Queued: {title}") @commands.command() async def stop(self, ctx): From a27733675e49cd4776bdbc2b15f44383b89fcc44 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 25 Sep 2021 02:41:53 +0200 Subject: [PATCH 04/12] Reply with stringified CommandError on CommandError --- src/error.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/error.py b/src/error.py index 2b4b457..7f1009e 100644 --- a/src/error.py +++ b/src/error.py @@ -15,6 +15,8 @@ class ErrorHandler(commands.Cog): message = "You are missing the required permissions to run this command!" elif isinstance(error, commands.UserInputError): message = "Something about your input was wrong, please check your input and try again!" + elif isinstance(error, commands.CommandError): + message = str(error) else: message = "Oh no! Something went wrong while running the command!" From b55b3a3f0d9cc12355c427d80a1bb99ca3083e55 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 25 Sep 2021 02:45:44 +0200 Subject: [PATCH 05/12] Make properties private --- src/bot.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/bot.py b/src/bot.py index 702dd73..9026522 100644 --- a/src/bot.py +++ b/src/bot.py @@ -17,9 +17,9 @@ youtube_dl.utils.bug_reports_message = lambda: '' class Music(commands.Cog): def __init__(self, bot): - self.bot = bot - self.queue = asyncio.Queue() - self.queue_lock = asyncio.Lock() + self._bot = bot + self._queue = asyncio.Queue() + self._queue_lock = asyncio.Lock() self._ytdl_format_options = { 'format': 'bestaudio/best', 'outtmpl': '%(extractor)s-%(id)s-%(title)s.%(ext)s', @@ -36,34 +36,34 @@ class Music(commands.Cog): self._ffmpeg_options = { 'options': '-vn' } - self.ytdl = youtube_dl.YoutubeDL(self._ytdl_format_options) + self._ytdl = youtube_dl.YoutubeDL(self._ytdl_format_options) # pylint: disable=no-member - self.handle_playback.start() + self._handle_playback.start() @tasks.loop() - async def handle_playback(self): + async def _handle_playback(self): while True: - await self.queue_lock.acquire() - ctx, url, title = await self.queue.get() + await self._queue_lock.acquire() + ctx, url, title = await self._queue.get() audio = discord.FFmpegPCMAudio(url, **self._ffmpeg_options) def after(err): if err: print(f"Player error: {err}") - self.queue.task_done() - self.queue_lock.release() + self._queue.task_done() + self._queue_lock.release() ctx.voice_client.play(audio, after=after) await ctx.send(f"Now playing: {title}") @commands.command() async def play(self, ctx, *, url): async with ctx.typing(): - data = self.ytdl.extract_info(url, download=False) + data = self._ytdl.extract_info(url, download=False) if 'entries' in data: data = data['entries'][0] title = data.get('title') url = data.get('url') - await self.queue.put((ctx, url, title)) + await self._queue.put((ctx, url, title)) if ctx.voice_client.is_playing(): await ctx.send(f"Queued: {title}") From 8ee2fc2682003bf35973ae28563037457a3c7e7d Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 25 Sep 2021 03:00:09 +0200 Subject: [PATCH 06/12] Rename url to query --- src/bot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bot.py b/src/bot.py index 9026522..c43c21d 100644 --- a/src/bot.py +++ b/src/bot.py @@ -56,9 +56,9 @@ class Music(commands.Cog): await ctx.send(f"Now playing: {title}") @commands.command() - async def play(self, ctx, *, url): + async def play(self, ctx, *, query): async with ctx.typing(): - data = self._ytdl.extract_info(url, download=False) + data = self._ytdl.extract_info(query, download=False) if 'entries' in data: data = data['entries'][0] title = data.get('title') From d96108119eb1e1bcd2ff78d1986ce721b9a880b8 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 25 Sep 2021 03:00:20 +0200 Subject: [PATCH 07/12] Update tests --- test/test_bot.py | 53 ++++++++++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/test/test_bot.py b/test/test_bot.py index 1f0b6a6..230c8a7 100644 --- a/test/test_bot.py +++ b/test/test_bot.py @@ -1,3 +1,4 @@ +import asyncio from unittest.mock import Mock, AsyncMock, patch import pytest @@ -10,28 +11,14 @@ from bot import Music async def test_bot_ensure_voice(bot, ctx): mbot = Music(bot) - # 1. Inside a voice channel - # 1.1 Does not call stop if no sound is playing - ctx.voice_client.is_playing.return_value = False - await mbot.ensure_voice(ctx) - assert ctx.voice_client.stop.call_count == 0 - ctx.reset_mock(return_value=True) - - # 1.2 Does call stop if sound is playing - ctx.voice_client.is_playing.return_value = True - await mbot.ensure_voice(ctx) - assert ctx.voice_client.stop.call_count == 1 - ctx.reset_mock(return_value=True) - - # 2. Not inside a voice channel - # 2.1 Connects to voice channel of author if possible + # Connects to voice channel of author if possible ctx.voice_client = None ctx.author.voice = AsyncMock() await mbot.ensure_voice(ctx) assert ctx.author.voice.channel.connect.call_count == 1 ctx.reset_mock(return_value=True) - # 2.2 Error if author not inside a channel + # Error if author not inside a channel ctx.voice_client = None ctx.author.voice = None with pytest.raises(commands.CommandError): @@ -42,13 +29,27 @@ async def test_bot_ensure_voice(bot, ctx): async def test_bot_play(bot, ctx): mbot = Music(bot) - with patch('bot.YTDLSource', new_callable=AsyncMock) as ytdl_source: - player = Mock() - ytdl_source.from_url.return_value = player - url = 'A Day To Remember - All I Want' - # pylint: disable=too-many-function-args - await mbot.play(mbot, ctx, url=url) - assert ytdl_source.from_url.await_args.args == (url,) - assert ytdl_source.from_url.await_args.kwargs == {"loop": bot.loop, "stream": True} - assert ctx.voice_client.play.call_args.args == (player,) - assert ctx.send.call_count == 1 + with patch.object(mbot, '_ytdl') as ytdl: + with patch('discord.FFmpegPCMAudio') as ffmpeg_pcm_audio: + ctx.voice_client.is_playing.return_value = False + url = "https://www.youtube.com/watch?v=Wr9LZ1hAFpQ" + title = "In Flames - Deliver Us (Official Video)" + ytdl.extract_info.return_value = { + "entries": [ + { + "url": url, + "title": title + } + ] + } + audio = Mock() + ffmpeg_pcm_audio.return_value = audio + query = 'in flames deliver us' + # pylint: disable=too-many-function-args + await mbot.play(mbot, ctx, query=query) + await asyncio.sleep(0) + assert ytdl.extract_info.call_args.args == (query,) + assert ytdl.extract_info.call_args.kwargs == {"download": False} + assert ffmpeg_pcm_audio.call_args.args == (url,) + assert ctx.voice_client.play.call_args.args == (audio,) + assert ctx.send.call_count == 1 From f6ee8ff63f2f762d7a60e6190187be135b583bb1 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 25 Sep 2021 03:49:46 +0200 Subject: [PATCH 08/12] Move mbot creation into fixture --- test/conftest.py | 8 ++++++++ test/test_bot.py | 10 ++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/test/conftest.py b/test/conftest.py index 1fa415c..78725ff 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -9,6 +9,8 @@ from pytest_mock import MockerFixture SRC_PATH = str(Path(__file__).parent / '..' / 'src') sys.path.insert(0, SRC_PATH) +from bot import Music + @pytest.fixture def bot(mocker: MockerFixture): @@ -17,6 +19,12 @@ def bot(mocker: MockerFixture): yield bot_mock +@pytest.fixture +def mbot(bot): + mbot_mock = Music(bot) + yield mbot_mock + + @pytest.fixture def ctx(mocker: MockerFixture): yield mocker.patch('discord.ext.commands.Context', autospec=True) diff --git a/test/test_bot.py b/test/test_bot.py index 230c8a7..d04596c 100644 --- a/test/test_bot.py +++ b/test/test_bot.py @@ -4,13 +4,9 @@ from unittest.mock import Mock, AsyncMock, patch import pytest from discord.ext import commands -from bot import Music - @pytest.mark.asyncio -async def test_bot_ensure_voice(bot, ctx): - mbot = Music(bot) - +async def test_bot_ensure_voice(mbot, ctx): # Connects to voice channel of author if possible ctx.voice_client = None ctx.author.voice = AsyncMock() @@ -26,9 +22,7 @@ async def test_bot_ensure_voice(bot, ctx): @pytest.mark.asyncio -async def test_bot_play(bot, ctx): - mbot = Music(bot) - +async def test_bot_play(mbot, ctx): with patch.object(mbot, '_ytdl') as ytdl: with patch('discord.FFmpegPCMAudio') as ffmpeg_pcm_audio: ctx.voice_client.is_playing.return_value = False From ac6b8783ba33483248b56a819bc4c7effebb1662 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 25 Sep 2021 16:01:34 +0200 Subject: [PATCH 09/12] Add cog_unload and before_loop --- src/bot.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/bot.py b/src/bot.py index c43c21d..c5c4f98 100644 --- a/src/bot.py +++ b/src/bot.py @@ -40,6 +40,10 @@ class Music(commands.Cog): # pylint: disable=no-member self._handle_playback.start() + def cog_unload(self): + # pylint: disable=no-member + self._handle_playback.cancel() + @tasks.loop() async def _handle_playback(self): while True: @@ -55,6 +59,10 @@ class Music(commands.Cog): ctx.voice_client.play(audio, after=after) await ctx.send(f"Now playing: {title}") + @_handle_playback.before_loop + async def before_handle_playback(self): + await self._bot.wait_until_ready() + @commands.command() async def play(self, ctx, *, query): async with ctx.typing(): From 247fb5a6d498f54291d557fbf51ccd3025e7f83a Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 25 Sep 2021 16:48:33 +0200 Subject: [PATCH 10/12] Proper cleanup after tests --- test/conftest.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/conftest.py b/test/conftest.py index 78725ff..65744e7 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,3 +1,4 @@ +import asyncio import sys from pathlib import Path from unittest.mock import Mock @@ -23,8 +24,17 @@ def bot(mocker: MockerFixture): def mbot(bot): mbot_mock = Music(bot) yield mbot_mock + mbot_mock.cog_unload() @pytest.fixture def ctx(mocker: MockerFixture): yield mocker.patch('discord.ext.commands.Context', autospec=True) + + +@pytest.fixture +def event_loop(): + loop = asyncio.get_event_loop() + yield loop + pending = asyncio.all_tasks(loop=loop) + asyncio.ensure_future(asyncio.gather(*pending)) From 068e65f4684d0caab6100915e281814ffdd55a90 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 25 Sep 2021 18:05:46 +0200 Subject: [PATCH 11/12] Improve queue test --- test/test_bot.py | 81 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 65 insertions(+), 16 deletions(-) diff --git a/test/test_bot.py b/test/test_bot.py index d04596c..bddb1cb 100644 --- a/test/test_bot.py +++ b/test/test_bot.py @@ -21,29 +21,78 @@ async def test_bot_ensure_voice(mbot, ctx): await mbot.ensure_voice(ctx) +def mock_ytdl_extract_info(ytdl, url, title): + ytdl.extract_info.return_value = {"entries": [{"url": url, "title": title}]} + + +def mock_ffmpeg_pcm_audio(ffmpeg_pcm_audio): + deliver_us_audio = Mock() + ffmpeg_pcm_audio.return_value = deliver_us_audio + return deliver_us_audio + + @pytest.mark.asyncio -async def test_bot_play(mbot, ctx): +async def test_bot_playback(mbot, ctx): with patch.object(mbot, '_ytdl') as ytdl: with patch('discord.FFmpegPCMAudio') as ffmpeg_pcm_audio: ctx.voice_client.is_playing.return_value = False url = "https://www.youtube.com/watch?v=Wr9LZ1hAFpQ" title = "In Flames - Deliver Us (Official Video)" - ytdl.extract_info.return_value = { - "entries": [ - { - "url": url, - "title": title - } - ] - } - audio = Mock() - ffmpeg_pcm_audio.return_value = audio + mock_ytdl_extract_info(ytdl, url, title) + deliver_us_audio = mock_ffmpeg_pcm_audio(ffmpeg_pcm_audio) query = 'in flames deliver us' # pylint: disable=too-many-function-args await mbot.play(mbot, ctx, query=query) + assert \ + ytdl.extract_info.call_args.args == (query,) and ytdl.extract_info.call_args.kwargs == {"download": False}, \ + f"ytdl.extract_info was not called with {query}, {{ download: False }}" + assert \ + ffmpeg_pcm_audio.call_args is None, \ + f"FFmpegPCMAudio was immediately called with {url} instead of being queued" + assert \ + ctx.voice_client.play.call_args is None, \ + "Did immediately playback audio instead of being queued" + assert \ + ctx.send.call_args is None, \ + "Did immediately send 'Now playing:' message of being queued" await asyncio.sleep(0) - assert ytdl.extract_info.call_args.args == (query,) - assert ytdl.extract_info.call_args.kwargs == {"download": False} - assert ffmpeg_pcm_audio.call_args.args == (url,) - assert ctx.voice_client.play.call_args.args == (audio,) - assert ctx.send.call_count == 1 + assert \ + ffmpeg_pcm_audio.call_args.args == (url,), \ + f"FFmpegPCMAudio was not called with {url}" + assert \ + ctx.voice_client.play.call_args.args == (deliver_us_audio,), \ + "Did not playback correct audio" + assert \ + ctx.send.call_args.args == (f"Now playing: {title}",), \ + "Did not send 'Now playing:' message" + + ctx.voice_client.is_playing.return_value = True + url = "https://www.youtube.com/watch?v=pMDcYX2wRSg" + title = "Three Days Grace - Time of Dying (lyrics)" + mock_ytdl_extract_info(ytdl, url, title) + time_of_dying_audio = mock_ffmpeg_pcm_audio(ffmpeg_pcm_audio) + # pylint: disable=too-many-function-args + query = "three days grace time of dying" + await mbot.play(mbot, ctx, query=query) + assert \ + ytdl.extract_info.call_args.args == (query,) and ytdl.extract_info.call_args.kwargs == {"download": False}, \ + f"ytdl.extract_info was not called with {query}, {{ download: False }}" + assert \ + not ffmpeg_pcm_audio.call_args.args == (url,), \ + f"FFmpegPCMAudio was immediately called with {url} instead of being queued" + assert \ + not ctx.voice_client.play.call_args.args == (time_of_dying_audio,), \ + "Did immediately playback audio instead of being queued" + assert \ + ctx.send.call_args.args == (f"Queued: {title}",), \ + "Did not send 'Queued:' message" + await asyncio.sleep(0) + # Still no playback because previous song not finished + assert \ + not ffmpeg_pcm_audio.call_args.args == (url,), \ + f"FFmpegPCMAudio was called with {url} before previous song finished" + ctx.voice_client.play.call_args.kwargs["after"](None) # Execute callback for song finish event + await asyncio.sleep(0) + assert \ + ffmpeg_pcm_audio.call_args.args == (url,), \ + f"FFmpegPCMAudio was not called with {url}" From e7de722d24b88b319f916c95bccb93c7d7a662a9 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 25 Sep 2021 18:06:01 +0200 Subject: [PATCH 12/12] Add assertion message --- test/test_bot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_bot.py b/test/test_bot.py index bddb1cb..4595652 100644 --- a/test/test_bot.py +++ b/test/test_bot.py @@ -11,7 +11,7 @@ async def test_bot_ensure_voice(mbot, ctx): ctx.voice_client = None ctx.author.voice = AsyncMock() await mbot.ensure_voice(ctx) - assert ctx.author.voice.channel.connect.call_count == 1 + assert ctx.author.voice.channel.connect.call_count == 1, "Did not connect to voice channel of author" ctx.reset_mock(return_value=True) # Error if author not inside a channel