diff options
| -rw-r--r-- | bot/constants.py | 7 | ||||
| -rw-r--r-- | bot/exts/moderation/defcon.py | 4 | ||||
| -rw-r--r-- | bot/exts/moderation/stream.py | 179 | ||||
| -rw-r--r-- | bot/resources/tags/ytdl.md | 8 | ||||
| -rw-r--r-- | bot/utils/scheduling.py | 8 | ||||
| -rw-r--r-- | config-default.yml | 7 | 
6 files changed, 205 insertions, 8 deletions
diff --git a/bot/constants.py b/bot/constants.py index 4884a1278..547a94a0b 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -482,6 +482,7 @@ class Roles(metaclass=YAMLGetter):      python_community: int      sprinters: int      voice_verified: int +    video: int      admins: int      core_developers: int @@ -661,6 +662,12 @@ class Event(Enum):      voice_state_update = "voice_state_update" +class VideoPermission(metaclass=YAMLGetter): +    section = "video_permission" + +    default_permission_duration: int + +  # Debug mode  DEBUG_MODE = 'local' in os.environ.get("SITE_URL", "local") diff --git a/bot/exts/moderation/defcon.py b/bot/exts/moderation/defcon.py index bab95405c..dfb1afd19 100644 --- a/bot/exts/moderation/defcon.py +++ b/bot/exts/moderation/defcon.py @@ -181,7 +181,7 @@ class Defcon(Cog):          role = ctx.guild.default_role          permissions = role.permissions -        permissions.update(send_messages=False, add_reactions=False) +        permissions.update(send_messages=False, add_reactions=False, connect=False)          await role.edit(reason="DEFCON shutdown", permissions=permissions)          await ctx.send(f"{Action.SERVER_SHUTDOWN.value.emoji} Server shut down.") @@ -192,7 +192,7 @@ class Defcon(Cog):          role = ctx.guild.default_role          permissions = role.permissions -        permissions.update(send_messages=True, add_reactions=True) +        permissions.update(send_messages=True, add_reactions=True, connect=True)          await role.edit(reason="DEFCON unshutdown", permissions=permissions)          await ctx.send(f"{Action.SERVER_OPEN.value.emoji} Server reopened.") diff --git a/bot/exts/moderation/stream.py b/bot/exts/moderation/stream.py new file mode 100644 index 000000000..12e195172 --- /dev/null +++ b/bot/exts/moderation/stream.py @@ -0,0 +1,179 @@ +import logging +from datetime import timedelta, timezone + +import arrow +import discord +from arrow import Arrow +from async_rediscache import RedisCache +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours, Emojis, Guild, Roles, STAFF_ROLES, VideoPermission +from bot.converters import Expiry +from bot.utils.scheduling import Scheduler +from bot.utils.time import format_infraction_with_duration + +log = logging.getLogger(__name__) + + +class Stream(commands.Cog): +    """Grant and revoke streaming permissions from members.""" + +    # Stores tasks to remove streaming permission +    # RedisCache[discord.Member.id, UtcPosixTimestamp] +    task_cache = RedisCache() + +    def __init__(self, bot: Bot): +        self.bot = bot +        self.scheduler = Scheduler(self.__class__.__name__) +        self.reload_task = self.bot.loop.create_task(self._reload_tasks_from_redis()) + +    def cog_unload(self) -> None: +        """Cancel all scheduled tasks.""" +        self.reload_task.cancel() +        self.reload_task.add_done_callback(lambda _: self.scheduler.cancel_all()) + +    async def _revoke_streaming_permission(self, member: discord.Member) -> None: +        """Remove the streaming permission from the given Member.""" +        await self.task_cache.delete(member.id) +        await member.remove_roles(discord.Object(Roles.video), reason="Streaming access revoked") + +    async def _reload_tasks_from_redis(self) -> None: +        """Reload outstanding tasks from redis on startup, delete the task if the member has since left the server.""" +        await self.bot.wait_until_guild_available() +        items = await self.task_cache.items() +        for key, value in items: +            member = self.bot.get_guild(Guild.id).get_member(key) + +            if not member: +                # Member isn't found in the cache +                try: +                    member = await self.bot.get_guild(Guild.id).fetch_member(key) +                except discord.errors.NotFound: +                    log.debug( +                        f"Member {key} left the guild before we could schedule " +                        "the revoking of their streaming permissions." +                    ) +                    await self.task_cache.delete(key) +                    continue +                except discord.HTTPException: +                    log.exception(f"Exception while trying to retrieve member {key} from Discord.") +                    continue + +            revoke_time = Arrow.utcfromtimestamp(value) +            log.debug(f"Scheduling {member} ({member.id}) to have streaming permission revoked at {revoke_time}") +            self.scheduler.schedule_at( +                revoke_time, +                key, +                self._revoke_streaming_permission(member) +            ) + +    @commands.command(aliases=("streaming",)) +    @commands.has_any_role(*STAFF_ROLES) +    async def stream(self, ctx: commands.Context, member: discord.Member, duration: Expiry = None) -> None: +        """ +        Temporarily grant streaming permissions to a member for a given duration. + +        A unit of time should be appended to the duration. +        Units (∗case-sensitive): +        \u2003`y` - years +        \u2003`m` - months∗ +        \u2003`w` - weeks +        \u2003`d` - days +        \u2003`h` - hours +        \u2003`M` - minutes∗ +        \u2003`s` - seconds + +        Alternatively, an ISO 8601 timestamp can be provided for the duration. +        """ +        log.trace(f"Attempting to give temporary streaming permission to {member} ({member.id}).") + +        if duration is None: +            # Use default duration and convert back to datetime as Embed.timestamp doesn't support Arrow +            duration = arrow.utcnow() + timedelta(minutes=VideoPermission.default_permission_duration) +            duration = duration.datetime +        elif duration.tzinfo is None: +            # Make duration tz-aware. +            # ISODateTime could already include tzinfo, this check is so it isn't overwritten. +            duration.replace(tzinfo=timezone.utc) + +        # Check if the member already has streaming permission +        already_allowed = any(Roles.video == role.id for role in member.roles) +        if already_allowed: +            await ctx.send(f"{Emojis.cross_mark} {member.mention} can already stream.") +            log.debug(f"{member} ({member.id}) already has permission to stream.") +            return + +        # Schedule task to remove streaming permission from Member and add it to task cache +        self.scheduler.schedule_at(duration, member.id, self._revoke_streaming_permission(member)) +        await self.task_cache.set(member.id, duration.timestamp()) + +        await member.add_roles(discord.Object(Roles.video), reason="Temporary streaming access granted") + +        # Use embed as embed timestamps do timezone conversions. +        embed = discord.Embed( +            description=f"{Emojis.check_mark} {member.mention} can now stream.", +            colour=Colours.soft_green +        ) +        embed.set_footer(text=f"Streaming permission has been given to {member} until") +        embed.timestamp = duration + +        # Mention in content as mentions in embeds don't ping +        await ctx.send(content=member.mention, embed=embed) + +        # Convert here for nicer logging +        revoke_time = format_infraction_with_duration(str(duration)) +        log.debug(f"Successfully gave {member} ({member.id}) permission to stream until {revoke_time}.") + +    @commands.command(aliases=("pstream",)) +    @commands.has_any_role(*STAFF_ROLES) +    async def permanentstream(self, ctx: commands.Context, member: discord.Member) -> None: +        """Permanently grants the given member the permission to stream.""" +        log.trace(f"Attempting to give permanent streaming permission to {member} ({member.id}).") + +        # Check if the member already has streaming permission +        if any(Roles.video == role.id for role in member.roles): +            if member.id in self.scheduler: +                # Member has temp permission, so cancel the task to revoke later and delete from cache +                self.scheduler.cancel(member.id) +                await self.task_cache.delete(member.id) + +                await ctx.send(f"{Emojis.check_mark} Permanently granted {member.mention} the permission to stream.") +                log.debug( +                    f"Successfully upgraded temporary streaming permission for {member} ({member.id}) to permanent." +                ) +                return + +            await ctx.send(f"{Emojis.cross_mark} This member can already stream.") +            log.debug(f"{member} ({member.id}) already had permanent streaming permission.") +            return + +        await member.add_roles(discord.Object(Roles.video), reason="Permanent streaming access granted") +        await ctx.send(f"{Emojis.check_mark} Permanently granted {member.mention} the permission to stream.") +        log.debug(f"Successfully gave {member} ({member.id}) permanent streaming permission.") + +    @commands.command(aliases=("unstream", "rstream")) +    @commands.has_any_role(*STAFF_ROLES) +    async def revokestream(self, ctx: commands.Context, member: discord.Member) -> None: +        """Revoke the permission to stream from the given member.""" +        log.trace(f"Attempting to remove streaming permission from {member} ({member.id}).") + +        # Check if the member already has streaming permission +        if any(Roles.video == role.id for role in member.roles): +            if member.id in self.scheduler: +                # Member has temp permission, so cancel the task to revoke later and delete from cache +                self.scheduler.cancel(member.id) +                await self.task_cache.delete(member.id) +            await self._revoke_streaming_permission(member) + +            await ctx.send(f"{Emojis.check_mark} Revoked the permission to stream from {member.mention}.") +            log.debug(f"Successfully revoked streaming permission from {member} ({member.id}).") +            return + +        await ctx.send(f"{Emojis.cross_mark} This member doesn't have video permissions to remove!") +        log.debug(f"{member} ({member.id}) didn't have the streaming permission to remove!") + + +def setup(bot: Bot) -> None: +    """Loads the Stream cog.""" +    bot.add_cog(Stream(bot)) diff --git a/bot/resources/tags/ytdl.md b/bot/resources/tags/ytdl.md index e34ecff44..df28024a0 100644 --- a/bot/resources/tags/ytdl.md +++ b/bot/resources/tags/ytdl.md @@ -1,12 +1,12 @@  Per [PyDis' Rule 5](https://pythondiscord.com/pages/rules), we are unable to assist with questions related to youtube-dl, commonly used by Discord bots to stream audio, as its use violates YouTube's Terms of Service. -For reference, this usage is covered by the following clauses in [YouTube's TOS](https://www.youtube.com/static?template=terms), as of 2019-07-22: +For reference, this usage is covered by the following clauses in [YouTube's TOS](https://www.youtube.com/static?gl=GB&template=terms), as of 2021-03-17:  ``` -The following restrictions apply to your use of the Service. You are not allowed to:   +The following restrictions apply to your use of the Service. You are not allowed to: -1. access, reproduce, download, distribute, transmit, broadcast, display, sell, license, alter, modify or otherwise use any part of the Service or any Content except: (a) as specifically permitted by the Service;  (b) with prior written permission from YouTube and, if applicable, the respective rights holders; or (c) as permitted by applicable law;   +1. access, reproduce, download, distribute, transmit, broadcast, display, sell, license, alter, modify or otherwise use any part of the Service or any Content except: (a) as specifically permitted by the Service;  (b) with prior written permission from YouTube and, if applicable, the respective rights holders; or (c) as permitted by applicable law; -3. access the Service using any automated means (such as robots, botnets or scrapers) except: (a) in the case of public search engines, in accordance with YouTube’s robots.txt file; (b) with YouTube’s prior written permission; or (c) as permitted by applicable law;   +3. access the Service using any automated means (such as robots, botnets or scrapers) except: (a) in the case of public search engines, in accordance with YouTube’s robots.txt file; (b) with YouTube’s prior written permission; or (c) as permitted by applicable law;  9. use the Service to view or listen to Content other than for personal, non-commercial use (for example, you may not publicly screen videos or stream music from the Service)  ``` diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 4dd036e4f..6843bae88 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -59,14 +59,18 @@ class Scheduler:      def schedule_at(self, time: datetime, task_id: t.Hashable, coroutine: t.Coroutine) -> None:          """ -        Schedule `coroutine` to be executed at the given naïve UTC `time`. +        Schedule `coroutine` to be executed at the given `time`. + +        If `time` is timezone aware, then use that timezone to calculate now() when subtracting. +        If `time` is naïve, then use UTC.          If `time` is in the past, schedule `coroutine` immediately.          If a task with `task_id` already exists, close `coroutine` instead of scheduling it. This          prevents unawaited coroutine warnings. Don't pass a coroutine that'll be re-used elsewhere.          """ -        delay = (time - datetime.utcnow()).total_seconds() +        now_datetime = datetime.now(time.tzinfo) if time.tzinfo else datetime.utcnow() +        delay = (time - now_datetime).total_seconds()          if delay > 0:              coroutine = self._await_later(delay, task_id, coroutine) diff --git a/config-default.yml b/config-default.yml index b89f1505e..9b07d026d 100644 --- a/config-default.yml +++ b/config-default.yml @@ -267,6 +267,9 @@ guild:          jammers:        737249140966162473          team_leaders:   737250302834638889 +        # Streaming +        video:          764245844798079016 +      moderation_roles:          - *ADMINS_ROLE          - *MODS_ROLE @@ -542,3 +545,7 @@ branding:  config:      required_keys: ['bot.token'] + + +video_permission: +    default_permission_duration: 5  # Default duration for stream command in minutes  |