Skip to content

Commit d73da3b

Browse files
authored
feat: Shared bot (#173)
1 parent f180d5e commit d73da3b

5 files changed

Lines changed: 185 additions & 65 deletions

File tree

readme.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,11 @@ bot:
179179
If Redis cache is requested but no configuration is provided, Botkit will fall back to
180180
memory cache with a warning.
181181

182+
When the bot and backend run in the same process (`use.bot` and `use.backend` both
183+
enabled), Botkit uses a single `CustomBot` instance so in-memory cache and Discord
184+
state are shared. Use Redis if you run multiple processes or containers and need a
185+
shared cache across them.
186+
182187
## Creating Extensions
183188

184189
Extensions are in truth just python located in the `src/extensions` directory. When

src/start.py

Lines changed: 65 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,50 @@
99
"""
1010

1111
import asyncio
12+
import contextlib
1213

14+
from fastapi import FastAPI
15+
16+
from src import custom
1317
from src.config import config
18+
from src.config.models import BotConfig
1419
from src.log import logger
15-
from src.startup import (
16-
load_extensions,
17-
run_startup_functions,
18-
setup_and_start_backend,
19-
setup_and_start_bot,
20+
from src.startup import load_extensions, run_startup_functions
21+
from src.startup.backend import (
22+
create_backend_app,
23+
run_backend_only,
24+
serve_backend,
25+
setup_backend_extensions,
2026
)
21-
from src.startup.backend import create_backend_app, create_backend_bot
27+
from src.startup.bot import create_bot, run_bot_connection, setup_bot, start_bot
2228
from src.utils import unzip_extensions
2329

2430

31+
async def run_bot_and_backend(
32+
bot: custom.Bot,
33+
app: FastAPI,
34+
bot_config: BotConfig,
35+
) -> None:
36+
"""Run the Discord gateway and backend API on one shared bot instance."""
37+
try:
38+
async with bot: # https://github.com/Pycord-Development/pycord/issues/2958
39+
serve_task = asyncio.create_task(serve_backend(app, config.backend))
40+
try:
41+
await run_bot_connection(
42+
bot,
43+
bot_config.token,
44+
bot_config.rest,
45+
bot_config.public_key,
46+
)
47+
finally:
48+
serve_task.cancel()
49+
with contextlib.suppress(asyncio.CancelledError):
50+
await serve_task
51+
except Exception as e: # noqa: BLE001
52+
logger.critical("An error occurred while running the bot and backend together.")
53+
logger.debug("", exc_info=e)
54+
55+
2556
async def start(run_bot: bool | None = None, run_backend: bool | None = None) -> None:
2657
"""Start the bot and/or backend server based on configuration.
2758
@@ -47,21 +78,38 @@ async def start(run_bot: bool | None = None, run_backend: bool | None = None) ->
4778

4879
bot_functions, back_functions, startup_functions, translations = load_extensions()
4980

50-
coros: list[asyncio.Task[None]] = []
81+
start_bot_extensions = bool(bot_functions and run_bot)
82+
start_backend_server = bool(back_functions and run_backend)
5183

52-
if bot_functions and run_bot:
53-
coros.append(asyncio.create_task(setup_and_start_bot(bot_functions, translations, config.bot)))
54-
55-
if back_functions and run_backend:
56-
coros.append(asyncio.create_task(setup_and_start_backend(back_functions)))
57-
58-
if not coros:
84+
if not start_bot_extensions and not start_backend_server:
5985
logger.error("Nothing to start, exiting...")
6086
return
6187

88+
app = None
89+
bot = create_bot(config.bot)
90+
if start_bot_extensions:
91+
setup_bot(bot, bot_functions, translations, config.bot)
92+
if start_backend_server:
93+
app = create_backend_app()
94+
setup_backend_extensions(app, bot, back_functions)
95+
6296
if startup_functions:
63-
app = create_backend_app() if (back_functions and run_backend) else None
64-
bot = create_backend_bot() if (back_functions and run_backend) else None
6597
await run_startup_functions(startup_functions, app, bot)
6698

67-
await asyncio.gather(*coros)
99+
if start_bot_extensions and start_backend_server:
100+
if config.bot.rest:
101+
logger.critical(
102+
"REST bot mode and the Botkit backend cannot run together in one process. "
103+
"Disable bot.rest or use.backend."
104+
)
105+
return
106+
if app is None:
107+
logger.error("Backend app was not initialized, exiting...")
108+
return
109+
await run_bot_and_backend(bot, app, config.bot)
110+
elif start_bot_extensions:
111+
await start_bot(bot, config.bot.token, config.bot.rest, config.bot.public_key)
112+
elif app is None:
113+
logger.error("Backend app was not initialized, exiting...")
114+
else:
115+
await run_backend_only(app, bot, config.bot.token, config.backend)

src/startup/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,19 @@
88
"""
99

1010
from src.startup.backend import (
11+
run_backend_only,
1112
run_startup_functions,
13+
serve_backend,
1214
setup_and_start_backend,
1315
)
1416
from src.startup.bot import setup_and_start_bot
1517
from src.startup.loader import load_extensions
1618

1719
__all__ = [
1820
"load_extensions",
21+
"run_backend_only",
1922
"run_startup_functions",
23+
"serve_backend",
2024
"setup_and_start_backend",
2125
"setup_and_start_bot",
2226
]

src/startup/backend.py

Lines changed: 67 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
if TYPE_CHECKING:
2020
from collections.abc import Awaitable
2121

22+
from src import custom
23+
2224

2325
def create_backend_app() -> FastAPI:
2426
"""Create a FastAPI application for the backend server.
@@ -30,17 +32,15 @@ def create_backend_app() -> FastAPI:
3032
return FastAPI(title="Botkit Backend")
3133

3234

33-
def create_backend_bot() -> discord.Bot:
34-
"""Create a minimal Discord bot for the backend server.
35-
36-
The backend server needs a bot instance for certain operations,
37-
but it doesn't need the full custom bot setup.
38-
39-
Returns:
40-
A basic Discord bot with default intents
35+
def create_backend_bot() -> "custom.Bot":
36+
"""Create a bot for backend-only mode.
4137
38+
Deprecated in favor of :func:`src.startup.bot.create_bot`, which applies cache
39+
and other bot configuration. Kept for backward compatibility.
4240
"""
43-
return discord.Bot(intents=discord.Intents.default())
41+
from src.startup.bot import create_bot # noqa: PLC0415
42+
43+
return create_bot(config.bot)
4444

4545

4646
def setup_backend_extensions(
@@ -80,55 +80,93 @@ async def run_startup_functions(
8080
await asyncio.gather(*startup_coros)
8181

8282

83-
async def start_backend(app: FastAPI, bot: discord.Bot, token: str, backend_config: BackendConfig) -> None:
83+
def _uvicorn_config(app: FastAPI, backend_config: BackendConfig) -> uvicorn.Config:
84+
return uvicorn.Config(
85+
app=app,
86+
host=backend_config.host,
87+
port=backend_config.port,
88+
access_log=backend_config.access_log,
89+
server_header=backend_config.server_header,
90+
log_config=None,
91+
)
92+
93+
94+
async def serve_backend(app: FastAPI, backend_config: BackendConfig) -> None:
95+
"""Run the FastAPI app with Uvicorn (no Discord login)."""
96+
await uvicorn.Server(_uvicorn_config(app, backend_config)).serve()
97+
98+
99+
async def run_backend_only(
100+
app: FastAPI,
101+
bot: discord.Bot,
102+
token: str,
103+
backend_config: BackendConfig,
104+
) -> None:
105+
"""Log in to Discord and serve the backend (backend-only mode)."""
106+
try:
107+
if not bot.user:
108+
await bot.login(token)
109+
await serve_backend(app, backend_config)
110+
except Exception as e: # noqa: BLE001
111+
logger.critical("An error occurred while starting the backend server.")
112+
logger.debug("", exc_info=e)
113+
114+
115+
async def start_backend(
116+
app: FastAPI,
117+
bot: discord.Bot,
118+
token: str,
119+
backend_config: BackendConfig,
120+
*,
121+
login: bool = True,
122+
) -> None:
84123
"""Start the backend server with Uvicorn.
85124
86125
Args:
87126
app: The FastAPI application to serve
88-
bot: The Discord bot instance (for login)
89-
token: Discord bot token
127+
bot: The Discord bot instance
128+
token: Discord bot token (used when ``login`` is True)
90129
backend_config: Backend server settings
130+
login: When True, log in before serving (backend-only). When False, the caller
131+
is responsible for authentication (combined bot + backend mode).
91132
92133
"""
93134
try:
94-
await bot.login(token)
95-
uvicorn_config = uvicorn.Config(
96-
app=app,
97-
host=backend_config.host,
98-
port=backend_config.port,
99-
access_log=backend_config.access_log,
100-
server_header=backend_config.server_header,
101-
log_config=None,
102-
)
103-
104-
await uvicorn.Server(uvicorn_config).serve()
135+
if login and not bot.user:
136+
await bot.login(token)
137+
await serve_backend(app, backend_config)
105138
except Exception as e: # noqa: BLE001
106139
logger.critical("An error occurred while starting the backend server.")
107140
logger.debug("", exc_info=e)
108141

109142

110143
async def setup_and_start_backend(
111144
back_functions: WebserverFunctionList,
145+
bot: "custom.Bot | None" = None,
112146
) -> None:
113-
"""Create, configure, and start the backend server.
114-
115-
This is a convenience function that combines backend app creation,
116-
bot creation, extension setup, and server startup.
147+
"""Configure and start the backend server.
117148
118149
Args:
119150
back_functions: List of (setup_webserver_function, config) tuples for extensions
151+
bot: Optional existing bot instance. When omitted, a new bot is created via
152+
:func:`src.startup.bot.create_bot`.
120153
121154
"""
155+
from src.startup.bot import create_bot # noqa: PLC0415
156+
122157
app = create_backend_app()
123-
bot = create_backend_bot()
158+
if bot is None:
159+
bot = create_bot(config.bot)
124160
setup_backend_extensions(app, bot, back_functions)
125-
await start_backend(app, bot, config.bot.token, config.backend)
161+
await run_backend_only(app, bot, config.bot.token, config.backend)
126162

127163

128164
__all__ = [
129165
"create_backend_app",
130166
"create_backend_bot",
167+
"run_backend_only",
131168
"run_startup_functions",
169+
"serve_backend",
132170
"setup_and_start_backend",
133171
"setup_backend_extensions",
134172
"start_backend",

src/startup/bot.py

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,45 @@ def configure_bot_features(bot: custom.Bot, config: BotConfig) -> None:
7878
bot._pending_application_commands = [] # noqa: SLF001 # pyright: ignore[reportPrivateUsage]
7979

8080

81+
def setup_bot(
82+
bot: custom.Bot,
83+
bot_functions: SetupFunctionList,
84+
translations: list[ExtensionTranslation],
85+
config: BotConfig,
86+
) -> None:
87+
"""Configure extensions and feature flags on an existing bot instance."""
88+
setup_bot_extensions(bot, bot_functions, translations)
89+
configure_bot_features(bot, config)
90+
91+
92+
async def run_bot_connection(
93+
bot: custom.Bot,
94+
token: str,
95+
rest_config: RestConfig,
96+
public_key: str | None = None,
97+
) -> None:
98+
"""Connect the bot to Discord without managing the client context manager.
99+
100+
The caller must enter ``async with bot`` when connection lifecycle is shared
101+
(for example when running the backend in the same process).
102+
"""
103+
if isinstance(bot, custom.CustomRestBot):
104+
if not public_key:
105+
raise TypeError("CustomRestBot requires a public key to start.")
106+
start_kwargs: dict[str, Any] = {
107+
"token": token,
108+
"public_key": public_key,
109+
"health": rest_config.health,
110+
"uvicorn_options": {
111+
"host": rest_config.host,
112+
"port": rest_config.port,
113+
},
114+
}
115+
await bot.start(**start_kwargs)
116+
else:
117+
await bot.start(token)
118+
119+
81120
async def start_bot(bot: custom.Bot, token: str, rest_config: RestConfig, public_key: str | None = None) -> None:
82121
"""Start the bot with appropriate configuration.
83122
@@ -92,23 +131,8 @@ async def start_bot(bot: custom.Bot, token: str, rest_config: RestConfig, public
92131
93132
"""
94133
try:
95-
if isinstance(bot, custom.CustomRestBot):
96-
if not public_key:
97-
raise TypeError("CustomRestBot requires a public key to start.") # noqa: TRY301
98-
start_kwargs: dict[str, Any] = {
99-
"token": token,
100-
"public_key": public_key,
101-
"health": rest_config.health,
102-
"uvicorn_options": {
103-
"host": rest_config.host,
104-
"port": rest_config.port,
105-
},
106-
}
107-
async with bot: # https://github.com/Pycord-Development/pycord/issues/2958
108-
await bot.start(**start_kwargs)
109-
else:
110-
async with bot:
111-
await bot.start(token)
134+
async with bot: # https://github.com/Pycord-Development/pycord/issues/2958
135+
await run_bot_connection(bot, token, rest_config, public_key)
112136
except discord.LoginFailure as e:
113137
logger.critical("Failed to log in, is the bot token valid?")
114138
logger.debug("", exc_info=e)
@@ -134,15 +158,16 @@ async def setup_and_start_bot(
134158
135159
"""
136160
bot = create_bot(config)
137-
setup_bot_extensions(bot, bot_functions, translations)
138-
configure_bot_features(bot, config)
161+
setup_bot(bot, bot_functions, translations, config)
139162
await start_bot(bot, config.token, config.rest, config.public_key)
140163

141164

142165
__all__ = [
143166
"configure_bot_features",
144167
"create_bot",
168+
"run_bot_connection",
145169
"setup_and_start_bot",
170+
"setup_bot",
146171
"setup_bot_extensions",
147172
"start_bot",
148173
]

0 commit comments

Comments
 (0)