diff --git a/.env.sample b/.env.sample index 575cdfb..efb7da1 100644 --- a/.env.sample +++ b/.env.sample @@ -1,2 +1,7 @@ TOKEN= DEBUG= + +DISCORD_UID_MAP="user1=1234,user2=4567,user3=7890" + +LDAP_USERNAME= +LDAP_PASSWORD= diff --git a/.github/deploy/production.hcl b/.github/deploy/production.hcl index 3ad0675..7a313d5 100644 --- a/.github/deploy/production.hcl +++ b/.github/deploy/production.hcl @@ -24,6 +24,9 @@ job "blockbot" { template { data = < None: + # Create an aiohttp ClientSession to use for web requests + aiohttp_client = aiohttp.ClientSession() + client.set_type_dependency(aiohttp.ClientSession, aiohttp_client) + + +@client.listen(hikari.StoppedEvent) +# By default, dependency injection is only enabled for command callbacks, pre/post hooks & error handlers +# so dependency injection must be enabled manually for this event listener +@client.inject_dependencies +async def on_stop( + event: hikari.StoppedEvent, aiohttp_client: aiohttp.ClientSession = arc.inject() +) -> None: + await aiohttp_client.close() + + @client.set_error_handler async def error_handler(ctx: arc.GatewayContext, exc: Exception) -> None: if DEBUG: diff --git a/src/config.py b/src/config.py index e4d66f9..e651c60 100644 --- a/src/config.py +++ b/src/config.py @@ -6,5 +6,27 @@ TOKEN = os.environ.get("TOKEN") # required DEBUG = os.environ.get("DEBUG", False) +DISCORD_UID_MAP = os.environ.get("DISCORD_UID_MAP") -CHANNEL_IDS: dict[str, int] = {"lobby": 627542044390457350} +CHANNEL_IDS: dict[str, int] = { + "lobby": 627542044390457350, + "bot-private": 853071983452225536, + "bots-cmt": 1162038557922312312, + "action-items": 1029132014210793513, +} + +# TODO: query API/LDAP for these +ROLE_IDS: dict[str, int] = { + "all": 568762266992902179, + "everyone": 568762266992902179, + "committee": 568762266992902179, + "cmt": 568762266992902179, + "events": 807389174009167902, + "admins": 585512338728419341, + "helpdesk": 1194683307921772594, +} + +UID_MAPS = dict(item.split("=") for item in DISCORD_UID_MAP.split(",")) + +LDAP_USERNAME = os.environ.get("LDAP_USERNAME") +LDAP_PASSWORD = os.environ.get("LDAP_PASSWORD") diff --git a/src/examples/components.py b/src/examples/components.py index d6f44e5..21994e6 100644 --- a/src/examples/components.py +++ b/src/examples/components.py @@ -57,7 +57,7 @@ async def view_check(self, ctx: miru.ViewContext) -> bool: async def on_timeout(self) -> None: message = self.message # Since the view is bound to a message, we can assert it's not None - assert message + assert message for item in self.children: item.disabled = True diff --git a/src/extensions/action_items.py b/src/extensions/action_items.py new file mode 100644 index 0000000..73367c5 --- /dev/null +++ b/src/extensions/action_items.py @@ -0,0 +1,107 @@ +import arc +import hikari +import re +import aiohttp +from urllib.parse import urlparse + +from src.utils import role_mention, hedgedoc_login +from src.hooks import restrict_to_channels, restrict_to_roles +from src.config import CHANNEL_IDS, ROLE_IDS, UID_MAPS + + +action_items = arc.GatewayPlugin(name="Action Items") + + +@action_items.include +@arc.with_hook(restrict_to_channels(channel_ids=[CHANNEL_IDS["action-items"]])) +@arc.with_hook(restrict_to_roles(role_ids=[ROLE_IDS["committee"]])) +@arc.slash_command( + "action_items", "Display the action items from the MD", is_dm_enabled=False +) +async def get_action_items( + ctx: arc.GatewayContext, + url: arc.Option[str, arc.StrParams("URL of the minutes from the MD")], + aiohttp_client: aiohttp.ClientSession = arc.inject(), +) -> None: + """Display the action items from the MD!""" + + if "https://md.redbrick.dcu.ie" not in url: + await ctx.respond( + f"❌ `{url}` is not a valid MD URL. Please provide a valid URL.", + flags=hikari.MessageFlag.EPHEMERAL, + ) + return + + await hedgedoc_login(aiohttp_client) + + parsed_url = urlparse(url) + request_url = ( + f"{parsed_url.scheme}://{parsed_url.hostname}{parsed_url.path}/download" + ) + + async with aiohttp_client.get(request_url) as response: + if response.status != 200: + await ctx.respond( + f"❌ Failed to fetch the minutes. Status code: `{response.status}`", + flags=hikari.MessageFlag.EPHEMERAL, + ) + return + + content = await response.text() + + # extract the action items section from the minutes + action_items_section = re.search( + r"# Action Items:?\n(.*?)(\n# |\n---|$)", content, re.DOTALL + ) + + if not action_items_section: + await ctx.respond("❌ No `Action Items` section found.") + return + + # Get the matched content (excluding the "Action Items" heading itself) + action_items_content = action_items_section.group(1) + + # extract each bullet point without the bullet point itself + bullet_points = re.findall(r"^\s*[-*]\s+(.+)", action_items_content, re.MULTILINE) + + # format each bullet point separately in a list + formatted_bullet_points = [ + "- " + re.sub(r"^\[.\]\s+", "", item) for item in bullet_points + ] + + # Replace user names with user mentions + for i, item in enumerate(formatted_bullet_points): + for name, uid in UID_MAPS.items(): + item = item.replace(f"`{name}`", f"<@{uid}>") + formatted_bullet_points[i] = item + + # Replace role names with role mentions + for i, item in enumerate(formatted_bullet_points): + for role, role_id in ROLE_IDS.items(): + item = item.replace(f"`{role}`", role_mention(role_id)) + formatted_bullet_points[i] = item + + # Send title to the action-items channel + await action_items.client.rest.create_message( + CHANNEL_IDS["action-items"], + content="# Action Items:", + ) + + # send each bullet point separately + for item in formatted_bullet_points: + await action_items.client.rest.create_message( + CHANNEL_IDS["action-items"], + mentions_everyone=False, + user_mentions=True, + role_mentions=True, + content=item, + ) + + # respond with success if it executes successfully + await ctx.respond("✅ Action Items sent successfully!") + return + + +@arc.loader +def loader(client: arc.GatewayClient) -> None: + client.add_plugin(action_items) diff --git a/src/hooks.py b/src/hooks.py new file mode 100644 index 0000000..5cbc949 --- /dev/null +++ b/src/hooks.py @@ -0,0 +1,52 @@ +import arc +import hikari +import typing + + +async def _restrict_to_roles( + ctx: arc.GatewayContext, role_ids: typing.Sequence[hikari.Snowflake] +) -> arc.HookResult: + assert ctx.member + + if not any(role_id in ctx.member.role_ids for role_id in role_ids): + await ctx.respond( + "❌ This command is restricted. Only allowed roles are permitted to use this command.", + flags=hikari.MessageFlag.EPHEMERAL, + ) + return arc.HookResult(abort=True) + + return arc.HookResult() # by default, abort is set to False + + +# TODO: make response type a TypeVar for reuse (WrappedHookResult) +def restrict_to_roles( + role_ids: typing.Sequence[hikari.Snowflake], +) -> typing.Callable[[arc.GatewayContext], typing.Awaitable[arc.HookResult]]: + """Any command which uses this hook requires that the command be disabled in DMs as a guild role is required for this hook to function.""" + + async def func(ctx: arc.GatewayContext) -> arc.HookResult: + return await _restrict_to_roles(ctx, role_ids) + + return func + + +async def _restrict_to_channels( + ctx: arc.GatewayContext, channel_ids: typing.Sequence[hikari.Snowflake] +) -> arc.HookResult: + if ctx.channel_id not in channel_ids: + await ctx.respond( + "❌ This command cannot be used in this channel.", + flags=hikari.MessageFlag.EPHEMERAL, + ) + return arc.HookResult(abort=True) + + return arc.HookResult() + + +def restrict_to_channels( + channel_ids: typing.Sequence[hikari.Snowflake], +) -> typing.Callable[[arc.GatewayContext], typing.Awaitable[arc.HookResult]]: + async def func(ctx: arc.GatewayContext) -> arc.HookResult: + return await _restrict_to_channels(ctx, channel_ids) + + return func diff --git a/src/utils.py b/src/utils.py index 20762ce..b014819 100644 --- a/src/utils.py +++ b/src/utils.py @@ -1,5 +1,7 @@ import hikari from arc import GatewayClient +import aiohttp +from src.config import LDAP_USERNAME, LDAP_PASSWORD async def get_guild( @@ -10,3 +12,12 @@ async def get_guild( def role_mention(role_id: hikari.Snowflake | int | str) -> str: return f"<@&{role_id}>" + + +async def hedgedoc_login(aiohttp_client: aiohttp.ClientSession) -> None: + data = { + "username": LDAP_USERNAME, + "password": LDAP_PASSWORD, + } + + await aiohttp_client.post("https://md.redbrick.dcu.ie/auth/ldap", data=data)