diff --git a/Dockerfile b/Dockerfile index 45fc0f5..d842ecd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,4 +15,6 @@ EXPOSE 3000 ENV PATH="/app/.venv/bin:$PATH" +RUN prisma db push + CMD ["nephthys"] \ No newline at end of file diff --git a/nephthys/__main__.py b/nephthys/__main__.py index 754a313..99de19c 100644 --- a/nephthys/__main__.py +++ b/nephthys/__main__.py @@ -7,6 +7,7 @@ from aiohttp import ClientSession from dotenv import load_dotenv from starlette.applications import Starlette +from nephthys.tasks.update_helpers import update_helpers from nephthys.utils.delete_thread import process_queue from nephthys.utils.env import env from nephthys.utils.logging import send_heartbeat @@ -30,6 +31,7 @@ async def main(_app: Starlette): env.session = session await env.db.connect() delete_msg_task = asyncio.create_task(process_queue()) + await update_helpers() handler = None if env.slack_app_token: if env.environment == "production": diff --git a/nephthys/actions/assign.py b/nephthys/actions/assign.py new file mode 100644 index 0000000..e69de29 diff --git a/nephthys/actions/assign_tag.py b/nephthys/actions/assign_tag.py new file mode 100644 index 0000000..d4b0cbe --- /dev/null +++ b/nephthys/actions/assign_tag.py @@ -0,0 +1,87 @@ +import logging +from typing import Any +from typing import Dict + +from slack_bolt.async_app import AsyncAck +from slack_sdk.web.async_client import AsyncWebClient + +from nephthys.utils.env import env +from nephthys.utils.logging import send_heartbeat + + +async def assign_tag_callback( + ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient +): + await ack() + user_id = body["user"]["id"] + raw_tags = body["actions"][0]["selected_options"] + tags = [ + {"name": tag["text"]["text"], "value": tag["value"]} + for tag in raw_tags + if "value" in tag + ] + logging.info(tags) + channel_id = body["channel"]["id"] + ts = body["message"]["ts"] + + user = await env.db.user.find_unique(where={"id": user_id}) + if not user or not user.helper: + await client.chat_postEphemeral( + channel=channel_id, + user=user_id, + text="You are not authorized to assign tags.", + ) + return + + ticket = await env.db.ticket.find_unique( + where={"ticketTs": ts}, include={"tagsOnTickets": True} + ) + if not ticket: + await send_heartbeat( + f"Failed to find ticket with ts {ts} in channel {channel_id}." + ) + return + if ticket.tagsOnTickets: + new_tags = [ + tag + for tag in tags + if tag["value"] not in [t.tagId for t in ticket.tagsOnTickets] + ] + old_tags = [ + tag + for tag in ticket.tagsOnTickets + if tag.tagId not in [t["value"] for t in tags] + ] + else: + new_tags = tags + old_tags = [] + logging.info(f"New: {new_tags}, Old: {old_tags}") + + await env.db.tagsontickets.create_many( + data=[{"tagId": tag["value"], "ticketId": ticket.id} for tag in new_tags] + ) + + await env.db.tagsontickets.delete_many( + where={"tagId": {"in": [tag.tagId for tag in old_tags]}, "ticketId": ts} + ) + + tags = await env.db.usertagsubscription.find_many( + where={"tagId": {"in": [tag["value"] for tag in new_tags]}} + ) + + users = [ + { + "id": tag.userId, + "tags": [tag.tagId for tag in tags if tag.userId == tag.userId], + } + for tag in tags + ] + url = f"https://hackclub.slack.com/archives/{env.slack_help_channel}/p{ticket.msgTs.replace('.', '')}" + + for user in users: + formatted_tags = ", ".join( + [tag["name"] for tag in new_tags if tag["value"] in user["tags"]] + ) + await client.chat_postMessage( + channel=user["id"], text=f"New ticket for {formatted_tags}!\n{url}" + ) diff --git a/nephthys/actions/create_tag.py b/nephthys/actions/create_tag.py new file mode 100644 index 0000000..a04cb32 --- /dev/null +++ b/nephthys/actions/create_tag.py @@ -0,0 +1,44 @@ +from slack_bolt.async_app import AsyncAck +from slack_sdk.web.async_client import AsyncWebClient + +from nephthys.events.app_home_opened import open_app_home +from nephthys.utils.env import env +from nephthys.utils.logging import send_heartbeat +from nephthys.views.modals.create_tag import get_create_tag_modal + + +async def create_tag_view_callback(ack: AsyncAck, body: dict, client: AsyncWebClient): + """ + Callback for the create tag view submission + """ + await ack() + user_id = body["user"]["id"] + + user = await env.db.user.find_unique(where={"id": user_id}) + if not user or not user.admin: + await send_heartbeat(f"Attempted to create tag by non-admin user <@{user_id}>") + return + + name = body["view"]["state"]["values"]["tag_name"]["tag_name"]["value"] + await env.db.tag.create(data={"name": name}) + + await open_app_home("tags", client, user_id) + + +async def create_tag_btn_callback(ack: AsyncAck, body: dict, client: AsyncWebClient): + """ + Open modal to create a tag + """ + await ack() + user_id = body["user"]["id"] + trigger_id = body["trigger_id"] + + user = await env.db.user.find_unique(where={"id": user_id}) + if not user or not user.admin: + await send_heartbeat( + f"Attempted to open create tag modal by non-admin user <@{user_id}>" + ) + return + + view = get_create_tag_modal() + await client.views_open(trigger_id=trigger_id, view=view, user_id=user_id) diff --git a/nephthys/actions/resolve.py b/nephthys/actions/resolve.py index 4e87dab..aac3389 100644 --- a/nephthys/actions/resolve.py +++ b/nephthys/actions/resolve.py @@ -1,3 +1,5 @@ +from datetime import datetime + from slack_sdk.web.async_client import AsyncWebClient from nephthys.data.transcript import Transcript @@ -9,7 +11,7 @@ from prisma.enums import TicketStatus async def resolve(ts: str, resolver: str, client: AsyncWebClient): - allowed = can_resolve(resolver, ts) + allowed = await can_resolve(resolver, ts) if not allowed: await send_heartbeat( f"User {resolver} attempted to resolve ticket with ts {ts} without permission.", @@ -22,9 +24,14 @@ async def resolve(ts: str, resolver: str, client: AsyncWebClient): if not ticket: return + now = datetime.now() tkt = await env.db.ticket.update( where={"msgTs": ts}, - data={"status": TicketStatus.CLOSED, "closedBy": {"connect": {"id": resolver}}}, + data={ + "status": TicketStatus.CLOSED, + "closedBy": {"connect": {"id": resolver}}, + "closedAt": now, + }, ) if not tkt: await send_heartbeat( @@ -35,7 +42,7 @@ async def resolve(ts: str, resolver: str, client: AsyncWebClient): await client.chat_postMessage( channel=env.slack_help_channel, - text=Transcript.ticket_resolve, + text=Transcript.ticket_resolve.format(user_id=resolver), thread_ts=ts, ) diff --git a/nephthys/actions/tag_subscribe.py b/nephthys/actions/tag_subscribe.py new file mode 100644 index 0000000..5ff6199 --- /dev/null +++ b/nephthys/actions/tag_subscribe.py @@ -0,0 +1,44 @@ +from typing import Any +from typing import Dict + +from slack_bolt.async_app import AsyncAck +from slack_sdk.web.async_client import AsyncWebClient + +from nephthys.events.app_home_opened import open_app_home +from nephthys.utils.env import env +from nephthys.utils.logging import send_heartbeat + + +async def tag_subscribe_callback( + ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient +): + """ + Callback for the tag subscribe button + """ + await ack() + user_id = body["user"]["id"] + + user = await env.db.user.find_unique(where={"id": user_id}) + if not user: + await send_heartbeat( + f"Attempted to subscribe to tag by unknown user <@{user_id}>" + ) + return + + tag_id, tag_name = body["actions"][0]["value"].split(";") + # check if user is subcribed + if await env.db.usertagsubscription.find_first( + where={"userId": user_id, "tagId": tag_id} + ): + await env.db.usertagsubscription.delete( + where={"userId_tagId": {"tagId": tag_id, "userId": user_id}} + ) + else: + await env.db.usertagsubscription.create( + data={ + "user": {"connect": {"id": user_id}}, + "tag": {"connect": {"id": tag_id}}, + } + ) + + await open_app_home("tags", client, user_id) diff --git a/nephthys/data/transcript.py b/nephthys/data/transcript.py index e8dc538..d9cdd66 100644 --- a/nephthys/data/transcript.py +++ b/nephthys/data/transcript.py @@ -13,5 +13,10 @@ someone should be along to help you soon but in the mean time i suggest you read """ ticket_resolve = f""" -oh, oh! it looks like this post has been marked as resolved by (user)! if you have any more questions, please make a new post in <#{env.slack_help_channel}> and someone'll be happy to help you out! not me though, i'm just a silly racoon ^-^ +oh, oh! it looks like this post has been marked as resolved by <@{{user_id}}>! if you have any more questions, please make a new post in <#{env.slack_help_channel}> and someone'll be happy to help you out! not me though, i'm just a silly racoon ^-^ """ + + home_unknown_user_title = ":upside-down_orpheus: woah, stop right there {name}!" + home_unknown_user_text = f"heyyyy, heidi here! it looks like i'm not allowed to show ya this. sorry! if you think this is a mistake, please reach out to <@{env.slack_maintainer_id}> and she'll lmk what to do!" + + not_allowed_channel = f"heya, it looks like you're not supposed to be in that channel, pls talk to <@{env.slack_maintainer_id}> if that's wrong" diff --git a/nephthys/events/app_home_opened.py b/nephthys/events/app_home_opened.py new file mode 100644 index 0000000..3121661 --- /dev/null +++ b/nephthys/events/app_home_opened.py @@ -0,0 +1,73 @@ +import logging +import traceback +from typing import Any + +from slack_sdk.web.async_client import AsyncWebClient + +from nephthys.utils.env import env +from nephthys.utils.logging import send_heartbeat +from nephthys.views.home.error import get_error_view +from nephthys.views.home.helper import get_helper_view +from nephthys.views.home.loading import get_loading_view +from nephthys.views.home.stats import get_stats_view +from nephthys.views.home.tags import get_manage_tags_view +from nephthys.views.home.unknown_user import get_unknown_user_view + + +async def on_app_home_opened(event: dict[str, Any], client: AsyncWebClient): + user_id = event["user"] + await open_app_home("default", client, user_id) + + +async def open_app_home(home_type: str, client: AsyncWebClient, user_id: str): + try: + await client.views_publish(view=get_loading_view(), user_id=user_id) + + user = await env.db.user.find_unique(where={"id": user_id}) + + if not user: + user_info = await client.users_info(user=user_id) or {} + name = ( + user_info.get("user", {}).get("profile", {}).get("display_name") + or user_info.get("user", {}).get("profile", {}).get("real_name") + or "person" + ) + view = get_unknown_user_view(name) + else: + logging.info(f"Opening {home_type} for {user_id}") + match home_type: + case "default" | "dashboard": + view = await get_helper_view(user) + case "tags": + if user.admin: + view = await get_manage_tags_view(user) + else: + view = get_error_view( + "You do not have permission to access this page." + ) + case "my-stats": + view = await get_stats_view(user) + case _: + await send_heartbeat( + f"Attempted to load unknown app home type {home_type} for <@{user_id}>" + ) + view = get_error_view( + f"This shouldn't happen, please tell <@{env.slack_maintainer_id}> that app home case `_` was hit with home type `{home_type}`" + ) + except Exception as e: + logging.error(f"Error opening app home: {e}") + tb = traceback.format_exception(e) + + tb_str = "".join(tb) + + view = get_error_view( + f"An error occurred while opening the app home: {e}", + traceback=tb_str, + ) + err_type = type(e).__name__ + await send_heartbeat( + f"`{err_type}` opening app home for <@{user_id}>", + messages=[f"```{tb_str}```", f"cc <@{env.slack_maintainer_id}>"], + ) + + await client.views_publish(user_id=user_id, view=view) diff --git a/nephthys/events/channel_join.py b/nephthys/events/channel_join.py new file mode 100644 index 0000000..36a708e --- /dev/null +++ b/nephthys/events/channel_join.py @@ -0,0 +1,23 @@ +from slack_bolt.context.ack.async_ack import AsyncAck +from slack_sdk.web.async_client import AsyncWebClient + +from nephthys.data.transcript import Transcript +from nephthys.tasks.update_helpers import update_helpers +from nephthys.utils.env import env + + +async def channel_join(ack: AsyncAck, event: dict, client: AsyncWebClient): + await ack() + user_id = event["user"] + channel_id = event["channel"] + + if channel_id in [env.slack_bts_channel, env.slack_ticket_channel]: + users = await client.usergroups_users_list(usergroup=env.slack_user_group) + if user_id not in users.get("users", []): + await client.conversations_kick(channel=channel_id, user=user_id) + await client.chat_postMessage( + channel=user_id, text=Transcript.not_allowed_channel + ) + await update_helpers() + else: + return diff --git a/nephthys/events/channel_left.py b/nephthys/events/channel_left.py new file mode 100644 index 0000000..8223449 --- /dev/null +++ b/nephthys/events/channel_left.py @@ -0,0 +1,28 @@ +from slack_bolt.context.ack.async_ack import AsyncAck +from slack_sdk.web.async_client import AsyncWebClient + +from nephthys.utils.env import env + + +async def channel_left(ack: AsyncAck, event: dict, client: AsyncWebClient): + await ack() + user_id = event["user"] + channel_id = event["channel"] + + if channel_id == env.slack_help_channel: + return + + users = await client.usergroups_users_list(usergroup=env.slack_user_group) + new_users = users.get("users", []) + new_users.remove(user_id) + await client.usergroups_users_update( + usergroup=env.slack_user_group, users=new_users + ) + + await env.db.user.update(where={"id": user_id}, data={"helper": False}) + + match channel_id: + case env.slack_bts_channel: + await client.conversations_kick(channel=channel_id, user=user_id) + case env.slack_ticket_channel: + await client.conversations_kick(channel=channel_id, user=user_id) diff --git a/nephthys/events/message.py b/nephthys/events/message.py index 8a98f05..222472a 100644 --- a/nephthys/events/message.py +++ b/nephthys/events/message.py @@ -5,6 +5,7 @@ from slack_sdk.web.async_client import AsyncWebClient from nephthys.data.transcript import Transcript from nephthys.utils.env import env +from nephthys.utils.logging import send_heartbeat ALLOWED_SUBTYPES = ["file_share", "me_message"] @@ -13,7 +14,6 @@ async def on_message(event: Dict[str, Any], client: AsyncWebClient): """ Handle incoming messages in Slack. """ - print("Received message event:", event) if "subtype" in event and event["subtype"] not in ALLOWED_SUBTYPES: return @@ -30,11 +30,23 @@ async def on_message(event: Dict[str, Any], client: AsyncWebClient): past_tickets = await env.db.ticket.count(where={"openedById": user}) else: past_tickets = 0 + user_info = await client.users_info(user=user) or {} + username = user_info.get("user", {})[ + "name" + ] # this should never actually be empty but if it is, that is a major issue + + if not username: + await send_heartbeat( + f"SOMETHING HAS GONE TERRIBLY WRONG <@{user}> has no username found - <@{env.slack_maintainer_id}>" + ) db_user = await env.db.user.upsert( where={ "id": user, }, - data={"create": {"id": user}, "update": {"id": user}}, + data={ + "create": {"id": user, "username": username}, + "update": {"id": user, "username": username}, + }, ) user_info = await client.users_info(user=user) @@ -50,6 +62,16 @@ async def on_message(event: Dict[str, Any], client: AsyncWebClient): channel=env.slack_ticket_channel, text=f"New message from <@{user}>: {text}", blocks=[ + { + "type": "input", + "label": {"type": "plain_text", "text": "Tag ticket", "emoji": True}, + "element": { + "action_id": "tag-list", + "type": "multi_external_select", + "placeholder": {"type": "plain_text", "text": "Select tags"}, + "min_query_length": 1, + }, + }, { "type": "context", "elements": [ @@ -58,7 +80,7 @@ async def on_message(event: Dict[str, Any], client: AsyncWebClient): "text": f"Submitted by <@{user}>. They have {past_tickets} past tickets. <{thread_url}|View thread>.", } ], - } + }, ], username=display_name or None, icon_url=profile_pic or None, diff --git a/nephthys/options/tags.py b/nephthys/options/tags.py new file mode 100644 index 0000000..6bca056 --- /dev/null +++ b/nephthys/options/tags.py @@ -0,0 +1,23 @@ +from thefuzz import fuzz +from thefuzz import process + +from nephthys.utils.env import env + + +async def get_tags(payload: dict) -> list[dict[str, dict[str, str] | str]]: + tags = await env.db.tag.find_many() + + keyword = payload.get("value") + if keyword: + tag_names = [tag.name for tag in tags] + scores = process.extract(keyword, tag_names, scorer=fuzz.ratio, limit=100) + old_tags = tags + + tags = [old_tags[tag_names.index(score[0])] for score in scores] + return [ + { + "text": {"type": "plain_text", "text": f"{tag.name}"}, + "value": tag.id, + } + for tag in tags + ] diff --git a/nephthys/tasks/update_helpers.py b/nephthys/tasks/update_helpers.py new file mode 100644 index 0000000..239b2cb --- /dev/null +++ b/nephthys/tasks/update_helpers.py @@ -0,0 +1,46 @@ +import logging + +from nephthys.utils.env import env + + +async def update_helpers(): + res = await env.slack_client.conversations_members(channel=env.slack_bts_channel) + team_ids = res.get("members", []) + + if not team_ids: + # if this happens then something concerning has happened :p + await env.slack_client.chat_postMessage( + channel=env.slack_bts_channel, + text=f"No members found in the bts channel. <@{env.slack_maintainer_id}>", + ) + return + + # unset helpers not in the team + await env.db.user.update_many( + where={"helper": True, "id": {"not_in": team_ids}}, + data={"helper": False}, + ) + + # update existing users in the db + await env.db.user.update_many( + where={"id": {"in": team_ids}}, + data={"helper": True}, + ) + + # create new users not in the db + existing_users_in_db = await env.db.user.find_many(where={"id": {"in": team_ids}}) + existing_user_ids_in_db = {user.id for user in existing_users_in_db} + + new_member_data_to_create = [] + for member_id in team_ids: + if member_id not in existing_user_ids_in_db: + user_info = await env.slack_client.users_info(user=member_id) + logging.info( + f"Creating new helper user {member_id} with info {user_info.get('name')}" + ) + new_member_data_to_create.append( + {"id": member_id, "helper": True, "username": user_info["name"]} + ) + + if new_member_data_to_create: + await env.db.user.create_many(data=new_member_data_to_create) diff --git a/nephthys/utils/env.py b/nephthys/utils/env.py index a73ab97..e49925d 100644 --- a/nephthys/utils/env.py +++ b/nephthys/utils/env.py @@ -19,6 +19,9 @@ class Environment: self.environment = os.environ.get("ENVIRONMENT", "development") self.slack_help_channel = os.environ.get("SLACK_HELP_CHANNEL", "unset") self.slack_ticket_channel = os.environ.get("SLACK_TICKET_CHANNEL", "unset") + self.slack_bts_channel = os.environ.get("SLACK_BTS_CHANNEL", "unset") + self.slack_user_group = os.environ.get("SLACK_USER_GROUP", "unset") + self.slack_maintainer_id = os.environ.get("SLACK_MAINTAINER_ID", "unset") self.port = int(os.environ.get("PORT", 3000)) diff --git a/nephthys/utils/slack.py b/nephthys/utils/slack.py index 6133537..7e563fa 100644 --- a/nephthys/utils/slack.py +++ b/nephthys/utils/slack.py @@ -5,8 +5,17 @@ from slack_bolt.async_app import AsyncApp from slack_bolt.context.ack.async_ack import AsyncAck from slack_sdk.web.async_client import AsyncWebClient +from nephthys.actions.assign_tag import assign_tag_callback +from nephthys.actions.create_tag import create_tag_btn_callback +from nephthys.actions.create_tag import create_tag_view_callback from nephthys.actions.resolve import resolve +from nephthys.actions.tag_subscribe import tag_subscribe_callback +from nephthys.events.app_home_opened import on_app_home_opened +from nephthys.events.app_home_opened import open_app_home +from nephthys.events.channel_join import channel_join +from nephthys.events.channel_left import channel_left from nephthys.events.message import on_message +from nephthys.options.tags import get_tags from nephthys.utils.env import env app = AsyncApp(token=env.slack_bot_token, signing_secret=env.slack_signing_secret) @@ -26,3 +35,55 @@ async def handle_mark_resolved_button( value = body["actions"][0]["value"] resolver = body["user"]["id"] await resolve(value, resolver, client) + + +@app.options("tag-list") +async def handle_tag_list_options(ack: AsyncAck, payload: dict): + tags = await get_tags(payload) + await ack(options=tags) + + +@app.event("app_home_opened") +async def app_home_opened_handler(event: dict[str, Any], client: AsyncWebClient): + await on_app_home_opened(event, client) + + +@app.action("dashboard") +@app.action("tags") +@app.action("my-stats") +async def manage_home_switcher(ack: AsyncAck, body, client: AsyncWebClient): + await ack() + user_id = body["user"]["id"] + action_id = body["actions"][0]["action_id"] + + await open_app_home(action_id, client, user_id) + + +@app.event("member_joined_channel") +async def handle_member_joined_channel(event: Dict[str, Any], client: AsyncWebClient): + await channel_join(ack=AsyncAck(), event=event, client=client) + + +@app.event("member_left_channel") +async def handle_member_left_channel(event: Dict[str, Any], client: AsyncWebClient): + await channel_left(ack=AsyncAck(), event=event, client=client) + + +@app.action("create-tag") +async def create_tag(ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient): + await create_tag_btn_callback(ack, body, client) + + +@app.view("create_tag") +async def create_tag_view(ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient): + await create_tag_view_callback(ack, body, client) + + +@app.action("tag-subscribe") +async def tag_subscribe(ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient): + await tag_subscribe_callback(ack, body, client) + + +@app.action("tag-list") +async def assign_tag(ack: AsyncAck, body: Dict[str, Any], client: AsyncWebClient): + await assign_tag_callback(ack, body, client) diff --git a/nephthys/views/home/components/buttons.py b/nephthys/views/home/components/buttons.py new file mode 100644 index 0000000..08798ab --- /dev/null +++ b/nephthys/views/home/components/buttons.py @@ -0,0 +1,35 @@ +from prisma.models import User + + +def get_buttons(user: User, current: str = "dashboard"): + buttons = [] + + buttons.append( + { + "type": "button", + "text": {"type": "plain_text", "text": "Dashboard", "emoji": True}, + "action_id": "dashboard", + **({"style": "primary"} if current != "dashboard" else {}), + } + ) + if user.admin: + buttons.append( + { + "type": "button", + "text": {"type": "plain_text", "text": "Tags", "emoji": True}, + "action_id": "tags", + **({"style": "primary"} if current != "tags" else {}), + } + ) + + buttons.append( + { + "type": "button", + "text": {"type": "plain_text", "text": "My Stats", "emoji": True}, + "action_id": "my-stats", + **({"style": "primary"} if current != "my-stats" else {}), + } + ) + + blocks = {"type": "actions", "elements": buttons} + return blocks diff --git a/nephthys/views/home/error.py b/nephthys/views/home/error.py new file mode 100644 index 0000000..ba96f86 --- /dev/null +++ b/nephthys/views/home/error.py @@ -0,0 +1,19 @@ +def get_error_view(msg: str, traceback: str | None = None): + if traceback: + msg = f"{msg}\n\nTraceback:\n```{traceback}```" + return { + "type": "home", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Sorry, something went wrong. Please try again later.", + }, + }, + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"Error message\n{msg}"}, + }, + ], + } diff --git a/nephthys/views/home/helper.py b/nephthys/views/home/helper.py new file mode 100644 index 0000000..ff0ae6a --- /dev/null +++ b/nephthys/views/home/helper.py @@ -0,0 +1,42 @@ +from nephthys.utils.env import env +from nephthys.views.home.components.buttons import get_buttons +from prisma.enums import TicketStatus +from prisma.models import User + + +async def get_helper_view(user: User): + tickets = await env.db.ticket.find_many() or [] + + organised_tkts = {} + for ticket in tickets: + status = ticket.status + if status not in organised_tkts: + organised_tkts[status] = [] + organised_tkts[status].append(ticket) + + formatted_msg = f""" + *Requests* + {len(tickets)} requests found + {len(organised_tkts.get(TicketStatus.OPEN, []))} open + {len(organised_tkts.get(TicketStatus.IN_PROGRESS, []))} in progress ({len([ticket for ticket in tickets if ticket.status == TicketStatus.IN_PROGRESS and ticket.assignedToId == user.id])} assigned to you) + {len(organised_tkts.get(TicketStatus.CLOSED, []))} closed ({len([ticket for ticket in tickets if ticket.status == TicketStatus.CLOSED and ticket.closedById == user.id])} closed by you) + """ + + btns = get_buttons(user, "dashboard") + + return { + "type": "home", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": ":rac_cute: helper heidi", + "emoji": True, + }, + }, + btns, + {"type": "divider"}, + {"type": "section", "text": {"type": "mrkdwn", "text": formatted_msg}}, + ], + } diff --git a/nephthys/views/home/loading.py b/nephthys/views/home/loading.py new file mode 100644 index 0000000..5c10704 --- /dev/null +++ b/nephthys/views/home/loading.py @@ -0,0 +1,21 @@ +def get_loading_view(): + return { + "type": "home", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":hourglass_flowing_sand: loading...", + }, + }, + { + "type": "divider", + }, + { + "type": "image", + "image_url": "https://hc-cdn.hel1.your-objectstorage.com/s/v3/1c1fc5fb03b8bf46c6ab047c97f962ed930616f0_loading-hugs.gif", + "alt_text": "Loading...", + }, + ], + } diff --git a/nephthys/views/home/stats.py b/nephthys/views/home/stats.py new file mode 100644 index 0000000..0247aa4 --- /dev/null +++ b/nephthys/views/home/stats.py @@ -0,0 +1,29 @@ +from nephthys.views.home.components.buttons import get_buttons +from prisma.models import User + + +async def get_stats_view(user: User): + btns = get_buttons(user, "my-stats") + + return { + "type": "home", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": ":rac_info: My Stats", + "emoji": True, + }, + }, + btns, + {"type": "divider"}, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":rac_cute: weh im just a silly raccoon, did you expect me to have stats :rac_ded: i don't even have a job >:(", + }, + }, + ], + } diff --git a/nephthys/views/home/tags.py b/nephthys/views/home/tags.py new file mode 100644 index 0000000..5e21176 --- /dev/null +++ b/nephthys/views/home/tags.py @@ -0,0 +1,106 @@ +import logging + +from nephthys.utils.env import env +from nephthys.views.home.components.buttons import get_buttons +from prisma.models import User + + +async def get_manage_tags_view(user: User) -> dict: + btns = get_buttons(user, "tags") + + tags = await env.db.tag.find_many(include={"userSubscriptions": True}) + + blocks = [] + + if not tags: + blocks.append( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":rac_nooo: i couldn't scrounge up any tags, you can make a new one below though", + }, + } + ) + + else: + blocks.append( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":rac_info: here are the tags i found!", + }, + } + ) + + for tag in tags: + logging.info(f"Tag {tag.name} with id {tag.id} found in the database") + logging.info( + f"Tag {tag.name} has {len(tag.userSubscriptions) if tag.userSubscriptions else 0} subscriptions" + ) + if tag.userSubscriptions: + subs = [user.userId for user in tag.userSubscriptions] + else: + subs = [] + stringified_subs = [f"<@{user}>" for user in subs] + blocks.append( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{tag.name}* - {''.join(stringified_subs) if stringified_subs else ':rac_nooo: no subscriptions'}", + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": f":rac_cute: {'subscribe' if user.id not in subs else 'unsubscribe'}", + "emoji": True, + }, + "action_id": "tag-subscribe", + "value": f"{tag.id};{tag.name}", + "style": "primary" if user.id not in subs else "danger", + }, + } + ) + + return { + "type": "home", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": ":rac_info: Manage Tags", + "emoji": True, + }, + }, + btns, + {"type": "divider"}, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":rac_thumbs: here you can manage tags and user subscriptions", + }, + }, + {"type": "divider"}, + *blocks, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": ":rac_cute: add a tag?", + "emoji": True, + }, + "action_id": "create-tag", + "style": "primary", + } + ], + }, + ], + } diff --git a/nephthys/views/home/unknown_user.py b/nephthys/views/home/unknown_user.py new file mode 100644 index 0000000..c16dd5d --- /dev/null +++ b/nephthys/views/home/unknown_user.py @@ -0,0 +1,21 @@ +from nephthys.data.transcript import Transcript + + +def get_unknown_user_view(name: str): + return { + "type": "home", + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": Transcript.home_unknown_user_title.format(name=name), + "emoji": True, + }, + }, + { + "type": "section", + "text": {"type": "mrkdwn", "text": Transcript.home_unknown_user_text}, + }, + ], + } diff --git a/nephthys/views/modals/create_tag.py b/nephthys/views/modals/create_tag.py new file mode 100644 index 0000000..a28ef8c --- /dev/null +++ b/nephthys/views/modals/create_tag.py @@ -0,0 +1,30 @@ +def get_create_tag_modal(): + return { + "type": "modal", + "callback_id": "create_tag", + "title": { + "type": "plain_text", + "text": ":rac_info: create a tag!", + "emoji": True, + }, + "blocks": [ + { + "type": "input", + "block_id": "tag_name", + "label": { + "type": "plain_text", + "text": "giv name?", + "emoji": True, + }, + "element": { + "type": "plain_text_input", + "action_id": "tag_name", + }, + }, + ], + "submit": { + "type": "plain_text", + "text": ":rac_question: add tag?", + "emoji": True, + }, + } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5ee89d2..74cc360 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,13 +16,17 @@ enum TicketStatus { } model User { - id String @id @unique - admin Boolean @default(false) + id String @id @unique + username String? @unique + admin Boolean @default(false) openedTickets Ticket[] @relation("OpenedTickets") closedTickets Ticket[] @relation("ClosedTickets") assignedTickets Ticket[] @relation("AssignedTickets") + tagSubscriptions UserTagSubscription[] + helper Boolean @default(false) + createdAt DateTime @default(now()) } @@ -38,12 +42,48 @@ model Ticket { openedBy User @relation("OpenedTickets", fields: [openedById], references: [id]) openedById String - closedBy User? @relation("ClosedTickets", fields: [closedById], references: [id]) + closedBy User? @relation("ClosedTickets", fields: [closedById], references: [id]) closedById String? - closedAt DateTime? + closedAt DateTime? assignedTo User? @relation("AssignedTickets", fields: [assignedToId], references: [id]) assignedToId String? + tagsOnTickets TagsOnTickets[] + createdAt DateTime @default(now()) } + +model Tag { + id String @id @unique @default(cuid()) + name String @unique + + ticketsOnTags TagsOnTickets[] + + userSubscriptions UserTagSubscription[] + createdAt DateTime @default(now()) +} + +model TagsOnTickets { + ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + ticketId String + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + tagId String + + assignedAt DateTime @default(now()) + + @@id([ticketId, tagId]) + @@map("tags_on_tickets") +} + +model UserTagSubscription { + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + tagId String + + subscribedAt DateTime @default(now()) + + @@id([userId, tagId]) + @@map("user_tag_subscriptions") +} diff --git a/pyproject.toml b/pyproject.toml index 8c2e5d2..0e611f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "python-dotenv>=1.1.0", "slack-bolt>=1.23.0", "starlette>=0.46.1", + "thefuzz>=0.22.1", "uvicorn>=0.34.0", "uvloop>=0.21.0", ] diff --git a/uv.lock b/uv.lock index 95ef7a0..7044afd 100644 --- a/uv.lock +++ b/uv.lock @@ -313,6 +313,7 @@ dependencies = [ { name = "python-dotenv" }, { name = "slack-bolt" }, { name = "starlette" }, + { name = "thefuzz" }, { name = "uvicorn" }, { name = "uvloop" }, ] @@ -330,6 +331,7 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=1.1.0" }, { name = "slack-bolt", specifier = ">=1.23.0" }, { name = "starlette", specifier = ">=0.46.1" }, + { name = "thefuzz", specifier = ">=0.22.1" }, { name = "uvicorn", specifier = ">=0.34.0" }, { name = "uvloop", specifier = ">=0.21.0" }, ] @@ -500,6 +502,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, ] +[[package]] +name = "rapidfuzz" +version = "3.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/f6/6895abc3a3d056b9698da3199b04c0e56226d530ae44a470edabf8b664f0/rapidfuzz-3.13.0.tar.gz", hash = "sha256:d2eaf3839e52cbcc0accbe9817a67b4b0fcf70aaeb229cfddc1c28061f9ce5d8", size = 57904226 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/76/606e71e4227790750f1646f3c5c873e18d6cfeb6f9a77b2b8c4dec8f0f66/rapidfuzz-3.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:09e908064d3684c541d312bd4c7b05acb99a2c764f6231bd507d4b4b65226c23", size = 1982282 }, + { url = "https://files.pythonhosted.org/packages/0a/f5/d0b48c6b902607a59fd5932a54e3518dae8223814db8349b0176e6e9444b/rapidfuzz-3.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:57c390336cb50d5d3bfb0cfe1467478a15733703af61f6dffb14b1cd312a6fae", size = 1439274 }, + { url = "https://files.pythonhosted.org/packages/59/cf/c3ac8c80d8ced6c1f99b5d9674d397ce5d0e9d0939d788d67c010e19c65f/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0da54aa8547b3c2c188db3d1c7eb4d1bb6dd80baa8cdaeaec3d1da3346ec9caa", size = 1399854 }, + { url = "https://files.pythonhosted.org/packages/09/5d/ca8698e452b349c8313faf07bfa84e7d1c2d2edf7ccc67bcfc49bee1259a/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df8e8c21e67afb9d7fbe18f42c6111fe155e801ab103c81109a61312927cc611", size = 5308962 }, + { url = "https://files.pythonhosted.org/packages/66/0a/bebada332854e78e68f3d6c05226b23faca79d71362509dbcf7b002e33b7/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:461fd13250a2adf8e90ca9a0e1e166515cbcaa5e9c3b1f37545cbbeff9e77f6b", size = 1625016 }, + { url = "https://files.pythonhosted.org/packages/de/0c/9e58d4887b86d7121d1c519f7050d1be5eb189d8a8075f5417df6492b4f5/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2b3dd5d206a12deca16870acc0d6e5036abeb70e3cad6549c294eff15591527", size = 1600414 }, + { url = "https://files.pythonhosted.org/packages/9b/df/6096bc669c1311568840bdcbb5a893edc972d1c8d2b4b4325c21d54da5b1/rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1343d745fbf4688e412d8f398c6e6d6f269db99a54456873f232ba2e7aeb4939", size = 3053179 }, + { url = "https://files.pythonhosted.org/packages/f9/46/5179c583b75fce3e65a5cd79a3561bd19abd54518cb7c483a89b284bf2b9/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b1b065f370d54551dcc785c6f9eeb5bd517ae14c983d2784c064b3aa525896df", size = 2456856 }, + { url = "https://files.pythonhosted.org/packages/6b/64/e9804212e3286d027ac35bbb66603c9456c2bce23f823b67d2f5cabc05c1/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:11b125d8edd67e767b2295eac6eb9afe0b1cdc82ea3d4b9257da4b8e06077798", size = 7567107 }, + { url = "https://files.pythonhosted.org/packages/8a/f2/7d69e7bf4daec62769b11757ffc31f69afb3ce248947aadbb109fefd9f65/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c33f9c841630b2bb7e69a3fb5c84a854075bb812c47620978bddc591f764da3d", size = 2854192 }, + { url = "https://files.pythonhosted.org/packages/05/21/ab4ad7d7d0f653e6fe2e4ccf11d0245092bef94cdff587a21e534e57bda8/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae4574cb66cf1e85d32bb7e9ec45af5409c5b3970b7ceb8dea90168024127566", size = 3398876 }, + { url = "https://files.pythonhosted.org/packages/0f/a8/45bba94c2489cb1ee0130dcb46e1df4fa2c2b25269e21ffd15240a80322b/rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e05752418b24bbd411841b256344c26f57da1148c5509e34ea39c7eb5099ab72", size = 4377077 }, + { url = "https://files.pythonhosted.org/packages/0c/f3/5e0c6ae452cbb74e5436d3445467447e8c32f3021f48f93f15934b8cffc2/rapidfuzz-3.13.0-cp313-cp313-win32.whl", hash = "sha256:0e1d08cb884805a543f2de1f6744069495ef527e279e05370dd7c83416af83f8", size = 1822066 }, + { url = "https://files.pythonhosted.org/packages/96/e3/a98c25c4f74051df4dcf2f393176b8663bfd93c7afc6692c84e96de147a2/rapidfuzz-3.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9a7c6232be5f809cd39da30ee5d24e6cadd919831e6020ec6c2391f4c3bc9264", size = 1615100 }, + { url = "https://files.pythonhosted.org/packages/60/b1/05cd5e697c00cd46d7791915f571b38c8531f714832eff2c5e34537c49ee/rapidfuzz-3.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:3f32f15bacd1838c929b35c84b43618481e1b3d7a61b5ed2db0291b70ae88b53", size = 858976 }, +] + [[package]] name = "slack-bolt" version = "1.23.0" @@ -542,6 +567,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995 }, ] +[[package]] +name = "thefuzz" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rapidfuzz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/4b/d3eb25831590d6d7d38c2f2e3561d3ba41d490dc89cd91d9e65e7c812508/thefuzz-0.22.1.tar.gz", hash = "sha256:7138039a7ecf540da323792d8592ef9902b1d79eb78c147d4f20664de79f3680", size = 19993 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/4f/1695e70ceb3604f19eda9908e289c687ea81c4fecef4d90a9d1d0f2f7ae9/thefuzz-0.22.1-py3-none-any.whl", hash = "sha256:59729b33556850b90e1093c4cf9e618af6f2e4c985df193fdf3c5b5cf02ca481", size = 8245 }, +] + [[package]] name = "tomlkit" version = "0.13.3"