|
36 | 36 | get_merged_pr_count_and_last_time, |
37 | 37 | group_pending_requests_by_repo, |
38 | 38 | ) |
39 | | -from ghdcbot.engine.notifications import send_notification_for_event |
| 39 | +from ghdcbot.engine.notifications import ( |
| 40 | + _build_dedupe_key, |
| 41 | + _mark_notification_sent, |
| 42 | + send_notification_for_event, |
| 43 | +) |
| 44 | +from ghdcbot.core.models import ContributionEvent |
40 | 45 | from ghdcbot.engine.pr_context import ( |
41 | 46 | build_pr_embed, |
42 | 47 | fetch_pr_context, |
@@ -1280,6 +1285,57 @@ async def approve_assign(self, interaction: discord.Interaction, button: discord |
1280 | 1285 | f"**Link:** https://github.com/{self.owner}/{self.repo}/issues/{self.issue_number}\n\n" |
1281 | 1286 | f"💡 You're now responsible for this issue. Good luck!" |
1282 | 1287 | ) |
| 1288 | + |
| 1289 | + # Mark notification as sent to prevent duplicate when /sync runs |
| 1290 | + try: |
| 1291 | + mentor_github = resolve_discord_to_github(self.storage, str(interaction.user.id)) |
| 1292 | + payload = { |
| 1293 | + "issue_number": self.issue_number, |
| 1294 | + "title": issue_title, |
| 1295 | + "state": issue.get("state", "open") if issue else "open", |
| 1296 | + "labels": [label.get("name") for label in issue.get("labels", [])] if issue else [], |
| 1297 | + } |
| 1298 | + if mentor_github: |
| 1299 | + payload["assigned_by"] = mentor_github |
| 1300 | + |
| 1301 | + event = ContributionEvent( |
| 1302 | + github_user=self.requester_github, |
| 1303 | + event_type="issue_assigned", |
| 1304 | + repo=self.repo, |
| 1305 | + created_at=datetime.now(timezone.utc), |
| 1306 | + payload=payload, |
| 1307 | + ) |
| 1308 | + |
| 1309 | + dedupe_key = _build_dedupe_key(event, self.requester_github) |
| 1310 | + _mark_notification_sent( |
| 1311 | + self.storage, |
| 1312 | + dedupe_key, |
| 1313 | + event, |
| 1314 | + self.requester_discord_id, |
| 1315 | + None, # channel_id=None for DM |
| 1316 | + self.requester_github, |
| 1317 | + ) |
| 1318 | + logger.debug( |
| 1319 | + "Marked issue assignment notification as sent to prevent duplicate", |
| 1320 | + extra={ |
| 1321 | + "dedupe_key": dedupe_key, |
| 1322 | + "issue_number": self.issue_number, |
| 1323 | + "repo": self.repo, |
| 1324 | + "assignee": self.requester_github, |
| 1325 | + }, |
| 1326 | + ) |
| 1327 | + except Exception as e: |
| 1328 | + # Log error but don't fail the approval flow |
| 1329 | + logger.warning( |
| 1330 | + "Failed to mark notification as sent (may result in duplicate notification)", |
| 1331 | + exc_info=e, |
| 1332 | + extra={ |
| 1333 | + "issue_number": self.issue_number, |
| 1334 | + "repo": self.repo, |
| 1335 | + "assignee": self.requester_github, |
| 1336 | + }, |
| 1337 | + ) |
| 1338 | + |
1283 | 1339 | await interaction.followup.send("✅ Request approved and issue assigned.", ephemeral=True) |
1284 | 1340 | if hasattr(self, "message") and self.message: |
1285 | 1341 | try: |
@@ -1321,6 +1377,57 @@ async def replace_assignee(self, interaction: discord.Interaction, button: disco |
1321 | 1377 | f"ℹ️ Note: The previous assignee was replaced.\n\n" |
1322 | 1378 | f"💡 You're now responsible for this issue. Good luck!" |
1323 | 1379 | ) |
| 1380 | + |
| 1381 | + # Mark notification as sent to prevent duplicate when /sync runs |
| 1382 | + try: |
| 1383 | + mentor_github = resolve_discord_to_github(self.storage, str(interaction.user.id)) |
| 1384 | + payload = { |
| 1385 | + "issue_number": self.issue_number, |
| 1386 | + "title": issue_title, |
| 1387 | + "state": issue.get("state", "open") if issue else "open", |
| 1388 | + "labels": [label.get("name") for label in issue.get("labels", [])] if issue else [], |
| 1389 | + } |
| 1390 | + if mentor_github: |
| 1391 | + payload["assigned_by"] = mentor_github |
| 1392 | + |
| 1393 | + event = ContributionEvent( |
| 1394 | + github_user=self.requester_github, |
| 1395 | + event_type="issue_assigned", |
| 1396 | + repo=self.repo, |
| 1397 | + created_at=datetime.now(timezone.utc), |
| 1398 | + payload=payload, |
| 1399 | + ) |
| 1400 | + |
| 1401 | + dedupe_key = _build_dedupe_key(event, self.requester_github) |
| 1402 | + _mark_notification_sent( |
| 1403 | + self.storage, |
| 1404 | + dedupe_key, |
| 1405 | + event, |
| 1406 | + self.requester_discord_id, |
| 1407 | + None, # channel_id=None for DM |
| 1408 | + self.requester_github, |
| 1409 | + ) |
| 1410 | + logger.debug( |
| 1411 | + "Marked issue assignment notification as sent to prevent duplicate (replacement)", |
| 1412 | + extra={ |
| 1413 | + "dedupe_key": dedupe_key, |
| 1414 | + "issue_number": self.issue_number, |
| 1415 | + "repo": self.repo, |
| 1416 | + "assignee": self.requester_github, |
| 1417 | + }, |
| 1418 | + ) |
| 1419 | + except Exception as e: |
| 1420 | + # Log error but don't fail the approval flow |
| 1421 | + logger.warning( |
| 1422 | + "Failed to mark notification as sent (may result in duplicate notification)", |
| 1423 | + exc_info=e, |
| 1424 | + extra={ |
| 1425 | + "issue_number": self.issue_number, |
| 1426 | + "repo": self.repo, |
| 1427 | + "assignee": self.requester_github, |
| 1428 | + }, |
| 1429 | + ) |
| 1430 | + |
1324 | 1431 | await interaction.followup.send("🔁 Replaced assignee and assigned contributor.", ephemeral=True) |
1325 | 1432 | if hasattr(self, "message") and self.message: |
1326 | 1433 | try: |
@@ -1435,7 +1542,7 @@ async def request_issue_cmd(interaction: discord.Interaction, issue_url: str) -> |
1435 | 1542 | ) |
1436 | 1543 | @app_commands.check(mentor_check) |
1437 | 1544 | async def issue_requests_cmd(interaction: discord.Interaction) -> None: |
1438 | | - await interaction.response.defer(ephemeral=False) |
| 1545 | + await interaction.response.defer(ephemeral=True) |
1439 | 1546 | pending = getattr(storage, "list_pending_issue_requests", None) |
1440 | 1547 | if not callable(pending): |
1441 | 1548 | await interaction.followup.send("❌ Request list unavailable.", ephemeral=True) |
@@ -1464,7 +1571,7 @@ async def issue_requests_cmd(interaction: discord.Interaction) -> None: |
1464 | 1571 | await interaction.followup.send( |
1465 | 1572 | embed=discord.Embed.from_dict(embed_dict), |
1466 | 1573 | view=view, |
1467 | | - ephemeral=False, |
| 1574 | + ephemeral=True, |
1468 | 1575 | ) |
1469 | 1576 |
|
1470 | 1577 | @client.event |
@@ -1599,19 +1706,46 @@ async def sync_cmd(interaction: discord.Interaction) -> None: |
1599 | 1706 | async def on_app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError) -> None: |
1600 | 1707 | """Handle app command errors, including check failures.""" |
1601 | 1708 | if isinstance(error, app_commands.CheckFailure): |
1602 | | - mentor_roles = getattr(config, "assignments", None) |
1603 | | - issue_assignee_roles = getattr(mentor_roles, "issue_assignees", []) if mentor_roles else [] |
1604 | | - await interaction.response.send_message( |
1605 | | - f"❌ Permission denied. Only mentors with roles {', '.join(issue_assignee_roles) or 'configure issue_assignees'} can use this command.", |
1606 | | - ephemeral=True, |
1607 | | - ) |
| 1709 | + try: |
| 1710 | + mentor_roles = getattr(config, "assignments", None) |
| 1711 | + issue_assignee_roles = getattr(mentor_roles, "issue_assignees", []) if mentor_roles else [] |
| 1712 | + role_list = ', '.join(issue_assignee_roles) if issue_assignee_roles else 'configure issue_assignees' |
| 1713 | + error_message = f"❌ Permission denied. Only mentors with roles **{role_list}** can use this command." |
| 1714 | + |
| 1715 | + logger.info( |
| 1716 | + "Check failure for user %s (%s) on command %s. Required roles: %s", |
| 1717 | + interaction.user.name, |
| 1718 | + interaction.user.id, |
| 1719 | + interaction.command.name if interaction.command else "unknown", |
| 1720 | + role_list, |
| 1721 | + ) |
| 1722 | + |
| 1723 | + # Check if response is already sent (shouldn't happen for check failures, but be safe) |
| 1724 | + if interaction.response.is_done(): |
| 1725 | + await interaction.followup.send(error_message, ephemeral=True) |
| 1726 | + else: |
| 1727 | + await interaction.response.send_message(error_message, ephemeral=True) |
| 1728 | + except Exception as e: |
| 1729 | + logger.exception("Failed to send permission denied message", exc_info=e) |
| 1730 | + # Try one more time with a simple message |
| 1731 | + try: |
| 1732 | + if not interaction.response.is_done(): |
| 1733 | + await interaction.response.send_message( |
| 1734 | + "❌ Permission denied. Only mentors can use this command.", |
| 1735 | + ephemeral=True, |
| 1736 | + ) |
| 1737 | + except Exception: |
| 1738 | + logger.error("Could not send any error message to user") |
1608 | 1739 | else: |
1609 | 1740 | logger.exception("App command error", exc_info=error) |
1610 | | - if not interaction.response.is_done(): |
1611 | | - await interaction.response.send_message( |
1612 | | - "❌ An error occurred while processing your command.", |
1613 | | - ephemeral=True, |
1614 | | - ) |
| 1741 | + try: |
| 1742 | + if not interaction.response.is_done(): |
| 1743 | + await interaction.response.send_message( |
| 1744 | + "❌ An error occurred while processing your command.", |
| 1745 | + ephemeral=True, |
| 1746 | + ) |
| 1747 | + except Exception: |
| 1748 | + logger.error("Could not send error message to user") |
1615 | 1749 |
|
1616 | 1750 | @client.event |
1617 | 1751 | async def on_ready() -> None: |
|
0 commit comments