ui: redesign email, Telegram, and WhatsApp settings

Redesign the three messaging integration panels with a clearer, more guided setup flow and polished user experience. - simplify the email panel by surfacing the essentials first, moving advanced scheduling behind Advanced, and making connection checks more visible - redesign Telegram and WhatsApp as step-based setup flows with clearer status states, safer access warnings, richer test feedback, and more responsive layouts - add shared plugin-settings wizard footer support, extract WhatsApp state into its own store, and align test-connection messages with the new UX ux: ease Email connector setup and refresh copy - Redesign the Email connector settings around a guided first-run flow with a clearer empty state, provider presets, and much friendlier copy - Move server, routing, and scheduling power-user controls into an `Advanced` section while keeping the existing config model compatible - Improve connection-test messaging, add Exchange inbound validation, and refresh the dashboard Email card copy while keeping the card visible - Verify the updated setup flow in the browser on desktop and mobile update and simplify x-data based on established frontend patterns Update 10_discovery_cards.py further polishing and first-draft no-click model for email and telegram update whatsapp Update telegram-config-store.js

Alessandro committed Apr 10, 2026 at 11:08 UTC 2000ba74a395ab68a4ff4f597f7d3e5dd1d96679
13 files changed +3008 -1034
plugins/_discovery/extensions/python/banners/10_discovery_cards.py
+4 -3
@@ -44,7 +44,9 @@ class DiscoveryCardsExtension(Extension):
44 })
45
46 # 3. Email
47 - if not email_config.get("imap_username") and not email_config.get("smtp_username"):
47 + email_handlers = email_config.get("handlers") or []
48 + email_is_configured = any((handler or {}).get("username") for handler in email_handlers)
49 + if not email_is_configured:
50 banners.append({
51 "id": "discovery-email",
52 "type": "feature",
@@ -52,7 +54,7 @@ class DiscoveryCardsExtension(Extension):
54 "description": "Let Agent Zero read and send emails on your behalf.",
55 "thumbnail": "/plugins/_discovery/webui/assets/thumb-email.png",
56 "icon": "mail",
55 - "cta_text": "Setup",
57 + "cta_text": "Open Setup",
58 "cta_action": "open-plugin-config:_email_integration",
59 "dismissible": True,
60 "priority": 50,
@@ -74,4 +76,3 @@ class DiscoveryCardsExtension(Extension):
76 "priority": 50,
77 "show_in_onboarding": True
78 })
77 -
plugins/_email_integration/api/test_connection.py
+51 -17
@@ -1,9 +1,12 @@
1 -"""Test IMAP/SMTP connection for an email handler config."""
1 +"""Test inbound and outbound email connectivity for a handler config."""
2 +
3 +import asyncio
4
5 from helpers.api import ApiHandler, Request
6 from helpers.errors import format_error
7
8 from plugins._email_integration.helpers.imap_client import (
9 + connect_exchange,
10 connect_imap,
11 disconnect_imap,
12 get_highest_uid,
@@ -18,7 +21,9 @@ class TestConnection(ApiHandler):
21 results: list[dict] = []
22 account_type = handler.get("account_type", "imap")
23
21 - if account_type == "imap":
24 + if account_type == "exchange":
25 + await self._test_exchange(handler, results)
26 + else:
27 await self._test_imap(handler, results)
28 await self._test_smtp(handler, results)
29 if all(r["ok"] for r in results):
@@ -35,18 +40,39 @@ class TestConnection(ApiHandler):
40 username=handler.get("username", ""),
41 password=handler.get("password", ""),
42 )
38 - uid = await get_highest_uid(client)
43 + await get_highest_uid(client)
44 await disconnect_imap(client)
45 results.append({
41 - "test": "IMAP",
46 + "test": "Incoming",
47 + "ok": True,
48 + "message": "Incoming mail looks good.",
49 + })
50 + except Exception as e:
51 + results.append({
52 + "test": "Incoming",
53 + "ok": False,
54 + "message": f"Could not reach the inbox: {format_error(e)}",
55 + })
56 +
57 + async def _test_exchange(self, handler: dict, results: list[dict]):
58 + try:
59 + account = await connect_exchange(
60 + server=handler.get("imap_server", ""),
61 + username=handler.get("username", ""),
62 + password=handler.get("password", ""),
63 + )
64 + loop = asyncio.get_event_loop()
65 + await loop.run_in_executor(None, lambda: account.inbox.total_count)
66 + results.append({
67 + "test": "Incoming",
68 "ok": True,
43 - "message": f"Connected, highest UID: {uid}",
69 + "message": "Exchange inbox looks good.",
70 })
71 except Exception as e:
72 results.append({
47 - "test": "IMAP",
73 + "test": "Incoming",
74 "ok": False,
49 - "message": format_error(e),
75 + "message": f"Could not reach the Exchange inbox: {format_error(e)}",
76 })
77
78 def _smtp_config(self, handler: dict) -> SmtpConfig:
@@ -62,18 +88,22 @@ class TestConnection(ApiHandler):
88 try:
89 error = await test_smtp(self._smtp_config(handler))
90 if error:
65 - results.append({"test": "SMTP", "ok": False, "message": error})
91 + results.append({
92 + "test": "Outgoing",
93 + "ok": False,
94 + "message": f"Could not sign in for sending mail: {error}",
95 + })
96 else:
97 results.append({
68 - "test": "SMTP",
98 + "test": "Outgoing",
99 "ok": True,
70 - "message": "Authenticated successfully",
100 + "message": "Outgoing mail looks good.",
101 })
102 except Exception as e:
103 results.append({
74 - "test": "SMTP",
104 + "test": "Outgoing",
105 "ok": False,
76 - "message": format_error(e),
106 + "message": f"Could not sign in for sending mail: {format_error(e)}",
107 })
108
109 async def _test_send(self, handler: dict, results: list[dict]):
@@ -85,16 +115,20 @@ class TestConnection(ApiHandler):
115 body="This is a test email from Agent Zero email integration.",
116 )
117 if error:
88 - results.append({"test": "Send", "ok": False, "message": error})
118 + results.append({
119 + "test": "Send test email",
120 + "ok": False,
121 + "message": f"Could not send the test email: {error}",
122 + })
123 else:
124 results.append({
91 - "test": "Send",
125 + "test": "Send test email",
126 "ok": True,
93 - "message": "Test email sent to self",
127 + "message": "Test email sent to this inbox.",
128 })
129 except Exception as e:
130 results.append({
97 - "test": "Send",
131 + "test": "Send test email",
132 "ok": False,
99 - "message": format_error(e),
133 + "message": f"Could not send the test email: {format_error(e)}",
134 })
plugins/_email_integration/webui/config.html
+687 -240
@@ -1,103 +1,71 @@
1 <html>
2 +
3 <head>
4 <title>Email Integration</title>
5 + <script type="module">
6 + import { store } from "/plugins/_email_integration/webui/email-config-store.js";
7 + </script>
8 </head>
9
10 <body>
7 - <div x-data="{
8 - get handlers() { return config?.handlers || [] },
9 - editing: null,
10 - testing: null,
11 - test_results: null,
12 - projects: [],
13 - async init() {
14 - try {
15 - const { callJsonApi } = await import('/js/api.js');
16 - const res = await callJsonApi('projects', { action: 'list' });
17 - this.projects = res.data || [];
18 - } catch (e) { this.projects = []; }
19 - },
20 - add_handler() {
21 - if (!config.handlers) config.handlers = [];
22 - config.handlers.push({
23 - name: 'handler_' + (config.handlers.length + 1),
24 - enabled: false,
25 - account_type: 'imap',
26 - imap_server: '',
27 - imap_port: 993,
28 - smtp_server: '',
29 - smtp_port: 587,
30 - username: '',
31 - password: '',
32 - poll_mode: 'seconds',
33 - poll_interval_seconds: 15,
34 - poll_interval_cron: '*/2 * * * *',
35 - process_unread_days: 0,
36 - sender_whitelist: [],
37 - project: '',
38 - dispatcher_model: 'utility',
39 - dispatcher_instructions: '',
40 - agent_instructions: ''
41 - });
42 - this.editing = config.handlers.length - 1;
43 - },
44 - remove_handler(idx) {
45 - config.handlers.splice(idx, 1);
46 - this.editing = null;
47 - },
48 - whitelist_text(handler) {
49 - return (handler.sender_whitelist || []).join(', ');
50 - },
51 - set_whitelist(handler, val) {
52 - handler.sender_whitelist = val.split(',').map(s => s.trim()).filter(s => s);
53 - },
54 - async test_connection(idx) {
55 - this.testing = idx;
56 - this.test_results = null;
57 - try {
58 - const { callJsonApi } = await import('/js/api.js');
59 - const res = await callJsonApi('/plugins/_email_integration/test_connection', {
60 - handler: this.handlers[idx]
61 - });
62 - this.test_results = res;
63 - } catch (e) {
64 - this.test_results = { success: false, results: [{ test: 'Connection', ok: false, message: String(e) }] };
65 - }
66 - this.testing = null;
67 - }
68 - }">
11 + <div x-data x-init="$store.emailConfig.init(config, context)" x-destroy="$store.emailConfig.cleanup()">
12 <template x-if="config">
70 - <div>
71 - <div class="section-title">Email Integration</div>
72 - <div class="section-description">
73 - Configure email handlers to communicate with Agent Zero via email.
74 - Each handler connects to an email account and polls for new messages.
75 - </div>
76 -
77 - <!-- Handler list -->
78 - <template x-for="(handler, idx) in handlers" :key="idx">
79 - <div style="border: 1px solid var(--border-color, #333); border-radius: 8px; padding: 12px; margin-bottom: 8px; margin-top: 8px;">
80 -
81 - <!-- Header row -->
82 - <div style="display: flex; justify-content: space-between; align-items: center; cursor: pointer;"
83 - @click="editing = editing === idx ? null : idx">
84 - <div>
85 - <span style="font-weight: bold;" x-text="handler.name"></span>
86 - <span style="opacity: 0.6; margin-left: 8px;" x-text="handler.enabled ? 'Enabled' : 'Disabled'"></span>
13 + <div class="email-settings">
14 + <div class="section-title">Email</div>
15 +
16 + <template x-if="$store.emailConfig.didInit && $store.emailConfig.handlers.length === 0">
17 + <div class="email-empty">
18 + <div class="email-empty-title">Connect Agent Zero and your email account</div>
19 + <div class="email-empty-copy">
20 + Most people only need a provider, an email address, and an app password.
21 + </div>
22 + <button class="btn btn-field" @click="$store.emailConfig.addHandler()">
23 + Connect
24 + </button>
25 + </div>
26 + </template>
27 +
28 + <template x-for="(handler, idx) in $store.emailConfig.handlers" :key="idx">
29 + <div class="email-card">
30 + <div class="email-card-header" @click="$store.emailConfig.toggleEditing(idx)">
31 + <div class="email-card-heading">
32 + <div class="email-card-title" x-text="$store.emailConfig.handlerTitle(handler, idx)">
33 + </div>
34 + <div class="email-card-subtitle" x-text="$store.emailConfig.handlerSubtitle(handler)">
35 + </div>
36 + </div>
37 + <div class="email-card-actions">
38 + <span class="email-status-pill"
39 + :class="'tone-' + $store.emailConfig.statusTone(handler)"
40 + x-text="$store.emailConfig.statusLabel(handler)"></span>
41 + <button class="btn btn-action delete"
42 + @click.stop="$confirmClick($event, () => $store.emailConfig.removeHandler(idx))"
43 + title="Remove inbox">
44 + <span class="material-symbols-outlined">delete</span>
45 + </button>
46 + <span class="material-symbols-outlined email-card-chevron"
47 + :class="{ 'is-open': $store.emailConfig.editing === idx }">expand_more</span>
48 </div>
88 - <button class="btn btn-action delete" @click.stop="$confirmClick($event, () => remove_handler(idx))" title="Remove handler">
89 - <span class="material-symbols-outlined">delete</span>
90 - </button>
49 </div>
50
93 - <!-- Expanded editor -->
94 - <template x-if="editing === idx">
95 - <div style="margin-top: 16px;">
51 + <template x-if="$store.emailConfig.editing === idx">
52 + <div class="email-card-body">
53 + <div class="email-intro-copy">
54 + <div x-text="$store.emailConfig.providerHint(handler)"></div>
55 + <template x-if="$store.emailConfig.providerHelpUrl(handler)">
56 + <a class="email-intro-link"
57 + :href="$store.emailConfig.providerHelpUrl(handler)"
58 + target="_blank"
59 + rel="noopener"
60 + x-text="$store.emailConfig.providerHelpLabel(handler)"></a>
61 + </template>
62 + </div>
63
64 <div class="field">
65 <div class="field-label">
99 - <div class="field-title">Enabled</div>
100 - <div class="field-description">Enable or disable inbox polling for this handler</div>
66 + <div class="field-title">Turn on this inbox</div>
67 + <div class="field-description">Enable when you are ready for Agent Zero to start
68 + checking mail.</div>
69 </div>
70 <div class="field-control">
71 <label class="toggle">
@@ -109,223 +77,702 @@
77
78 <div class="field">
79 <div class="field-label">
112 - <div class="field-title">Handler Name</div>
113 - <div class="field-description">Unique identifier for this email handler</div>
80 + <div class="field-title">Provider</div>
81 + <div class="field-description">Pick the provider first. We will fill in the
82 + common server settings for you.</div>
83 </div>
84 <div class="field-control">
116 - <input type="text" x-model="handler.name" placeholder="e.g. support" />
117 - </div>
118 - </div>
119 -
120 - <div class="field">
121 - <div class="field-label">
122 - <div class="field-title">Account Type</div>
123 - <div class="field-description">IMAP for most providers, Exchange for Microsoft Exchange/Office 365</div>
124 - </div>
125 - <div class="field-control">
126 - <select x-model="handler.account_type">
127 - <option value="imap">IMAP</option>
85 + <select :value="$store.emailConfig.providerValue(handler)"
86 + @change="$store.emailConfig.applyProvider(handler, $event.target.value)">
87 + <option value="">Choose a provider</option>
88 + <option value="gmail">Gmail</option>
89 + <option value="icloud">iCloud Mail</option>
90 + <option value="microsoft365">Outlook / Microsoft 365</option>
91 + <option value="yahoo">Yahoo Mail</option>
92 <option value="exchange">Exchange</option>
93 + <option value="custom-imap">Custom IMAP</option>
94 </select>
95 </div>
96 </div>
97
98 <div class="field">
99 <div class="field-label">
135 - <div class="field-title">IMAP Server</div>
136 - <div class="field-description">Incoming mail server hostname</div>
100 + <div class="field-title">Email address</div>
101 + <div class="field-description">Usually the full address for this inbox.</div>
102 </div>
103 <div class="field-control">
139 - <input type="text" x-model="handler.imap_server" placeholder="imap.gmail.com" />
140 - </div>
141 - </div>
142 -
143 - <div class="field">
144 - <div class="field-label">
145 - <div class="field-title">IMAP Port</div>
146 - <div class="field-description">SSL port for incoming mail, usually 993</div>
147 - </div>
148 - <div class="field-control">
149 - <input type="number" x-model.number="handler.imap_port" placeholder="993" />
150 - </div>
151 - </div>
152 -
153 - <div class="field">
154 - <div class="field-label">
155 - <div class="field-title">SMTP Server</div>
156 - <div class="field-description">Outgoing mail server hostname. Defaults to IMAP server if empty</div>
157 - </div>
158 - <div class="field-control">
159 - <input type="text" x-model="handler.smtp_server" placeholder="smtp.gmail.com" />
160 - </div>
161 - </div>
162 -
163 - <div class="field">
164 - <div class="field-label">
165 - <div class="field-title">SMTP Port</div>
166 - <div class="field-description">TLS port for outgoing mail, usually 587</div>
167 - </div>
168 - <div class="field-control">
169 - <input type="number" x-model.number="handler.smtp_port" placeholder="587" />
170 - </div>
171 - </div>
172 -
173 - <div class="field">
174 - <div class="field-label">
175 - <div class="field-title">Username</div>
176 - <div class="field-description">Email account login, usually the full email address</div>
177 - </div>
178 - <div class="field-control">
179 - <input type="text" x-model="handler.username" placeholder="user@domain.com" />
104 + <input type="text" x-model="handler.username"
105 + @input="$store.emailConfig.maybeAutoname(handler)"
106 + placeholder="name@company.com" />
107 </div>
108 </div>
109
110 <div class="field">
111 <div class="field-label">
112 <div class="field-title">Password</div>
186 - <div class="field-description">Account password or app-specific password</div>
113 + <div class="field-description">An app password is best. Many mail providers
114 + block regular account passwords.</div>
115 </div>
116 <div class="field-control">
117 <input type="password" x-model="handler.password" />
118 </div>
119 </div>
120
193 - <div class="field">
194 - <div class="field-label">
195 - <div class="field-title">Poll Mode</div>
196 - <div class="field-description">How to schedule inbox checks</div>
197 - </div>
198 - <div class="field-control">
199 - <select x-model="handler.poll_mode">
200 - <option value="seconds">Interval (seconds)</option>
201 - <option value="cron">Cron expression</option>
202 - </select>
121 + <template x-if="$store.emailConfig.showExchangeServer(handler)">
122 + <div>
123 + <div class="field">
124 + <div class="field-label">
125 + <div class="field-title"
126 + x-text="$store.emailConfig.incomingLabel(handler)"></div>
127 + <div class="field-description"
128 + x-text="$store.emailConfig.incomingDescription(handler)"></div>
129 + </div>
130 + <div class="field-control">
131 + <input type="text" x-model="handler.imap_server"
132 + :placeholder="$store.emailConfig.incomingPlaceholder(handler)" />
133 + </div>
134 + </div>
135 </div>
204 - </div>
136 + </template>
137
206 - <div class="field" x-show="handler.poll_mode === 'seconds'">
207 - <div class="field-label">
208 - <div class="field-title">Poll Interval (seconds)</div>
209 - <div class="field-description">How often to check for new emails</div>
210 - </div>
211 - <div class="field-control">
212 - <input type="number" x-model.number="handler.poll_interval_seconds" min="5" placeholder="15" />
213 - </div>
214 - </div>
138 + <template x-if="$store.emailConfig.showManualServers(handler)">
139 + <div class="email-grid">
140 + <div class="field">
141 + <div class="field-label">
142 + <div class="field-title">Incoming mail server</div>
143 + <div class="field-description">The IMAP server for this inbox.</div>
144 + </div>
145 + <div class="field-control">
146 + <input type="text" x-model="handler.imap_server"
147 + placeholder="imap.your-provider.com" />
148 + </div>
149 + </div>
150
216 - <div class="field" x-show="handler.poll_mode !== 'seconds'">
217 - <div class="field-label">
218 - <div class="field-title">Cron Expression</div>
219 - <div class="field-description">e.g. */2 * * * * for every 2 minutes</div>
220 - </div>
221 - <div class="field-control">
222 - <input type="text" x-model="handler.poll_interval_cron" placeholder="*/2 * * * *" />
223 - </div>
224 - </div>
151 + <div class="field">
152 + <div class="field-label">
153 + <div class="field-title">Incoming port</div>
154 + <div class="field-description">993 is the usual SSL port.</div>
155 + </div>
156 + <div class="field-control">
157 + <input type="number" x-model.number="handler.imap_port"
158 + placeholder="993" />
159 + </div>
160 + </div>
161
226 - <div class="field">
227 - <div class="field-label">
228 - <div class="field-title">Process Unread (days)</div>
229 - <div class="field-description">Process unread emails from the last N days on every startup. 0 = only track new emails</div>
230 - </div>
231 - <div class="field-control">
232 - <input type="number" x-model.number="handler.process_unread_days" min="0" placeholder="0" />
233 - </div>
234 - </div>
162 + <div class="field">
163 + <div class="field-label">
164 + <div class="field-title">Outgoing mail server</div>
165 + <div class="field-description">The SMTP server used for replies.</div>
166 + </div>
167 + <div class="field-control">
168 + <input type="text" x-model="handler.smtp_server"
169 + placeholder="smtp.your-provider.com" />
170 + </div>
171 + </div>
172
236 - <div class="field">
237 - <div class="field-label">
238 - <div class="field-title">Sender Whitelist</div>
239 - <div class="field-description">Comma-separated. Empty = allow all. Wildcards supported (e.g. *@company.com)</div>
240 - </div>
241 - <div class="field-control">
242 - <input type="text" :value="whitelist_text(handler)" @input="set_whitelist(handler, $event.target.value)" placeholder="*@company.com, boss@other.com" />
173 + <div class="field">
174 + <div class="field-label">
175 + <div class="field-title">Outgoing port</div>
176 + <div class="field-description">587 is the usual TLS port.</div>
177 + </div>
178 + <div class="field-control">
179 + <input type="number" x-model.number="handler.smtp_port"
180 + placeholder="587" />
181 + </div>
182 + </div>
183 </div>
244 - </div>
184 + </template>
185
246 - <div class="field">
247 - <div class="field-label">
248 - <div class="field-title">Project</div>
249 - <div class="field-description">Project to activate for email chats</div>
186 + <template
187 + x-if="!$store.emailConfig.showManualServers(handler) && !$store.emailConfig.showExchangeServer(handler) && $store.emailConfig.providerValue(handler)">
188 + <div class="email-note">
189 + Server settings are filled in for <span
190 + x-text="$store.emailConfig.providerLabel($store.emailConfig.providerValue(handler))"></span>.
191 + You can change them any time in Advanced.
192 </div>
251 - <div class="field-control">
252 - <select :value="handler.project" @change="handler.project = $event.target.value">
253 - <option value="">No project</option>
254 - <template x-for="proj in projects" :key="proj.name">
255 - <option :value="proj.name" x-text="proj.title || proj.name" :selected="handler.project === proj.name"></option>
256 - </template>
257 - </select>
258 - </div>
259 - </div>
193 + </template>
194
261 - <div class="field">
262 - <div class="field-label">
263 - <div class="field-title">Dispatcher Model</div>
264 - <div class="field-description">LLM model used to route incoming emails to chats. Utility is faster, chat is more capable</div>
265 - </div>
266 - <div class="field-control">
267 - <select x-model="handler.dispatcher_model">
268 - <option value="utility">Utility</option>
269 - <option value="chat">Chat</option>
270 - </select>
271 - </div>
195 + <div class="email-missing" x-show="$store.emailConfig.missingBits(handler).length > 0">
196 + Still needed:
197 + <span x-text="$store.emailConfig.missingBits(handler).join(', ')"></span>
198 </div>
199
200 <div class="field">
201 <div class="field-label">
276 - <div class="field-title">Dispatcher Instructions</div>
277 - <div class="field-description">Extra instructions for the AI that routes emails to chats</div>
202 + <div class="field-title">Routing instructions</div>
203 + <div class="field-description">Extra guidance for how incoming email should be
204 + routed into chats.</div>
205 </div>
206 <div class="field-control">
280 - <textarea x-model="handler.dispatcher_instructions" rows="3" placeholder="e.g. Always start a new chat for emails from support@..."></textarea>
207 + <textarea x-model="handler.dispatcher_instructions" rows="3"
208 + placeholder="Always start a new chat for invoices or billing questions."></textarea>
209 </div>
210 </div>
211
212 <div class="field">
213 <div class="field-label">
286 - <div class="field-title">Agent Instructions</div>
287 - <div class="field-description">Extra instructions for the agent in email chats</div>
214 + <div class="field-title">Reply instructions</div>
215 + <div class="field-description">Extra guidance for the agent when it replies by
216 + email.</div>
217 </div>
218 <div class="field-control">
290 - <textarea x-model="handler.agent_instructions" rows="3" placeholder="e.g. Always respond in formal English..."></textarea>
219 + <textarea x-model="handler.agent_instructions" rows="3"
220 + placeholder="Reply in a calm, concise tone."></textarea>
221 </div>
222 </div>
223
294 - <!-- Test connection -->
295 - <div style="margin-top: 12px; display: flex; align-items: center; gap: 12px;">
296 - <button class="btn btn-field" @click.stop="test_connection(idx)"
297 - :disabled="testing === idx">
298 - <span x-show="testing !== idx">Test Connection</span>
299 - <span x-show="testing === idx">Testing...</span>
224 + <div class="email-test-panel">
225 + <div class="email-test-copy">
226 + <div class="email-test-title">Check the connection</div>
227 + <div class="email-test-description"
228 + x-text="$store.emailConfig.testIntro(handler)"></div>
229 + </div>
230 + <button class="btn btn-field" @click.stop="$store.emailConfig.testConnection(idx)"
231 + :disabled="$store.emailConfig.testing === idx || !$store.emailConfig.canTest(handler)">
232 + <span x-text="$store.emailConfig.testButtonLabel(handler, idx)"></span>
233 </button>
234 </div>
235
303 - <!-- Test results -->
304 - <template x-if="test_results && editing === idx">
305 - <div style="margin-top: 8px; padding: 8px 12px; border-radius: 6px; font-size: 0.85rem;
306 - border: 1px solid var(--border-color, #333);">
307 - <template x-for="r in test_results.results" :key="r.test">
308 - <div style="display: flex; align-items: center; gap: 8px; padding: 4px 0;">
309 - <span x-text="r.ok ? '✓' : '✗'"
310 - :style="'font-weight: bold; color:' + (r.ok ? '#4caf50' : '#f44336')"></span>
311 - <span style="font-weight: 500; min-width: 50px;" x-text="r.test"></span>
312 - <span style="opacity: 0.8;" x-text="r.message"></span>
236 + <template
237 + x-if="$store.emailConfig.testResults && $store.emailConfig.testResultsFor === idx">
238 + <div class="email-results"
239 + :class="{ 'is-error': !$store.emailConfig.testResults.success }">
240 + <template x-for="result in $store.emailConfig.testResults.results"
241 + :key="result.test + result.message">
242 + <div class="email-result-row">
243 + <span class="email-result-icon" x-text="result.ok ? '✓' : '✗'"></span>
244 + <div class="email-result-copy">
245 + <div class="email-result-title"
246 + x-text="$store.emailConfig.resultTitle(result)"></div>
247 + <div class="email-result-message"
248 + x-text="$store.emailConfig.resultMessage(result)"></div>
249 + </div>
250 </div>
251 </template>
252 </div>
253 </template>
254
255 + <details class="email-advanced">
256 + <summary>
257 + <span>Advanced</span>
258 + <span class="material-symbols-outlined email-advanced-chevron"
259 + aria-hidden="true">keyboard_arrow_down</span>
260 + </summary>
261 + <div class="email-advanced-body">
262 + <div class="field">
263 + <div class="field-label">
264 + <div class="field-title">Inbox name</div>
265 + <div class="field-description">A friendly internal label for this
266 + connection.</div>
267 + </div>
268 + <div class="field-control">
269 + <input type="text" x-model="handler.name" placeholder="support" />
270 + </div>
271 + </div>
272 +
273 + <div class="email-grid">
274 + <div class="field">
275 + <div class="field-label">
276 + <div class="field-title">Incoming mail server</div>
277 + <div class="field-description">Override the auto-filled incoming
278 + server if needed.</div>
279 + </div>
280 + <div class="field-control">
281 + <input type="text" x-model="handler.imap_server"
282 + placeholder="imap.your-provider.com" />
283 + </div>
284 + </div>
285 +
286 + <div class="field">
287 + <div class="field-label">
288 + <div class="field-title">Incoming port</div>
289 + <div class="field-description">993 is the common SSL port.</div>
290 + </div>
291 + <div class="field-control">
292 + <input type="number" x-model.number="handler.imap_port"
293 + placeholder="993" />
294 + </div>
295 + </div>
296 +
297 + <div class="field">
298 + <div class="field-label">
299 + <div class="field-title">Outgoing mail server</div>
300 + <div class="field-description">Override the auto-filled reply server
301 + if needed.</div>
302 + </div>
303 + <div class="field-control">
304 + <input type="text" x-model="handler.smtp_server"
305 + placeholder="smtp.your-provider.com" />
306 + </div>
307 + </div>
308 +
309 + <div class="field">
310 + <div class="field-label">
311 + <div class="field-title">Outgoing port</div>
312 + <div class="field-description">587 is the common TLS port.</div>
313 + </div>
314 + <div class="field-control">
315 + <input type="number" x-model.number="handler.smtp_port"
316 + placeholder="587" />
317 + </div>
318 + </div>
319 + </div>
320 +
321 + <div class="field">
322 + <div class="field-label">
323 + <div class="field-title">Project</div>
324 + <div class="field-description">Open email conversations inside a
325 + specific project.</div>
326 + </div>
327 + <div class="field-control">
328 + <select :value="handler.project"
329 + @change="handler.project = $event.target.value">
330 + <option value="">No project</option>
331 + <template x-for="proj in $store.emailConfig.projects"
332 + :key="proj.name">
333 + <option :value="proj.name" x-text="proj.title || proj.name"
334 + :selected="handler.project === proj.name"></option>
335 + </template>
336 + </select>
337 + </div>
338 + </div>
339 +
340 + <div class="field">
341 + <div class="field-label">
342 + <div class="field-title">Allowed senders</div>
343 + <div class="field-description">Leave empty to allow anyone. Wildcards
344 + like *@company.com work.</div>
345 + </div>
346 + <div class="field-control">
347 + <input type="text" :value="$store.emailConfig.whitelistText(handler)"
348 + @input="$store.emailConfig.setWhitelist(handler, $event.target.value)"
349 + placeholder="*@company.com, founder@company.com" />
350 + </div>
351 + </div>
352 +
353 + <div class="field">
354 + <div class="field-label">
355 + <div class="field-title">Catch up on unread mail</div>
356 + <div class="field-description">On startup, process unread mail from the
357 + last N days. Use 0 for brand-new mail only.</div>
358 + </div>
359 + <div class="field-control">
360 + <input type="number" x-model.number="handler.process_unread_days"
361 + min="0" placeholder="0" />
362 + </div>
363 + </div>
364 +
365 + <div class="field">
366 + <div class="field-label">
367 + <div class="field-title">Check for new mail</div>
368 + <div class="field-description"
369 + x-text="$store.emailConfig.frequencyHint(handler)"></div>
370 + </div>
371 + <div class="field-control">
372 + <select :value="$store.emailConfig.frequencyValue(handler)"
373 + @change="$store.emailConfig.applyFrequency(handler, $event.target.value)">
374 + <option value="15">Every 15 seconds</option>
375 + <option value="30">Every 30 seconds</option>
376 + <option value="60">Every minute</option>
377 + <option value="300">Every 5 minutes</option>
378 + <option value="900">Every 15 minutes</option>
379 + <option value="custom">Custom schedule</option>
380 + </select>
381 + </div>
382 + </div>
383 +
384 + <div class="field"
385 + x-show="$store.emailConfig.frequencyValue(handler) === 'custom'">
386 + <div class="field-label">
387 + <div class="field-title">Scheduling mode</div>
388 + <div class="field-description">Use seconds for a simple interval, or
389 + switch to cron for full control.</div>
390 + </div>
391 + <div class="field-control">
392 + <select x-model="handler.poll_mode">
393 + <option value="seconds">Seconds</option>
394 + <option value="cron">Cron</option>
395 + </select>
396 + </div>
397 + </div>
398 +
399 + <div class="field"
400 + x-show="$store.emailConfig.frequencyValue(handler) === 'custom' && handler.poll_mode === 'seconds'">
401 + <div class="field-label">
402 + <div class="field-title">Poll interval (seconds)</div>
403 + <div class="field-description">The exact delay between inbox checks.
404 + </div>
405 + </div>
406 + <div class="field-control">
407 + <input type="number" x-model.number="handler.poll_interval_seconds"
408 + min="5" placeholder="60" />
409 + </div>
410 + </div>
411 +
412 + <div class="field"
413 + x-show="$store.emailConfig.frequencyValue(handler) === 'custom' && handler.poll_mode === 'cron'">
414 + <div class="field-label">
415 + <div class="field-title">Cron expression</div>
416 + <div class="field-description">For example, */2 * * * * checks every two
417 + minutes.</div>
418 + </div>
419 + <div class="field-control">
420 + <input type="text" x-model="handler.poll_interval_cron"
421 + placeholder="*/2 * * * *" />
422 + </div>
423 + </div>
424 +
425 + <div class="field">
426 + <div class="field-label">
427 + <div class="field-title">Routing model</div>
428 + <div class="field-description">Utility is faster. Chat is more capable
429 + when routing gets nuanced.</div>
430 + </div>
431 + <div class="field-control">
432 + <select x-model="handler.dispatcher_model">
433 + <option value="utility">Utility</option>
434 + <option value="chat">Chat</option>
435 + </select>
436 + </div>
437 + </div>
438 +
439 + </div>
440 + </details>
441 </div>
442 </template>
443 </div>
444 </template>
445
323 - <button class="btn btn-field" @click="add_handler()" style="margin-top: 8px;">
324 - Add Handler
325 - </button>
446 + <template x-if="$store.emailConfig.handlers.length > 0">
447 + <button class="btn btn-field email-add-another" @click="$store.emailConfig.addHandler()">
448 + Connect another inbox
449 + </button>
450 + </template>
451 </div>
452 </template>
453 </div>
454 +
455 + <style>
456 + .email-settings {
457 + display: flex;
458 + flex-direction: column;
459 + gap: 0.9rem;
460 + }
461 +
462 + .email-advanced summary {
463 + cursor: pointer;
464 + list-style: none;
465 + font-weight: 700;
466 + display: flex;
467 + align-items: center;
468 + gap: 0.75rem;
469 + }
470 +
471 + .email-advanced summary::-webkit-details-marker {
472 + display: none;
473 + }
474 +
475 + .email-advanced summary {
476 + padding: 0.95rem 1rem;
477 + }
478 +
479 + .email-advanced-chevron {
480 + margin-left: auto;
481 + flex: 0 0 auto;
482 + transition: transform 0.18s ease, opacity 0.18s ease;
483 + opacity: 0.72;
484 + }
485 +
486 + .email-advanced[open] .email-advanced-chevron {
487 + transform: rotate(180deg);
488 + opacity: 1;
489 + }
490 +
491 + .email-guide-card {
492 + border: 1px solid color-mix(in srgb, var(--color-border) 80%, white 20%);
493 + border-radius: 12px;
494 + padding: 0.9rem 1rem;
495 + background: color-mix(in srgb, var(--color-background) 88%, white 12%);
496 + }
497 +
498 + .email-guide-title {
499 + font-weight: 700;
500 + margin-bottom: 0.55rem;
501 + }
502 +
503 + .email-guide-list {
504 + margin: 0;
505 + padding-left: 1.1rem;
506 + color: var(--color-text-secondary);
507 + line-height: 1.55;
508 + }
509 +
510 + .email-empty {
511 + border-radius: 0.5rem;
512 + padding: 1rem;
513 + background: color-mix(in srgb, var(--color-background) 94%, white 6%);
514 + }
515 +
516 + .email-empty-title {
517 + font-size: 1.05rem;
518 + font-weight: 700;
519 + }
520 +
521 + .email-empty-copy {
522 + margin-top: 0.35rem;
523 + margin-bottom: 0.9rem;
524 + color: var(--color-text-secondary);
525 + line-height: 1.5;
526 + }
527 +
528 + .email-card {
529 + border-radius: 0.5rem;
530 + overflow: hidden;
531 + }
532 +
533 + .email-card-header {
534 + display: flex;
535 + justify-content: space-between;
536 + gap: 1rem;
537 + align-items: flex-start;
538 + padding: var(--spacing-sm) 0;
539 + border-bottom: 1px solid var(--color-border);
540 + cursor: pointer;
541 + }
542 +
543 + .email-card-heading {
544 + min-width: 0;
545 + flex: 1 1 auto;
546 + }
547 +
548 + .email-card-title {
549 + font-weight: 700;
550 + font-size: 1rem;
551 + line-height: 1.35;
552 + overflow-wrap: anywhere;
553 + }
554 +
555 + .email-card-subtitle {
556 + margin-top: 0.3rem;
557 + color: var(--color-text-secondary);
558 + font-size: var(--font-size-small);
559 + line-height: 1.45;
560 + }
561 +
562 + .email-card-actions {
563 + display: flex;
564 + align-items: center;
565 + gap: 0.45rem;
566 + flex: 0 0 auto;
567 + }
568 +
569 + .email-card-chevron {
570 + transition: transform 0.18s ease;
571 + opacity: 0.7;
572 + }
573 +
574 + .email-card-chevron.is-open {
575 + transform: rotate(180deg);
576 + }
577 +
578 + .email-intro-copy {
579 + margin: 1rem 0;
580 + padding: 0.85rem 0.95rem;
581 + border-radius: 12px;
582 + background: color-mix(in srgb, #3b82f6 12%, var(--color-background) 88%);
583 + color: var(--color-text-secondary);
584 + line-height: 1.5;
585 + }
586 +
587 + .email-intro-link {
588 + display: inline-flex;
589 + margin-top: 0.55rem;
590 + color: var(--color-highlight);
591 + font-weight: 600;
592 + text-decoration: none;
593 + }
594 +
595 + .email-intro-link:hover {
596 + text-decoration: underline;
597 + }
598 +
599 + .email-grid {
600 + display: grid;
601 + grid-template-columns: repeat(2, minmax(0, 1fr));
602 + gap: 0.85rem;
603 + }
604 +
605 + .email-note,
606 + .email-missing {
607 + margin-top: 0.2rem;
608 + margin-bottom: 0.85rem;
609 + padding: 0.8rem 0.9rem;
610 + border-radius: 12px;
611 + font-size: var(--font-size-small);
612 + line-height: 1.5;
613 + }
614 +
615 + .email-note {
616 + background: color-mix(in srgb, var(--color-background) 82%, white 18%);
617 + color: var(--color-text-secondary);
618 + }
619 +
620 + .email-missing {
621 + background: color-mix(in srgb, #f59e0b 14%, var(--color-background) 86%);
622 + color: var(--color-text-secondary);
623 + }
624 +
625 + .email-advanced {
626 + margin-top: 0.95rem;
627 + border: 1px solid color-mix(in srgb, var(--color-border) 88%, white 12%);
628 + border-radius: 14px;
629 + overflow: hidden;
630 + }
631 +
632 + .email-advanced summary {
633 + padding: 0.9rem 1rem;
634 + background: color-mix(in srgb, var(--color-background) 88%, white 12%);
635 + }
636 +
637 + .email-advanced-body {
638 + padding: 1rem;
639 + display: flex;
640 + flex-direction: column;
641 + gap: 0.2rem;
642 + }
643 +
644 + .email-test-panel {
645 + margin-top: 1rem;
646 + padding: var(--spacing-xs) 0;
647 + display: flex;
648 + justify-content: space-between;
649 + align-items: center;
650 + gap: 1rem;
651 + }
652 +
653 + .email-test-copy {
654 + min-width: 0;
655 + }
656 +
657 + .email-test-title {
658 + font-weight: 700;
659 + margin-bottom: 0.25rem;
660 + }
661 +
662 + .email-test-description {
663 + color: var(--color-text-secondary);
664 + line-height: 1.5;
665 + font-size: var(--font-size-small);
666 + }
667 +
668 + .email-results {
669 + margin-top: 0.9rem;
670 + border-radius: 14px;
671 + border: 1px solid color-mix(in srgb, #22c55e 28%, var(--color-border) 72%);
672 + background: color-mix(in srgb, #22c55e 8%, var(--color-background) 92%);
673 + padding: 0.35rem 0.9rem;
674 + }
675 +
676 + .email-results.is-error {
677 + border-color: color-mix(in srgb, #ef4444 28%, var(--color-border) 72%);
678 + background: color-mix(in srgb, #ef4444 8%, var(--color-background) 92%);
679 + }
680 +
681 + .email-result-row {
682 + display: flex;
683 + align-items: flex-start;
684 + gap: 0.8rem;
685 + padding: 0.7rem 0;
686 + }
687 +
688 + .email-result-row+.email-result-row {
689 + border-top: 1px solid color-mix(in srgb, var(--color-border) 85%, white 15%);
690 + }
691 +
692 + .email-result-icon {
693 + width: 1.25rem;
694 + font-weight: 800;
695 + line-height: 1.3;
696 + }
697 +
698 + .email-result-copy {
699 + min-width: 0;
700 + }
701 +
702 + .email-result-title {
703 + font-weight: 700;
704 + margin-bottom: 0.18rem;
705 + }
706 +
707 + .email-result-message {
708 + color: var(--color-text-secondary);
709 + line-height: 1.5;
710 + font-size: var(--font-size-small);
711 + overflow-wrap: anywhere;
712 + }
713 +
714 + .email-status-pill {
715 + display: inline-flex;
716 + align-items: center;
717 + justify-content: center;
718 + min-width: 5.25rem;
719 + padding: 0.35rem 0.7rem;
720 + border-radius: 999px;
721 + font-size: 0.8rem;
722 + font-weight: 700;
723 + letter-spacing: 0.01em;
724 + }
725 +
726 + .email-status-pill.tone-success {
727 + background: rgba(34, 197, 94, 0.14);
728 + color: #7ee7a4;
729 + }
730 +
731 + .email-status-pill.tone-ready {
732 + background: rgba(59, 130, 246, 0.16);
733 + color: #93c5fd;
734 + }
735 +
736 + .email-status-pill.tone-warning {
737 + background: rgba(245, 158, 11, 0.16);
738 + color: #fcd34d;
739 + }
740 +
741 + .email-status-pill.tone-muted {
742 + background: rgba(148, 163, 184, 0.14);
743 + color: #cbd5e1;
744 + }
745 +
746 + .email-add-another {
747 + align-self: flex-start;
748 + }
749 +
750 + @media (max-width: 900px) {
751 +
752 + .email-guide-body,
753 + .email-grid {
754 + grid-template-columns: minmax(0, 1fr);
755 + }
756 + }
757 +
758 + @media (max-width: 640px) {
759 +
760 + .email-card-header,
761 + .email-test-panel {
762 + flex-direction: column;
763 + align-items: stretch;
764 + }
765 +
766 + .email-card-actions {
767 + justify-content: space-between;
768 + width: 100%;
769 + }
770 +
771 + .email-status-pill {
772 + min-width: 0;
773 + }
774 + }
775 + </style>
776 </body>
777
778 </html>
plugins/_email_integration/webui/email-config-store.js new
+404
@@ -0,0 +1,404 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import * as API from "/js/api.js";
3 +
4 +const API_BASE = "/plugins/_email_integration";
5 +const GMAIL_APP_PASSWORDS_URL = "https://support.google.com/mail/answer/185833?hl=en";
6 +const PRESETS = {
7 + "": {
8 + label: "Choose a provider",
9 + account_type: "imap",
10 + imap_server: "",
11 + imap_port: 993,
12 + smtp_server: "",
13 + smtp_port: 587,
14 + },
15 + gmail: {
16 + label: "Gmail",
17 + account_type: "imap",
18 + imap_server: "imap.gmail.com",
19 + imap_port: 993,
20 + smtp_server: "smtp.gmail.com",
21 + smtp_port: 587,
22 + },
23 + icloud: {
24 + label: "iCloud Mail",
25 + account_type: "imap",
26 + imap_server: "imap.mail.me.com",
27 + imap_port: 993,
28 + smtp_server: "smtp.mail.me.com",
29 + smtp_port: 587,
30 + },
31 + microsoft365: {
32 + label: "Outlook / Microsoft 365",
33 + account_type: "imap",
34 + imap_server: "outlook.office365.com",
35 + imap_port: 993,
36 + smtp_server: "smtp.office365.com",
37 + smtp_port: 587,
38 + },
39 + yahoo: {
40 + label: "Yahoo Mail",
41 + account_type: "imap",
42 + imap_server: "imap.mail.yahoo.com",
43 + imap_port: 993,
44 + smtp_server: "smtp.mail.yahoo.com",
45 + smtp_port: 587,
46 + },
47 + exchange: {
48 + label: "Exchange",
49 + account_type: "exchange",
50 + imap_server: "outlook.office365.com",
51 + imap_port: 993,
52 + smtp_server: "smtp.office365.com",
53 + smtp_port: 587,
54 + },
55 + "custom-imap": {
56 + label: "Custom IMAP",
57 + account_type: "imap",
58 + imap_server: "",
59 + imap_port: 993,
60 + smtp_server: "",
61 + smtp_port: 587,
62 + },
63 +};
64 +
65 +function ensureConfig(config) {
66 + if (!config || typeof config !== "object") return;
67 + if (!Array.isArray(config.handlers)) config.handlers = [];
68 +}
69 +
70 +export const store = createStore("emailConfig", {
71 + config: null,
72 + context: null,
73 + editing: null,
74 + testing: null,
75 + testResults: null,
76 + testResultsFor: null,
77 + guideOpen: false,
78 + didInit: false,
79 + projects: [],
80 + presets: PRESETS,
81 +
82 + get handlers() {
83 + ensureConfig(this.config);
84 + return Array.isArray(this.config?.handlers) ? this.config.handlers : [];
85 + },
86 +
87 + async init(config, context = null) {
88 + this.config = config || null;
89 + this.context = context;
90 + this.didInit = false;
91 + ensureConfig(this.config);
92 + this.editing = this.handlers.length === 1 ? 0 : null;
93 + this.testing = null;
94 + this.testResults = null;
95 + this.testResultsFor = null;
96 + this.guideOpen = this.handlers.length === 0 && window.innerWidth > 720;
97 + if (this.handlers.length === 0) this._startInitialHandlerFlow();
98 + this.didInit = true;
99 +
100 + try {
101 + const response = await API.callJsonApi("projects", { action: "list" });
102 + this.projects = response.data || [];
103 + } catch (_) {
104 + this.projects = [];
105 + }
106 + },
107 +
108 + cleanup() {
109 + this.config = null;
110 + this.context = null;
111 + this.editing = null;
112 + this.testing = null;
113 + this.testResults = null;
114 + this.testResultsFor = null;
115 + this.guideOpen = false;
116 + this.didInit = false;
117 + },
118 +
119 + newHandler() {
120 + return {
121 + name: "",
122 + enabled: false,
123 + account_type: "imap",
124 + imap_server: "",
125 + imap_port: 993,
126 + smtp_server: "",
127 + smtp_port: 587,
128 + username: "",
129 + password: "",
130 + poll_mode: "seconds",
131 + poll_interval_seconds: 60,
132 + poll_interval_cron: "*/2 * * * *",
133 + process_unread_days: 0,
134 + sender_whitelist: [],
135 + project: "",
136 + dispatcher_model: "utility",
137 + dispatcher_instructions: "",
138 + agent_instructions: "",
139 + };
140 + },
141 +
142 + addHandler() {
143 + ensureConfig(this.config);
144 + this.config.handlers.push(this.newHandler());
145 + this.editing = this.config.handlers.length - 1;
146 + this.testResults = null;
147 + this.testResultsFor = null;
148 + },
149 +
150 + removeHandler(idx) {
151 + this.handlers.splice(idx, 1);
152 + if (this.editing === idx) this.editing = null;
153 + if (this.editing !== null && this.editing > idx) this.editing -= 1;
154 + if (this.testResultsFor === idx) {
155 + this.testResults = null;
156 + this.testResultsFor = null;
157 + }
158 + },
159 +
160 + toggleEditing(idx) {
161 + this.editing = this.editing === idx ? null : idx;
162 + if (this.testResultsFor !== idx) {
163 + this.testResults = null;
164 + this.testResultsFor = null;
165 + }
166 + },
167 +
168 + providerValue(handler) {
169 + const incoming = String(handler.imap_server || "").trim().toLowerCase();
170 + const outgoing = String(handler.smtp_server || "").trim().toLowerCase();
171 + const accountType = handler.account_type || "imap";
172 +
173 + if (!incoming && !outgoing && !handler.username && !handler.password) return "";
174 + if (accountType === "exchange") return "exchange";
175 + if (incoming === "imap.gmail.com" || outgoing === "smtp.gmail.com") return "gmail";
176 + if (incoming === "imap.mail.me.com" || outgoing === "smtp.mail.me.com") return "icloud";
177 + if (incoming === "outlook.office365.com" || outgoing === "smtp.office365.com") return "microsoft365";
178 + if (incoming === "imap.mail.yahoo.com" || outgoing === "smtp.mail.yahoo.com") return "yahoo";
179 + return "custom-imap";
180 + },
181 +
182 + providerLabel(value) {
183 + return this.presets[value]?.label || "Custom IMAP";
184 + },
185 +
186 + applyProvider(handler, value) {
187 + const preset = this.presets[value];
188 + if (!preset) return;
189 + const previous = this.providerValue(handler);
190 +
191 + handler.account_type = preset.account_type;
192 +
193 + if (value === "custom-imap") {
194 + if (previous !== "custom-imap") {
195 + handler.imap_server = "";
196 + handler.smtp_server = "";
197 + }
198 + if (!handler.imap_port) handler.imap_port = 993;
199 + if (!handler.smtp_port) handler.smtp_port = 587;
200 + return;
201 + }
202 +
203 + handler.imap_server = preset.imap_server;
204 + handler.imap_port = preset.imap_port;
205 + handler.smtp_server = preset.smtp_server;
206 + handler.smtp_port = preset.smtp_port;
207 + },
208 +
209 + providerHint(handler) {
210 + const provider = this.providerValue(handler);
211 + if (provider === "gmail") return "Use a Google App Password. A regular Gmail password usually will not work here.";
212 + if (provider === "icloud") return "Use an app-specific password from your Apple account settings.";
213 + if (provider === "microsoft365") return "Most Outlook and Microsoft 365 inboxes work with this preset.";
214 + if (provider === "yahoo") return "Yahoo Mail usually works best with an app password.";
215 + if (provider === "exchange") return "Choose Exchange only if your organization requires it. For the simplest setup, try Outlook / Microsoft 365 first.";
216 + if (provider === "custom-imap") return "Bring your own incoming and outgoing mail server details.";
217 + return "How to start: turn on inbox, pick your provider, then add your email address and password.";
218 + },
219 +
220 + providerHelpUrl(handler) {
221 + return this.providerValue(handler) === "gmail" ? GMAIL_APP_PASSWORDS_URL : "";
222 + },
223 +
224 + providerHelpLabel(handler) {
225 + if (this.providerValue(handler) !== "gmail") return "";
226 + return "Google's guide to create a Gmail App Password";
227 + },
228 +
229 + showManualServers(handler) {
230 + return this.providerValue(handler) === "custom-imap";
231 + },
232 +
233 + showExchangeServer(handler) {
234 + return this.providerValue(handler) === "exchange";
235 + },
236 +
237 + incomingLabel(handler) {
238 + return this.showExchangeServer(handler) ? "Exchange server" : "Incoming mail server";
239 + },
240 +
241 + incomingDescription(handler) {
242 + return this.showExchangeServer(handler)
243 + ? "The Exchange or Microsoft 365 server for this inbox"
244 + : "The IMAP server for incoming mail";
245 + },
246 +
247 + incomingPlaceholder(handler) {
248 + return this.showExchangeServer(handler) ? "outlook.office365.com" : "imap.your-provider.com";
249 + },
250 +
251 + scheduleLabel(handler) {
252 + const value = this.frequencyValue(handler);
253 + if (value === "15") return "Checks every 15 seconds";
254 + if (value === "30") return "Checks every 30 seconds";
255 + if (value === "60") return "Checks every minute";
256 + if (value === "300") return "Checks every 5 minutes";
257 + if (value === "900") return "Checks every 15 minutes";
258 + return "Uses a custom schedule";
259 + },
260 +
261 + frequencyValue(handler) {
262 + if (handler.poll_mode !== "seconds") return "custom";
263 + const seconds = Number(handler.poll_interval_seconds || 0);
264 + if ([15, 30, 60, 300, 900].includes(seconds)) return String(seconds);
265 + return "custom";
266 + },
267 +
268 + applyFrequency(handler, value) {
269 + if (value === "custom") {
270 + if (handler.poll_mode !== "cron") handler.poll_mode = "seconds";
271 + if (!handler.poll_interval_seconds) handler.poll_interval_seconds = 60;
272 + return;
273 + }
274 + handler.poll_mode = "seconds";
275 + handler.poll_interval_seconds = Number(value);
276 + },
277 +
278 + frequencyHint(handler) {
279 + return this.frequencyValue(handler) === "custom"
280 + ? "You are using a custom schedule. You can change the raw timing in Advanced."
281 + : this.scheduleLabel(handler);
282 + },
283 +
284 + whitelistText(handler) {
285 + return (handler.sender_whitelist || []).join(", ");
286 + },
287 +
288 + setWhitelist(handler, value) {
289 + handler.sender_whitelist = value
290 + .split(",")
291 + .map((entry) => entry.trim())
292 + .filter((entry) => entry);
293 + },
294 +
295 + slugify(value) {
296 + return String(value || "")
297 + .toLowerCase()
298 + .replace(/[^a-z0-9]+/g, "_")
299 + .replace(/^_+|_+$/g, "");
300 + },
301 +
302 + maybeAutoname(handler) {
303 + const current = String(handler.name || "").trim();
304 + if (current && !/^handler_\d+$/.test(current)) return;
305 + const email = String(handler.username || "").trim();
306 + const localPart = email.split("@")[0] || "";
307 + const nextName = this.slugify(localPart);
308 + if (nextName) handler.name = nextName;
309 + },
310 +
311 + missingBits(handler) {
312 + const missing = [];
313 + const provider = this.providerValue(handler);
314 + if (!provider) missing.push("provider");
315 + if (!handler.username) missing.push("email address");
316 + if (!handler.password) missing.push("password");
317 + if ((this.showManualServers(handler) || this.showExchangeServer(handler)) && !handler.imap_server) {
318 + missing.push(this.showExchangeServer(handler) ? "Exchange server" : "incoming server");
319 + }
320 + if (this.showManualServers(handler) && !handler.smtp_server) missing.push("outgoing server");
321 + return missing;
322 + },
323 +
324 + canTest(handler) {
325 + return this.missingBits(handler).length === 0;
326 + },
327 +
328 + statusLabel(handler) {
329 + if (!handler.username && !this.providerValue(handler)) return "New";
330 + if (handler.enabled && this.canTest(handler)) return "Live";
331 + if (this.canTest(handler)) return "Ready";
332 + return "Needs info";
333 + },
334 +
335 + statusTone(handler) {
336 + if (!handler.username && !this.providerValue(handler)) return "muted";
337 + if (handler.enabled && this.canTest(handler)) return "success";
338 + if (this.canTest(handler)) return "ready";
339 + return "warning";
340 + },
341 +
342 + handlerTitle(handler, idx) {
343 + return handler.username || handler.name || `Inbox ${idx + 1}`;
344 + },
345 +
346 + handlerSubtitle(handler) {
347 + const pieces = [];
348 + const provider = this.providerValue(handler);
349 + if (provider) pieces.push(this.providerLabel(provider));
350 + pieces.push(this.scheduleLabel(handler).replace("Checks ", ""));
351 + if (handler.project) pieces.push(`Project: ${handler.project}`);
352 + return pieces.join(" · ");
353 + },
354 +
355 + testButtonLabel(handler, idx) {
356 + if (this.testing === idx) return "Checking...";
357 + if (this.canTest(handler)) return "Check setup";
358 + return "Fill in the basics first";
359 + },
360 +
361 + testIntro(handler) {
362 + if (this.providerValue(handler) === "exchange") {
363 + return "We will check the inbox, check outgoing mail, then send a test email to this address.";
364 + }
365 + return "We will check incoming mail, check outgoing mail, then send a test email to this inbox.";
366 + },
367 +
368 + resultTitle(result) {
369 + return result.test || "Check";
370 + },
371 +
372 + resultMessage(result) {
373 + return result.message || (result.ok ? "Done." : "Something went wrong.");
374 + },
375 +
376 + _startInitialHandlerFlow() {
377 + this.addHandler();
378 + if (!this.context) return;
379 + const toComparableJson = typeof this.context._toComparableJson === "function"
380 + ? this.context._toComparableJson.bind(this.context)
381 + : JSON.stringify;
382 + this.context.settingsSnapshotJson = toComparableJson(this.context.settings);
383 + },
384 +
385 + async testConnection(idx) {
386 + const handler = this.handlers[idx];
387 + if (!handler || !this.canTest(handler)) return;
388 +
389 + this.testing = idx;
390 + this.testResults = null;
391 + this.testResultsFor = idx;
392 +
393 + try {
394 + this.testResults = await API.callJsonApi(`${API_BASE}/test_connection`, { handler });
395 + } catch (error) {
396 + this.testResults = {
397 + success: false,
398 + results: [{ test: "Connection", ok: false, message: String(error) }],
399 + };
400 + }
401 +
402 + this.testing = null;
403 + },
404 +});
plugins/_telegram_integration/api/test_connection.py
+6 -6
@@ -12,9 +12,9 @@ class TestConnection(ApiHandler):
12
13 if not token:
14 results.append({
15 - "test": "Token",
15 + "test": "Bot token",
16 "ok": False,
17 - "message": "No bot token provided",
17 + "message": "Add your bot token first.",
18 })
19 return {"success": False, "results": results}
20
@@ -23,15 +23,15 @@ class TestConnection(ApiHandler):
23 from plugins._telegram_integration.helpers.bot_manager import test_token
24 ok, message = await test_token(token)
25 results.append({
26 - "test": "Bot Token",
26 + "test": "Telegram bot",
27 "ok": ok,
28 - "message": message,
28 + "message": "Telegram accepted the bot token." if ok else message,
29 })
30 except Exception as e:
31 results.append({
32 - "test": "Bot Token",
32 + "test": "Telegram bot",
33 "ok": False,
34 - "message": format_error(e),
34 + "message": f"Could not validate the bot token: {format_error(e)}",
35 })
36
37 return {"success": all(r["ok"] for r in results), "results": results}
plugins/_telegram_integration/webui/config.html
+632 -405
@@ -1,4 +1,5 @@
1 <html>
2 +
3 <head>
4 <title>Telegram Integration</title>
5 <script type="module">
@@ -7,447 +8,673 @@
8 </head>
9
10 <body>
10 - <div x-data x-init="$store.telegramConfig.init()">
11 + <div x-data x-init="$store.telegramConfig.init(config, context)" x-destroy="$store.telegramConfig.cleanup()">
12 <template x-if="config">
13 <div class="tg-page">
13 - <div class="section-title">Telegram Integration</div>
14 - <div class="section-description">
15 - Configure Telegram bots to communicate with Agent Zero via Telegram.
16 - </div>
17 -
18 - <!-- Quick Start Guide (collapsible) -->
19 - <details class="tg-guide">
20 - <summary class="tg-guide-toggle">Quick Start Guide</summary>
21 - <div class="tg-guide-body">
22 - <div class="tg-guide-section tg-guide-section-first">
23 - <div class="tg-guide-row">
24 - <div class="tg-guide-steps">
25 - <div class="tg-guide-section-title">Get Bot Token</div>
26 - <ol>
27 - <li>Click <a href="https://t.me/BotFather" target="_blank" rel="noopener">@BotFather</a> or scan the QR code on your mobile device, or search <strong>@BotFather</strong> in Telegram</li>
28 - <li>Send <strong>/newbot</strong> and follow the prompts to create a bot</li>
29 - <li>Copy the API token provided by BotFather</li>
30 - <li>Click <strong>Add Bot</strong> below, paste the token, and save</li>
31 - </ol>
32 - </div>
33 - <img class="tg-qr" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHQAAAB0AQAAAAB84SuKAAAA50lEQVR4nMWVQWoFMQxDnz+zl2/w73+suYF8Aheni98ux1BqglEgQjOx7ETzM+r1awt/v4+ILCAHLPjqJivLAx7zLwjh7Dxg+T/pKj84/4nr5FHPgxb6bRLjAY/50QE6sED3Uz79HZrr7/ZzPiM/X2B7w/cw263uFZ+2sOb6rMf8iyZNNiqt6lfFG1fembHxzxszKQ1a9a8SO26STf9RU8MUqUX/0DLSmULSpn4T6Kx+zn+d+cMdJrWYP4zvxjy91SeSt6mKjf51cinGgOznsVP2rn7dksaEU8uF/yPAGvsu/B///H59AYlVhAI4J5PTAAAAAElFTkSuQmCC"
34 - alt="Scan to open @BotFather" title="@BotFather" />
35 - </div>
36 - </div>
37 - <div class="tg-guide-section">
38 - <div class="tg-guide-row">
39 - <div class="tg-guide-steps">
40 - <div class="tg-guide-section-title">Set Allowed Users</div>
41 - <ol>
42 - <li>Enter your Telegram handle (e.g. <strong>@yourname</strong>) in the <strong>Allowed Users</strong> field below.</li>
43 - <li>If you don't know your handle, click <a href="https://t.me/userinfobot" target="_blank" rel="noopener">@userinfobot</a> or scan the QR code, then send <strong>/start</strong> to get your numeric ID</li>
44 - </ol>
45 - </div>
46 - <img class="tg-qr" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHQAAAB0AQAAAAB84SuKAAAA5klEQVR4nMWVUWrEQAxDn5f+yzfY+x8rN7BPoOLZhW0/Yyg1A6OBOJbHihLmZ/Tj1xH+/hwRGUEesMiXjSs4YMMnkqaGQG77UVJ14/lPfL331Gtf1HfRVdSA2/nhubkDG3Td5+/XW3TAQj/RmYWahPvzDydUUmEC366P36tkrFroTzkKpHtEuJjfVdEJfmqhX6a0LSGrXKt8kCnDqn9kkSZKG/6qo9waGW74T0zmsLif/zi2czmfJN76D32NhbDqH830zhC84H9CTak6l/5TgccJtPSfLOdYwEq/oDM/1eL7i3/+f30Di2x7PjI9e0cAAAAASUVORK5CYII="
47 - alt="Scan to open @userinfobot" title="@userinfobot" />
48 - </div>
49 - </div>
50 - <div class="tg-guide-note">
51 - <strong>Group chats:</strong> To let the bot respond to all group messages (not just @mentions),
52 - go to @BotFather → <em>/mybots</em> → select your bot → <em>Bot Settings</em> →
53 - <em>Group Privacy</em> → <em>Turn off</em>.
14 + <div class="section-title">Telegram</div>
15 +
16 + <template x-if="$store.telegramConfig.didInit && $store.telegramConfig.bots.length === 0">
17 + <div class="tg-empty">
18 + <div class="tg-empty-title">Start with one bot</div>
19 + <div class="tg-empty-copy">
20 + Most setups only need a bot token, one allowed user, and the default polling mode.
21 </div>
22 + <button class="btn btn-field" @click="$store.telegramConfig.addBot()">
23 + Connect a bot
24 + </button>
25 </div>
56 - </details>
57 -
58 - <!-- Bot cards -->
59 - <template x-for="(bot, idx) in (config.bots || [])" :key="idx">
60 - <div class="bot-card">
61 - <div class="bot-card-header" @click="$store.telegramConfig.toggle(idx)">
62 - <span class="material-symbols-outlined bot-expand-icon"
63 - :class="{ 'expanded': $store.telegramConfig.expandedIdx === idx }">chevron_right</span>
64 - <span class="bot-card-name" x-text="bot.name || '(unnamed)'"></span>
65 - <span class="bot-card-summary" x-show="$store.telegramConfig.expandedIdx !== idx"
66 - x-text="bot.enabled ? 'Enabled' : 'Disabled'"></span>
67 - <button class="text-button bot-delete-btn"
68 - @click.stop="$confirmClick($event, () => $store.telegramConfig.removeBot(config, idx))"
69 - title="Remove bot">
70 - <span class="material-symbols-outlined">close</span>
71 - </button>
72 - </div>
73 -
74 - <div class="bot-card-body" x-show="$store.telegramConfig.expandedIdx === idx" x-transition.opacity>
26 + </template>
27
76 - <div class="field">
77 - <div class="field-label">
78 - <div class="field-title">Enabled</div>
79 - <div class="field-description">Enable or disable this Telegram bot</div>
80 - </div>
81 - <div class="field-control">
82 - <label class="toggle">
83 - <input type="checkbox" x-model="bot.enabled" />
84 - <span class="toggler"></span>
85 - </label>
86 - </div>
28 + <template x-for="(bot, idx) in $store.telegramConfig.bots" :key="idx">
29 + <div class="tg-card">
30 + <div class="tg-card-header" @click="$store.telegramConfig.toggleEditing(idx)">
31 + <div class="tg-card-heading">
32 + <div class="tg-card-title" x-text="$store.telegramConfig.botTitle(bot, idx)"></div>
33 + <div class="tg-card-subtitle" x-text="$store.telegramConfig.botSubtitle(bot)"></div>
34 </div>
88 -
89 - <div class="field">
90 - <div class="field-label">
91 - <div class="field-title">Message Notifications</div>
92 - <div class="field-description">Show a WebUI notification for each incoming Telegram message</div>
93 - </div>
94 - <div class="field-control">
95 - <label class="toggle">
96 - <input type="checkbox" x-model="bot.notify_messages" />
97 - <span class="toggler"></span>
98 - </label>
99 - </div>
35 + <div class="tg-card-actions">
36 + <span class="tg-status-pill" :class="'tone-' + $store.telegramConfig.botStatusTone(bot)"
37 + x-text="$store.telegramConfig.botStatusLabel(bot)"></span>
38 + <button class="btn btn-action delete"
39 + @click.stop="$confirmClick($event, () => $store.telegramConfig.removeBot(idx))" title="Remove bot">
40 + <span class="material-symbols-outlined">delete</span>
41 + </button>
42 + <span class="material-symbols-outlined tg-card-chevron"
43 + :class="{ 'is-open': $store.telegramConfig.editing === idx }">expand_more</span>
44 </div>
45 + </div>
46
102 - <div class="field">
103 - <div class="field-label">
104 - <div class="field-title">Bot Name</div>
105 - <div class="field-description">Unique identifier for this bot configuration</div>
106 - </div>
107 - <div class="field-control">
108 - <input type="text" x-model="bot.name" placeholder="e.g. my_bot" />
47 + <template x-if="$store.telegramConfig.editing === idx">
48 + <div class="tg-card-body">
49 + <div class="tg-step-header">
50 + <div class="tg-step-copy">
51 + <div class="tg-step-title" x-text="$store.telegramConfig.currentStepMeta().title"></div>
52 + <div class="tg-step-description" x-text="$store.telegramConfig.currentStepMeta().description"></div>
53 + </div>
54 + <div class="tg-step-dots" aria-hidden="true">
55 + <template x-for="(step, stepIdx) in $store.telegramConfig.steps" :key="step.title">
56 + <button type="button" class="tg-step-dot"
57 + :class="{ 'is-active': $store.telegramConfig.currentStep === stepIdx }"
58 + @click="$store.telegramConfig.setStep(stepIdx)"></button>
59 + </template>
60 + </div>
61 </div>
110 - </div>
62
112 - <div class="field">
113 - <div class="field-label">
114 - <div class="field-title">Bot Token</div>
115 - <div class="field-description">Token from @BotFather on Telegram</div>
116 - </div>
117 - <div class="field-control">
118 - <input type="password" x-model="bot.token" placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" />
119 - </div>
120 - </div>
63 + <template x-if="$store.telegramConfig.currentStep === 0">
64 + <div class="tg-step-panel">
65 + <div class="tg-token-row">
66 + <div class="tg-qr-card">
67 + <div class="tg-qr-title">Scan to open @BotFather</div>
68 + <img class="tg-qr-image" :src="$store.telegramConfig.botFatherQr"
69 + alt="Scan to open @BotFather" />
70 + </div>
71
122 - <div class="field">
123 - <div class="field-label">
124 - <div class="field-title">Mode</div>
125 - <div class="field-description">Polling: no public IP required. Webhook: needs HTTPS URL</div>
126 - </div>
127 - <div class="field-control">
128 - <select x-model="bot.mode">
129 - <option value="polling">Polling</option>
130 - <option value="webhook">Webhook</option>
131 - </select>
132 - </div>
133 - </div>
72 + <div class="tg-token-card">
73 + <div class="tg-token-steps">
74 + <div class="tg-token-steps-title">Create your bot</div>
75 + <ol class="tg-token-steps-list">
76 + <li>Open <a href="https://t.me/BotFather" target="_blank" rel="noopener">@BotFather</a></li>
77 + <li>Send <strong>/newbot</strong> and follow the prompts</li>
78 + <li>Paste the token here</li>
79 + </ol>
80 + </div>
81
135 - <div class="field" x-show="bot.mode === 'webhook'">
136 - <div class="field-label">
137 - <div class="field-title">Webhook URL</div>
138 - <div class="field-description">Your Agent Zero base URL, e.g. https://yourdomain.com</div>
139 - </div>
140 - <div class="field-control">
141 - <input type="text" x-model="bot.webhook_url" placeholder="https://yourdomain.com" />
142 - </div>
143 - </div>
82 + <div class="field">
83 + <div class="field-label">
84 + <div class="field-title">Bot token</div>
85 + <div class="field-description">Paste the token you got from @BotFather.</div>
86 + </div>
87 + <div class="field-control">
88 + <input type="password" x-model="bot.token"
89 + placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" />
90 + </div>
91 + </div>
92 + </div>
93 + </div>
94 + </div>
95 + </template>
96
145 - <div class="field" x-show="bot.mode === 'webhook'">
146 - <div class="field-label">
147 - <div class="field-title">Webhook Secret</div>
148 - <div class="field-description">Optional shared secret for webhook verification</div>
149 - </div>
150 - <div class="field-control">
151 - <input type="password" x-model="bot.webhook_secret" />
152 - </div>
153 - </div>
97 + <template x-if="$store.telegramConfig.currentStep === 1">
98 + <div class="tg-step-panel">
99 + <div class="field">
100 + <div class="field-label">
101 + <div class="field-title">Turn on this bot</div>
102 + <div class="field-description">Enable message polling when you are ready for the bot to go live.
103 + </div>
104 + </div>
105 + <div class="field-control">
106 + <label class="toggle">
107 + <input type="checkbox" x-model="bot.enabled" />
108 + <span class="toggler"></span>
109 + </label>
110 + </div>
111 + </div>
112
155 - <div class="field">
156 - <div class="field-label">
157 - <div class="field-title">Allowed Users</div>
158 - <div class="field-description">Comma-separated user IDs or @usernames. Empty = anyone can use</div>
159 - </div>
160 - <div class="field-control">
161 - <input type="text"
162 - x-effect="if (document.activeElement !== $el) $el.value = $store.telegramConfig.whitelistText(bot)"
163 - @change="$store.telegramConfig.setWhitelist(bot, $event.target.value)"
164 - placeholder="123456789, @username" />
165 - </div>
166 - </div>
113 + <div class="field">
114 + <div class="field-label">
115 + <div class="field-title">Bot name</div>
116 + <div class="field-description">A friendly internal label so you can tell bots apart.</div>
117 + </div>
118 + <div class="field-control">
119 + <input type="text" x-model="bot.name" placeholder="support_bot" />
120 + </div>
121 + </div>
122
168 - <div class="field">
169 - <div class="field-label">
170 - <div class="field-title">Group Mode</div>
171 - <div class="field-description">How the bot responds in group chats</div>
172 - </div>
173 - <div class="field-control">
174 - <select x-model="bot.group_mode">
175 - <option value="mention">Respond when @mentioned or replied to</option>
176 - <option value="all">Respond to every message</option>
177 - <option value="off">Ignore group messages</option>
178 - </select>
179 - </div>
180 - </div>
123 + <div class="field">
124 + <div class="field-label">
125 + <div class="field-title">Delivery mode</div>
126 + <div class="field-description">Polling is the simplest option. Webhook is for public HTTPS
127 + setups.</div>
128 + </div>
129 + <div class="field-control">
130 + <select x-model="bot.mode">
131 + <option value="polling">Polling</option>
132 + <option value="webhook">Webhook</option>
133 + </select>
134 + </div>
135 + </div>
136
182 - <div class="field" x-show="bot.group_mode !== 'off'">
183 - <div class="field-label">
184 - <div class="field-title">Welcome New Members</div>
185 - <div class="field-description">Send a greeting when someone joins the group</div>
186 - </div>
187 - <div class="field-control">
188 - <label class="toggle">
189 - <input type="checkbox" x-model="bot.welcome_enabled" />
190 - <span class="toggler"></span>
191 - </label>
192 - </div>
193 - </div>
137 + <div class="field" x-show="bot.mode === 'webhook'">
138 + <div class="field-label">
139 + <div class="field-title">Webhook URL</div>
140 + <div class="field-description">Your public Agent Zero base URL, for example
141 + https://yourdomain.com.</div>
142 + </div>
143 + <div class="field-control">
144 + <input type="text" x-model="bot.webhook_url" placeholder="https://yourdomain.com" />
145 + </div>
146 + </div>
147
195 - <div class="field" x-show="bot.group_mode !== 'off' && bot.welcome_enabled">
196 - <div class="field-label">
197 - <div class="field-title">Welcome Message</div>
198 - <div class="field-description">Use {name} for the new member's name</div>
199 - </div>
200 - <div class="field-control">
201 - <input type="text" x-model="bot.welcome_message" placeholder="Welcome, {name}!" />
202 - </div>
203 - </div>
148 + <div class="field">
149 + <div class="field-label">
150 + <div class="field-title">Allowed users</div>
151 + <div class="field-description">Comma-separated @usernames or numeric IDs. Leave empty only if
152 + you truly want open access.</div>
153 + </div>
154 + <div class="field-control">
155 + <input type="text" :value="$store.telegramConfig.whitelistText(bot)"
156 + @input="$store.telegramConfig.setWhitelist(bot, $event.target.value)"
157 + placeholder="@yourname, 123456789" />
158 + </div>
159 + </div>
160
205 - <div class="field">
206 - <div class="field-label">
207 - <div class="field-title">User Project Mapping</div>
208 - <div class="field-description">Map user IDs to projects: user_id=project, separate by comma</div>
209 - </div>
210 - <div class="field-control">
211 - <input type="text"
212 - x-effect="if (document.activeElement !== $el) $el.value = $store.telegramConfig.userProjectsText(bot)"
213 - @change="$store.telegramConfig.setUserProjects(bot, $event.target.value)"
214 - placeholder="123456=my_project, 789012=other_project" />
215 - </div>
216 - </div>
161 + <div class="tg-warning" x-show="$store.telegramConfig.accessWarning(bot)">
162 + <span x-text="$store.telegramConfig.accessWarning(bot)"></span>
163 + </div>
164
218 - <div class="field">
219 - <div class="field-label">
220 - <div class="field-title">Default Project</div>
221 - <div class="field-description">Fallback project if user not in the mapping above</div>
222 - </div>
223 - <div class="field-control">
224 - <select x-effect="void $store.telegramConfig.projects; $nextTick(() => $el.value = bot.default_project)"
225 - @change="bot.default_project = $event.target.value">
226 - <option value="">No project</option>
227 - <template x-for="proj in $store.telegramConfig.projects" :key="proj.name">
228 - <option :value="proj.name" x-text="proj.title || proj.name"></option>
229 - </template>
230 - </select>
231 - </div>
232 - </div>
165 + <div class="field">
166 + <div class="field-label">
167 + <div class="field-title">Default project</div>
168 + <div class="field-description">Optional fallback project for conversations from this bot.</div>
169 + </div>
170 + <div class="field-control">
171 + <select :value="bot.default_project" @change="bot.default_project = $event.target.value">
172 + <option value="">No project</option>
173 + <template x-for="proj in $store.telegramConfig.projects" :key="proj.name">
174 + <option :value="proj.name" x-text="proj.title || proj.name"
175 + :selected="bot.default_project === proj.name"></option>
176 + </template>
177 + </select>
178 + </div>
179 + </div>
180 + </div>
181 + </template>
182
234 - <div class="field">
235 - <div class="field-label">
236 - <div class="field-title">Attachment Max Age</div>
237 - <div class="field-description">Hours before attachments auto-delete. 0 = keep forever</div>
238 - </div>
239 - <div class="field-control">
240 - <input type="number" x-model.number="bot.attachment_max_age_hours" min="0" step="1" placeholder="0" />
241 - </div>
242 - </div>
183 + <template x-if="$store.telegramConfig.currentStep === 2">
184 + <div class="tg-step-panel">
185 + <div class="field">
186 + <div class="field-label">
187 + <div class="field-title">Group behavior</div>
188 + <div class="field-description">Choose whether the bot replies only when invited into the
189 + conversation or to everything in a group.</div>
190 + </div>
191 + <div class="field-control">
192 + <select x-model="bot.group_mode">
193 + <option value="mention">Reply when @mentioned or replied to</option>
194 + <option value="all">Reply to every group message</option>
195 + <option value="off">Ignore group messages</option>
196 + </select>
197 + </div>
198 + </div>
199
244 - <div class="field">
245 - <div class="field-label">
246 - <div class="field-title">Agent Instructions</div>
247 - <div class="field-description">Extra instructions for the agent in Telegram chats</div>
248 - </div>
249 - <div class="field-control">
250 - <textarea x-model="bot.agent_instructions" rows="3" placeholder="e.g. Always respond concisely for mobile..."></textarea>
200 + <div class="field" x-show="bot.group_mode !== 'off'">
201 + <div class="field-label">
202 + <div class="field-title">Welcome new members</div>
203 + <div class="field-description">Send a greeting when someone joins the group.</div>
204 + </div>
205 + <div class="field-control">
206 + <label class="toggle">
207 + <input type="checkbox" x-model="bot.welcome_enabled" />
208 + <span class="toggler"></span>
209 + </label>
210 + </div>
211 + </div>
212 +
213 + <div class="field" x-show="bot.group_mode !== 'off' && bot.welcome_enabled">
214 + <div class="field-label">
215 + <div class="field-title">Welcome message</div>
216 + <div class="field-description">Use {name} for the new member's name.</div>
217 + </div>
218 + <div class="field-control">
219 + <input type="text" x-model="bot.welcome_message" placeholder="Welcome, {name}!" />
220 + </div>
221 + </div>
222 +
223 + <div class="field">
224 + <div class="field-label">
225 + <div class="field-title">Agent instructions</div>
226 + <div class="field-description">Extra guidance for how the agent should reply in Telegram chats.
227 + </div>
228 + </div>
229 + <div class="field-control">
230 + <textarea x-model="bot.agent_instructions" rows="3"
231 + placeholder="Reply in a concise, mobile-friendly way."></textarea>
232 + </div>
233 + </div>
234 + </div>
235 + </template>
236 +
237 + <div class="tg-test-panel">
238 + <div class="tg-test-copy">
239 + <div class="tg-test-title">Check the connection</div>
240 + <div class="tg-test-description" x-text="$store.telegramConfig.testIntro()"></div>
241 + </div>
242 + <button class="btn btn-field" @click.stop="$store.telegramConfig.testConnection(idx)"
243 + :disabled="$store.telegramConfig.testing === idx || !$store.telegramConfig.canTest(bot)">
244 + <span x-text="$store.telegramConfig.testButtonLabel(bot, idx)"></span>
245 + </button>
246 </div>
252 - </div>
247
254 - <!-- Test connection + results -->
255 - <div class="bot-test-row">
256 - <button class="btn btn-field"
257 - @click.stop="$store.telegramConfig.testConnection(config, idx)"
258 - :disabled="$store.telegramConfig.testing === idx">
259 - <span x-show="$store.telegramConfig.testing !== idx">Test Connection</span>
260 - <span x-show="$store.telegramConfig.testing === idx">Testing...</span>
261 - </button>
262 - <template x-if="$store.telegramConfig.testResults && $store.telegramConfig.expandedIdx === idx">
263 - <div class="bot-test-results">
264 - <template x-for="r in $store.telegramConfig.testResults.results" :key="r.test">
265 - <div class="bot-test-result-row">
266 - <span class="bot-test-icon"
267 - x-text="r.ok ? '✓' : '✗'"
268 - :class="r.ok ? 'ok' : 'fail'"></span>
269 - <span class="bot-test-label" x-text="r.test"></span>
270 - <span class="bot-test-msg" x-text="r.message"></span>
248 + <template x-if="$store.telegramConfig.testResults && $store.telegramConfig.testResultsFor === idx">
249 + <div class="tg-results" :class="{ 'is-error': !$store.telegramConfig.testResults.success }">
250 + <template x-for="result in $store.telegramConfig.testResults.results"
251 + :key="result.test + result.message">
252 + <div class="tg-result-row">
253 + <span class="tg-result-icon" x-text="result.ok ? '✓' : '✗'"></span>
254 + <div class="tg-result-copy">
255 + <div class="tg-result-title" x-text="$store.telegramConfig.resultTitle(result)"></div>
256 + <div class="tg-result-message" x-text="$store.telegramConfig.resultMessage(result)"></div>
257 + </div>
258 </div>
259 </template>
260 </div>
261 </template>
275 - </div>
262
277 - </div>
263 + <template x-if="$store.telegramConfig.currentStep > 0">
264 + <details class="tg-advanced">
265 + <summary>
266 + <span>Advanced</span>
267 + <span class="material-symbols-outlined tg-advanced-chevron"
268 + aria-hidden="true">keyboard_arrow_down</span>
269 + </summary>
270 + <div class="tg-advanced-body">
271 + <div class="field" x-show="bot.mode === 'webhook'">
272 + <div class="field-label">
273 + <div class="field-title">Webhook secret</div>
274 + <div class="field-description">Optional shared secret for webhook verification.</div>
275 + </div>
276 + <div class="field-control">
277 + <input type="password" x-model="bot.webhook_secret" />
278 + </div>
279 + </div>
280 +
281 + <div class="field">
282 + <div class="field-label">
283 + <div class="field-title">Message notifications</div>
284 + <div class="field-description">Show a WebUI notification for each incoming Telegram message.
285 + </div>
286 + </div>
287 + <div class="field-control">
288 + <label class="toggle">
289 + <input type="checkbox" x-model="bot.notify_messages" />
290 + <span class="toggler"></span>
291 + </label>
292 + </div>
293 + </div>
294 +
295 + <div class="field">
296 + <div class="field-label">
297 + <div class="field-title">User project mapping</div>
298 + <div class="field-description">Map specific Telegram user IDs to projects with user_id=project
299 + entries.</div>
300 + </div>
301 + <div class="field-control">
302 + <input type="text" :value="$store.telegramConfig.userProjectsText(bot)"
303 + @input="$store.telegramConfig.setUserProjects(bot, $event.target.value)"
304 + placeholder="123456=my_project, 789012=other_project" />
305 + </div>
306 + </div>
307 +
308 + <div class="field">
309 + <div class="field-label">
310 + <div class="field-title">Attachment max age</div>
311 + <div class="field-description">How long to keep downloaded attachments. Use 0 to keep them
312 + forever.</div>
313 + </div>
314 + <div class="field-control">
315 + <input type="number" x-model.number="bot.attachment_max_age_hours" min="0" step="1"
316 + placeholder="0" />
317 + </div>
318 + </div>
319 +
320 + </div>
321 + </details>
322 + </template>
323 + </div>
324 + </template>
325 </div>
326 </template>
327
281 - <button class="text-button bot-add-btn" @click="$store.telegramConfig.addBot(config)">
282 - <span class="material-symbols-outlined">add</span>
283 - <span>Add Bot</span>
284 - </button>
328 + <template x-if="$store.telegramConfig.bots.length > 0">
329 + <button class="btn btn-field tg-add-btn" @click="$store.telegramConfig.addBot()">
330 + Connect another bot
331 + </button>
332 + </template>
333 </div>
334 </template>
335 </div>
336
337 <style>
290 - .tg-page {
291 - display: flex;
292 - flex-direction: column;
293 - gap: 8px;
294 - }
295 - .tg-guide {
296 - border: 1px solid var(--color-border);
297 - border-radius: 6px;
298 - font-size: 0.85rem;
299 - }
300 - .tg-guide-toggle {
301 - padding: 8px 10px;
302 - cursor: pointer;
303 - opacity: 0.8;
304 - }
305 - .tg-guide-toggle:hover {
306 - opacity: 1;
307 - }
308 - .tg-guide-body {
309 - padding: 4px 12px 12px;
310 - border-top: 1px solid var(--color-border);
311 - }
312 - .tg-guide-section {
313 - margin-top: 10px;
314 - }
315 - .tg-guide-section-first {
316 - margin-top: 15px;
317 - }
318 - .tg-guide-steps {
319 - flex: 1;
320 - }
321 - .tg-guide-section-title {
322 - font-weight: 600;
323 - font-size: 0.85rem;
324 - margin-bottom: 4px;
325 - opacity: 0.9;
326 - }
327 - .tg-guide-body ol {
328 - margin: 4px 0;
329 - padding-left: 20px;
330 - }
331 - .tg-guide-body li {
332 - margin-bottom: 4px;
333 - }
334 - .tg-guide-body a {
335 - color: var(--color-highlight);
336 - }
337 - .tg-guide-note {
338 - margin-top: 15px;
339 - padding: 6px 10px;
340 - background: var(--color-background-hover, rgba(255,255,255,0.04));
341 - border-radius: 4px;
342 - font-size: 0.8rem;
343 - opacity: 0.85;
344 - }
345 - .tg-guide-row {
346 - display: flex;
347 - align-items: flex-start;
348 - gap: 12px;
349 - }
350 - .tg-guide-row ol {
351 - flex: 1;
352 - }
353 - .tg-qr {
354 - width: 88px;
355 - height: 88px;
356 - border-radius: 4px;
357 - flex-shrink: 0;
358 - image-rendering: pixelated;
359 - }
360 - .bot-card {
361 - border: 1px solid var(--color-border);
362 - border-radius: 6px;
363 - overflow: hidden;
364 - }
365 - .bot-card-header {
366 - display: flex;
367 - align-items: center;
368 - gap: 6px;
369 - padding: 8px 10px;
370 - cursor: pointer;
371 - font-size: 0.85rem;
372 - }
373 - .bot-card-header:hover {
374 - background: var(--color-background-hover, rgba(255,255,255,0.04));
375 - }
376 - .bot-expand-icon {
377 - font-size: 16px;
378 - transition: transform 0.15s ease;
379 - }
380 - .bot-expand-icon.expanded {
381 - transform: rotate(90deg);
382 - }
383 - .bot-card-name {
384 - font-weight: 500;
385 - }
386 - .bot-card-summary {
387 - flex: 1;
388 - text-align: right;
389 - opacity: 0.5;
390 - font-size: 0.75rem;
391 - overflow: hidden;
392 - text-overflow: ellipsis;
393 - white-space: nowrap;
394 - }
395 - .bot-delete-btn {
396 - margin-left: auto;
397 - opacity: 0.5;
398 - padding: 2px !important;
399 - }
400 - .bot-delete-btn:hover {
401 - opacity: 1;
402 - color: var(--color-error, #f44) !important;
403 - }
404 - .bot-delete-btn .material-symbols-outlined {
405 - font-size: 16px;
406 - }
407 - .bot-card-body {
408 - padding: 4px 12px 12px;
409 - border-top: 1px solid var(--color-border);
410 - }
411 - .bot-add-btn {
412 - margin-top: 4px;
413 - width: fit-content;
414 - }
415 - .bot-test-row {
416 - margin-top: 12px;
417 - display: flex;
418 - align-items: flex-start;
419 - gap: 12px;
420 - flex-wrap: wrap;
421 - }
422 - .bot-test-results {
423 - padding: 4px 0;
424 - font-size: 0.85rem;
425 - flex: 1;
426 - min-width: 200px;
427 - }
428 - .bot-test-result-row {
429 - display: flex;
430 - align-items: center;
431 - gap: 8px;
432 - padding: 4px 0;
433 - }
434 - .bot-test-icon {
435 - font-weight: bold;
436 - }
437 - .bot-test-icon.ok {
438 - color: #4caf50;
439 - }
440 - .bot-test-icon.fail {
441 - color: #f44336;
442 - }
443 - .bot-test-label {
444 - font-weight: 500;
445 - min-width: 80px;
446 - }
447 - .bot-test-msg {
448 - opacity: 0.8;
449 - }
338 + .tg-page {
339 + display: flex;
340 + flex-direction: column;
341 + gap: 0.9rem;
342 + }
343 +
344 + .tg-advanced summary {
345 + cursor: pointer;
346 + list-style: none;
347 + font-weight: 700;
348 + display: flex;
349 + align-items: center;
350 + gap: 0.75rem;
351 + }
352 +
353 + .tg-advanced summary::-webkit-details-marker {
354 + display: none;
355 + }
356 +
357 + .tg-guide-card,
358 + .tg-empty,
359 + .tg-card {
360 + border-radius: 0.5rem;
361 + }
362 +
363 + .tg-empty-title,
364 + .tg-step-title,
365 + .tg-test-title,
366 + .tg-qr-title {
367 + font-weight: 700;
368 + }
369 +
370 + .tg-empty-copy,
371 + .tg-step-description,
372 + .tg-info-box,
373 + .tg-test-description,
374 + .tg-result-message,
375 + .tg-card-subtitle {
376 + color: var(--color-text-secondary);
377 + line-height: 1.5;
378 + }
379 +
380 + .tg-empty {
381 + padding: 1rem;
382 + }
383 +
384 + .tg-empty-copy {
385 + margin-top: 0.35rem;
386 + margin-bottom: 0.9rem;
387 + }
388 +
389 + .tg-card-header {
390 + display: flex;
391 + justify-content: space-between;
392 + gap: 1rem;
393 + align-items: flex-start;
394 + padding: var(--spacing-sm) 0;
395 + border-bottom: 1px solid var(--color-border);
396 + cursor: pointer;
397 + }
398 +
399 + .tg-card-heading {
400 + min-width: 0;
401 + flex: 1 1 auto;
402 + }
403 +
404 + .tg-card-title {
405 + font-weight: 700;
406 + line-height: 1.35;
407 + overflow-wrap: anywhere;
408 + }
409 +
410 + .tg-card-subtitle {
411 + margin-top: 0.3rem;
412 + font-size: var(--font-size-small);
413 + }
414 +
415 + .tg-card-actions {
416 + display: flex;
417 + align-items: center;
418 + gap: 0.45rem;
419 + flex: 0 0 auto;
420 + }
421 +
422 + .tg-card-chevron {
423 + transition: transform 0.18s ease;
424 + opacity: 0.7;
425 + }
426 +
427 + .tg-card-chevron.is-open {
428 + transform: rotate(180deg);
429 + }
430 +
431 + .tg-card-body {
432 + border-top: 1px solid color-mix(in srgb, var(--color-border) 88%, white 12%);
433 + }
434 +
435 + .tg-step-header {
436 + display: flex;
437 + justify-content: space-between;
438 + gap: 1rem;
439 + align-items: flex-start;
440 + margin: 1rem 0;
441 + }
442 +
443 + .tg-step-dots {
444 + display: flex;
445 + gap: 0.5rem;
446 + flex-wrap: wrap;
447 + justify-content: flex-end;
448 + align-items: center;
449 + }
450 +
451 + .tg-step-dot {
452 + width: 0.85rem;
453 + height: 0.85rem;
454 + border-radius: 999px;
455 + border: 1px solid var(--color-border);
456 + background: transparent;
457 + cursor: pointer;
458 + padding: 0;
459 + }
460 +
461 + .tg-step-dot.is-active {
462 + background: rgba(59, 130, 246, 0.9);
463 + border-color: rgba(59, 130, 246, 0.9);
464 + }
465 +
466 + .tg-step-panel {
467 + display: flex;
468 + flex-direction: column;
469 + gap: 0.2rem;
470 + }
471 +
472 + .tg-token-row {
473 + display: grid;
474 + grid-template-columns: minmax(12rem, 16rem) minmax(0, 1fr);
475 + gap: 1rem;
476 + align-items: stretch;
477 + }
478 +
479 + .tg-qr-card,
480 + .tg-token-card {
481 + border-radius: 0.5rem;
482 + background: color-mix(in srgb, var(--color-background) 90%, white 10%);
483 + padding: 1rem;
484 + }
485 +
486 + .tg-qr-card {
487 + display: flex;
488 + flex-direction: column;
489 + align-items: center;
490 + text-align: center;
491 + gap: 0.8rem;
492 + }
493 +
494 + .tg-qr-image {
495 + width: 150px;
496 + height: 150px;
497 + border-radius: 0.5rem;
498 + image-rendering: pixelated;
499 + }
500 +
501 + .tg-token-steps {
502 + margin-bottom: 0.9rem;
503 + }
504 +
505 + .tg-token-steps-title {
506 + font-weight: 700;
507 + margin-bottom: 0.45rem;
508 + }
509 +
510 + .tg-token-steps-list {
511 + margin: 0;
512 + padding-left: 1.15rem;
513 + color: var(--color-text-secondary);
514 + line-height: 1.55;
515 + }
516 +
517 + .tg-token-steps-list a {
518 + color: var(--color-highlight);
519 + }
520 +
521 + .tg-info-box,
522 + .tg-warning {
523 + margin-bottom: 0.9rem;
524 + padding: 0.8rem 0.9rem;
525 + border-radius: 12px;
526 + font-size: var(--font-size-small);
527 + }
528 +
529 + .tg-info-box {
530 + background: color-mix(in srgb, #3b82f6 12%, var(--color-background) 88%);
531 + }
532 +
533 + .tg-warning {
534 + background: color-mix(in srgb, #f59e0b 14%, var(--color-background) 86%);
535 + }
536 +
537 + .tg-advanced {
538 + margin-top: 0.95rem;
539 + border: 1px solid color-mix(in srgb, var(--color-border) 88%, white 12%);
540 + border-radius: 14px;
541 + overflow: hidden;
542 + }
543 +
544 + .tg-advanced summary {
545 + padding: 0.9rem 1rem;
546 + background: color-mix(in srgb, var(--color-background) 88%, white 12%);
547 + }
548 +
549 + .tg-advanced-chevron {
550 + margin-left: auto;
551 + flex: 0 0 auto;
552 + transition: transform 0.18s ease, opacity 0.18s ease;
553 + opacity: 0.72;
554 + }
555 +
556 + .tg-advanced[open] .tg-advanced-chevron {
557 + transform: rotate(180deg);
558 + opacity: 1;
559 + }
560 +
561 + .tg-advanced-body {
562 + padding: 1rem;
563 + display: flex;
564 + flex-direction: column;
565 + gap: 0.2rem;
566 + }
567 +
568 + .tg-test-panel {
569 + margin-top: 1rem;
570 + padding: var(--spacing-xs) 0;
571 + display: flex;
572 + justify-content: space-between;
573 + align-items: center;
574 + gap: 1rem;
575 + }
576 +
577 + .tg-test-copy,
578 + .tg-result-copy {
579 + min-width: 0;
580 + }
581 +
582 + .tg-results {
583 + margin-top: 0.9rem;
584 + border-radius: 14px;
585 + border: 1px solid color-mix(in srgb, #22c55e 28%, var(--color-border) 72%);
586 + background: color-mix(in srgb, #22c55e 8%, var(--color-background) 92%);
587 + padding: 0.35rem 0.9rem;
588 + }
589 +
590 + .tg-results.is-error {
591 + border-color: color-mix(in srgb, #ef4444 28%, var(--color-border) 72%);
592 + background: color-mix(in srgb, #ef4444 8%, var(--color-background) 92%);
593 + }
594 +
595 + .tg-result-row {
596 + display: flex;
597 + align-items: flex-start;
598 + gap: 0.8rem;
599 + padding: 0.7rem 0;
600 + }
601 +
602 + .tg-result-row+.tg-result-row {
603 + border-top: 1px solid color-mix(in srgb, var(--color-border) 85%, white 15%);
604 + }
605 +
606 + .tg-result-icon {
607 + width: 1.25rem;
608 + font-weight: 800;
609 + line-height: 1.3;
610 + }
611 +
612 + .tg-result-title {
613 + font-weight: 700;
614 + margin-bottom: 0.18rem;
615 + }
616 +
617 + .tg-status-pill {
618 + display: inline-flex;
619 + align-items: center;
620 + justify-content: center;
621 + min-width: 5.25rem;
622 + padding: 0.35rem 0.7rem;
623 + border-radius: 999px;
624 + font-size: 0.8rem;
625 + font-weight: 700;
626 + }
627 +
628 + .tg-status-pill.tone-success {
629 + background: rgba(34, 197, 94, 0.14);
630 + color: #7ee7a4;
631 + }
632 +
633 + .tg-status-pill.tone-ready {
634 + background: rgba(59, 130, 246, 0.16);
635 + color: #93c5fd;
636 + }
637 +
638 + .tg-status-pill.tone-warning {
639 + background: rgba(245, 158, 11, 0.16);
640 + color: #fcd34d;
641 + }
642 +
643 + .tg-status-pill.tone-muted {
644 + background: rgba(148, 163, 184, 0.14);
645 + color: #cbd5e1;
646 + }
647 +
648 + .tg-add-btn {
649 + align-self: flex-start;
650 + }
651 +
652 + @media (max-width: 900px) {
653 + .tg-token-row {
654 + grid-template-columns: minmax(0, 1fr);
655 + }
656 + }
657 +
658 + @media (max-width: 640px) {
659 +
660 + .tg-card-header,
661 + .tg-step-header,
662 + .tg-test-panel {
663 + flex-direction: column;
664 + align-items: stretch;
665 + }
666 +
667 + .tg-card-actions,
668 + .tg-step-dots {
669 + width: 100%;
670 + justify-content: space-between;
671 + }
672 +
673 + .tg-status-pill {
674 + min-width: 0;
675 + }
676 + }
677 </style>
678 </body>
679
453 -</html>
680 +</html>
\ No newline at end of file
plugins/_telegram_integration/webui/telegram-config-store.js
+281 -45
@@ -1,30 +1,129 @@
1 import { createStore } from "/js/AlpineStore.js";
2 +import * as API from "/js/api.js";
3
4 const API_BASE = "/plugins/_telegram_integration";
5 +const STEPS = [
6 + {
7 + title: "Connect your bot",
8 + description: "Start with BotFather, then paste the bot token here.",
9 + },
10 + {
11 + title: "Choose who can use it",
12 + description: "Finish the core setup, choose access, and decide how messages arrive.",
13 + },
14 + {
15 + title: "Shape the conversation",
16 + description: "Choose how the bot behaves in groups and how the agent should reply.",
17 + },
18 +];
19 +
20 +const BOTFATHER_QR =
21 + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHQAAAB0AQAAAAB84SuKAAAA50lEQVR4nMWVQWoFMQxDnz+zl2/w73+suYF8Aheni98ux1BqglEgQjOx7ETzM+r1awt/v4+ILCAHLPjqJivLAx7zLwjh7Dxg+T/pKj84/4nr5FHPgxb6bRLjAY/50QE6sED3Uz79HZrr7/ZzPiM/X2B7w/cw263uFZ+2sOb6rMf8iyZNNiqt6lfFG1fembHxzxszKQ1a9a8SO26STf9RU8MUqUX/0DLSmULSpn4T6Kx+zn+d+cMdJrWYP4zvxjy91SeSt6mKjf51cinGgOznsVP2rn7dksaEU8uF/yPAGvsu/B///H59AYlVhAI4J5PTAAAAAElFTkSuQmCC";
22 +
23 +function ensureConfig(config) {
24 + if (!config || typeof config !== "object") return;
25 + if (!Array.isArray(config.bots)) config.bots = [];
26 +}
27
28 export const store = createStore("telegramConfig", {
29 + config: null,
30 projects: [],
7 - expandedIdx: null,
31 + editing: null,
32 testing: null,
33 testResults: null,
10 - _loaded: false,
34 + testResultsFor: null,
35 + botSteps: [],
36 + didInit: false,
37 + steps: STEPS,
38 + botFatherQr: BOTFATHER_QR,
39 + _projectsLoaded: false,
40 + context: null,
41 +
42 + get bots() {
43 + ensureConfig(this.config);
44 + return Array.isArray(this.config?.bots) ? this.config.bots : [];
45 + },
46 +
47 + get activeIndex() {
48 + return typeof this.editing === "number" ? this.editing : -1;
49 + },
50 +
51 + get activeBot() {
52 + return this.activeIndex >= 0 ? this.bots[this.activeIndex] || null : null;
53 + },
54 +
55 + get showFooterNav() {
56 + return this.activeIndex >= 0;
57 + },
58 +
59 + get currentStep() {
60 + return this.activeIndex >= 0 && typeof this.botSteps[this.activeIndex] === "number"
61 + ? this.botSteps[this.activeIndex]
62 + : 0;
63 + },
64 +
65 + get isFirstStep() {
66 + return this.currentStep === 0;
67 + },
68 +
69 + get isLastStep() {
70 + return this.currentStep >= this.steps.length - 1;
71 + },
72 +
73 + get nextDisabled() {
74 + return !!this.stepBlockedReason();
75 + },
76
12 - async init() {
13 - if (this._loaded) return;
77 + get nextButtonLabel() {
78 + return this.isLastStep ? "Done" : "Next";
79 + },
80 +
81 + get footerStepLabel() {
82 + return `Step ${this.currentStep + 1} of ${this.steps.length}`;
83 + },
84 +
85 + async init(config, context = null) {
86 + this.config = config || null;
87 + this.context = context;
88 + this.didInit = false;
89 + ensureConfig(this.config);
90 + this.editing = this.bots.length === 1 ? 0 : null;
91 + this.testing = null;
92 + this.testResults = null;
93 + this.testResultsFor = null;
94 + this.botSteps = this.bots.map((bot) => this.initialStepForBot(bot));
95 + if (this.bots.length === 0) this._startInitialBotFlow();
96 + this._installWizardFooter();
97 + this.didInit = true;
98 +
99 + if (this._projectsLoaded) return;
100 try {
15 - const { callJsonApi } = await import("/js/api.js");
16 - const res = await callJsonApi("projects", { action: "list" });
17 - this.projects = res.data || [];
101 + const response = await API.callJsonApi("projects", { action: "list" });
102 + this.projects = response.data || [];
103 } catch (_) {
104 this.projects = [];
105 }
21 - this._loaded = true;
106 + this._projectsLoaded = true;
107 + },
108 +
109 + cleanup() {
110 + if (this.context?.wizardFooter?.owner === "telegramConfig") {
111 + this.context.wizardFooter = null;
112 + }
113 + this.config = null;
114 + this.context = null;
115 + this.editing = null;
116 + this.testing = null;
117 + this.testResults = null;
118 + this.testResultsFor = null;
119 + this.botSteps = [];
120 + this.didInit = false;
121 },
122
123 defaultBot() {
124 return {
125 name: "",
27 - enabled: true,
126 + enabled: false,
127 notify_messages: false,
128 token: "",
129 mode: "polling",
@@ -41,71 +140,208 @@ export const store = createStore("telegramConfig", {
140 };
141 },
142
44 - addBot(config) {
45 - if (!config.bots) config.bots = [];
46 - const bot = this.defaultBot();
47 - bot.name = "bot_" + (config.bots.length + 1);
48 - config.bots.push(bot);
49 - this.expandedIdx = config.bots.length - 1;
143 + addBot() {
144 + ensureConfig(this.config);
145 + this.config.bots.push(this.defaultBot());
146 + this.botSteps.push(0);
147 + this.editing = this.config.bots.length - 1;
148 + this.testResults = null;
149 + this.testResultsFor = null;
150 },
151
52 - removeBot(config, idx) {
53 - config.bots.splice(idx, 1);
54 - this.expandedIdx = null;
152 + removeBot(idx) {
153 + this.bots.splice(idx, 1);
154 + this.botSteps.splice(idx, 1);
155 + if (this.editing === idx) this.editing = null;
156 + if (this.editing !== null && this.editing > idx) this.editing -= 1;
157 + if (this.testResultsFor === idx) {
158 + this.testResults = null;
159 + this.testResultsFor = null;
160 + }
161 },
162
57 - toggle(idx) {
58 - this.expandedIdx = this.expandedIdx === idx ? null : idx;
59 - this.testResults = null;
163 + toggleEditing(idx) {
164 + this.editing = this.editing === idx ? null : idx;
165 + if (this.editing !== null && typeof this.botSteps[this.editing] !== "number") {
166 + this.botSteps[this.editing] = this.initialStepForBot(this.bots[this.editing]);
167 + }
168 + if (this.testResultsFor !== idx) {
169 + this.testResults = null;
170 + this.testResultsFor = null;
171 + }
172 + },
173 +
174 + currentStepMeta() {
175 + return this.steps[this.currentStep] || this.steps[0];
176 + },
177 +
178 + setStep(step) {
179 + if (this.activeIndex < 0) return;
180 + const next = Math.max(0, Math.min(this.steps.length - 1, Number(step) || 0));
181 + this.botSteps[this.activeIndex] = next;
182 + },
183 +
184 + previousStep() {
185 + if (this.isFirstStep) return;
186 + this.setStep(this.currentStep - 1);
187 + },
188 +
189 + nextStep() {
190 + if (this.stepBlockedReason()) return;
191 + if (this.isLastStep) {
192 + this.editing = null;
193 + return;
194 + }
195 + this.setStep(this.currentStep + 1);
196 + },
197 +
198 + stepBlockedReason() {
199 + const bot = this.activeBot;
200 + if (!bot) return "";
201 + if (this.currentStep === 0 && !String(bot.token || "").trim()) {
202 + return "Add your bot token first.";
203 + }
204 + if (this.currentStep === 1 && bot.mode === "webhook" && !String(bot.webhook_url || "").trim()) {
205 + return "Add your webhook URL first.";
206 + }
207 + return "";
208 + },
209 +
210 + canTest(bot) {
211 + if (!bot) return false;
212 + if (!String(bot.token || "").trim()) return false;
213 + if (bot.mode === "webhook" && !String(bot.webhook_url || "").trim()) return false;
214 + return true;
215 + },
216 +
217 + botStatusLabel(bot) {
218 + if (!String(bot?.token || "").trim()) return "New";
219 + if (bot?.mode === "webhook" && !String(bot?.webhook_url || "").trim()) return "Needs URL";
220 + if (bot?.enabled && this.canTest(bot)) return "Live";
221 + if (this.canTest(bot)) return "Ready";
222 + return "Needs info";
223 + },
224 +
225 + botStatusTone(bot) {
226 + const label = this.botStatusLabel(bot);
227 + if (label === "Live") return "success";
228 + if (label === "Ready") return "ready";
229 + if (label === "New") return "muted";
230 + return "warning";
231 + },
232 +
233 + botTitle(bot, idx) {
234 + return String(bot?.name || "").trim() || `Bot ${idx + 1}`;
235 + },
236 +
237 + botSubtitle(bot) {
238 + const pieces = [bot.mode === "webhook" ? "Webhook" : "Polling"];
239 + pieces.push(Array.isArray(bot.allowed_users) && bot.allowed_users.length > 0 ? "Private access" : "Open access");
240 + if (bot.default_project) pieces.push(`Project: ${bot.default_project}`);
241 + return pieces.join(" · ");
242 },
243
244 whitelistText(bot) {
245 return (bot.allowed_users || []).join(", ");
246 },
247
66 - setWhitelist(bot, val) {
67 - bot.allowed_users = val
248 + setWhitelist(bot, value) {
249 + bot.allowed_users = value
250 .split(",")
69 - .map((s) => s.trim())
70 - .filter((s) => s);
251 + .map((item) => item.trim())
252 + .filter((item) => item);
253 },
254
255 userProjectsText(bot) {
74 - const up = bot.user_projects || {};
75 - return Object.entries(up)
76 - .map(([k, v]) => k + "=" + v)
256 + return Object.entries(bot.user_projects || {})
257 + .map(([userId, project]) => `${userId}=${project}`)
258 .join(", ");
259 },
260
80 - setUserProjects(bot, val) {
81 - const obj = {};
82 - val
261 + setUserProjects(bot, value) {
262 + const mapping = {};
263 + value
264 .split(",")
84 - .map((s) => s.trim())
85 - .filter((s) => s)
265 + .map((item) => item.trim())
266 + .filter((item) => item)
267 .forEach((item) => {
87 - const parts = item.split("=").map((p) => p.trim());
88 - const k = parts[0];
89 - if (k) obj[k] = parts[1] || "";
268 + const [userId, project] = item.split("=").map((part) => part.trim());
269 + if (userId) mapping[userId] = project || "";
270 });
91 - bot.user_projects = obj;
271 + bot.user_projects = mapping;
272 },
273
94 - async testConnection(config, idx) {
274 + accessWarning(bot) {
275 + if (!bot?.enabled) return "";
276 + if (Array.isArray(bot.allowed_users) && bot.allowed_users.length > 0) return "";
277 + return "Allowed users is empty. Anyone who finds this bot can reach your Agent Zero.";
278 + },
279 +
280 + async testConnection(idx) {
281 + const bot = this.bots[idx];
282 + if (!this.canTest(bot)) return;
283 +
284 this.testing = idx;
285 this.testResults = null;
286 + this.testResultsFor = idx;
287 +
288 try {
98 - const { callJsonApi } = await import("/js/api.js");
99 - const res = await callJsonApi(`${API_BASE}/test_connection`, {
100 - bot: config.bots[idx],
101 - });
102 - this.testResults = res;
103 - } catch (e) {
289 + this.testResults = await API.callJsonApi(`${API_BASE}/test_connection`, { bot });
290 + } catch (error) {
291 this.testResults = {
292 success: false,
106 - results: [{ test: "Connection", ok: false, message: String(e) }],
293 + results: [{ test: "Telegram bot", ok: false, message: String(error) }],
294 };
295 }
296 +
297 this.testing = null;
298 },
299 +
300 + testButtonLabel(bot, idx) {
301 + if (this.testing === idx) return "Checking...";
302 + if (this.canTest(bot)) return "Check Telegram connection";
303 + return "Fill in the basics first";
304 + },
305 +
306 + testIntro() {
307 + return "We will validate the bot token with Telegram so you know this bot can connect.";
308 + },
309 +
310 + resultTitle(result) {
311 + return result.test || "Check";
312 + },
313 +
314 + resultMessage(result) {
315 + return result.message || (result.ok ? "Done." : "Something went wrong.");
316 + },
317 +
318 + initialStepForBot(bot) {
319 + return String(bot?.token || "").trim() ? 1 : 0;
320 + },
321 +
322 + _startInitialBotFlow() {
323 + this.addBot();
324 + if (!this.context) return;
325 + const toComparableJson = typeof this.context._toComparableJson === "function"
326 + ? this.context._toComparableJson.bind(this.context)
327 + : JSON.stringify;
328 + this.context.settingsSnapshotJson = toComparableJson(this.context.settings);
329 + },
330 +
331 + _installWizardFooter() {
332 + if (!this.context) return;
333 + this.context.wizardFooter = {
334 + owner: "telegramConfig",
335 + visible: () => this.showFooterNav,
336 + canGoBack: () => !this.isFirstStep,
337 + backLabel: () => "Back",
338 + note: () => this.footerStepLabel,
339 + showNext: () => this.showFooterNav && !this.isLastStep,
340 + nextLabel: () => this.nextButtonLabel,
341 + nextDisabled: () => this.nextDisabled,
342 + showSave: () => this.showFooterNav && this.isLastStep,
343 + onBack: () => this.previousStep(),
344 + onNext: () => this.nextStep(),
345 + };
346 + },
347 });
plugins/_whatsapp_integration/api/test_connection.py
+6 -6
@@ -32,19 +32,19 @@ class TestConnection(ApiHandler):
32
33 if status == "connected":
34 results.append({
35 - "test": "Bridge",
35 + "test": "WhatsApp bridge",
36 "ok": True,
37 - "message": f"Connected (uptime: {uptime:.0f}s, queue: {queue})",
37 + "message": f"Connected and ready (uptime: {uptime:.0f}s, queue: {queue})",
38 })
39 else:
40 results.append({
41 - "test": "Bridge",
41 + "test": "WhatsApp bridge",
42 "ok": False,
43 - "message": f"Bridge running but status: {status}",
43 + "message": f"The bridge is running, but WhatsApp is not fully connected yet (status: {status}).",
44 })
45 except Exception as e:
46 results.append({
47 - "test": "Bridge",
47 + "test": "WhatsApp bridge",
48 "ok": False,
49 - "message": f"Bridge not reachable: {format_error(e)}",
49 + "message": f"Could not reach the local WhatsApp bridge: {format_error(e)}",
50 })
plugins/_whatsapp_integration/webui/config.html
+576 -296
@@ -1,337 +1,617 @@
1 <html>
2 +
3 <head>
4 <title>WhatsApp Integration</title>
5 + <script type="module">
6 + import { store } from "/plugins/_whatsapp_integration/webui/whatsapp-config-store.js";
7 + </script>
8 </head>
9
10 <body>
7 - <div x-data="{
8 - testing: false,
9 - test_results: null,
10 - projects: [],
11 -
12 - qr_visible: false,
13 - qr_status: '',
14 - qr_message: '',
15 - qr_data_url: null,
16 - qr_poll_timer: null,
17 -
18 - disconnecting: false,
19 - disconnect_message: '',
20 -
21 - async init() {
22 - try {
23 - const { callJsonApi } = await import('/js/api.js');
24 - const res = await callJsonApi('projects', { action: 'list' });
25 - this.projects = res.data || [];
26 - } catch (e) { this.projects = []; }
27 - },
28 - allowed_text() {
29 - const value = config?.allowed_numbers;
30 - if (Array.isArray(value)) return value.join(', ');
31 - return typeof value === 'string' ? value : '';
32 - },
33 - allowed_is_empty() {
34 - const value = config?.allowed_numbers;
35 - if (Array.isArray(value)) return value.length === 0;
36 - return !String(value || '').trim();
37 - },
38 - set_allowed(val) {
39 - config.allowed_numbers = val.split(',')
40 - .map(s => s.trim())
41 - .filter(s => s);
42 - },
43 - async test_connection() {
44 - this.testing = true;
45 - this.test_results = null;
46 - try {
47 - const { callJsonApi } = await import('/js/api.js');
48 - const res = await callJsonApi('/plugins/_whatsapp_integration/test_connection', {
49 - config: { bridge_port: config.bridge_port }
50 - });
51 - this.test_results = res;
52 - } catch (e) {
53 - this.test_results = { success: false, results: [{ test: 'Connection', ok: false, message: String(e) }] };
54 - }
55 - this.testing = false;
56 - },
57 -
58 - async show_qr() {
59 - this.qr_visible = true;
60 - this.qr_status = 'loading';
61 - this.qr_message = 'Starting bridge...';
62 - this.qr_data_url = null;
63 - await this.poll_qr();
64 - this.qr_poll_timer = setInterval(() => this.poll_qr(), 3000);
65 - },
66 - hide_qr() {
67 - this.qr_visible = false;
68 - this.qr_data_url = null;
69 - this.qr_status = '';
70 - if (this.qr_poll_timer) {
71 - clearInterval(this.qr_poll_timer);
72 - this.qr_poll_timer = null;
73 - }
74 - },
75 - async poll_qr() {
76 - try {
77 - const { callJsonApi } = await import('/js/api.js');
78 - const res = await callJsonApi('/plugins/_whatsapp_integration/qr_code', {});
79 - this.qr_status = res.status || 'error';
80 - this.qr_message = res.message || '';
81 - this.qr_data_url = res.qr || null;
82 -
83 - if (res.status === 'connected') {
84 - if (this.qr_poll_timer) {
85 - clearInterval(this.qr_poll_timer);
86 - this.qr_poll_timer = null;
87 - }
88 - }
89 - } catch (e) {
90 - this.qr_status = 'error';
91 - this.qr_message = String(e);
92 - this.qr_data_url = null;
93 - }
94 - },
95 - async disconnect_account() {
96 - if (!confirm('Disconnect this WhatsApp account? You will need to scan a new QR code to reconnect.')) return;
97 - this.disconnecting = true;
98 - this.disconnect_message = '';
99 - try {
100 - const { callJsonApi } = await import('/js/api.js');
101 - const res = await callJsonApi('/plugins/_whatsapp_integration/disconnect', {});
102 - this.disconnect_message = res.success ? 'Account disconnected' : (res.message || 'Failed');
103 - } catch (e) {
104 - this.disconnect_message = String(e);
105 - }
106 - this.disconnecting = false;
107 - }
108 - }">
11 + <div x-data x-init="$store.whatsappConfig.init(config, context)" x-destroy="$store.whatsappConfig.cleanup()">
12 <template x-if="config">
110 - <div>
111 - <div class="section-title">WhatsApp Integration</div>
112 -
13 + <div class="wa-page">
14 + <div class="section-title">WhatsApp</div>
15
114 - <div class="field">
115 - <div class="field-label">
116 - <div class="field-title">Enabled</div>
117 - <div class="field-description">Enable WhatsApp bridge and message polling</div>
16 + <template x-if="config.enabled && $store.whatsappConfig.hasMeaningfulConfig()">
17 + <div class="wa-summary-card">
18 + <div class="wa-summary-copy">
19 + <div class="wa-summary-title">Current setup</div>
20 + <div class="wa-summary-subtitle"
21 + x-text="$store.whatsappConfig.modeSummary() + ' · ' + $store.whatsappConfig.projectSummary()">
22 + </div>
23 + </div>
24 + <span class="wa-status-pill" :class="'tone-' + $store.whatsappConfig.statusTone()"
25 + x-text="$store.whatsappConfig.statusLabel()"></span>
26 </div>
119 - <div class="field-control">
120 - <label class="toggle">
121 - <input type="checkbox" x-model="config.enabled" />
122 - <span class="toggler"></span>
123 - </label>
27 + </template>
28 +
29 + <div class="wa-wizard-card">
30 + <div class="wa-step-header">
31 + <div class="wa-step-copy">
32 + <div class="wa-step-title" x-text="$store.whatsappConfig.currentStepMeta().title"></div>
33 + <div class="wa-step-description"
34 + x-text="$store.whatsappConfig.currentStepMeta().description"></div>
35 + </div>
36 + <div class="wa-step-dots" aria-hidden="true">
37 + <template x-for="(step, idx) in $store.whatsappConfig.steps" :key="step.title">
38 + <button type="button" class="wa-step-dot"
39 + :class="{ 'is-active': $store.whatsappConfig.currentStep === idx }"
40 + @click="$store.whatsappConfig.setStep(idx)"></button>
41 + </template>
42 + </div>
43 </div>
125 - </div>
44
127 - <!-- WhatsApp Account (shown when enabled) -->
128 - <template x-if="config.enabled">
129 - <div>
130 - <div class="field">
131 - <div class="field-label">
132 - <div class="field-title">WhatsApp Account</div>
133 - <div class="field-description">
134 - <span x-show="!disconnect_message">Pair or switch your WhatsApp account</span>
135 - <span x-show="disconnect_message" x-text="disconnect_message"
136 - :style="'color:' + (disconnect_message === 'Account disconnected' ? '#4caf50' : '#f44336')"></span>
45 + <template x-if="$store.whatsappConfig.currentStep === 0">
46 + <div class="wa-step-panel">
47 +
48 + <div class="field">
49 + <div class="field-label">
50 + <div class="field-title">Turn on WhatsApp</div>
51 + <div class="field-description">Enable the local bridge to start.</div>
52 + </div>
53 + <div class="field-control">
54 + <label class="toggle">
55 + <input type="checkbox"
56 + x-model="config.enabled"
57 + @change="$store.whatsappConfig.onEnabledChange()" />
58 + <span class="toggler"></span>
59 + </label>
60 </div>
61 </div>
139 - <div class="field-control" style="display: flex; gap: 8px;">
140 - <button class="btn btn-field" @click="show_qr()">
141 - Show QR Code
142 - </button>
143 - <button class="btn btn-field" @click="disconnect_account()" :disabled="disconnecting">
144 - <span x-show="!disconnecting">Disconnect</span>
145 - <span x-show="disconnecting">Disconnecting...</span>
146 - </button>
147 - </div>
148 - </div>
62
150 - <!-- QR Code panel -->
151 - <template x-if="qr_visible">
152 - <div style="margin-top: 8px; padding: 16px; border-radius: 8px;
153 - border: 1px solid var(--border-color, #333);
154 - text-align: center;">
155 -
156 - <!-- Connected state -->
157 - <template x-if="qr_status === 'connected'">
158 - <div>
159 - <div style="font-size: 1.5rem; margin-bottom: 8px;">&#10003;</div>
160 - <div style="font-weight: 500; color: #4caf50;" x-text="qr_message"></div>
161 - <button class="btn btn-field" @click="hide_qr()" style="margin-top: 12px;">
162 - Close
163 - </button>
164 - </div>
165 - </template>
166 -
167 - <!-- QR code ready -->
168 - <template x-if="qr_status === 'waiting_scan' && qr_data_url">
169 - <div>
170 - <div style="font-weight: 500; margin-bottom: 12px;">
171 - Scan with WhatsApp on your phone
63 + <template x-if="config.enabled">
64 + <div>
65 + <div class="field">
66 + <div class="field-label">
67 + <div class="field-title">WhatsApp account</div>
68 + <div class="field-description">
69 + <span x-show="!$store.whatsappConfig.disconnectMessage">Click "Show QR code" to pair your WhatsApp account.</span>
70 + <span x-show="$store.whatsappConfig.disconnectMessage"
71 + x-text="$store.whatsappConfig.disconnectMessage"></span>
72 + </div>
73 </div>
173 - <img :src="qr_data_url" alt="WhatsApp QR Code"
174 - style="width: 256px; height: 256px; border-radius: 8px;
175 - background: white; padding: 4px;" />
176 - <div style="margin-top: 8px; font-size: 0.8rem; opacity: 0.6;">
177 - QR code refreshes automatically
74 + <div class="field-control wa-inline-actions">
75 + <button class="btn btn-field" @click="$store.whatsappConfig.showQr()">
76 + Show QR code
77 + </button>
78 + <button class="btn btn-field" @click="$store.whatsappConfig.disconnectAccount()"
79 + :disabled="$store.whatsappConfig.disconnecting">
80 + <span x-show="!$store.whatsappConfig.disconnecting">Disconnect</span>
81 + <span x-show="$store.whatsappConfig.disconnecting">Disconnecting...</span>
82 + </button>
83 </div>
179 - <button class="btn btn-field" @click="hide_qr()" style="margin-top: 12px;">
180 - Cancel
181 - </button>
84 </div>
183 - </template>
184 -
185 - <!-- Loading / waiting for QR -->
186 - <template x-if="qr_status !== 'connected' && !(qr_status === 'waiting_scan' && qr_data_url)">
187 - <div>
188 - <div style="font-weight: 500; margin-bottom: 8px;" x-text="qr_message || 'Connecting...'"></div>
189 - <div style="font-size: 0.85rem; opacity: 0.6;">
190 - <template x-if="qr_status === 'error'">
191 - <span style="color: #f44336;" x-text="qr_message"></span>
85 +
86 + <template x-if="$store.whatsappConfig.qrVisible">
87 + <div class="wa-qr-panel">
88 + <template x-if="$store.whatsappConfig.qrStatus === 'connected'">
89 + <div>
90 + <div class="wa-qr-status ok">Connected</div>
91 + <div class="wa-qr-message" x-text="$store.whatsappConfig.qrMessage"></div>
92 + <button class="btn btn-field" @click="$store.whatsappConfig.hideQr()"
93 + style="margin-top: 12px;">
94 + Close
95 + </button>
96 + </div>
97 </template>
193 - <template x-if="qr_status !== 'error'">
194 - <span>Please wait...</span>
98 +
99 + <template
100 + x-if="$store.whatsappConfig.qrStatus === 'waiting_scan' && $store.whatsappConfig.qrDataUrl">
101 + <div>
102 + <div class="wa-qr-status">Scan with WhatsApp on your phone</div>
103 + <img :src="$store.whatsappConfig.qrDataUrl" alt="WhatsApp QR Code"
104 + class="wa-qr-image" />
105 + <div class="wa-qr-help">The QR code refreshes automatically.</div>
106 + <button class="btn btn-field" @click="$store.whatsappConfig.hideQr()"
107 + style="margin-top: 12px;">
108 + Cancel
109 + </button>
110 + </div>
111 + </template>
112 +
113 + <template
114 + x-if="$store.whatsappConfig.qrStatus !== 'connected' && !($store.whatsappConfig.qrStatus === 'waiting_scan' && $store.whatsappConfig.qrDataUrl)">
115 + <div>
116 + <div class="wa-qr-status"
117 + x-text="$store.whatsappConfig.qrMessage || 'Connecting...'"></div>
118 + <div class="wa-qr-help"
119 + x-show="$store.whatsappConfig.qrStatus !== 'error'">
120 + Please wait...</div>
121 + <div class="wa-qr-help error"
122 + x-show="$store.whatsappConfig.qrStatus === 'error'"
123 + x-text="$store.whatsappConfig.qrMessage"></div>
124 + <button class="btn btn-field" @click="$store.whatsappConfig.hideQr()"
125 + style="margin-top: 12px;">
126 + Cancel
127 + </button>
128 + </div>
129 </template>
130 </div>
197 - <button class="btn btn-field" @click="hide_qr()" style="margin-top: 12px;">
198 - Cancel
199 - </button>
131 + </template>
132 + </div>
133 + </template>
134 +
135 + <div class="wa-note" x-show="!config.enabled">
136 + Turn on WhatsApp to pair or switch your account.
137 + </div>
138 +
139 + <div class="field">
140 + <div class="field-label">
141 + <div class="field-title">Mode</div>
142 + <div class="field-description">
143 + <span x-show="config.mode === 'self-chat'">
144 + Use your own number. You can message yourself to talk to the agent.
145 + </span>
146 + <span x-show="config.mode !== 'self-chat'">
147 + Use a separate number dedicated to Agent Zero conversations.
148 + </span>
149 </div>
201 - </template>
150 + </div>
151 + <div class="field-control">
152 + <select x-model="config.mode">
153 + <option value="self-chat">Personal number (self-chat)</option>
154 + <option value="dedicated">Separate number (dedicated)</option>
155 + </select>
156 + </div>
157 </div>
203 - </template>
204 - </div>
205 - </template>
158
207 - <div class="field">
208 - <div class="field-label">
209 - <div class="field-title">Mode</div>
210 - <div class="field-description">
211 - <span x-show="config.mode === 'self-chat'">
212 - Use your personal number. You can message yourself to talk to the agent, and the agent can also handle messages that other people send to your number.
213 - </span>
214 - <span x-show="config.mode !== 'self-chat'">
215 - Use a separate WhatsApp number dedicated to Agent Zero conversations.
216 - </span>
217 - </div>
218 - </div>
219 - <div class="field-control">
220 - <select x-model="config.mode">
221 - <option value="self-chat">Personal number (self-chat)</option>
222 - <option value="dedicated">Separate number (dedicated)</option>
223 - </select>
224 - </div>
225 - </div>
159 + <div class="wa-mode-note">
160 + <strong>Good to know:</strong> Self-chat uses your own number. Dedicated is better for
161 + shared or public access. You can pair now or come back to it later.
162 + </div>
163
227 - <template x-if="config.enabled && allowed_is_empty()">
228 - <div style="margin: 8px 0 20px; padding: 12px 14px; border-radius: 10px;
229 - border: 1px solid rgba(255, 170, 0, 0.45);
230 - background: rgba(255, 170, 0, 0.12);
231 - color: var(--color-warning-text);">
232 - <div style="font-weight: 600; display: flex; align-items: center; gap: 8px;">
233 - <span aria-hidden="true">&#9888;</span>
234 - <span>Warning</span>
235 - </div>
236 - <div style="margin-top: 4px; line-height: 1.45;">
237 - Allowed Numbers is empty. If other people can message this WhatsApp number, they can use your Agent Zero.
164 + <div class="wa-warning" x-show="$store.whatsappConfig.accessWarning()">
165 + <div class="wa-warning-title">
166 + <span aria-hidden="true">&#9888;</span>
167 + <span>Warning</span>
168 + </div>
169 + <div class="wa-warning-body" x-text="$store.whatsappConfig.accessWarning()"></div>
170 + </div>
171 +
172 + <div class="field">
173 + <div class="field-label">
174 + <div class="field-title">Allowed numbers</div>
175 + <div class="field-description">Comma-separated phone numbers. Punctuation and +
176 + prefixes are okay. Leave empty only if you want open access.</div>
177 + </div>
178 + <div class="field-control">
179 + <input type="text" :value="$store.whatsappConfig.allowedText()"
180 + @input="$store.whatsappConfig.setAllowed($event.target.value)"
181 + placeholder="+1 (415) 555-1234, +44 7911 123456" />
182 + </div>
183 + </div>
184 +
185 + <div class="field">
186 + <div class="field-label">
187 + <div class="field-title">Allow groups</div>
188 + <div class="field-description">Reply in group chats when mentioned or replied to.
189 + </div>
190 + </div>
191 + <div class="field-control">
192 + <label class="toggle">
193 + <input type="checkbox" x-model="config.allow_group" />
194 + <span class="toggler"></span>
195 + </label>
196 + </div>
197 + </div>
198 </div>
239 - </div>
240 - </template>
199 + </template>
200
242 - <div class="field">
243 - <div class="field-label">
244 - <div class="field-title">Allowed Numbers</div>
245 - <div class="field-description">Comma-separated phone numbers. Matching is normalized by the backend, so punctuation and + prefixes are okay. Empty = allow all.</div>
246 - </div>
247 - <div class="field-control">
248 - <input type="text" :value="allowed_text()" @change="set_allowed($event.target.value)" placeholder="+1 (415) 555-1234, +44 7911 123456" />
249 - </div>
250 - </div>
201 + <template x-if="$store.whatsappConfig.currentStep === 1">
202 + <div class="wa-step-panel">
203 + <div class="field">
204 + <div class="field-label">
205 + <div class="field-title">Project</div>
206 + <div class="field-description">Optional project to activate for WhatsApp
207 + conversations.</div>
208 + </div>
209 + <div class="field-control">
210 + <select :value="config.project" @change="config.project = $event.target.value">
211 + <option value="">No project</option>
212 + <template x-for="proj in $store.whatsappConfig.projects" :key="proj.name">
213 + <option :value="proj.name" x-text="proj.title || proj.name"
214 + :selected="config.project === proj.name"></option>
215 + </template>
216 + </select>
217 + </div>
218 + </div>
219
252 - <div class="field">
253 - <div class="field-label">
254 - <div class="field-title">Allow Group</div>
255 - <div class="field-description">Respond in group chats when mentioned or replied to</div>
256 - </div>
257 - <div class="field-control">
258 - <label class="toggle">
259 - <input type="checkbox" x-model="config.allow_group" />
260 - <span class="toggler"></span>
261 - </label>
262 - </div>
263 - </div>
220 + <div class="field">
221 + <div class="field-label">
222 + <div class="field-title">Agent instructions</div>
223 + <div class="field-description">Extra guidance for how the agent should reply in
224 + WhatsApp chats.</div>
225 + </div>
226 + <div class="field-control">
227 + <textarea x-model="config.agent_instructions" rows="3"
228 + placeholder="Reply briefly and naturally, like a mobile conversation."></textarea>
229 + </div>
230 + </div>
231 + </div>
232 + </template>
233
265 - <div class="field">
266 - <div class="field-label">
267 - <div class="field-title">Project</div>
268 - <div class="field-description">Project to activate for WhatsApp chats</div>
269 - </div>
270 - <div class="field-control">
271 - <select :value="config.project" @change="config.project = $event.target.value">
272 - <option value="">No project</option>
273 - <template x-for="proj in projects" :key="proj.name">
274 - <option :value="proj.name" x-text="proj.title || proj.name" :selected="config.project === proj.name"></option>
275 - </template>
276 - </select>
234 + <div class="wa-test-panel">
235 + <div class="wa-test-copy">
236 + <div class="wa-test-title">Check the connection</div>
237 + <div class="wa-test-description">We will check whether the local WhatsApp bridge is up and
238 + connected.</div>
239 + </div>
240 + <button class="btn btn-field" @click="$store.whatsappConfig.testConnection()"
241 + :disabled="$store.whatsappConfig.testing">
242 + <span x-text="$store.whatsappConfig.testButtonLabel()"></span>
243 + </button>
244 </div>
278 - </div>
245
280 - <div class="field">
281 - <div class="field-label">
282 - <div class="field-title">Agent Instructions</div>
283 - <div class="field-description">Extra instructions for the agent in WhatsApp chats</div>
284 - </div>
285 - <div class="field-control">
286 - <textarea x-model="config.agent_instructions" rows="3" placeholder="e.g. Always respond concisely..."></textarea>
287 - </div>
288 - </div>
246 + <template x-if="$store.whatsappConfig.testResults">
247 + <div class="wa-results" :class="{ 'is-error': !$store.whatsappConfig.testResults.success }">
248 + <template x-for="result in $store.whatsappConfig.testResults.results"
249 + :key="result.test + result.message">
250 + <div class="wa-result-row">
251 + <span class="wa-result-icon" x-text="result.ok ? '✓' : '✗'"></span>
252 + <div class="wa-result-copy">
253 + <div class="wa-result-title" x-text="result.test"></div>
254 + <div class="wa-result-message" x-text="result.message"></div>
255 + </div>
256 + </div>
257 + </template>
258 + </div>
259 + </template>
260
290 - <div class="field">
291 - <div class="field-label">
292 - <div class="field-title">Bridge Port</div>
293 - <div class="field-description">Local port for the WhatsApp bridge HTTP server</div>
294 - </div>
295 - <div class="field-control">
296 - <input type="number" x-model.number="config.bridge_port" placeholder="3100" />
297 - </div>
298 - </div>
261 + <template x-if="$store.whatsappConfig.currentStep > 0">
262 + <details class="wa-advanced">
263 + <summary>
264 + <span>Advanced</span>
265 + <span class="material-symbols-outlined wa-advanced-chevron"
266 + aria-hidden="true">keyboard_arrow_down</span>
267 + </summary>
268 + <div class="wa-advanced-body">
269 + <div class="wa-info-box">
270 + These settings are here when you need them, but the defaults are fine for most
271 + setups.
272 + </div>
273
300 - <div class="field">
301 - <div class="field-label">
302 - <div class="field-title">Poll Interval (seconds)</div>
303 - <div class="field-description">How often to check for new messages (minimum 2)</div>
304 - </div>
305 - <div class="field-control">
306 - <input type="number" x-model.number="config.poll_interval_seconds" min="2" placeholder="3" />
307 - </div>
308 - </div>
274 + <div class="field">
275 + <div class="field-label">
276 + <div class="field-title">Bridge port</div>
277 + <div class="field-description">Local port for the WhatsApp bridge HTTP server.
278 + </div>
279 + </div>
280 + <div class="field-control">
281 + <input type="number" x-model.number="config.bridge_port" placeholder="3100" />
282 + </div>
283 + </div>
284
310 - <!-- Test connection -->
311 - <div style="margin-top: 16px; display: flex; align-items: center; gap: 12px;">
312 - <button class="btn btn-field" @click="test_connection()" :disabled="testing">
313 - <span x-show="!testing">Test Connection</span>
314 - <span x-show="testing">Testing...</span>
315 - </button>
316 - </div>
285 + <div class="field">
286 + <div class="field-label">
287 + <div class="field-title">Poll interval (seconds)</div>
288 + <div class="field-description">How often to check for new messages. The minimum
289 + is 2 seconds.</div>
290 + </div>
291 + <div class="field-control">
292 + <input type="number" x-model.number="config.poll_interval_seconds" min="2"
293 + placeholder="3" />
294 + </div>
295 + </div>
296
318 - <!-- Test results -->
319 - <template x-if="test_results">
320 - <div style="margin-top: 8px; padding: 8px 12px; border-radius: 6px; font-size: 0.85rem;
321 - border: 1px solid var(--border-color, #333);">
322 - <template x-for="r in test_results.results" :key="r.test">
323 - <div style="display: flex; align-items: center; gap: 8px; padding: 4px 0;">
324 - <span x-text="r.ok ? '✓' : '✗'"
325 - :style="'font-weight: bold; color:' + (r.ok ? '#4caf50' : '#f44336')"></span>
326 - <span style="font-weight: 500; min-width: 50px;" x-text="r.test"></span>
327 - <span style="opacity: 0.8;" x-text="r.message"></span>
297 </div>
329 - </template>
330 - </div>
331 - </template>
298 + </details>
299 + </template>
300 + </div>
301 </div>
302 </template>
303 </div>
304 +
305 + <style>
306 + .wa-page {
307 + display: flex;
308 + flex-direction: column;
309 + gap: 0.9rem;
310 + }
311 +
312 + .wa-summary-card,
313 + .wa-wizard-card {
314 + border-radius: 0.5rem;
315 + }
316 +
317 + .wa-advanced summary {
318 + cursor: pointer;
319 + list-style: none;
320 + font-weight: 700;
321 + display: flex;
322 + align-items: center;
323 + gap: 0.75rem;
324 + }
325 +
326 + .wa-advanced summary::-webkit-details-marker {
327 + display: none;
328 + }
329 +
330 + .wa-summary-title,
331 + .wa-step-title,
332 + .wa-test-title {
333 + font-weight: 700;
334 + }
335 +
336 + .wa-summary-subtitle,
337 + .wa-step-description,
338 + .wa-note,
339 + .wa-mode-note,
340 + .wa-test-description,
341 + .wa-result-message {
342 + color: var(--color-text-secondary);
343 + line-height: 1.5;
344 + }
345 +
346 + .wa-summary-card {
347 + padding: 1rem;
348 + border: 1px solid var(--color-border);
349 + display: flex;
350 + justify-content: space-between;
351 + gap: 1rem;
352 + align-items: center;
353 + }
354 +
355 + .wa-step-header {
356 + display: flex;
357 + justify-content: space-between;
358 + gap: 1rem;
359 + align-items: flex-start;
360 + margin-bottom: 1rem;
361 + }
362 +
363 + .wa-step-dots {
364 + display: flex;
365 + gap: 0.5rem;
366 + flex-wrap: wrap;
367 + justify-content: flex-end;
368 + align-items: center;
369 + }
370 +
371 + .wa-step-dot {
372 + width: 0.85rem;
373 + height: 0.85rem;
374 + border-radius: 999px;
375 + border: 1px solid var(--color-border);
376 + background: transparent;
377 + cursor: pointer;
378 + padding: 0;
379 + }
380 +
381 + .wa-step-dot.is-active {
382 + background: rgba(59, 130, 246, 0.9);
383 + border-color: rgba(59, 130, 246, 0.9);
384 + }
385 +
386 + .wa-step-panel {
387 + display: flex;
388 + flex-direction: column;
389 + gap: 0.2rem;
390 + }
391 +
392 + .wa-info-box,
393 + .wa-note {
394 + margin-bottom: 0.9rem;
395 + padding: 0.8rem 0.9rem;
396 + border-radius: 12px;
397 + font-size: var(--font-size-small);
398 + }
399 +
400 + .wa-info-box {
401 + background: color-mix(in srgb, #3b82f6 12%, var(--color-background) 88%);
402 + }
403 +
404 + .wa-note {
405 + background: color-mix(in srgb, var(--color-background) 88%, white 12%);
406 + }
407 +
408 + .wa-warning {
409 + margin: 0.5rem 0 1.25rem;
410 + padding: 0.75rem 0.9rem;
411 + border-radius: 10px;
412 + border: 1px solid rgba(255, 170, 0, 0.45);
413 + background: rgba(255, 170, 0, 0.12);
414 + color: var(--color-warning-text);
415 + font-size: var(--font-size-small);
416 + }
417 +
418 + .wa-warning-title {
419 + font-weight: 600;
420 + display: flex;
421 + align-items: center;
422 + gap: 0.5rem;
423 + }
424 +
425 + .wa-warning-body {
426 + margin-top: 0.25rem;
427 + line-height: 1.45;
428 + }
429 +
430 + .wa-mode-note {
431 + margin-top: -0.2rem;
432 + margin-bottom: 0.9rem;
433 + font-size: var(--font-size-small);
434 + }
435 +
436 + .wa-inline-actions {
437 + display: flex;
438 + gap: 0.75rem;
439 + flex-wrap: wrap;
440 + }
441 +
442 + .wa-qr-panel {
443 + margin-top: 0.25rem;
444 + padding: 1rem;
445 + border-radius: 14px;
446 + border: 1px solid color-mix(in srgb, var(--color-border) 88%, white 12%);
447 + background: color-mix(in srgb, var(--color-background) 90%, white 10%);
448 + text-align: center;
449 + }
450 +
451 + .wa-qr-status {
452 + font-weight: 700;
453 + margin-bottom: 0.5rem;
454 + }
455 +
456 + .wa-qr-status.ok {
457 + color: #7ee7a4;
458 + }
459 +
460 + .wa-qr-message,
461 + .wa-qr-help {
462 + color: var(--color-text-secondary);
463 + line-height: 1.5;
464 + }
465 +
466 + .wa-qr-help {
467 + font-size: var(--font-size-small);
468 + margin-top: 0.4rem;
469 + }
470 +
471 + .wa-qr-help.error {
472 + color: #fca5a5;
473 + }
474 +
475 + .wa-qr-image {
476 + width: 256px;
477 + height: 256px;
478 + max-width: 100%;
479 + border-radius: 8px;
480 + background: white;
481 + padding: 4px;
482 + }
483 +
484 + .wa-advanced {
485 + margin-top: 1rem;
486 + border: 1px solid color-mix(in srgb, var(--color-border) 88%, white 12%);
487 + border-radius: 14px;
488 + overflow: hidden;
489 + }
490 +
491 + .wa-advanced summary {
492 + padding: 0.9rem 1rem;
493 + background: color-mix(in srgb, var(--color-background) 88%, white 12%);
494 + }
495 +
496 + .wa-advanced-chevron {
497 + margin-left: auto;
498 + flex: 0 0 auto;
499 + transition: transform 0.18s ease, opacity 0.18s ease;
500 + opacity: 0.72;
501 + }
502 +
503 + .wa-advanced[open] .wa-advanced-chevron {
504 + transform: rotate(180deg);
505 + opacity: 1;
506 + }
507 +
508 + .wa-advanced-body {
509 + padding: 1rem;
510 + display: flex;
511 + flex-direction: column;
512 + gap: 0.2rem;
513 + }
514 +
515 + .wa-test-panel {
516 + margin-top: 1rem;
517 + padding: var(--spacing-xs) 0;
518 + display: flex;
519 + justify-content: space-between;
520 + align-items: center;
521 + gap: 1rem;
522 + }
523 +
524 + .wa-test-copy,
525 + .wa-result-copy {
526 + min-width: 0;
527 + }
528 +
529 + .wa-results {
530 + margin-top: 0.9rem;
531 + border-radius: 14px;
532 + border: 1px solid color-mix(in srgb, #22c55e 28%, var(--color-border) 72%);
533 + background: color-mix(in srgb, #22c55e 8%, var(--color-background) 92%);
534 + padding: 0.35rem 0.9rem;
535 + }
536 +
537 + .wa-results.is-error {
538 + border-color: color-mix(in srgb, #ef4444 28%, var(--color-border) 72%);
539 + background: color-mix(in srgb, #ef4444 8%, var(--color-background) 92%);
540 + }
541 +
542 + .wa-result-row {
543 + display: flex;
544 + align-items: flex-start;
545 + gap: 0.8rem;
546 + padding: 0.7rem 0;
547 + }
548 +
549 + .wa-result-row+.wa-result-row {
550 + border-top: 1px solid color-mix(in srgb, var(--color-border) 85%, white 15%);
551 + }
552 +
553 + .wa-result-icon {
554 + width: 1.25rem;
555 + font-weight: 800;
556 + line-height: 1.3;
557 + }
558 +
559 + .wa-result-title {
560 + font-weight: 700;
561 + margin-bottom: 0.18rem;
562 + }
563 +
564 + .wa-status-pill {
565 + display: inline-flex;
566 + align-items: center;
567 + justify-content: center;
568 + min-width: 5.25rem;
569 + padding: 0.35rem 0.7rem;
570 + border-radius: 999px;
571 + font-size: 0.8rem;
572 + font-weight: 700;
573 + }
574 +
575 + .wa-status-pill.tone-success {
576 + background: rgba(34, 197, 94, 0.14);
577 + color: #7ee7a4;
578 + }
579 +
580 + .wa-status-pill.tone-ready {
581 + background: rgba(59, 130, 246, 0.16);
582 + color: #93c5fd;
583 + }
584 +
585 + .wa-status-pill.tone-warning {
586 + background: rgba(245, 158, 11, 0.16);
587 + color: #fcd34d;
588 + }
589 +
590 + .wa-status-pill.tone-muted {
591 + background: rgba(148, 163, 184, 0.14);
592 + color: #cbd5e1;
593 + }
594 +
595 + @media (max-width: 640px) {
596 +
597 + .wa-summary-card,
598 + .wa-step-header,
599 + .wa-test-panel {
600 + flex-direction: column;
601 + align-items: stretch;
602 + }
603 +
604 + .wa-step-dots {
605 + width: 100%;
606 + justify-content: space-between;
607 + }
608 +
609 + .wa-status-pill {
610 + min-width: 0;
611 + align-self: flex-start;
612 + }
613 + }
614 + </style>
615 </body>
616
617 </html>
plugins/_whatsapp_integration/webui/whatsapp-config-store.js new
+273
@@ -0,0 +1,273 @@
1 +import { createStore } from "/js/AlpineStore.js";
2 +import * as API from "/js/api.js";
3 +
4 +const API_BASE = "/plugins/_whatsapp_integration";
5 +const STEPS = [
6 + {
7 + title: "Pair your account and set access",
8 + description: "Turn on WhatsApp, connect the account, and choose who can reach it.",
9 + },
10 + {
11 + title: "Choose where conversations go",
12 + description: "Pick a project if you want one and shape how the agent should reply.",
13 + },
14 +];
15 +
16 +function ensureConfig(config) {
17 + if (!config || typeof config !== "object") return;
18 + if (typeof config.enabled !== "boolean") config.enabled = false;
19 + if (!config.mode) config.mode = "self-chat";
20 + if (!config.bridge_port) config.bridge_port = 3100;
21 + if (!config.poll_interval_seconds) config.poll_interval_seconds = 3;
22 + if (typeof config.allow_group !== "boolean") config.allow_group = false;
23 +
24 + if (Array.isArray(config.allowed_numbers)) return;
25 + if (typeof config.allowed_numbers === "string") {
26 + config.allowed_numbers = config.allowed_numbers
27 + .split(",")
28 + .map((item) => item.trim())
29 + .filter((item) => item);
30 + return;
31 + }
32 + config.allowed_numbers = [];
33 +}
34 +
35 +export const store = createStore("whatsappConfig", {
36 + config: null,
37 + projects: [],
38 + guideOpen: false,
39 + currentStep: 0,
40 + testing: false,
41 + testResults: null,
42 + qrVisible: false,
43 + qrStatus: "",
44 + qrMessage: "",
45 + qrDataUrl: null,
46 + qrPollTimer: null,
47 + disconnecting: false,
48 + disconnectMessage: "",
49 + steps: STEPS,
50 + _projectsLoaded: false,
51 + context: null,
52 +
53 + get showFooterNav() {
54 + return true;
55 + },
56 +
57 + get isFirstStep() {
58 + return this.currentStep === 0;
59 + },
60 +
61 + get isLastStep() {
62 + return this.currentStep >= this.steps.length - 1;
63 + },
64 +
65 + get nextButtonLabel() {
66 + return this.isLastStep ? "Done" : "Next";
67 + },
68 +
69 + get footerStepLabel() {
70 + return `Step ${this.currentStep + 1} of ${this.steps.length}`;
71 + },
72 +
73 + async init(config, context = null) {
74 + this.config = config || null;
75 + this.context = context;
76 + ensureConfig(this.config);
77 + this.guideOpen = !this.hasMeaningfulConfig() && window.innerWidth > 720;
78 + this.currentStep = 0;
79 + this.testing = false;
80 + this.testResults = null;
81 + this._installWizardFooter();
82 +
83 + if (this._projectsLoaded) return;
84 + try {
85 + const response = await API.callJsonApi("projects", { action: "list" });
86 + this.projects = response.data || [];
87 + } catch (_) {
88 + this.projects = [];
89 + }
90 + this._projectsLoaded = true;
91 + },
92 +
93 + cleanup() {
94 + if (this.context?.wizardFooter?.owner === "whatsappConfig") {
95 + this.context.wizardFooter = null;
96 + }
97 + this.hideQr();
98 + this.config = null;
99 + this.context = null;
100 + this.guideOpen = false;
101 + this.currentStep = 0;
102 + this.testing = false;
103 + this.testResults = null;
104 + this.disconnecting = false;
105 + this.disconnectMessage = "";
106 + },
107 +
108 + currentStepMeta() {
109 + return this.steps[this.currentStep] || this.steps[0];
110 + },
111 +
112 + setStep(step) {
113 + this.currentStep = Math.max(0, Math.min(this.steps.length - 1, Number(step) || 0));
114 + },
115 +
116 + nextStep() {
117 + if (!this.isLastStep) this.currentStep += 1;
118 + },
119 +
120 + previousStep() {
121 + if (!this.isFirstStep) this.currentStep -= 1;
122 + },
123 +
124 + hasMeaningfulConfig() {
125 + if (!this.config) return false;
126 + return !!(
127 + this.config.enabled
128 + || String(this.config.project || "").trim()
129 + || String(this.config.agent_instructions || "").trim()
130 + || (Array.isArray(this.config.allowed_numbers) && this.config.allowed_numbers.length > 0)
131 + );
132 + },
133 +
134 + allowedText() {
135 + ensureConfig(this.config);
136 + return (this.config?.allowed_numbers || []).join(", ");
137 + },
138 +
139 + allowedIsEmpty() {
140 + ensureConfig(this.config);
141 + return (this.config?.allowed_numbers || []).length === 0;
142 + },
143 +
144 + setAllowed(value) {
145 + ensureConfig(this.config);
146 + this.config.allowed_numbers = value
147 + .split(",")
148 + .map((item) => item.trim())
149 + .filter((item) => item);
150 + },
151 +
152 + onEnabledChange() {
153 + if (this.config?.enabled) return;
154 + this.hideQr();
155 + },
156 +
157 + accessWarning() {
158 + if (!this.config?.enabled) return "";
159 + if (!this.allowedIsEmpty()) return "";
160 + return "Allowed numbers is empty. If other people can message this number, they can reach your Agent Zero.";
161 + },
162 +
163 + statusLabel() {
164 + if (!this.config?.enabled) return "Off";
165 + if (this.qrStatus === "connected") return "Live";
166 + if (this.allowedIsEmpty()) return "Open access";
167 + return "Ready";
168 + },
169 +
170 + statusTone() {
171 + const label = this.statusLabel();
172 + if (label === "Live") return "success";
173 + if (label === "Ready") return "ready";
174 + if (label === "Off") return "muted";
175 + return "warning";
176 + },
177 +
178 + modeSummary() {
179 + if (!this.config) return "";
180 + return this.config.mode === "self-chat" ? "Self-chat" : "Dedicated number";
181 + },
182 +
183 + projectSummary() {
184 + return this.config?.project ? `Project: ${this.config.project}` : "No project";
185 + },
186 +
187 + async testConnection() {
188 + this.testing = true;
189 + this.testResults = null;
190 + try {
191 + this.testResults = await API.callJsonApi(`${API_BASE}/test_connection`, {
192 + config: { bridge_port: this.config?.bridge_port },
193 + });
194 + } catch (error) {
195 + this.testResults = {
196 + success: false,
197 + results: [{ test: "WhatsApp", ok: false, message: String(error) }],
198 + };
199 + }
200 + this.testing = false;
201 + },
202 +
203 + testButtonLabel() {
204 + return this.testing ? "Checking..." : "Check WhatsApp connection";
205 + },
206 +
207 + async showQr() {
208 + this.qrVisible = true;
209 + this.qrStatus = "loading";
210 + this.qrMessage = "Starting the WhatsApp bridge...";
211 + this.qrDataUrl = null;
212 + await this.pollQr();
213 + this.qrPollTimer = setInterval(() => this.pollQr(), 3000);
214 + },
215 +
216 + hideQr() {
217 + this.qrVisible = false;
218 + this.qrDataUrl = null;
219 + this.qrStatus = "";
220 + if (this.qrPollTimer) {
221 + clearInterval(this.qrPollTimer);
222 + this.qrPollTimer = null;
223 + }
224 + },
225 +
226 + async pollQr() {
227 + try {
228 + const response = await API.callJsonApi(`${API_BASE}/qr_code`, {});
229 + this.qrStatus = response.status || "error";
230 + this.qrMessage = response.message || "";
231 + this.qrDataUrl = response.qr || null;
232 +
233 + if (response.status === "connected" && this.qrPollTimer) {
234 + clearInterval(this.qrPollTimer);
235 + this.qrPollTimer = null;
236 + }
237 + } catch (error) {
238 + this.qrStatus = "error";
239 + this.qrMessage = String(error);
240 + this.qrDataUrl = null;
241 + }
242 + },
243 +
244 + async disconnectAccount() {
245 + if (!window.confirm("Disconnect this WhatsApp account? You will need to scan a new QR code to reconnect.")) return;
246 + this.disconnecting = true;
247 + this.disconnectMessage = "";
248 + try {
249 + const response = await API.callJsonApi(`${API_BASE}/disconnect`, {});
250 + this.disconnectMessage = response.success ? "Account disconnected" : (response.message || "Disconnect failed");
251 + } catch (error) {
252 + this.disconnectMessage = String(error);
253 + }
254 + this.disconnecting = false;
255 + },
256 +
257 + _installWizardFooter() {
258 + if (!this.context) return;
259 + this.context.wizardFooter = {
260 + owner: "whatsappConfig",
261 + visible: () => this.showFooterNav,
262 + canGoBack: () => !this.isFirstStep,
263 + backLabel: () => "Back",
264 + note: () => this.footerStepLabel,
265 + showNext: () => !this.isLastStep,
266 + nextLabel: () => this.nextButtonLabel,
267 + nextDisabled: () => false,
268 + showSave: () => this.isLastStep,
269 + onBack: () => this.previousStep(),
270 + onNext: () => this.nextStep(),
271 + };
272 + },
273 +});
webui/components/plugins/plugin-settings-store.js
+3
@@ -19,6 +19,7 @@ const model = {
19
20 // plugin settings data (plugins bind their fields here)
21 settings: {},
22 + wizardFooter: null,
23
24 settingsSnapshotJson: "",
25 previousProjectName: "",
@@ -80,6 +81,7 @@ const model = {
81 this.pluginMeta = pluginMeta || null;
82 this.settings = {};
83 this.settingsSnapshotJson = "";
84 + this.wizardFooter = null;
85 this.error = null;
86 this.projectName = projectName;
87 this.agentProfileKey = agentProfileKey;
@@ -373,6 +375,7 @@ const model = {
375 this.agentProfileKey = "";
376 this.settings = {};
377 this.settingsSnapshotJson = "";
378 + this.wizardFooter = null;
379 this.previousProjectName = "";
380 this.previousAgentProfileKey = "";
381 this.loadedPath = "";
webui/components/plugins/plugin-settings.html
+84 -15
@@ -114,21 +114,42 @@
114 </div>
115
116 <!-- Footer (pinned outside scroll area) -->
117 - <div class="modal-footer" data-modal-footer>
118 - <button class="btn btn-ok"
119 - @click="context.save()"
120 - :disabled="context?.isSaving || context?.isLoading">
121 - Save
122 - </button>
123 - <button class="btn"
124 - @click="context.resetToDefault()"
125 - :disabled="context?.isSaving || context?.isLoading">
126 - Default
127 - </button>
128 - <button class="btn btn-cancel"
129 - @click="window.closeModal?.()">
130 - Cancel
131 - </button>
117 + <div class="modal-footer plugin-settings-footer" data-modal-footer>
118 + <div class="plugin-settings-footer-nav"
119 + x-show="context.wizardFooter?.visible?.()">
120 + <div class="plugin-settings-footer-nav-row">
121 + <button class="btn"
122 + @click="context.wizardFooter?.onBack?.()"
123 + :disabled="!context.wizardFooter?.canGoBack?.()">
124 + <span x-text="context.wizardFooter?.backLabel?.() || 'Back'"></span>
125 + </button>
126 + <div class="plugin-settings-footer-note" x-text="context.wizardFooter?.note?.() || ''"></div>
127 + </div>
128 + </div>
129 +
130 + <div class="plugin-settings-footer-actions">
131 + <button class="btn"
132 + @click="context.resetToDefault()"
133 + :disabled="context?.isSaving || context?.isLoading">
134 + Default
135 + </button>
136 + <button class="btn btn-cancel"
137 + @click="window.closeModal?.()">
138 + Cancel
139 + </button>
140 + <button class="btn btn-field"
141 + x-show="context.wizardFooter?.showNext?.()"
142 + @click="context.wizardFooter?.onNext?.()"
143 + :disabled="context.wizardFooter?.nextDisabled?.()">
144 + <span x-text="context.wizardFooter?.nextLabel?.() || 'Next'"></span>
145 + </button>
146 + <button class="btn btn-ok"
147 + x-show="!context.wizardFooter || context.wizardFooter?.showSave?.()"
148 + @click="context.save()"
149 + :disabled="context?.isSaving || context?.isLoading">
150 + Save
151 + </button>
152 + </div>
153 </div>
154 </div>
155 </template>
@@ -213,6 +234,39 @@
234 min-height: 4rem;
235 }
236
237 + .plugin-settings-footer {
238 + justify-content: space-between;
239 + gap: 1rem;
240 + flex-wrap: wrap;
241 + }
242 +
243 + .plugin-settings-footer-nav {
244 + flex: 1 1 22rem;
245 + min-width: 18rem;
246 + }
247 +
248 + .plugin-settings-footer-nav-row {
249 + display: flex;
250 + align-items: center;
251 + gap: 0.75rem;
252 + width: 100%;
253 + }
254 +
255 + .plugin-settings-footer-note {
256 + color: var(--color-text-secondary);
257 + font-size: var(--font-size-small);
258 + line-height: 1.4;
259 + flex: 1 1 auto;
260 + min-width: 0;
261 + }
262 +
263 + .plugin-settings-footer-actions {
264 + display: flex;
265 + align-items: center;
266 + gap: 1rem;
267 + margin-left: auto;
268 + }
269 +
270 .spinning {
271 animation: spin 1s linear infinite;
272 }
@@ -282,6 +336,21 @@
336 justify-content: flex-start;
337 white-space: normal;
338 }
339 +
340 + .plugin-settings-footer-nav {
341 + width: 100%;
342 + min-width: 0;
343 + }
344 +
345 + .plugin-settings-footer-nav-row {
346 + flex-wrap: wrap;
347 + }
348 +
349 + .plugin-settings-footer-actions {
350 + width: 100%;
351 + justify-content: flex-end;
352 + margin-left: 0;
353 + }
354 }
355 </style>
356 </body>
webui/css/settings.css
+1 -1
@@ -3,7 +3,7 @@
3 /* Field Styles */
4 .field {
5 display: grid;
6 - grid-template-columns: 60% 1fr;
6 + grid-template-columns: 55% 1fr;
7 align-items: center;
8 margin-block: 1rem;
9 padding: var(--spacing-xs) 0;