Skip to content

Commit 735ccad

Browse files
fix: Resolve critical pairing system issues
- Fix callback secret caching with @lru_cache to prevent verification failures - Add local InlineKeyboardMarkup import to fix Telegram NameError - Fix requester binding by including user_id in signed callback payloads - Update policy routing to handle empty allowlist correctly across all platforms - Improve Discord reply method with proper channel/user/fetch fallback order - Add config validation with __post_init__ to fail-fast on invalid policies - Fix 'allow' policy to avoid invalid verify_and_pair call - Ensure denied codes are consumed to prevent reuse - Update test to use real signed callback path instead of bypassing verification Resolves CodeRabbit's 9 critical/major architectural issues. Co-authored-by: Mervin Praison <MervinPraison@users.noreply.github.com>
1 parent 20a7838 commit 735ccad

7 files changed

Lines changed: 86 additions & 49 deletions

File tree

src/praisonai-agents/praisonaiagents/bots/config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ class BotConfig:
8282

8383
# Owner user ID for pairing approvals (platform-specific format)
8484
owner_user_id: Optional[str] = None
85+
86+
def __post_init__(self) -> None:
87+
if self.unknown_user_policy not in {"deny", "pair", "allow"}:
88+
raise ValueError(
89+
f"unknown_user_policy must be one of: deny, pair, allow. Got: {self.unknown_user_policy}"
90+
)
8591

8692
metadata: Dict[str, Any] = field(default_factory=dict)
8793

src/praisonai/praisonai/bots/_pairing_ui.py

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,20 @@
99
import hmac
1010
import json
1111
import logging
12+
from functools import lru_cache
1213
from typing import Any, Dict, Optional
1314

1415
from praisonaiagents.bots.pairing_types import PairingApprovalResult
1516

1617
logger = logging.getLogger(__name__)
1718

1819

20+
@lru_cache(maxsize=1)
1921
def _get_callback_secret() -> str:
2022
"""Get HMAC secret for callback payload verification."""
2123
import os
2224
import secrets
23-
return os.environ.get("PRAISONAI_CALLBACK_SECRET", "") or secrets.token_hex(16)
25+
return os.environ.get("PRAISONAI_CALLBACK_SECRET", "") or secrets.token_hex(32)
2426

2527

2628
class PairingUIBuilder:
@@ -29,8 +31,8 @@ class PairingUIBuilder:
2931
@staticmethod
3032
def create_telegram_keyboard(user_name: str, code: str, channel: str, user_id: str) -> Dict[str, Any]:
3133
"""Create Telegram inline keyboard for approval."""
32-
approve_data = f"pair:approve:{channel}:{code}"
33-
deny_data = f"pair:deny:{channel}:{code}"
34+
approve_data = f"pair:approve:{channel}:{user_id}:{code}"
35+
deny_data = f"pair:deny:{channel}:{user_id}:{code}"
3436

