This repository was archived by the owner on Sep 27, 2026. It is now read-only.
Repository navigation
Expand file tree
/
Copy pathbot.py
More file actions
377 lines (301 loc) · 14.2 KB
/
Copy pathbot.py
File metadata and controls
377 lines (301 loc) · 14.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
# -*- coding: utf-8 -*-
# ============================================================================ #
# DockerDiscordControl (DDC) #
# https://ddc.bot #
# Copyright (c) 2025 MAX #
# Licensed under the MIT License #
# ============================================================================ #
"""Entry point for the DockerDiscordControl Discord bot."""
from __future__ import annotations
import asyncio
import dataclasses
import logging
import os
import signal
import sys
import time
from datetime import datetime, timezone
from typing import Optional
import pytz
# Fix for audioop module in Python 3.13
try:
import audioop # type: ignore
except ImportError: # pragma: no cover - import shim only used on specific platforms
import audioop_lts as audioop # type: ignore
sys.modules["audioop"] = audioop
import discord
from app.bootstrap import configure_environment, load_main_configuration
from app.bootstrap.runtime import get_web_port
from app.bot import build_runtime, create_bot, get_decrypted_bot_token, register_event_handlers
from services.scheduling.scheduler_service import stop_scheduler_service
from utils.app_commands_helper import initialize_app_commands
# Trigger app command availability detection early in the process.
app_commands, DiscordOption, app_commands_available = initialize_app_commands()
# Seconds between re-checks of the configured token after Discord rejected the login.
TOKEN_RECHECK_INTERVAL = 20
# Missing privileged intents cannot be detected without a new login, so DDC re-executes
# itself to retry. The delay doubles per attempt (the counter survives the re-exec via
# the environment) up to the cap, so an unattended install does not burn through
# Discord's daily IDENTIFY budget (1000 per token and day).
INTENTS_RETRY_INITIAL_DELAY = 90
INTENTS_RETRY_MAX_DELAY = 900
INTENTS_RETRY_ENV = "DDC_INTENTS_RETRY_ATTEMPT"
def _handle_sigterm(signum, frame) -> None:
logging.getLogger("ddc.bot").info("Received SIGTERM - shutting down")
raise SystemExit(0)
def install_sigterm_handler(runtime_logger: Optional[logging.Logger] = None) -> None:
"""Make SIGTERM end the process while py-cord's own handler is not installed.
DDC runs as PID 1 in the container, where the kernel ignores signals that have
no handler, so ``docker stop`` would wait 10 s and then SIGKILL. ``bot.run()``
replaces this handler with py-cord's own one while the bot runs (and resets it to
the default when its loop closes).
"""
try:
signal.signal(signal.SIGTERM, _handle_sigterm)
except (ValueError, OSError) as e: # not the main thread / unsupported platform
if runtime_logger:
runtime_logger.debug("Could not install SIGTERM handler: %s", e)
def _prepare_event_loop() -> asyncio.AbstractEventLoop:
"""Create a dedicated asyncio loop for the bot runtime."""
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
else:
try: # pragma: no cover - uvloop optional dependency
import uvloop
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
logging.getLogger("ddc.bot").info("Using uvloop for better performance")
except ImportError:
pass
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop
def _ensure_timezone(runtime_logger: logging.Logger) -> None:
"""Ensure the process timezone matches the configuration.
An invalid ``TZ`` value (e.g. ``GMT+1``) must not crash the startup, so it
falls back to UTC with a warning.
"""
timezone_name = os.environ.get("TZ", "Europe/Berlin")
runtime_logger.info("Attempting to set timezone to: %s", timezone_name)
try:
import zoneinfo
except ImportError:
zoneinfo = None
tz = None
if zoneinfo is not None:
try:
tz = zoneinfo.ZoneInfo(timezone_name)
runtime_logger.info("Successfully set timezone to %s using zoneinfo", timezone_name)
except (zoneinfo.ZoneInfoNotFoundError, ValueError, OSError) as e:
runtime_logger.warning("Could not set timezone %s: %s", timezone_name, e)
else:
try:
tz = pytz.timezone(timezone_name)
runtime_logger.info("Successfully set timezone to %s using pytz", timezone_name)
except (pytz.exceptions.UnknownTimeZoneError, ValueError, OSError) as e:
runtime_logger.warning("Could not set timezone %s: %s", timezone_name, e)
if tz is None:
runtime_logger.info("Using UTC as fallback")
tz = timezone.utc
datetime.now().astimezone(tz)
def _read_configured_token(runtime) -> Optional[str]:
"""Re-read the currently configured bot token without flooding the log."""
quiet_logger = logging.getLogger("ddc.bot.token_watch")
quiet_logger.propagate = False
if not quiet_logger.handlers:
quiet_logger.addHandler(logging.NullHandler())
try:
from services.config.config_service import load_config
fresh_runtime = dataclasses.replace(runtime, config=load_config(), logger=quiet_logger)
return get_decrypted_bot_token(fresh_runtime)
except Exception as e: # keep polling on transient config/decryption errors
runtime.logger.debug("Token re-check failed: %s", e)
return None
def _restart_process(runtime_logger: logging.Logger) -> None:
"""Replace this process with a fresh DDC instance (same PID, same arguments)."""
for stream in (sys.stdout, sys.stderr):
try:
stream.flush()
except (OSError, ValueError):
pass
try:
os.execv(sys.executable, [sys.executable, *sys.orig_argv[1:]])
except OSError as e:
runtime_logger.critical(
"Could not restart DDC in-process (%s) - exiting so Docker can restart the container", e
)
sys.exit(1)
def _intents_retry_attempt() -> int:
"""Number of automatic login retries already made for missing intents."""
try:
return max(0, int(os.environ.get(INTENTS_RETRY_ENV, "0")))
except ValueError:
return 0
def _intents_retry_delay(attempt: int) -> int:
"""Seconds to wait before the next automatic login retry (90 s, doubling, capped)."""
return min(INTENTS_RETRY_MAX_DELAY, INTENTS_RETRY_INITIAL_DELAY * 2 ** min(attempt, 10))
def _wait_for_new_token_and_restart(runtime, rejected_token: str, retry_after: Optional[int] = None) -> None:
"""Keep the process (and the Web UI thread) alive until a different token is configured.
py-cord closes its event loop when ``bot.run()`` fails, so a second login in this
process is not possible. As soon as a new token is saved, the process re-executes
itself (same PID, fresh bot and Web UI) instead of depending on a restart policy.
With ``retry_after`` (missing intents - fixed in the Developer Portal, not by a new
token) the process also re-executes once that many seconds have passed.
"""
# py-cord reset SIGTERM to the default disposition when its loop closed.
install_sigterm_handler(runtime.logger)
web_port = get_web_port()
runtime.logger.warning(
"Bot login disabled - waiting for a new bot token (re-checked every %d s). "
"The Web UI stays available on port %d.",
TOKEN_RECHECK_INTERVAL,
web_port,
)
checks = 0
waited = 0
while True:
time.sleep(TOKEN_RECHECK_INTERVAL)
checks += 1
waited += TOKEN_RECHECK_INTERVAL
new_token = _read_configured_token(runtime)
if new_token and new_token != rejected_token:
runtime.logger.info("🔑 New bot token detected - restarting DDC to log in again...")
os.environ.pop(INTENTS_RETRY_ENV, None) # new token -> start the intents backoff over
time.sleep(2) # let the Web UI finish the request that saved the token
_restart_process(runtime.logger)
return
if retry_after is not None and waited >= retry_after:
runtime.logger.warning(
"🔁 Retrying the Discord login - restarting DDC to check whether the privileged "
"intents are enabled now..."
)
os.environ[INTENTS_RETRY_ENV] = str(_intents_retry_attempt() + 1)
_restart_process(runtime.logger)
return
if checks % 15 == 0:
runtime.logger.warning(
"Still waiting for a valid bot token / enabled intents. The Web UI is available on port %d.",
web_port,
)
def _run_bot_until_stopped(bot, runtime, token: str) -> None:
"""Run the bot; on a rejected token or missing intents keep the Web UI alive.
Exiting here would make the container restart-loop, and the Web UI - the only
place to fix the token - would only be up for a few seconds per cycle.
"""
try:
bot.run(token)
except discord.LoginFailure:
runtime.logger.error("FATAL: Discord rejected the bot token (invalid or reset token).")
if os.getenv("DISCORD_BOT_TOKEN"):
runtime.logger.error(
"The token comes from the DISCORD_BOT_TOKEN environment variable - fix it in the "
"container settings and recreate the container."
)
else:
runtime.logger.error(
"Fix the bot token in the Web UI - DDC restarts automatically as soon as a new token is saved."
)
_wait_for_new_token_and_restart(runtime, token)
except discord.PrivilegedIntentsRequired:
retry_delay = _intents_retry_delay(_intents_retry_attempt())
runtime.logger.error(
"FATAL: Required privileged intents (Server Members Intent, Message Content Intent) "
"are not enabled for this bot in the Discord Developer Portal."
)
runtime.logger.error(
"Enable them under Bot -> Privileged Gateway Intents - DDC retries the login "
"automatically in about %d s (or restart the container). The Web UI stays available "
"in the meantime.",
retry_delay,
)
_wait_for_new_token_and_restart(runtime, token, retry_after=retry_delay)
def main() -> None:
"""Main entry point for the Discord bot."""
install_sigterm_handler()
# Ensure event loop exists before creating bot (required for py-cord)
try:
asyncio.get_running_loop()
except RuntimeError:
_prepare_event_loop()
configure_environment()
config = load_main_configuration()
runtime = build_runtime(config)
_ensure_timezone(runtime.logger)
bot = create_bot(runtime)
register_event_handlers(bot, runtime)
# Set global bot instance for Web UI access
from services.scheduling.donation_message_service import set_bot_instance
set_bot_instance(bot)
# Retry loop for missing token with countdown
retry_interval = int(os.getenv("DDC_TOKEN_RETRY_INTERVAL", "60"))
max_retries = int(os.getenv("DDC_TOKEN_MAX_RETRIES", "0")) # 0 = infinite
retry_count = 0
while True:
token = get_decrypted_bot_token(runtime)
if token:
break
retry_count += 1
runtime.logger.error("FATAL: Bot token not found or could not be decrypted.")
runtime.logger.error(
"Please configure the bot token in the Web UI or check the configuration files."
)
if max_retries > 0 and retry_count >= max_retries:
runtime.logger.error(f"Maximum retries ({max_retries}) reached. Exiting.")
sys.exit(1)
runtime.logger.warning(f"Retry {retry_count}: Waiting {retry_interval} seconds before next attempt...")
# Countdown timer
for remaining in range(retry_interval, 0, -1):
if remaining % 10 == 0 or remaining <= 5:
runtime.logger.info(f"⏳ Retrying in {remaining} seconds...")
time.sleep(1)
runtime.logger.info("Attempting to reload configuration and retry...")
# Reload config in case it was updated
config = load_main_configuration()
runtime = build_runtime(config)
runtime.logger.info("Starting bot with token ending in: ...%s", token[-4:])
_run_bot_until_stopped(bot, runtime, token)
runtime.logger.info("Bot has stopped gracefully.")
def _shutdown_scheduler(logger: logging.Logger) -> None:
try:
logger.info("Stopping Scheduler Service...")
if stop_scheduler_service():
logger.info("Scheduler Service stopped successfully.")
else:
logger.warning("Scheduler Service could not be stopped or was not running.")
except (RuntimeError) as e:
logger.error("Error stopping Scheduler Service: %s", e, exc_info=True)
if __name__ == "__main__":
try:
try:
asyncio.get_running_loop()
logging.getLogger("ddc.bot").error("Event loop already running - this should not happen!")
sys.exit(1)
except RuntimeError:
pass
loop = _prepare_event_loop()
try:
main()
except KeyboardInterrupt:
logging.getLogger("ddc.bot").info("Received keyboard interrupt - shutting down gracefully")
finally:
pending = asyncio.all_tasks(loop)
for task in pending:
task.cancel()
if pending:
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
loop.close()
asyncio.set_event_loop(None)
except discord.LoginFailure:
logging.getLogger("ddc.bot").error("FATAL: Invalid Discord Bot Token provided.")
sys.exit(1)
except discord.PrivilegedIntentsRequired:
logging.getLogger("ddc.bot").error(
"FATAL: Necessary Privileged Intents are missing in the Discord Developer Portal!"
)
sys.exit(1)
except (RuntimeError, discord.Forbidden, discord.HTTPException, discord.NotFound) as e:
logging.getLogger("ddc.bot").error(
"FATAL: An unexpected error occurred during bot execution: %s", e, exc_info=True
)
sys.exit(1)
finally:
_shutdown_scheduler(logging.getLogger("ddc.bot"))