Skip to content

Commit

Permalink
Merge pull request #116 from zeusops/feature/autocancel
Browse files Browse the repository at this point in the history
Automatically cancel empty events
  • Loading branch information
Gehock authored Feb 1, 2025
2 parents 2b67941 + d699b53 commit d344950
Show file tree
Hide file tree
Showing 11 changed files with 233 additions and 34 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 10 additions & 2 deletions src/operationbot/bot.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
"""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
from discord.ext.commands import Bot, DefaultHelpCommand
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

Expand Down Expand Up @@ -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()
Expand All @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions src/operationbot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@
"REFORGER_SIDEOP": 0x99CB48,
"REFORGER_DLC": 0x00FF00,
"REFORGER_DLC_SIDEOP": 0x00FFFF,
"CANCELLED": 0x808080,
}

DLC_TERRAINS = {
Expand All @@ -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]
17 changes: 17 additions & 0 deletions src/operationbot/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def __init__(
self._dlc: str = ""
self.overhaul = ""
self.embed_hash = ""
self.cancelled = False

if platoon_size is None:
if sideop:
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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}")
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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))
Expand Down
34 changes: 34 additions & 0 deletions src/operationbot/eventDatabase.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -198,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
Expand All @@ -207,6 +213,34 @@ 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: list[Event] = []

now = datetime.now(cfg.TIME_ZONE)
for event in cls.events.values():
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:
cls.cancel_event(event)

cls.toJson(archive=False)

return events

@classmethod
def toJson(cls, archive=False):
# TODO: rename to saveDatabase
Expand Down
11 changes: 1 addition & 10 deletions src/operationbot/eventListener.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import importlib
import logging
from datetime import datetime, timedelta
from typing import Optional, Union, cast

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
64 changes: 46 additions & 18 deletions src/operationbot/messageFunctions.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -28,7 +29,17 @@ async def getEventMessage(event: Event, bot: OperationBot, archived=False) -> Me
) from e


async def sortEventMessages(bot: OperationBot):
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.
Saves the database to disk after sorting.
Expand All @@ -40,12 +51,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()


Expand Down Expand Up @@ -172,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.
Expand Down Expand Up @@ -224,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
Expand All @@ -241,22 +247,44 @@ async def archive_single_event(


async def archive_past_events(
bot: OperationBot,
bot: "OperationBot",
target: Messageable | None = None,
delta: timedelta = timedelta(),
) -> list[Event]:
"""Archive all 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
Loading

0 comments on commit d344950

Please sign in to comment.