From 89cd64c71f1f6ffd1c191b9a367aa252a4de0f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BB=8Bnh=20Ng=E1=BB=8Dc=20C=C3=A1c?= Date: Tue, 2 Jul 2024 16:31:27 +0700 Subject: [PATCH 1/4] Added 'Max Cap Timeout' for help channels Added a feature where a channel cannot stay open past a specified amount of time. The default value for cap is 3 days, but may be changed by configuration. The command for configuration is '.config clopen timeout_cap [duration]' This is tested locally with different amount of times ranging from a few minutes to an hour. I did not test for larger values since I was impatient. But it should still work. More specifications: Upon closing, a reason for closing will be given "Channel closed due to maximum timeout reached!". The user is not however pinged as this is an embedded message. When closed, the channel will be archived (moved to hidden help channel category in the server). --- ...94377eea1aacbc54e8179ca19edc966343fb15.sql | 4 +++ plugins/clopen.py | 33 +++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 migrations/plugins.clopen-b25ab226ad95b4f90af72b7b29ec81ed6c6be5fa-4f94377eea1aacbc54e8179ca19edc966343fb15.sql diff --git a/migrations/plugins.clopen-b25ab226ad95b4f90af72b7b29ec81ed6c6be5fa-4f94377eea1aacbc54e8179ca19edc966343fb15.sql b/migrations/plugins.clopen-b25ab226ad95b4f90af72b7b29ec81ed6c6be5fa-4f94377eea1aacbc54e8179ca19edc966343fb15.sql new file mode 100644 index 0000000..7e262a4 --- /dev/null +++ b/migrations/plugins.clopen-b25ab226ad95b4f90af72b7b29ec81ed6c6be5fa-4f94377eea1aacbc54e8179ca19edc966343fb15.sql @@ -0,0 +1,4 @@ +ALTER TABLE clopen.channels ADD COLUMN max_expiry TIMESTAMP; +ALTER TABLE clopen.guilds +ADD COLUMN timeout_cap INTERVAL NOT NULL +DEFAULT INTERVAL '3 DAY'; diff --git a/plugins/clopen.py b/plugins/clopen.py index 5b3bd41..e207469 100644 --- a/plugins/clopen.py +++ b/plugins/clopen.py @@ -125,6 +125,10 @@ def prompt_message(mention: int) -> str: return format("{!m} Has your question been resolved?", mention) +def timeout_cap_reached_message(mention: int) -> str: + return format("{!m} Channel closed due to maximum timeout reached!", mention) + + registry = sqlalchemy.orm.registry() sessionmaker = async_sessionmaker(util.db.engine, expire_on_commit=False) logger = logging.getLogger(__name__) @@ -143,6 +147,8 @@ class GuildConfig: timeout: Mapped[timedelta] = mapped_column(INTERVAL, nullable=False) # How long initially until the channel becomes pending for closure after the owner talks owner_timeout: Mapped[timedelta] = mapped_column(INTERVAL, nullable=False) + # The maximum duration that a channel keeps open + timeout_cap: Mapped[timedelta] = mapped_column(INTERVAL, nullable=False) # Acceptable minimum number of channels in the available category at any time min_avail: Mapped[int] = mapped_column(BigInteger, nullable=False) # Acceptable maximum number of channels in the available category at any time @@ -174,6 +180,7 @@ def __init__( hidden_category_id: int, timeout: timedelta, owner_timeout: timedelta, + timeout_cap: timedelta, min_avail: int, max_avail: int, max_channels: int, @@ -215,6 +222,8 @@ class Channel: extension: Mapped[int] = mapped_column(BigInteger, nullable=False) # When to transition to the respective next state expiry: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP) + # The maximum amount of time a channel can be kept open + max_expiry: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP) guild: Mapped[GuildConfig] = relationship(GuildConfig, lazy="joined") @@ -232,6 +241,7 @@ def __init__( prompt_id: Optional[int] = ..., op_id: Optional[int] = ..., expiry: Optional[datetime] = ..., + max_expiry: Optional[datetime] = ..., ) -> None: ... @@ -265,7 +275,9 @@ async def scheduler_task() -> None: for channel in config.channels: async with channel_locks[channel.id]: if channel.state == ChannelState.USED and channel.expiry is not None: - if channel.expiry < datetime.utcnow(): + if channel.max_expiry < datetime.utcnow(): + await close(session, channel, timeout_cap_reached_message(channel.owner_id), reopen=False) + elif channel.expiry < datetime.utcnow(): await make_pending(session, channel) elif min_next is None or channel.expiry < min_next: min_next = channel.expiry @@ -275,7 +287,9 @@ async def scheduler_task() -> None: elif min_next is None or channel.expiry < min_next: min_next = channel.expiry elif channel.state == ChannelState.CLOSED: - if channel.expiry is None or channel.expiry < datetime.utcnow(): + if channel.max_expiry < datetime.utcnow(): + await make_hidden(session, channel) + elif channel.expiry is None or channel.expiry < datetime.utcnow(): if ( sum(channel.state == ChannelState.AVAILABLE for channel in config.channels) >= config.max_avail @@ -310,6 +324,7 @@ async def init() -> None: hidden_category_id=cast(int, conf.hidden_category), timeout=timedelta(seconds=cast(int, conf.timeout)), owner_timeout=timedelta(seconds=cast(int, conf.owner_timeout)), + timeout_cap=timedelta(seconds=cast(int, conf.timeout_cap)), min_avail=cast(int, conf.min_avail), max_avail=cast(int, conf.max_avail), max_channels=cast(int, conf.max_channels), @@ -426,6 +441,7 @@ async def occupy(session: AsyncSession, channel: Channel, msg_id: int, author: U channel.op_id = msg_id channel.extension = 1 channel.expiry = datetime.utcnow() + channel.guild.owner_timeout + channel.max_expiry = datetime.utcnow() + channel.guild.timeout_cap await session.commit() await enact_occupied(conf, chan, author, op_id=msg_id, old_op_id=old_op_id) scheduler_task.run_coalesced(0) @@ -1078,6 +1094,7 @@ async def config_new( hidden_category_id=hidden_category.id, timeout=timedelta(seconds=60), owner_timeout=timedelta(seconds=60), + timeout_cap=timedelta(days=3), min_avail=1, max_avail=1, max_channels=0, @@ -1160,6 +1177,18 @@ async def config_owner_timeout(ctx: GuildContext, duration: Optional[DurationCon await ctx.send("\u2705") +@config.command("timeout_cap") +async def config_owner_timeout(ctx: GuildContext, duration: Optional[DurationConverter]) -> None: + async with sessionmaker() as session: + conf = await get_conf(session, ctx) + if duration is None: + await ctx.send(str(conf.timeout_cap)) + else: + conf.timeout_cap = duration + await session.commit() + await ctx.send("\u2705") + + @config.command("min_avail") async def config_min_avail(ctx: GuildContext, number: Optional[int]) -> None: async with sessionmaker() as session: From f74cac944fe238de28d757b6dc8b008c37547ca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BB=8Bnh=20Ng=E1=BB=8Dc=20C=C3=A1c?= Date: Wed, 3 Jul 2024 17:04:30 +0700 Subject: [PATCH 2/4] Update type-checking with pyright and condition checking Forgot to run pyright so ran it and updated with type-checking. Also forgot the case where a channel is made pending right before the timeout_cap is reached. --- plugins/clopen.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/plugins/clopen.py b/plugins/clopen.py index e207469..89155d0 100644 --- a/plugins/clopen.py +++ b/plugins/clopen.py @@ -274,19 +274,31 @@ async def scheduler_task() -> None: for config in configs: for channel in config.channels: async with channel_locks[channel.id]: - if channel.state == ChannelState.USED and channel.expiry is not None: + if ( + channel.state == ChannelState.USED + and channel.expiry is not None + and channel.max_expiry is not None + ): if channel.max_expiry < datetime.utcnow(): + assert channel.owner_id is not None await close(session, channel, timeout_cap_reached_message(channel.owner_id), reopen=False) elif channel.expiry < datetime.utcnow(): await make_pending(session, channel) elif min_next is None or channel.expiry < min_next: min_next = channel.expiry - elif channel.state == ChannelState.PENDING and channel.expiry is not None: + elif ( + channel.state == ChannelState.PENDING + and channel.expiry is not None + and channel.max_expiry is not None + ): + if channel.max_expiry < datetime.utcnow(): + assert channel.owner_id is not None + await close(session, channel, timeout_cap_reached_message(channel.owner_id), reopen=False) if channel.expiry < datetime.utcnow(): await close(session, channel, "Closed due to timeout") elif min_next is None or channel.expiry < min_next: min_next = channel.expiry - elif channel.state == ChannelState.CLOSED: + elif channel.state == ChannelState.CLOSED and channel.max_expiry is not None: if channel.max_expiry < datetime.utcnow(): await make_hidden(session, channel) elif channel.expiry is None or channel.expiry < datetime.utcnow(): @@ -338,6 +350,7 @@ async def init() -> None: session.add(guild) for i, id in enumerate(cast(List[int], conf.channels), start=1): expiry = cast(Optional[float], conf[id, "expiry"]) + max_expiry = cast(Optional[float], conf[id, "max_expiry"]) session.add( Channel( guild_id=guild_id, @@ -349,6 +362,7 @@ async def init() -> None: prompt_id=cast(Optional[int], conf[id, "prompt_id"]), op_id=cast(Optional[int], conf[id, "op_id"]), expiry=datetime.utcfromtimestamp(expiry) if expiry is not None else None, + max_expiry=datetime.utcfromtimestamp(max_expiry) if max_expiry is not None else None ) ) await session.commit() @@ -1178,7 +1192,7 @@ async def config_owner_timeout(ctx: GuildContext, duration: Optional[DurationCon @config.command("timeout_cap") -async def config_owner_timeout(ctx: GuildContext, duration: Optional[DurationConverter]) -> None: +async def config_timeout_cap(ctx: GuildContext, duration: Optional[DurationConverter]) -> None: async with sessionmaker() as session: conf = await get_conf(session, ctx) if duration is None: From c222e9017f870f4d6c4c243c92b135e3b51a2406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BB=8Bnh=20Ng=E1=BB=8Dc=20C=C3=A1c?= Date: Wed, 3 Jul 2024 18:09:51 +0700 Subject: [PATCH 3/4] Refactored code and fixed condition checking Refactored the code for closing channel due to maximum timeout reached. Condensed the whole procedure of closing a channel due to max timeout reached into a function called `timeout_cap_close_procedure` Fixed condition checking: Fixed the condition checking in `scheduler_task`. The structure should be if-elif instead of if-if when checking conditions for max_expiry and expiry. --- plugins/clopen.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/plugins/clopen.py b/plugins/clopen.py index 89155d0..9077077 100644 --- a/plugins/clopen.py +++ b/plugins/clopen.py @@ -125,10 +125,6 @@ def prompt_message(mention: int) -> str: return format("{!m} Has your question been resolved?", mention) -def timeout_cap_reached_message(mention: int) -> str: - return format("{!m} Channel closed due to maximum timeout reached!", mention) - - registry = sqlalchemy.orm.registry() sessionmaker = async_sessionmaker(util.db.engine, expire_on_commit=False) logger = logging.getLogger(__name__) @@ -280,8 +276,7 @@ async def scheduler_task() -> None: and channel.max_expiry is not None ): if channel.max_expiry < datetime.utcnow(): - assert channel.owner_id is not None - await close(session, channel, timeout_cap_reached_message(channel.owner_id), reopen=False) + await timeout_cap_close_procedure(session, channel) elif channel.expiry < datetime.utcnow(): await make_pending(session, channel) elif min_next is None or channel.expiry < min_next: @@ -292,9 +287,8 @@ async def scheduler_task() -> None: and channel.max_expiry is not None ): if channel.max_expiry < datetime.utcnow(): - assert channel.owner_id is not None - await close(session, channel, timeout_cap_reached_message(channel.owner_id), reopen=False) - if channel.expiry < datetime.utcnow(): + await timeout_cap_close_procedure(session, channel) + elif channel.expiry < datetime.utcnow(): await close(session, channel, "Closed due to timeout") elif min_next is None or channel.expiry < min_next: min_next = channel.expiry @@ -537,6 +531,12 @@ async def close(session: AsyncSession, channel: Channel, reason: str, *, reopen: scheduler_task.run_coalesced(0) +async def timeout_cap_close_procedure(session: AsyncSession, channel: Channel) -> None: + assert channel.owner_id is not None + close_reason = "{!m} Channel closed due to maximum timeout reached!".format(channel.owner_id) + await close(session, channel, close_reason, reopen=False) + + async def make_available(session: AsyncSession, channel: Channel) -> None: logger.debug("Making {} available".format(channel.id)) assert isinstance(chan := client.get_channel(channel.id), TextChannel) From 935c4e874c26422d221594d704872cc07ac9eeb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BB=8Bnh=20Ng=E1=BB=8Dc=20C=C3=A1c?= Date: Mon, 12 Aug 2024 14:10:41 +0700 Subject: [PATCH 4/4] Updated README and close procedure README: added instructions for configuring the maximum timeout (timeout_cap) Close procedure: Added pinging user after closing the channel. I thought it might be unnecessary. But maybe this helps the owner find their channel more easily,. --- README.md | 1 + plugins/clopen.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/README.md b/README.md index d4a81a2..82627f8 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,7 @@ Config: - `config clopen hidden [category]` -- configure where the "hidden" channels are placed. - `config clopen owner_timeout [duration]` -- configure how long (initially) since the last message by the owner before the owner is prompted about closure, and how long until the channel is automatically closed if there was no response. - `config clopen timeout [duration]` -- configure how long since the last message by anyone else before the owner is prompted about closure. +- `config clopen timeout_cap [duration]` -- configure how long channels can stay open for each occupying session - `config clopen min_avail [number]` -- configure how many channels minimum should be "available". If not enough channels are available, channels may be unhidden, or new channels may be created. - `config clopen max_avail [number]` -- configure how many channels maximum should be "available". If too many channels are available, some may be hidden. - `config clopen max_channels [number]` -- configure the max number of channels that can be created. This number should not exceed 50, as is is impossible to place more than 50 channels in a category. diff --git a/plugins/clopen.py b/plugins/clopen.py index 9077077..2bc7155 100644 --- a/plugins/clopen.py +++ b/plugins/clopen.py @@ -532,8 +532,11 @@ async def close(session: AsyncSession, channel: Channel, reason: str, *, reopen: async def timeout_cap_close_procedure(session: AsyncSession, channel: Channel) -> None: + assert isinstance(chan := client.get_channel(channel.id), TextChannel) assert channel.owner_id is not None close_reason = "{!m} Channel closed due to maximum timeout reached!".format(channel.owner_id) + # ping owner before closing + await chan.send("{!m}".format(channel.owner_id)) await close(session, channel, close_reason, reopen=False)