Skip to content

Commit 2dd9fbd

Browse files
authored
Merge branch 'main' into fix/dmrelay
2 parents 0b4e488 + 07fb4ed commit 2dd9fbd

File tree

9 files changed

+490
-345
lines changed

9 files changed

+490
-345
lines changed

Pipfile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ aiodns = "~=2.0"
99
aiohttp = "~=3.7"
1010
aioping = "~=0.3.1"
1111
aioredis = "~=1.3.1"
12+
arrow = "~=1.0.3"
1213
"async-rediscache[fakeredis]" = "~=0.1.2"
1314
beautifulsoup4 = "~=4.9"
1415
colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"}
1516
coloredlogs = "~=14.0"
1617
deepdiff = "~=4.0"
1718
"discord.py" = "~=1.6.0"
19+
emoji = "~=0.6"
1820
feedparser = "~=5.2"
1921
fuzzywuzzy = "~=0.17"
2022
lxml = "~=4.4"
@@ -26,11 +28,10 @@ requests = "~=2.22"
2628
sentry-sdk = "~=0.19"
2729
sphinx = "~=2.2"
2830
statsd = "~=3.3"
29-
arrow = "~=0.17"
30-
emoji = "~=0.6"
3131

3232
[dev-packages]
3333
coverage = "~=5.0"
34+
coveralls = "~=2.1"
3435
flake8 = "~=3.8"
3536
flake8-annotations = "~=2.0"
3637
flake8-bugbear = "~=20.1"
@@ -41,7 +42,6 @@ flake8-tidy-imports = "~=4.0"
4142
flake8-todo = "~=0.7"
4243
pep8-naming = "~=0.9"
4344
pre-commit = "~=2.1"
44-
coveralls = "~=2.1"
4545

4646
[requires]
4747
python_version = "3.8"

Pipfile.lock

Lines changed: 333 additions & 264 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

bot/constants.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,8 @@ class HelpChannels(metaclass=YAMLGetter):
590590
enable: bool
591591
claim_minutes: int
592592
cmd_whitelist: List[int]
593-
idle_minutes: int
593+
idle_minutes_claimant: int
594+
idle_minutes_others: int
594595
deleted_idle_minutes: int
595596
max_available: int
596597
max_total_channels: int

bot/exts/help_channels/_caches.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@
88
# RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]]
99
claimants = RedisCache(namespace="HelpChannels.help_channel_claimants")
1010

11+
# Stores the timestamp of the last message from the claimant of a help channel
12+
# RedisCache[discord.TextChannel.id, UtcPosixTimestamp]
13+
claimant_last_message_times = RedisCache(namespace="HelpChannels.claimant_last_message_times")
14+
15+
# This cache maps a help channel to the timestamp of the last non-claimant message.
16+
# This cache being empty for a given help channel indicates the question is unanswered.
17+
# RedisCache[discord.TextChannel.id, UtcPosixTimestamp]
18+
non_claimant_last_message_times = RedisCache(namespace="HelpChannels.non_claimant_last_message_times")
19+
1120
# This cache maps a help channel to original question message in same channel.
1221
# RedisCache[discord.TextChannel.id, discord.Message.id]
1322
question_messages = RedisCache(namespace="HelpChannels.question_messages")
14-
15-
# This cache maps a help channel to whether it has had any
16-
# activity other than the original claimant. True being no other
17-
# activity and False being other activity.
18-
# RedisCache[discord.TextChannel.id, bool]
19-
unanswered = RedisCache(namespace="HelpChannels.unanswered")

bot/exts/help_channels/_channel.py

Lines changed: 73 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import logging
22
import typing as t
3-
from datetime import datetime, timedelta
3+
from datetime import timedelta
4+
from enum import Enum
45

6+
import arrow
57
import discord
8+
from arrow import Arrow
69

710
import bot
811
from bot import constants
@@ -15,6 +18,17 @@
1518
EXCLUDED_CHANNELS = (constants.Channels.cooldown,)
1619

1720

