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.
@@ -1,28 +1,23 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: timer-bot
3
- Version: 1.10
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.11
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-Python: >=3.11
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 = ["setuptools>=68"]
3
- build-backend = "setuptools.build_meta"
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.10"
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
- requires-python = ">=3.11"
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.11",
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 = ["discord-py-interactions>=5.14", "uvloop>=0.21"]
26
-
27
- [project.optional-dependencies]
28
- dev = ["black", "ruff", "zest.releaser[recommended]"]
26
+ dependencies = [
27
+ "hikari>=2.6,<3",
28
+ ]
29
29
 
30
30
  [project.scripts]
31
- timer-bot = "src.timer_bot:main"
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
- [tool.setuptools.packages.find]
37
- include = ["src*"]
36
+ [dependency-groups]
37
+ deploy = ["ansible-core"]
38
+ dev = ["pytest", "ruff", "ty"]
38
39
 
39
- [tool.zest-releaser]
40
- create-wheel = true
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()
@@ -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.
@@ -1,7 +0,0 @@
1
- include LICENSE README.md CHANGELOG.rst
2
-
3
- graft src
4
- exclude Makefile
5
-
6
- global-exclude */__pycache__/*
7
- global-exclude *.egg-info/*
timer_bot-1.10/setup.cfg DELETED
@@ -1,4 +0,0 @@
1
- [egg_info]
2
- tag_build =
3
- tag_date = 0
4
-
File without changes
@@ -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)
@@ -1,7 +0,0 @@
1
- CHANGELOG.rst
2
- LICENSE
3
- MANIFEST.in
4
- README.md
5
- pyproject.toml
6
- src/__init__.py
7
- src/timer_bot.py
File without changes
File without changes