integrations: add native chat controls and email config presets

Add shared transport-level control commands so Telegram, WhatsApp, and email threads can manage the active chat directly. - add a shared integration command helper for /project, /config, /send, and /queue send - wire native command handling into Telegram and WhatsApp sessions - expose Telegram control commands through bot command routing and update transport docs - add email thread command handling for existing A0 email conversations - add an optional per-handler email conversation preset backed by model presets in the email settings UI and default config - document the new transport control flow across Telegram, WhatsApp, and email

Alessandro committed Apr 11, 2026 at 18:49 UTC 395ef8dd33b9ec3fb573c040b183474bf6e107bb
12 files changed +473 -20
helpers/integration_commands.py new
+236
@@ -0,0 +1,236 @@
1 +from __future__ import annotations
2 +
3 +import re
4 +from typing import TYPE_CHECKING
5 +
6 +from helpers import message_queue as mq
7 +from helpers import projects
8 +from helpers.persist_chat import save_tmp_chat
9 +from helpers.state_monitor_integration import mark_dirty_for_context
10 +from plugins._model_config.helpers import model_config
11 +
12 +if TYPE_CHECKING:
13 + from agent import AgentContext
14 +
15 +
16 +_CLEAR_VALUES = {"", "default", "none", "clear", "off"}
17 +_SUPPORTED_COMMANDS = {"/send", "/queue", "/project", "/config", "/preset"}
18 +
19 +
20 +def extract_command_line(text: str) -> str:
21 + for line in (text or "").splitlines():
22 + stripped = line.strip()
23 + if not stripped:
24 + continue
25 + return stripped
26 + return ""
27 +
28 +
29 +def parse_command(text: str) -> tuple[str, str] | None:
30 + line = extract_command_line(text)
31 + if not line.startswith("/"):
32 + return None
33 +
34 + command, _, args = line.partition(" ")
35 + command = command.strip().lower()
36 + if command not in _SUPPORTED_COMMANDS:
37 + return None
38 +
39 + return command, args.strip()
40 +
41 +
42 +def try_handle_command(context: "AgentContext", text: str) -> str | None:
43 + parsed = parse_command(text)
44 + if not parsed:
45 + return None
46 +
47 + command, args = parsed
48 + if command == "/send":
49 + return _handle_queue(context, "send")
50 + if command == "/queue":
51 + return _handle_queue(context, args)
52 + if command == "/project":
53 + return _handle_project(context, args)
54 + if command in {"/config", "/preset"}:
55 + return _handle_config(context, args)
56 + return None
57 +
58 +
59 +def _handle_queue(context: "AgentContext", args: str) -> str:
60 + queue = mq.get_queue(context)
61 + count = len(queue)
62 + action = args.strip().lower()
63 +
64 + if not action:
65 + noun = "message" if count == 1 else "messages"
66 + return (
67 + f"Queue has {count} {noun}.\n"
68 + "Use /send or /queue send to send everything as one batch."
69 + )
70 +
71 + if action not in {"send", "all"}:
72 + return "Unknown queue action. Use /queue send to flush the queue."
73 +
74 + if count == 0:
75 + return "Queue is empty."
76 +
77 + sent_count = mq.send_all_aggregated(context)
78 + mark_dirty_for_context(context.id, reason="integration_commands.queue_send")
79 + noun = "message" if sent_count == 1 else "messages"
80 + return f"Sent {sent_count} queued {noun} as one batch."
81 +
82 +
83 +def _handle_project(context: "AgentContext", args: str) -> str:
84 + items = projects.get_active_projects_list() or []
85 + current_name = context.get_data("project") or ""
86 +
87 + if not args:
88 + current_label = _describe_project(items, current_name)
89 + available = ", ".join(_format_project_entry(item) for item in items) or "none"
90 + return (
91 + f"Current project: {current_label}\n"
92 + f"Available projects: {available}\n"
93 + "Use /project <name> to switch, or /project none to clear it."
94 + )
95 +
96 + desired = _strip_quotes(args)
97 + if _normalize_lookup(desired) in _CLEAR_VALUES:
98 + if not current_name:
99 + return "No project is active."
100 + projects.deactivate_project(context.id)
101 + return "Cleared the active project."
102 +
103 + match, ambiguous = _match_named_item(items, desired, keys=("name", "title"))
104 + if ambiguous:
105 + names = ", ".join(_format_project_entry(item) for item in ambiguous)
106 + return f"Project name is ambiguous. Matches: {names}"
107 + if not match:
108 + available = ", ".join(_format_project_entry(item) for item in items) or "none"
109 + return f"Project '{desired}' was not found. Available projects: {available}"
110 +
111 + if match.get("name") == current_name:
112 + return f"Already using project {match.get('title') or match.get('name')}."
113 +
114 + projects.activate_project(context.id, match["name"])
115 + return f"Switched project to {match.get('title') or match['name']}."
116 +
117 +
118 +def _handle_config(context: "AgentContext", args: str) -> str:
119 + allowed = model_config.is_chat_override_allowed(context.agent0)
120 + presets = [preset for preset in model_config.get_presets() if preset.get("name")]
121 + current_override = context.get_data("chat_model_override")
122 +
123 + if not args:
124 + current_label = _describe_override(current_override)
125 + available = ", ".join(preset["name"] for preset in presets) or "none"
126 + suffix = "Use /config <name> to switch, or /config default to clear it."
127 + if not allowed:
128 + suffix = "Per-chat config switching is disabled in Model Configuration."
129 + return (
130 + f"Current config: {current_label}\n"
131 + f"Available configs: {available}\n"
132 + f"{suffix}"
133 + )
134 +
135 + if not allowed:
136 + return "Config switching is disabled in Model Configuration."
137 +
138 + desired = _strip_quotes(args)
139 + if _normalize_lookup(desired) in _CLEAR_VALUES:
140 + if not current_override:
141 + return "Already using the default config."
142 + context.set_data("chat_model_override", None)
143 + save_tmp_chat(context)
144 + mark_dirty_for_context(context.id, reason="integration_commands.config_clear")
145 + return "Switched back to the default config."
146 +
147 + match, ambiguous = _match_named_item(presets, desired, keys=("name",))
148 + if ambiguous:
149 + names = ", ".join(item["name"] for item in ambiguous)
150 + return f"Config name is ambiguous. Matches: {names}"
151 + if not match:
152 + available = ", ".join(preset["name"] for preset in presets) or "none"
153 + return f"Config '{desired}' was not found. Available configs: {available}"
154 +
155 + preset_name = match["name"]
156 + if isinstance(current_override, dict) and current_override.get("preset_name") == preset_name:
157 + return f"Already using config {preset_name}."
158 +
159 + context.set_data("chat_model_override", {"preset_name": preset_name})
160 + save_tmp_chat(context)
161 + mark_dirty_for_context(context.id, reason="integration_commands.config_set")
162 + return f"Switched config to {preset_name}."
163 +
164 +
165 +def _format_project_entry(item: dict) -> str:
166 + title = str(item.get("title", "") or "").strip()
167 + name = str(item.get("name", "") or "").strip()
168 + if title and title.lower() != name.lower():
169 + return f"{title} ({name})"
170 + return name or title
171 +
172 +
173 +def _describe_project(items: list[dict], current_name: str) -> str:
174 + if not current_name:
175 + return "none"
176 + for item in items:
177 + if item.get("name") == current_name:
178 + return item.get("title") or current_name
179 + return current_name
180 +
181 +
182 +def _describe_override(override: dict | None) -> str:
183 + if not override:
184 + return "Default"
185 + if isinstance(override, dict) and override.get("preset_name"):
186 + return str(override["preset_name"])
187 + return "Custom override"
188 +
189 +
190 +def _strip_quotes(value: str) -> str:
191 + trimmed = value.strip()
192 + if len(trimmed) >= 2 and trimmed[0] == trimmed[-1] and trimmed[0] in {'"', "'"}:
193 + return trimmed[1:-1].strip()
194 + return trimmed
195 +
196 +
197 +def _normalize_lookup(value: str) -> str:
198 + lowered = value.lower().strip()
199 + lowered = re.sub(r"[\s_\-]+", " ", lowered)
200 + lowered = re.sub(r"[^a-z0-9 ]+", "", lowered)
201 + return lowered.strip()
202 +
203 +
204 +def _match_named_item(
205 + items: list[dict],
206 + desired: str,
207 + *,
208 + keys: tuple[str, ...],
209 +) -> tuple[dict | None, list[dict]]:
210 + normalized = _normalize_lookup(desired)
211 + exact_matches: list[dict] = []
212 +
213 + for item in items:
214 + values = [str(item.get(key, "") or "") for key in keys]
215 + normalized_values = [_normalize_lookup(value) for value in values if value]
216 + if normalized in normalized_values:
217 + exact_matches.append(item)
218 +
219 + if len(exact_matches) == 1:
220 + return exact_matches[0], []
221 + if len(exact_matches) > 1:
222 + return None, exact_matches
223 +
224 + partial_matches: list[dict] = []
225 + for item in items:
226 + values = [str(item.get(key, "") or "") for key in keys]
227 + normalized_values = [_normalize_lookup(value) for value in values if value]
228 + if any(normalized and normalized in value for value in normalized_values):
229 + partial_matches.append(item)
230 +
231 + if len(partial_matches) == 1:
232 + return partial_matches[0], []
233 + if len(partial_matches) > 1:
234 + return None, partial_matches
235 +
236 + return None, []
plugins/_email_integration/README.md
+2
@@ -22,9 +22,11 @@ It supports both:
22 - **Dispatcher workflow**
23 - Reuses or creates a background `Email Dispatcher` context.
24 - Uses model prompts to decide whether an email belongs to an existing chat or should open a new one.
25 + - Supports handler-level model presets for new chats, in addition to the dispatcher's utility/chat routing mode.
26 - **Thread routing**
27 - Can continue an existing chat by thread ID found in the email subject.
28 - Falls back to model-based dispatch if no direct thread match is available.
29 + - Email replies inside an existing Agent Zero thread can use `/project`, `/config`, and `/send` control commands.
30 - **Notifications and persistence**
31 - Saves chats after routing and emits notifications about new or continued conversations.
32
plugins/_email_integration/default_config.yaml
+1
@@ -16,5 +16,6 @@ handlers: []
16 # sender_whitelist: []
17 # project: ""
18 # dispatcher_model: utility
19 +# chat_model_preset: "" # Optional preset from Model Configuration for new email chats
20 # dispatcher_instructions: ""
21 # agent_instructions: ""
plugins/_email_integration/helpers/handler.py
+133
@@ -13,12 +13,14 @@ import uuid
13 from agent import Agent, AgentContext, AgentContextType, UserMessage
14 from helpers import guids, plugins, files, runtime
15 from helpers import message_queue as mq
16 +from helpers import integration_commands
17 from helpers.persist_chat import save_tmp_chat
18 from helpers.print_style import PrintStyle
19 from helpers.errors import format_error
20 from initialize import initialize_agent
21
22 from plugins._email_integration.helpers import dispatcher as disp
23 +from plugins._model_config.helpers import model_config
24 from plugins._email_integration.helpers.imap_client import (
25 InboundMessage,
26 connect_imap,
@@ -177,6 +179,9 @@ async def _dispatch_message(agent: Agent, handler_cfg: dict, msg: InboundMessage
179
180 existing = _find_handler_chats(handler_name, msg.sender)
181
182 + if await _handle_control_email(handler_cfg, msg, existing, thread_id):
183 + return
184 +
185 # Fast path: thread ID in subject matches a known chat
186 if thread_id:
187 for chat in existing:
@@ -281,6 +286,7 @@ async def _start_new_chat(agent: Agent, handler_cfg: dict, msg: InboundMessage):
286 if project:
287 projects.activate_project(context.id, project)
288
289 + _apply_handler_model_preset(context, handler_cfg)
290 save_tmp_chat(context)
291
292 user_msg = _build_user_message(agent, msg, handler_cfg)
@@ -310,6 +316,8 @@ async def _route_to_chat(
316
317 context.data[disp.CTX_EMAIL_MESSAGE_ID] = msg.message_id
318 context.data[disp.CTX_EMAIL_LAST_BODY] = msg.body
319 + if not context.get_data("chat_model_override"):
320 + _apply_handler_model_preset(context, handler_cfg)
321
322 refs = context.data.get(disp.CTX_EMAIL_REFERENCES, "")
323 refs_list = refs.split() if refs else []
@@ -337,6 +345,131 @@ async def _route_to_chat(
345 PrintStyle.info(f"Email: continuing chat {context_id}")
346
347
348 +async def _handle_control_email(
349 + handler_cfg: dict,
350 + msg: InboundMessage,
351 + existing_chats: list[disp.ChatSummary],
352 + thread_id: str,
353 +) -> bool:
354 + parsed = integration_commands.parse_command(msg.body)
355 + if not parsed:
356 + return False
357 +
358 + target_context_id = ""
359 + if thread_id:
360 + for chat in existing_chats:
361 + if chat["thread_id"] == thread_id:
362 + target_context_id = chat["context_id"]
363 + break
364 +
365 + if not target_context_id:
366 + if len(existing_chats) == 1:
367 + target_context_id = existing_chats[0]["context_id"]
368 + elif len(existing_chats) > 1:
369 + await _send_control_email_reply(
370 + handler_cfg,
371 + msg,
372 + "Multiple Agent Zero email chats match this sender. Reply inside the thread you want to control.",
373 + thread_id=thread_id,
374 + )
375 + return True
376 + else:
377 + await _send_control_email_reply(
378 + handler_cfg,
379 + msg,
380 + "No matching Agent Zero email chat was found. Reply inside an existing Agent Zero email thread to use /project, /config, or /send.",
381 + thread_id=thread_id,
382 + )
383 + return True
384 +
385 + context = AgentContext.get(target_context_id)
386 + if not context:
387 + await _send_control_email_reply(
388 + handler_cfg,
389 + msg,
390 + "The matching Agent Zero email chat is no longer available. Send a normal email to start a fresh thread.",
391 + thread_id=thread_id,
392 + )
393 + return True
394 +
395 + response = integration_commands.try_handle_command(context, msg.body)
396 + if response is None:
397 + return False
398 +
399 + await _send_control_email_reply(
400 + handler_cfg,
401 + msg,
402 + response,
403 + thread_id=context.data.get(disp.CTX_EMAIL_THREAD_ID, "") or thread_id,
404 + )
405 + return True
406 +
407 +
408 +def _apply_handler_model_preset(context: AgentContext, handler_cfg: dict) -> None:
409 + preset_name = str(handler_cfg.get("chat_model_preset", "") or "").strip()
410 + if not preset_name:
411 + return
412 + if not model_config.is_chat_override_allowed(context.agent0):
413 + PrintStyle.warning(
414 + f"Email ({handler_cfg.get('name', 'default')}): chat override is disabled,"
415 + f" cannot apply preset '{preset_name}'"
416 + )
417 + return
418 + if not model_config.get_preset_by_name(preset_name):
419 + PrintStyle.warning(
420 + f"Email ({handler_cfg.get('name', 'default')}): preset '{preset_name}' was not found"
421 + )
422 + return
423 + context.set_data("chat_model_override", {"preset_name": preset_name})
424 +
425 +
426 +async def _send_control_email_reply(
427 + handler_cfg: dict,
428 + msg: InboundMessage,
429 + body: str,
430 + *,
431 + thread_id: str = "",
432 +) -> str | None:
433 + smtp_cfg = SmtpConfig(
434 + server=handler_cfg.get("smtp_server", handler_cfg.get("imap_server", "")),
435 + port=int(handler_cfg.get("smtp_port", 587)),
436 + username=handler_cfg.get("username", ""),
437 + password=handler_cfg.get("password", ""),
438 + )
439 +
440 + subject = _build_control_reply_subject(msg.subject, thread_id)
441 + references = _merge_references(msg.references, msg.message_id)
442 +
443 + return await send_reply(
444 + config=smtp_cfg,
445 + to=msg.sender,
446 + subject=subject,
447 + body=body,
448 + in_reply_to=msg.message_id,
449 + references=references,
450 + attachments=None,
451 + )
452 +
453 +
454 +def _build_control_reply_subject(subject: str, thread_id: str) -> str:
455 + if thread_id:
456 + return disp.build_reply_subject(subject, thread_id)
457 + cleaned = subject.strip()
458 + if not cleaned.lower().startswith("re:"):
459 + cleaned = f"Re: {cleaned}"
460 + return cleaned
461 +
462 +
463 +def _merge_references(existing: str, message_id: str) -> str:
464 + refs = []
465 + for ref in (existing or "").split():
466 + if ref and ref not in refs:
467 + refs.append(ref)
468 + if message_id and message_id not in refs:
469 + refs.append(message_id)
470 + return " ".join(refs)
471 +
472 +
473 # ------------------------------------------------------------------
474 # Chat discovery
475 # ------------------------------------------------------------------
plugins/_email_integration/webui/config.html
+15
@@ -436,6 +436,21 @@
436 </div>
437 </div>
438
439 + <div class="field">
440 + <div class="field-label">
441 + <div class="field-title">Conversation config</div>
442 + <div class="field-description">Optional model preset for new chats created from this inbox.</div>
443 + </div>
444 + <div class="field-control">
445 + <select x-model="handler.chat_model_preset">
446 + <option value="">Default</option>
447 + <template x-for="preset in $store.emailConfig.modelPresets" :key="preset.name">
448 + <option :value="preset.name" x-text="preset.name"></option>
449 + </template>
450 + </select>
451 + </div>
452 + </div>
453 +
454 </div>
455 </details>
456 </div>
plugins/_email_integration/webui/email-config-store.js
+11 -6
@@ -77,6 +77,7 @@ export const store = createStore("emailConfig", {
77 guideOpen: false,
78 didInit: false,
79 projects: [],
80 + modelPresets: [],
81 presets: PRESETS,
82
83 get handlers() {
@@ -97,12 +98,15 @@ export const store = createStore("emailConfig", {
98 if (this.handlers.length === 0) this._startInitialHandlerFlow();
99 this.didInit = true;
100
100 - try {
101 - const response = await API.callJsonApi("projects", { action: "list" });
102 - this.projects = response.data || [];
103 - } catch (_) {
104 - this.projects = [];
105 - }
101 + const [projectsResult, presetsResult] = await Promise.allSettled([
102 + API.callJsonApi("projects", { action: "list" }),
103 + API.callJsonApi("/plugins/_model_config/model_presets", { action: "get" }),
104 + ]);
105 +
106 + this.projects = projectsResult.status === "fulfilled" ? (projectsResult.value.data || []) : [];
107 + this.modelPresets = presetsResult.status === "fulfilled" && Array.isArray(presetsResult.value.presets)
108 + ? presetsResult.value.presets
109 + : [];
110 },
111
112 cleanup() {
@@ -134,6 +138,7 @@ export const store = createStore("emailConfig", {
138 sender_whitelist: [],
139 project: "",
140 dispatcher_model: "utility",
141 + chat_model_preset: "",
142 dispatcher_instructions: "",
143 agent_instructions: "",
144 };
plugins/_telegram_integration/README.md
+3
@@ -17,6 +17,9 @@ This plugin connects one or more Telegram bots to Agent Zero. Each bot runs inde
17 - **Per-user chat sessions**
18 - Each Telegram user gets a dedicated `AgentContext`, persisted across restarts via a JSON state file.
19 - `/start` creates a context; `/clear` resets it.
20 + - `/project <name>` switches the active project for the current chat.
21 + - `/config <preset>` switches the active model preset for the current chat.
22 + - `/send` or `/queue send` flushes the queued messages for the current chat.
23 - **Group support**
24 - Three modes: `mention` (respond only when @mentioned or replied to), `all` (respond to every message), `off` (private only).
25 - Optional welcome message for new members.
plugins/_telegram_integration/extensions/python/job_loop/_10_telegram_bot.py
+1
@@ -84,6 +84,7 @@ class TelegramBotManager(Extension):
84 on_message=_on_message,
85 on_command_start=_on_start,
86 on_command_clear=_on_clear,
87 + on_command_control=_on_message,
88 on_callback_query=_on_callback,
89 on_new_members=_on_new_members,
90 group_mode=bot_cfg.get("group_mode", "mention"),
plugins/_telegram_integration/helpers/bot_manager.py
+6
@@ -45,6 +45,7 @@ def create_bot(
45 on_message: Callable[..., Awaitable],
46 on_command_start: Callable[..., Awaitable],
47 on_command_clear: Callable[..., Awaitable],
48 + on_command_control: Callable[..., Awaitable] | None = None,
49 on_callback_query: Callable[..., Awaitable] | None = None,
50 on_new_members: Callable[..., Awaitable] | None = None,
51 group_mode: str = "mention",
@@ -56,6 +57,11 @@ def create_bot(
57 # Register command handlers
58 router.message.register(on_command_start, CommandStart())
59 router.message.register(on_command_clear, Command("clear"))
60 + if on_command_control:
61 + router.message.register(
62 + on_command_control,
63 + Command(commands=["project", "config", "preset", "queue", "send"]),
64 + )
65
66 if on_callback_query:
67 router.callback_query.register(on_callback_query)
plugins/_telegram_integration/helpers/handler.py
+19 -9
@@ -13,6 +13,7 @@ from aiogram.types import Message as TgMessage, CallbackQuery
13 from agent import AgentContext, UserMessage
14 from helpers import plugins, files, projects
15 from helpers import message_queue as mq
16 +from helpers import integration_commands
17 from helpers.notification import NotificationManager, NotificationType, NotificationPriority
18 from helpers.persist_chat import save_tmp_chat
19 from helpers.print_style import PrintStyle
@@ -139,7 +140,8 @@ async def handle_start(message: TgMessage, bot_name: str, bot_cfg: dict):
140 instance.bot.token, message.chat.id,
141 f"\U0001f44b Hello {user.first_name}! I'm connected to Agent Zero.\n\n"
142 "Send me a message and I'll process it.\n"
142 - "Use /clear to reset the conversation.",
143 + "Use /clear to reset the conversation.\n"
144 + "Use /project, /config, or /send to control the current chat.",
145 parse_mode=None,
146 )
147
@@ -201,13 +203,9 @@ async def handle_message(message: TgMessage, bot_name: str, bot_cfg: dict):
203 if not instance:
204 return
205
204 - # Start persistent typing indicator (thread-based, works across event loops)
205 - typing_stop = _start_typing(instance.bot.token, message.chat.id)
206 -
207 - # Get or create agent context
206 + text = _extract_message_content(message)
207 context = await _get_or_create_context(bot_name, bot_cfg, message)
208 if not context:
210 - typing_stop.set()
209 await _send_with_temp_bot(
210 instance.bot.token, message.chat.id,
211 "Failed to create chat session.",
@@ -215,6 +213,14 @@ async def handle_message(message: TgMessage, bot_name: str, bot_cfg: dict):
213 )
214 return
215
216 + command_reply = integration_commands.try_handle_command(context, text)
217 + if command_reply is not None:
218 + await _send_with_temp_bot(instance.bot.token, message.chat.id, command_reply, parse_mode=None)
219 + return
220 +
221 + # Start persistent typing indicator (thread-based, works across event loops)
222 + typing_stop = _start_typing(instance.bot.token, message.chat.id)
223 +
224 # Store stop event so send_telegram_reply can cancel typing
225 context.data[CTX_TG_TYPING_STOP] = typing_stop
226
@@ -227,9 +233,6 @@ async def handle_message(message: TgMessage, bot_name: str, bot_cfg: dict):
233 reply_to_id = message.message_id
234 context.data[CTX_TG_REPLY_TO] = reply_to_id
235
230 - # Build user message text
231 - text = _extract_message_content(message)
232 -
236 # Use temp bot for downloads (cross-event-loop safe)
237 async with _temp_bot(instance.bot.token) as dl_bot:
238 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
292 if not context:
293 return
294
295 + command_reply = integration_commands.try_handle_command(context, text)
296 + if command_reply is not None:
297 + instance = get_bot(bot_name)
298 + if instance:
299 + await _send_with_temp_bot(instance.bot.token, query.message.chat.id, command_reply, parse_mode=None)
300 + return
301 +
302 agent = context.agent0
303 user_msg = agent.read_prompt(
304 "fw.telegram.user_message.md",
plugins/_whatsapp_integration/README.md
+1
@@ -24,6 +24,7 @@ Dependencies are auto-installed on first bridge start if missing.
24 2. Configure allowed phone numbers
25 3. Click Show QR Code and scan with WhatsApp on your phone
26 4. Send a message from an allowed number to start a chat
27 +5. Use `/project <name>`, `/config <preset>`, or `/send` in WhatsApp to control the active chat directly
28
29 The WhatsApp session persists across restarts in `tmp/whatsapp/session/`. No re-pairing needed unless you disconnect via settings.
30 Be careful: if you use your personal number and leave `allowed_numbers` open, other people could misuse your Agent Zero.
plugins/_whatsapp_integration/helpers/handler.py
+45 -5
@@ -13,6 +13,7 @@ import uuid
13 from agent import Agent, AgentContext, UserMessage
14 from helpers import plugins, files, runtime
15 from helpers import message_queue as mq
16 +from helpers import integration_commands
17 from helpers.persist_chat import save_tmp_chat
18 from helpers.print_style import PrintStyle
19 from helpers.errors import format_error
@@ -120,15 +121,15 @@ async def _dispatch_message(config: dict, msg: dict) -> None:
121 PrintStyle.debug(f"WhatsApp: skipping group message (not mentioned or replied to)")
122 return
123
123 - # Show typing indicator immediately so user sees activity
124 - port = int(config.get("bridge_port", 3100))
125 - base_url = bridge_manager.get_bridge_url(port)
126 - await wa_client.send_typing(base_url, chat_id)
127 -
124 existing = _find_chats_by_jid(chat_id)
125
126 if existing:
127 # Continue most recent chat for this JID
128 + if await _handle_control_message(config, msg, existing[0]):
129 + return
130 + port = int(config.get("bridge_port", 3100))
131 + base_url = bridge_manager.get_bridge_url(port)
132 + await wa_client.send_typing(base_url, chat_id)
133 await _route_to_chat(msg, existing[0])
134 else:
135 await _start_new_chat(config, msg)
@@ -163,6 +164,13 @@ async def _start_new_chat(config: dict, msg: dict) -> None:
164
165 save_tmp_chat(context)
166
167 + if await _handle_control_message(config, msg, context.id, context=context):
168 + return
169 +
170 + port = int(config.get("bridge_port", 3100))
171 + base_url = bridge_manager.get_bridge_url(port)
172 + await wa_client.send_typing(base_url, chat_id)
173 +
174 user_msg = _build_user_message(context.agent0, msg)
175 system_ctx = context.agent0.read_prompt("fw.wa.system_context.md")
176
@@ -212,6 +220,38 @@ async def _route_to_chat(
220 PrintStyle.info(f"WhatsApp: continuing chat {context_id}")
221
222
223 +async def _handle_control_message(
224 + config: dict,
225 + msg: dict,
226 + context_id: str,
227 + *,
228 + context: AgentContext | None = None,
229 +) -> bool:
230 + text = msg.get("body", "") or ""
231 + parsed = integration_commands.parse_command(text)
232 + if not parsed:
233 + return False
234 +
235 + context = context or AgentContext.get(context_id)
236 + if not context:
237 + return False
238 +
239 + response = integration_commands.try_handle_command(context, text)
240 + if response is None:
241 + return False
242 +
243 + port = int(config.get("bridge_port", 3100))
244 + base_url = bridge_manager.get_bridge_url(port)
245 + chat_id = context.data.get(CTX_WA_CHAT_ID, "") or msg.get("chatId", "")
246 + reply_to = msg.get("messageId", "") if context.data.get(CTX_WA_IS_GROUP) else ""
247 +
248 + await wa_client.send_message(base_url, chat_id, response, reply_to=reply_to)
249 + await wa_client.send_typing(base_url, chat_id, paused=True)
250 + context.data[CTX_WA_TYPING_ACTIVE] = False
251 + PrintStyle.info(f"WhatsApp: handled control command in chat {context.id}")
252 + return True
253 +
254 +
255 # ------------------------------------------------------------------
256 # Chat discovery
257 # ------------------------------------------------------------------