Skip to content

Commit ddffad6

Browse files
committed
v3.0.14
1 parent 5f25f48 commit ddffad6

7 files changed

Lines changed: 38 additions & 246 deletions

File tree

src/bot/cogs/open_ai.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import discord
22
from discord.ext import commands
3-
from openai import OpenAI
3+
from openai import AsyncOpenAI
44
from openai.types.chat import ChatCompletionSystemMessageParam, ChatCompletionUserMessageParam
55
from src.bot.constants.settings import get_bot_settings
66
from src.bot.discord_bot import Bot
@@ -14,7 +14,7 @@ class OpenAi(commands.Cog):
1414
def __init__(self, bot: Bot) -> None:
1515
self.bot = bot
1616
self._bot_settings = get_bot_settings()
17-
self._openai_client: OpenAI = OpenAI(api_key=self._bot_settings.openai_api_key)
17+
self._openai_client: AsyncOpenAI = AsyncOpenAI(api_key=self._bot_settings.openai_api_key)
1818

1919
@commands.command()
2020
@commands.cooldown(1, CoolDowns.OpenAI.value, commands.BucketType.user)
@@ -57,8 +57,8 @@ async def _get_ai_response(self, message: str) -> str:
5757
ChatCompletionUserMessageParam(role="user", content=message),
5858
]
5959

