diff --git a/.env.template b/.env.template index d0b5d50..d8ec662 100644 --- a/.env.template +++ b/.env.template @@ -1,3 +1,5 @@ +LOGGING_LEVEL=DEBUG + CALENDAR_URL= CALENDAR_OUTLOOK_DAYS= CALENDAR_EVENT_MAXIMUM= diff --git a/.gitignore b/.gitignore index 4fae22f..bd7e040 100644 --- a/.gitignore +++ b/.gitignore @@ -204,6 +204,11 @@ cython_debug/ .cursorignore .cursorindexingignore + +# shtl +.shtl/ +shtl.toml + # Marimo marimo/_static/ marimo/_lsp/ diff --git a/Dockerfile b/Dockerfile index 0a3dab6..6f75e3c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,11 +5,13 @@ ENV UV_NO_CACHE=1 WORKDIR /app COPY pyproject.toml uv.lock ./ -RUN uv sync --group docs --frozen +RUN uv sync --frozen FROM base AS docbuilder ENV UV_NO_CACHE=1 +RUN uv sync --group docs --frozen + WORKDIR /appdocs COPY mkdocs.yml . @@ -20,8 +22,8 @@ WORKDIR /app RUN uv run zensical build --config-file /appdocs/mkdocs.yml -FROM ghcr.io/astral-sh/uv:python3.14-alpine -ENV UV_NO_CACHE=1 +FROM python:3.14-alpine +ENV PATH="/app/.venv/bin:$PATH" COPY --from=base /app/.venv /app/.venv COPY src /app @@ -34,4 +36,4 @@ RUN addgroup -g 2000 jumpgroup && \ USER jumpstart -CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-config", "/app/logging_config.yaml", "--proxy-headers", "--forwarded-allow-ips", "*"] +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-config", "/app/logging_config.yaml", "--proxy-headers", "--forwarded-allow-ips", "*"] diff --git a/docker-compose.yml b/docker-compose.yml index 4573dfa..f1778eb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,4 @@ --- - services: jumpstart: build: . diff --git a/src/api/endpoints.py b/src/api/endpoints.py index 704d1db..64f4371 100644 --- a/src/api/endpoints.py +++ b/src/api/endpoints.py @@ -1,13 +1,17 @@ -from logging import getLogger, Logger +import json +from logging import Logger, getLogger -from fastapi import APIRouter, Request, Form +from fastapi import APIRouter, Request from fastapi.responses import JSONResponse -from core import slack, wikithoughts, cshcalendar -import json -import urllib +from config import LOGGING_LEVEL +from core import cshcalendar, slack, wikithoughts + +import urllib.parse logger: Logger = getLogger(__name__) +logger.setLevel(LOGGING_LEVEL) + router: APIRouter = APIRouter() @@ -23,10 +27,14 @@ async def get_calendar() -> JSONResponse: events: list[dict[str, str]] = [] try: - get_future_events_ical: list[ - cshcalendar.CalendarInfo - ] = await cshcalendar.get_future_events() - events = cshcalendar.format_events(get_future_events_ical) + get_future_events_ical: ( + list[cshcalendar.CalendarInfo] | None + ) = await cshcalendar.get_future_events() + + if get_future_events_ical is None: + raise Exception("Gathering future events resulted in None") + + events.extend(cshcalendar.format_events(get_future_events_ical)) except Exception as e: logger.error(f"Error fetching calendar events: {e}") return JSONResponse({"status": "error", "message": str(e)}, status_code=500) @@ -64,7 +72,10 @@ async def slack_events(request: Request) -> JSONResponse: logger.warning(f"Received a Fake Slack Event!: {raw_body}") return JSONResponse({"error": "Invalid signature"}, status_code=403) - body: dict = json.loads(raw_body) + try: + body: dict = json.loads(raw_body) + except json.JSONDecodeError: + return JSONResponse({"error": "Invalid JSON body"}, status_code=400) # Challenge from Bot Authentication if request.headers.get("content-type") == "application/json": @@ -93,7 +104,11 @@ async def message_actions(request: Request) -> JSONResponse: 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")) + try: + form_data = urllib.parse.parse_qs(raw_body.decode("utf-8")) + except UnicodeDecodeError: + return JSONResponse({"error": "Invalid request body"}, status_code=400) + payload = form_data.get("payload", [None])[0] if payload is None: diff --git a/src/config.py b/src/config.py index 9e43a5f..ed4f90e 100644 --- a/src/config.py +++ b/src/config.py @@ -1,15 +1,24 @@ -import os import json import logging +import os +from typing import overload + from dotenv import load_dotenv -from typing import Any load_dotenv() logger: logging.Logger = logging.getLogger(__name__) -def _get_env_variable(name: str, default: str | None = None) -> str | Any: +@overload +def _get_env_variable(name: str, default: None = None) -> str | None: ... + + +@overload +def _get_env_variable(name: str, default: str) -> str: ... + + +def _get_env_variable(name: str, default: str | None = None) -> str | None: """ Retrieves an environment variable, with an optional default value. @@ -38,9 +47,37 @@ def _get_env_variable(name: str, default: str | None = None) -> str | Any: BASE_DIR: str = os.path.dirname(os.path.abspath(__file__)) +_raw_logging_level: str = _get_env_variable("LOGGING_LEVEL", "DEBUG") +LOGGING_LEVEL: int = logging.INFO + +match _raw_logging_level: + case "DEBUG": + LOGGING_LEVEL = logging.DEBUG + case "WARN": + LOGGING_LEVEL = logging.WARN + case "ERROR": + LOGGING_LEVEL = logging.ERROR + case "FATAL": + LOGGING_LEVEL = logging.FATAL + case "CRITICAL": + LOGGING_LEVEL = logging.CRITICAL + +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?" +WATCHED_CHANNELS: tuple[str, ...] = tuple( + _get_env_variable("WATCHED_CHANNELS", "0,1,2").split(",") +) + +SLACK_DM_TEMPLATE_FILEPATH: str = os.path.join( + BASE_DIR, "static", "slack", "dm_request_template.json" +) SLACK_MEETINGS_KEYWORD: str = _get_env_variable("SLACK_MEETINGS_KEYWORD", "meeting") -SLACK_NONTECHNICAL_SEMINAR_KEYWORD: str = _get_env_variable("SLACK_NONTECHNICAL_SEMINAR_KEYWORD", "non-technical") -SLACK_TECHNICAL_SEMINAR_KEYWORD: str = _get_env_variable("SLACK_TECHNICAL_SEMINAR_KEYWORD", "technical") +SLACK_NONTECHNICAL_SEMINAR_KEYWORD: str = _get_env_variable( + "SLACK_NONTECHNICAL_SEMINAR_KEYWORD", "non-technical" +) +SLACK_TECHNICAL_SEMINAR_KEYWORD: str = _get_env_variable( + "SLACK_TECHNICAL_SEMINAR_KEYWORD", "technical" +) SLACK_ALLOW_ANNOUNCEMENTS: bool = ( _get_env_variable("SLACK_ALLOW_ANNOUNCEMENTS", "false") == "true" @@ -51,14 +88,9 @@ def _get_env_variable(name: str, default: str | None = None) -> str | Any: SLACK_FROSH_GROUP_ID: str = _get_env_variable("SLACK_FROSH_GROUP_ID", "") SLACK_TEST_GROUP_ID: str = _get_env_variable("SLACK_TEST_GROUP_ID", "") -SLACK_API_TOKEN: str = _get_env_variable("SLACK_API_TOKEN", "") -SLACK_JUMPSTART_MESSAGE: str = "Would you like to post this message to Jumpstart?" -SLACK_SIGNING_SECRET: str = _get_env_variable("SLACK_SIGNING_SECRET", None) +SLACK_SIGNING_SECRET: str | None = _get_env_variable("SLACK_SIGNING_SECRET", None) -WATCHED_CHANNELS: tuple[str] = tuple( - _get_env_variable("WATCHED_CHANNELS", "0,1,2").split(",") -) -SLACK_DM_TEMPLATE: dict | None = None +SLACK_DM_TEMPLATE: list | None = None CALENDAR_URL: str = _get_env_variable("CALENDAR_URL", "") CALENDAR_OUTLOOK_DAYS: int = int(_get_env_variable("CALENDAR_OUTLOOK_DAYS", "7")) @@ -67,9 +99,10 @@ def _get_env_variable(name: str, default: str | None = None) -> str | Any: CALENDAR_CACHE_REFRESH: int = int(_get_env_variable("CALENDAR_CACHE_REFRESH", "10")) WIKI_API: str | None = _get_env_variable("WIKI_API", None) -WIKIBOT_USER: str | None = _get_env_variable("WIKIBOT_USER", None) -WIKIBOT_PASSWORD: str | None = _get_env_variable("WIKIBOT_PASSWORD", None) +WIKIBOT_USER: str = _get_env_variable("WIKIBOT_USER", "") +WIKIBOT_PASSWORD: str = _get_env_variable("WIKIBOT_PASSWORD", "") WIKI_CATEGORY: str = _get_env_variable("WIKI_CATEGORY", "JobAdvice") -with open(os.path.join(BASE_DIR, "static", "slack", "dm_request_template.json")) as f: - SLACK_DM_TEMPLATE = json.load(f) +if os.path.exists(SLACK_DM_TEMPLATE_FILEPATH): + with open(SLACK_DM_TEMPLATE_FILEPATH, mode="r") as f: + SLACK_DM_TEMPLATE = json.load(f) diff --git a/src/core/announcement_queue.py b/src/core/announcement_queue.py index 3166f3d..decb026 100644 --- a/src/core/announcement_queue.py +++ b/src/core/announcement_queue.py @@ -17,13 +17,15 @@ SLACK_MEETINGS_GROUP_ID, SLACK_TEST_GROUP_ID, SLACK_ALLOW_ANNOUNCEMENTS, - SLACK_TECHNICAL_SEMINAR_KEYWORD, SLACK_NONTECHNICAL_SEMINAR_KEYWORD, - SLACK_MEETINGS_KEYWORD + SLACK_MEETINGS_KEYWORD, + LOGGING_LEVEL, ) logger: Logger = getLogger(__name__) +logger.setLevel(LOGGING_LEVEL) + client: AsyncWebClient | None = None event_id_cache: dict[str, str] = {} @@ -35,6 +37,7 @@ logger.info(SLACK_MEETINGS_KEYWORD) logger.info(SLACK_TECHNICAL_SEMINAR_KEYWORD) + async def create_announcement_worker( event_uid: str, event_recurrence_id: str, text: str, event_time: datetime ) -> None: @@ -47,6 +50,7 @@ async def create_announcement_worker( text (str): The message to be sent event_time (datetime): The time for the event. """ + key: str = f"{event_uid}:{event_recurrence_id}" # we should use redis instead logger.info(key) @@ -93,104 +97,85 @@ def queue_announcement( queued_announcement_id_cache[key] = task -def clear_running_workers() -> None: - """ - Loops through and removes each running worker event. Used for clearing events that have been deleted +def clear_stale_workers(valid_keys: set[str]) -> None: """ + Cancels and removes any queued workers whose key is not in valid_keys. - for event in queued_announcement_id_cache.values(): - event.cancel() + Args: + valid_keys (set[str]): The set of event keys that are still valid this rebuild + """ - queued_announcement_id_cache.clear() + for key in list(queued_announcement_id_cache.keys()): + if key not in valid_keys: + task = queued_announcement_id_cache.pop(key, None) + if task is not None: + task.cancel() -def check_for_announcement(event: dict[str, Any], time: datetime) -> None: +def check_for_announcement(event: dict[str, Any], time: datetime) -> str | None: """ Checks to see if a worker needs to be created for an event Args: event (dict[str, str]): The information for the event time (datetime): The time for the event + + Returns: + str | None: The queued announcement's key if one was queued, otherwise None """ if not SLACK_ALLOW_ANNOUNCEMENTS: - return + return None description: str = event.get("DESCRIPTION", "") if not description: - return + return None title: str = event.get("SUMMARY", "") if not title: - return + return None uid: str = str(event.get("UID", "")) if not uid: - return + return None recurrence_id = event.get("RECURRENCE-ID", None) if not recurrence_id: - return + return None rec_id: str = recurrence_id.dt.isoformat() loc = event.get("LOCATION", None) description = description.lower().strip() - if SLACK_NONTECHNICAL_SEMINAR_KEYWORD.lower() in description: - if loc: - queue_announcement( - uid, - rec_id, - f" The Non-Technical Seminar {title} will be happening in the {loc} in {MINUTES_BEFORE_EVENT_PING} minutes!", - time, - ) - else: - queue_announcement( - uid, - rec_id, - f" The Non-Technical Seminar {title} will be happening in {MINUTES_BEFORE_EVENT_PING} minutes!", - time, - ) - elif SLACK_TECHNICAL_SEMINAR_KEYWORD.lower() in description: - if loc: - queue_announcement( - uid, - rec_id, - f" The Technical Seminar {title} will be happening in the {loc} in {MINUTES_BEFORE_EVENT_PING} minutes!", - time, - ) - else: - queue_announcement( - uid, - rec_id, - f" The Technical Seminar {title} will be happening in {MINUTES_BEFORE_EVENT_PING} minutes!", - time, - ) - elif SLACK_MEETINGS_KEYWORD.lower() in description: - if loc: - queue_announcement( - uid, - rec_id, - f" The {title} directorship will be happening in the {loc} in {MINUTES_BEFORE_EVENT_PING} minutes!", - time, - ) - else: - queue_announcement( - uid, - rec_id, - f" The {title} directorship will be happening in {MINUTES_BEFORE_EVENT_PING} minutes!", - time, - ) - elif "gick" in description: - if loc: - queue_announcement( - uid, rec_id, f" gick go to the {loc}!", time - ) - else: - queue_announcement( - uid, rec_id, f" hi gick!", time - ) + message: str | None = None + + match description: + case x if SLACK_NONTECHNICAL_SEMINAR_KEYWORD.lower() in x: + message = f" The Non-Technical Seminar {title} will be happening {f'in the {loc} ' if loc else ''}in {MINUTES_BEFORE_EVENT_PING} minutes!" + + case x if SLACK_TECHNICAL_SEMINAR_KEYWORD.lower() in x: + message = f" The Technical Seminar {title} will be happening {f'in the {loc} ' if loc else ''}in {MINUTES_BEFORE_EVENT_PING} minutes!" + + case x if SLACK_MEETINGS_KEYWORD.lower() in x: + message = f" The {title} directorship will be happening {f'in the {loc} ' if loc else ''}in {MINUTES_BEFORE_EVENT_PING} minutes!" + + case x if "gick" in x: + message = f" {f'gick go to the {loc}' if loc else 'hi gick'}!" + + case _: + return None + + if message is not None: + queue_announcement( + uid, + rec_id, + message, + time, + ) + + return f"{uid}:{rec_id}" + # if TECHNICAL_SEMINAR_KEYWORD.lower() in description: # taskmanager.create_background_task(create_announcement_worker( diff --git a/src/core/cshcalendar.py b/src/core/cshcalendar.py index 777dbc5..5410d81 100644 --- a/src/core/cshcalendar.py +++ b/src/core/cshcalendar.py @@ -1,5 +1,7 @@ -from logging import getLogger, Logger -from datetime import datetime, date, timedelta, time +import asyncio +import re +from datetime import date, datetime, time, timedelta +from logging import Logger, getLogger from zoneinfo import ZoneInfo from core import announcement_queue @@ -8,8 +10,6 @@ import httpx import recurring_ical_events import arrow -import re -import asyncio from modules import taskmanager @@ -19,6 +19,7 @@ CALENDAR_OUTLOOK_DAYS, CALENDAR_TIMEZONE, CALENDAR_URL, + LOGGING_LEVEL, ) calendar_cache: list[CalendarInfo] = [] # The current cache of the calendar @@ -35,6 +36,8 @@ cal_constructed_event.clear() logger: Logger = getLogger(__name__) +logger.setLevel(LOGGING_LEVEL) + logger.info("Starting up the calendar service!") cshcal_client = httpx.AsyncClient() @@ -50,7 +53,7 @@ WARNING: PERCENTAGE SIGNS WILL TRIGGER A REGEX OPERATION WARNING: FOLLOW INSERTION ORDER """ -HUMANIZER_CHECKS: dict[int, str] = { +HUMANIZER_CHECKS: dict[int | float, str] = { MINUTE: "In 1 Minute", (HOUR - MINUTE): f"In %{MINUTE}% Minutes", (HOUR * 1.5): "In 1 Hour", @@ -74,7 +77,7 @@ class CalendarInfo: def __init__(self, name: str, date_time: date, location: str | None = None): self.name: str = name - self.date: arrow.arrow = arrow.get(date_time) # Arrow has way cooler stuff + self.date: arrow.Arrow = arrow.get(date_time) # Arrow has way cooler stuff self.location: str | None = location def __eq__(self, other): @@ -89,6 +92,7 @@ def __hash__(self): def ceil_division(num: int, den: int) -> int: """ Returns a ceiling division of the two numbers + Args: num (int): the numerator den (int): the denominator @@ -100,13 +104,14 @@ def ceil_division(num: int, den: int) -> int: return (num + den - 1) // den -def time_humanizer(current_time: datetime, event_time: datetime) -> str: +def time_humanizer(current_time: datetime, event_time: arrow.Arrow) -> str: """ Custom humanizer for text to be displayed Args: current_time (datetime): The current time to be judged off of - event_time (datetime): The events time to be factored + event_time (arrow.Arrow): The events time to be factored + Returns: str: The humanized time as a string """ @@ -125,7 +130,7 @@ def repl(match: re.Match[str]) -> str: num = int(match.group(1)) return str(round(time_before_event / num)) - time_before_event: int = (event_time - current_time).total_seconds() + time_before_event: int | float = (event_time - current_time).total_seconds() if time_before_event > WEEK: return "Over a Week Away" @@ -149,7 +154,7 @@ def format_events(events: list[CalendarInfo]) -> list[dict[str, str]]: Formats a parsed list of CalendarInfos, and returns the HTML required for front end Args: - events: The list of CalendarInfos to be formatted + events: (list[CalendarInfo]) The list of CalendarInfos to be formatted Returns: list[dict[str, str]]: Returns a dictionary with the "data" key mapping to a list of dictionarys of each event. @@ -163,20 +168,16 @@ def format_events(events: list[CalendarInfo]) -> list[dict[str, str]]: formatted_list: list[dict[str, str]] = [] for event in events: - content_dict: dict[str, str] = {} + content_dict: dict[str, str] = {"content": str(event.name)} - event_cur_happening: bool = event.date < current_date - if event_cur_happening: - formatted: str = ( + if event.date < current_date: + content_dict["header"] = ( f"Happening in {event.location}!" if event.location else "Happening Now!" ) - content_dict["header"] = formatted - content_dict["content"] = str(event.name) else: content_dict["header"] = time_humanizer(current_date, event.date) - content_dict["content"] = str(event.name) formatted_list.append(content_dict) return formatted_list @@ -203,7 +204,7 @@ async def rebuild_calendar() -> None: current_time, current_time + timedelta(days=CALENDAR_OUTLOOK_DAYS) ) - announcement_queue.clear_running_workers() + matched_announcement_keys: set[str] = set() for event in fetched_daily_events: dt = event.get("DTSTART").dt @@ -225,9 +226,13 @@ async def rebuild_calendar() -> None: event.get("LOCATION"), ) - announcement_queue.check_for_announcement(event, dt) + announcement_key = announcement_queue.check_for_announcement(event, dt) + if announcement_key is not None: + matched_announcement_keys.add(announcement_key) found_events.add(new_event) + announcement_queue.clear_stale_workers(matched_announcement_keys) + cal_last_update = current_time calendar_cache = sorted(found_events, key=lambda x: x.date)[ :CALENDAR_EVENT_MAXIMUM @@ -240,7 +245,7 @@ async def rebuild_calendar() -> None: cal_constructed_event.set() -async def get_future_events() -> list[CalendarInfo]: +async def get_future_events() -> list[CalendarInfo] | None: """ Returns the first events up to event maximum within the the calendar outlook day amount custom object has name, date and the location @@ -256,6 +261,9 @@ async def get_future_events() -> list[CalendarInfo]: header_none_match, \ cal_constructed_event + if not CALENDAR_URL: + raise Exception("Calendar URL is not configured, cant request.") + if not cal_constructed_event.is_set(): await cal_constructed_event.wait() return calendar_cache @@ -273,10 +281,11 @@ async def get_future_events() -> list[CalendarInfo]: logger.info("Checking to rebuild CSH Calendar...") try: - headers: dict[str, str | None] = {} + headers: dict[str, str] = {} if header_none_match: headers["If-None-Match"] = header_none_match + if header_last_modified: headers["If-Modified-Since"] = header_last_modified diff --git a/src/core/slack.py b/src/core/slack.py index 56d589b..fc903a2 100644 --- a/src/core/slack.py +++ b/src/core/slack.py @@ -1,11 +1,8 @@ -import re import copy import json +import re -from logging import getLogger, Logger - -from slack_sdk.web.async_client import AsyncWebClient -from slack_sdk.web.slack_response import SlackResponse +from slack_sdk.web.async_client import AsyncWebClient, AsyncSlackResponse from slack_sdk.errors import SlackApiError from slack_sdk.signature import SignatureVerifier @@ -19,9 +16,11 @@ CALENDAR_TIMEZONE, WATCHED_CHANNELS, SLACK_SIGNING_SECRET, + LOGGING_LEVEL, ) from datetime import datetime +from logging import Logger, getLogger from zoneinfo import ZoneInfo from fastapi import Request @@ -29,8 +28,10 @@ import httpx logger: Logger = getLogger(__name__) +logger.setLevel(LOGGING_LEVEL) + client: AsyncWebClient | None = None -event_id_cache: dict[str, str] = {} +event_id_cache: dict[str, int] = {} EVENT_CACHE_DEBOUNCE = ( 60 # Hold event in for one minute? I think its fine genuiflowkirkenuinelowskinly @@ -108,19 +109,20 @@ async def reset_event_from_cache(event_id: str) -> None: 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 + event_id_cache.pop(event_id, None) return -def get_event_retry_amount(event_id: str) -> int: +def get_event_retry_amount(event_id: str | None) -> int: """ Returns the amount of times a event has been retried Arguments: - event_id (str): The id of the slack event + event_id (str | None): The id of the slack event Returns: int: The amount of times the event has been retried @@ -128,6 +130,9 @@ def get_event_retry_amount(event_id: str) -> int: global event_id_cache + if event_id is None: + return 0 + if event_id in event_id_cache: event_id_cache[event_id] += 1 return event_id_cache[event_id] @@ -151,7 +156,7 @@ async def gather_emojis() -> dict: if client is None: raise ValueError("Slack client is not initialized") - emoji_request: dict = await client.emoji_list() + emoji_request: AsyncSlackResponse = await client.emoji_list() assert emoji_request.get("ok", False) emojis = emoji_request.get("emoji", {}) @@ -172,7 +177,10 @@ async def get_username(user_id: str) -> str: str: The username, or an empty string if not applicable """ - response = await client.users_info(user=user_id) + if client is None: + raise ValueError("Slack client is not initialized") + + response: AsyncSlackResponse = await client.users_info(user=user_id) user = response.get("user", None) if user is None: @@ -199,7 +207,10 @@ async def request_upload_via_dm(user_id: str, announcement_text: str) -> None: if client is None: raise ValueError("Slack client is not initialized") - message: dict = copy.deepcopy(SLACK_DM_TEMPLATE) + message: list | None = copy.deepcopy(SLACK_DM_TEMPLATE) + + if message is None: + raise Exception("Unable to deepcopy dm template.") message[0]["text"]["text"] += announcement_text message[1]["elements"][0]["value"] = json.dumps( @@ -219,7 +230,9 @@ 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]: +async def process_slack_events( + body: dict, +) -> dict[str, str] | tuple[dict[str, str], int]: """ Processes a slack event, logging and returning the result from the event @@ -238,7 +251,7 @@ async def process_slack_events(body: dict) -> dict[str, str]: logger.info( f"SLACK EVENT: Retried event for {body.get('event_id', None)} {event_amounts} time(s)!" ) - return ({"status": "success"}, 200) + return {"status": "success"} event: dict = body.get("event", {}) @@ -276,7 +289,7 @@ async def process_slack_message_actions(payload: str): logger.info( f"SLACK MESSAGE ACTION: Retried event for {form_json.get('trigger_id', None)} {event_amounts} time(s)!" ) - return {"status": "ignored"} + return ({"status": "ignored"}, 200) if form_json.get("type") != "block_actions": return ({}, 200) @@ -286,8 +299,8 @@ async def process_slack_message_actions(payload: str): "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:""}') + message_object: str | None = json.loads( + form_json.get("actions", [{}])[0].get("value", '{"text": ""}') ).get("text", None) user_id = form_json.get("user", {}).get("id") @@ -326,8 +339,8 @@ async def send_announcement_message(msg_text: str) -> None: Args: msg_text (str): The text for the message - """ + if not client: logger.warning("Client has not been initalized") return @@ -369,7 +382,7 @@ def get_announcement() -> dict[str, str] | None: return current_announcement -def add_announcement(announcement_text: str, username: str) -> None: +def add_announcement(announcement_text: str | None, username: str) -> None: """ Adds an announcement to the queue. @@ -377,6 +390,7 @@ def add_announcement(announcement_text: str, username: str) -> None: announcement_text (str): The text of the announcement to be added. user_id (str): The user_id of the person """ + global current_announcement if announcement_text is None or announcement_text.strip() == "": diff --git a/src/core/wikithoughts.py b/src/core/wikithoughts.py index 1a3e715..4bfe234 100644 --- a/src/core/wikithoughts.py +++ b/src/core/wikithoughts.py @@ -1,15 +1,20 @@ -import re -import httpx -import random import asyncio import logging - -from typing import Pattern -from itertools import islice +import random +import re from datetime import datetime, timedelta +from itertools import islice +from typing import Pattern -from config import WIKIBOT_PASSWORD, WIKIBOT_USER, WIKI_CATEGORY, WIKI_API +import httpx +from config import ( + LOGGING_LEVEL, + WIKI_API, + WIKI_CATEGORY, + WIKIBOT_PASSWORD, + WIKIBOT_USER, +) CYCLE_DEBOUNCE_TIME: int = 12 # How long it takes to resfresh wiki titles BATCH_SIZE: int = 50 # max titles per request @@ -17,15 +22,16 @@ 3 # The amount of times it will attempt to re-authenticare ) -HEADERS: dict[str, str] = {"User-Agent": "JumpstartFetcher/1.0"} -AUTH: tuple[str] = (WIKIBOT_USER, WIKIBOT_PASSWORD) +_HEADERS: dict[str, str] = {"User-Agent": "JumpstartFetcher/1.0"} +_AUTH: tuple[str, str] = (WIKIBOT_USER, WIKIBOT_PASSWORD) logger: logging.Logger = logging.getLogger(__name__) +logger.setLevel(LOGGING_LEVEL) client: httpx.AsyncClient | None = None try: - client = httpx.AsyncClient(headers=HEADERS, auth=AUTH) + client = httpx.AsyncClient(headers=_HEADERS, auth=_AUTH) except Exception as e: logger.warning(f"Failed to initialize HTTP client for wiki: {e}") @@ -68,7 +74,7 @@ def clean_wikitext(text: str) -> str: str: The cleaned up text string """ - reg_operations: tuple[Pattern[str]] = ( + reg_operations: tuple[Pattern[str], ...] = ( RE_FILE, RE_IMAGE, RE_LINK, @@ -133,6 +139,8 @@ async def auth_bot() -> None: ) return + global bot_authenticated + token_req: httpx.Response = await client.get( WIKI_API, params={"action": "query", "meta": "tokens", "type": "login", "format": "json"}, @@ -154,8 +162,6 @@ async def auth_bot() -> None: returned_json: dict = login_req.json()["login"] if returned_json and returned_json["result"] == "Success": - global bot_authenticated - bot_authenticated = True logger.info("Bot was authenticated successfully!") else: @@ -218,12 +224,12 @@ def needs_category_refresh(update_time: datetime) -> bool: ) -def process_category_page(r_json: dict[str, str]) -> tuple[list[str], bool | str]: +def process_category_page(r_json: dict) -> tuple[list[str], bool | str]: """ Processes a wikithoughts response into a list of title pages Args: - r_json (dict[str,str]): The JSON from the wiki to be processed + r_json: The JSON from the wiki to be processed Returns: tuple[list[str], bool | str]: The list of titles from the request, along with a possible continutation if needed @@ -238,11 +244,10 @@ def process_category_page(r_json: dict[str, str]) -> tuple[list[str], bool | str # Loop to keep everything going if "continue" in r_json: return (titles, r_json["continue"]["cmcontinue"]) - - return (titles, False) else: logger.warning(f"Failure in obtaining info, JSON:\n{r_json}") - return (titles, False) + + return (titles, False) async def fetch_category_pages(response: httpx.Response) -> list[str]: @@ -256,7 +261,19 @@ async def fetch_category_pages(response: httpx.Response) -> list[str]: list[str]: The list of titles to be fetched. """ - params: dict[str, str] = { + if not client: + logger.warning( + "HTTP client for wiki is not initialized, unable to fetch category pages!" + ) + return [] + + if not WIKI_API: + logger.warning( + "There is no WIKI_API set to make requests, unable to fetch category pages!" + ) + return [] + + params: dict[str, str | bool] = { "action": "query", "list": "categorymembers", "cmtitle": f"Category:{WIKI_CATEGORY}", @@ -269,7 +286,7 @@ async def fetch_category_pages(response: httpx.Response) -> list[str]: failed_authentication_attempts: int = 0 while True: - r_json: dict[str, str] = response.json() + r_json: dict = response.json() if "error" in r_json and r_json["error"].get("code") in ( "readapidenied", @@ -296,7 +313,7 @@ async def fetch_category_pages(response: httpx.Response) -> list[str]: continue added, repeat_req = process_category_page(r_json) - titles_found += added + titles_found.extend(added) if repeat_req not in (None, False, ""): params["cmcontinue"] = repeat_req @@ -325,13 +342,19 @@ async def refresh_category_pages() -> list[str]: ) return [] + if not WIKI_API: + logger.warning( + "There is no WIKI_API set to make requests, unable to refresh category pages!" + ) + return [] + global page_title_cache, last_updated_time, queued_pages, shown_pages + time_now: datetime = datetime.now() if not needs_category_refresh(time_now): return page_title_cache - titles: list[str] = [] params: dict[str, str] = { "action": "query", "list": "categorymembers", @@ -361,7 +384,7 @@ async def refresh_category_pages() -> list[str]: queued_pages = titles.copy() random.shuffle(queued_pages) - shown_pages = [] + shown_pages.clear() await refresh_page_dictionary() return page_title_cache @@ -378,6 +401,12 @@ async def refresh_page_dictionary() -> None: ) return + if not WIKI_API: + logger.warning( + "There is no WIKI_API set to make requests, unable to refresh page dictionary!" + ) + return + global page_dict_cache, page_title_cache if not page_title_cache: @@ -386,7 +415,7 @@ async def refresh_page_dictionary() -> None: results: dict[str, str] = {} tasks: list = [] for batch in batch_iterable(page_title_cache, BATCH_SIZE): - params = { + params: dict[str, str | bool] = { "action": "query", "prop": "revisions", "rvprop": "content", @@ -435,7 +464,7 @@ def reset_queues() -> None: queued_pages = shown_pages random.shuffle(queued_pages) - shown_pages = [] + shown_pages.clear() async def get_next_display() -> dict[str, str]: diff --git a/src/main.py b/src/main.py index 8c81b78..b2319ca 100644 --- a/src/main.py +++ b/src/main.py @@ -6,22 +6,21 @@ """ import os - -from logging import getLogger, Logger +from contextlib import asynccontextmanager +from logging import Logger, getLogger from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from fastapi.responses import RedirectResponse, HTMLResponse -from contextlib import asynccontextmanager - -from config import BASE_DIR from api import endpoints -from core import wikithoughts, cshcalendar +from config import BASE_DIR, LOGGING_LEVEL +from core import cshcalendar, wikithoughts from modules import taskmanager logger: Logger = getLogger(__name__) +logger.setLevel(LOGGING_LEVEL) @asynccontextmanager diff --git a/src/modules/taskmanager.py b/src/modules/taskmanager.py index a0c5610..88d8b32 100644 --- a/src/modules/taskmanager.py +++ b/src/modules/taskmanager.py @@ -3,8 +3,10 @@ from core import cshcalendar from logging import getLogger, Logger from collections.abc import Coroutine +from config import LOGGING_LEVEL logger: Logger = getLogger(__name__) +logger.setLevel(LOGGING_LEVEL) running_background_tasks: set[asyncio.Task] = set() @@ -18,6 +20,7 @@ def handle_task_exception(task: asyncio.Task) -> None: Arguments: Task (asyncio.Task): The task to be """ + try: task.result() except asyncio.CancelledError: @@ -46,10 +49,11 @@ def create_background_task(coroutine: Coroutine) -> asyncio.Task: return task -async def calendar_worker(): +async def calendar_worker() -> None: """ Loop to force rebuild the calendar every 20 minutes to check for event updates """ + while True: await asyncio.sleep(TWENTY_MINUTES) await cshcalendar.rebuild_calendar() diff --git a/tests/conftest.py b/tests/conftest.py index 96dcc6a..cd8997c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ -import coverage import os +import coverage + # Get the absolute path to the src directory src_path = os.path.join(os.path.dirname((os.path.abspath(__file__))), "..", "src") diff --git a/tests/src/core/test_slack.py b/tests/src/core/test_slack.py index ca138b9..120b988 100644 --- a/tests/src/core/test_slack.py +++ b/tests/src/core/test_slack.py @@ -1,6 +1,6 @@ -import sys import asyncio import importlib +import sys def import_slack_module(monkeypatch) -> object: @@ -153,8 +153,6 @@ 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!")) diff --git a/tests/src/test_config.py b/tests/src/test_config.py index 767b36f..b1aa955 100644 --- a/tests/src/test_config.py +++ b/tests/src/test_config.py @@ -1,5 +1,5 @@ -import sys import importlib +import sys def import_config_module() -> object: diff --git a/ty.toml b/ty.toml new file mode 100644 index 0000000..868b754 --- /dev/null +++ b/ty.toml @@ -0,0 +1,2 @@ +[environment] +extra-paths = ["./src"]