From 8edc9a8edb4c53cc9b5fa785898d536dbfb23678 Mon Sep 17 00:00:00 2001 From: Sami Laine Date: Wed, 4 Dec 2024 16:49:20 +0000 Subject: [PATCH 1/4] Add initial support for cancelling events --- src/operationbot/config.py | 5 ++ src/operationbot/event.py | 17 +++++++ src/operationbot/eventDatabase.py | 29 +++++++++++ src/operationbot/messageFunctions.py | 49 ++++++++++++++----- src/operationbot/tasks.py | 14 ++++++ tests/test_database.py | 73 ++++++++++++++++++++++++++++ tests/test_event.py | 8 +++ 7 files changed, 184 insertions(+), 11 deletions(-) diff --git a/src/operationbot/config.py b/src/operationbot/config.py index 50e043f..93fea94 100644 --- a/src/operationbot/config.py +++ b/src/operationbot/config.py @@ -222,6 +222,7 @@ "REFORGER_SIDEOP": 0x99CB48, "REFORGER_DLC": 0x00FF00, "REFORGER_DLC_SIDEOP": 0x00FFFF, + "CANCELLED": 0x808080, } DLC_TERRAINS = { @@ -233,6 +234,10 @@ ARCHIVE_AUTOMATICALLY = True ARCHIVE_AFTER_TIME = timedelta(hours=2) +CANCEL_CHECK_DELAY = 60 +CANCEL_AUTOMATICALLY = True +CANCEL_THRESHOLD = timedelta(hours=24) + OVERHAUL_MODS = "https://zeusops.com/overhaul" MULTICREATE_WEEKEND = [6, 7] diff --git a/src/operationbot/event.py b/src/operationbot/event.py index 39a8a7a..f8dae7a 100644 --- a/src/operationbot/event.py +++ b/src/operationbot/event.py @@ -68,6 +68,7 @@ def __init__( self._dlc: str = "" self.overhaul = "" self.embed_hash = "" + self.cancelled = False if platoon_size is None: if sideop: @@ -93,6 +94,8 @@ def additional_role_count(self) -> int: @property def color(self) -> int: + if self.cancelled: + return EMBED_COLOR["CANCELLED"] if self.overhaul: return EMBED_COLOR["OVERHAUL"] if self.reforger and self.dlc and self.sideop: @@ -117,6 +120,8 @@ def title(self) -> str: # It an explicit title is set, return that return self._title # Otherwise, use dynamic title + if self.cancelled: + return f"Cancelled {TITLE}" if self.overhaul: return f"{self.overhaul} Overhaul {TITLE}" if self.reforger and self.dlc and self.sideop: @@ -484,6 +489,9 @@ def _getNormalEmojis(self, guildEmojis: tuple[Emoji, ...]) -> dict[str, Emoji]: def getReactions(self) -> list[str | Emoji]: """Return reactions of all roles and extra reactions""" + if self.cancelled: + return [] + reactions = [] for roleGroup in self.roleGroups.values(): @@ -573,6 +581,7 @@ def signup( old_role = self.undoSignup(user) role.userID = user.id role.userName = user.display_name + self.cancelled = False return old_role, old_user # Probably shouldn't ever reach this raise RoleNotFound(f"Could not find role: {roleToSet}") @@ -599,6 +608,12 @@ def findSignupRole(self, userID) -> Role | None: # TODO: raise RoleNotFound instead of returning None? return None + def is_empty(self) -> bool: + role = self.findRoleWithName("ZEUS") + if not role: + return False + return role.userID is None + def has_attendee(self, user: discord.abc.User) -> bool: """Check if the given user has been marked as attending.""" return user in self.attendees @@ -646,6 +661,7 @@ def toJson(self, brief_output=False) -> dict[str, Any]: data["reforger"] = self.reforger data["attendees"] = attendees_data data["embed_hash"] = self.embed_hash + data["cancelled"] = self.cancelled data["roleGroups"] = roleGroupsData return data @@ -666,6 +682,7 @@ def fromJson(self, eventID, data: dict, emojis, manual_load=False): self.sideop = bool(data.get("sideop", False)) self.reforger = bool(data.get("reforger", False)) self.embed_hash = data.get("embed_hash", "") + self.cancelled = data.get("cancelled", False) attendees_data = data.get("attendees", {}) for userID, name in attendees_data.items(): self.attendees.append(User(int(userID), name)) diff --git a/src/operationbot/eventDatabase.py b/src/operationbot/eventDatabase.py index 6fc1a07..3e622f8 100644 --- a/src/operationbot/eventDatabase.py +++ b/src/operationbot/eventDatabase.py @@ -79,6 +79,11 @@ def archiveEvent(cls, event: Event): cls.toJson(archive=False) cls.toJson(archive=True) + @classmethod + def cancel_event(cls, event: Event): + """Cancel event.""" + event.cancelled = True + @classmethod def removeEvent(cls, eventID: int, archived=False) -> Optional[Event]: """Remove event. @@ -207,6 +212,30 @@ def archive_past_events(cls, delta: timedelta = timedelta()) -> list[Event]: return archived + @classmethod + def cancel_empty_events( + cls, + threshold: timedelta = timedelta(), + ) -> list[Event]: + """Cancel (archive) empty events a certain time before the event time. + + Returns a list of the cancelled events + """ + events = [] + + for event in cls.events.values(): + if event.date - datetime.now() < threshold: + if event.is_empty(): + events.append(event) + # Using a separate loop to prevent modifying cls.events while looping + # over it + for event in events: + cls.cancel_event(event) + + cls.toJson(archive=False) + + return events + @classmethod def toJson(cls, archive=False): # TODO: rename to saveDatabase diff --git a/src/operationbot/messageFunctions.py b/src/operationbot/messageFunctions.py index 665202c..60a11ba 100644 --- a/src/operationbot/messageFunctions.py +++ b/src/operationbot/messageFunctions.py @@ -28,6 +28,16 @@ async def getEventMessage(event: Event, bot: OperationBot, archived=False) -> Me ) from e +async def update_event_message(bot: OperationBot, event: Event): + """Update event embed and reactions.""" + try: + message = await getEventMessage(event, bot) + except MessageNotFound as e: + raise MessageNotFound(f"sortEventMessages: {e}") from e + await updateMessageEmbed(message, event) + await updateReactions(event, message=message) + + async def sortEventMessages(bot: OperationBot): """Sort event messages according to the event database. @@ -40,12 +50,7 @@ async def sortEventMessages(bot: OperationBot): event: Event for event in EventDatabase.events.values(): - try: - message = await getEventMessage(event, bot) - except MessageNotFound as e: - raise MessageNotFound(f"sortEventMessages: {e}") from e - await updateMessageEmbed(message, event) - await updateReactions(event, message=message) + await update_event_message(bot, event) EventDatabase.toJson() @@ -249,14 +254,36 @@ async def archive_past_events( if target is None: target = bot.commandchannel - archived = EventDatabase.archive_past_events(delta) + events = EventDatabase.archive_past_events(delta) - for event in archived: + for event in events: await archive_single_event(event, target, bot) - if archived: - msg = f"{len(archived)} events archived" + if events: + msg = f"{len(events)} events archived" + await target.send(msg) + logging.info(msg) + + return events + + +async def cancel_empty_events( + bot: OperationBot, + target: Messageable | None = None, + threshold: timedelta = timedelta(), +) -> list[Event]: + """Cancel empty events.""" + if target is None: + target = bot.commandchannel + + events = EventDatabase.cancel_empty_events(threshold) + + for event in events: + await update_event_message(bot, event) + + if events: + msg = f"{len(events)} events cancelled" await target.send(msg) logging.info(msg) - return archived + return events diff --git a/src/operationbot/tasks.py b/src/operationbot/tasks.py index 94eb3a8..8ab1c4b 100644 --- a/src/operationbot/tasks.py +++ b/src/operationbot/tasks.py @@ -16,3 +16,17 @@ async def archive_past_events(bot: OperationBot): while True: await msgFnc.archive_past_events(bot, delta=cfg.ARCHIVE_AFTER_TIME) await asyncio.sleep(cfg.ARCHIVE_CHECK_DELAY) + + +async def cancel_empty_events(bot: OperationBot): + if not cfg.CANCEL_AUTOMATICALLY: + logging.info( + "Automatic cancellation disabled, skipping cancel_empty_events task" + ) + return + + logging.info("Starting cancel_empty_events task") + + while True: + await msgFnc.cancel_empty_events(bot, threshold=cfg.CANCEL_THRESHOLD) + await asyncio.sleep(cfg.CANCEL_CHECK_DELAY) diff --git a/tests/test_database.py b/tests/test_database.py index 69697ec..bef69d1 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -1,7 +1,13 @@ from datetime import datetime, timedelta, timezone +from typing import Any + +from discord import Emoji from operationbot import config as cfg +from operationbot.event import Event from operationbot.eventDatabase import EventDatabase as db +from operationbot.role import Role +from operationbot.roleGroup import RoleGroup def _timestamp(date: datetime) -> int: @@ -52,3 +58,70 @@ def test_archive_past_delta(): assert len(db.eventsArchive) == 0 assert db.events.get(0) == event_past assert db.events.get(1) == event_coming + + +def test_cancel_empty(): + _init_db() + + class _Role(Role): + next_uid = 0 + + def __init__(self, name: str, emoji: str | Emoji, show_name: bool = False): + self.name = name + self.userID: int | None = None + self.userName = "" + self.emoji = cfg.ADDITIONAL_ROLE_EMOJIS[0] + + def signup(self): + self.userID = _Role.next_uid + _Role.next_uid += 1 + self.userName = f"User {self.userID}" + + def toJson(self, brief_output=False) -> dict[str, Any]: + return {} + + def _init_event(event: Event, signup=False) -> None: + group = event.roleGroups.get("Company") + if not group: + group = RoleGroup("Company") + event.roleGroups["Company"] = group + role = _Role("ZEUS", ":zeus:") + group.addRole(role) + if signup: + role.signup() + + date_close = datetime.now() + timedelta(hours=1) + date_far = datetime.now() + timedelta(hours=3) + + event_close = db.createEvent(date_close, platoon_size="empty") + event_close.title = "close" + event_far = db.createEvent(date_far, platoon_size="empty") + event_far.title = "far" + event_close_empty = db.createEvent( + date_close + timedelta(minutes=1), platoon_size="empty" + ) + event_close_empty.title = "close empty" + event_far_empty = db.createEvent( + date_far + timedelta(minutes=1), platoon_size="empty" + ) + event_far_empty.title = "far empty" + + _init_event(event_close, signup=True) + _init_event(event_far, signup=True) + _init_event(event_close_empty) + _init_event(event_far_empty) + + # print(event_close.roleGroups) + + assert not event_close.is_empty() + assert not event_far.is_empty() + assert event_close_empty.is_empty() + assert event_far_empty.is_empty() + + assert len(db.events) == 4 + assert len(db.eventsArchive) == 0 + events = db.cancel_empty_events(timedelta(hours=2)) + # print(events) + assert events[0].cancelled + assert len(events) == 1 + assert len(db.events) == 4 diff --git a/tests/test_event.py b/tests/test_event.py index 0488512..1f5c897 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -103,3 +103,11 @@ def test_reforger_side_dlc(): event.dlc = "DLC 1" assert event.title == "DLC 1 Reforger Side Operation" + + +def test_cancel(): + date = datetime(2020, 1, 1, 12, 0, 0) + event = Event(date, guildEmojis=(), platoon_size="empty") + event.cancelled = True + + assert event.title == "Cancelled Operation" From 260c264f88f58f77ff0e77fbbd13d7f7aad047f5 Mon Sep 17 00:00:00 2001 From: Sami Laine Date: Wed, 4 Dec 2024 18:11:34 +0000 Subject: [PATCH 2/4] Start cancel_empty_events task on bot startup --- src/operationbot/bot.py | 12 ++++++++++-- src/operationbot/eventDatabase.py | 13 +++++++++---- src/operationbot/eventListener.py | 11 +---------- src/operationbot/messageFunctions.py | 19 ++++++++++--------- src/operationbot/tasks.py | 25 +++++++++++++++++++------ 5 files changed, 49 insertions(+), 31 deletions(-) diff --git a/src/operationbot/bot.py b/src/operationbot/bot.py index cd1154e..a22142a 100644 --- a/src/operationbot/bot.py +++ b/src/operationbot/bot.py @@ -1,9 +1,9 @@ """A bot module that implements a custom help command and extra features.""" -import asyncio import logging import sys import traceback +from asyncio import Task import discord from discord import TextChannel, User @@ -11,6 +11,7 @@ from discord.guild import Guild from operationbot import config as cfg +from operationbot import tasks from operationbot.eventDatabase import EventDatabase from operationbot.secret import ADMIN, SIGNOFF_NOTIFY_USER @@ -66,7 +67,7 @@ def __init__(self, *args, help_command=None, **kwargs): self.signoff_notify_user: User self.awaiting_reply = False self.processing = True - self.archive_task: asyncio.Task | None = None + self.tasks: dict["str", Task] = {} if help_command is None: self.help_command = AliasHelpCommand() @@ -83,6 +84,13 @@ def fetch_data(self) -> None: self.owner = self._get_user(self.owner_id) self.signoff_notify_user = self._get_user(SIGNOFF_NOTIFY_USER) + def start_tasks(self) -> None: + for name, task in tasks.ALL_TASKS.items(): + if name not in self.tasks: + self.tasks[name] = self.loop.create_task(task(self)) + else: + logging.info(f"The {name} task is already running, not starting again") + def _get_user(self, user_id: int) -> User: user = self.get_user(user_id) if user is None: diff --git a/src/operationbot/eventDatabase.py b/src/operationbot/eventDatabase.py index 3e622f8..4894264 100644 --- a/src/operationbot/eventDatabase.py +++ b/src/operationbot/eventDatabase.py @@ -203,6 +203,7 @@ def archive_past_events(cls, delta: timedelta = timedelta()) -> list[Event]: archived = [] for event in cls.events.values(): + # TODO: check timezones if datetime.now() - event.date > delta: archived.append(event) # Archiving in a separate loop to prevent modifying cls.events while @@ -221,12 +222,16 @@ def cancel_empty_events( Returns a list of the cancelled events """ - events = [] + events: list[Event] = [] + now = datetime.now(cfg.TIME_ZONE) for event in cls.events.values(): - if event.date - datetime.now() < threshold: - if event.is_empty(): - events.append(event) + if ( + event.date.astimezone(cfg.TIME_ZONE) - now < threshold + and event.is_empty() + ): + events.append(event) + # Using a separate loop to prevent modifying cls.events while looping # over it for event in events: diff --git a/src/operationbot/eventListener.py b/src/operationbot/eventListener.py index 05c816b..e2adf12 100644 --- a/src/operationbot/eventListener.py +++ b/src/operationbot/eventListener.py @@ -1,5 +1,4 @@ import importlib -import logging from datetime import datetime, timedelta from typing import Optional, Union, cast @@ -10,7 +9,6 @@ from operationbot import config as cfg from operationbot import messageFunctions as msgFnc -from operationbot import tasks from operationbot.bot import OperationBot from operationbot.errors import EventNotFound, RoleNotFound, RoleTaken, UnknownEmoji from operationbot.event import Event @@ -43,14 +41,7 @@ async def on_ready(self): print(msg) await commandchannel.send(msg) await self.bot.change_presence(activity=Game(name=cfg.GAME)) - if not self.bot.archive_task: - self.bot.archive_task = self.bot.loop.create_task( - tasks.archive_past_events(self.bot) - ) - else: - logging.info( - "The archive_past_events task is already running, not starting again" - ) + self.bot.start_tasks() print("Logged in as", self.bot.user.name, self.bot.user.id) self.bot.processing = False diff --git a/src/operationbot/messageFunctions.py b/src/operationbot/messageFunctions.py index 60a11ba..0739d39 100644 --- a/src/operationbot/messageFunctions.py +++ b/src/operationbot/messageFunctions.py @@ -1,19 +1,20 @@ import logging from datetime import timedelta -from typing import Dict, List, Union, cast +from typing import TYPE_CHECKING, Dict, List, Union, cast from discord import Emoji, Message, NotFound, TextChannel from discord.abc import Messageable from discord.embeds import Embed from discord.errors import Forbidden, HTTPException -from operationbot.bot import OperationBot +if TYPE_CHECKING: + from operationbot.bot import OperationBot from operationbot.errors import EventUpdateFailed, MessageNotFound, RoleError from operationbot.event import Event from operationbot.eventDatabase import EventDatabase -async def getEventMessage(event: Event, bot: OperationBot, archived=False) -> Message: +async def getEventMessage(event: Event, bot: "OperationBot", archived=False) -> Message: """Get a message related to an event.""" if archived: channel = bot.eventarchivechannel @@ -28,7 +29,7 @@ async def getEventMessage(event: Event, bot: OperationBot, archived=False) -> Me ) from e -async def update_event_message(bot: OperationBot, event: Event): +async def update_event_message(bot: "OperationBot", event: Event): """Update event embed and reactions.""" try: message = await getEventMessage(event, bot) @@ -38,7 +39,7 @@ async def update_event_message(bot: OperationBot, event: Event): await updateReactions(event, message=message) -async def sortEventMessages(bot: OperationBot): +async def sortEventMessages(bot: "OperationBot"): """Sort event messages according to the event database. Saves the database to disk after sorting. @@ -177,7 +178,7 @@ def messageEventId(message: Message) -> int: return int(cast(str, footer.text).split(" ")[-1]) -async def syncMessages(events: Dict[int, Event], bot: OperationBot): +async def syncMessages(events: Dict[int, Event], bot: "OperationBot"): """Sync event messages with the event database. Saves the database to disk after syncing. @@ -229,7 +230,7 @@ async def syncMessages(events: Dict[int, Event], bot: OperationBot): async def archive_single_event( - event: Event, target: Messageable, bot: OperationBot + event: Event, target: Messageable, bot: "OperationBot" ) -> None: """Archive a single event.""" # Archive event and export @@ -246,7 +247,7 @@ async def archive_single_event( async def archive_past_events( - bot: OperationBot, + bot: "OperationBot", target: Messageable | None = None, delta: timedelta = timedelta(), ) -> list[Event]: @@ -268,7 +269,7 @@ async def archive_past_events( async def cancel_empty_events( - bot: OperationBot, + bot: "OperationBot", target: Messageable | None = None, threshold: timedelta = timedelta(), ) -> list[Event]: diff --git a/src/operationbot/tasks.py b/src/operationbot/tasks.py index 8ab1c4b..dfbac03 100644 --- a/src/operationbot/tasks.py +++ b/src/operationbot/tasks.py @@ -1,32 +1,45 @@ import asyncio import logging +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from operationbot.bot import OperationBot import operationbot.config as cfg import operationbot.messageFunctions as msgFnc -from operationbot.bot import OperationBot +# OperationBot: TypeAlias = operationbot.bot.OperationBot -async def archive_past_events(bot: OperationBot): + +async def archive_past_events(bot: "OperationBot"): if not cfg.ARCHIVE_AUTOMATICALLY: logging.info("Automatic archival disabled, skipping archive_past_events task") return - logging.info("Starting archive_past_events task") + logging.info("Started archive_past_events task") while True: await msgFnc.archive_past_events(bot, delta=cfg.ARCHIVE_AFTER_TIME) await asyncio.sleep(cfg.ARCHIVE_CHECK_DELAY) -async def cancel_empty_events(bot: OperationBot): +async def cancel_empty_events(bot: "OperationBot"): if not cfg.CANCEL_AUTOMATICALLY: logging.info( "Automatic cancellation disabled, skipping cancel_empty_events task" ) return - logging.info("Starting cancel_empty_events task") + logging.info("Started cancel_empty_events task") while True: - await msgFnc.cancel_empty_events(bot, threshold=cfg.CANCEL_THRESHOLD) + try: + await msgFnc.cancel_empty_events(bot, threshold=cfg.CANCEL_THRESHOLD) + except Exception as e: + logging.error(e) await asyncio.sleep(cfg.CANCEL_CHECK_DELAY) + + +ALL_TASKS = { + "Archive past events": archive_past_events, + "Cancel empty events": cancel_empty_events, +} From 2ab2d6c8be6aea02cc841988be760eaa0f4d2df1 Mon Sep 17 00:00:00 2001 From: Sami Laine Date: Wed, 11 Dec 2024 16:36:54 +0000 Subject: [PATCH 3/4] Disable docs build Has been broken for a while and is mostly unused --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 0f85823..8b918ea 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ DOCKER_REGISTRY= APP_VERSION=$(shell poetry version --short) .PHONY: all -all: install lint test docs build install-hooks +all: install lint test build install-hooks .PHONY: install install: From d699b5343129280ff495f7a4b72a6177089c5e8c Mon Sep 17 00:00:00 2001 From: Sami Laine Date: Sat, 1 Feb 2025 09:15:09 +0000 Subject: [PATCH 4/4] Update changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24dcfd9..fa7997c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,16 @@ The project uses semantic versioning (see [SemVer](https://semver.org)). ## [Unreleased] +### Added + +- Automatic cancellation of empty events + ## v0.49.0 - 2025-01-23 +### Added + +- Support for Reforger events + ## v0.48.1 - 2025-01-01 ### Changed