21+
class ClosingReason(Enum):
22+
"""All possible closing reasons for help channels."""
23+
24+
COMMAND = "command"
25+
LATEST_MESSSAGE = "auto.latest_message"
26+
CLAIMANT_TIMEOUT = "auto.claimant_timeout"
27+
OTHER_TIMEOUT = "auto.other_timeout"
28+
DELETED = "auto.deleted"
29+
CLEANUP = "auto.cleanup"
30+
31+
1832
def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[discord.TextChannel]:
1933
"""Yield the text channels of the `category` in an unsorted manner."""
2034
log.trace(f"Getting text channels in the category '{category}' ({category.id}).")
@@ -25,23 +39,68 @@ def get_category_channels(category: discord.CategoryChannel) -> t.Iterable[disco
2539
yield channel
2640

2741

28-
async def get_idle_time(channel: discord.TextChannel) -> t.Optional[int]:
42+
async def get_closing_time(channel: discord.TextChannel, init_done: bool) -> t.Tuple[Arrow, ClosingReason]:
2943
"""
30-
Return the time elapsed, in seconds, since the last message sent in the `channel`.
44+
Return the time at which the given help `channel` should be closed along with the reason.
3145
32-
Return None if the channel has no messages.
46+
`init_done` is True if the cog has finished loading and False otherwise.
47+
48+
The time is calculated as follows:
49+
50+
* If `init_done` is True or the cached time for the claimant's last message is unavailable,
51+
add the configured `idle_minutes_claimant` to the time the most recent message was sent.
52+
* If the help session is empty (see `is_empty`), do the above but with `deleted_idle_minutes`.
53+
* If either of the above is attempted but the channel is completely empty, close the channel
54+
immediately.
55+
* Otherwise, retrieve the times of the claimant's and non-claimant's last messages from the
56+
cache. Add the configured `idle_minutes_claimant` and idle_minutes_others`, respectively, and
57+
choose the time which is furthest in the future.
3358
"""
34-
log.trace(f"Getting the idle time for #{channel} ({channel.id}).")
59+
log.trace(f"Getting the closing time for #{channel} ({channel.id}).")
60+
61+
is_empty = await _message.is_empty(channel)
62+
if is_empty:
63+
idle_minutes_claimant = constants.HelpChannels.deleted_idle_minutes
64+
else:
65+
idle_minutes_claimant = constants.HelpChannels.idle_minutes_claimant
66+
67+
claimant_time = await _caches.claimant_last_message_times.get(channel.id)
68+
69+
# The current session lacks messages, the cog is still starting, or the cache is empty.
70+
if is_empty or not init_done or claimant_time is None:
71+
msg = await _message.get_last_message(channel)
72+
if not msg:
73+
log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages, closing now.")
74+
return Arrow.min, ClosingReason.DELETED
75+
76+
# Use the greatest offset to avoid the possibility of prematurely closing the channel.
77+
time = Arrow.fromdatetime(msg.created_at) + timedelta(minutes=idle_minutes_claimant)
78+
return time, ClosingReason.LATEST_MESSSAGE
79+
80+
claimant_time = Arrow.utcfromtimestamp(claimant_time)
81+
others_time = await _caches.non_claimant_last_message_times.get(channel.id)
82+
83+
if others_time:
84+
others_time = Arrow.utcfromtimestamp(others_time)
85+
else:
86+
# The help session hasn't received any answers (messages from non-claimants) yet.
87+
# Set to min value so it isn't considered when calculating the closing time.
88+
others_time = Arrow.min
3589

36-
msg = await _message.get_last_message(channel)
37-
if not msg:
38-
log.debug(f"No idle time available; #{channel} ({channel.id}) has no messages.")
39-
return None
90+
# Offset the cached times by the configured values.
91+
others_time += timedelta(minutes=constants.HelpChannels.idle_minutes_others)
92+
claimant_time += timedelta(minutes=idle_minutes_claimant)
4093

41-
idle_time = (datetime.utcnow() - msg.created_at).seconds
94+
# Use the time which is the furthest into the future.
95+
if claimant_time >= others_time:
96+
closing_time = claimant_time
97+
reason = ClosingReason.CLAIMANT_TIMEOUT
98+
else:
99+
closing_time = others_time
100+
reason = ClosingReason.OTHER_TIMEOUT
42101

43-
log.trace(f"#{channel} ({channel.id}) has been idle for {idle_time} seconds.")
44-
return idle_time
102+
log.trace(f"#{channel} ({channel.id}) should be closed at {closing_time} due to {reason}.")
103+
return closing_time, reason
45104

46105

47106
async def get_in_use_time(channel_id: int) -> t.Optional[timedelta]:
@@ -50,8 +109,8 @@ async def get_in_use_time(channel_id: int) -> t.Optional[timedelta]:
50109

51110
claimed_timestamp = await _caches.claim_times.get(channel_id)
52111
if claimed_timestamp:
53-
claimed = datetime.utcfromtimestamp(claimed_timestamp)
54-
return datetime.utcnow() - claimed
112+
claimed = Arrow.utcfromtimestamp(claimed_timestamp)
113+
return arrow.utcnow() - claimed
55114

56115

57116
def is_excluded_channel(channel: discord.abc.GuildChannel) -> bool:

bot/exts/help_channels/_cog.py

Lines changed: 35 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
import logging
33
import random
44
import typing as t
5-
from datetime import datetime, timezone
5+
from datetime import timedelta
66
from operator import attrgetter
77

8+
import arrow
89
import discord
910
import discord.abc
1011
from discord.ext import commands
@@ -43,7 +44,9 @@ class HelpChannels(commands.Cog):
4344
In Use Category
4445
4546
* Contains all channels which are occupied by someone needing help
46-
* Channel moves to dormant category after `constants.HelpChannels.idle_minutes` of being idle
47+
* Channel moves to dormant category after
48+
- `constants.HelpChannels.idle_minutes_other` minutes since the last user message, or
49+
- `constants.HelpChannels.idle_minutes_claimant` minutes since the last claimant message.
4750
* Command can prematurely mark a channel as dormant
4851
* Channel claimant is allowed to use the command
4952
* Allowed roles for the command are configurable with `constants.HelpChannels.cmd_whitelist`
@@ -70,7 +73,7 @@ def __init__(self, bot: Bot):
7073
self.channel_queue: asyncio.Queue[discord.TextChannel] = None
7174
self.name_queue: t.Deque[str] = None
7275

73-
self.last_notification: t.Optional[datetime] = None
76+
self.last_notification: t.Optional[arrow.Arrow] = None
7477

7578
# Asyncio stuff
7679
self.queue_tasks: t.List[asyncio.Task] = []
@@ -112,11 +115,13 @@ async def claim_channel(self, message: discord.Message) -> None:
112115

113116
self.bot.stats.incr("help.claimed")
114117

115-
# Must use a timezone-aware datetime to ensure a correct POSIX timestamp.
116-
timestamp = datetime.now(timezone.utc).timestamp()
117-
await _caches.claim_times.set(message.channel.id, timestamp)
118+
# datetime.timestamp() would assume it's local, despite d.py giving a (naïve) UTC time.
119+
timestamp = arrow.Arrow.fromdatetime(message.created_at).timestamp()
118120

119-
await _caches.unanswered.set(message.channel.id, True)
121+
await _caches.claim_times.set(message.channel.id, timestamp)
122+
await _caches.claimant_last_message_times.set(message.channel.id, timestamp)
123+
# Delete to indicate that the help session has yet to receive an answer.
124+
await _caches.non_claimant_last_message_times.delete(message.channel.id)
120125

121126
# Not awaited because it may indefinitely hold the lock while waiting for a channel.
122127
scheduling.create_task(self.move_to_available(), name=f"help_claim_{message.id}")
@@ -187,7 +192,7 @@ async def close_command(self, ctx: commands.Context) -> None:
187192
# Don't use a discord.py check because the check needs to fail silently.
188193
if await self.close_check(ctx):
189194
log.info(f"Close command invoked by {ctx.author} in #{ctx.channel}.")
190-
await self.unclaim_channel(ctx.channel, is_auto=False)
195+
await self.unclaim_channel(ctx.channel, closed_on=_channel.ClosingReason.COMMAND)
191196

192197
async def get_available_candidate(self) -> discord.TextChannel:
193198
"""
@@ -233,7 +238,7 @@ async def init_available(self) -> None:
233238
elif missing < 0:
234239
log.trace(f"Moving {abs(missing)} superfluous available channels over to the Dormant category.")
235240
for channel in channels[:abs(missing)]:
236-
await self.unclaim_channel(channel)
241+
await self.unclaim_channel(channel, closed_on=_channel.ClosingReason.CLEANUP)
237242

238243
async def init_categories(self) -> None:
239244
"""Get the help category objects. Remove the cog if retrieval fails."""
@@ -293,26 +298,23 @@ async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool =
293298
"""
294299
log.trace(f"Handling in-use channel #{channel} ({channel.id}).")
295300

296-
if not await _message.is_empty(channel):
297-
idle_seconds = constants.HelpChannels.idle_minutes * 60
298-
else:
299-
idle_seconds = constants.HelpChannels.deleted_idle_minutes * 60
300-
301-
time_elapsed = await _channel.get_idle_time(channel)
301+
closing_time, closed_on = await _channel.get_closing_time(channel, self.init_task.done())
302302

303-
if time_elapsed is None or time_elapsed >= idle_seconds:
303+
# Closing time is in the past.
304+
# Add 1 second due to POSIX timestamps being lower resolution than datetime objects.
305+
if closing_time < (arrow.utcnow() + timedelta(seconds=1)):
304306
log.info(
305-
f"#{channel} ({channel.id}) is idle longer than {idle_seconds} seconds "
306-
f"and will be made dormant."
307+
f"#{channel} ({channel.id}) is idle past {closing_time} "
308+
f"and will be made dormant. Reason: {closed_on.value}"
307309
)
308310

309-
await self.unclaim_channel(channel)
311+
await self.unclaim_channel(channel, closed_on=closed_on)
310312
else:
311313
# Cancel the existing task, if any.
312314
if has_task:
313315
self.scheduler.cancel(channel.id)
314316

315-
delay = idle_seconds - time_elapsed
317+
delay = (closing_time - arrow.utcnow()).seconds
316318
log.info(
317319
f"#{channel} ({channel.id}) is still active; "
318320
f"scheduling it to be moved after {delay} seconds."
@@ -356,15 +358,15 @@ async def move_to_dormant(self, channel: discord.TextChannel) -> None:
356358
_stats.report_counts()
357359

358360
@lock.lock_arg(f"{NAMESPACE}.unclaim", "channel")
359-
async def unclaim_channel(self, channel: discord.TextChannel, *, is_auto: bool = True) -> None:
361+
async def unclaim_channel(self, channel: discord.TextChannel, *, closed_on: _channel.ClosingReason) -> None:
360362
"""
361363
Unclaim an in-use help `channel` to make it dormant.
362364
363365
Unpin the claimant's question message and move the channel to the Dormant category.
364366
Remove the cooldown role from the channel claimant if they have no other channels claimed.
365367
Cancel the scheduled cooldown role removal task.
366368
367-
Set `is_auto` to True if the channel was automatically closed or False if manually closed.
369+
`closed_on` is the reason that the channel was closed. See _channel.ClosingReason for possible values.
368370
"""
369371
claimant_id = await _caches.claimants.get(channel.id)
370372
_unclaim_channel = self._unclaim_channel
@@ -375,9 +377,14 @@ async def unclaim_channel(self, channel: discord.TextChannel, *, is_auto: bool =
375377
decorator = lock.lock_arg(f"{NAMESPACE}.unclaim", "claimant_id", wait=True)
376378
_unclaim_channel = decorator(_unclaim_channel)
377379

378-
return await _unclaim_channel(channel, claimant_id, is_auto)
380+
return await _unclaim_channel(channel, claimant_id, closed_on)
379381

380-
async def _unclaim_channel(self, channel: discord.TextChannel, claimant_id: int, is_auto: bool) -> None:
382+
async def _unclaim_channel(
383+
self,
384+
channel: discord.TextChannel,
385+
claimant_id: int,
386+
closed_on: _channel.ClosingReason
387+
) -> None:
381388
"""Actual implementation of `unclaim_channel`. See that for full documentation."""
382389
await _caches.claimants.delete(channel.id)
383390

@@ -393,12 +400,12 @@ async def _unclaim_channel(self, channel: discord.TextChannel, claimant_id: int,
393400
await _cooldown.remove_cooldown_role(claimant)
394401

395402
await _message.unpin(channel)
396-
await _stats.report_complete_session(channel.id, is_auto)
403+
await _stats.report_complete_session(channel.id, closed_on)
397404
await self.move_to_dormant(channel)
398405

399406
# Cancel the task that makes the channel dormant only if called by the close command.
400407
# In other cases, the task is either already done or not-existent.
401-
if not is_auto:
408+
if closed_on == _channel.ClosingReason.COMMAND:
402409
self.scheduler.cancel(channel.id)
403410

404411
async def move_to_in_use(self, channel: discord.TextChannel) -> None:
@@ -410,7 +417,7 @@ async def move_to_in_use(self, channel: discord.TextChannel) -> None:
410417
category_id=constants.Categories.help_in_use,
411418
)
412419

413-
timeout = constants.HelpChannels.idle_minutes * 60
420+
timeout = constants.HelpChannels.idle_minutes_claimant * 60
414421

415422
log.trace(f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec.")
416423
self.scheduler.schedule_later(timeout, channel.id, self.move_idle_channel(channel))
@@ -428,7 +435,7 @@ async def on_message(self, message: discord.Message) -> None:
428435
if not _channel.is_excluded_channel(message.channel):
429436
await self.claim_channel(message)
430437
else:
431-
await _message.check_for_answer(message)
438+
await _message.update_message_caches(message)
432439

433440
@commands.Cog.listener()
434441
async def on_message_delete(self, msg: discord.Message) -> None:

0 commit comments

Comments
 (0)