Add Autoroom cog
Some checks are pending
Run pre-commit / Run pre-commit (push) Waiting to run

This commit is contained in:
Valerie 2025-06-13 19:15:32 -04:00
parent 6d50c3279d
commit 7fc83b2022
10 changed files with 4059 additions and 0 deletions

63
autoroom/README.md Normal file
View file

@ -0,0 +1,63 @@
# AutoRoom
This cog facilitates automatic voice channel creation. When a member joins an AutoRoom Source (voice channel), this cog will move them to a brand new AutoRoom that they have control over. Once everyone leaves the AutoRoom, it is automatically deleted.
## For Members - `[p]autoroom`
Once you join an AutoRoom Source, you will be moved into a brand new AutoRoom (voice channel). This is your AutoRoom, you can do whatever you want with it. Use the `[p]autoroom` command to check out all the different things you can do. Some examples include:
- Check its current settings with `[p]autoroom settings`
- Make it a public AutoRoom with `[p]autoroom public` (everyone can see and join your AutoRoom)
- Make it a locked AutoRoom with `[p]autoroom locked` (everyone can see, but nobody can join your AutoRoom)
- Make it a private AutoRoom with `[p]autoroom private` (nobody can see and join your AutoRoom)
- Kick/ban users (or entire roles) from your AutoRoom with `[p]autoroom deny` (useful for public AutoRooms)
- Allow users (or roles) into your AutoRoom with `[p]autoroom allow` (useful for locked and private AutoRooms)
- You can manage the messages in your AutoRooms associated text channel
When everyone leaves your AutoRoom, it will automatically be deleted.
## For Server Admins - `[p]autoroomset`
Start by having a voice channel, and a category (the voice channel does not need to be in the category, but it can be if you want). The voice channel will be the "AutoRoom Source", where your members will join and then be moved into their own AutoRoom (voice channel). These AutoRooms will be created in the category that you choose.
```
[p]autoroomset create <source_voice_channel> <dest_category>
```
This command will guide you through setting up an AutoRoom Source by asking some questions. If you get a warning about missing permissions, take a look at `[p]autoroomset permissions`, grant the missing permissions, and then run the command again. Otherwise, answer the questions, and you'll have a new AutoRoom Source. Give it a try by joining it: if all goes well, you will be moved to a new AutoRoom, where you can do all of the `[p]autoroom` commands.
There are some additional configuration options for AutoRoom Sources that you can set by using `[p]autoroomset modify`. You can also check out `[p]autoroomset access`, which controls whether admins (default yes) or moderators (default no) can see and join private AutoRooms. For an overview of all of your settings, use `[p]autoroomset settings`.
#### Member Roles and Hidden Sources
The AutoRoom Source will behave in a certain way, depending on what permissions it has. If the `@everyone` role is denied the connect permission (and optionally the view channel permission), the AutoRoom Source and AutoRooms will behave in a member role style. Any roles that are allowed to connect on the AutoRoom Source will be considered the "member roles". Only members of the server with one or more of these roles will be able to utilize these AutoRooms and their Sources.
For hidden AutoRoom Sources, you can deny the view channel permission for the `@everyone` role, but still allow the connect permission. The members won't be able to see the AutoRoom Source, but any AutoRooms it creates they will be able to see (depending on if it isn't a private AutoRoom). Ideally you would have a role that is allowed to see the AutoRoom Source, that role is allowed to create AutoRooms, but then can invite anyone to their AutoRooms.
You can of course do both of these, where the `@everyone` role is denied view channel and connect, and your member role is denied the view channel permission, but is allowed the connect permission. Non-members will never see the AutoRoom Source and AutoRooms, and the members will not see the AutoRoom Source, but will see AutoRooms.
#### Templates
The default AutoRoom name format is based on the AutoRoom Owners username. Using `[p]autoroomset modify name`, you can choose a default format, or you can set a custom format. For custom formats, you have a couple of variables you can use in your template:
- `{{username}}` - The AutoRoom Owners username
- `{{game}}` - The AutoRoom Owners game they were playing when the AutoRoom was created, or blank if they were not plating a game.
- `{{dupenum}}` - A number, starting at 1, that will increment when the generated AutoRoom name would be a duplicate of an already existing AutoRoom.
You are also able to use `if`/`elif`/`else`/`endif` statements in order to conditionally show or hide parts of your template. Here are the templates for the two default formats included:
- `username` - `{{username}}'s Room{% if dupenum > 1 %} ({{dupenum}}){% endif %}`
- `game` - `{{game}}{% if not game %}{{username}}'s Room{% endif %}{% if dupenum > 1 %} ({{dupenum}}){% endif %}`
The username format is pretty self-explanatory: put the username, along with "'s Room" after it. For the game format, we put the game name, and then if there isn't a game, show `{{username}}'s Room` instead. Remember, if no game is being played, `{{game}}` won't return anything.
The last bit of both of these is `{% if dupenum > 1 %} ({{dupenum}}){% endif %}`. With this, we are checking if `dupenum` is greater than 1. If it is, we display ` ({{dupenum}})` at the end of our room name. This way, only duplicate named rooms will ever get a ` (2)`, ` (3)`, etc. appended to them, no ` (1)` will be shown.
Finally, you can use filters in order to format your variables. They are specified by adding a pipe, and then the name of the filter. The following are the currently implemented filters:
- `{{username | lower}}` - Will lowercase the variable, the username in this example
- `{{game | upper}}` - Will uppercase the variable, the game name in this example
This template format can also be used for the message hint sent to new AutoRooms built in text channels. For that, you can also use this variable:
- `{{mention}}` - The AutoRoom Owners mention

18
autoroom/__init__.py Normal file
View file

@ -0,0 +1,18 @@
"""Package for AutoRoom cog."""
import json
from pathlib import Path
from redbot.core.bot import Red
from .autoroom import AutoRoom
with Path(__file__).parent.joinpath("info.json").open() as fp:
__red_end_user_data_statement__ = json.load(fp)["end_user_data_statement"]
async def setup(bot: Red) -> None:
"""Load AutoRoom cog."""
cog = AutoRoom(bot)
await cog.initialize()
await bot.add_cog(cog)

103
autoroom/abc.py Normal file
View file

@ -0,0 +1,103 @@
"""ABC for the AutoRoom Cog."""
from abc import ABC, abstractmethod
from typing import Any, ClassVar
import discord
from discord.ext.commands import CooldownMapping
from redbot.core import Config
from redbot.core.bot import Red
from autoroom.pcx_template import Template
class MixinMeta(ABC):
"""Base class for well-behaved type hint detection with composite class.
Basically, to keep developers sane when not all attributes are defined in each mixin.
"""
bot: Red
config: Config
template: Template
bucket_autoroom_name: CooldownMapping
bucket_autoroom_owner_claim: CooldownMapping
extra_channel_name_change_delay: int
perms_legacy_text_allow: ClassVar[dict[str, bool]]
perms_legacy_text_reset: ClassVar[dict[str, None]]
perms_autoroom_owner_legacy_text: ClassVar[dict[str, bool]]
@staticmethod
@abstractmethod
def get_template_data(member: discord.Member | discord.User) -> dict[str, str]:
raise NotImplementedError
@abstractmethod
async def format_template_room_name(
self, template: str, data: dict, num: int = 1
) -> str:
raise NotImplementedError
@abstractmethod
async def is_admin_or_admin_role(self, who: discord.Role | discord.Member) -> bool:
raise NotImplementedError
@abstractmethod
async def is_mod_or_mod_role(self, who: discord.Role | discord.Member) -> bool:
raise NotImplementedError
@abstractmethod
def check_perms_source_dest(
self,
autoroom_source: discord.VoiceChannel,
category_dest: discord.CategoryChannel,
*,
with_manage_roles_guild: bool = False,
with_legacy_text_channel: bool = False,
with_optional_clone_perms: bool = False,
detailed: bool = False,
) -> tuple[bool, bool, str | None]:
raise NotImplementedError
@abstractmethod
async def get_all_autoroom_source_configs(
self, guild: discord.Guild
) -> dict[int, dict[str, Any]]:
raise NotImplementedError
@abstractmethod
async def get_autoroom_source_config(
self, autoroom_source: discord.VoiceChannel | discord.abc.GuildChannel | None
) -> dict[str, Any] | None:
raise NotImplementedError
@abstractmethod
async def get_autoroom_info(
self, autoroom: discord.VoiceChannel | None
) -> dict[str, Any] | None:
raise NotImplementedError
@abstractmethod
async def get_autoroom_legacy_text_channel(
self, autoroom: discord.VoiceChannel | int | None
) -> discord.TextChannel | None:
raise NotImplementedError
@staticmethod
@abstractmethod
def check_if_member_or_role_allowed(
channel: discord.VoiceChannel,
member_or_role: discord.Member | discord.Role,
) -> bool:
raise NotImplementedError
@abstractmethod
def get_member_roles(
self, autoroom_source: discord.VoiceChannel
) -> list[discord.Role]:
raise NotImplementedError
@abstractmethod
async def get_bot_roles(self, guild: discord.Guild) -> list[discord.Role]:
raise NotImplementedError

1064
autoroom/autoroom.py Normal file

File diff suppressed because it is too large Load diff

517
autoroom/c_autoroom.py Normal file
View file

@ -0,0 +1,517 @@
"""The autoroom command."""
import datetime
from abc import ABC
from typing import Any
import discord
from redbot.core import commands
from redbot.core.utils.chat_formatting import error, humanize_timedelta
from .abc import MixinMeta
from .pcx_lib import Perms, SettingDisplay, delete
MAX_CHANNEL_NAME_LENGTH = 100
class AutoRoomCommands(MixinMeta, ABC):
"""The autoroom command."""
@commands.group(aliases=["vc"])
@commands.guild_only()
async def autoroom(self, ctx: commands.Context) -> None:
"""Manage your AutoRoom.
For a quick rundown on how to manage your AutoRoom,
check out [the readme](https://github.com/PhasecoreX/PCXCogs/tree/master/autoroom/README.md)
"""
@autoroom.command(name="settings", aliases=["about", "info"])
async def autoroom_settings(self, ctx: commands.Context) -> None:
"""Display current settings."""
if not ctx.guild:
return
autoroom_channel, autoroom_info = await self._get_autoroom_channel_and_info(
ctx, check_owner=False
)
if not autoroom_channel or not autoroom_info:
return
room_settings = SettingDisplay("Room Settings")
owner = ctx.guild.get_member(autoroom_info["owner"])
if owner:
room_settings.add(
"Owner",
owner.display_name,
)
else:
room_settings.add(
"Mode",
"Server Managed",
)
source_channel = ctx.guild.get_channel(autoroom_info["source_channel"])
if isinstance(source_channel, discord.VoiceChannel):
member_roles = self.get_member_roles(source_channel)
access_text = ""
if member_roles:
for role in member_roles:
autoroom_type = self._get_autoroom_type(autoroom_channel, role)
if not access_text:
access_text = autoroom_type
elif access_text != autoroom_type:
# Multiple member roles present, and we can't determine the autoroom type
access_text = "custom"
break
else:
access_text = self._get_autoroom_type(
autoroom_channel, autoroom_channel.guild.default_role
)
access_text = access_text.capitalize()
if member_roles:
access_text += ", only certain roles"
room_settings.add("Access", access_text)
if member_roles:
room_settings.add(
"Member Roles", ", ".join(role.name for role in member_roles)
)
room_settings.add("Bitrate", f"{autoroom_channel.bitrate // 1000}kbps")
room_settings.add(
"Channel Age",
humanize_timedelta(
timedelta=datetime.datetime.now(datetime.UTC)
- autoroom_channel.created_at
),
)
access_settings = SettingDisplay("Current Access Settings")
allowed_members = []
allowed_roles = []
denied_members = []
denied_roles = []
for member_or_role in autoroom_channel.overwrites:
if isinstance(member_or_role, discord.Role):
if self.check_if_member_or_role_allowed(
autoroom_channel, member_or_role
):
allowed_roles.append(member_or_role)
else:
denied_roles.append(member_or_role)
elif isinstance(member_or_role, discord.Member):
if self.check_if_member_or_role_allowed(
autoroom_channel, member_or_role
):
allowed_members.append(member_or_role)
else:
denied_members.append(member_or_role)
if allowed_members:
access_settings.add(
"Allowed Members",
", ".join(member.display_name for member in allowed_members),
)
if allowed_roles:
access_settings.add(
"Allowed Roles", ", ".join(role.name for role in allowed_roles)
)
if denied_members:
access_settings.add(
"Denied Members",
", ".join(member.display_name for member in denied_members),
)
if denied_roles:
access_settings.add(
"Denied Roles", ", ".join(role.name for role in denied_roles)
)
await ctx.send(str(room_settings.display(access_settings)))
@autoroom.command(name="name")
async def autoroom_name(self, ctx: commands.Context, *, name: str) -> None:
"""Change the name of your AutoRoom."""
if not ctx.guild:
return
autoroom_channel, autoroom_info = await self._get_autoroom_channel_and_info(ctx)
if not autoroom_channel or not autoroom_info:
return
if len(name) > MAX_CHANNEL_NAME_LENGTH:
name = name[:MAX_CHANNEL_NAME_LENGTH]
if name != autoroom_channel.name:
bucket = self.bucket_autoroom_name.get_bucket(autoroom_channel)
if bucket:
retry_after = bucket.update_rate_limit()
if retry_after:
per_display = bucket.per - self.extra_channel_name_change_delay
hint_text = error(
f"{ctx.message.author.mention}, you can only modify an AutoRoom name **{bucket.rate}** times "
f"every **{humanize_timedelta(seconds=per_display)}** with this command. "
f"You can try again in **{humanize_timedelta(seconds=max(1, int(min(per_display, retry_after))))}**."
"\n\n"
"Alternatively, you can modify the channel yourself by either right clicking the channel on "
"desktop or by long pressing it on mobile."
)
if ctx.guild.mfa_level:
hint_text += (
" Do note that since this server has 2FA enabled, you will need it enabled "
"on your account to modify the channel in this way."
)
hint = await ctx.send(hint_text)
await delete(ctx.message, delay=30)
await delete(hint, delay=30)
return
await autoroom_channel.edit(
name=name, reason="AutoRoom: User edit room info"
)
await ctx.tick()
await delete(ctx.message, delay=5)
@autoroom.command(name="bitrate", aliases=["kbps"])
async def autoroom_bitrate(self, ctx: commands.Context, kbps: int) -> None:
"""Change the bitrate of your AutoRoom."""
if not ctx.guild:
return
autoroom_channel, autoroom_info = await self._get_autoroom_channel_and_info(ctx)
if not autoroom_channel or not autoroom_info:
return
bps = max(8000, min(int(ctx.guild.bitrate_limit), kbps * 1000))
if bps != autoroom_channel.bitrate:
await autoroom_channel.edit(
bitrate=bps, reason="AutoRoom: User edit room info"
)
await ctx.tick()
await delete(ctx.message, delay=5)
@autoroom.command(name="users", aliases=["userlimit"])
async def autoroom_users(self, ctx: commands.Context, user_limit: int) -> None:
"""Change the user limit of your AutoRoom."""
autoroom_channel, autoroom_info = await self._get_autoroom_channel_and_info(ctx)
if not autoroom_channel or not autoroom_info:
return
limit = max(0, min(99, user_limit))
if limit != autoroom_channel.user_limit:
await autoroom_channel.edit(
user_limit=limit, reason="AutoRoom: User edit room info"
)
await ctx.tick()
await delete(ctx.message, delay=5)
@autoroom.command()
async def claim(self, ctx: commands.Context) -> None:
"""Claim ownership of this AutoRoom."""
if not ctx.guild:
return
new_owner = ctx.message.author
if not isinstance(new_owner, discord.Member):
return
autoroom_channel, autoroom_info = await self._get_autoroom_channel_and_info(
ctx, check_owner=False
)
if not autoroom_channel or not autoroom_info:
return
bucket = self.bucket_autoroom_owner_claim.get_bucket(autoroom_channel)
old_owner = ctx.guild.get_member(autoroom_info["owner"])
denied_message = ""
if (
not await self.is_mod_or_mod_role(new_owner)
and not await self.is_admin_or_admin_role(new_owner)
and new_owner != ctx.guild.owner
):
if old_owner and old_owner in autoroom_channel.members:
denied_message = (
"you can only claim ownership once the AutoRoom Owner has left"
)
elif bucket:
retry_after = bucket.update_rate_limit()
if retry_after:
denied_message = f"you must wait **{humanize_timedelta(seconds=max(retry_after, 1))}** before claiming ownership, in case the previous AutoRoom Owner comes back"
if denied_message:
await self._send_temp_error_message(ctx, denied_message)
return
source_channel = ctx.guild.get_channel(autoroom_info["source_channel"])
asc = await self.get_autoroom_source_config(source_channel)
if not asc:
await self._send_temp_error_message(
ctx,
"it seems like the AutoRoom Source this AutoRoom was made from "
"no longer exists. Because of that, I can no longer modify this AutoRoom.",
)
return
perms = Perms(autoroom_channel.overwrites)
if old_owner:
perms.overwrite(old_owner, asc["perms"]["allow"])
perms.update(new_owner, asc["perms"]["owner"])
if perms.modified:
await autoroom_channel.edit(
overwrites=perms.overwrites if perms.overwrites else {},
reason="AutoRoom: Ownership claimed",
)
await self.config.channel(autoroom_channel).owner.set(new_owner.id)
legacy_text_channel = await self.get_autoroom_legacy_text_channel(
autoroom_channel
)
if legacy_text_channel:
legacy_text_perms = Perms(legacy_text_channel.overwrites)
if old_owner:
if old_owner in autoroom_channel.members:
legacy_text_perms.overwrite(old_owner, self.perms_legacy_text_allow)
else:
legacy_text_perms.overwrite(old_owner, self.perms_legacy_text_reset)
legacy_text_perms.update(new_owner, self.perms_autoroom_owner_legacy_text)
if legacy_text_perms.modified:
await legacy_text_channel.edit(
overwrites=(
legacy_text_perms.overwrites
if legacy_text_perms.overwrites
else {}
),
reason="AutoRoom: Ownership claimed (legacy text channel)",
)
if bucket:
bucket.reset()
await ctx.tick()
await delete(ctx.message, delay=5)
@autoroom.command()
async def public(self, ctx: commands.Context) -> None:
"""Make your AutoRoom public."""
await self._process_allow_deny(ctx, "allow")
@autoroom.command()
async def locked(self, ctx: commands.Context) -> None:
"""Lock your AutoRoom (visible, but no one can join)."""
await self._process_allow_deny(ctx, "lock")
@autoroom.command()
async def private(self, ctx: commands.Context) -> None:
"""Make your AutoRoom private."""
await self._process_allow_deny(ctx, "deny")
@autoroom.command(aliases=["add"])
async def allow(
self, ctx: commands.Context, member_or_role: discord.Role | discord.Member
) -> None:
"""Allow a user (or role) into your AutoRoom."""
await self._process_allow_deny(ctx, "allow", member_or_role=member_or_role)
@autoroom.command(aliases=["ban", "block"])
async def deny(
self, ctx: commands.Context, member_or_role: discord.Role | discord.Member
) -> None:
"""Deny a user (or role) from accessing your AutoRoom.
If the user is already in your AutoRoom, they will be disconnected.
If a user is no longer able to access the room due to denying a role,
they too will be disconnected. Keep in mind that if the server is using
member roles, denying roles will probably not work as expected.
"""
if not ctx.guild:
return
if await self._process_allow_deny(ctx, "deny", member_or_role=member_or_role):
channel = self._get_current_voice_channel(ctx.message.author)
if not channel or not channel.permissions_for(ctx.guild.me).move_members:
return
for member in channel.members:
if not channel.permissions_for(member).connect:
await member.move_to(None, reason="AutoRoom: Deny user")
async def _process_allow_deny(
self,
ctx: commands.Context,
access: str,
*,
member_or_role: discord.Role | discord.Member | None = None,
) -> bool:
"""Actually do channel edit for allow/deny."""
if not ctx.guild:
return False
autoroom_channel, autoroom_info = await self._get_autoroom_channel_and_info(ctx)
if not autoroom_channel or not autoroom_info:
return False
if not autoroom_channel.permissions_for(autoroom_channel.guild.me).manage_roles:
await self._send_temp_error_message(
ctx,
"I do not have the required permissions to do this. "
"Please let the staff know about this!",
)
return False
source_channel = ctx.guild.get_channel(autoroom_info["source_channel"])
if not isinstance(source_channel, discord.VoiceChannel):
await self._send_temp_error_message(
ctx,
"it seems like the AutoRoom Source this AutoRoom was made from "
"no longer exists. Because of that, I can no longer modify this AutoRoom.",
)
return False
# Gather member roles and determine the lowest ranked member role
member_roles = self.get_member_roles(source_channel)
lowest_member_role = 999999999999
for role in member_roles:
lowest_member_role = min(lowest_member_role, role.position)
asc = await self.get_autoroom_source_config(source_channel)
if not asc:
await self._send_temp_error_message(
ctx,
"it seems like the AutoRoom Source this AutoRoom was made from "
"no longer exists. Because of that, I can no longer modify this AutoRoom.",
)
return False
perms = Perms(autoroom_channel.overwrites)
denied_message = ""
to_modify = [member_or_role]
if not member_or_role:
# Public/locked/private command
to_modify = member_roles or [source_channel.guild.default_role]
if access != "allow":
for member in autoroom_channel.members:
perms.update(member, asc["perms"]["allow"])
elif access == "allow":
# If we are allowing a bot role, allow it
if isinstance(
member_or_role, discord.Role
) and member_or_role in await self.get_bot_roles(ctx.guild):
pass
# Allow a specific user
# - check if they have "connect" perm in the source channel
# - works for both deny everyone with allowed roles/users, and allow everyone with denied roles/users
# Allow a specific role
# - Make sure that the role isn't specifically denied on the source channel
elif not self.check_if_member_or_role_allowed(
source_channel, member_or_role
):
user_role = "user"
them_it = "them"
if isinstance(member_or_role, discord.Role):
user_role = "role"
them_it = "it"
denied_message = (
f"since that {user_role} is not allowed to connect to the AutoRoom Source "
f"that this AutoRoom was made from, I can't allow {them_it} here either."
)
# Allow a specific role part 2
# - Check that the role is equal to or above the lowest allowed (member) role on the source channel
elif (
isinstance(member_or_role, discord.Role)
and member_roles
and member_or_role.position < lowest_member_role
):
denied_message = "this AutoRoom is using member roles, so I can't allow a lower hierarchy role."
# Deny a specific user
# - check that they aren't a mod/admin/owner/autoroom owner/bot itself, then deny user
# Deny a specific role
# - Check that it isn't a mod/admin role, then deny role
elif member_or_role == ctx.guild.me:
denied_message = "why would I deny myself from entering your AutoRoom?"
elif member_or_role == ctx.message.author:
denied_message = "don't be so hard on yourself! This is your AutoRoom!"
elif member_or_role == ctx.guild.owner:
denied_message = (
"I don't know if you know this, but that's the server owner... "
"I can't deny them from entering your AutoRoom."
)
elif await self.is_admin_or_admin_role(member_or_role):
role_suffix = " role" if isinstance(member_or_role, discord.Role) else ""
denied_message = f"that's an admin{role_suffix}, so I can't deny them from entering your AutoRoom."
elif await self.is_mod_or_mod_role(member_or_role):
role_suffix = " role" if isinstance(member_or_role, discord.Role) else ""
denied_message = f"that's a moderator{role_suffix}, so I can't deny them from entering your AutoRoom."
if denied_message:
await self._send_temp_error_message(ctx, denied_message)
return False
for target in to_modify:
if isinstance(target, discord.Member | discord.Role):
perms.update(target, asc["perms"][access])
if isinstance(target, discord.Member):
async with self.config.channel(
autoroom_channel
).denied() as denied_users:
if access == "deny" and target.id not in denied_users:
denied_users.append(target.id)
elif access == "allow" and target.id in denied_users:
denied_users.remove(target.id)
if perms.modified:
await autoroom_channel.edit(
overwrites=perms.overwrites if perms.overwrites else {},
reason="AutoRoom: Permission change",
)
await ctx.tick()
await delete(ctx.message, delay=5)
return True
@staticmethod
def _get_current_voice_channel(
member: discord.Member | discord.User,
) -> discord.VoiceChannel | None:
"""Get the members current voice channel, or None if not in a voice channel."""
if (
isinstance(member, discord.Member)
and member.voice
and isinstance(member.voice.channel, discord.VoiceChannel)
):
return member.voice.channel
return None
async def _get_autoroom_channel_and_info(
self, ctx: commands.Context, *, check_owner: bool = True
) -> tuple[discord.VoiceChannel | None, dict[str, Any] | None]:
autoroom_channel = self._get_current_voice_channel(ctx.message.author)
autoroom_info = await self.get_autoroom_info(autoroom_channel)
if not autoroom_info:
await self._send_temp_error_message(ctx, "you are not in an AutoRoom.")
return None, None
if check_owner and ctx.message.author.id != autoroom_info["owner"]:
reason_server = ""
if not autoroom_info["owner"]:
reason_server = " (it is a server AutoRoom)"
await self._send_temp_error_message(
ctx, f"you are not the owner of this AutoRoom{reason_server}."
)
return None, None
return autoroom_channel, autoroom_info
@staticmethod
def _get_autoroom_type(autoroom: discord.VoiceChannel, role: discord.Role) -> str:
"""Get the type of access a role has in an AutoRoom (public, locked, private, etc)."""
view_channel = role.permissions.view_channel
connect = role.permissions.connect
if role in autoroom.overwrites:
overwrites_allow, overwrites_deny = autoroom.overwrites[role].pair()
if overwrites_allow.view_channel:
view_channel = True
if overwrites_allow.connect:
connect = True
if overwrites_deny.view_channel:
view_channel = False
if overwrites_deny.connect:
connect = False
if not view_channel and not connect:
return "private"
if view_channel and not connect:
return "locked"
return "public"
async def _send_temp_error_message(
self, ctx: commands.Context, message: str
) -> None:
"""Send an error message that deletes itself along with the context message."""
hint = await ctx.send(error(f"{ctx.message.author.mention}, {message}"))
await delete(ctx.message, delay=10)
await delete(hint, delay=10)

1008
autoroom/c_autoroomset.py Normal file

File diff suppressed because it is too large Load diff

29
autoroom/info.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "AutoRoom",
"author": [
"PhasecoreX (PhasecoreX#0635)"
],
"short": "Automatic voice channel management.",
"description": "This cog facilitates automatic voice channel creation. When a member joins an AutoRoom Source (voice channel), this cog will move them to a brand new AutoRoom that they have control over. Once everyone leaves the AutoRoom, it is automatically deleted.",
"install_msg": "Thanks for installing AutoRoom! For a quick rundown on how to get started with this cog, check out [the readme](https://github.com/PhasecoreX/PCXCogs/tree/master/autoroom/README.md)",
"requirements": [
"func-timeout",
"jinja2"
],
"tags": [
"audio",
"auto",
"automated",
"automatic",
"channel",
"room",
"voice"
],
"min_bot_version": "3.5.0",
"min_python_version": [
3,
11,
0
],
"end_user_data_statement": "This cog does not persistently store data or metadata about users."
}

252
autoroom/pcx_lib.py Normal file
View file

@ -0,0 +1,252 @@
"""Shared code across multiple cogs."""
import asyncio
from collections.abc import Mapping
from contextlib import suppress
from typing import Any
import discord
from redbot.core import __version__ as redbot_version
from redbot.core import commands
from redbot.core.utils import common_filters
from redbot.core.utils.chat_formatting import box
headers = {"user-agent": "Red-DiscordBot/" + redbot_version}
MAX_EMBED_SIZE = 5900
MAX_EMBED_FIELDS = 20
MAX_EMBED_FIELD_SIZE = 1024
async def delete(message: discord.Message, *, delay: float | None = None) -> bool:
"""Attempt to delete a message.
Returns True if successful, False otherwise.
"""
try:
await message.delete(delay=delay)
except discord.NotFound:
return True # Already deleted
except discord.HTTPException:
return False
return True
async def reply(
ctx: commands.Context, content: str | None = None, **kwargs: Any # noqa: ANN401
) -> None:
"""Safely reply to a command message.
If the command is in a guild, will reply, otherwise will send a message like normal.
Pre discord.py 1.6, replies are just messages sent with the users mention prepended.
"""
if ctx.guild:
if (
hasattr(ctx, "reply")
and ctx.channel.permissions_for(ctx.guild.me).read_message_history
):
mention_author = kwargs.pop("mention_author", False)
kwargs.update(mention_author=mention_author)
with suppress(discord.HTTPException):
await ctx.reply(content=content, **kwargs)
return
allowed_mentions = kwargs.pop(
"allowed_mentions",
discord.AllowedMentions(users=False),
)
kwargs.update(allowed_mentions=allowed_mentions)
await ctx.send(content=f"{ctx.message.author.mention} {content}", **kwargs)
else:
await ctx.send(content=content, **kwargs)
async def type_message(
destination: discord.abc.Messageable, content: str, **kwargs: Any # noqa: ANN401
) -> discord.Message | None:
"""Simulate typing and sending a message to a destination.
Will send a typing indicator, wait a variable amount of time based on the length
of the text (to simulate typing speed), then send the message.
"""
content = common_filters.filter_urls(content)
with suppress(discord.HTTPException):
async with destination.typing():
await asyncio.sleep(max(0.25, min(2.5, len(content) * 0.01)))
return await destination.send(content=content, **kwargs)
async def embed_splitter(
embed: discord.Embed, destination: discord.abc.Messageable | None = None
) -> list[discord.Embed]:
"""Take an embed and split it so that each embed has at most 20 fields and a length of 5900.
Each field value will also be checked to have a length no greater than 1024.
If supplied with a destination, will also send those embeds to the destination.
"""
embed_dict = embed.to_dict()
# Check and fix field value lengths
modified = False
if "fields" in embed_dict:
for field in embed_dict["fields"]:
if len(field["value"]) > MAX_EMBED_FIELD_SIZE:
field["value"] = field["value"][: MAX_EMBED_FIELD_SIZE - 3] + "..."
modified = True
if modified:
embed = discord.Embed.from_dict(embed_dict)
# Short circuit
if len(embed) <= MAX_EMBED_SIZE and (
"fields" not in embed_dict or len(embed_dict["fields"]) <= MAX_EMBED_FIELDS
):
if destination:
await destination.send(embed=embed)
return [embed]
# Nah, we're really doing this
split_embeds: list[discord.Embed] = []
fields = embed_dict.get("fields", [])
embed_dict["fields"] = []
for field in fields:
embed_dict["fields"].append(field)
current_embed = discord.Embed.from_dict(embed_dict)
if (
len(current_embed) > MAX_EMBED_SIZE
or len(embed_dict["fields"]) > MAX_EMBED_FIELDS
):
embed_dict["fields"].pop()
current_embed = discord.Embed.from_dict(embed_dict)
split_embeds.append(current_embed.copy())
embed_dict["fields"] = [field]
current_embed = discord.Embed.from_dict(embed_dict)
split_embeds.append(current_embed.copy())
if destination:
for split_embed in split_embeds:
await destination.send(embed=split_embed)
return split_embeds
class SettingDisplay:
"""A formatted list of settings."""
def __init__(self, header: str | None = None) -> None:
"""Init."""
self.header = header
self._length = 0
self._settings: list[tuple] = []
def add(self, setting: str, value: Any) -> None: # noqa: ANN401
"""Add a setting."""
setting_colon = setting + ":"
self._settings.append((setting_colon, value))
self._length = max(len(setting_colon), self._length)
def raw(self) -> str:
"""Generate the raw text of this SettingDisplay, to be monospace (ini) formatted later."""
msg = ""
if not self._settings:
return msg
if self.header:
msg += f"--- {self.header} ---\n"
for setting in self._settings:
msg += f"{setting[0].ljust(self._length, ' ')} [{setting[1]}]\n"
return msg.strip()
def display(self, *additional) -> str: # noqa: ANN002 (Self)
"""Generate a ready-to-send formatted box of settings.
If additional SettingDisplays are provided, merges their output into one.
"""
msg = self.raw()
for section in additional:
msg += "\n\n" + section.raw()
return box(msg, lang="ini")
def __str__(self) -> str:
"""Generate a ready-to-send formatted box of settings."""
return self.display()
def __len__(self) -> int:
"""Count of how many settings there are to display."""
return len(self._settings)
class Perms:
"""Helper class for dealing with a dictionary of discord.PermissionOverwrite."""
def __init__(
self,
overwrites: (
dict[
discord.Role | discord.Member | discord.Object,
discord.PermissionOverwrite,
]
| None
) = None,
) -> None:
"""Init."""
self.__overwrites: dict[
discord.Role | discord.Member,
discord.PermissionOverwrite,
] = {}
self.__original: dict[
discord.Role | discord.Member,
discord.PermissionOverwrite,
] = {}
if overwrites:
for key, value in overwrites.items():
if isinstance(key, discord.Role | discord.Member):
pair = value.pair()
self.__overwrites[key] = discord.PermissionOverwrite().from_pair(
*pair
)
self.__original[key] = discord.PermissionOverwrite().from_pair(
*pair
)
def overwrite(
self,
target: discord.Role | discord.Member | discord.Object,
permission_overwrite: Mapping[str, bool | None] | discord.PermissionOverwrite,
) -> None:
"""Set the permissions for a target."""
if not isinstance(target, discord.Role | discord.Member):
return
if isinstance(permission_overwrite, discord.PermissionOverwrite):
if permission_overwrite.is_empty():
self.__overwrites[target] = discord.PermissionOverwrite()
return
self.__overwrites[target] = discord.PermissionOverwrite().from_pair(
*permission_overwrite.pair()
)
else:
self.__overwrites[target] = discord.PermissionOverwrite()
self.update(target, permission_overwrite)
def update(
self,
target: discord.Role | discord.Member,
perm: Mapping[str, bool | None],
) -> None:
"""Update the permissions for a target."""
if target not in self.__overwrites:
self.__overwrites[target] = discord.PermissionOverwrite()
self.__overwrites[target].update(**perm)
if self.__overwrites[target].is_empty():
del self.__overwrites[target]
@property
def modified(self) -> bool:
"""Check if current overwrites are different from when this object was first initialized."""
return self.__overwrites != self.__original
@property
def overwrites(
self,
) -> dict[discord.Role | discord.Member, discord.PermissionOverwrite] | None:
"""Get current overwrites."""
return self.__overwrites

75
autoroom/pcx_template.py Normal file
View file

@ -0,0 +1,75 @@
"""Module for template engine using Jinja2, safe for untrusted user templates."""
import random
from typing import Any
from func_timeout import FunctionTimedOut, func_timeout
from jinja2 import Undefined, pass_context
from jinja2.exceptions import TemplateError
from jinja2.runtime import Context
from jinja2.sandbox import ImmutableSandboxedEnvironment
TIMEOUT = 0.25 # Maximum runtime for template rendering in seconds / should be very low to avoid DoS attacks
class TemplateTimeoutError(TemplateError):
"""Custom exception raised when template rendering exceeds maximum runtime."""
class SilentUndefined(Undefined):
"""Class that converts Undefined type to None."""
def _fail_with_undefined_error(self, *_args: Any, **_kwargs: Any) -> None: # type: ignore[incorrect-return-type] # noqa: ANN401
return None
class Template:
"""A template engine using Jinja2, safe for untrusted user templates with an immutable sandbox."""
def __init__(self) -> None:
"""Set up the Jinja2 environment with an immutable sandbox."""
self.env = ImmutableSandboxedEnvironment(
finalize=self.finalize,
undefined=SilentUndefined,
keep_trailing_newline=True,
trim_blocks=True,
lstrip_blocks=True,
)
# Override Jinja's built-in random filter with a deterministic version
self.env.filters["random"] = self.deterministic_random
@pass_context
def deterministic_random(self, ctx: Context, seq: list) -> Any: # noqa: ANN401
"""Generate a deterministic random choice from a sequence based on the context's random_seed."""
seed = ctx.get(
"random_seed", random.getrandbits(32)
) # Use seed from context or default
random.seed(seed)
return random.choice(seq) # Return a deterministic random choice # noqa: S311
def finalize(self, element: Any) -> Any: # noqa: ANN401
"""Callable that converts None elements to an empty string."""
return element if element is not None else ""
def _render_template(self, template_str: str, data: dict[str, Any]) -> str:
"""Render the template to a string."""
return self.env.from_string(template_str).render(data)
async def render(
self,
template_str: str,
data: dict[str, Any] | None = None,
timeout: float = TIMEOUT, # noqa: ASYNC109
) -> str:
"""Render a template with the given data, enforcing a maximum runtime."""
if data is None:
data = {}
try:
result: str = func_timeout(
timeout, self._render_template, args=(template_str, data)
) # type: ignore[unknown-return-type]
except FunctionTimedOut as err:
msg = f"Template rendering exceeded {timeout} seconds."
raise TemplateTimeoutError(msg) from err
return result

View file

@ -0,0 +1,930 @@
"""Tests for template engine using Jinja2."""
import pytest
from pcx_template import (
Template,
TemplateTimeoutError,
)
@pytest.mark.asyncio
async def test_simple_template():
tpl = Template()
template_str = "Hello, {{ name }}!"
data = {"name": "World"}
result = await tpl.render(template_str, data)
assert result == "Hello, World!"
@pytest.mark.asyncio
async def test_template_with_no_data():
tpl = Template()
template_str = "Hello, World!"
result = await tpl.render(template_str)
assert result == "Hello, World!"
@pytest.mark.asyncio
async def test_template_with_missing_variable():
tpl = Template()
template_str = "Hello, {{ name }}!"
result = await tpl.render(template_str)
assert result == "Hello, !"
@pytest.mark.asyncio
async def test_template_timeout():
tpl = Template()
template_str = """{% for i in range(100000) %}{% for j in range(100000) %}{% for k in range(100000) %}{{ i*i }}{% endfor %}{% endfor %}{% endfor %}"""
data = {}
with pytest.raises(TemplateTimeoutError) as excinfo:
await tpl.render(template_str, data)
assert "Template rendering exceeded" in str(excinfo.value)
@pytest.mark.asyncio
async def test_template_no_timeout():
tpl = Template()
template_str = """{% for i in range(100) %}{{ i }}\n{% endfor %}"""
data = {}
result = await tpl.render(template_str, data, timeout=2)
expected_result = "\n".join(str(i) for i in range(100)) + "\n"
assert result == expected_result
@pytest.mark.asyncio
async def test_template_with_custom_timeout():
tpl = Template()
template_str = """
{% for i in range(100000) %}
{% for j in range(100000) %}
{{ i }}
{% endfor %}
{% endfor %}
"""
data = {}
with pytest.raises(TemplateTimeoutError) as excinfo:
await tpl.render(template_str, data, timeout=0.1)
assert "Template rendering exceeded 0.1 seconds" in str(excinfo.value)
@pytest.mark.asyncio
async def test_template_with_complex_data():
tpl = Template()
template_str = (
"User: {{ user.name }}, Age: {{ user.age }}, Active: {{ user.active }}"
)
data = {"user": {"name": "John", "age": 30, "active": True}}
result = await tpl.render(template_str, data)
assert result == "User: John, Age: 30, Active: True"
@pytest.mark.asyncio
async def test_template_with_escape_sequences():
tpl = Template()
template_str = "Hello, {{ user.name }}!\nYour score: {{ user.score }}\n\nThank you!"
data = {"user": {"name": "Alice", "score": 42}}
result = await tpl.render(template_str, data)
expected_result = "Hello, Alice!\nYour score: 42\n\nThank you!"
assert result == expected_result
@pytest.mark.asyncio
async def test_deterministic_random() -> None:
tpl = Template()
template_str = "Random choice: {{ ['a', 'b', 'c', 'd', 'e', 'f', 'g']|random }}"
data = {"random_seed": "test_seed"}
result1 = await tpl.render(template_str, data)
result2 = await tpl.render(template_str, data)
assert result1 == result2 # Should be the same due to deterministic randomness
@pytest.mark.asyncio
async def test_deterministic_random_with_different_seeds():
tpl = Template()
template_str = "Random choice: {{ ['a', 'b', 'c', 'd', 'e', 'f', 'g']|random }}"
data1 = {"random_seed": "seed1"}
data2 = {"random_seed": "seed2"}
result1 = await tpl.render(template_str, data1)
result2 = await tpl.render(template_str, data2)
assert result1 != result2 # Should be different due to different seeds
@pytest.mark.asyncio
async def test_template_with_large_data():
tpl = Template()
template_str = "Sum: {{ data|sum }}"
data = {"data": list(range(10000))}
result = await tpl.render(template_str, data)
assert result == f"Sum: {sum(range(10000))}"
@pytest.mark.asyncio
async def test_template_with_nested_data():
tpl = Template()
template_str = "User: {{ user.name }}, Address: {{ user.address.city }}, {{ user.address.zip }}"
data = {"user": {"name": "John", "address": {"city": "New York", "zip": "10001"}}}
result = await tpl.render(template_str, data)
assert result == "User: John, Address: New York, 10001"
# Old template system tests: Interpolation
@pytest.mark.asyncio
async def test_no_interpolation():
tpl = Template()
template_str = "PhasecoreX says {hello}!"
data = {}
result = await tpl.render(template_str, data)
expected = template_str
assert expected == result
@pytest.mark.asyncio
async def test_basic_interpolation():
tpl = Template()
template_str = "Hello, {{subject}}!"
data = {"subject": "world"}
result = await tpl.render(template_str, data)
expected = "Hello, world!"
assert expected == result
@pytest.mark.asyncio
async def test_basic_integer_interpolation():
tpl = Template()
template_str = '"{{mph}} miles an hour!"'
data = {"mph": 88}
result = await tpl.render(template_str, data)
expected = '"88 miles an hour!"'
assert expected == result
@pytest.mark.asyncio
async def test_basic_float_interpolation():
tpl = Template()
template_str = '"{{power}} jiggawatts!"'
data = {"power": 1.210}
result = await tpl.render(template_str, data)
expected = '"1.21 jiggawatts!"'
assert expected == result
@pytest.mark.asyncio
async def test_basic_context_miss_interpolation():
tpl = Template()
template_str = "I ({{cannot}}) be seen!"
data = {}
result = await tpl.render(template_str, data)
expected = "I () be seen!"
assert expected == result
# Dotted Names
@pytest.mark.asyncio
async def test_dotted_names_arbitrary_depth():
tpl = Template()
template_str = '"{{a.b.c.d.e.name}}" == "Phil"'
data = {"a": {"b": {"c": {"d": {"e": {"name": "Phil"}}}}}}
result = await tpl.render(template_str, data)
expected = '"Phil" == "Phil"'
assert expected == result
@pytest.mark.asyncio
async def test_dotted_names_broken_chains():
tpl = Template()
template_str = '"{{a.b.c}}" == ""'
data = {"a": {}}
result = await tpl.render(template_str, data)
expected = '"" == ""'
assert expected == result
@pytest.mark.asyncio
async def test_dotted_names_broken_chain_resolution():
tpl = Template()
template_str = '"{{a.b.c.name}}" == ""'
data = {"a": {"b": {}}, "c": {"name": "Jim"}}
result = await tpl.render(template_str, data)
expected = '"" == ""'
assert expected == result
# Whitespace Sensitivity
@pytest.mark.asyncio
async def test_interpolation_surrounding_whitespace():
tpl = Template()
template_str = "| {{string}} |"
data = {"string": "---"}
result = await tpl.render(template_str, data)
expected = "| --- |"
assert expected == result
@pytest.mark.asyncio
async def test_interpolation_standalone():
tpl = Template()
template_str = " {{string}}\n"
data = {"string": "---"}
result = await tpl.render(template_str, data)
expected = " ---\n"
assert expected == result
# Whitespace Insensitivity
@pytest.mark.asyncio
async def test_interpolation_with_padding():
tpl = Template()
template_str = "|{{ string }}|"
data = {"string": "---"}
result = await tpl.render(template_str, data)
expected = "|---|"
assert expected == result
# Old template system tests: IfStatement
@pytest.mark.asyncio
async def test_if_truthy():
tpl = Template()
template_str = '"{% if boolean %}This should be rendered.{% endif %}"'
data = {"boolean": True}
result = await tpl.render(template_str, data)
expected = '"This should be rendered."'
assert expected == result
@pytest.mark.asyncio
async def test_if_falsey():
tpl = Template()
template_str = '"{% if boolean %}This should not be rendered.{% endif %}"'
data = {"boolean": False}
result = await tpl.render(template_str, data)
expected = '""'
assert expected == result
@pytest.mark.asyncio
async def test_empty_lists():
tpl = Template()
template_str = '"{% if list %}Yay lists!{% endif %}"'
data = {"list": []}
result = await tpl.render(template_str, data)
expected = '""'
assert expected == result
@pytest.mark.asyncio
async def test_if_doubled():
tpl = Template()
template_str = """{% if bool %}
* first
{% endif %}
* {{two}}
{% if bool %}
* third
{% endif %}
"""
data = {"bool": True, "two": "second"}
result = await tpl.render(template_str, data)
expected = """* first
* second
* third
"""
assert expected == result
@pytest.mark.asyncio
async def test_if_nested_truthy():
tpl = Template()
template_str = "| A {% if bool %}B {% if bool %}C{% endif %} D{% endif %} E |"
data = {"bool": True}
result = await tpl.render(template_str, data)
expected = "| A B C D E |"
assert expected == result
@pytest.mark.asyncio
async def test_if_nested_falsey():
tpl = Template()
template_str = "| A {% if bool %}B {% if bool %}C{% endif %} D{% endif %} E |"
data = {"bool": False}
result = await tpl.render(template_str, data)
expected = "| A E |"
assert expected == result
@pytest.mark.asyncio
async def test_if_context_misses():
tpl = Template()
template_str = "[{% if missing %}Found key 'missing'!{% endif %}]"
data = {}
result = await tpl.render(template_str, data)
expected = "[]"
assert expected == result
# Dotted Names
@pytest.mark.asyncio
async def test_if_dotted_names_truthy():
tpl = Template()
template_str = '"{% if a.b.c %}Here{% endif %}" == "Here"'
data = {"a": {"b": {"c": True}}}
result = await tpl.render(template_str, data)
expected = '"Here" == "Here"'
assert expected == result
@pytest.mark.asyncio
async def test_if_dotted_names_falsey():
tpl = Template()
template_str = '"{% if a.b.c %}Here{% endif %}" == ""'
data = {"a": {"b": {"c": False}}}
result = await tpl.render(template_str, data)
expected = '"" == ""'
assert expected == result
@pytest.mark.asyncio
async def test_if_dotted_names_broken_chains():
tpl = Template()
template_str = '"{% if a.b.c %}Here{% endif %}" == ""'
data = {"a": {}}
result = await tpl.render(template_str, data)
expected = '"" == ""'
assert expected == result
# Whitespace Sensitivity
@pytest.mark.asyncio
async def test_if_surrounding_whitespace():
tpl = Template()
template_str = " | {% if boolean %}\t|\t{% endif %} | \n"
data = {"boolean": True}
result = await tpl.render(template_str, data)
expected = " | \t|\t | \n"
assert expected == result
@pytest.mark.asyncio
async def test_if_internal_whitespace():
tpl = Template()
template_str = " | {% if boolean %} {# Important Whitespace #}\n {% endif %} | \n"
data = {"boolean": True}
result = await tpl.render(template_str, data)
expected = " | \n | \n"
assert expected == result
@pytest.mark.asyncio
async def test_if_indented_inline_sections():
tpl = Template()
template_str = " {% if boolean %}YES{% endif %}\n {% if boolean %}GOOD{% endif %}\n"
data = {"boolean": True}
result = await tpl.render(template_str, data)
expected = " YES\n GOOD\n"
assert expected == result
@pytest.mark.asyncio
async def test_if_standalone_lines():
tpl = Template()
template_str = """| This Is
{% if boolean %}
|
{% endif %}
| A Line
"""
data = {"boolean": True}
result = await tpl.render(template_str, data)
expected = """| This Is
|
| A Line
"""
assert expected == result
@pytest.mark.asyncio
async def test_if_indented_standalone_lines():
tpl = Template()
template_str = """| This Is
{% if boolean %}
|
{% endif %}
| A Line
"""
data = {"boolean": True}
result = await tpl.render(template_str, data)
expected = """| This Is
|
| A Line
"""
assert expected == result
@pytest.mark.asyncio
async def test_if_standalone_line_endings():
tpl = Template()
template_str = "|\r\n{% if boolean %}\r\n{% endif %}\r\n|"
data = {"boolean": True}
result = await tpl.render(template_str, data)
expected = "|\r\n|"
assert expected == result
@pytest.mark.asyncio
async def test_if_standalone_without_previous_line():
tpl = Template()
template_str = " {% if boolean %}\n#{% endif %}\n/"
data = {"boolean": True}
result = await tpl.render(template_str, data)
expected = "#\n/"
assert expected == result
@pytest.mark.asyncio
async def test_if_standalone_without_newline():
tpl = Template()
template_str = "#{% if boolean %}\n/\n {% endif %}"
data = {"boolean": True}
result = await tpl.render(template_str, data)
expected = "#\n/\n"
assert expected == result
# Whitespace Insensitivity
@pytest.mark.asyncio
async def test_if_padding():
tpl = Template()
template_str = "|{% if boolean%}={% endif %}|"
data = {"boolean": True}
result = await tpl.render(template_str, data)
expected = "|=|"
assert expected == result
# Old template system tests: IfNotStatement
@pytest.mark.asyncio
async def test_falsey():
tpl = Template()
template_str = '"{% if not boolean %}This should be rendered.{% endif %}"'
data = {"boolean": False}
result = await tpl.render(template_str, data)
expected = '"This should be rendered."'
assert expected == result
@pytest.mark.asyncio
async def test_truthy():
tpl = Template()
template_str = '"{% if not boolean %}This should not be rendered.{% endif %}"'
data = {"boolean": True}
result = await tpl.render(template_str, data)
expected = '""'
assert expected == result
@pytest.mark.asyncio
async def test_empty_list():
tpl = Template()
template_str = '"{% if not list %}Yay lists!{% endif %}"'
data = {"list": []}
result = await tpl.render(template_str, data)
expected = '"Yay lists!"'
assert expected == result
@pytest.mark.asyncio
async def test_doubled():
tpl = Template()
template_str = """{% if not bool %}
* first
{% endif %}
* {{two}}
{% if not bool %}
* third
{% endif %}
"""
data = {"bool": False, "two": "second"}
result = await tpl.render(template_str, data)
expected = """* first
* second
* third
"""
assert expected == result
@pytest.mark.asyncio
async def test_nested_falsey():
tpl = Template()
template_str = (
"| A {% if not bool %}B {% if not bool %}C{% endif %} D{% endif %} E |"
)
data = {"bool": False}
result = await tpl.render(template_str, data)
expected = "| A B C D E |"
assert expected == result
@pytest.mark.asyncio
async def test_nested_truthy():
tpl = Template()
template_str = (
"| A {% if not bool %}B {% if not bool %}C{% endif %} D{% endif %} E |"
)
data = {"bool": True}
result = await tpl.render(template_str, data)
expected = "| A E |"
assert expected == result
@pytest.mark.asyncio
async def test_context_misses():
tpl = Template()
template_str = "[{% if not missing %}Cannot find key 'missing'!{% endif %}]"
data = {}
result = await tpl.render(template_str, data)
expected = "[Cannot find key 'missing'!]"
assert expected == result
# Dotted Names
@pytest.mark.asyncio
async def test_dotted_names_truthy():
tpl = Template()
template_str = '"{% if not a.b.c %}Here{% endif %}" == ""'
data = {"a": {"b": {"c": True}}}
result = await tpl.render(template_str, data)
expected = '"" == ""'
assert expected == result
@pytest.mark.asyncio
async def test_dotted_names_falsey():
tpl = Template()
template_str = '"{% if not a.b.c %}Not Here{% endif %}" == "Not Here"'
data = {"a": {"b": {"c": False}}}
result = await tpl.render(template_str, data)
expected = '"Not Here" == "Not Here"'
assert expected == result
@pytest.mark.asyncio
async def test_ifnot_dotted_names_broken_chains():
tpl = Template()
template_str = '"{% if not a.b.c %}Not Here{% endif %}" == "Not Here"'
data = {"a": {}}
result = await tpl.render(template_str, data)
expected = '"Not Here" == "Not Here"'
assert expected == result
# Whitespace Sensitivity
@pytest.mark.asyncio
async def test_ifnot_surrounding_whitespace():
tpl = Template()
template_str = " | {% if not boolean %}\t|\t{% endif %} | \n"
data = {"boolean": False}
result = await tpl.render(template_str, data)
expected = " | \t|\t | \n"
assert expected == result
@pytest.mark.asyncio
async def test_internal_whitespace():
tpl = Template()
template_str = (
" | {% if not boolean %} {# Important Whitespace #}\n {% endif %} | \n"
)
data = {"boolean": False}
result = await tpl.render(template_str, data)
expected = " | \n | \n"
assert expected == result
@pytest.mark.asyncio
async def test_indented_inline_sections():
tpl = Template()
template_str = (
" {% if not boolean %}YES{% endif %}\n {% if not boolean %}GOOD{% endif %}\n"
)
data = {"boolean": False}
result = await tpl.render(template_str, data)
expected = " YES\n GOOD\n"
assert expected == result
@pytest.mark.asyncio
async def test_standalone_lines():
tpl = Template()
template_str = """| This Is
{% if not boolean %}
|
{% endif %}
| A Line
"""
data = {"boolean": False}
result = await tpl.render(template_str, data)
expected = """| This Is
|
| A Line
"""
assert expected == result
@pytest.mark.asyncio
async def test_indented_standalone_lines():
tpl = Template()
template_str = """| This Is
{% if not boolean %}
|
{% endif %}
| A Line
"""
data = {"boolean": False}
result = await tpl.render(template_str, data)
expected = """| This Is
|
| A Line
"""
assert expected == result
@pytest.mark.asyncio
async def test_ifnot_standalone_line_endings():
tpl = Template()
template_str = "|\r\n{% if not boolean %}\r\n{% endif %}\r\n|"
data = {"boolean": False}
result = await tpl.render(template_str, data)
expected = "|\r\n|"
assert expected == result
@pytest.mark.asyncio
async def test_ifnot_standalone_without_previous_line():
tpl = Template()
template_str = " {% if not boolean %}\n#{% endif %}\n/"
data = {"boolean": False}
result = await tpl.render(template_str, data)
expected = "#\n/"
assert expected == result
@pytest.mark.asyncio
async def test_ifnot_standalone_without_newline():
tpl = Template()
template_str = "#{% if not boolean %}\n/\n {% endif %}"
data = {"boolean": False}
result = await tpl.render(template_str, data)
expected = "#\n/\n"
assert expected == result
# Whitespace Insensitivity
@pytest.mark.asyncio
async def test_padding():
tpl = Template()
template_str = "|{% if not boolean%}={% endif %}|"
data = {"boolean": False}
result = await tpl.render(template_str, data)
expected = "|=|"
assert expected == result
# Old template system tests: ElseStatement
@pytest.mark.asyncio
async def test_else():
tpl = Template()
template_str = "{% if 1 > 2 %}Bad...{% else %}Good!{% endif %}"
data = {}
result = await tpl.render(template_str, data)
expected = "Good!"
assert expected == result
# Old template system tests: ElifStatement
@pytest.mark.asyncio
async def test_elif():
tpl = Template()
template_str = "{% if 1 > 2 %}Bad if...{% elif 2 == 3 %}Bad elif 1...{% elif 1 == 1 %}Good!{% elif 1 == 2 %}Bad elif 2...{% else %}Bad else...{% endif %}"
data = {}
result = await tpl.render(template_str, data)
expected = "Good!"
assert expected == result
# Old template system tests: Comment
@pytest.mark.asyncio
async def test_inline():
tpl = Template()
template_str = "12345{# Comment Block! #}67890"
data = {}
result = await tpl.render(template_str, data)
expected = "1234567890"
assert expected == result
@pytest.mark.asyncio
async def test_multiline():
tpl = Template()
template_str = """12345{#
This is a
multi-line comment...
#}67890
"""
data = {}
result = await tpl.render(template_str, data)
expected = """1234567890
"""
assert expected == result
@pytest.mark.asyncio
async def test_standalone():
tpl = Template()
template_str = """Begin.
{# Comment Block! #}
End.
"""
data = {}
result = await tpl.render(template_str, data)
expected = """Begin.
End.
"""
assert expected == result
@pytest.mark.asyncio
async def test_indented_standalone():
tpl = Template()
template_str = """Begin.
{# Indented Comment Block! #}
End.
"""
data = {}
result = await tpl.render(template_str, data)
expected = """Begin.
End.
"""
assert expected == result
@pytest.mark.asyncio
async def test_standalone_line_endings():
tpl = Template()
template_str = "|\r\n{# Standalone Comment #}\r\n|"
data = {}
result = await tpl.render(template_str, data)
expected = "|\r\n|"
assert expected == result
@pytest.mark.asyncio
async def test_standalone_without_previous_line():
tpl = Template()
template_str = " {# I'm Still Standalone #}\n!"
data = {}
result = await tpl.render(template_str, data)
expected = "!"
assert expected == result
@pytest.mark.asyncio
async def test_standalone_without_newline():
tpl = Template()
template_str = "!\n {# I'm Still Standalone #}"
data = {}
result = await tpl.render(template_str, data)
expected = "!\n"
assert expected == result
@pytest.mark.asyncio
async def test_multiline_standalone():
tpl = Template()
template_str = """Begin.
{#
Something's going on here...
#}
End.
"""
data = {}
result = await tpl.render(template_str, data)
expected = """Begin.
End.
"""
assert expected == result
@pytest.mark.asyncio
async def test_indented_multiline_standalone():
tpl = Template()
template_str = """Begin.
{#
Something's going on here...
#}
End.
"""
data = {}
result = await tpl.render(template_str, data)
expected = """Begin.
End.
"""
assert expected == result
@pytest.mark.asyncio
async def test_indented_inline():
tpl = Template()
template_str = " 12 {# 34 #}\n"
data = {}
result = await tpl.render(template_str, data)
expected = " 12 \n"
assert expected == result
@pytest.mark.asyncio
async def test_surrounding_whitespace():
tpl = Template()
template_str = "12345 {# Comment Block! #} 67890"
data = {}
result = await tpl.render(template_str, data)
expected = "12345 67890"
assert expected == result
# Old template system tests: Filter
@pytest.mark.asyncio
async def test_invalid():
tpl = Template()
template_str = "{{words | invalidFilter}}"
data = {"words": "This won't be changed"}
result = await tpl.render(template_str, data)
expected = "This won't be changed"
assert expected == result
@pytest.mark.asyncio
async def test_lower():
tpl = Template()
template_str = "{{word | lower}}"
data = {"word": "QUIET"}
result = await tpl.render(template_str, data)
expected = "quiet"
assert expected == result
@pytest.mark.asyncio
async def test_upper():
tpl = Template()
template_str = "{{word | upper}}"
data = {"word": "loud"}
result = await tpl.render(template_str, data)
expected = "LOUD"
assert expected == result
@pytest.mark.asyncio
async def test_multiple():
tpl = Template()
template_str = "{{word | upper | lower | upper | lower | upper | lower | upper | lower | upper}}"
data = {"word": "loud"}
result = await tpl.render(template_str, data)
expected = "LOUD"
assert expected == result
if __name__ == "__main__":
pytest.main(["-v", __file__])