Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 116 additions & 32 deletions src/praisonai/praisonai/bots/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,37 +181,10 @@ async def start(self) -> None:
)

async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
# Handle text OR audio messages
message_text = None

if update.message:
# Check for voice/audio first
if update.message.voice or update.message.audio:
message_text = await self._transcribe_audio(update)
elif update.message.text:
message_text = update.message.text

if not update.message or not message_text:
return

message = self._convert_update_to_message(update, override_text=message_text)

# Add channel type for pairing system
message._channel_type = "telegram"

self.fire_message_received(message)

# Check if channel is allowed
if not self.config.is_channel_allowed(message.channel.channel_id if message.channel else ""):
return

# Handle unknown users with pairing system
user_id = message.sender.user_id if message.sender else ""
is_explicitly_allowed = bool(self.config.allowed_users) and self.config.is_user_allowed(user_id)
if not is_explicitly_allowed:
user_allowed = await UnknownUserHandler.handle(message, self._bot_context)
if not user_allowed:
return
# Use shared security pipeline for consistent enforcement
message = await process_inbound_telegram_message(update, self)
if not message:
return # Message was dropped by security checks

for handler in self._message_handlers:
try:
Expand Down Expand Up @@ -252,7 +225,7 @@ async def _tg_unreact(emoji, **kw):
update.message.from_user.username or update.message.from_user.first_name or ""
) if update.message.from_user else ""
try:
message_text = await self._debouncer.debounce(user_id, message_text)
message_text = await self._debouncer.debounce(user_id, message.content)

# Show typing indicator with renewal during long operation
if self.config.typing_indicator:
Expand Down Expand Up @@ -848,3 +821,114 @@ async def reply(self, chat_id: str, text: str) -> None:
except Exception as e:
logger.error(f"Failed to send reply: {e}")


async def process_inbound_telegram_message(
update, # Telegram Update
bot: TelegramBot,
gateway_context: Optional[Dict] = None
) -> Optional[BotMessage]:
Comment on lines +825 to +829
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The gateway_context parameter is declared in the function signature and documented in the docstring, but is never read anywhere in the function body. Dead parameters add noise and invite callers to pass data that is silently ignored.

Suggested change
async def process_inbound_telegram_message(
update, # Telegram Update
bot: TelegramBot,
gateway_context: Optional[Dict] = None
) -> Optional[BotMessage]:
async def process_inbound_telegram_message(
update, # Telegram Update
bot: TelegramBot,
) -> Optional[BotMessage]:

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

"""
Shared security pipeline for processing inbound Telegram messages.

Used by both standalone bot (TelegramBot.handle_message) and
gateway polling (_start_telegram_bot_polling) to ensure consistent
access control enforcement.

Args:
update: Telegram Update object
bot: TelegramBot instance with config and security settings
gateway_context: Optional dict with gateway-specific context

Returns:
BotMessage if message passes all security checks, None if dropped
"""
if not update.message:
return None

# Extract message text (including audio transcription)
message_text = None
if update.message.voice or update.message.audio:
message_text = await bot._transcribe_audio(update)
elif update.message.text:
message_text = update.message.text

if not message_text:
return None

# Convert to BotMessage for consistent processing
message = bot._convert_update_to_message(update, override_text=message_text)

# Set channel type for pairing system
message._channel_type = "telegram"

# Fire message received event
bot.fire_message_received(message)

# 1. Channel allowlist check
channel_id = message.channel.channel_id if message.channel else ""
if not bot.config.is_channel_allowed(channel_id):
logger.debug(f"Message dropped: channel {channel_id} not in allowed_channels")
return None

# 2. User allowlist and pairing check
user_id = message.sender.user_id if message.sender else ""
is_explicitly_allowed = bot.config.is_user_allowed(user_id)

if not is_explicitly_allowed:
# Check if bot context is available for pairing system
Comment on lines +873 to +878
if not hasattr(bot, '_bot_context') or bot._bot_context is None:
# For gateway mode, we need to create bot context on demand
if not hasattr(bot, '_pairing_store'):
from ..gateway.pairing import PairingStore
bot._pairing_store = PairingStore()

bot._bot_context = BotContext(
config=bot.config,
pairing_store=bot._pairing_store,
adapter=bot
)

user_allowed = await UnknownUserHandler.handle(message, bot._bot_context)
if not user_allowed:
logger.debug(f"Message dropped: user {user_id} not allowed by pairing system")
return None
Comment on lines +873 to +894
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. allowed_users bypassed via pairing 📎 Requirement gap ⛨ Security

