diff --git a/helpers/integration_commands.py b/helpers/integration_commands.py new file mode 100644 index 000000000..d78f3ff6e --- /dev/null +++ b/helpers/integration_commands.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from helpers import message_queue as mq +from helpers import projects +from helpers.persist_chat import save_tmp_chat +from helpers.state_monitor_integration import mark_dirty_for_context +from plugins._model_config.helpers import model_config + +if TYPE_CHECKING: + from agent import AgentContext + + +_CLEAR_VALUES = {"", "default", "none", "clear", "off"} +_SUPPORTED_COMMANDS = {"/send", "/queue", "/project", "/config", "/preset"} + + +def extract_command_line(text: str) -> str: + for line in (text or "").splitlines(): + stripped = line.strip() + if not stripped: + continue + return stripped + return "" + + +def parse_command(text: str) -> tuple[str, str] | None: + line = extract_command_line(text) + if not line.startswith("/"): + return None + + command, _, args = line.partition(" ") + command = command.strip().lower() + if command not in _SUPPORTED_COMMANDS: + return None + + return command, args.strip() + + +def try_handle_command(context: "AgentContext", text: str) -> str | None: + parsed = parse_command(text) + if not parsed: + return None + + command, args = parsed + if command == "/send": + return _handle_queue(context, "send") + if command == "/queue": + return _handle_queue(context, args) + if command == "/project": + return _handle_project(context, args) + if command in {"/config", "/preset"}: + return _handle_config(context, args) + return None + + +def _handle_queue(context: "AgentContext", args: str) -> str: + queue = mq.get_queue(context) + count = len(queue) + action = args.strip().lower() + + if not action: + noun = "message" if count == 1 else "messages" + return ( + f"Queue has {count} {noun}.\n" + "Use /send or /queue send to send everything as one batch." + ) + + if action not in {"send", "all"}: + return "Unknown queue action. Use /queue send to flush the queue." + + if count == 0: + return "Queue is empty." + + sent_count = mq.send_all_aggregated(context) + mark_dirty_for_context(context.id, reason="integration_commands.queue_send") + noun = "message" if sent_count == 1 else "messages" + return f"Sent {sent_count} queued {noun} as one batch." + + +def _handle_project(context: "AgentContext", args: str) -> str: + items = projects.get_active_projects_list() or [] + current_name = context.get_data("project") or "" + + if not args: + current_label = _describe_project(items, current_name) + available = ", ".join(_format_project_entry(item) for item in items) or "none" + return ( + f"Current project: {current_label}\n" + f"Available projects: {available}\n" + "Use /project to switch, or /project none to clear it." + ) + + desired = _strip_quotes(args) + if _normalize_lookup(desired) in _CLEAR_VALUES: + if not current_name: + return "No project is active." + projects.deactivate_project(context.id) + return "Cleared the active project." + + match, ambiguous = _match_named_item(items, desired, keys=("name", "title")) + if ambiguous: + names = ", ".join(_format_project_entry(item) for item in ambiguous) + return f"Project name is ambiguous. Matches: {names}" + if not match: + available = ", ".join(_format_project_entry(item) for item in items) or "none" + return f"Project '{desired}' was not found. Available projects: {available}" + + if match.get("name") == current_name: + return f"Already using project {match.get('title') or match.get('name')}." + + projects.activate_project(context.id, match["name"]) + return f"Switched project to {match.get('title') or match['name']}." + + +def _handle_config(context: "AgentContext", args: str) -> str: + allowed = model_config.is_chat_override_allowed(context.agent0) + presets = [preset for preset in model_config.get_presets() if preset.get("name")] + current_override = context.get_data("chat_model_override") + + if not args: + current_label = _describe_override(current_override) + available = ", ".join(preset["name"] for preset in presets) or "none" + suffix = "Use /config to switch, or /config default to clear it." + if not allowed: + suffix = "Per-chat config switching is disabled in Model Configuration." + return ( + f"Current config: {current_label}\n" + f"Available configs: {available}\n" + f"{suffix}" + ) + + if not allowed: + return "Config switching is disabled in Model Configuration." + + desired = _strip_quotes(args) + if _normalize_lookup(desired) in _CLEAR_VALUES: + if not current_override: + return "Already using the default config." + context.set_data("chat_model_override", None) + save_tmp_chat(context) + mark_dirty_for_context(context.id, reason="integration_commands.config_clear") + return "Switched back to the default config." + + match, ambiguous = _match_named_item(presets, desired, keys=("name",)) + if ambiguous: + names = ", ".join(item["name"] for item in ambiguous) + return f"Config name is ambiguous. Matches: {names}" + if not match: + available = ", ".join(preset["name"] for preset in presets) or "none" + return f"Config '{desired}' was not found. Available configs: {available}" + + preset_name = match["name"] + if isinstance(current_override, dict) and current_override.get("preset_name") == preset_name: + return f"Already using config {preset_name}." + + context.set_data("chat_model_override", {"preset_name": preset_name}) + save_tmp_chat(context) + mark_dirty_for_context(context.id, reason="integration_commands.config_set") + return f"Switched config to {preset_name}." + + +def _format_project_entry(item: dict) -> str: + title = str(item.get("title", "") or "").strip() + name = str(item.get("name", "") or "").strip() + if title and title.lower() != name.lower(): + return f"{title} ({name})" + return name or title + + +def _describe_project(items: list[dict], current_name: str) -> str: + if not current_name: + return "none" + for item in items: + if item.get("name") == current_name: + return item.get("title") or current_name + return current_name + + +def _describe_override(override: dict | None) -> str: + if not override: + return "Default" + if isinstance(override, dict) and override.get("preset_name"): + return str(override["preset_name"]) + return "Custom override" + + +def _strip_quotes(value: str) -> str: + trimmed = value.strip() + if len(trimmed) >= 2 and trimmed[0] == trimmed[-1] and trimmed[0] in {'"', "'"}: + return trimmed[1:-1].strip() + return trimmed + + +def _normalize_lookup(value: str) -> str: + lowered = value.lower().strip() + lowered = re.sub(r"[\s_\-]+", " ", lowered) + lowered = re.sub(r"[^a-z0-9 ]+", "", lowered) + return lowered.strip() + + +def _match_named_item( + items: list[dict], + desired: str, + *, + keys: tuple[str, ...], +) -> tuple[dict | None, list[dict]]: + normalized = _normalize_lookup(desired) + exact_matches: list[dict] = [] + + for item in items: + values = [str(item.get(key, "") or "") for key in keys] + normalized_values = [_normalize_lookup(value) for value in values if value] + if normalized in normalized_values: + exact_matches.append(item) + + if len(exact_matches) == 1: + return exact_matches[0], [] + if len(exact_matches) > 1: + return None, exact_matches + + partial_matches: list[dict] = [] + for item in items: + values = [str(item.get(key, "") or "") for key in keys] + normalized_values = [_normalize_lookup(value) for value in values if value] + if any(normalized and normalized in value for value in normalized_values): + partial_matches.append(item) + + if len(partial_matches) == 1: + return partial_matches[0], [] + if len(partial_matches) > 1: + return None, partial_matches + + return None, [] diff --git a/plugins/_email_integration/README.md b/plugins/_email_integration/README.md index a35f70b04..ad85fd1c6 100644 --- a/plugins/_email_integration/README.md +++ b/plugins/_email_integration/README.md @@ -22,9 +22,11 @@ It supports both: - **Dispatcher workflow** - Reuses or creates a background `Email Dispatcher` context. - Uses model prompts to decide whether an email belongs to an existing chat or should open a new one. + - Supports handler-level model presets for new chats, in addition to the dispatcher's utility/chat routing mode. - **Thread routing** - Can continue an existing chat by thread ID found in the email subject. - Falls back to model-based dispatch if no direct thread match is available. + - Email replies inside an existing Agent Zero thread can use `/project`, `/config`, and `/send` control commands. - **Notifications and persistence** - Saves chats after routing and emits notifications about new or continued conversations. diff --git a/plugins/_email_integration/default_config.yaml b/plugins/_email_integration/default_config.yaml index e6f4e20ad..557bad0ff 100644 --- a/plugins/_email_integration/default_config.yaml +++ b/plugins/_email_integration/default_config.yaml @@ -16,5 +16,6 @@ handlers: [] # sender_whitelist: [] # project: "" # dispatcher_model: utility +# chat_model_preset: "" # Optional preset from Model Configuration for new email chats # dispatcher_instructions: "" # agent_instructions: "" diff --git a/plugins/_email_integration/helpers/handler.py b/plugins/_email_integration/helpers/handler.py index f36ee3efe..4d27e41a5 100644 --- a/plugins/_email_integration/helpers/handler.py +++ b/plugins/_email_integration/helpers/handler.py @@ -13,12 +13,14 @@ import uuid from agent import Agent, AgentContext, AgentContextType, UserMessage from helpers import guids, plugins, files, runtime from helpers import message_queue as mq +from helpers import integration_commands from helpers.persist_chat import save_tmp_chat from helpers.print_style import PrintStyle from helpers.errors import format_error from initialize import initialize_agent from plugins._email_integration.helpers import dispatcher as disp +from plugins._model_config.helpers import model_config from plugins._email_integration.helpers.imap_client import ( InboundMessage, connect_imap, @@ -177,6 +179,9 @@ async def _dispatch_message(agent: Agent, handler_cfg: dict, msg: InboundMessage existing = _find_handler_chats(handler_name, msg.sender) + if await _handle_control_email(handler_cfg, msg, existing, thread_id): + return + # Fast path: thread ID in subject matches a known chat if thread_id: for chat in existing: @@ -281,6 +286,7 @@ async def _start_new_chat(agent: Agent, handler_cfg: dict, msg: InboundMessage): if project: projects.activate_project(context.id, project) + _apply_handler_model_preset(context, handler_cfg) save_tmp_chat(context) user_msg = _build_user_message(agent, msg, handler_cfg) @@ -310,6 +316,8 @@ async def _route_to_chat( context.data[disp.CTX_EMAIL_MESSAGE_ID] = msg.message_id context.data[disp.CTX_EMAIL_LAST_BODY] = msg.body + if not context.get_data("chat_model_override"): + _apply_handler_model_preset(context, handler_cfg) refs = context.data.get(disp.CTX_EMAIL_REFERENCES, "") refs_list = refs.split() if refs else [] @@ -337,6 +345,131 @@ async def _route_to_chat( PrintStyle.info(f"Email: continuing chat {context_id}") +async def _handle_control_email( + handler_cfg: dict, + msg: InboundMessage, + existing_chats: list[disp.ChatSummary], + thread_id: str, +) -> bool: + parsed = integration_commands.parse_command(msg.body) + if not parsed: + return False + + target_context_id = "" + if thread_id: + for chat in existing_chats: + if chat["thread_id"] == thread_id: + target_context_id = chat["context_id"] + break + + if not target_context_id: + if len(existing_chats) == 1: + target_context_id = existing_chats[0]["context_id"] + elif len(existing_chats) > 1: + await _send_control_email_reply( + handler_cfg, + msg, + "Multiple Agent Zero email chats match this sender. Reply inside the thread you want to control.", + thread_id=thread_id, + ) + return True + else: + await _send_control_email_reply( + handler_cfg, + msg, + "No matching Agent Zero email chat was found. Reply inside an existing Agent Zero email thread to use /project, /config, or /send.", + thread_id=thread_id, + ) + return True + + context = AgentContext.get(target_context_id) + if not context: + await _send_control_email_reply( + handler_cfg, + msg, + "The matching Agent Zero email chat is no longer available. Send a normal email to start a fresh thread.", + thread_id=thread_id, + ) + return True + + response = integration_commands.try_handle_command(context, msg.body) + if response is None: + return False + + await _send_control_email_reply( + handler_cfg, + msg, + response, + thread_id=context.data.get(disp.CTX_EMAIL_THREAD_ID, "") or thread_id, + ) + return True + + +def _apply_handler_model_preset(context: AgentContext, handler_cfg: dict) -> None: + preset_name = str(handler_cfg.get("chat_model_preset", "") or "").strip() + if not preset_name: + return + if not model_config.is_chat_override_allowed(context.agent0): + PrintStyle.warning( + f"Email ({handler_cfg.get('name', 'default')}): chat override is disabled," + f" cannot apply preset '{preset_name}'" + ) + return + if not model_config.get_preset_by_name(preset_name): + PrintStyle.warning( + f"Email ({handler_cfg.get('name', 'default')}): preset '{preset_name}' was not found" + ) + return + context.set_data("chat_model_override", {"preset_name": preset_name}) + + +async def _send_control_email_reply( + handler_cfg: dict, + msg: InboundMessage, + body: str, + *, + thread_id: str = "", +) -> str | None: + smtp_cfg = SmtpConfig( + server=handler_cfg.get("smtp_server", handler_cfg.get("imap_server", "")), + port=int(handler_cfg.get("smtp_port", 587)), + username=handler_cfg.get("username", ""), + password=handler_cfg.get("password", ""), + ) + + subject = _build_control_reply_subject(msg.subject, thread_id) + references = _merge_references(msg.references, msg.message_id) + + return await send_reply( + config=smtp_cfg, + to=msg.sender, + subject=subject, + body=body, + in_reply_to=msg.message_id, + references=references, + attachments=None, + ) + + +def _build_control_reply_subject(subject: str, thread_id: str) -> str: + if thread_id: + return disp.build_reply_subject(subject, thread_id) + cleaned = subject.strip() + if not cleaned.lower().startswith("re:"): + cleaned = f"Re: {cleaned}" + return cleaned + + +def _merge_references(existing: str, message_id: str) -> str: + refs = [] + for ref in (existing or "").split(): + if ref and ref not in refs: + refs.append(ref) + if message_id and message_id not in refs: + refs.append(message_id) + return " ".join(refs) + + # ------------------------------------------------------------------ # Chat discovery # ------------------------------------------------------------------ diff --git a/plugins/_email_integration/webui/config.html b/plugins/_email_integration/webui/config.html index 5240045f9..4e70a913b 100644 --- a/plugins/_email_integration/webui/config.html +++ b/plugins/_email_integration/webui/config.html @@ -436,6 +436,21 @@ +
+
+
Conversation config
+
Optional model preset for new chats created from this inbox.
+
+
+ +
+
+ diff --git a/plugins/_email_integration/webui/email-config-store.js b/plugins/_email_integration/webui/email-config-store.js index a7dcecca7..aa9300d2b 100644 --- a/plugins/_email_integration/webui/email-config-store.js +++ b/plugins/_email_integration/webui/email-config-store.js @@ -77,6 +77,7 @@ export const store = createStore("emailConfig", { guideOpen: false, didInit: false, projects: [], + modelPresets: [], presets: PRESETS, get handlers() { @@ -97,12 +98,15 @@ export const store = createStore("emailConfig", { if (this.handlers.length === 0) this._startInitialHandlerFlow(); this.didInit = true; - try { - const response = await API.callJsonApi("projects", { action: "list" }); - this.projects = response.data || []; - } catch (_) { - this.projects = []; - } + const [projectsResult, presetsResult] = await Promise.allSettled([ + API.callJsonApi("projects", { action: "list" }), + API.callJsonApi("/plugins/_model_config/model_presets", { action: "get" }), + ]); + + this.projects = projectsResult.status === "fulfilled" ? (projectsResult.value.data || []) : []; + this.modelPresets = presetsResult.status === "fulfilled" && Array.isArray(presetsResult.value.presets) + ? presetsResult.value.presets + : []; }, cleanup() { @@ -134,6 +138,7 @@ export const store = createStore("emailConfig", { sender_whitelist: [], project: "", dispatcher_model: "utility", + chat_model_preset: "", dispatcher_instructions: "", agent_instructions: "", }; diff --git a/plugins/_telegram_integration/README.md b/plugins/_telegram_integration/README.md index c83d6d3b3..8201ab882 100644 --- a/plugins/_telegram_integration/README.md +++ b/plugins/_telegram_integration/README.md @@ -17,6 +17,9 @@ This plugin connects one or more Telegram bots to Agent Zero. Each bot runs inde - **Per-user chat sessions** - Each Telegram user gets a dedicated `AgentContext`, persisted across restarts via a JSON state file. - `/start` creates a context; `/clear` resets it. + - `/project ` switches the active project for the current chat. + - `/config ` switches the active model preset for the current chat. + - `/send` or `/queue send` flushes the queued messages for the current chat. - **Group support** - Three modes: `mention` (respond only when @mentioned or replied to), `all` (respond to every message), `off` (private only). - Optional welcome message for new members. diff --git a/plugins/_telegram_integration/extensions/python/job_loop/_10_telegram_bot.py b/plugins/_telegram_integration/extensions/python/job_loop/_10_telegram_bot.py index ad2f9493d..6355a9a96 100644 --- a/plugins/_telegram_integration/extensions/python/job_loop/_10_telegram_bot.py +++ b/plugins/_telegram_integration/extensions/python/job_loop/_10_telegram_bot.py @@ -84,6 +84,7 @@ class TelegramBotManager(Extension): on_message=_on_message, on_command_start=_on_start, on_command_clear=_on_clear, + on_command_control=_on_message, on_callback_query=_on_callback, on_new_members=_on_new_members, group_mode=bot_cfg.get("group_mode", "mention"), diff --git a/plugins/_telegram_integration/helpers/bot_manager.py b/plugins/_telegram_integration/helpers/bot_manager.py index cd300fa2f..b59c68664 100644 --- a/plugins/_telegram_integration/helpers/bot_manager.py +++ b/plugins/_telegram_integration/helpers/bot_manager.py @@ -45,6 +45,7 @@ def create_bot( on_message: Callable[..., Awaitable], on_command_start: Callable[..., Awaitable], on_command_clear: Callable[..., Awaitable], + on_command_control: Callable[..., Awaitable] | None = None, on_callback_query: Callable[..., Awaitable] | None = None, on_new_members: Callable[..., Awaitable] | None = None, group_mode: str = "mention", @@ -56,6 +57,11 @@ def create_bot( # Register command handlers router.message.register(on_command_start, CommandStart()) router.message.register(on_command_clear, Command("clear")) + if on_command_control: + router.message.register( + on_command_control, + Command(commands=["project", "config", "preset", "queue", "send"]), + ) if on_callback_query: router.callback_query.register(on_callback_query) diff --git a/plugins/_telegram_integration/helpers/handler.py b/plugins/_telegram_integration/helpers/handler.py index 444e54c2b..ea2dbd055 100644 --- a/plugins/_telegram_integration/helpers/handler.py +++ b/plugins/_telegram_integration/helpers/handler.py @@ -13,6 +13,7 @@ from aiogram.types import Message as TgMessage, CallbackQuery from agent import AgentContext, UserMessage from helpers import plugins, files, projects from helpers import message_queue as mq +from helpers import integration_commands from helpers.notification import NotificationManager, NotificationType, NotificationPriority from helpers.persist_chat import save_tmp_chat from helpers.print_style import PrintStyle @@ -139,7 +140,8 @@ async def handle_start(message: TgMessage, bot_name: str, bot_cfg: dict): instance.bot.token, message.chat.id, f"\U0001f44b Hello {user.first_name}! I'm connected to Agent Zero.\n\n" "Send me a message and I'll process it.\n" - "Use /clear to reset the conversation.", + "Use /clear to reset the conversation.\n" + "Use /project, /config, or /send to control the current chat.", parse_mode=None, ) @@ -201,13 +203,9 @@ async def handle_message(message: TgMessage, bot_name: str, bot_cfg: dict): if not instance: return - # Start persistent typing indicator (thread-based, works across event loops) - typing_stop = _start_typing(instance.bot.token, message.chat.id) - - # Get or create agent context + text = _extract_message_content(message) context = await _get_or_create_context(bot_name, bot_cfg, message) if not context: - typing_stop.set() await _send_with_temp_bot( instance.bot.token, message.chat.id, "Failed to create chat session.", @@ -215,6 +213,14 @@ async def handle_message(message: TgMessage, bot_name: str, bot_cfg: dict): ) return + command_reply = integration_commands.try_handle_command(context, text) + if command_reply is not None: + await _send_with_temp_bot(instance.bot.token, message.chat.id, command_reply, parse_mode=None) + return + + # Start persistent typing indicator (thread-based, works across event loops) + typing_stop = _start_typing(instance.bot.token, message.chat.id) + # Store stop event so send_telegram_reply can cancel typing context.data[CTX_TG_TYPING_STOP] = typing_stop @@ -227,9 +233,6 @@ async def handle_message(message: TgMessage, bot_name: str, bot_cfg: dict): reply_to_id = message.message_id context.data[CTX_TG_REPLY_TO] = reply_to_id - # Build user message text - text = _extract_message_content(message) - # Use temp bot for downloads (cross-event-loop safe) async with _temp_bot(instance.bot.token) as dl_bot: attachments = await _download_attachments(dl_bot, message, bot_name=bot_name) @@ -289,6 +292,13 @@ async def handle_callback_query(query: CallbackQuery, bot_name: str, bot_cfg: di if not context: return + command_reply = integration_commands.try_handle_command(context, text) + if command_reply is not None: + instance = get_bot(bot_name) + if instance: + await _send_with_temp_bot(instance.bot.token, query.message.chat.id, command_reply, parse_mode=None) + return + agent = context.agent0 user_msg = agent.read_prompt( "fw.telegram.user_message.md", diff --git a/plugins/_whatsapp_integration/README.md b/plugins/_whatsapp_integration/README.md index 3180556c1..f144507a1 100644 --- a/plugins/_whatsapp_integration/README.md +++ b/plugins/_whatsapp_integration/README.md @@ -24,6 +24,7 @@ Dependencies are auto-installed on first bridge start if missing. 2. Configure allowed phone numbers 3. Click Show QR Code and scan with WhatsApp on your phone 4. Send a message from an allowed number to start a chat +5. Use `/project `, `/config `, or `/send` in WhatsApp to control the active chat directly The WhatsApp session persists across restarts in `tmp/whatsapp/session/`. No re-pairing needed unless you disconnect via settings. Be careful: if you use your personal number and leave `allowed_numbers` open, other people could misuse your Agent Zero. diff --git a/plugins/_whatsapp_integration/helpers/handler.py b/plugins/_whatsapp_integration/helpers/handler.py index 88587e43f..e1efe415c 100644 --- a/plugins/_whatsapp_integration/helpers/handler.py +++ b/plugins/_whatsapp_integration/helpers/handler.py @@ -13,6 +13,7 @@ import uuid from agent import Agent, AgentContext, UserMessage from helpers import plugins, files, runtime from helpers import message_queue as mq +from helpers import integration_commands from helpers.persist_chat import save_tmp_chat from helpers.print_style import PrintStyle from helpers.errors import format_error @@ -120,15 +121,15 @@ async def _dispatch_message(config: dict, msg: dict) -> None: PrintStyle.debug(f"WhatsApp: skipping group message (not mentioned or replied to)") return - # Show typing indicator immediately so user sees activity - port = int(config.get("bridge_port", 3100)) - base_url = bridge_manager.get_bridge_url(port) - await wa_client.send_typing(base_url, chat_id) - existing = _find_chats_by_jid(chat_id) if existing: # Continue most recent chat for this JID + if await _handle_control_message(config, msg, existing[0]): + return + port = int(config.get("bridge_port", 3100)) + base_url = bridge_manager.get_bridge_url(port) + await wa_client.send_typing(base_url, chat_id) await _route_to_chat(msg, existing[0]) else: await _start_new_chat(config, msg) @@ -163,6 +164,13 @@ async def _start_new_chat(config: dict, msg: dict) -> None: save_tmp_chat(context) + if await _handle_control_message(config, msg, context.id, context=context): + return + + port = int(config.get("bridge_port", 3100)) + base_url = bridge_manager.get_bridge_url(port) + await wa_client.send_typing(base_url, chat_id) + user_msg = _build_user_message(context.agent0, msg) system_ctx = context.agent0.read_prompt("fw.wa.system_context.md") @@ -212,6 +220,38 @@ async def _route_to_chat( PrintStyle.info(f"WhatsApp: continuing chat {context_id}") +async def _handle_control_message( + config: dict, + msg: dict, + context_id: str, + *, + context: AgentContext | None = None, +) -> bool: + text = msg.get("body", "") or "" + parsed = integration_commands.parse_command(text) + if not parsed: + return False + + context = context or AgentContext.get(context_id) + if not context: + return False + + response = integration_commands.try_handle_command(context, text) + if response is None: + return False + + port = int(config.get("bridge_port", 3100)) + base_url = bridge_manager.get_bridge_url(port) + chat_id = context.data.get(CTX_WA_CHAT_ID, "") or msg.get("chatId", "") + reply_to = msg.get("messageId", "") if context.data.get(CTX_WA_IS_GROUP) else "" + + await wa_client.send_message(base_url, chat_id, response, reply_to=reply_to) + await wa_client.send_typing(base_url, chat_id, paused=True) + context.data[CTX_WA_TYPING_ACTIVE] = False + PrintStyle.info(f"WhatsApp: handled control command in chat {context.id}") + return True + + # ------------------------------------------------------------------ # Chat discovery # ------------------------------------------------------------------