60-
# Use the correct OpenAI API endpoint
61-
response = self._openai_client.chat.completions.create(
60+
# Use the correct OpenAI API endpoint (async — does not block the event loop)
61+
response = await self._openai_client.chat.completions.create(
6262
model=model,
6363
messages=messages,
6464
max_completion_tokens=1000,

src/bot/tools/bot_utils.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ def _is_transient_discord_error(e: discord.HTTPException) -> bool:
123123
return (isinstance(status, int) and status >= 500) or code == 40062
124124

125125

126-
async def _send_with_retry(ctx, send_method, *args, max_attempts: int = 3, base_delay: float = 1.0, **kwargs):
126+
async def send_with_retry(ctx, send_method, *args, max_attempts: int = 3, base_delay: float = 1.0, **kwargs):
127127
"""Call send_method(*args, **kwargs) and retry on transient Discord errors.
128128
129129
On the first transient failure, posts a one-time "retrying" notice to the channel.
@@ -163,25 +163,25 @@ async def send_embed(ctx, embed, dm=False):
163163

164164
if is_private_message(ctx):
165165
# Already in DM, just send the embed
166-
await _send_with_retry(ctx, ctx.author.send, embed=embed)
166+
await send_with_retry(ctx, ctx.author.send, embed=embed)
167167
elif dm:
168168
# Send to DM and notify in channel
169169
try:
170-
await _send_with_retry(ctx, ctx.author.send, embed=embed)
170+
await send_with_retry(ctx, ctx.author.send, embed=embed)
171171
notification_embed = discord.Embed(
172172
description="📬 Response sent to your DM", color=discord.Color.green()
173173
)
174174
notification_embed.set_author(
175175
name=ctx.author.display_name,
176176
icon_url=ctx.author.avatar.url if ctx.author.avatar else ctx.author.default_avatar.url,
177177
)
178-
await _send_with_retry(ctx, ctx.send, embed=notification_embed)
178+
await send_with_retry(ctx, ctx.send, embed=notification_embed)
179179
except discord.Forbidden, discord.HTTPException:
180180
# DM failed, fall back to sending in the channel
181-
await _send_with_retry(ctx, ctx.send, embed=embed)
181+
await send_with_retry(ctx, ctx.send, embed=embed)
182182
else:
183183
# Send to channel
184-
await _send_with_retry(ctx, ctx.send, embed=embed)
184+
await send_with_retry(ctx, ctx.send, embed=embed)
185185
except (discord.Forbidden, discord.HTTPException) as e:
186186
ctx.bot.log.error(f"Failed to send message: {e}")
187187
if dm or is_private_message(ctx):
@@ -240,10 +240,10 @@ def _dict_to_embed(data: dict) -> discord.Embed:
240240
async def send_and_save(self, ctx) -> None:
241241
"""Send the first page and save all pages to the database.
242242
243-
Uses _send_with_retry so transient Discord errors (5xx, code 40062) are
243+
Uses send_with_retry so transient Discord errors (5xx, code 40062) are
244244
retried before propagating to the command error handler.
245245
"""
246-
msg = await _send_with_retry(ctx, ctx.send, embed=self.pages[0], view=self)
246+
msg = await send_with_retry(ctx, ctx.send, embed=self.pages[0], view=self)
247247
self.message = msg
248248
from src.database.dal.bot.embed_pages_dal import EmbedPagesDal
249249

src/gw2/cogs/account.py

Lines changed: 1 addition & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,6 @@
1111
from src.gw2.tools.gw2_cooldowns import GW2CoolDowns
1212

1313

14-
async def _keep_typing_alive(ctx, stop_event):
15-
"""Helper to keep Discord typing indicator alive during long operations."""
16-
try:
17-
while not stop_event.is_set():
18-
try:
19-
await ctx.message.channel.typing()
20-
await asyncio.sleep(4) # Renew every 4 seconds (Discord typing lasts ~5s)
21-
except asyncio.CancelledError:
22-
raise # Re-raise CancelledError
23-
except discord.HTTPException, discord.Forbidden:
24-
# Handle Discord API errors gracefully and stop the loop
25-
break
26-
except asyncio.CancelledError:
27-
# Clean up and re-raise CancelledError as required
28-
raise
29-
30-
3114
async def _fetch_guild_info_standalone(gw2_api, guild_id, api_key, ctx):
3215
"""Helper to fetch individual guild information."""
3316
try:
@@ -83,10 +66,6 @@ async def account(ctx):
8366
if "account" not in permissions:
8467
return await bot_utils.send_error_msg(ctx, gw2_messages.API_KEY_NO_PERMISSION, True)
8568

86-
# Initialize variables for cleanup
87-
stop_typing = None
88-
typing_task = None
89-
9069
try:
9170
# Send progress message as embed
9271
color = ctx.bot.settings["gw2"]["EmbedColor"]
@@ -95,11 +74,7 @@ async def account(ctx):
9574
color=color,
9675
)
9776
progress_embed.set_author(name=ctx.message.author.display_name, icon_url=ctx.message.author.display_avatar.url)
98-
progress_msg = await ctx.send(embed=progress_embed)
99-
100-
# Start background typing keeper
101-
stop_typing = asyncio.Event()
102-
typing_task = asyncio.create_task(_keep_typing_alive(ctx, stop_typing))
77+
progress_msg = await bot_utils.send_with_retry(ctx, ctx.send, embed=progress_embed)
10378

10479
# Fetch basic account info and server info in parallel
10580
account_task = gw2_api.call_api("account", api_key)
@@ -251,23 +226,11 @@ async def limited_guild_fetch(task):
251226
text=f"{bot_utils.get_current_date_time_str_long()} UTC",
252227
)
253228

254-
# Stop the background typing task
255-
stop_typing.set()
256-
typing_task.cancel()
257-
258229
# Clean up progress message and send final result
259230
await progress_msg.delete()
260231
await bot_utils.send_embed(ctx, embed)
261232

262233
except Exception as e:
263-
# Stop the background typing task if it exists
264-
if stop_typing is not None and typing_task is not None:
265-
try:
266-
stop_typing.set()
267-
typing_task.cancel()
268-
except AttributeError, RuntimeError:
269-
# Handle cases where task is already done or event is invalid
270-
pass
271234
await bot_utils.send_error_msg(ctx, e)
272235
return ctx.bot.log.error(ctx, e)
273236

src/gw2/tools/gw2_utils.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,17 @@ class Gw2Servers(Enum):
8989
async def send_progress_embed(
9090
ctx: commands.Context, message: str = "Please wait, I'm fetching data from GW2 API... (this may take a moment)"
9191
) -> discord.Message:
92-
"""Send a progress embed that can be deleted when the operation completes."""
92+
"""Send a progress embed that can be deleted when the operation completes.
93+
94+
Uses send_with_retry so transient Discord errors (5xx, code 40062) are retried
95+
instead of bubbling up to the command error handler.
96+
"""
97+
from src.bot.tools.bot_utils import send_with_retry
98+
9399
color = ctx.bot.settings["gw2"]["EmbedColor"]
94100
embed = discord.Embed(description=f"\U0001f504 **{message}**", color=color)
95101
embed.set_author(name=ctx.message.author.display_name, icon_url=ctx.message.author.display_avatar.url)
96-
return await ctx.send(embed=embed)
102+
return await send_with_retry(ctx, ctx.send, embed=embed)
97103

98104

99105
async def send_msg(ctx: commands.Context, description: str, dm: bool = False) -> None:

tests/unit/bot/cogs/test_open_ai.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def mock_bot():
2828
@pytest.fixture
2929
def openai_cog(mock_bot):
3030
"""Create an OpenAi cog instance."""
31-
with patch("src.bot.cogs.open_ai.get_bot_settings") as mock_settings, patch("src.bot.cogs.open_ai.OpenAI"):
31+
with patch("src.bot.cogs.open_ai.get_bot_settings") as mock_settings, patch("src.bot.cogs.open_ai.AsyncOpenAI"):
3232
mock_settings.return_value = MagicMock(openai_api_key="test-key", openai_model="gpt-3.5-turbo")
3333
return OpenAi(mock_bot)
3434

@@ -79,7 +79,7 @@ class TestOpenAi:
7979

8080
def test_init(self, mock_bot):
8181
"""Test OpenAi cog initialization."""
82-
with patch("src.bot.cogs.open_ai.get_bot_settings") as mock_settings, patch("src.bot.cogs.open_ai.OpenAI"):
82+
with patch("src.bot.cogs.open_ai.get_bot_settings") as mock_settings, patch("src.bot.cogs.open_ai.AsyncOpenAI"):
8383
mock_settings.return_value = MagicMock(openai_api_key="test-key", openai_model="gpt-3.5-turbo")
8484
cog = OpenAi(mock_bot)
8585
assert cog.bot == mock_bot
@@ -140,7 +140,7 @@ async def test_get_ai_response_success(
140140

141141
# Mock the client instance directly
142142
mock_client = MagicMock()
143-
mock_client.chat.completions.create.return_value = mock_openai_response
143+
mock_client.chat.completions.create = AsyncMock(return_value=mock_openai_response)
144144
openai_cog._openai_client = mock_client
145145

146146
result = await openai_cog._get_ai_response("What is Python?")
@@ -173,7 +173,7 @@ async def test_get_ai_response_with_leading_trailing_spaces(
173173

174174
# Mock the client instance directly
175175
mock_client = MagicMock()
176-
mock_client.chat.completions.create.return_value = mock_openai_response
176+
mock_client.chat.completions.create = AsyncMock(return_value=mock_openai_response)
177177
openai_cog._openai_client = mock_client
178178

179179
result = await openai_cog._get_ai_response("Test message")
@@ -250,7 +250,7 @@ async def test_ai_command_with_different_models(self, mock_send_embed, openai_co
250250

251251
# Mock the client instance directly
252252
mock_client = MagicMock()
253-
mock_client.chat.completions.create.return_value = mock_openai_response
253+
mock_client.chat.completions.create = AsyncMock(return_value=mock_openai_response)
254254
openai_cog._openai_client = mock_client
255255

256256
await openai_cog.ai.callback(openai_cog, mock_ctx, msg_text="Test question")
@@ -303,7 +303,7 @@ async def test_get_ai_response_system_message_content(
303303

304304
# Mock the client instance directly
305305
mock_client = MagicMock()
306-
mock_client.chat.completions.create.return_value = mock_openai_response
306+
mock_client.chat.completions.create = AsyncMock(return_value=mock_openai_response)
307307
openai_cog._openai_client = mock_client
308308

309309
await openai_cog._get_ai_response("Test message")
@@ -323,7 +323,7 @@ async def test_get_ai_response_api_parameters(
323323

324324
# Mock the client instance directly
325325
mock_client = MagicMock()
326-
mock_client.chat.completions.create.return_value = mock_openai_response
326+
mock_client.chat.completions.create = AsyncMock(return_value=mock_openai_response)
327327
openai_cog._openai_client = mock_client
328328

329329
await openai_cog._get_ai_response("Test message")
@@ -348,7 +348,7 @@ async def test_setup_function(self, mock_bot):
348348
"""Test the setup function."""
349349
from src.bot.cogs.open_ai import setup
350350

351-
with patch("src.bot.cogs.open_ai.get_bot_settings") as mock_settings, patch("src.bot.cogs.open_ai.OpenAI"):
351+
with patch("src.bot.cogs.open_ai.get_bot_settings") as mock_settings, patch("src.bot.cogs.open_ai.AsyncOpenAI"):
352352
mock_settings.return_value = MagicMock(openai_api_key="test-key", openai_model="gpt-3.5-turbo")
353353
await setup(mock_bot)
354354

@@ -414,7 +414,7 @@ async def test_get_ai_response_empty_response(self, mock_get_settings, openai_co
414414

415415
# Mock the client instance directly
416416
mock_client = MagicMock()
417-
mock_client.chat.completions.create.return_value = mock_response
417+
mock_client.chat.completions.create = AsyncMock(return_value=mock_response)
418418
openai_cog._openai_client = mock_client
419419

420420
result = await openai_cog._get_ai_response("Test message")

tests/unit/bot/tools/test_bot_utils_extra.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1107,7 +1107,7 @@ def _make_http_exception(status: int, code: int = 0) -> discord.HTTPException:
11071107

11081108

11091109
class TestSendWithRetry:
1110-
"""Test _send_with_retry helper for transient Discord errors."""
1110+
"""Test send_with_retry helper for transient Discord errors."""
11111111

11121112
@pytest.fixture
11131113
def mock_ctx(self):
@@ -1121,7 +1121,7 @@ def mock_ctx(self):
11211121
async def test_success_on_first_attempt_no_retry(self, mock_ctx):
11221122
"""Happy path: send_method called once, no notice sent."""
11231123
send = AsyncMock(return_value="ok")
1124-
result = await bot_utils._send_with_retry(mock_ctx, send, embed="x")
1124+
result = await bot_utils.send_with_retry(mock_ctx, send, embed="x")
11251125
assert result == "ok"
11261126
send.assert_awaited_once_with(embed="x")
11271127
mock_ctx.send.assert_not_called()
@@ -1131,7 +1131,7 @@ async def test_retries_on_500_then_succeeds(self, mock_ctx):
11311131
"""500 error → retry, second attempt succeeds; one channel notice sent."""
11321132
send = AsyncMock(side_effect=[_make_http_exception(500), "ok"])
11331133
with patch("src.bot.tools.bot_utils.asyncio.sleep", new_callable=AsyncMock):
1134-
result = await bot_utils._send_with_retry(mock_ctx, send, embed="x")
1134+
result = await bot_utils.send_with_retry(mock_ctx, send, embed="x")
11351135
assert result == "ok"
11361136
assert send.await_count == 2
11371137
# Notice sent exactly once
@@ -1144,7 +1144,7 @@ async def test_retries_on_429_code_40062(self, mock_ctx):
11441144
"""429 with code 40062 is treated as transient and retried."""
11451145
send = AsyncMock(side_effect=[_make_http_exception(429, code=40062), "ok"])
11461146
with patch("src.bot.tools.bot_utils.asyncio.sleep", new_callable=AsyncMock):
1147-
result = await bot_utils._send_with_retry(mock_ctx, send)
1147+
result = await bot_utils.send_with_retry(mock_ctx, send)
11481148
assert result == "ok"
11491149
assert send.await_count == 2
11501150

@@ -1154,7 +1154,7 @@ async def test_does_not_retry_on_403_forbidden(self, mock_ctx):
11541154
forbidden = discord.Forbidden(MagicMock(status=403), {"message": "no", "code": 50007})
11551155
send = AsyncMock(side_effect=forbidden)
11561156
with pytest.raises(discord.Forbidden):
1157-
await bot_utils._send_with_retry(mock_ctx, send)
1157+
await bot_utils.send_with_retry(mock_ctx, send)
11581158
send.assert_awaited_once()
11591159
mock_ctx.send.assert_not_called()
11601160

@@ -1163,7 +1163,7 @@ async def test_does_not_retry_on_429_other_code(self, mock_ctx):
11631163
"""429 without code 40062 is not retried by this helper."""
11641164
send = AsyncMock(side_effect=_make_http_exception(429, code=20016))
11651165
with pytest.raises(discord.HTTPException):
1166-
await bot_utils._send_with_retry(mock_ctx, send)
1166+
await bot_utils.send_with_retry(mock_ctx, send)
11671167
send.assert_awaited_once()
11681168

11691169
@pytest.mark.asyncio
@@ -1172,7 +1172,7 @@ async def test_exhausts_retries_then_raises(self, mock_ctx):
11721172
send = AsyncMock(side_effect=_make_http_exception(500))
11731173
with patch("src.bot.tools.bot_utils.asyncio.sleep", new_callable=AsyncMock):
11741174
with pytest.raises(discord.HTTPException):
1175-
await bot_utils._send_with_retry(mock_ctx, send, max_attempts=3)
1175+
await bot_utils.send_with_retry(mock_ctx, send, max_attempts=3)
11761176
assert send.await_count == 3
11771177
# Notice sent at most once even across multiple failed attempts
11781178
assert mock_ctx.send.call_count == 1
@@ -1183,6 +1183,6 @@ async def test_notice_failure_does_not_break_retry(self, mock_ctx):
11831183
mock_ctx.send.side_effect = _make_http_exception(500)
11841184
send = AsyncMock(side_effect=[_make_http_exception(500), "ok"])
11851185
with patch("src.bot.tools.bot_utils.asyncio.sleep", new_callable=AsyncMock):
1186-
result = await bot_utils._send_with_retry(mock_ctx, send)
1186+
result = await bot_utils.send_with_retry(mock_ctx, send)
11871187
assert result == "ok"
11881188
assert send.await_count == 2

0 commit comments

Comments
 (0)