In process_inbound_telegram_message(), users not in allowed_users can still be permitted if
UnknownUserHandler.handle() returns True (e.g., already paired or policy allows), which can
allow unauthorized users to reach _session.chat() in gateway polling mode.
Agent Prompt
## Issue description
Gateway polling must enforce `BotConfig.allowed_users` as a hard allowlist before any chat/LLM call. The current shared pipeline allows `UnknownUserHandler.handle()` to override `allowed_users`, enabling unauthorized users to proceed.

## Issue Context
`process_inbound_telegram_message()` is used by gateway polling and the standalone bot path. With `allowed_users` set (e.g., `["42"]`), a user like `99` must not be able to reach `_session.chat()` regardless of pairing state or unknown-user policy.

## Fix Focus Areas
- src/praisonai/praisonai/bots/telegram.py[857-878]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +873 to +894
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Empty allowlist drops all 🐞 Bug ≡ Correctness

process_inbound_telegram_message() treats users as “not explicitly allowed” when allowed_users is
empty and then invokes UnknownUserHandler.handle(); with BotConfig.unknown_user_policy defaulting to
"deny", this drops all inbound messages instead of allowing all (the documented empty-allowlist
behavior). This will also make the new test_empty_allowlists_allow_all fail and contradicts the
gateway warning that empty allowed_users “accepts messages from everyone.”
Agent Prompt
### Issue description
`process_inbound_telegram_message()` currently routes *all* users through `UnknownUserHandler` when `allowed_users` is empty because it uses `bool(config.allowed_users) and is_user_allowed(...)`. Since `BotConfig.unknown_user_policy` defaults to `"deny"`, this causes silent drops for every inbound message whenever no allowlist is configured, contradicting `BotConfig.is_user_allowed()` semantics (empty list => allow all) and the gateway warning text.

### Issue Context
This pipeline is now used by the gateway polling handler, so it can turn previously-working “open” gateway Telegram bots into bots that respond to nobody.

### Fix Focus Areas
- src/praisonai/praisonai/bots/telegram.py[857-878]
- src/praisonai/tests/unit/gateway/test_telegram_security_pipeline.py[186-197]

### What to change
- Replace `is_explicitly_allowed = bool(config.allowed_users) and config.is_user_allowed(user_id)` with logic based on `config.is_user_allowed(user_id)` (or explicitly gate UnknownUserHandler behind `if config.allowed_users and not config.is_user_allowed(user_id):`).
- Only call `UnknownUserHandler.handle(...)` when the allowlist is configured and the user is not in it (so empty allowlist truly means allow all, matching `BotConfig`).
- Update the unit tests accordingly (they currently assert allow-all for empty allowlists).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +873 to +894
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Silent standalone-bot access-control regression for empty allowed_users

The old handle_message gated pairing with bool(self.config.allowed_users) and self.config.is_user_allowed(user_id): when allowed_users is empty, this evaluated to False, so every user entered UnknownUserHandler. With the default unknown_user_policy = "deny", that handler rejected everyone. The new pipeline calls bot.config.is_user_allowed(user_id) directly, which returns True for an empty list — so when no allowlist is configured, all users bypass UnknownUserHandler entirely.

Concrete failure: a standalone bot deployed with unknown_user_policy = "pair" (requires explicit owner approval) and no allowed_users list relied on this gate to require pairing before any user could interact. After this change, the pairing system is never invoked and the bot accepts every message without approval.


# 3. Group policy enforcement
if message.channel and message.channel.channel_type not in ("dm", "private"):
# This is a group/channel message, check group policies
group_policy = getattr(bot.config, 'group_policy', 'mention_only')
mention_required = getattr(bot.config, 'mention_required', True)

if group_policy == "command_only":
if message.message_type != MessageType.COMMAND:
logger.debug(f"Message dropped: non-command in command_only group {channel_id}")
return None
elif group_policy == "mention_only":
# Check if bot was mentioned in the message
Comment on lines +899 to +907
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

4. Gateway ignores group_policy 🐞 Bug ⛨ Security

Gateway extracts group_policy from gateway.yaml but does not pass it into BotConfig; the new shared
pipeline then reads bot.config.group_policy and defaults to "mention_only", so gateway Telegram will
silently ignore respond_all/command_only and enforce mention-only behavior. This is a
security/configuration correctness break because command_only becomes more permissive (mentions
allowed) than configured.
Agent Prompt
### Issue description
In gateway mode, the config loader computes `group_policy` but only forwards `mention_required` into `BotConfig`. The new shared Telegram pipeline uses `bot.config.group_policy` for enforcement, so gateway ends up using `BotConfig`’s default `group_policy="mention_only"` regardless of gateway.yaml.

### Issue Context
This impacts the gateway polling path newly wired to `process_inbound_telegram_message()`.

