Skip to content

Commit d3c08f9

Browse files
dcramercodex
andcommitted
Add per-chat passive response policy controls
Keep passive memory extraction active when response policy suppresses replies. Co-Authored-By: GPT-5 Codex <noreply@openai.com>
1 parent eb7d7a5 commit d3c08f9

6 files changed

Lines changed: 106 additions & 0 deletions

File tree

docs/src/content/docs/configuration/reference.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,8 @@ group_mode = "mention"
323323

324324
[telegram.passive]
325325
enabled = false
326+
response_allowed_chats = []
327+
response_blocked_chats = []
326328

327329
[brave_search]
328330
api_key = "..."

docs/src/content/docs/systems/providers.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ group_mode = "mention" # mention | always
2626

2727
[telegram.passive]
2828
enabled = false # Passive group listening
29+
response_allowed_chats = [] # Optional passive-response allowlist
30+
response_blocked_chats = [] # Suppress passive responses in specific chats
2931
```
3032

3133
Start runtime:
@@ -88,6 +90,8 @@ Enable it explicitly:
8890
```toml
8991
[telegram.passive]
9092
enabled = true
93+
# response_allowed_chats = ["-100123456789"] # Reply only in these groups
94+
# response_blocked_chats = ["-100999999999"] # Never reply in these groups
9195
```
9296

9397
## Reference (Advanced)

specs/telegram.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,11 @@ webhook_path = "/telegram/webhook"
144144
# Group chat settings
145145
allowed_groups = [] # Group IDs (empty = allow all with authorized users)
146146
group_mode = "mention" # "mention" (default) or "always"
147+
148+
[telegram.passive]
149+
enabled = false
150+
response_allowed_chats = [] # Optional allowlist for passive responses
151+
response_blocked_chats = [] # Always suppress passive responses in these chats
147152
```
148153

149154
## Message UX
@@ -234,6 +239,7 @@ attribution in the user-visible response.
234239
| Group message (always mode) | Respond to all messages from authorized users |
235240
| Group message with mention | Strip @botname from text before processing |
236241
| Group not in allowed_groups | Ignore message silently |
242+
| Passive message in blocked chat | Skip passive engagement but still run passive memory extraction |
237243

238244
## Errors
239245

src/ash/config/models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ class PassiveListeningConfig(BaseModel):
7979
memory_lookup_timeout: float = 2.0 # seconds
8080
memory_similarity_threshold: float = 0.4
8181

82+
# Chat-level passive response policy (Telegram group chat IDs as strings)
83+
# Empty allowed list means all chats are eligible for passive responses.
84+
# Blocked chats always suppress passive responses.
85+
# Passive memory extraction still runs for passively observed messages.
86+
response_allowed_chats: list[str] = []
87+
response_blocked_chats: list[str] = []
88+
8289

8390
class TelegramConfig(BaseModel):
8491
"""Configuration for Telegram provider."""

src/ash/providers/telegram/handlers/passive_handler.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,20 @@ def is_enabled(self) -> bool:
137137
"""Check if passive listening is enabled and initialized."""
138138
return self._passive_decider is not None and self._memory_manager is not None
139139

140+
def _is_passive_response_allowed_for_chat(self, chat_id: str) -> bool:
141+
"""Apply chat-level policy for passive response decisions."""
142+
passive_config = self._provider.passive_config
143+
if not passive_config:
144+
return True
145+
146+
if chat_id in passive_config.response_blocked_chats:
147+
return False
148+
149+
if passive_config.response_allowed_chats:
150+
return chat_id in passive_config.response_allowed_chats
151+
152+
return True
153+
140154
async def handle_passive_message(self, message: IncomingMessage) -> None:
141155
"""Handle a passively observed message (not mentioned or replied to).
142156
@@ -165,6 +179,23 @@ async def handle_passive_message(self, message: IncomingMessage) -> None:
165179
chat_title or chat_id[:8],
166180
)
167181

182+
if not self._is_passive_response_allowed_for_chat(chat_id):
183+
if self._passive_extractor:
184+
asyncio.create_task(
185+
self._extract_passive_memories(message),
186+
name=f"passive_extract_{message.id}",
187+
)
188+
189+
logger.info(
190+
"passive_engagement_skipped",
191+
extra={
192+
"decision_path": "response_policy",
193+
"engagement_reason": "response_policy",
194+
"username": message.username or message.user_id,
195+
},
196+
)
197+
return
198+
168199
# Build bot context for identity awareness (needed for name check)
169200
bot_context = BotContext(
170201
name=self._get_bot_display_name(),

tests/test_providers.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,62 @@ async def mock_stream():
790790
assert getattr(engaging, "decision_path", None) == "name_mentioned_fast_path"
791791
assert getattr(engaging, "engagement_reason", None) == "name_mentioned"
792792

793+
async def test_handle_passive_message_response_policy_skips_response_but_still_extracts(
794+
self, mock_provider, mock_agent, caplog
795+
):
796+
"""Response policy should suppress passive replies while still extracting."""
797+
import asyncio
798+
import logging
799+
800+
from ash.config.models import PassiveListeningConfig
801+
from ash.providers.base import IncomingMessage
802+
from ash.providers.telegram.handlers import TelegramMessageHandler
803+
804+
mock_provider.passive_config = PassiveListeningConfig(
805+
enabled=True,
806+
response_allowed_chats=["group_allowed"],
807+
)
808+
mock_provider.bot_username = "ash_bot"
809+
810+
handler = TelegramMessageHandler(
811+
provider=mock_provider,
812+
agent=mock_agent,
813+
streaming=False,
814+
)
815+
816+
mock_decider = MagicMock()
817+
mock_decider.decide = AsyncMock(return_value=True)
818+
handler._passive_handler._passive_decider = mock_decider # type: ignore[union-attr]
819+
handler._passive_handler._memory_manager = MagicMock() # type: ignore[union-attr]
820+
handler._passive_handler._passive_extractor = MagicMock() # type: ignore[union-attr]
821+
handler._passive_handler._extract_passive_memories = AsyncMock() # type: ignore[method-assign]
822+
823+
passive_message = IncomingMessage(
824+
id="99",
825+
chat_id="group_blocked",
826+
user_id="user_456",
827+
text="normal group chatter",
828+
username="otheruser",
829+
display_name="Other User",
830+
)
831+
832+
with caplog.at_level(logging.INFO, logger="telegram"):
833+
await handler.handle_passive_message(passive_message)
834+
await asyncio.sleep(0)
835+
836+
mock_decider.decide.assert_not_called()
837+
handler._passive_handler._extract_passive_memories.assert_awaited_once_with( # type: ignore[attr-defined]
838+
passive_message
839+
)
840+
mock_agent.process_message.assert_not_called()
841+
mock_agent.process_message_streaming.assert_not_called()
842+
skipped = next(
843+
(r for r in caplog.records if r.msg == "passive_engagement_skipped"), None
844+
)
845+
assert skipped is not None
846+
assert getattr(skipped, "decision_path", None) == "response_policy"
847+
assert getattr(skipped, "engagement_reason", None) == "response_policy"
848+
793849
async def test_handle_passive_message_direct_followup_bypasses_throttle_but_uses_decider(
794850
self, mock_provider, mock_agent, tmp_path, caplog
795851
):

0 commit comments

Comments
 (0)