timer-bot 1.10__tar.gz → 1.11__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {timer_bot-1.10 → timer_bot-1.11}/PKG-INFO +9 -14
- timer_bot-1.11/pyproject.toml +48 -0
- timer_bot-1.10/pyproject.toml → timer_bot-1.11/pyproject.toml.orig +17 -15
- timer_bot-1.11/src/timer_bot/__init__.py +426 -0
- timer_bot-1.10/CHANGELOG.rst +0 -137
- timer_bot-1.10/MANIFEST.in +0 -7
- timer_bot-1.10/setup.cfg +0 -4
- timer_bot-1.10/src/__init__.py +0 -0
- timer_bot-1.10/src/timer_bot.py +0 -583
- timer_bot-1.10/timer_bot.egg-info/SOURCES.txt +0 -7
- {timer_bot-1.10 → timer_bot-1.11}/LICENSE +0 -0
- {timer_bot-1.10 → timer_bot-1.11}/README.md +0 -0
|
@@ -1,28 +1,23 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: timer-bot
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.11
|
|
4
4
|
Summary: Discord Timer Bot
|
|
5
|
-
Author-email: Lionel Panhaleux <lionel.panhaleux+timer@gmail.com>
|
|
6
|
-
Project-URL: Repository, https://github.com/lionel-panhaleux/timer-bot
|
|
7
5
|
Keywords: Discord,timer
|
|
6
|
+
Author: Lionel Panhaleux
|
|
7
|
+
Author-email: Lionel Panhaleux <lionel.panhaleux+timer@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
8
10
|
Classifier: Programming Language :: Python :: 3
|
|
9
|
-
Classifier: Programming Language :: Python :: 3.
|
|
10
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
11
12
|
Classifier: Development Status :: 5 - Production/Stable
|
|
12
13
|
Classifier: Intended Audience :: Other Audience
|
|
13
14
|
Classifier: Natural Language :: English
|
|
14
15
|
Classifier: Operating System :: OS Independent
|
|
15
16
|
Classifier: Environment :: Web Environment
|
|
16
|
-
Requires-
|
|
17
|
+
Requires-Dist: hikari>=2.6,<3
|
|
18
|
+
Requires-Python: >=3.13
|
|
19
|
+
Project-URL: Repository, https://github.com/lionel-panhaleux/timer-bot
|
|
17
20
|
Description-Content-Type: text/markdown
|
|
18
|
-
License-File: LICENSE
|
|
19
|
-
Requires-Dist: discord-py-interactions>=5.14
|
|
20
|
-
Requires-Dist: uvloop>=0.21
|
|
21
|
-
Provides-Extra: dev
|
|
22
|
-
Requires-Dist: black; extra == "dev"
|
|
23
|
-
Requires-Dist: ruff; extra == "dev"
|
|
24
|
-
Requires-Dist: zest.releaser[recommended]; extra == "dev"
|
|
25
|
-
Dynamic: license-file
|
|
26
21
|
|
|
27
22
|
# timer
|
|
28
23
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.12.13,<0.13"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "timer-bot"
|
|
7
|
+
version = "1.11"
|
|
8
|
+
description = "Discord Timer Bot"
|
|
9
|
+
keywords = [
|
|
10
|
+
"Discord",
|
|
11
|
+
"timer",
|
|
12
|
+
]
|
|
13
|
+
readme = "README.md"
|
|
14
|
+
license = "MIT"
|
|
15
|
+
license-files = ["LICENSE"]
|
|
16
|
+
requires-python = ">=3.13"
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Development Status :: 5 - Production/Stable",
|
|
21
|
+
"Intended Audience :: Other Audience",
|
|
22
|
+
"Natural Language :: English",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Environment :: Web Environment",
|
|
25
|
+
]
|
|
26
|
+
dependencies = ["hikari>=2.6,<3"]
|
|
27
|
+
|
|
28
|
+
[[project.authors]]
|
|
29
|
+
name = "Lionel Panhaleux"
|
|
30
|
+
email = "lionel.panhaleux+timer@gmail.com"
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
timer-bot = "timer_bot:main"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Repository = "https://github.com/lionel-panhaleux/timer-bot"
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
deploy = ["ansible-core"]
|
|
40
|
+
dev = [
|
|
41
|
+
"pytest",
|
|
42
|
+
"ruff",
|
|
43
|
+
"ty",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
line-length = 100
|
|
48
|
+
target-version = "py313"
|
|
@@ -1,40 +1,42 @@
|
|
|
1
1
|
[build-system]
|
|
2
|
-
requires = ["
|
|
3
|
-
build-backend = "
|
|
2
|
+
requires = ["uv_build>=0.12.13,<0.13"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "timer-bot"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.11"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name = "Lionel Panhaleux", email = "lionel.panhaleux+timer@gmail.com" },
|
|
10
10
|
]
|
|
11
11
|
description = "Discord Timer Bot"
|
|
12
12
|
keywords = ["Discord", "timer"]
|
|
13
13
|
readme = "README.md"
|
|
14
|
-
|
|
14
|
+
license = "MIT"
|
|
15
|
+
license-files = ["LICENSE"]
|
|
16
|
+
requires-python = ">=3.13"
|
|
15
17
|
classifiers = [
|
|
16
18
|
"Programming Language :: Python :: 3",
|
|
17
|
-
"Programming Language :: Python :: 3.
|
|
18
|
-
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
19
20
|
"Development Status :: 5 - Production/Stable",
|
|
20
21
|
"Intended Audience :: Other Audience",
|
|
21
22
|
"Natural Language :: English",
|
|
22
23
|
"Operating System :: OS Independent",
|
|
23
24
|
"Environment :: Web Environment",
|
|
24
25
|
]
|
|
25
|
-
dependencies = [
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
dev = ["black", "ruff", "zest.releaser[recommended]"]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"hikari>=2.6,<3",
|
|
28
|
+
]
|
|
29
29
|
|
|
30
30
|
[project.scripts]
|
|
31
|
-
timer-bot = "
|
|
31
|
+
timer-bot = "timer_bot:main"
|
|
32
32
|
|
|
33
33
|
[project.urls]
|
|
34
34
|
Repository = "https://github.com/lionel-panhaleux/timer-bot"
|
|
35
35
|
|
|
36
|
-
[
|
|
37
|
-
|
|
36
|
+
[dependency-groups]
|
|
37
|
+
deploy = ["ansible-core"]
|
|
38
|
+
dev = ["pytest", "ruff", "ty"]
|
|
38
39
|
|
|
39
|
-
[tool.
|
|
40
|
-
|
|
40
|
+
[tool.ruff]
|
|
41
|
+
line-length = 100
|
|
42
|
+
target-version = "py313"
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import typing
|
|
6
|
+
|
|
7
|
+
import hikari
|
|
8
|
+
import hikari.impl
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger()
|
|
11
|
+
bot = hikari.GatewayBot(
|
|
12
|
+
os.environ["DISCORD_TOKEN"],
|
|
13
|
+
intents=hikari.Intents.NONE,
|
|
14
|
+
logs="DEBUG" if __debug__ else "INFO",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
DEBUG_GUILD = 161406117686149120
|
|
18
|
+
THRESHOLDS = [0, 60, 300, 900, 1800]
|
|
19
|
+
DISPLAY_SECONDS = 5 * 60
|
|
20
|
+
PAUSE_TIMEOUT = 1800
|
|
21
|
+
RUNNING_TIMER_HELP = (
|
|
22
|
+
"- `/timer display` to display it anew\n"
|
|
23
|
+
"- `/timer pause` to pause\n"
|
|
24
|
+
"- `/timer resume` to resume\n"
|
|
25
|
+
"- `/timer stop` to terminate it\n"
|
|
26
|
+
"- `/timer add` to add time to it\n"
|
|
27
|
+
"- `/timer sub` to substract time from it\n"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
NO_TIMER = "No timer running in this channel. Use `/timer start` to start one."
|
|
31
|
+
|
|
32
|
+
type Interaction = hikari.CommandInteraction | hikari.ComponentInteraction
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CommandFailed(Exception):
|
|
36
|
+
"""A refusal of the user's request, answered ephemerally with its content or embed."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self, content: str = "", embed: hikari.UndefinedOr[hikari.Embed] = hikari.UNDEFINED
|
|
40
|
+
):
|
|
41
|
+
super().__init__(content)
|
|
42
|
+
self.content = content
|
|
43
|
+
self.embed = embed
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _now() -> float:
|
|
47
|
+
return asyncio.get_running_loop().time()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def time_str(time: float) -> str:
|
|
51
|
+
seconds = round(time % 60)
|
|
52
|
+
if seconds > 59 or time > DISPLAY_SECONDS:
|
|
53
|
+
seconds = 0
|
|
54
|
+
minutes = round((time - seconds) % 3600 / 60)
|
|
55
|
+
if minutes > 59:
|
|
56
|
+
minutes = 0
|
|
57
|
+
hours = round((time - minutes * 60 - seconds) / 3600)
|
|
58
|
+
if time > 3569:
|
|
59
|
+
return f"{hours}:{minutes:0>2} remaining"
|
|
60
|
+
if time > DISPLAY_SECONDS:
|
|
61
|
+
return f"{minutes} minutes remaining"
|
|
62
|
+
if minutes:
|
|
63
|
+
return f"{minutes}′ {seconds:0>2}″ remaining"
|
|
64
|
+
if time > 0:
|
|
65
|
+
return f"{seconds} seconds remaining"
|
|
66
|
+
return "time!"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Timer:
|
|
70
|
+
"""One countdown, in TIMERS under its channel until run() ends.
|
|
71
|
+
|
|
72
|
+
Once run() is started, commands only change state and set `wake`, and run() alone writes
|
|
73
|
+
to the channel. A command can still land while a write is awaited: state read before an
|
|
74
|
+
await is stale after it.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self, channel_id: hikari.Snowflake, author_id: hikari.Snowflake, time: int, secured: bool
|
|
79
|
+
):
|
|
80
|
+
self.channel_id = channel_id
|
|
81
|
+
self.author_id = author_id
|
|
82
|
+
self.secured = secured
|
|
83
|
+
self.deadline = _now() + time
|
|
84
|
+
self.paused_left: float | None = None
|
|
85
|
+
self.resume_at = 0.0
|
|
86
|
+
self.thresholds: list[int] = []
|
|
87
|
+
self.message_id: hikari.Snowflake | None = None
|
|
88
|
+
self.repost = False
|
|
89
|
+
self.stopped = False
|
|
90
|
+
self.wake = asyncio.Event()
|
|
91
|
+
self.task: asyncio.Task[None] | None = None
|
|
92
|
+
self._set_thresholds()
|
|
93
|
+
|
|
94
|
+
def left(self) -> float:
|
|
95
|
+
if self.paused_left is not None:
|
|
96
|
+
return self.paused_left
|
|
97
|
+
return max(0.0, self.deadline - _now())
|
|
98
|
+
|
|
99
|
+
def _set_thresholds(self) -> None:
|
|
100
|
+
left = self.left()
|
|
101
|
+
self.thresholds = [limit for limit in THRESHOLDS if left > limit or limit == 0]
|
|
102
|
+
self.thresholds += [hour * 3600 for hour in range(1, int(left // 3600) + 1)]
|
|
103
|
+
|
|
104
|
+
def adjust(self, time: int) -> None:
|
|
105
|
+
if self.paused_left is None:
|
|
106
|
+
self.deadline += time
|
|
107
|
+
else:
|
|
108
|
+
self.paused_left = max(0.0, self.paused_left + time)
|
|
109
|
+
self._set_thresholds()
|
|
110
|
+
self.refresh()
|
|
111
|
+
|
|
112
|
+
def pause(self) -> None:
|
|
113
|
+
if self.paused_left is None:
|
|
114
|
+
self.paused_left = self.left()
|
|
115
|
+
self.resume_at = _now() + PAUSE_TIMEOUT
|
|
116
|
+
self.wake.set()
|
|
117
|
+
|
|
118
|
+
def resume(self) -> None:
|
|
119
|
+
if self.paused_left is not None:
|
|
120
|
+
self.deadline = _now() + self.paused_left
|
|
121
|
+
self.paused_left = None
|
|
122
|
+
self.wake.set()
|
|
123
|
+
|
|
124
|
+
def refresh(self) -> None:
|
|
125
|
+
"""Post the embed anew, at the bottom of the channel."""
|
|
126
|
+
self.repost = True
|
|
127
|
+
self.wake.set()
|
|
128
|
+
|
|
129
|
+
def stop(self) -> None:
|
|
130
|
+
self.stopped = True
|
|
131
|
+
self.wake.set()
|
|
132
|
+
|
|
133
|
+
async def run(self) -> None:
|
|
134
|
+
try:
|
|
135
|
+
while True:
|
|
136
|
+
if self.paused_left is not None:
|
|
137
|
+
delay = self.resume_at - _now()
|
|
138
|
+
else:
|
|
139
|
+
left = self.left()
|
|
140
|
+
delay = 1.1 if left < DISPLAY_SECONDS + 30 else 30
|
|
141
|
+
if self.thresholds:
|
|
142
|
+
delay = min(delay, left - self.thresholds[-1])
|
|
143
|
+
with contextlib.suppress(TimeoutError):
|
|
144
|
+
await asyncio.wait_for(self.wake.wait(), delay)
|
|
145
|
+
if self.paused_left is not None and _now() >= self.resume_at:
|
|
146
|
+
self.resume()
|
|
147
|
+
# after resume(), whose wake this pass serves: cleared later, it renders twice
|
|
148
|
+
self.wake.clear()
|
|
149
|
+
if self.stopped:
|
|
150
|
+
if self.message_id:
|
|
151
|
+
await self._delete(self.message_id)
|
|
152
|
+
logger.info("[%s] Timer stopped", self.channel_id)
|
|
153
|
+
if self.left() > 0:
|
|
154
|
+
await bot.rest.create_message(
|
|
155
|
+
self.channel_id, "Stopped with " + time_str(self.left())
|
|
156
|
+
)
|
|
157
|
+
return
|
|
158
|
+
await self.render()
|
|
159
|
+
if self.left() <= 0:
|
|
160
|
+
logger.info("[%s] Timer finished", self.channel_id)
|
|
161
|
+
return
|
|
162
|
+
except hikari.ClientHTTPResponseError as e:
|
|
163
|
+
logger.info("[%s] Timer dropped, Discord refused: %s", self.channel_id, e)
|
|
164
|
+
if self.message_id:
|
|
165
|
+
await self._delete(self.message_id)
|
|
166
|
+
except Exception:
|
|
167
|
+
logger.exception("[%s] Timer crashed", self.channel_id)
|
|
168
|
+
finally:
|
|
169
|
+
if TIMERS.get(self.channel_id) is self:
|
|
170
|
+
del TIMERS[self.channel_id]
|
|
171
|
+
|
|
172
|
+
async def render(self) -> None:
|
|
173
|
+
left = self.left()
|
|
174
|
+
rows: list[hikari.impl.MessageActionRowBuilder] = []
|
|
175
|
+
if left <= 0:
|
|
176
|
+
title = "Timer finished"
|
|
177
|
+
description = "Use `/timer start` to start a new timer."
|
|
178
|
+
else:
|
|
179
|
+
description = "Use the buttons or the `/timer` commands to manipulate the timer."
|
|
180
|
+
row = hikari.impl.MessageActionRowBuilder()
|
|
181
|
+
if self.paused_left is None:
|
|
182
|
+
title = time_str(left)
|
|
183
|
+
row.add_interactive_button(
|
|
184
|
+
hikari.ButtonStyle.PRIMARY, "pause", emoji="⏱", label="Pause"
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
title = "Timer paused: " + time_str(left)
|
|
188
|
+
row.add_interactive_button(
|
|
189
|
+
hikari.ButtonStyle.SUCCESS, "resume", emoji="▶️", label="Resume"
|
|
190
|
+
)
|
|
191
|
+
row.add_interactive_button(hikari.ButtonStyle.DANGER, "stop", emoji="🛑", label="Stop")
|
|
192
|
+
rows = [row]
|
|
193
|
+
embed = hikari.Embed(title=title, description=description)
|
|
194
|
+
if self.repost and self.message_id:
|
|
195
|
+
await self._delete(self.message_id)
|
|
196
|
+
self.message_id = None
|
|
197
|
+
self.repost = False
|
|
198
|
+
if self.message_id:
|
|
199
|
+
try:
|
|
200
|
+
await bot.rest.edit_message(
|
|
201
|
+
self.channel_id, self.message_id, embed=embed, components=rows
|
|
202
|
+
)
|
|
203
|
+
except hikari.ClientHTTPResponseError as e:
|
|
204
|
+
logger.info("[%s] Failed to edit message: %s", self.channel_id, e)
|
|
205
|
+
old_message_id = self.message_id
|
|
206
|
+
message = await bot.rest.create_message(
|
|
207
|
+
self.channel_id, embed=embed, components=rows
|
|
208
|
+
)
|
|
209
|
+
self.message_id = message.id
|
|
210
|
+
await self._delete(old_message_id)
|
|
211
|
+
else:
|
|
212
|
+
message = await bot.rest.create_message(self.channel_id, embed=embed, components=rows)
|
|
213
|
+
self.message_id = message.id
|
|
214
|
+
left = self.left()
|
|
215
|
+
if self.thresholds and self.thresholds[-1] >= left:
|
|
216
|
+
# hikari parses no user mention unless told to: the ping would not notify
|
|
217
|
+
await bot.rest.create_message(
|
|
218
|
+
self.channel_id,
|
|
219
|
+
f"<@{self.author_id}> {time_str(self.thresholds.pop())}",
|
|
220
|
+
user_mentions=[self.author_id],
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
async def _delete(self, message_id: hikari.Snowflake) -> None:
|
|
224
|
+
try:
|
|
225
|
+
await bot.rest.delete_message(self.channel_id, message_id)
|
|
226
|
+
except hikari.ClientHTTPResponseError as e:
|
|
227
|
+
logger.info("[%s] Failed to delete message: %s", self.channel_id, e)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
TIMERS: dict[hikari.Snowflake, Timer] = {}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
async def _answer(interaction: Interaction, content: str = "", **kwargs: typing.Any) -> None:
|
|
234
|
+
await interaction.create_initial_response(
|
|
235
|
+
hikari.ResponseType.MESSAGE_CREATE, content, flags=hikari.MessageFlag.EPHEMERAL, **kwargs
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _timer(interaction: Interaction) -> Timer:
|
|
240
|
+
timer = TIMERS.get(interaction.channel_id)
|
|
241
|
+
if not timer:
|
|
242
|
+
raise CommandFailed(NO_TIMER)
|
|
243
|
+
return timer
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _owned_timer(interaction: Interaction, verb: str) -> Timer:
|
|
247
|
+
timer = _timer(interaction)
|
|
248
|
+
if timer.secured and interaction.user.id != timer.author_id:
|
|
249
|
+
raise CommandFailed(f"This is a secured timer, only the owner can {verb} it.")
|
|
250
|
+
return timer
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
async def timer_start(
|
|
254
|
+
interaction: hikari.CommandInteraction, hours: int, minutes: int = 0, secured: bool = False
|
|
255
|
+
) -> None:
|
|
256
|
+
channel_id = interaction.channel_id
|
|
257
|
+
if channel_id in TIMERS:
|
|
258
|
+
embed = hikari.Embed(title="Timer already running", description=RUNNING_TIMER_HELP)
|
|
259
|
+
raise CommandFailed(embed=embed)
|
|
260
|
+
total_time = hours * 3600 + minutes * 60
|
|
261
|
+
if not total_time:
|
|
262
|
+
embed = hikari.Embed(title="No time", description="Hours and minutes cannot both be zero.")
|
|
263
|
+
raise CommandFailed(embed=embed)
|
|
264
|
+
# claimed before the first await: a start racing this one finds the channel busy
|
|
265
|
+
timer = TIMERS[channel_id] = Timer(channel_id, interaction.user.id, total_time, secured)
|
|
266
|
+
try:
|
|
267
|
+
await _answer(interaction, "Starting Timer")
|
|
268
|
+
logger.info("[%s] Start timer: %sh %smin", channel_id, hours, minutes)
|
|
269
|
+
try:
|
|
270
|
+
await timer.render()
|
|
271
|
+
except hikari.ClientHTTPResponseError as e:
|
|
272
|
+
logger.info("[%s] Failed to start: %s", channel_id, e)
|
|
273
|
+
if timer.message_id:
|
|
274
|
+
await timer._delete(timer.message_id)
|
|
275
|
+
await interaction.edit_initial_response(
|
|
276
|
+
"**Failed to start**\nTimer bot requires permission to send messages"
|
|
277
|
+
)
|
|
278
|
+
return
|
|
279
|
+
timer.task = asyncio.create_task(timer.run())
|
|
280
|
+
finally:
|
|
281
|
+
if timer.task is None:
|
|
282
|
+
del TIMERS[channel_id]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
async def timer_pause(interaction: Interaction) -> None:
|
|
286
|
+
timer = _owned_timer(interaction, "pause")
|
|
287
|
+
timer.pause()
|
|
288
|
+
if interaction.user.id != timer.author_id:
|
|
289
|
+
await interaction.create_initial_response(
|
|
290
|
+
hikari.ResponseType.MESSAGE_CREATE,
|
|
291
|
+
f"<@{timer.author_id}> timer paused by {interaction.user.mention}",
|
|
292
|
+
user_mentions=[timer.author_id],
|
|
293
|
+
)
|
|
294
|
+
else:
|
|
295
|
+
await _answer(interaction, "Timer paused")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
async def timer_resume(interaction: Interaction) -> None:
|
|
299
|
+
timer = _owned_timer(interaction, "resume")
|
|
300
|
+
timer.resume()
|
|
301
|
+
timer.refresh()
|
|
302
|
+
await _answer(interaction, "Timer resumed")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
async def timer_stop(interaction: Interaction) -> None:
|
|
306
|
+
_owned_timer(interaction, "stop").stop()
|
|
307
|
+
await _answer(interaction, "Timer stopped")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
async def timer_add(interaction: hikari.CommandInteraction, minutes: int) -> None:
|
|
311
|
+
_owned_timer(interaction, "modify").adjust(minutes * 60)
|
|
312
|
+
await interaction.create_initial_response(
|
|
313
|
+
hikari.ResponseType.MESSAGE_CREATE, f"Time added ({minutes}min)"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
async def timer_sub(interaction: hikari.CommandInteraction, minutes: int) -> None:
|
|
318
|
+
_owned_timer(interaction, "modify").adjust(-minutes * 60)
|
|
319
|
+
await interaction.create_initial_response(
|
|
320
|
+
hikari.ResponseType.MESSAGE_CREATE, f"Time substracted ({minutes}min)"
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
async def timer_display(interaction: hikari.CommandInteraction) -> None:
|
|
325
|
+
_timer(interaction).refresh()
|
|
326
|
+
await _answer(interaction, "Timer displayed")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _minutes(min_value: int, max_value: int, required: bool) -> hikari.CommandOption:
|
|
330
|
+
return hikari.CommandOption(
|
|
331
|
+
type=hikari.OptionType.INTEGER,
|
|
332
|
+
name="minutes",
|
|
333
|
+
description="Number of minutes",
|
|
334
|
+
is_required=required,
|
|
335
|
+
min_value=min_value,
|
|
336
|
+
max_value=max_value,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _subcommand(name: str, description: str, *options: hikari.CommandOption):
|
|
341
|
+
return hikari.CommandOption(
|
|
342
|
+
type=hikari.OptionType.SUB_COMMAND,
|
|
343
|
+
name=name,
|
|
344
|
+
description=description,
|
|
345
|
+
options=list(options),
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
TIMER_COMMAND = hikari.impl.SlashCommandBuilder(
|
|
350
|
+
"timer",
|
|
351
|
+
"Countdown timer",
|
|
352
|
+
options=[
|
|
353
|
+
_subcommand(
|
|
354
|
+
"start",
|
|
355
|
+
"Start a timer",
|
|
356
|
+
hikari.CommandOption(
|
|
357
|
+
type=hikari.OptionType.INTEGER,
|
|
358
|
+
name="hours",
|
|
359
|
+
description="Number of hours",
|
|
360
|
+
is_required=True,
|
|
361
|
+
min_value=0,
|
|
362
|
+
max_value=24,
|
|
363
|
+
),
|
|
364
|
+
_minutes(0, 59, required=False),
|
|
365
|
+
hikari.CommandOption(
|
|
366
|
+
type=hikari.OptionType.BOOLEAN,
|
|
367
|
+
name="secured",
|
|
368
|
+
description="Only the owner can modify a secure timer (default false)",
|
|
369
|
+
),
|
|
370
|
+
),
|
|
371
|
+
_subcommand("pause", "pause the timer"),
|
|
372
|
+
_subcommand("resume", "resume the timer"),
|
|
373
|
+
_subcommand("stop", "stop the timer"),
|
|
374
|
+
_subcommand("add", "Add time", _minutes(1, 1440, required=True)),
|
|
375
|
+
_subcommand("sub", "Substract time", _minutes(1, 1440, required=True)),
|
|
376
|
+
_subcommand("display", "Display the timer anew"),
|
|
377
|
+
],
|
|
378
|
+
)
|
|
379
|
+
COMMANDS: dict[str, typing.Callable[..., typing.Awaitable[None]]] = {
|
|
380
|
+
"start": timer_start,
|
|
381
|
+
"pause": timer_pause,
|
|
382
|
+
"resume": timer_resume,
|
|
383
|
+
"stop": timer_stop,
|
|
384
|
+
"add": timer_add,
|
|
385
|
+
"sub": timer_sub,
|
|
386
|
+
"display": timer_display,
|
|
387
|
+
}
|
|
388
|
+
#: embeds posted by an earlier process still carry these custom_ids
|
|
389
|
+
BUTTONS: dict[str, typing.Callable[[Interaction], typing.Awaitable[None]]] = {
|
|
390
|
+
"pause": timer_pause,
|
|
391
|
+
"resume": timer_resume,
|
|
392
|
+
"stop": timer_stop,
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@bot.listen()
|
|
397
|
+
async def on_started(event: hikari.StartedEvent) -> None:
|
|
398
|
+
application = await bot.rest.fetch_application()
|
|
399
|
+
# a PUT replaces the whole set: commands no longer declared here are deleted
|
|
400
|
+
await bot.rest.set_application_commands(
|
|
401
|
+
application, [TIMER_COMMAND], guild=DEBUG_GUILD if __debug__ else hikari.UNDEFINED
|
|
402
|
+
)
|
|
403
|
+
logger.info("Commands registered")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@bot.listen()
|
|
407
|
+
async def on_interaction(event: hikari.InteractionCreateEvent) -> None:
|
|
408
|
+
interaction = event.interaction
|
|
409
|
+
if isinstance(interaction, hikari.CommandInteraction) and interaction.command_name == "timer":
|
|
410
|
+
subcommand = interaction.options[0]
|
|
411
|
+
name, handler = subcommand.name, COMMANDS[subcommand.name]
|
|
412
|
+
kwargs = {option.name: option.value for option in subcommand.options or ()}
|
|
413
|
+
elif isinstance(interaction, hikari.ComponentInteraction) and interaction.custom_id in BUTTONS:
|
|
414
|
+
name, handler = interaction.custom_id, BUTTONS[interaction.custom_id]
|
|
415
|
+
kwargs = {}
|
|
416
|
+
else:
|
|
417
|
+
return
|
|
418
|
+
logger.info("[%s] %s by %s %s", interaction.channel_id, name, interaction.user.username, kwargs)
|
|
419
|
+
try:
|
|
420
|
+
await handler(interaction, **kwargs)
|
|
421
|
+
except CommandFailed as e:
|
|
422
|
+
await _answer(interaction, e.content, embed=e.embed)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def main() -> None:
|
|
426
|
+
bot.run()
|
timer_bot-1.10/CHANGELOG.rst
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
Changelog
|
|
2
|
-
=========
|
|
3
|
-
|
|
4
|
-
1.10 (2025-04-26)
|
|
5
|
-
-----------------
|
|
6
|
-
|
|
7
|
-
- Bump discord-py-interactions
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
1.9 (2024-12-01)
|
|
11
|
-
----------------
|
|
12
|
-
|
|
13
|
-
- Fix dependencies
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
1.8 (2024-12-01)
|
|
17
|
-
----------------
|
|
18
|
-
|
|
19
|
-
- Upgrade to discord-py-interactions 5.13
|
|
20
|
-
|
|
21
|
-
1.7 (2024-02-06)
|
|
22
|
-
----------------
|
|
23
|
-
|
|
24
|
-
- Fix python 3.11 install for Debian 12
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
1.6 (2023-11-04)
|
|
28
|
-
----------------
|
|
29
|
-
|
|
30
|
-
- Fix timer updater after 1h (new Discord limitation)
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
1.5 (2023-11-03)
|
|
34
|
-
----------------
|
|
35
|
-
|
|
36
|
-
- Bump discord-py-interactions version
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
1.4 (2023-11-03)
|
|
40
|
-
----------------
|
|
41
|
-
|
|
42
|
-
- Fix timer update when finishing (display as finished, not with 1 or 2 seconds left)
|
|
43
|
-
- Add "secured" option so that only the owner can modify the timer
|
|
44
|
-
- Send message when missing permissions
|
|
45
|
-
|
|
46
|
-
1.3 (2022-12-01)
|
|
47
|
-
----------------
|
|
48
|
-
|
|
49
|
-
- Fix error messages when there's no timer running
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
1.2 (2022-08-27)
|
|
53
|
-
----------------
|
|
54
|
-
|
|
55
|
-
- Now works in threads and voice channel chats
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
1.1 (2022-08-27)
|
|
59
|
-
----------------
|
|
60
|
-
|
|
61
|
-
- Fix connection (no guild message access)
|
|
62
|
-
- Fix time boundary display (exactly 1 hour was not displayed properly)
|
|
63
|
-
|
|
64
|
-
1.0 (2022-08-27)
|
|
65
|
-
----------------
|
|
66
|
-
|
|
67
|
-
- V1.0
|
|
68
|
-
- Improve time display on boundaries (no more 1:60 or going from 5min to 4'35'')
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
0.12 (2022-08-27)
|
|
72
|
-
-----------------
|
|
73
|
-
|
|
74
|
-
- Switch to slash commands
|
|
75
|
-
- Use real buttons instead of reactions
|
|
76
|
-
|
|
77
|
-
0.11 (2021-03-17)
|
|
78
|
-
-----------------
|
|
79
|
-
|
|
80
|
-
- Fix pausing user mention
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
0.10 (2021-03-02)
|
|
84
|
-
-----------------
|
|
85
|
-
|
|
86
|
-
- Resuming a paused timer with a "timer resume" message could cause the timer to crash. Fixed it.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
0.9 (2020-11-03)
|
|
90
|
-
----------------
|
|
91
|
-
|
|
92
|
-
- Fix pause reaction
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
0.8 (2020-07-22)
|
|
96
|
-
----------------
|
|
97
|
-
|
|
98
|
-
- Fix pause timeout (after 30mn) and `timer resume` display
|
|
99
|
-
- Improve help message
|
|
100
|
-
|
|
101
|
-
0.7 (2020-07-05)
|
|
102
|
-
----------------
|
|
103
|
-
|
|
104
|
-
- Fixed the pause timer feature
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
0.6 (2020-06-22)
|
|
108
|
-
----------------
|
|
109
|
-
|
|
110
|
-
- Mention author when finished
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
0.5 (2020-06-22)
|
|
114
|
-
----------------
|
|
115
|
-
|
|
116
|
-
- Mention timer author on thresholds and when paused by someone else
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
0.4 (2020-05-30)
|
|
120
|
-
----------------
|
|
121
|
-
|
|
122
|
-
- Fixed a bug when pause and resume where used successively
|
|
123
|
-
- Added "pause", "add" and "sub" commands
|
|
124
|
-
- Better time input parsing
|
|
125
|
-
- More proper hours:minutes display (with padding zeroes)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
0.3 (2020-05-28)
|
|
129
|
-
----------------
|
|
130
|
-
|
|
131
|
-
- First version on pypi.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
0.2 (2020-05-28)
|
|
135
|
-
----------------
|
|
136
|
-
|
|
137
|
-
- First published version.
|
timer_bot-1.10/MANIFEST.in
DELETED
timer_bot-1.10/setup.cfg
DELETED
timer_bot-1.10/src/__init__.py
DELETED
|
File without changes
|
timer_bot-1.10/src/timer_bot.py
DELETED
|
@@ -1,583 +0,0 @@
|
|
|
1
|
-
from typing import Optional, Union
|
|
2
|
-
import asyncio
|
|
3
|
-
import logging
|
|
4
|
-
import os
|
|
5
|
-
|
|
6
|
-
import interactions
|
|
7
|
-
|
|
8
|
-
import interactions.api.events
|
|
9
|
-
import interactions.client.errors
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
logger = logging.getLogger()
|
|
13
|
-
bot = interactions.Client(
|
|
14
|
-
token=os.getenv("DISCORD_TOKEN") or "",
|
|
15
|
-
# intents=interactions.Intents.new(guild_messages=True),
|
|
16
|
-
delete_unused_application_cmds=True,
|
|
17
|
-
debug_scope=161406117686149120 if __debug__ else interactions.MISSING,
|
|
18
|
-
logging_level=logging.DEBUG if __debug__ else logging.INFO,
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
@interactions.listen()
|
|
23
|
-
async def on_ready():
|
|
24
|
-
"""Login success"""
|
|
25
|
-
logger.info(f"Logged in as {bot.user.username}")
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
@interactions.listen()
|
|
29
|
-
async def on_startup():
|
|
30
|
-
"""Startup success"""
|
|
31
|
-
logger.info("Started")
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
@interactions.listen()
|
|
35
|
-
async def on_error(error: interactions.api.events.Error):
|
|
36
|
-
logger.error("API error: %s", error)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
#: fixed list of times on which to send a notification
|
|
40
|
-
THRESHOLDS = [
|
|
41
|
-
0, # finished
|
|
42
|
-
1 * 60, # 1min
|
|
43
|
-
5 * 60, # 5min
|
|
44
|
-
15 * 60, # 15min
|
|
45
|
-
30 * 60, # 30min
|
|
46
|
-
]
|
|
47
|
-
|
|
48
|
-
#: timer embed will display seconds starting from this point int time
|
|
49
|
-
DISPLAY_SECONDS = 5 * 60
|
|
50
|
-
|
|
51
|
-
#: pause will timeout after this amount of seconds
|
|
52
|
-
PAUSE_TIMEOUT = 1800
|
|
53
|
-
|
|
54
|
-
#: help message for a running timer
|
|
55
|
-
RUNNING_TIMER_HELP = (
|
|
56
|
-
"- `/timer display` to display it anew\n"
|
|
57
|
-
"- `/timer pause` to pause\n"
|
|
58
|
-
"- `/timer resume` to resume\n"
|
|
59
|
-
"- `/timer stop` to terminate it\n"
|
|
60
|
-
"- `/timer add` to add time to it\n"
|
|
61
|
-
"- `/timer sub` to substract time from it\n"
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
class Timer:
|
|
66
|
-
"""Timer object: one per channel"""
|
|
67
|
-
|
|
68
|
-
def __init__(
|
|
69
|
-
self,
|
|
70
|
-
channel: interactions.GuildChannel,
|
|
71
|
-
author: interactions.Member,
|
|
72
|
-
time: int,
|
|
73
|
-
secured: bool,
|
|
74
|
-
log_prefix: str = "",
|
|
75
|
-
):
|
|
76
|
-
self.channel = channel
|
|
77
|
-
self.author = author
|
|
78
|
-
self.secured = secured
|
|
79
|
-
self.start_time: float = 0
|
|
80
|
-
self.total_time: int = 0
|
|
81
|
-
self.time_left: float = 0
|
|
82
|
-
self.log_prefix = log_prefix + "|internal"
|
|
83
|
-
self.thresholds: list[int] = []
|
|
84
|
-
self.adjust_time(time)
|
|
85
|
-
# internals
|
|
86
|
-
self.message: Optional[interactions.Message] = None
|
|
87
|
-
self.countdown_future = None # waiting for time to refresh
|
|
88
|
-
self.resume_future = None # waiting for resume
|
|
89
|
-
|
|
90
|
-
def adjust_time(self, time: int):
|
|
91
|
-
self.total_time += time
|
|
92
|
-
self.time_left += time
|
|
93
|
-
self.thresholds = [limit for limit in THRESHOLDS if self.time_left > limit]
|
|
94
|
-
# add a threshold on every hour
|
|
95
|
-
for limit in range(1, int(self.time_left // 3600) + 1):
|
|
96
|
-
self.thresholds.append(limit * 3600)
|
|
97
|
-
|
|
98
|
-
async def countdown(self):
|
|
99
|
-
"""Countdown: update embed, send notifications"""
|
|
100
|
-
while self.time_left > 0:
|
|
101
|
-
# update time_left
|
|
102
|
-
if not self.resume_future:
|
|
103
|
-
time_spent = max(0, asyncio.get_event_loop().time() - self.start_time)
|
|
104
|
-
self.time_left = max(0, self.total_time - time_spent)
|
|
105
|
-
await self._send_or_update_message()
|
|
106
|
-
# update frequency depends on time left
|
|
107
|
-
if self.resume_future:
|
|
108
|
-
paused_time = asyncio.get_event_loop().time()
|
|
109
|
-
try:
|
|
110
|
-
logging.debug(f"[{self.log_prefix}] Wait for resume")
|
|
111
|
-
await self.resume_future
|
|
112
|
-
except asyncio.CancelledError:
|
|
113
|
-
logging.debug(f"[{self.log_prefix}] Pause cancelled - resume")
|
|
114
|
-
except asyncio.TimeoutError:
|
|
115
|
-
logging.debug(f"[{self.log_prefix}] Pause timed out - resume")
|
|
116
|
-
finally: # in any case resume.
|
|
117
|
-
logging.debug(f"[{self.log_prefix}] Timer resume")
|
|
118
|
-
self.resume_future = None
|
|
119
|
-
self.start_time += asyncio.get_event_loop().time() - paused_time
|
|
120
|
-
else:
|
|
121
|
-
if self.time_left < DISPLAY_SECONDS + 30:
|
|
122
|
-
# minimum because of Discord rate limitation
|
|
123
|
-
self.countdown_future = asyncio.ensure_future(asyncio.sleep(1.1))
|
|
124
|
-
else:
|
|
125
|
-
self.countdown_future = asyncio.ensure_future(asyncio.sleep(30))
|
|
126
|
-
try:
|
|
127
|
-
logging.debug(f"[{self.log_prefix}] Wait for countdown")
|
|
128
|
-
await self.countdown_future
|
|
129
|
-
except asyncio.CancelledError:
|
|
130
|
-
logging.debug(f"[{self.log_prefix}] Countdown canceled")
|
|
131
|
-
finally: # in any case resume.
|
|
132
|
-
self.countdown_future = None
|
|
133
|
-
# final "Finished" update
|
|
134
|
-
await self._send_or_update_message()
|
|
135
|
-
|
|
136
|
-
async def run(self):
|
|
137
|
-
"""Run the timer, update the client.TIMERS map accordingly."""
|
|
138
|
-
logging.debug(f"[{self.log_prefix}] Run")
|
|
139
|
-
TIMERS[self.channel] = self
|
|
140
|
-
self.start_time = asyncio.get_event_loop().time()
|
|
141
|
-
try:
|
|
142
|
-
await self.countdown()
|
|
143
|
-
except asyncio.CancelledError:
|
|
144
|
-
logger.info(f"[{self.log_prefix}] Timer cancelled")
|
|
145
|
-
# at that point aiohttp may be closed in case of SIGINT/SIGTERM
|
|
146
|
-
except asyncio.TimeoutError:
|
|
147
|
-
logger.exception(f"[{self.log_prefix}] Timeout - something went wrong")
|
|
148
|
-
await self.stop()
|
|
149
|
-
except Exception:
|
|
150
|
-
logger.exception(f"[{self.log_prefix}] Unhandled exception")
|
|
151
|
-
raise
|
|
152
|
-
finally:
|
|
153
|
-
del TIMERS[self.channel]
|
|
154
|
-
|
|
155
|
-
async def stop(self):
|
|
156
|
-
"""Stops the timer."""
|
|
157
|
-
logging.debug(f"[{self.log_prefix}] Stop")
|
|
158
|
-
if self.time_left > 0:
|
|
159
|
-
await self.channel.send("Stopped with " + self.time_str())
|
|
160
|
-
self.time_left = -1
|
|
161
|
-
logger.info(f"[{self.log_prefix}] Timer stopped")
|
|
162
|
-
if self.countdown_future:
|
|
163
|
-
self.countdown_future.cancel()
|
|
164
|
-
if self.resume_future:
|
|
165
|
-
self.resume_future.cancel()
|
|
166
|
-
if self.message:
|
|
167
|
-
await self.message.delete()
|
|
168
|
-
self.message = None
|
|
169
|
-
|
|
170
|
-
async def pause(self):
|
|
171
|
-
"""Pauses the timer."""
|
|
172
|
-
# don't pause twice
|
|
173
|
-
if self.resume_future:
|
|
174
|
-
return
|
|
175
|
-
logging.debug(f"[{self.log_prefix}] Pause")
|
|
176
|
-
self.resume_future = asyncio.ensure_future(asyncio.sleep(PAUSE_TIMEOUT))
|
|
177
|
-
# cancel countdown
|
|
178
|
-
if self.countdown_future:
|
|
179
|
-
self.countdown_future.cancel()
|
|
180
|
-
self.countdown_future = None
|
|
181
|
-
|
|
182
|
-
async def refresh(self, resume=True):
|
|
183
|
-
"""Display a new embed."""
|
|
184
|
-
logging.debug(f"[{self.log_prefix}] Refresh")
|
|
185
|
-
if self.message:
|
|
186
|
-
await self.message.delete()
|
|
187
|
-
self.message = None
|
|
188
|
-
if self.resume_future:
|
|
189
|
-
# this cancels the countdown_future internally
|
|
190
|
-
if resume:
|
|
191
|
-
self.resume_future.cancel()
|
|
192
|
-
else:
|
|
193
|
-
await self._send_or_update_message()
|
|
194
|
-
elif self.countdown_future:
|
|
195
|
-
self.countdown_future.cancel()
|
|
196
|
-
|
|
197
|
-
async def _send_or_update_message(self):
|
|
198
|
-
"""The running timer embed"""
|
|
199
|
-
if self.time_left < 1:
|
|
200
|
-
title = "Timer finished"
|
|
201
|
-
components = []
|
|
202
|
-
description = "Use `/timer start` to start a new timer."
|
|
203
|
-
elif self.resume_future:
|
|
204
|
-
title = "Timer paused: " + self.time_str()
|
|
205
|
-
components = [button_resume, button_stop]
|
|
206
|
-
description = (
|
|
207
|
-
"Use the buttons or the `/timer` commands to manipulate the timer."
|
|
208
|
-
)
|
|
209
|
-
else:
|
|
210
|
-
title = self.time_str()
|
|
211
|
-
components = [button_pause, button_stop]
|
|
212
|
-
description = (
|
|
213
|
-
"Use the buttons or the `/timer` commands to manipulate the timer."
|
|
214
|
-
)
|
|
215
|
-
embeds = [interactions.Embed(title=title, description=description)]
|
|
216
|
-
if self.message:
|
|
217
|
-
try:
|
|
218
|
-
self.message = await self.message.edit(
|
|
219
|
-
embeds=embeds, components=components
|
|
220
|
-
)
|
|
221
|
-
# messages older than 1h cannot be edited too much, at some point it fails
|
|
222
|
-
except interactions.errors.LibraryException as e:
|
|
223
|
-
logger.info("Failed to edit message: %s", e)
|
|
224
|
-
old_message = self.message
|
|
225
|
-
self.message = await self.channel.send(
|
|
226
|
-
embeds=embeds, components=components
|
|
227
|
-
)
|
|
228
|
-
if old_message:
|
|
229
|
-
try:
|
|
230
|
-
await old_message.delete()
|
|
231
|
-
except interactions.errors.LibraryException as e:
|
|
232
|
-
logger.info("Failed to delete old message: %s", e)
|
|
233
|
-
pass
|
|
234
|
-
else:
|
|
235
|
-
self.message = await self.channel.send(embeds=embeds, components=components)
|
|
236
|
-
if self.thresholds and self.thresholds[-1] >= self.time_left >= 0:
|
|
237
|
-
await self.channel.send(
|
|
238
|
-
f"{self.author.mention} {self._time_str(self.thresholds.pop())}"
|
|
239
|
-
)
|
|
240
|
-
|
|
241
|
-
def time_str(self):
|
|
242
|
-
"""Time string for the current time left."""
|
|
243
|
-
return self._time_str(self.time_left)
|
|
244
|
-
|
|
245
|
-
@staticmethod
|
|
246
|
-
def _time_str(time):
|
|
247
|
-
"""Returns a human readable string for given time (int) in seconds"""
|
|
248
|
-
seconds = round(time % 60)
|
|
249
|
-
if seconds > 59 or time > DISPLAY_SECONDS:
|
|
250
|
-
seconds = 0
|
|
251
|
-
minutes = round((time - seconds) % 3600 / 60)
|
|
252
|
-
if minutes > 59:
|
|
253
|
-
minutes = 0
|
|
254
|
-
hours = round((time - minutes * 60 - seconds) / 3600)
|
|
255
|
-
if time > 3569:
|
|
256
|
-
return f"{hours}:{minutes:0>2} remaining"
|
|
257
|
-
if time > DISPLAY_SECONDS:
|
|
258
|
-
return f"{minutes} minutes remaining"
|
|
259
|
-
if minutes:
|
|
260
|
-
return f"{minutes}′ {seconds:0>2}″ remaining"
|
|
261
|
-
if time > 0:
|
|
262
|
-
return f"{seconds} seconds remaining"
|
|
263
|
-
return "time!"
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
TIMERS: dict[interactions.Snowflake : Timer] = {}
|
|
267
|
-
timer_base = interactions.SlashCommand(name="timer")
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
def _get_prefix(ctx: interactions.SlashContext):
|
|
271
|
-
"""Prefix used for log messages"""
|
|
272
|
-
if ctx.guild:
|
|
273
|
-
prefix = f"{ctx.guild.name}"
|
|
274
|
-
logger.debug("CTX: %s", ctx)
|
|
275
|
-
logger.debug("channel: %s", ctx.channel)
|
|
276
|
-
logger.debug("channel_id: %s", ctx.channel_id)
|
|
277
|
-
if ctx.channel:
|
|
278
|
-
prefix += f":{ctx.channel.name}"
|
|
279
|
-
else:
|
|
280
|
-
prefix = f"{ctx.author.name}"
|
|
281
|
-
return prefix
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
@timer_base.subcommand(
|
|
285
|
-
sub_cmd_name="start",
|
|
286
|
-
sub_cmd_description="Start a timer",
|
|
287
|
-
)
|
|
288
|
-
@interactions.slash_option(
|
|
289
|
-
name="hours",
|
|
290
|
-
opt_type=interactions.OptionType.INTEGER,
|
|
291
|
-
description="Number of hours",
|
|
292
|
-
required=True,
|
|
293
|
-
min_value=0,
|
|
294
|
-
max_value=24,
|
|
295
|
-
)
|
|
296
|
-
@interactions.slash_option(
|
|
297
|
-
name="minutes",
|
|
298
|
-
opt_type=interactions.OptionType.INTEGER,
|
|
299
|
-
description="Number of minutes",
|
|
300
|
-
required=False,
|
|
301
|
-
min_value=0,
|
|
302
|
-
max_value=59,
|
|
303
|
-
)
|
|
304
|
-
@interactions.slash_option(
|
|
305
|
-
name="secured",
|
|
306
|
-
opt_type=interactions.OptionType.BOOLEAN,
|
|
307
|
-
description="Only the owner can modify a secure timer (default false)",
|
|
308
|
-
required=False,
|
|
309
|
-
)
|
|
310
|
-
async def timer_start(
|
|
311
|
-
ctx: interactions.SlashContext,
|
|
312
|
-
hours: int,
|
|
313
|
-
minutes: int = 0,
|
|
314
|
-
secured: bool = False,
|
|
315
|
-
):
|
|
316
|
-
"""Start a timer"""
|
|
317
|
-
# channel info will miss from threads and voice channels chats
|
|
318
|
-
# see https://github.com/interactions-py/library/issues/1041
|
|
319
|
-
if ctx.channel is interactions.MISSING:
|
|
320
|
-
ctx.channel = await ctx.client.get_channel(ctx.channel_id)
|
|
321
|
-
prefix = _get_prefix(ctx)
|
|
322
|
-
# timer already running in channel
|
|
323
|
-
if ctx.channel_id in TIMERS:
|
|
324
|
-
await ctx.send(
|
|
325
|
-
embeds=[
|
|
326
|
-
interactions.Embed(
|
|
327
|
-
title="Timer already running", description=RUNNING_TIMER_HELP
|
|
328
|
-
)
|
|
329
|
-
],
|
|
330
|
-
ephemeral=True,
|
|
331
|
-
)
|
|
332
|
-
return
|
|
333
|
-
# no timer running in channel
|
|
334
|
-
total_time = hours * 3600 + minutes * 60
|
|
335
|
-
if total_time:
|
|
336
|
-
timer = Timer(ctx.channel, ctx.author, total_time, secured, prefix)
|
|
337
|
-
await ctx.send("Starting Timer", ephemeral=True)
|
|
338
|
-
logger.info(f"[{prefix}] Start timer: {hours}h {minutes}min")
|
|
339
|
-
try:
|
|
340
|
-
await timer.run()
|
|
341
|
-
except interactions.client.errors.LibraryException:
|
|
342
|
-
await ctx.edit(
|
|
343
|
-
"**Failed to start**\nTimer bot requires permission to send messages"
|
|
344
|
-
)
|
|
345
|
-
logger.info(f"[{prefix}] Timer finished")
|
|
346
|
-
else:
|
|
347
|
-
await ctx.send(
|
|
348
|
-
embeds=[
|
|
349
|
-
interactions.Embed(
|
|
350
|
-
title="No time",
|
|
351
|
-
description="Hours and minutes cannot both be zero.",
|
|
352
|
-
)
|
|
353
|
-
],
|
|
354
|
-
ephemeral=True,
|
|
355
|
-
)
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
@timer_base.subcommand(sub_cmd_name="pause", sub_cmd_description="pause the timer")
|
|
359
|
-
async def timer_pause(ctx: interactions.SlashContext):
|
|
360
|
-
"""Pause the timer"""
|
|
361
|
-
await _pause_timer(ctx)
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
@timer_base.subcommand(sub_cmd_name="resume", sub_cmd_description="resume the timer")
|
|
365
|
-
async def timer_resume(ctx: interactions.SlashContext):
|
|
366
|
-
"""Resume the timer"""
|
|
367
|
-
await _resume_timer(ctx)
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
@timer_base.subcommand(sub_cmd_name="stop", sub_cmd_description="stop the timer")
|
|
371
|
-
async def timer_stop(ctx: interactions.SlashContext):
|
|
372
|
-
"""Stop the timer"""
|
|
373
|
-
await _stop_timer(ctx)
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
@timer_base.subcommand(
|
|
377
|
-
sub_cmd_name="add",
|
|
378
|
-
sub_cmd_description="Add time",
|
|
379
|
-
)
|
|
380
|
-
@interactions.slash_option(
|
|
381
|
-
name="minutes",
|
|
382
|
-
opt_type=interactions.OptionType.INTEGER,
|
|
383
|
-
description="Number of minutes",
|
|
384
|
-
required=True,
|
|
385
|
-
min_value=1,
|
|
386
|
-
max_value=1440,
|
|
387
|
-
)
|
|
388
|
-
async def timer_add(ctx: interactions.SlashContext, minutes: int):
|
|
389
|
-
"""Add time to the timer"""
|
|
390
|
-
timer = TIMERS.get(ctx.channel, None)
|
|
391
|
-
if not timer:
|
|
392
|
-
await ctx.send(
|
|
393
|
-
"No timer running in this channel. Use `/timer start` to start one.",
|
|
394
|
-
ephemeral=True,
|
|
395
|
-
)
|
|
396
|
-
return
|
|
397
|
-
prefix = _get_prefix(ctx)
|
|
398
|
-
if timer.secured and ctx.author.id != timer.author.id:
|
|
399
|
-
await ctx.send(
|
|
400
|
-
"This is a secured timer, only the owner can modify it.", ephemeral=True
|
|
401
|
-
)
|
|
402
|
-
return
|
|
403
|
-
timer.adjust_time(minutes * 60)
|
|
404
|
-
await timer.refresh(resume=False)
|
|
405
|
-
logger.info(f"[{prefix}] Added {minutes}min and refreshed")
|
|
406
|
-
await ctx.send(f"Time added ({minutes}min)")
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
@timer_base.subcommand(
|
|
410
|
-
sub_cmd_name="sub",
|
|
411
|
-
sub_cmd_description="Substract time",
|
|
412
|
-
)
|
|
413
|
-
@interactions.slash_option(
|
|
414
|
-
name="minutes",
|
|
415
|
-
opt_type=interactions.OptionType.INTEGER,
|
|
416
|
-
description="Number of minutes",
|
|
417
|
-
required=True,
|
|
418
|
-
min_value=1,
|
|
419
|
-
max_value=1440,
|
|
420
|
-
)
|
|
421
|
-
async def timer_sub(ctx: interactions.SlashContext, minutes: int):
|
|
422
|
-
"""Substract time from the timer"""
|
|
423
|
-
timer = TIMERS.get(ctx.channel, None)
|
|
424
|
-
if not timer:
|
|
425
|
-
await ctx.send(
|
|
426
|
-
"No timer running in this channel. Use `/timer start` to start one.",
|
|
427
|
-
ephemeral=True,
|
|
428
|
-
)
|
|
429
|
-
return
|
|
430
|
-
prefix = _get_prefix(ctx)
|
|
431
|
-
if timer.secured and ctx.author.id != timer.author.id:
|
|
432
|
-
await ctx.send(
|
|
433
|
-
"This is a secured timer, only the owner can modify it.", ephemeral=True
|
|
434
|
-
)
|
|
435
|
-
return
|
|
436
|
-
timer.adjust_time(-minutes * 60)
|
|
437
|
-
await timer.refresh(resume=False)
|
|
438
|
-
logger.info(f"[{prefix}] Substracted {minutes}min and refreshed")
|
|
439
|
-
await ctx.send(f"Time substracted ({minutes}min)")
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
@timer_base.subcommand(
|
|
443
|
-
sub_cmd_name="display", sub_cmd_description="Display the timer anew"
|
|
444
|
-
)
|
|
445
|
-
async def timer_display(ctx: interactions.SlashContext):
|
|
446
|
-
"""Discplay the timer anew"""
|
|
447
|
-
timer = TIMERS.get(ctx.channel, None)
|
|
448
|
-
if not timer:
|
|
449
|
-
await ctx.send(
|
|
450
|
-
"No timer running in this channel. Use `/timer start` to start one.",
|
|
451
|
-
ephemeral=True,
|
|
452
|
-
)
|
|
453
|
-
return
|
|
454
|
-
prefix = _get_prefix(ctx)
|
|
455
|
-
await timer.refresh(resume=False)
|
|
456
|
-
await ctx.send("Timer displayed", ephemeral=True)
|
|
457
|
-
logger.info(f"[{prefix}] Refreshed")
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
button_pause = interactions.Button(
|
|
461
|
-
style=interactions.ButtonStyle.PRIMARY,
|
|
462
|
-
label="Pause",
|
|
463
|
-
custom_id="pause",
|
|
464
|
-
emoji=interactions.PartialEmoji.from_str("⏱"),
|
|
465
|
-
)
|
|
466
|
-
|
|
467
|
-
button_resume = interactions.Button(
|
|
468
|
-
style=interactions.ButtonStyle.SUCCESS,
|
|
469
|
-
label="Resume",
|
|
470
|
-
custom_id="resume",
|
|
471
|
-
emoji=interactions.PartialEmoji.from_str("▶️"),
|
|
472
|
-
)
|
|
473
|
-
|
|
474
|
-
button_stop = interactions.Button(
|
|
475
|
-
style=interactions.ButtonStyle.DANGER,
|
|
476
|
-
label="Stop",
|
|
477
|
-
custom_id="stop",
|
|
478
|
-
emoji=interactions.PartialEmoji.from_str("🛑"),
|
|
479
|
-
)
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
@interactions.component_callback("pause")
|
|
483
|
-
async def button_pause_response(ctx: interactions.ComponentContext):
|
|
484
|
-
await _pause_timer(ctx)
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
@interactions.component_callback("resume")
|
|
488
|
-
async def button_resume_response(ctx: interactions.ComponentContext):
|
|
489
|
-
await _resume_timer(ctx)
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
@interactions.component_callback("stop")
|
|
493
|
-
async def button_stop_response(ctx: interactions.ComponentContext):
|
|
494
|
-
await _stop_timer(ctx)
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
async def _pause_timer(
|
|
498
|
-
ctx: Union[interactions.SlashContext, interactions.ComponentContext],
|
|
499
|
-
):
|
|
500
|
-
timer = TIMERS.get(ctx.channel, None)
|
|
501
|
-
if not timer:
|
|
502
|
-
await ctx.send(
|
|
503
|
-
"No timer running in this channel. Use `/timer start` to start one.",
|
|
504
|
-
ephemeral=True,
|
|
505
|
-
)
|
|
506
|
-
return
|
|
507
|
-
prefix = _get_prefix(ctx)
|
|
508
|
-
if timer.secured and ctx.author.id != timer.author.id:
|
|
509
|
-
await ctx.send(
|
|
510
|
-
"This is a secured timer, only the owner can pause it.", ephemeral=True
|
|
511
|
-
)
|
|
512
|
-
return
|
|
513
|
-
await timer.pause()
|
|
514
|
-
logger.info(f"[{prefix}] Paused")
|
|
515
|
-
if ctx.author.id != timer.author.id:
|
|
516
|
-
ephemeral = False
|
|
517
|
-
message = f"{timer.author.mention} timer paused by {ctx.author.mention}"
|
|
518
|
-
else:
|
|
519
|
-
ephemeral = True
|
|
520
|
-
message = "Timer paused"
|
|
521
|
-
await ctx.send(message, ephemeral=ephemeral)
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
async def _resume_timer(
|
|
525
|
-
ctx: Union[interactions.SlashContext, interactions.ComponentContext],
|
|
526
|
-
):
|
|
527
|
-
timer = TIMERS.get(ctx.channel, None)
|
|
528
|
-
if not timer:
|
|
529
|
-
await ctx.send(
|
|
530
|
-
"No timer running in this channel. Use `/timer start` to start one.",
|
|
531
|
-
ephemeral=True,
|
|
532
|
-
)
|
|
533
|
-
return
|
|
534
|
-
prefix = _get_prefix(ctx)
|
|
535
|
-
if timer.secured and ctx.author.id != timer.author.id:
|
|
536
|
-
await ctx.send(
|
|
537
|
-
"This is a secured timer, only the owner can resume it.", ephemeral=True
|
|
538
|
-
)
|
|
539
|
-
return
|
|
540
|
-
await timer.refresh()
|
|
541
|
-
await ctx.send("Timer resumed", ephemeral=True)
|
|
542
|
-
logger.info(f"[{prefix}] Refreshed and resumed")
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
async def _stop_timer(
|
|
546
|
-
ctx: Union[interactions.SlashContext, interactions.ComponentContext],
|
|
547
|
-
):
|
|
548
|
-
timer = TIMERS.get(ctx.channel, None)
|
|
549
|
-
if not timer:
|
|
550
|
-
await ctx.send(
|
|
551
|
-
"No timer running in this channel. Use `/timer start` to start one.",
|
|
552
|
-
ephemeral=True,
|
|
553
|
-
)
|
|
554
|
-
return
|
|
555
|
-
if timer.secured and ctx.author.id != timer.author.id:
|
|
556
|
-
await ctx.send(
|
|
557
|
-
"This is a secured timer, only the owner can stop it.", ephemeral=True
|
|
558
|
-
)
|
|
559
|
-
return
|
|
560
|
-
await timer.stop()
|
|
561
|
-
await ctx.send("Timer stopped", ephemeral=True)
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
bot.add_listener(on_ready)
|
|
565
|
-
bot.add_listener(on_startup)
|
|
566
|
-
bot.add_command(timer_start)
|
|
567
|
-
bot.add_command(timer_stop)
|
|
568
|
-
bot.add_command(timer_pause)
|
|
569
|
-
bot.add_command(timer_resume)
|
|
570
|
-
bot.add_command(timer_display)
|
|
571
|
-
bot.add_command(timer_add)
|
|
572
|
-
bot.add_command(timer_sub)
|
|
573
|
-
bot.add_component_callback(button_pause_response)
|
|
574
|
-
bot.add_component_callback(button_resume_response)
|
|
575
|
-
bot.add_component_callback(button_stop_response)
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
def main():
|
|
579
|
-
"""Entrypoint"""
|
|
580
|
-
logger.addHandler(logging.StreamHandler())
|
|
581
|
-
logger.setLevel(logging.DEBUG if __debug__ else logging.INFO)
|
|
582
|
-
bot.start()
|
|
583
|
-
logger.setLevel(logging.NOTSET)
|
|
File without changes
|
|
File without changes
|