### Fix Focus Areas
- src/praisonai/praisonai/gateway/server.py[1593-1612]
- src/praisonai/praisonai/bots/telegram.py[883-887]

### What to change
- In `gateway/server.py`, include `group_policy=group_policy` in `config_kwargs` when constructing `BotConfig`.
- Then rely on `group_policy` in the shared pipeline (and implement the full policy handling per the other finding).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

bot_username = bot._bot_user.username.lower() if bot._bot_user and bot._bot_user.username else ""
mention_handle = f"@{bot_username}" if bot_username else ""
bot_mentioned = (
mention_handle and mention_handle in message.content.lower()
) or message.message_type == MessageType.COMMAND # Commands are always allowed

if not bot_mentioned:
logger.debug(f"Message dropped: bot not mentioned in group {channel_id}")
return None
elif group_policy == "respond_all":
# Allow all group messages
pass
elif mention_required:
# Fallback for backward compatibility when group_policy is not set
bot_username = bot._bot_user.username.lower() if bot._bot_user and bot._bot_user.username else ""
mention_handle = f"@{bot_username}" if bot_username else ""
bot_mentioned = (
mention_handle and mention_handle in message.content.lower()
) or message.message_type == MessageType.COMMAND # Commands are always allowed
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
greptile-apps[bot] marked this conversation as resolved.

if not bot_mentioned:
logger.debug(f"Message dropped: bot not mentioned in group {channel_id}")
return None
Comment on lines +899 to +930

Comment on lines +896 to +931
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Group policy command_only bypass 📎 Requirement gap ⛨ Security

The shared pipeline’s group gating in process_inbound_telegram_message() does not correctly
implement BotConfig.group_policy semantics: it applies mention-gating whenever mention_required is
true and fails to properly enforce command_only (and can also mis-gate respond_all). This can cause
incorrect allow/deny behavior, including responding to non-command group messages when policy should
suppress replies or dropping messages when respond_all should allow them.
Agent Prompt
## Issue description
Group message gating must correctly enforce the configured `BotConfig.group_policy` semantics (`respond_all`, `mention_only`, `command_only`) and only apply `mention_required` in a way that does not override those policies, so that disallowed group messages are dropped before the bot/gateway responds.

## Issue Context
- `BotConfig.group_policy` explicitly supports `respond_all`, `mention_only`, and `command_only`.
- The shared pipeline is intended to unify enforcement across standalone + gateway, and PR Compliance ID 3 requires that gateway polling enforce group policy/mention requirements before responding.
- Current logic applies mention gating whenever `mention_required` is true (e.g., via `if group_policy == "mention_only" or mention_required`), which can unintentionally allow mention-based replies under `command_only` and can unintentionally require mentions under `respond_all`.

## Fix Focus Areas
- src/praisonai/praisonai/bots/telegram.py[880-896]
- src/praisonai-agents/praisonaiagents/bots/config.py[50-52]

## What to change
- Replace the current mention-gating OR condition with explicit policy dispatch:
  - `respond_all`: allow group messages (no mention required)
  - `mention_only`: allow only if bot is mentioned OR message is a command
  - `command_only`: allow only commands
- If backwards compatibility with `mention_required` is needed, use it only as a fallback when `group_policy` is unset/empty, not as an overriding OR condition.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

# All security checks passed
logger.debug(f"Message security checks passed for user {user_id} in channel {channel_id}")
return message
22 changes: 10 additions & 12 deletions src/praisonai/praisonai/gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1648,6 +1648,7 @@ async def start_channels(self, channels_cfg: Dict[str, Dict[str, Any]]) -> None:
allowed_users=list(_raw_allowed),
allowed_channels=list(_raw_channels),
mention_required=mention_required,
group_policy=group_policy,
auto_approve_tools=auto_approve_tools,
)

Expand Down Expand Up @@ -1876,19 +1877,16 @@ async def _start_telegram_bot_polling(self, name: str, bot: Any) -> None:
gateway = self

async def handle_message(update: Update, context: Any):
if not update.message:
return

message_text = None
if update.message.voice or update.message.audio:
message_text = await bot._transcribe_audio(update)
elif update.message.text:
message_text = update.message.text

if not message_text:
return
# Import the shared security pipeline from telegram.py
from praisonai.bots.telegram import process_inbound_telegram_message

# Use shared security pipeline for consistent enforcement
message = await process_inbound_telegram_message(update, bot)
if not message:
return # Message was dropped by security checks

user_id = str(update.message.from_user.id) if update.message.from_user else "unknown"
user_id = message.sender.user_id if message.sender else "unknown"
message_text = message.content
Comment on lines 1887 to +1889

# Determine routing context
chat_type = update.message.chat.type if update.message.chat else "private"
Expand Down
Loading
Loading