Skip to content

Commit

Permalink
Add member services (#36)
Browse files Browse the repository at this point in the history
* Add anonymous report feature

* Add Away feature for leaders

* Convert invite command to view

* Fix Dr. Schwartz email, add transparency statement

* Away view should be persistent

* Reports changes for Summer 2024 (and beyond):
* Add 'Report History' button for reviewing
  report history
* Reports are now graded on red-yellow-green scale
* Add report reviewing for leaders each week
* Update report instructions embed w/ more info
* Members are emailed upon report being reviewed

* Fix report period

* Use single quote instead of multi quote
  • Loading branch information
cbrxyz authored May 21, 2024
1 parent e172732 commit f553df2
Show file tree
Hide file tree
Showing 7 changed files with 1,004 additions and 148 deletions.
109 changes: 109 additions & 0 deletions src/anonymous.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Literal

import discord

from .email import send_email
from .views import MILBotModal, MILBotView

if TYPE_CHECKING:
from .bot import MILBot


IntendedTargets = Literal["schwartz", "operations", "leaders"]


class AnonymousReportModal(MILBotModal):

report = discord.ui.TextInput(
label="Report",
placeholder="Enter your report here",
style=discord.TextStyle.long,
max_length=2000,
)

def __init__(self, bot: MILBot, target: IntendedTargets):
self.bot = bot
self.target = target
super().__init__(title="Submit an Anonymous Report")

async def on_submit(self, interaction: discord.Interaction):
embed = discord.Embed(
title="New Anonymous Report",
color=discord.Color.light_gray(),
description=self.report.value,
)
embed.set_footer(text="Submitted by an anonymous user")
if self.target == "schwartz":
html = f"""A new anonymous report has been submitted. The report is as follows:
<blockquote>{self.report.value}</blockquote>
Replies to this email will not be received. Please address any concerns with the appropriate leadership team."""
text = f"""A new anonymous report has been submitted. The report is as follows:
{self.report.value}
Replies to this email will not be received. Please address any concerns with the appropriate leadership team."""
await send_email(
"[email protected]",
"New Anonymous Report Received",
html,
text,
)
elif self.target == "operations":
await self.bot.operations_leaders_channel.send(embed=embed)
elif self.target == "leaders":
await self.bot.leaders_channel.send(embed=embed)
await interaction.response.send_message(
"Your report has been submitted. Your input is invaluable, and we are committed to making MIL a better place for everyone. Thank you for helping us improve.",
embed=embed,
ephemeral=True,
)


class AnonymousTargetSelect(discord.ui.Select):
def __init__(self, bot: MILBot):
self.bot = bot
options = [
discord.SelectOption(label="Dr. Schwartz", emoji="👨‍🏫", value="schwartz"),
discord.SelectOption(
label="Operations Leadership",
emoji="👷",
value="operations",
),
discord.SelectOption(label="Leaders", emoji="👑", value="leaders"),
]
super().__init__(
placeholder="Select the target of your report",
options=options,
)

async def callback(self, interaction: discord.Interaction):
target = self.values[0]
await interaction.response.send_modal(AnonymousReportModal(self.bot, target)) # type: ignore


class AnonymousReportView(MILBotView):
def __init__(self, bot: MILBot):
self.bot = bot
super().__init__(timeout=None)

@discord.ui.button(
label="Submit an anonymous report",
style=discord.ButtonStyle.red,
custom_id="anonymous_report:submit",
)
async def submit_anonymous_report(
self,
interaction: discord.Interaction,
button: discord.ui.Button,
):
view = MILBotView()
view.add_item(AnonymousTargetSelect(self.bot))
await interaction.response.send_message(
"Select the target of your report.",
view=view,
ephemeral=True,
)
46 changes: 39 additions & 7 deletions src/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from google.oauth2.service_account import Credentials
from rich.logging import RichHandler

from .anonymous import AnonymousReportView
from .calendar import CalendarView
from .constants import Team
from .env import (
Expand All @@ -30,9 +31,10 @@
GUILD_ID,
)
from .exceptions import MILBotErrorHandler, ResourceNotFound
from .github import GitHub
from .github import GitHub, GitHubInviteView
from .leaders import AwayView
from .projects import SoftwareProjectsView
from .reports import ReportsCog, ReportsView
from .reports import ReportsCog, ReportsView, StartReviewView
from .roles import MechanicalRolesView, SummerRolesView, TeamRolesView
from .tasks import TaskManager
from .testing import TestingSignUpView
Expand Down Expand Up @@ -88,10 +90,11 @@ class MILBot(commands.Bot):
leaders_channel: discord.TextChannel
leave_channel: discord.TextChannel
general_channel: discord.TextChannel
reports_channel: discord.TextChannel
member_services_channel: discord.TextChannel
errors_channel: discord.TextChannel
software_projects_channel: discord.TextChannel
software_category_channel: discord.CategoryChannel
operations_leaders_channel: discord.TextChannel

# Emojis
loading_emoji: str
Expand Down Expand Up @@ -228,6 +231,10 @@ async def setup_hook(self) -> None:
self.add_view(CalendarView(self))
self.add_view(TestingSignUpView(self, ""))
self.add_view(SummerRolesView(self))
self.add_view(AnonymousReportView(self))
self.add_view(GitHubInviteView(self))
self.add_view(AwayView(self))
self.add_view(StartReviewView(self))

agcm = gspread_asyncio.AsyncioGspreadClientManager(get_creds)
self.agc = await agcm.authorize()
Expand Down Expand Up @@ -256,12 +263,12 @@ async def fetch_vars(self) -> None:
assert isinstance(general_channel, discord.TextChannel)
self.general_channel = general_channel

reports_channel = discord.utils.get(
member_services_channel = discord.utils.get(
self.active_guild.text_channels,
name="reports",
name="member-services",
)
assert isinstance(reports_channel, discord.TextChannel)
self.reports_channel = reports_channel
assert isinstance(member_services_channel, discord.TextChannel)
self.member_services_channel = member_services_channel

leaders_channel = discord.utils.get(
self.active_guild.text_channels,
Expand All @@ -270,6 +277,13 @@ async def fetch_vars(self) -> None:
assert isinstance(leaders_channel, discord.TextChannel)
self.leaders_channel = leaders_channel

operations_leaders_channel = discord.utils.get(
self.active_guild.text_channels,
name="operations-leadership",
)
assert isinstance(operations_leaders_channel, discord.TextChannel)
self.operations_leaders_channel = operations_leaders_channel

errors_channel = discord.utils.get(
self.active_guild.text_channels,
name="bot-errors",
Expand Down Expand Up @@ -334,6 +348,13 @@ async def fetch_vars(self) -> None:
assert isinstance(alumni_role, discord.Role)
self.alumni_role = alumni_role

away_role = discord.utils.get(
self.active_guild.roles,
name="Away from MIL",
)
assert isinstance(away_role, discord.Role)
self.away_role = away_role

reports_cog = self.get_cog("ReportsCog")
if not reports_cog:
raise ResourceNotFound("Reports cog not found.")
Expand Down Expand Up @@ -366,6 +387,17 @@ async def reading_gif(self) -> discord.File:
raise ResourceNotFound("Cat gif not found.")
return discord.File(BytesIO(await resp.read()), filename="cat.gif")

async def good_job_gif(self) -> discord.File:
gifs = [
"https://media0.giphy.com/media/v1.Y2lkPTc5MGI3NjExb3BqbnlzaXBmODdxMzRkeHFxYWg1N3NoM3A4czh3aGo2NHhmNGRtYSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/ktfInKGOVkdQtwJy9h/giphy.gif",
"https://media3.giphy.com/media/v1.Y2lkPTc5MGI3NjExdjRyOGJjY3cxdTUzb2gycXlmcW1lZ2ZsYXh3aGxjaDY1cTNyMzRnNiZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/UjCXeFnYcI2R2/giphy.gif",
"https://media4.giphy.com/media/v1.Y2lkPTc5MGI3NjExMXo1dHdxem04N2M4ZXJhaTlnb25mYTZsMmtjMGFyMWJweDFleG03ZCZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/8pR7lPuRZzTXVtWlTF/giphy.gif",
]
async with self.session.get(random.choice(gifs)) as resp:
if resp.status != 200:
raise ResourceNotFound("Cat gif not found.")
return discord.File(BytesIO(await resp.read()), filename="cat.gif")

async def on_message(self, message):
# don't respond to ourselves
if message.author == self.user:
Expand Down
25 changes: 19 additions & 6 deletions src/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,39 @@
SEMESTERS = [
(datetime.date(2023, 8, 23), datetime.date(2023, 12, 6)),
(datetime.date(2024, 1, 8), datetime.date(2024, 4, 28)),
(datetime.date(2024, 5, 13), datetime.date(2024, 8, 9)),
(datetime.date(2024, 5, 20), datetime.date(2024, 7, 29)),
]
SCHWARTZ_EMAIL = "[email protected]"


def semester_given_date(
date: datetime.datetime,
*,
next_semester: bool = False,
) -> tuple[datetime.date, datetime.date] | None:
for semester in SEMESTERS:
if semester[0] <= date.date() <= semester[1]:
return semester
if next_semester and date.date() < semester[0]:
return semester
return None


class Team(enum.Enum):
SOFTWARE = auto()
ELECTRICAL = auto()
MECHANICAL = auto()
SYSTEMS = auto()
GENERAL = auto()

@classmethod
def from_str(cls, ss_str: str) -> Team:
if "software" in ss_str.lower():
if "software" in ss_str.lower() or "S" in ss_str:
return cls.SOFTWARE
if "electrical" in ss_str.lower():
elif "electrical" in ss_str.lower() or "E" in ss_str:
return cls.ELECTRICAL
if "mechanical" in ss_str.lower():
elif "mechanical" in ss_str.lower() or "M" in ss_str:
return cls.MECHANICAL
return cls.SYSTEMS
return cls.GENERAL

@property
def emoji(self) -> str:
Expand Down
63 changes: 63 additions & 0 deletions src/email.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,74 @@
from dataclasses import dataclass
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from enum import Enum

import aiosmtplib

from .env import EMAIL_PASSWORD, EMAIL_USERNAME


class HeaderPrefix(Enum):
FULL = LONG = "[Machine Intelligence Laboratory]"
INITIALS = SHORT = "[MIL]"


@dataclass
class Email:
receiver_emails: list[str]
subject: str
html: str
text: str | None = None
cc_emails: list[str] | None = None
header_prefix: HeaderPrefix | None = HeaderPrefix.INITIALS

async def send(self) -> None:
port = 587
hostname = "smtp.ufl.edu"
sender_email = EMAIL_USERNAME
password = EMAIL_PASSWORD
if not sender_email or not password:
raise RuntimeError(
"No email username and/or password found! Cannot send email.",
)

smtp_server = aiosmtplib.SMTP()
await smtp_server.connect(hostname=hostname, port=port)
await smtp_server.login(sender_email, password)
custom_email = "[email protected]"

# Create a multipart message and set headers
message = MIMEMultipart("alternative")
message["From"] = custom_email
message["To"] = ", ".join(self.receiver_emails)
message["Subject"] = (
f"{self.header_prefix.value} {self.subject}"
if self.header_prefix
else self.subject
)

# Turn these into plain/html MIMEText objects
if self.text is None:
# Attempt to convert HTML to text
self.text = (
self.html.replace("<p>", " ").replace("</p>", " ").replace("\n", " ")
)

text_footer = " --- This is an automated message. Replies will not be received."
html_footer = "\n\n<p>---<br>This is an automated message. Replies will not be received.</p>"
part1 = MIMEText(self.text + text_footer, "plain")
part2 = MIMEText(self.html + html_footer, "html")
message.attach(part1)
message.attach(part2)

await smtp_server.sendmail(
custom_email,
self.receiver_emails,
message.as_string(),
)
await smtp_server.quit()


async def send_email(receiver_email, subject, html, text) -> bool:
port = 587
hostname = "smtp.ufl.edu"
Expand Down
Loading

0 comments on commit f553df2

Please sign in to comment.