From a0c5be5c55b483f7d960b9a772e4c9308ab4d06d Mon Sep 17 00:00:00 2001 From: Weather Date: Sun, 12 Apr 2026 16:41:54 -0400 Subject: [PATCH 01/17] tests: Slack Coverage --- mkdocs.yml | 2 +- src/core/cshcalendar.py | 9 ++++--- src/core/slack.py | 3 +-- src/main.py | 3 +-- src/static/css/style.css | 6 ----- src/static/js/main.js | 23 +++++++++------- tests/src/core/test_slack.py | 52 +++++++++++++++++++++++++++++++++++- 7 files changed, 72 insertions(+), 26 deletions(-) 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/core/cshcalendar.py b/src/core/cshcalendar.py index 01b7c34..a5788cc 100644 --- a/src/core/cshcalendar.py +++ b/src/core/cshcalendar.py @@ -136,6 +136,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 +151,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 +292,9 @@ 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 + asyncio.create_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..699d6ca 100644 --- a/src/core/slack.py +++ b/src/core/slack.py @@ -32,11 +32,10 @@ "user": "Jumpstart", "timestamp": datetime.now(ZoneInfo(CALENDAR_TIMEZONE)) .strftime("%I:%M %p") - .lstrip("0") + .lstrip("0"), } - def clean_text(raw: str) -> str: """ Strip Slack mrkdwn, HTML entities, and formatting characters. 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/static/css/style.css b/src/static/css/style.css index f3f7066..4df74f6 100644 --- a/src/static/css/style.css +++ b/src/static/css/style.css @@ -8,12 +8,6 @@ --shadow-color: rgb(176,25,126, 0.5); /* Used for the shadow in the calendar */ } -/* b { - display: flex; - align-items: center; - justify-content: center; -} */ - .theme-dark { --panel-header-color: #B0197E; --panel-header-text-color: #FFFFFF; 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..2360710 100644 --- a/tests/src/core/test_slack.py +++ b/tests/src/core/test_slack.py @@ -46,9 +46,57 @@ 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" + + # Test Failure def test_gather_emojis_success_and_failure(monkeypatch): """ Test the gather_emojis function in the slack module. @@ -107,6 +155,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!")) From 1e7b791db51fab2029927c622e4a39830b0f5d40 Mon Sep 17 00:00:00 2001 From: Weather Date: Fri, 17 Apr 2026 22:38:44 -0400 Subject: [PATCH 02/17] fix: Multiple slac messages --- docs/endpoints/csh_calendar.md | 1 + src/api/endpoints.py | 7 ++++--- src/static/css/style.css | 6 ++++++ tests/src/core/test_slack.py | 3 +-- 4 files changed, 12 insertions(+), 5 deletions(-) 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/src/api/endpoints.py b/src/api/endpoints.py index b5985a6..a36abe2 100644 --- a/src/api/endpoints.py +++ b/src/api/endpoints.py @@ -2,6 +2,7 @@ import json import httpx +import asyncio from fastapi import APIRouter, Request, Form from fastapi.responses import JSONResponse @@ -64,9 +65,8 @@ async def slack_events(request: Request) -> JSONResponse: """ try: - logger.debug(f"Received Slack event: {await request.body()}") - body: dict = await request.json() + logger.debug(f"Received Slack event: {body}") if request.headers.get("content-type") == "application/json": if body.get("type") == "url_verification": @@ -91,7 +91,8 @@ async def slack_events(request: Request) -> JSONResponse: return JSONResponse({"status": "ignored"}) logger.info("SLACK EVENT: Requesting upload via dm!") - await slack.request_upload_via_dm(event.get("user", ""), cleaned_text) + + await asyncio.create_task(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)}) diff --git a/src/static/css/style.css b/src/static/css/style.css index 4df74f6..f3f7066 100644 --- a/src/static/css/style.css +++ b/src/static/css/style.css @@ -8,6 +8,12 @@ --shadow-color: rgb(176,25,126, 0.5); /* Used for the shadow in the calendar */ } +/* b { + display: flex; + align-items: center; + justify-content: center; +} */ + .theme-dark { --panel-header-color: #B0197E; --panel-header-text-color: #FFFFFF; diff --git a/tests/src/core/test_slack.py b/tests/src/core/test_slack.py index 2360710..245049a 100644 --- a/tests/src/core/test_slack.py +++ b/tests/src/core/test_slack.py @@ -6,7 +6,7 @@ def import_slack_module(monkeypatch) -> object: """ Helper function to import the slack module after setting the SLACK_API_TOKEN environment variable. - +p Args: monkeypatch: The pytest monkeypatch fixture. @@ -96,7 +96,6 @@ async def users_info(self, user): username = asyncio.run(slack.get_username(user_id="")) assert username == "Unknown" - # Test Failure def test_gather_emojis_success_and_failure(monkeypatch): """ Test the gather_emojis function in the slack module. From 4cf0a50c8455bd33a2fad251087e191c0029dc12 Mon Sep 17 00:00:00 2001 From: Weather Date: Fri, 17 Apr 2026 22:40:36 -0400 Subject: [PATCH 03/17] docs: formatting --- src/api/endpoints.py | 4 +++- tests/src/core/test_slack.py | 33 ++++++++++++++++----------------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/api/endpoints.py b/src/api/endpoints.py index a36abe2..fb92401 100644 --- a/src/api/endpoints.py +++ b/src/api/endpoints.py @@ -92,7 +92,9 @@ async def slack_events(request: Request) -> JSONResponse: logger.info("SLACK EVENT: Requesting upload via dm!") - await asyncio.create_task(slack.request_upload_via_dm(event.get("user", ""), cleaned_text)) + await asyncio.create_task( + 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)}) diff --git a/tests/src/core/test_slack.py b/tests/src/core/test_slack.py index 245049a..c31bb34 100644 --- a/tests/src/core/test_slack.py +++ b/tests/src/core/test_slack.py @@ -5,13 +5,13 @@ def import_slack_module(monkeypatch) -> object: """ - Helper function to import the slack module after setting the SLACK_API_TOKEN environment variable. -p - Args: - monkeypatch: The pytest monkeypatch fixture. + Helper function to import the slack module after setting the SLACK_API_TOKEN environment variable. + + Args: + monkeypatch: The pytest monkeypatch fixture. - Returns: - object: The imported config module. + Returns: + object: The imported config module. """ monkeypatch.setenv("SLACK_API_TOKEN", "test-token") @@ -58,35 +58,33 @@ def test_get_username(monkeypatch): """ 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 + 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 - }, + "profile": {"display_name": self.display_name}, "real_name": self.real_name, "name": self.name, }, } - #Test Display 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 + # 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 + # Test Account Name monkeypatch.setattr(slack, "client", FakeClient(name="AccountName")) username = asyncio.run(slack.get_username(user_id="")) assert username == "AccountName" @@ -96,6 +94,7 @@ async def users_info(self, user): username = asyncio.run(slack.get_username(user_id="")) assert username == "Unknown" + def test_gather_emojis_success_and_failure(monkeypatch): """ Test the gather_emojis function in the slack module. From fd41cafaac192065aa574cb81f76cec4790a447b Mon Sep 17 00:00:00 2001 From: Weather Date: Mon, 20 Apr 2026 15:26:03 -0400 Subject: [PATCH 04/17] docs: Updated README.md --- README.md | 2 +- tests/src/core/test_slack.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/tests/src/core/test_slack.py b/tests/src/core/test_slack.py index c31bb34..5f9e097 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") From c6d1e3189426653befce4ede0377552e475a3daf Mon Sep 17 00:00:00 2001 From: Weather Date: Tue, 21 Apr 2026 21:05:58 -0400 Subject: [PATCH 05/17] fix: Slack immediately responds --- src/api/endpoints.py | 8 ++++++-- tests/src/core/test_slack.py | 12 ++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/api/endpoints.py b/src/api/endpoints.py index fb92401..f60e70f 100644 --- a/src/api/endpoints.py +++ b/src/api/endpoints.py @@ -66,7 +66,11 @@ async def slack_events(request: Request) -> JSONResponse: try: body: dict = await request.json() - logger.debug(f"Received Slack event: {body}") + logger.info(f"Received Slack event: {body}") + + if request.headers.get("x-slack-retry-num"): + logger.info("SLACK EVENT: Ignoring Slack retry") + return JSONResponse({"status": "ignored"}) if request.headers.get("content-type") == "application/json": if body.get("type") == "url_verification": @@ -92,7 +96,7 @@ async def slack_events(request: Request) -> JSONResponse: logger.info("SLACK EVENT: Requesting upload via dm!") - await asyncio.create_task( + asyncio.create_task( slack.request_upload_via_dm(event.get("user", ""), cleaned_text) ) except Exception as e: diff --git a/tests/src/core/test_slack.py b/tests/src/core/test_slack.py index 5f9e097..ca138b9 100644 --- a/tests/src/core/test_slack.py +++ b/tests/src/core/test_slack.py @@ -5,13 +5,13 @@ def import_slack_module(monkeypatch) -> object: """ - Helper function to import the slack module after setting the SLACK_API_TOKEN environment variable. - - Args: - monkeypatch: The pytest monkeypatch fixture. + Helper function to import the slack module after setting the SLACK_API_TOKEN environment variable. - Returns: - object: The imported config module. + Args: + monkeypatch: The pytest monkeypatch fixture. + + Returns: + object: The imported config module. """ monkeypatch.setenv("SLACK_API_TOKEN", "test-token") From 1b4342c94ea829653fa68e548a87fc0fbafa8e49 Mon Sep 17 00:00:00 2001 From: Weather Date: Fri, 1 May 2026 23:10:42 -0400 Subject: [PATCH 06/17] feat: Caches Slack Events --- src/api/endpoints.py | 93 +-------------------- src/core/cshcalendar.py | 8 +- src/core/slack.py | 161 ++++++++++++++++++++++++++++++++++++- src/modules/taskmanager.py | 43 ++++++++++ 4 files changed, 212 insertions(+), 93 deletions(-) create mode 100644 src/modules/taskmanager.py diff --git a/src/api/endpoints.py b/src/api/endpoints.py index f60e70f..96b2975 100644 --- a/src/api/endpoints.py +++ b/src/api/endpoints.py @@ -1,21 +1,13 @@ from logging import getLogger, Logger -import json -import httpx -import asyncio - from fastapi import APIRouter, Request, Form from fastapi.responses import JSONResponse from core import slack, wikithoughts, cshcalendar -from config import WATCHED_CHANNELS 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: @@ -64,46 +56,7 @@ async def slack_events(request: Request) -> JSONResponse: JSONResponse: A JSON response indicating the result of the event handling. """ - try: - body: dict = await request.json() - logger.info(f"Received Slack event: {body}") - - if request.headers.get("x-slack-retry-num"): - logger.info("SLACK EVENT: Ignoring Slack retry") - return JSONResponse({"status": "ignored"}) - - 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")}) - - if not body: - logger.debug("SLACK EVENT: Was a challenge, with no body") - 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!") - - asyncio.create_task( - 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(slack.process_slack_events(request)) @router.post("/slack/message_actions") @@ -118,48 +71,8 @@ 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}, - ) - - except Exception as e: - logger.error(f"Error in message_actions: {e}") - return JSONResponse({"status": "error", "message": str(e)}, status_code=500) - - return JSONResponse({"status": "success"}, status_code=200) + response_dict, status_code = slack.process_slack_message_actions(payload) + return JSONResponse(response_dict, status_code=status_code) @router.get("/wikithought") diff --git a/src/core/cshcalendar.py b/src/core/cshcalendar.py index a5788cc..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 = ( @@ -292,9 +294,11 @@ async def get_future_events() -> list[CalendarInfo]: if cal_correct_length: logger.info("Calendar cache is full length, rebuilding async!") - asyncio.create_task( + + 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 699d6ca..544d21a 100644 --- a/src/core/slack.py +++ b/src/core/slack.py @@ -8,19 +8,34 @@ from slack_sdk.web.slack_response import SlackResponse from slack_sdk.errors import SlackApiError +from modules import taskmanager + from config import ( SLACK_API_TOKEN, SLACK_JUMPSTART_MESSAGE, SLACK_DM_TEMPLATE, CALENDAR_TIMEZONE, + WATCHED_CHANNELS, ) + from datetime import datetime from zoneinfo import ZoneInfo +from fastapi import Request + +import httpx logger: Logger = getLogger(__name__) +client: AsyncWebClient | None = None +event_id_cache: dict[str, str] = {} +EVENT_CACHE_DEBOUNCE = ( + 60 # Hold event in for one minute? I think its fine genuiflowkirkenuinelowskinly +) -client: AsyncWebClient | None = None +ACCEPT_MESSAGE: str = "Posting right now :^)" +DENY_MESSAGE: str = ( + "RAHHHHHHHHHHHHHHHHHHHHHHHH HOW DARE YOU :skeleton-shield-banging-here:" +) try: client = AsyncWebClient(token=SLACK_API_TOKEN) @@ -52,6 +67,40 @@ 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 + + event_id_cache[event_id] = None + return + + +async 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 += 1 + return event_id_cache + + event_id_cache[event_id] = 0 + taskmanager.create_background_task(reset_event_from_cache(event_id)) + + async def gather_emojis() -> dict: """ Gathers emojis from Slack and returns a mapping of emoji names to their URLs. @@ -134,6 +183,116 @@ 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(request: Request) -> 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: + body: dict = await request.json() + 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 + + # 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 {"challenge": body.get("challenge")} + + if not body: + logger.debug("SLACK EVENT: Was a challenge, with no body") + return {"challenge": body.get("challenge")} + + 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("event_id"), None) + if event_amounts > 0: + logger.info( + f"SLACK MESSAGE ACTION: Retried event for {form_json.get('event_id', None)} {event_amounts} time(s)!" + ) + return + + 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] + + 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/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 From ddde2c03564ad35f33d0573c87de41d1af2bf116 Mon Sep 17 00:00:00 2001 From: Weather Date: Fri, 1 May 2026 23:17:17 -0400 Subject: [PATCH 07/17] fix: await await await await await await await await await --- src/api/endpoints.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/endpoints.py b/src/api/endpoints.py index 96b2975..9037866 100644 --- a/src/api/endpoints.py +++ b/src/api/endpoints.py @@ -56,7 +56,7 @@ async def slack_events(request: Request) -> JSONResponse: JSONResponse: A JSON response indicating the result of the event handling. """ - return JSONResponse(slack.process_slack_events(request)) + return JSONResponse(await slack.process_slack_events(request)) @router.post("/slack/message_actions") @@ -71,7 +71,7 @@ async def message_actions(payload: str = Form(...)) -> JSONResponse: JSONResponse: A JSON response indicating the result of the action. """ - response_dict, status_code = slack.process_slack_message_actions(payload) + response_dict, status_code = await slack.process_slack_message_actions(payload) return JSONResponse(response_dict, status_code=status_code) From fe4b3618f88b632074e75565128eeeb864a48e33 Mon Sep 17 00:00:00 2001 From: Weather Date: Fri, 1 May 2026 23:20:21 -0400 Subject: [PATCH 08/17] fix: Body argument in the wrong position --- src/core/slack.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/slack.py b/src/core/slack.py index 544d21a..5dbcb23 100644 --- a/src/core/slack.py +++ b/src/core/slack.py @@ -198,7 +198,7 @@ async def process_slack_events(request: Request) -> dict[str, str]: body: dict = await request.json() logger.info(f"Received Slack event: {body}") - event_amounts: int = get_event_retry_amount(body.get("event_id"), None) + 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)!" @@ -246,7 +246,7 @@ async def process_slack_message_actions(payload: str): form_json: dict = json.loads(payload) response_url = form_json.get("response_url") - event_amounts: int = get_event_retry_amount(form_json.get("event_id"), None) + event_amounts: int = get_event_retry_amount(form_json.get("event_id", None)) if event_amounts > 0: logger.info( f"SLACK MESSAGE ACTION: Retried event for {form_json.get('event_id', None)} {event_amounts} time(s)!" From 59bb73a85f8cc3a7bee0414b93284da29cc1c640 Mon Sep 17 00:00:00 2001 From: Weather Date: Fri, 1 May 2026 23:23:56 -0400 Subject: [PATCH 09/17] fix: await squared --- src/core/slack.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/slack.py b/src/core/slack.py index 5dbcb23..707fcd5 100644 --- a/src/core/slack.py +++ b/src/core/slack.py @@ -22,6 +22,7 @@ from zoneinfo import ZoneInfo from fastapi import Request +import asyncio import httpx logger: Logger = getLogger(__name__) @@ -76,11 +77,12 @@ async def reset_event_from_cache(event_id: str) -> None: """ global event_id_cache + await asyncio.sleep(EVENT_CACHE_DEBOUNCE) event_id_cache[event_id] = None return -async def get_event_retry_amount(event_id: str) -> int: +def get_event_retry_amount(event_id: str) -> int: """ Returns the amount of times a event has been retried From 59f702a980b951a2eb88da42c498ef6f39ab51db Mon Sep 17 00:00:00 2001 From: Weather Date: Fri, 1 May 2026 23:34:53 -0400 Subject: [PATCH 10/17] fix: actually return the correct thing --- src/core/slack.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/slack.py b/src/core/slack.py index 707fcd5..be1a5f5 100644 --- a/src/core/slack.py +++ b/src/core/slack.py @@ -101,6 +101,7 @@ def get_event_retry_amount(event_id: str) -> int: 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: From 8cc7fe9637aff55d3e11749e364a905697e7e776 Mon Sep 17 00:00:00 2001 From: Weather Date: Fri, 1 May 2026 23:37:32 -0400 Subject: [PATCH 11/17] fix: woops --- dev-requirements.txt | 1 + 1 file changed, 1 insertion(+) 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 From 2227abbc42afc093d9cf2100f41ff06cd1d2a8d1 Mon Sep 17 00:00:00 2001 From: Weather Date: Fri, 1 May 2026 23:42:16 -0400 Subject: [PATCH 12/17] fix: Adds correct event has (im too tired) --- src/core/slack.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/slack.py b/src/core/slack.py index be1a5f5..ee19b75 100644 --- a/src/core/slack.py +++ b/src/core/slack.py @@ -96,7 +96,7 @@ def get_event_retry_amount(event_id: str) -> int: global event_id_cache if event_id in event_id_cache: - event_id_cache += 1 + event_id_cache[event_id] += 1 return event_id_cache event_id_cache[event_id] = 0 From 296c7945ed5d45c9bb13c5367d0a490d780f5cd7 Mon Sep 17 00:00:00 2001 From: Weather Date: Fri, 1 May 2026 23:55:47 -0400 Subject: [PATCH 13/17] fix: Slack lies to you, and it lied to me too --- src/core/slack.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/core/slack.py b/src/core/slack.py index ee19b75..37196d7 100644 --- a/src/core/slack.py +++ b/src/core/slack.py @@ -97,7 +97,7 @@ def get_event_retry_amount(event_id: str) -> int: if event_id in event_id_cache: event_id_cache[event_id] += 1 - return event_id_cache + return event_id_cache[event_id] event_id_cache[event_id] = 0 taskmanager.create_background_task(reset_event_from_cache(event_id)) @@ -206,7 +206,8 @@ async def process_slack_events(request: Request) -> dict[str, str]: logger.info( f"SLACK EVENT: Retried event for {body.get('event_id', None)} {event_amounts} time(s)!" ) - return + return ({"status": "success"}, 200) + # Challenge from Bot Authentication if request.headers.get("content-type") == "application/json": @@ -249,12 +250,12 @@ async def process_slack_message_actions(payload: str): form_json: dict = json.loads(payload) response_url = form_json.get("response_url") - event_amounts: int = get_event_retry_amount(form_json.get("event_id", None)) + 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('event_id', None)} {event_amounts} time(s)!" + f"SLACK MESSAGE ACTION: Retried event for {form_json.get('trigger_id', None)} {event_amounts} time(s)!" ) - return + return {"status": "ignored"} if form_json.get("type") != "block_actions": return ({}, 200) From 91499bef38c92ce236da0fd24e8103248a20c63f Mon Sep 17 00:00:00 2001 From: Weather Date: Sat, 2 May 2026 18:28:34 -0400 Subject: [PATCH 14/17] fix: Removes conflicting check --- src/core/slack.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/core/slack.py b/src/core/slack.py index 37196d7..e95243a 100644 --- a/src/core/slack.py +++ b/src/core/slack.py @@ -208,17 +208,12 @@ async def process_slack_events(request: Request) -> dict[str, str]: ) return ({"status": "success"}, 200) - # 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 {"challenge": body.get("challenge")} - if not body: - logger.debug("SLACK EVENT: Was a challenge, with no body") - return {"challenge": body.get("challenge")} - event: dict = body.get("event", {}) if event.get("subtype", None) is not None: @@ -272,7 +267,7 @@ async def process_slack_message_actions(payload: str): user_id = form_json.get("user", {}).get("id") username: str = await get_username(user_id) - username = username[:40] + username = username[:40] # Only get the first 40 characters so it fits on a single line add_announcement(message_object, username) From 9dada2a0d92f65ff6a26bcaa7f4ccb62937753ea Mon Sep 17 00:00:00 2001 From: Weather Date: Sat, 2 May 2026 18:59:46 -0400 Subject: [PATCH 15/17] feat: Implements Slack Signing --- .env.template | 1 + docker-compose.yml | 3 ++- src/api/endpoints.py | 25 ++++++++++++++++++-- src/config.py | 2 ++ src/core/slack.py | 55 +++++++++++++++++++++++++++++++++----------- 5 files changed, 69 insertions(+), 17 deletions(-) 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/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/src/api/endpoints.py b/src/api/endpoints.py index 9037866..992ff5a 100644 --- a/src/api/endpoints.py +++ b/src/api/endpoints.py @@ -4,6 +4,7 @@ from fastapi.responses import JSONResponse from core import slack, wikithoughts, cshcalendar +import json logger: Logger = getLogger(__name__) router: APIRouter = APIRouter() @@ -56,11 +57,25 @@ async def slack_events(request: Request) -> JSONResponse: JSONResponse: A JSON response indicating the result of the event handling. """ - return JSONResponse(await slack.process_slack_events(request)) + raw_body: bytes = await request.body() + + if not (slack.is_valid_slack_request(request, raw_body)): + logger.warning(f"Received a Fake Slack Event!: {body}") + return JSONResponse({"error": "Invalid signature"}, status_code=403) + + body: dict = json.load(raw_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 {"challenge": body.get("challenge")} + + 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, payload: str = Form(...)) -> JSONResponse: """ Handles slack message action. @@ -71,6 +86,12 @@ async def message_actions(payload: str = Form(...)) -> JSONResponse: JSONResponse: A JSON response indicating the result of the action. """ + raw_body: bytes = await request.body() + + if not (slack.is_valid_slack_request(request, raw_body)): + logger.warning(f"Received a Fake Slack Message Action!") + return JSONResponse({"error": "Invalid signature"}, status_code=403) + response_dict, status_code = await slack.process_slack_message_actions(payload) return JSONResponse(response_dict, status_code=status_code) 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/slack.py b/src/core/slack.py index e95243a..e5ad40c 100644 --- a/src/core/slack.py +++ b/src/core/slack.py @@ -7,6 +7,7 @@ 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 @@ -16,6 +17,7 @@ SLACK_DM_TEMPLATE, CALENDAR_TIMEZONE, WATCHED_CHANNELS, + SLACK_SIGNING_SECRET, ) from datetime import datetime @@ -38,11 +40,6 @@ "RAHHHHHHHHHHHHHHHHHHHHHHHH HOW DARE YOU :skeleton-shield-banging-here:" ) -try: - client = AsyncWebClient(token=SLACK_API_TOKEN) -except Exception as e: - logger.error(f"Failed to initialize Slack client: {e}") - current_announcement: dict[str, str] = { "content": "Welcome to Jumpstart!", "user": "Jumpstart", @@ -52,6 +49,41 @@ } +_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: """ Strip Slack mrkdwn, HTML entities, and formatting characters. @@ -186,7 +218,7 @@ 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(request: Request) -> dict[str, str]: +async def process_slack_events(body: dict) -> dict[str, str]: """ Processes a slack event, logging and returning the result from the event @@ -198,7 +230,6 @@ async def process_slack_events(request: Request) -> dict[str, str]: """ try: - body: dict = await request.json() logger.info(f"Received Slack event: {body}") event_amounts: int = get_event_retry_amount(body.get("event_id", None)) @@ -208,12 +239,6 @@ async def process_slack_events(request: Request) -> dict[str, str]: ) return ({"status": "success"}, 200) - # 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 {"challenge": body.get("challenge")} - event: dict = body.get("event", {}) if event.get("subtype", None) is not None: @@ -267,7 +292,9 @@ async def process_slack_message_actions(payload: str): 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 + username = username[ + :40 + ] # Only get the first 40 characters so it fits on a single line add_announcement(message_object, username) From 718756f43dba74eff4a29f8e7c83ac674ea1e277 Mon Sep 17 00:00:00 2001 From: Weather Date: Sat, 2 May 2026 19:11:21 -0400 Subject: [PATCH 16/17] fix: Fixes load to loads --- src/api/endpoints.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/api/endpoints.py b/src/api/endpoints.py index 992ff5a..479819b 100644 --- a/src/api/endpoints.py +++ b/src/api/endpoints.py @@ -60,16 +60,16 @@ async def slack_events(request: Request) -> JSONResponse: raw_body: bytes = await request.body() if not (slack.is_valid_slack_request(request, raw_body)): - logger.warning(f"Received a Fake Slack Event!: {body}") + logger.warning(f"Received a Fake Slack Event!: {raw_body}") return JSONResponse({"error": "Invalid signature"}, status_code=403) - body: dict = json.load(raw_body) + body: dict = json.loads(raw_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 {"challenge": body.get("challenge")} + return JSONResponse({"challenge": body.get("challenge")}) return JSONResponse(await slack.process_slack_events(body)) @@ -89,7 +89,7 @@ async def message_actions(request: Request, payload: str = Form(...)) -> JSONRes raw_body: bytes = await request.body() if not (slack.is_valid_slack_request(request, raw_body)): - logger.warning(f"Received a Fake Slack Message Action!") + logger.warning(f"Received a Fake Slack Message Action! {raw_body}") return JSONResponse({"error": "Invalid signature"}, status_code=403) response_dict, status_code = await slack.process_slack_message_actions(payload) From 55f45324dd2edbe48a3b2795e290d65d4686b0c5 Mon Sep 17 00:00:00 2001 From: Weather Date: Sat, 2 May 2026 19:18:10 -0400 Subject: [PATCH 17/17] fix: Stops Form swallowing Stream --- src/api/endpoints.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/api/endpoints.py b/src/api/endpoints.py index 479819b..704d1db 100644 --- a/src/api/endpoints.py +++ b/src/api/endpoints.py @@ -5,6 +5,7 @@ from core import slack, wikithoughts, cshcalendar import json +import urllib logger: Logger = getLogger(__name__) router: APIRouter = APIRouter() @@ -75,7 +76,7 @@ async def slack_events(request: Request) -> JSONResponse: @router.post("/slack/message_actions") -async def message_actions(request: Request, payload: str = Form(...)) -> JSONResponse: +async def message_actions(request: Request) -> JSONResponse: """ Handles slack message action. @@ -92,6 +93,12 @@ async def message_actions(request: Request, payload: str = Form(...)) -> JSONRes 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) + response_dict, status_code = await slack.process_slack_message_actions(payload) return JSONResponse(response_dict, status_code=status_code)