fix(bridge): approval ENOENT race condition + multi-choice button grouping #task-276 #task-277

This commit is contained in:
2026-03-10 06:32:20 +09:00
parent 373c0f7ddc
commit aab1cfba27
8 changed files with 371 additions and 77 deletions

97
bot.py
View File

@@ -32,16 +32,77 @@ logger = logging.getLogger(__name__)
# ─── Discord UI Components ──────────────────────────────────────────
class ApprovalView(discord.ui.View):
"""Discord buttons for approving/rejecting Antigravity actions."""
"""Discord buttons for approving/rejecting Antigravity actions.
def __init__(self, bridge: BridgeProtocol, request: ApprovalRequest):
Supports two modes:
1. Legacy: ✅ 승인 / ❌ 거부 (when no buttons array)
2. Multi-choice: dynamic buttons from pending's buttons array
(e.g., ✅ Allow Once / ✅ Allow This Conversation / ❌ Deny)
"""
def __init__(self, bridge: BridgeProtocol, request: ApprovalRequest,
buttons: list[dict] | None = None):
super().__init__(timeout=1800) # 30 minutes
self.bridge = bridge
self.request = request
self.responded = False
self.buttons_data = buttons
if buttons and len(buttons) > 1:
# Multi-choice mode: remove the default decorated buttons first
# (they are added by @discord.ui.button at class definition time)
self.clear_items()
# Add a Discord button for each option
for btn_info in buttons:
btn_text = btn_info.get("text", "?")
btn_index = btn_info.get("index", 0)
is_reject = btn_text.lower() in ("deny", "reject", "cancel",
"reject all", "decline",
"dismiss", "stop")
style = discord.ButtonStyle.red if is_reject else discord.ButtonStyle.green
emoji = "" if is_reject else ""
button = discord.ui.Button(
label=f"{emoji} {btn_text}",
style=style,
custom_id=f"choice_{request.request_id}_{btn_index}",
)
# Bind the callback with closure over btn_index and btn_text
button.callback = self._make_choice_callback(btn_index, btn_text,
is_reject)
self.add_item(button)
# else: use the default @discord.ui.button decorated methods below
def _make_choice_callback(self, btn_index: int, btn_text: str,
is_reject: bool):
async def callback(interaction: discord.Interaction):
if self.responded:
await interaction.response.send_message("이미 응답됨",
ephemeral=True)
return
self.responded = True
self.bridge.write_response(UserResponse(
request_id=self.request.request_id,
approved=not is_reject,
button_index=btn_index,
))
embed = interaction.message.embeds[0] if interaction.message.embeds else None
if embed:
color = discord.Color.red() if is_reject else discord.Color.green()
embed.color = color
emoji = "" if is_reject else ""
embed.set_footer(
text=f"{emoji} {btn_text} by {interaction.user.display_name}"
)
await interaction.response.edit_message(embed=embed, view=None)
return callback
@discord.ui.button(label="✅ 승인", style=discord.ButtonStyle.green)
async def approve(self, interaction: discord.Interaction, button: discord.ui.Button):
# Only active in legacy mode (no buttons array)
if self.buttons_data and len(self.buttons_data) > 1:
return # multi-choice mode handles via dynamic buttons
if self.responded:
await interaction.response.send_message("이미 응답됨", ephemeral=True)
return
@@ -57,6 +118,9 @@ class ApprovalView(discord.ui.View):
@discord.ui.button(label="❌ 거부", style=discord.ButtonStyle.red)
async def reject(self, interaction: discord.Interaction, button: discord.ui.Button):
# Only active in legacy mode (no buttons array)
if self.buttons_data and len(self.buttons_data) > 1:
return # multi-choice mode handles via dynamic buttons
if self.responded:
await interaction.response.send_message("이미 응답됨", ephemeral=True)
return
@@ -516,21 +580,38 @@ class GravityBot(commands.Bot):
async def _send_approval_request(
self, channel: discord.TextChannel, request: ApprovalRequest
):
# Read buttons array from pending file (if present)
buttons = None
pending_file = self.bridge.pending_dir / f"{request.request_id}.json"
if pending_file.exists():
try:
pending_data = json.loads(
pending_file.read_text(encoding="utf-8-sig")
)
buttons = pending_data.get("buttons")
except (json.JSONDecodeError, OSError):
pass
# Build embed description
desc_parts = [f"**명령어:**\n```\n{request.command[:1000]}\n```"]
if buttons and len(buttons) > 1:
# Multi-choice: show all options in description
btn_names = [b.get("text", "?") for b in buttons]
desc_parts.append(f"**선택지:** {' / '.join(btn_names)}")
if request.description:
desc_parts.append(request.description[:500])
embed = discord.Embed(
title="⚠️ 승인 요청",
description=(
f"**명령어:**\n```\n{request.command[:1000]}\n```\n"
f"{request.description[:500]}"
),
description="\n".join(desc_parts),
color=discord.Color.orange(),
timestamp=datetime.now(timezone.utc),
)
embed.set_footer(text=f"ID: {request.request_id}")
view = ApprovalView(self.bridge, request)
view = ApprovalView(self.bridge, request, buttons=buttons)
msg = await channel.send(embed=embed, view=view)
pending_file = self.bridge.pending_dir / f"{request.request_id}.json"
if pending_file.exists():
try:
data = json.loads(pending_file.read_text(encoding="utf-8-sig"))