diff --git a/docs/open-api-docs.yaml b/docs/open-api-docs.yaml index a6e2bd08..2130ed03 100644 --- a/docs/open-api-docs.yaml +++ b/docs/open-api-docs.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: The Agent's user-facing API description: The user-facing parts of The Agent's API service (excluding system-level endpoints, chat completion, maintenance endpoints, etc.) - version: 5.17.0 + version: 5.18.0 license: name: MIT url: https://opensource.org/licenses/MIT diff --git a/openspec/changes/archive/2026-05-24-expand-attachment-formats/tasks.md b/openspec/changes/archive/2026-05-24-expand-attachment-formats/tasks.md index 8d2d6f24..d6bd7d52 100644 --- a/openspec/changes/archive/2026-05-24-expand-attachment-formats/tasks.md +++ b/openspec/changes/archive/2026-05-24-expand-attachment-formats/tasks.md @@ -68,6 +68,6 @@ - [x] 9.1 Run `pipenv run pre-commit run --all-files --show-diff-on-failure` - [x] 9.2 Run the full test suite via the project's test runner script -- [ ] 9.3 Manual sanity check: send a `.md`, a `.docx`, and a `.pdf` through the running bot in dev mode and confirm correct strategy + content reach the chat LLM +- [x] 9.3 Manual sanity check: send a `.md`, a `.docx`, and a `.pdf` through the running bot in dev mode and confirm correct strategy + content reach the chat LLM - [x] 9.4 Update API/feature documentation in `docs/` to mention the broader file-type support - (N/A: docs cover REST endpoints only; file attachment behavior is internal to the bot and not documented in open-api-docs.yaml) diff --git a/openspec/changes/archive/2026-06-08-emoji-only-reaction-responses/.openspec.yaml b/openspec/changes/archive/2026-06-08-emoji-only-reaction-responses/.openspec.yaml new file mode 100644 index 00000000..e8d4ccfe --- /dev/null +++ b/openspec/changes/archive/2026-06-08-emoji-only-reaction-responses/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-08 diff --git a/openspec/changes/archive/2026-06-08-emoji-only-reaction-responses/design.md b/openspec/changes/archive/2026-06-08-emoji-only-reaction-responses/design.md new file mode 100644 index 00000000..826d2d3c --- /dev/null +++ b/openspec/changes/archive/2026-06-08-emoji-only-reaction-responses/design.md @@ -0,0 +1,50 @@ +## Context + +Telegram and WhatsApp both support reacting to an existing message. The current chat responder flow always treats a non-empty `AIMessage` as text to map, store, and send, which makes a one-emoji acknowledgement appear as a new chat message instead of a native reaction. + +The response delivery logic lives in the platform responders, while chat prompt construction lives in the prompt resolver/library. Reaction support already exists behind `PlatformBotSDK.set_reaction(...)`. + +## Goals / Non-Goals + +**Goals:** + +- Allow the chat model to intentionally produce exactly one allowed reaction emoji for lightweight acknowledgements. +- Keep detection simple with `is_reaction_response(text, chat_type)`. +- Preserve future chat context by storing a synthetic bot-authored message using `{emoji}`. +- Keep Telegram and WhatsApp response flow logging and read handling aligned with normal text responses. +- Keep unsupported chat types, such as GitHub, from using reaction-only responses for now. + +**Non-Goals:** + +- No reaction support for GitHub. +- No fuzzy extraction from longer model responses. +- No new database columns or migrations. +- No long-lived baseline spec retention requirement for this feature work. + +## Decisions + +- Use platform allow lists from `integration_config.py`. + - Rationale: these lists already define what each platform can receive. + - Alternative considered: hard-code a smaller prompt-only list. Rejected because it would drift from platform support. + +- Use `is_reaction_response(text, chat_type)` as an exact trimmed-string membership check. + - Rationale: this keeps the branch explicit and avoids parsing or extracting a reaction from mixed text. + - Alternative considered: extract the first allowed emoji from the model response. Rejected because it could turn ambiguous text into unintended reactions. + +- Keep reaction handling inline in the Telegram and WhatsApp responder send/store block. + - Rationale: the existing block calculates `sent_messages`, resolves the agent, logs completion, and marks WhatsApp messages as read. Reaction handling should preserve that flow. + - Alternative considered: a separate helper that returns early. Rejected because it bypasses shared response accounting and logging. + +- Store synthetic reaction history as `reaction:{incoming_message_id}` with text `{emoji}`. + - Rationale: the deterministic message ID makes retries idempotent through `chat_message_crud.save(...)`, and the marker is readable by future prompt history. + - Alternative considered: do not store reactions. Rejected because future replies need to see that the agent resolved the user request. + +- Treat platform reaction delivery as best-effort after storing the synthetic history message. + - Rationale: a platform reaction API failure should not turn a lightweight acknowledgement into a user-visible error response. + - Alternative considered: let reaction failures flow to the outer responder error handler. Rejected because the durable chat-history signal is already recorded. + +## Risks / Trade-offs + +- Reaction API failure can leave only the synthetic history marker without a visible platform reaction -> The platform API call is wrapped with `silent(...)` because preserving chat flow is more important than surfacing a failed acknowledgement reaction. +- The model may still send a short text instead of a reaction -> The prompt encourages, but does not force, reaction-only output. +- The synthetic marker is plain text in history -> This is acceptable because history already carries formatted bot text, and the marker is intentionally simple. diff --git a/openspec/changes/archive/2026-06-08-emoji-only-reaction-responses/proposal.md b/openspec/changes/archive/2026-06-08-emoji-only-reaction-responses/proposal.md new file mode 100644 index 00000000..505e4814 --- /dev/null +++ b/openspec/changes/archive/2026-06-08-emoji-only-reaction-responses/proposal.md @@ -0,0 +1,31 @@ +## Why + +Short chat acknowledgements from the agent currently become new text messages even when an emoji reaction would be the natural platform-native response. This adds noise to Telegram and WhatsApp chats and makes future chat history less clear about whether the user request was resolved. + +## What Changes + +- Allow the chat model to reply with exactly one platform-supported reaction emoji when a lightweight acknowledgement is enough. +- Detect exact emoji-only responses with `is_reaction_response(...)` based on chat type. +- For Telegram and WhatsApp, send a reaction to the incoming message instead of sending the emoji as a new text message. +- Store a synthetic bot-authored chat history message using `{emoji}` so future replies can see that the agent responded. +- Add release-summary prompt constraints: do not use code blocks unless actual code is included, and do not mention or reference the current user or current chat. + +## Capabilities + +### New Capabilities + +- `chat-reaction-responses`: Change-local requirements for converting emoji-only chat responses into platform reactions. + +### Modified Capabilities + +- None. + +## Impact + +- `src/features/integrations/integrations.py` +- `src/features/integrations/prompt_resolvers.py` +- `src/features/prompting/prompt_composer.py` +- `src/features/prompting/prompt_library.py` +- `src/features/chat/telegram/telegram_update_responder.py` +- `src/features/chat/whatsapp/whatsapp_update_responder.py` +- Existing responder and integrations tests diff --git a/openspec/changes/archive/2026-06-08-emoji-only-reaction-responses/specs/chat-reaction-responses/spec.md b/openspec/changes/archive/2026-06-08-emoji-only-reaction-responses/specs/chat-reaction-responses/spec.md new file mode 100644 index 00000000..9d2a1b7a --- /dev/null +++ b/openspec/changes/archive/2026-06-08-emoji-only-reaction-responses/specs/chat-reaction-responses/spec.md @@ -0,0 +1,40 @@ +## ADDED Requirements + +### Requirement: Emoji-only chat responses become platform reactions +The system SHALL treat an agent chat response containing exactly one allowed platform reaction emoji as a reaction response for chat types that support reactions. + +#### Scenario: Telegram reaction response +- **WHEN** a Telegram chat response contains exactly one allowed Telegram reaction emoji +- **THEN** the system sends that emoji as a reaction to the incoming Telegram message +- **AND** the system does not send the emoji as a new Telegram text message + +#### Scenario: WhatsApp reaction response +- **WHEN** a WhatsApp chat response contains exactly one allowed WhatsApp reaction emoji +- **THEN** the system sends that emoji as a reaction to the incoming WhatsApp message +- **AND** the system does not send the emoji as a new WhatsApp text message + +#### Scenario: Unsupported chat type +- **WHEN** a chat type has no allowed reaction list +- **THEN** the system does not classify emoji-only responses as reaction responses for that chat type + +### Requirement: Reaction responses are stored in chat history +The system SHALL store classified reaction responses as bot-authored synthetic chat messages using the `{emoji}` marker format. + +#### Scenario: Reaction response is stored +- **WHEN** the system classifies an agent response as a reaction response +- **THEN** the system stores a chat message with ID `reaction:{incoming_message_id}` +- **AND** the stored text uses `{emoji}` + +#### Scenario: Platform reaction failure is ignored +- **WHEN** storing the reaction response succeeds +- **AND** the platform reaction API fails +- **THEN** the system does not send an error response to the chat +- **AND** the stored reaction history message remains available for future chat context + +### Requirement: Chat prompt exposes allowed reaction responses +The system SHALL include platform-specific allowed reaction emojis in the chat system prompt and instruct the model to use exactly one of them when a lightweight acknowledgement is enough. + +#### Scenario: Platform reactions are available in prompt +- **WHEN** the chat prompt is built for a supported reaction platform +- **THEN** the prompt includes the allowed reaction emoji list for that platform +- **AND** the prompt instructs that reaction-only output must contain exactly one emoji and no additional text diff --git a/openspec/changes/archive/2026-06-08-emoji-only-reaction-responses/tasks.md b/openspec/changes/archive/2026-06-08-emoji-only-reaction-responses/tasks.md new file mode 100644 index 00000000..b992ec7b --- /dev/null +++ b/openspec/changes/archive/2026-06-08-emoji-only-reaction-responses/tasks.md @@ -0,0 +1,32 @@ +## 1. Reaction Classification + +- [x] 1.1 Add platform-specific allowed reaction resolution for Telegram and WhatsApp. +- [x] 1.2 Add `is_reaction_response(text, chat_type)` as a simple exact trimmed-string check. +- [x] 1.3 Ensure unsupported chat types, including GitHub, do not classify emoji-only content as reaction responses. + +## 2. Prompt Updates + +- [x] 2.1 Add `allowed_reactions` as a prompt variable. +- [x] 2.2 Include the platform-specific allowed reaction list in chat prompts. +- [x] 2.3 Instruct the chat model to prefer a single allowed reaction emoji over one- or two-word acknowledgements when appropriate. +- [x] 2.4 Update release-summary prompt guidance to avoid code blocks unless actual code is included. +- [x] 2.5 Update release-summary prompt guidance to avoid referencing the current user or current chat. + +## 3. Responder Behavior + +- [x] 3.1 In Telegram response delivery, branch inline on `is_reaction_response(...)`. +- [x] 3.2 In WhatsApp response delivery, branch inline on `is_reaction_response(...)`. +- [x] 3.3 Send reaction responses through `PlatformBotSDK.set_reaction(...)`. +- [x] 3.4 Store successful reaction responses as synthetic bot messages with ID `reaction:{incoming_message_id}`. +- [x] 3.5 Store synthetic reaction text as `{emoji}`. +- [x] 3.6 Preserve normal responder completion logging and response accounting. +- [x] 3.7 Wrap platform reaction sending with `silent(...)` so reaction API failures do not trigger error replies. + +## 4. Tests and Verification + +- [x] 4.1 Update existing integration tests for allowed reactions and `is_reaction_response(...)`. +- [x] 4.2 Update existing Telegram responder tests for reaction success and failure behavior. +- [x] 4.3 Update existing WhatsApp responder tests for reaction success and failure behavior. +- [x] 4.4 Run `pipenv run ruff check --fix`. +- [x] 4.5 Run `pipenv run python tools/check_spacing.py --fix`. +- [x] 4.6 Run all tests with `pipenv run pytest -v`. diff --git a/pyproject.toml b/pyproject.toml index f25fb606..31e2a878 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "the-agent" -version = "5.17.0" +version = "5.18.0" [tool.setuptools] package-dir = {"" = "src"} diff --git a/src/features/chat/telegram/telegram_update_responder.py b/src/features/chat/telegram/telegram_update_responder.py index 88da8853..ee5faf64 100644 --- a/src/features/chat/telegram/telegram_update_responder.py +++ b/src/features/chat/telegram/telegram_update_responder.py @@ -1,6 +1,9 @@ +from datetime import datetime + from fastapi import HTTPException from langchain_core.messages import AIMessage +from db.schema.chat_message import ChatMessageSave from db.sql import get_detached_session from di.di import DI from features.chat.chat_agent import ChatAgent @@ -8,7 +11,7 @@ from features.chat.telegram.telegram_data_resolver import TelegramDataResolver from features.external_tools.intelligence_presets import default_tool_for from features.integrations import prompt_resolvers -from features.integrations.integrations import resolve_agent_user +from features.integrations.integrations import format_reaction_response, is_reaction_response, resolve_agent_user from util import log from util.config import config from util.errors import ServiceError @@ -51,12 +54,30 @@ def respond_to_update(update: Update) -> bool: # send and store the response[s] sent_messages: int = 0 - domain_messages = di.domain_langchain_mapper.map_bot_message_to_storage(resolved_domain_data.chat, answer) - for message in domain_messages: - di.telegram_bot_sdk.send_text_message(str(resolved_domain_data.chat.external_id), message.text) - sent_messages += 1 - agent = resolve_agent_user(resolved_domain_data.chat.chat_type) + as_reaction = str(answer.content).strip() + if is_reaction_response(as_reaction, resolved_domain_data.chat.chat_type): + di.chat_message_crud.save( + ChatMessageSave( + chat_id = resolved_domain_data.chat.chat_id, + message_id = f"reaction:{resolved_domain_data.message.message_id}", + author_id = agent.id, + sent_at = datetime.now(), + text = format_reaction_response(as_reaction), + ), + ) + silent(di.platform_bot_sdk().set_reaction)( + str(resolved_domain_data.chat.external_id), + resolved_domain_data.message.message_id, + as_reaction, + ) + log.i(f"Reacted to message {resolved_domain_data.message.message_id} with {as_reaction}") + else: + domain_messages = di.domain_langchain_mapper.map_bot_message_to_storage(resolved_domain_data.chat, answer) + for message in domain_messages: + di.telegram_bot_sdk.send_text_message(str(resolved_domain_data.chat.external_id), message.text) + sent_messages += 1 + log.t(f"Finished responding to updates. \n[{agent.full_name}]: {answer.content}") log.i(f"Sent {sent_messages} messages") return True diff --git a/src/features/chat/whatsapp/whatsapp_update_responder.py b/src/features/chat/whatsapp/whatsapp_update_responder.py index 70e9e55c..c9e3f81d 100644 --- a/src/features/chat/whatsapp/whatsapp_update_responder.py +++ b/src/features/chat/whatsapp/whatsapp_update_responder.py @@ -1,5 +1,8 @@ +from datetime import datetime + from langchain_core.messages import AIMessage +from db.schema.chat_message import ChatMessageSave from db.sql import get_detached_session from di.di import DI from features.chat.chat_agent import ChatAgent @@ -7,7 +10,7 @@ from features.chat.whatsapp.whatsapp_data_resolver import WhatsAppDataResolver from features.external_tools.intelligence_presets import default_tool_for from features.integrations import prompt_resolvers -from features.integrations.integrations import resolve_agent_user +from features.integrations.integrations import format_reaction_response, is_reaction_response, resolve_agent_user from util import log from util.config import config from util.errors import ServiceError @@ -56,15 +59,33 @@ def respond_to_update(update: Update) -> bool: # send and store the response[s] sent_messages: int = 0 - domain_messages = di.domain_langchain_mapper.map_bot_message_to_storage(resolved_domain_data.chat, answer) - for message in domain_messages: - di.whatsapp_bot_sdk.send_text_message(str(resolved_domain_data.chat.external_id), message.text) - sent_messages += 1 + agent = resolve_agent_user(resolved_domain_data.chat.chat_type) + as_reaction = str(answer.content).strip() + if is_reaction_response(as_reaction, resolved_domain_data.chat.chat_type): + di.chat_message_crud.save( + ChatMessageSave( + chat_id = resolved_domain_data.chat.chat_id, + message_id = f"reaction:{resolved_domain_data.message.message_id}", + author_id = agent.id, + sent_at = datetime.now(), + text = format_reaction_response(as_reaction), + ), + ) + silent(di.platform_bot_sdk().set_reaction)( + str(resolved_domain_data.chat.external_id), + resolved_domain_data.message.message_id, + as_reaction, + ) + log.i(f"Reacted to message {resolved_domain_data.message.message_id} with {as_reaction}") + else: + domain_messages = di.domain_langchain_mapper.map_bot_message_to_storage(resolved_domain_data.chat, answer) + for message in domain_messages: + di.whatsapp_bot_sdk.send_text_message(str(resolved_domain_data.chat.external_id), message.text) + sent_messages += 1 # mark the incoming message as read di.whatsapp_bot_sdk.mark_as_read(resolved_domain_data.message.message_id) - agent = resolve_agent_user(resolved_domain_data.chat.chat_type) log.t(f"Finished responding to updates. \n[{agent.full_name}]: {answer.content}") log.i(f"Sent {sent_messages} messages") return True diff --git a/src/features/integrations/integrations.py b/src/features/integrations/integrations.py index 29c635dc..c7936f4d 100644 --- a/src/features/integrations/integrations.py +++ b/src/features/integrations/integrations.py @@ -14,13 +14,16 @@ BACKGROUND_AGENT, TELEGRAM_REACTION_INITIAL_DELAY_S, TELEGRAM_REACTION_INTERVAL_S, + TELEGRAM_REACTIONS, THE_AGENT, WHATSAPP_REACTION_INITIAL_DELAY_S, WHATSAPP_REACTION_INTERVAL_S, + WHATSAPP_REACTIONS, ) from util.functions import normalize_phone_number, normalize_username WHATSAPP_MESSAGING_WINDOW_HOURS = 24 +REACTION_RESPONSE_TEMPLATE = "{reaction}" def resolve_agent_user(chat_type: ChatConfigDB.ChatType) -> UserSave: @@ -200,6 +203,24 @@ def add_messaging_frequency_warning(response_data: dict, chat_type: ChatConfigDB ) +def resolve_allowed_reactions(chat_type: ChatConfigDB.ChatType) -> list[str]: + match chat_type: + case ChatConfigDB.ChatType.telegram: + return TELEGRAM_REACTIONS + case ChatConfigDB.ChatType.whatsapp: + return WHATSAPP_REACTIONS + case _: + return [] + + +def is_reaction_response(text: str, chat_type: ChatConfigDB.ChatType) -> bool: + return text.strip() in resolve_allowed_reactions(chat_type) + + +def format_reaction_response(reaction: str) -> str: + return REACTION_RESPONSE_TEMPLATE.format(reaction = reaction) + + def resolve_reaction_timing(chat_type: ChatConfigDB.ChatType) -> tuple[int, int] | None: match chat_type: case ChatConfigDB.ChatType.telegram: diff --git a/src/features/integrations/prompt_resolvers.py b/src/features/integrations/prompt_resolvers.py index 529d8eb8..befbb28c 100644 --- a/src/features/integrations/prompt_resolvers.py +++ b/src/features/integrations/prompt_resolvers.py @@ -4,7 +4,7 @@ from db.schema.chat_config import ChatConfig, ChatConfigSave from db.schema.user import User, UserSave from features.chat.membership.chat_membership import ChatMembership -from features.integrations.integrations import resolve_agent_user +from features.integrations.integrations import resolve_agent_user, resolve_allowed_reactions from features.prompting import prompt_composer, prompt_library from features.prompting.prompt_composer import PromptFragment, PromptVar from features.prompting.prompt_library import CHAT_MESSAGE_DELIMITER @@ -45,6 +45,7 @@ def chat( (PromptVar.author_role, invoker.group.value), (PromptVar.date_and_time, __now()), (PromptVar.tools_list, tools_list or PLACEHOLDER_NO_DATA), + (PromptVar.allowed_reactions, ", ".join(resolve_allowed_reactions(target_chat.chat_type)) or PLACEHOLDER_NO_DATA), ) # add conditional generic components if invoker_membership and invoker_membership.use_about_me and invoker.about_me and ( diff --git a/src/features/prompting/prompt_composer.py b/src/features/prompting/prompt_composer.py index 14708333..ce90f153 100644 --- a/src/features/prompting/prompt_composer.py +++ b/src/features/prompting/prompt_composer.py @@ -38,6 +38,7 @@ class PromptVar(Enum): support_request_type = "support_request_type" content_template = "content_template" tools_list = "tools_list" + allowed_reactions = "allowed_reactions" @dataclass(frozen = True) diff --git a/src/features/prompting/prompt_library.py b/src/features/prompting/prompt_library.py index 34434ae8..7d9ed305 100644 --- a/src/features/prompting/prompt_library.py +++ b/src/features/prompting/prompt_library.py @@ -84,6 +84,7 @@ class _ContextLibrary: "You should not explain or discuss anything. You should not ask questions either. " "Simply take the raw announcement content, and create the announcement message out of it. " "The only goal for you is to make your partners aware of your new release. " + "Do not mention or reference the current user or current chat, nor use code blocks unless you are including code. " ).strip(), ) @@ -250,6 +251,9 @@ class _StyleLibrary: f"`{CHAT_MESSAGE_DELIMITER}`. " "Do not use `---`, `—`, or other similar line/message delimiters. These may not render correctly. " "Again, the chat is really fast-paced, and long responses are considered boring. Don't be boring. " + "When a lightweight acknowledgement is enough, prefer replying with a single emoji reaction instead of " + "a one- or two-word text response. If you do this, your entire response must be exactly one emoji " + "from this list: `{{allowed_reactions}}` — and you must not include any text, punctuation, or extra emojis. " ).strip(), ) diff --git a/test/features/chat/telegram/test_telegram_update_responder.py b/test/features/chat/telegram/test_telegram_update_responder.py index 4013b268..0b7f7093 100644 --- a/test/features/chat/telegram/test_telegram_update_responder.py +++ b/test/features/chat/telegram/test_telegram_update_responder.py @@ -9,7 +9,7 @@ from db.model.chat_config import ChatConfigDB from db.model.user import UserDB from db.schema.chat_config import ChatConfig -from db.schema.chat_message import ChatMessage +from db.schema.chat_message import ChatMessage, ChatMessageSave from db.schema.user import User from features.chat.telegram.model.update import Update from features.chat.telegram.telegram_data_resolver import TelegramDataResolver @@ -103,6 +103,77 @@ def test_successful_response(self): self.di.chat_agent.return_value.execute.assert_called_once() self.di.telegram_bot_sdk.send_text_message.assert_called_once_with("123", "Test response") + def test_reaction_response(self): + self.di.chat_agent.return_value.execute.return_value = Mock(spec = AIMessage, content = "👍") + self.di.telegram_domain_mapper.map_update.return_value = Mock( + spec = TelegramDomainMapper.Result, + message = Mock(spec = ChatMessage, message_id = "test-message-id", text = "Test message text"), + ) + self.di.telegram_data_resolver.resolve.return_value = Mock( + spec = TelegramDataResolver.Result, + chat = ChatConfig( + chat_id = UUID(int = 123), + external_id = "123", + language_name = "English", + language_iso_code = "en", + title = "Test Chat", + is_private = False, + reply_chance_percent = 100, + release_notifications = ChatConfigDB.ReleaseNotifications.all, + media_mode = ChatConfigDB.MediaMode.photo, + chat_type = ChatConfigDB.ChatType.telegram, + ), + author = Mock(spec = User, id = UUID(int = 1)), + message = Mock(spec = ChatMessage, message_id = "test-message-id"), + ) + + result = respond_to_update(self.update) + + self.assertTrue(result) + self.di.platform_bot_sdk.return_value.set_reaction.assert_called_once_with("123", "test-message-id", "👍") + self.di.domain_langchain_mapper.map_bot_message_to_storage.assert_not_called() + self.di.telegram_bot_sdk.send_text_message.assert_not_called() + saved_message = self.di.chat_message_crud.save.call_args.args[0] + self.assertIsInstance(saved_message, ChatMessageSave) + self.assertEqual(saved_message.message_id, "reaction:test-message-id") + self.assertEqual(saved_message.text, "👍") + + def test_reaction_response_failure(self): + self.di.chat_agent.return_value.execute.return_value = Mock(spec = AIMessage, content = "👍") + self.di.platform_bot_sdk.return_value.set_reaction.side_effect = Exception("Reaction failed") + self.di.telegram_domain_mapper.map_update.return_value = Mock( + spec = TelegramDomainMapper.Result, + message = Mock(spec = ChatMessage, message_id = "test-message-id", text = "Test message text"), + ) + self.di.telegram_data_resolver.resolve.return_value = Mock( + spec = TelegramDataResolver.Result, + chat = ChatConfig( + chat_id = UUID(int = 123), + external_id = "123", + language_name = "English", + language_iso_code = "en", + title = "Test Chat", + is_private = False, + reply_chance_percent = 100, + release_notifications = ChatConfigDB.ReleaseNotifications.all, + media_mode = ChatConfigDB.MediaMode.photo, + chat_type = ChatConfigDB.ChatType.telegram, + ), + author = Mock(spec = User, id = UUID(int = 1)), + message = Mock(spec = ChatMessage, message_id = "test-message-id"), + ) + + result = respond_to_update(self.update) + + self.assertTrue(result) + self.di.platform_bot_sdk.return_value.set_reaction.assert_called_once_with("123", "test-message-id", "👍") + self.di.domain_langchain_mapper.map_bot_message_to_storage.assert_not_called() + self.di.telegram_bot_sdk.send_text_message.assert_not_called() + saved_message = self.di.chat_message_crud.save.call_args.args[0] + self.assertIsInstance(saved_message, ChatMessageSave) + self.assertEqual(saved_message.message_id, "reaction:test-message-id") + self.assertEqual(saved_message.text, "👍") + def test_empty_response(self): self.di.telegram_domain_mapper.map_update.return_value = Mock(spec = TelegramDomainMapper.Result) self.di.telegram_data_resolver.resolve.return_value = Mock( diff --git a/test/features/chat/whatsapp/test_whatsapp_update_responder.py b/test/features/chat/whatsapp/test_whatsapp_update_responder.py index 8131664d..72a91b82 100644 --- a/test/features/chat/whatsapp/test_whatsapp_update_responder.py +++ b/test/features/chat/whatsapp/test_whatsapp_update_responder.py @@ -9,7 +9,7 @@ from db.model.chat_config import ChatConfigDB from db.model.user import UserDB from db.schema.chat_config import ChatConfig -from db.schema.chat_message import ChatMessage +from db.schema.chat_message import ChatMessage, ChatMessageSave from db.schema.user import User from features.chat.whatsapp.model.update import Update from features.chat.whatsapp.whatsapp_data_resolver import WhatsAppDataResolver @@ -107,6 +107,97 @@ def test_successful_response(self): self.di.chat_agent.return_value.execute.assert_called_once() self.di.whatsapp_bot_sdk.send_text_message.assert_called_once_with("123", "Test response") + def test_reaction_response(self): + self.di.chat_agent.return_value.execute.return_value = Mock(spec = AIMessage, content = "👍") + self.di.whatsapp_domain_mapper.map_update.return_value = [ + Mock( + spec = WhatsAppDomainMapper.Result, + message = Mock(spec = ChatMessage, sent_at = datetime.now()), + ), + ] + self.di.whatsapp_data_resolver.resolve_all.return_value = [ + Mock( + spec = WhatsAppDataResolver.Result, + chat = ChatConfig( + chat_id = UUID(int = 123), + external_id = "123", + language_name = "English", + language_iso_code = "en", + title = "Test Chat", + is_private = False, + reply_chance_percent = 100, + release_notifications = ChatConfigDB.ReleaseNotifications.all, + media_mode = ChatConfigDB.MediaMode.photo, + chat_type = ChatConfigDB.ChatType.whatsapp, + ), + author = Mock(spec = User, id = UUID(int = 1)), + message = Mock( + spec = ChatMessage, + message_id = "test-message-id", + sent_at = datetime.now(), + text = "Test message text", + ), + ), + ] + + result = respond_to_update(self.update) + + self.assertTrue(result) + self.di.platform_bot_sdk.return_value.set_reaction.assert_called_once_with("123", "test-message-id", "👍") + self.di.domain_langchain_mapper.map_bot_message_to_storage.assert_not_called() + self.di.whatsapp_bot_sdk.send_text_message.assert_not_called() + self.di.whatsapp_bot_sdk.mark_as_read.assert_called_once_with("test-message-id") + saved_message = self.di.chat_message_crud.save.call_args.args[0] + self.assertIsInstance(saved_message, ChatMessageSave) + self.assertEqual(saved_message.message_id, "reaction:test-message-id") + self.assertEqual(saved_message.text, "👍") + + def test_reaction_response_failure(self): + self.di.chat_agent.return_value.execute.return_value = Mock(spec = AIMessage, content = "👍") + self.di.platform_bot_sdk.return_value.set_reaction.side_effect = Exception("Reaction failed") + self.di.whatsapp_domain_mapper.map_update.return_value = [ + Mock( + spec = WhatsAppDomainMapper.Result, + message = Mock(spec = ChatMessage, sent_at = datetime.now()), + ), + ] + self.di.whatsapp_data_resolver.resolve_all.return_value = [ + Mock( + spec = WhatsAppDataResolver.Result, + chat = ChatConfig( + chat_id = UUID(int = 123), + external_id = "123", + language_name = "English", + language_iso_code = "en", + title = "Test Chat", + is_private = False, + reply_chance_percent = 100, + release_notifications = ChatConfigDB.ReleaseNotifications.all, + media_mode = ChatConfigDB.MediaMode.photo, + chat_type = ChatConfigDB.ChatType.whatsapp, + ), + author = Mock(spec = User, id = UUID(int = 1)), + message = Mock( + spec = ChatMessage, + message_id = "test-message-id", + sent_at = datetime.now(), + text = "Test message text", + ), + ), + ] + + result = respond_to_update(self.update) + + self.assertTrue(result) + self.di.platform_bot_sdk.return_value.set_reaction.assert_called_once_with("123", "test-message-id", "👍") + self.di.domain_langchain_mapper.map_bot_message_to_storage.assert_not_called() + self.di.whatsapp_bot_sdk.send_text_message.assert_not_called() + self.di.whatsapp_bot_sdk.mark_as_read.assert_called_once_with("test-message-id") + saved_message = self.di.chat_message_crud.save.call_args.args[0] + self.assertIsInstance(saved_message, ChatMessageSave) + self.assertEqual(saved_message.message_id, "reaction:test-message-id") + self.assertEqual(saved_message.text, "👍") + def test_empty_response(self): self.di.whatsapp_domain_mapper.map_update.return_value = [Mock(spec = WhatsAppDomainMapper.Result)] self.di.whatsapp_data_resolver.resolve.return_value = Mock( diff --git a/test/features/integrations/test_integrations.py b/test/features/integrations/test_integrations.py index c85c8d1f..5a99510c 100644 --- a/test/features/integrations/test_integrations.py +++ b/test/features/integrations/test_integrations.py @@ -18,9 +18,11 @@ WHATSAPP_MESSAGING_WINDOW_HOURS, format_handle, is_own_chat, + is_reaction_response, is_the_agent, lookup_user_by_handle, resolve_agent_user, + resolve_allowed_reactions, resolve_any_external_handle, resolve_best_notification_chat, resolve_external_handle, @@ -60,6 +62,22 @@ def test_resolve_agent_user_whatsapp(self): self.assertEqual(agent.whatsapp_phone_number.get_secret_value(), config.whatsapp_bot_phone_number) self.assertEqual(agent.full_name, "The Agent") + def test_resolve_allowed_reactions_telegram(self): + reactions = resolve_allowed_reactions(ChatConfigDB.ChatType.telegram) + self.assertIn("👍", reactions) + + def test_resolve_allowed_reactions_github_empty(self): + self.assertEqual(resolve_allowed_reactions(ChatConfigDB.ChatType.github), []) + + def test_is_reaction_response(self): + self.assertTrue(is_reaction_response(" 👍 ", ChatConfigDB.ChatType.telegram)) + + def test_is_reaction_response_rejects_text(self): + self.assertFalse(is_reaction_response("👍 ok", ChatConfigDB.ChatType.telegram)) + + def test_is_reaction_response_rejects_unsupported_chat_type(self): + self.assertFalse(is_reaction_response("👍", ChatConfigDB.ChatType.github)) + def test_resolve_external_id_telegram_success(self): user = User( id = UUID(int = 1),