diff --git a/.env.template b/.env.template index 853032e..1e4146b 100644 --- a/.env.template +++ b/.env.template @@ -7,6 +7,7 @@ CALENDAR_TIMEZONE= WATCHED_CHANNELS= SLACK_API_TOKEN= SLACK_JUMPSTART_MESSAGE= +SLACK_SIGNING_SECRET= WIKI_API= WIKIBOT_USER= diff --git a/README.md b/README.md index c8347b6..6de5ed5 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Documentation for the project can be found be appended /docs to the url All HTML requests that are sent in the project can be seen by appending /swag This project uses Python, [FastAPI](https://fastapi.tiangolo.com/), HTML/CSS, and Javascript. -See it live [here](http://jumpstart-cubed.cs.house/)! +See it live [here](https://jumpstart.csh.rit.edu)! ## Installing 1. Clone and cd into the repo: git clone https://github.com/WeatherGod3218/jumpstartV2 diff --git a/dev-requirements.txt b/dev-requirements.txt index 89303c8..3c38249 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,2 +1,3 @@ pre-commit==4.5.1 ruff==0.15.6 +pytest==9.0.2 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 6468237..a16693f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,8 @@ services: - WATCHED_CHANNELS=${WATCHED_CHANNELS} - SLACK_API_TOKEN=${SLACK_API_TOKEN} - SLACK_JUMPSTART_MESSAGE=${SLACK_JUMPSTART_MESSAGE} - + - SLACK_SIGNING_SECRET=${SLACK_SIGNING_SECRET} + - WIKI_API=${WIKI_API} - WIKIBOT_USER=${WIKIBOT_USER} - WIKIBOT_PASSWORD=${WIKIBOT_PASSWORD} diff --git a/docs/endpoints/csh_calendar.md b/docs/endpoints/csh_calendar.md index e9d8ff0..580e9ca 100644 --- a/docs/endpoints/csh_calendar.md +++ b/docs/endpoints/csh_calendar.md @@ -4,6 +4,7 @@ This API interacts with the CSH Google calendar to pull the number of events set in the .env file to display in the calendar widget on Jumpstart + --- ### Authentication diff --git a/mkdocs.yml b/mkdocs.yml index 1b7673e..a806442 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -38,7 +38,7 @@ use_directory_urls: false nav: - Home: index.md - Getting Started: getting-started/getting-started.md - - Backend: + - Backend: - Calendar: core/csh_calendar.md - Slack: core/slack.md - Wikithoughts: core/wikithoughts.md diff --git a/src/api/endpoints.py b/src/api/endpoints.py index b5985a6..704d1db 100644 --- a/src/api/endpoints.py +++ b/src/api/endpoints.py @@ -1,20 +1,15 @@ from logging import getLogger, Logger -import json -import httpx - from fastapi import APIRouter, Request, Form from fastapi.responses import JSONResponse from core import slack, wikithoughts, cshcalendar -from config import WATCHED_CHANNELS +import json +import urllib logger: Logger = getLogger(__name__) router: APIRouter = APIRouter() -ACCEPT_MESSAGE: str = "Posting right now :^)" -DENY_MESSAGE: str = "Okay :( maybe next time" - @router.get("/calendar") async def get_calendar() -> JSONResponse: @@ -63,44 +58,25 @@ async def slack_events(request: Request) -> JSONResponse: JSONResponse: A JSON response indicating the result of the event handling. """ - try: - logger.debug(f"Received Slack event: {await request.body()}") + raw_body: bytes = await request.body() - body: dict = await request.json() + if not (slack.is_valid_slack_request(request, raw_body)): + logger.warning(f"Received a Fake Slack Event!: {raw_body}") + return JSONResponse({"error": "Invalid signature"}, status_code=403) - if request.headers.get("content-type") == "application/json": - if body.get("type") == "url_verification": - logger.info("SLACK EVENT: Was a challenge!") - return JSONResponse({"challenge": body.get("challenge")}) + body: dict = json.loads(raw_body) - if not body: - logger.debug("SLACK EVENT: Was a challenge, with no body") + # Challenge from Bot Authentication + if request.headers.get("content-type") == "application/json": + if body.get("type") == "url_verification": + logger.info("SLACK EVENT: Was a challenge!") return JSONResponse({"challenge": body.get("challenge")}) - event: dict = body.get("event", {}) - cleaned_text: str = slack.clean_text(event.get("text", "")) - - if event.get("subtype", None) is not None: - logger.info("SLACK EVENT: Had no subtype, ignoring it") - return JSONResponse({"status": "ignored"}) - - if event.get("channel", None) not in WATCHED_CHANNELS: - logger.info( - "SLACK EVENT: Message was not in a Watched Channel, ignoring it" - ) - return JSONResponse({"status": "ignored"}) - - logger.info("SLACK EVENT: Requesting upload via dm!") - await slack.request_upload_via_dm(event.get("user", ""), cleaned_text) - except Exception as e: - logger.error(f"Error handling Slack event: {e}") - return JSONResponse({"status": "error", "message": str(e)}) - - return JSONResponse({"status": "success"}) + return JSONResponse(await slack.process_slack_events(body)) @router.post("/slack/message_actions") -async def message_actions(payload: str = Form(...)) -> JSONResponse: +async def message_actions(request: Request) -> JSONResponse: """ Handles slack message action. @@ -111,48 +87,20 @@ async def message_actions(payload: str = Form(...)) -> JSONResponse: JSONResponse: A JSON response indicating the result of the action. """ - try: - form_json: dict = json.loads(payload) - response_url = form_json.get("response_url") - - if form_json.get("type") != "block_actions": - return JSONResponse({}, status_code=200) - - if slack.convert_user_response_to_bool(form_json): - logger.info( - "User approved the announcement, Adding it to the announcement list!" - ) - - message_object: dict[str, dict] = json.loads( - form_json.get("actions", [{}])[0].get("value", '{text:""}') - ).get("text", None) - - user_id = form_json.get("user", {}).get("id") - - username: str = await slack.get_username(user_id=user_id) - username = username[:40] - - slack.add_announcement(message_object, username) - - if response_url: - async with httpx.AsyncClient() as client: - await client.post( - response_url, - json={"text": ACCEPT_MESSAGE, "replace_original": True}, - ) - else: - if response_url: - async with httpx.AsyncClient() as client: - await client.post( - response_url, - json={"text": DENY_MESSAGE, "replace_original": True}, - ) + raw_body: bytes = await request.body() - except Exception as e: - logger.error(f"Error in message_actions: {e}") - return JSONResponse({"status": "error", "message": str(e)}, status_code=500) + if not (slack.is_valid_slack_request(request, raw_body)): + logger.warning(f"Received a Fake Slack Message Action! {raw_body}") + return JSONResponse({"error": "Invalid signature"}, status_code=403) + + form_data = urllib.parse.parse_qs(raw_body.decode("utf-8")) + payload = form_data.get("payload", [None])[0] + + if payload is None: + return JSONResponse({"error": "Missing payload"}, status_code=400) - return JSONResponse({"status": "success"}, status_code=200) + response_dict, status_code = await slack.process_slack_message_actions(payload) + return JSONResponse(response_dict, status_code=status_code) @router.get("/wikithought") diff --git a/src/config.py b/src/config.py index 3e3eb81..4ff172c 100644 --- a/src/config.py +++ b/src/config.py @@ -39,6 +39,8 @@ def _get_env_variable(name: str, default: str | None = None) -> str | None: SLACK_API_TOKEN: str | None = _get_env_variable("SLACK_API_TOKEN", None) SLACK_JUMPSTART_MESSAGE: str = "Would you like to post this message to Jumpstart?" +SLACK_SIGNING_SECRET: str = _get_env_variable("SLACK_SIGNING_SECRET", None) + WATCHED_CHANNELS: tuple[str] = tuple( _get_env_variable("WATCHED_CHANNELS", "").split(",") ) diff --git a/src/core/cshcalendar.py b/src/core/cshcalendar.py index 01b7c34..2e16cae 100644 --- a/src/core/cshcalendar.py +++ b/src/core/cshcalendar.py @@ -7,6 +7,9 @@ import recurring_ical_events import arrow import re +import asyncio + +from modules import taskmanager from config import ( CALENDAR_CACHE_REFRESH, @@ -15,7 +18,6 @@ CALENDAR_TIMEZONE, CALENDAR_URL, ) -import asyncio calendar_cache: list[CalendarInfo] = [] # The current cache of the calendar cal_last_update: date | None = ( @@ -136,6 +138,7 @@ def repl(match: re.Match[str]) -> str: return TIME_PATTERN.sub(repl, unformatted_string) + def format_events(events: list[CalendarInfo]) -> list[dict[str, str]]: """ Formats a parsed list of CalendarInfos, and returns the HTML required for front end @@ -150,7 +153,7 @@ def format_events(events: list[CalendarInfo]) -> list[dict[str, str]]: current_date: date = datetime.now(ZoneInfo(CALENDAR_TIMEZONE)) if not events: - return {"data": [{"header": ":(", "content": "No Events on the Calendar"}]} + return [{"header": ":(", "content": "No Events on the Calendar"}] formatted_list: list[dict[str, str]] = [] @@ -291,9 +294,11 @@ async def get_future_events() -> list[CalendarInfo]: if cal_correct_length: logger.info("Calendar cache is full length, rebuilding async!") - async with asyncio.TaskGroup() as taskGroup: - taskGroup.create_task(rebuild_calendar()) - # Calendar is correct length, we can just run this in the background + + taskmanager.create_background_task( + rebuild_calendar() + ) # Calendar is correct length, we can just run this in the background + else: logger.info("Calendar cache is NOT full length, yielding rebuild!") await rebuild_calendar() diff --git a/src/core/slack.py b/src/core/slack.py index 1b410b3..e5ad40c 100644 --- a/src/core/slack.py +++ b/src/core/slack.py @@ -7,35 +7,82 @@ from slack_sdk.web.async_client import AsyncWebClient from slack_sdk.web.slack_response import SlackResponse from slack_sdk.errors import SlackApiError +from slack_sdk.signature import SignatureVerifier + +from modules import taskmanager from config import ( SLACK_API_TOKEN, SLACK_JUMPSTART_MESSAGE, SLACK_DM_TEMPLATE, CALENDAR_TIMEZONE, + WATCHED_CHANNELS, + SLACK_SIGNING_SECRET, ) + from datetime import datetime from zoneinfo import ZoneInfo +from fastapi import Request -logger: Logger = getLogger(__name__) - +import asyncio +import httpx +logger: Logger = getLogger(__name__) client: AsyncWebClient | None = None +event_id_cache: dict[str, str] = {} -try: - client = AsyncWebClient(token=SLACK_API_TOKEN) -except Exception as e: - logger.error(f"Failed to initialize Slack client: {e}") +EVENT_CACHE_DEBOUNCE = ( + 60 # Hold event in for one minute? I think its fine genuiflowkirkenuinelowskinly +) + +ACCEPT_MESSAGE: str = "Posting right now :^)" +DENY_MESSAGE: str = ( + "RAHHHHHHHHHHHHHHHHHHHHHHHH HOW DARE YOU :skeleton-shield-banging-here:" +) current_announcement: dict[str, str] = { "content": "Welcome to Jumpstart!", "user": "Jumpstart", "timestamp": datetime.now(ZoneInfo(CALENDAR_TIMEZONE)) .strftime("%I:%M %p") - .lstrip("0") + .lstrip("0"), } +_slack_signature_verifier: SignatureVerifier | None = ( + SignatureVerifier(SLACK_SIGNING_SECRET) if SLACK_SIGNING_SECRET else None +) + +try: + client = AsyncWebClient(token=SLACK_API_TOKEN) +except Exception as e: + logger.error(f"Failed to initialize Slack client: {e}") + + +def is_valid_slack_request(request: Request, raw_body: bytes) -> bool: + """ + Validates Slack's request signature using the signing secret. + + Args: + request (Request): The request to be checked + raw_body (bytes): The raw body + + Returns + bool: Whether or not it's verified + """ + + if _slack_signature_verifier is None: + logger.error("Slack signing secret is not configured") + return False + + try: + return _slack_signature_verifier.is_valid_request( + body=raw_body, + headers=dict(request.headers), + ) + except TypeError, ValueError: + return False + def clean_text(raw: str) -> str: """ @@ -53,6 +100,42 @@ def clean_text(raw: str) -> str: return text.replace("*", "").replace("_", "").replace("`", "").strip() +async def reset_event_from_cache(event_id: str) -> None: + """ + Removes an event from the cache + + Arguments: + event_id (str): The id of the slack event + """ + global event_id_cache + + await asyncio.sleep(EVENT_CACHE_DEBOUNCE) + event_id_cache[event_id] = None + return + + +def get_event_retry_amount(event_id: str) -> int: + """ + Returns the amount of times a event has been retried + + Arguments: + event_id (str): The id of the slack event + + Returns: + int: The amount of times the event has been retried + """ + + global event_id_cache + + if event_id in event_id_cache: + event_id_cache[event_id] += 1 + return event_id_cache[event_id] + + event_id_cache[event_id] = 0 + taskmanager.create_background_task(reset_event_from_cache(event_id)) + return event_id_cache[event_id] + + async def gather_emojis() -> dict: """ Gathers emojis from Slack and returns a mapping of emoji names to their URLs. @@ -135,6 +218,107 @@ async def request_upload_via_dm(user_id: str, announcement_text: str) -> None: logger.error(f"Error messaging user {user_id}: {e}") +async def process_slack_events(body: dict) -> dict[str, str]: + """ + Processes a slack event, logging and returning the result from the event + + Arguments: + request (Request): The slack event to be processed + + Returns: + dict[str, str]: The dictionary to be responded to. + """ + + try: + logger.info(f"Received Slack event: {body}") + + event_amounts: int = get_event_retry_amount(body.get("event_id", None)) + if event_amounts > 0: + logger.info( + f"SLACK EVENT: Retried event for {body.get('event_id', None)} {event_amounts} time(s)!" + ) + return ({"status": "success"}, 200) + + event: dict = body.get("event", {}) + + if event.get("subtype", None) is not None: + logger.info("SLACK EVENT: Had a subtype, ignoring it") + return {"status": "ignored"} + + if event.get("channel", None) not in WATCHED_CHANNELS: + logger.info( + "SLACK EVENT: Message was not in a Watched Channel, ignoring it" + ) + return {"status": "ignored"} + + logger.info("SLACK EVENT: Requesting upload via dm!") + cleaned_text: str = clean_text(event.get("text", "")) + + taskmanager.create_background_task( + request_upload_via_dm(event.get("user", ""), cleaned_text) + ) + except Exception as e: + logger.error(f"Error handling Slack event: {e}") + return {"status": "error", "message": str(e)} + + return {"status": "success"} + + +async def process_slack_message_actions(payload: str): + + try: + form_json: dict = json.loads(payload) + response_url = form_json.get("response_url") + + event_amounts: int = get_event_retry_amount(form_json.get("trigger_id", None)) + if event_amounts > 0: + logger.info( + f"SLACK MESSAGE ACTION: Retried event for {form_json.get('trigger_id', None)} {event_amounts} time(s)!" + ) + return {"status": "ignored"} + + if form_json.get("type") != "block_actions": + return ({}, 200) + + if convert_user_response_to_bool(form_json): + logger.info( + "User approved the announcement, Adding it to the announcement list!" + ) + + message_object: dict[str, dict] = json.loads( + form_json.get("actions", [{}])[0].get("value", '{text:""}') + ).get("text", None) + + user_id = form_json.get("user", {}).get("id") + + username: str = await get_username(user_id) + username = username[ + :40 + ] # Only get the first 40 characters so it fits on a single line + + add_announcement(message_object, username) + + if response_url: + async with httpx.AsyncClient() as client: + await client.post( + response_url, + json={"text": ACCEPT_MESSAGE, "replace_original": True}, + ) + else: + if response_url: + async with httpx.AsyncClient() as client: + await client.post( + response_url, + json={"text": DENY_MESSAGE, "replace_original": True}, + ) + + except Exception as e: + logger.error(f"Error in message_actions: {e}") + return ({"status": "error", "message": str(e)}, 500) + + return ({"status": "success"}, 200) + + def convert_user_response_to_bool(message_data: dict) -> bool: """ Converts a Slack message action response to a boolean indicating whether the user approved the announcement. diff --git a/src/main.py b/src/main.py index 0fd0da7..8b94c94 100644 --- a/src/main.py +++ b/src/main.py @@ -27,8 +27,7 @@ @asynccontextmanager async def lifespan(app: FastAPI): logger.info("Starting up the Jumpstart application!") - async with asyncio.TaskGroup() as tg: - tg.create_task(cshcalendar.rebuild_calendar()) + asyncio.create_task(cshcalendar.rebuild_calendar()) await wikithoughts.auth_bot() yield diff --git a/src/modules/taskmanager.py b/src/modules/taskmanager.py new file mode 100644 index 0000000..2dd58b0 --- /dev/null +++ b/src/modules/taskmanager.py @@ -0,0 +1,43 @@ +import asyncio + +from logging import getLogger, Logger +from collections.abc import Coroutine + +logger: Logger = getLogger(__name__) + +running_background_tasks: set[asyncio.Task] = set() + + +def handle_task_exception(task: asyncio.Task) -> None: + """ + Views ended tasks result, throwing the end error if applicable + + Arguments: + Task (asyncio.Task): The task to be + """ + try: + task.result() + except asyncio.CancelledError: + logger.info("Background task was cancelled") + except Exception as e: + logger.error(f"Background task failed: {e}") + + return + + +def create_background_task(coroutine: Coroutine) -> asyncio.Task: + """ + Creates and executes a background task, holding a strong reference to avoid GC + + Arguments: + coroutine (Coroutine): The coroutine object that was created + + Returns: + asyncio.Task: The task object that was created and executing + """ + + task: asyncio.Task = asyncio.create_task(coroutine) + running_background_tasks.add(task) + task.add_done_callback(running_background_tasks.discard) + task.add_done_callback(handle_task_exception) + return task diff --git a/src/static/js/main.js b/src/static/js/main.js index 457cb72..9fca239 100644 --- a/src/static/js/main.js +++ b/src/static/js/main.js @@ -48,12 +48,13 @@ function setWeatherTheme(newTheme) { newWidget.id = "weather-image"; newWidget.href = "https://forecast7.com/en/43d16n77d61/rochester/?unit=us"; - newWidget.setAttribute("data-label_1", "ROCHESTER"); - newWidget.setAttribute("data-label_2", "WEATHER"); - newWidget.setAttribute("data-font", "Fira Sans"); - newWidget.setAttribute("data-icons", "Climacons Animated"); - newWidget.setAttribute("data-days", "100"); - newWidget.setAttribute("data-theme", newTheme); + newWidget.dataset.label_1 = "ROCHESTER"; + + newWidget.dataset.label_2 = "WEATHER"; + newWidget.dataset.font = "Fira Sans"; + newWidget.dataset.icons = "Climacons Animated"; + newWidget.dataset.days = "100"; + newWidget.dataset.theme = newTheme; newWidget.textContent = "ROCHESTER WEATHER"; oldWidget.replaceWith(newWidget); @@ -63,10 +64,11 @@ function setNewPageTheme(newTheme) { if (newTheme === currentPageTheme) return; currentPageTheme = newTheme; - document.body.classList.forEach(cls => { - if (cls.startsWith('theme-')) { - document.body.classList.remove(cls); - }}); + for (const classList of document.body.classList) { + if (classList.startsWith("theme-")) { + document.body.classList.remove(classList) + } + } document.body.classList.toggle(newTheme); } @@ -96,6 +98,7 @@ async function generateCalendar(calData) { calendar.replaceChildren(); calendar.appendChild(document.createElement("br")); + for (const event of calData) { const newEvent = createCalendarEvent(event.header, event.content); calendar.appendChild(newEvent); diff --git a/tests/src/core/test_slack.py b/tests/src/core/test_slack.py index ea952a4..ca138b9 100644 --- a/tests/src/core/test_slack.py +++ b/tests/src/core/test_slack.py @@ -11,7 +11,7 @@ def import_slack_module(monkeypatch) -> object: monkeypatch: The pytest monkeypatch fixture. Returns: - object: The imported config module. + object: The imported config module. """ monkeypatch.setenv("SLACK_API_TOKEN", "test-token") @@ -46,7 +46,53 @@ def test_clean_text_and_convert_response(monkeypatch): assert slack.convert_user_response_to_bool(yes_payload) is True assert slack.convert_user_response_to_bool(no_payload) is False # malformed payload - assert slack.convert_user_response_to_bool({}) is False + assert slack.convert_user_response_to_bool(True) is False + + +def test_get_username(monkeypatch): + """ + Test the get_username function in the slack module. + + Args: + monkeypatch: The pytest monkeypatch fixture. + """ + slack = import_slack_module(monkeypatch) + + class FakeClient: + def __init__(self, display_name=None, real_name=None, name=None): + self.display_name: str | None = display_name + self.real_name: str | None = real_name + self.name: str | None = name + + async def users_info(self, user): + return { + "ok": True, + "user": { + "profile": {"display_name": self.display_name}, + "real_name": self.real_name, + "name": self.name, + }, + } + + # Test Display Name + monkeypatch.setattr(slack, "client", FakeClient(display_name="DisplayName")) + username = asyncio.run(slack.get_username(user_id="")) + assert username == "DisplayName" + + # Test Real Name + monkeypatch.setattr(slack, "client", FakeClient(real_name="RealName")) + username = asyncio.run(slack.get_username(user_id="")) + assert username == "RealName" + + # Test Account Name + monkeypatch.setattr(slack, "client", FakeClient(name="AccountName")) + username = asyncio.run(slack.get_username(user_id="")) + assert username == "AccountName" + + # Test Unknown + monkeypatch.setattr(slack, "client", FakeClient()) + username = asyncio.run(slack.get_username(user_id="")) + assert username == "Unknown" def test_gather_emojis_success_and_failure(monkeypatch): @@ -107,6 +153,8 @@ async def chat_postMessage(self, *, channel, text, blocks): recorded["text"] = text recorded["blocks"] = blocks + asyncio.run(slack.request_upload_via_dm("U123", "Announcement!")) + monkeypatch.setattr(slack, "client", FakeClient()) asyncio.run(slack.request_upload_via_dm("U123", "Announcement!"))