3537
# Add HMAC signature to prevent tampering
3638
secret = _get_callback_secret().encode()
@@ -55,8 +57,8 @@ def create_telegram_keyboard(user_name: str, code: str, channel: str, user_id: s
5557
@staticmethod
5658
def create_discord_components(user_name: str, code: str, channel: str, user_id: str) -> list:
5759
"""Create Discord button components for approval."""
58-
approve_id = f"pair:approve:{channel}:{code}"
59-
deny_id = f"pair:deny:{channel}:{code}"
60+
approve_id = f"pair:approve:{channel}:{user_id}:{code}"
61+
deny_id = f"pair:deny:{channel}:{user_id}:{code}"
6062

6163
# Add HMAC signature
6264
secret = _get_callback_secret().encode()
@@ -86,8 +88,8 @@ def create_discord_components(user_name: str, code: str, channel: str, user_id:
8688
@staticmethod
8789
def create_slack_blocks(user_name: str, code: str, channel: str, user_id: str) -> list:
8890
"""Create Slack block kit for approval."""
89-
approve_value = f"pair:approve:{channel}:{code}"
90-
deny_value = f"pair:deny:{channel}:{code}"
91+
approve_value = f"pair:approve:{channel}:{user_id}:{code}"
92+
deny_value = f"pair:deny:{channel}:{user_id}:{code}"
9193

9294
# Add HMAC signature
9395
secret = _get_callback_secret().encode()
@@ -140,23 +142,24 @@ def parse_and_verify_callback(self, callback_data: str) -> Optional[Dict[str, st
140142
"""Parse and verify callback data from button press.
141143
142144
Args:
143-
callback_data: Raw callback data (e.g., "pair:approve:telegram:abc123:sig")
145+
callback_data: Raw callback data (e.g., "pair:approve:telegram:user123:abc123:sig")
144146
145147
Returns:
146148
Parsed data dict or None if invalid
147149
"""
148150
try:
149151
parts = callback_data.split(":")
150-
if len(parts) < 5 or parts[0] != "pair":
152+
if len(parts) < 6 or parts[0] != "pair":
151153
return None
152154

153-
action = parts[1] # approve/deny
154-
channel = parts[2] # telegram/discord/slack
155-
code = parts[3] # pairing code
156-
signature = parts[4] # HMAC signature
155+
action = parts[1] # approve/deny
156+
channel = parts[2] # telegram/discord/slack
157+
user_id = parts[3] # original requester user ID
158+
code = parts[4] # pairing code
159+
signature = parts[5] # HMAC signature
157160

158161
# Verify signature
159-
payload = f"pair:{action}:{channel}:{code}"
162+
payload = f"pair:{action}:{channel}:{user_id}:{code}"
160163
secret = _get_callback_secret().encode()
161164
expected_sig = hmac.new(secret, payload.encode(), hashlib.sha256).hexdigest()[:8]
162165

@@ -166,7 +169,8 @@ def parse_and_verify_callback(self, callback_data: str) -> Optional[Dict[str, st
166169

167170
return {
168171
"action": action,
169-
"channel": channel,
172+
"channel": channel,
173+
"user_id": user_id,
170174
"code": code
171175
}
172176

@@ -200,12 +204,13 @@ async def handle_approval_callback(
200204
action = parsed["action"]
201205
channel = parsed["channel"]
202206
code = parsed["code"]
207+
requester_user_id = parsed["user_id"]
203208

204209
if action == "approve":
205210
# Approve the pairing
206211
success = self.pairing_store.verify_and_pair(
207212
code=code,
208-
channel_id=owner_user_id, # This would be the original user_id
213+
channel_id=requester_user_id, # Use original requester's ID
209214
channel_type=channel,
210215
label=f"Approved by {owner_user_id}"
211216
)
@@ -214,7 +219,7 @@ async def handle_approval_callback(
214219
# Notify original user
215220
try:
216221
await bot_adapter.reply(
217-
owner_user_id, # This should be the original requester
222+
requester_user_id, # Notify the original requester
218223
"You've been approved! Send me a message."
219224
)
220225
except Exception as e:
@@ -223,7 +228,7 @@ async def handle_approval_callback(
223228
return PairingApprovalResult(
224229
success=True,
225230
message="✅ Approved",
226-
user_id=owner_user_id,
231+
user_id=requester_user_id,
227232
channel=channel
228233
)
229234
else:
@@ -233,8 +238,19 @@ async def handle_approval_callback(
233238
)
234239

235240
elif action == "deny":
236-
# Revoke if was temporarily added, or just ignore
237-
# Note: In real implementation, we'd need to track the original user_id
241+
# Consume the code to prevent reuse
242+
try:
243+
# Try to consume the code without pairing by calling verify_and_pair with dummy values
244+
# This will consume the code and return False since it won't actually pair
245+
self.pairing_store.verify_and_pair(
246+
code=code,
247+
channel_id="__denied__", # Dummy value that won't be stored
248+
channel_type="__denied__",
249+
label="Denied pairing request"
250+
)
251+
except Exception as e:
252+
logger.error(f"Failed to consume denied code: {e}")
253+
238254
return PairingApprovalResult(
239255
success=True,
240256
message="❌ Denied",

src/praisonai/praisonai/bots/_unknown_user.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,8 @@ async def handle(cls, message: BotMessage, bot_ctx: BotContext) -> bool:
8484
unknown_policy = getattr(bot_ctx.config, 'unknown_user_policy', 'deny')
8585

8686
if unknown_policy == "allow":
87-
# Auto-approve and pair the user
88-
bot_ctx.pairing_store.verify_and_pair(
89-
code="auto",
90-
channel_id=user_id,
91-
channel_type=channel_type,
92-
label=f"Auto-approved: {user_name}"
93-
)
87+
# Auto-approve without creating a persistent pair
88+
# Users with "allow" policy don't need to be paired permanently
9489
return True
9590

9691
elif unknown_policy == "deny":

src/praisonai/praisonai/bots/discord.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,9 @@ async def on_message(message):
180180
return
181181

182182
# Handle unknown users with pairing system
183-
if not self.config.is_user_allowed(bot_message.sender.user_id if bot_message.sender else ""):
183+
user_id = bot_message.sender.user_id if bot_message.sender else ""
184+
is_explicitly_allowed = bool(self.config.allowed_users) and self.config.is_user_allowed(user_id)
185+
if not is_explicitly_allowed:
184186
user_allowed = await UnknownUserHandler.handle(bot_message, self._bot_context)
185187
if not user_allowed:
186188
return
@@ -582,14 +584,22 @@ async def reply(self, chat_id: str, text: str) -> None:
582584

583585
try:
584586
if chat_id.isdigit():
585-
# DM to user
586-
user = await self._client.fetch_user(int(chat_id))
587-
await user.send(text)
588-
else:
589-
# Channel message
587+
# Try channel first, then user, then fetch channel
590588
channel = self._client.get_channel(int(chat_id))
591589
if channel:
592590
await channel.send(text)
591+
return
592+
593+
try:
594+
user = await self._client.fetch_user(int(chat_id))
595+
await user.send(text)
596+
return
597+
except Exception:
598+
# Last resort: fetch channel
599+
channel = await self._client.fetch_channel(int(chat_id))
600+
await channel.send(text)
601+
else:
602+
logger.error(f"Invalid chat_id format: {chat_id}")
593603
except Exception as e:
594604
logger.error(f"Failed to send reply: {e}")
595605

src/praisonai/praisonai/bots/slack.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,9 @@ async def handle_message(event, say):
194194
return
195195

196196
# Handle unknown users with pairing system
197-
if not self.config.is_user_allowed(bot_message.sender.user_id if bot_message.sender else ""):
197+
user_id = bot_message.sender.user_id if bot_message.sender else ""
198+
is_explicitly_allowed = bool(self.config.allowed_users) and self.config.is_user_allowed(user_id)
199+
if not is_explicitly_allowed:
198200
user_allowed = await UnknownUserHandler.handle(bot_message, self._bot_context)
199201
if not user_allowed:
200202
return

src/praisonai/praisonai/bots/telegram.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,9 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
204204
return
205205

206206
# Handle unknown users with pairing system
207-
if not self.config.is_user_allowed(message.sender.user_id if message.sender else ""):
207+
user_id = message.sender.user_id if message.sender else ""
208+
is_explicitly_allowed = bool(self.config.allowed_users) and self.config.is_user_allowed(user_id)
209+
if not is_explicitly_allowed:
208210
user_allowed = await UnknownUserHandler.handle(message, self._bot_context)
209211
if not user_allowed:
210212
return
@@ -787,6 +789,8 @@ async def send_approval_dm(
787789
return None
788790

789791
try:
792+
from telegram import InlineKeyboardMarkup
793+
790794
keyboard = PairingUIBuilder.create_telegram_keyboard(
791795
user_name=user_name,
792796
code=code,

src/praisonai/tests/integration/bots/test_pairing_owner_dm.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from praisonaiagents.bots.config import BotConfig
1313
from praisonaiagents.bots.pairing_types import PairingApprovalResult
1414
from praisonai.bots._unknown_user import UnknownUserHandler, BotContext
15-
from praisonai.bots._pairing_ui import PairingCallbackHandler
15+
from praisonai.bots._pairing_ui import PairingCallbackHandler, PairingUIBuilder
1616
from praisonai.gateway.pairing import PairingStore
1717

1818

@@ -69,7 +69,7 @@ def setup_method(self):
6969

7070
def create_test_message(self, user_id: str = "new-user", user_name: str = "Alice") -> BotMessage:
7171
"""Create a test message from an unknown user."""
72-
return BotMessage(
72+
message = BotMessage(
7373
message_id="msg-1",
7474
content="hello",
7575
message_type=MessageType.TEXT,
@@ -83,8 +83,9 @@ def create_test_message(self, user_id: str = "new-user", user_name: str = "Alice
8383
channel_type="dm"
8484
),
8585
timestamp=1234567890.0,
86-
_channel_type="telegram" # Add channel type for pairing
8786
)
87+
message._channel_type = "telegram" # Add channel type for pairing
88+
return message
8889

8990
async def test_unknown_user_triggers_pairing_request(self):
9091
"""Test that unknown user triggers pairing request to owner."""
@@ -119,19 +120,22 @@ async def test_owner_approval_allows_future_messages(self):
119120
approval_dm = self.adapter.approval_dms[0]
120121
code = approval_dm["code"]
121122

122-
# Simulate owner approval
123-
callback_handler = PairingCallbackHandler(self.pairing_store)
124-
callback_data = f"pair:approve:telegram:{code}:fake_sig" # Signature check will fail but that's OK for test
125-
126-
# We need to bypass signature verification for testing
127-
# So let's directly approve via the pairing store
128-
success = self.pairing_store.verify_and_pair(
123+
# Simulate owner approval using real signed callback
124+
keyboard = PairingUIBuilder.create_telegram_keyboard(
125+
user_name="Alice",
129126
code=code,
130-
channel_id="new-user",
131-
channel_type="telegram",
132-
label="Test approval"
127+
channel="telegram",
128+
user_id="new-user",
129+
)
130+
callback_data = keyboard["inline_keyboard"][0][0]["callback_data"] # Get approve button callback
131+
132+
callback_handler = PairingCallbackHandler(self.pairing_store)
133+
result = await callback_handler.handle_approval_callback(
134+
callback_data=callback_data,
135+
owner_user_id="owner-123",
136+
bot_adapter=self.adapter,
133137
)
134-
assert success
138+
assert result.success
135139

136140
# Now a second message from the same user should be allowed
137141
message2 = self.create_test_message()

0 commit comments

Comments
 (0)