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
# ------------------------------------------------------------------