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
This commit is contained in:
Alessandro 2026-04-10 11:08:29 +02:00
parent c06e13f8c2
commit 2000ba74a3
13 changed files with 3040 additions and 1066 deletions

View file

@ -44,7 +44,9 @@ class DiscoveryCardsExtension(Extension):
})
# 3. Email
if not email_config.get("imap_username") and not email_config.get("smtp_username"):
email_handlers = email_config.get("handlers") or []
email_is_configured = any((handler or {}).get("username") for handler in email_handlers)
if not email_is_configured:
banners.append({
"id": "discovery-email",
"type": "feature",
@ -52,7 +54,7 @@ class DiscoveryCardsExtension(Extension):
"description": "Let Agent Zero read and send emails on your behalf.",
"thumbnail": "/plugins/_discovery/webui/assets/thumb-email.png",
"icon": "mail",
"cta_text": "Setup",
"cta_text": "Open Setup",
"cta_action": "open-plugin-config:_email_integration",
"dismissible": True,
"priority": 50,
@ -74,4 +76,3 @@ class DiscoveryCardsExtension(Extension):
"priority": 50,
"show_in_onboarding": True
})

View file

@ -1,9 +1,12 @@
"""Test IMAP/SMTP connection for an email handler config."""
"""Test inbound and outbound email connectivity for a handler config."""
import asyncio
from helpers.api import ApiHandler, Request
from helpers.errors import format_error
from plugins._email_integration.helpers.imap_client import (
connect_exchange,
connect_imap,
disconnect_imap,
get_highest_uid,
@ -18,7 +21,9 @@ class TestConnection(ApiHandler):
results: list[dict] = []
account_type = handler.get("account_type", "imap")
if account_type == "imap":
if account_type == "exchange":
await self._test_exchange(handler, results)
else:
await self._test_imap(handler, results)
await self._test_smtp(handler, results)
if all(r["ok"] for r in results):
@ -35,18 +40,39 @@ class TestConnection(ApiHandler):
username=handler.get("username", ""),
password=handler.get("password", ""),
)
uid = await get_highest_uid(client)
await get_highest_uid(client)
await disconnect_imap(client)
results.append({
"test": "IMAP",
"test": "Incoming",
"ok": True,
"message": f"Connected, highest UID: {uid}",
"message": "Incoming mail looks good.",
})
except Exception as e:
results.append({
"test": "IMAP",
"test": "Incoming",
"ok": False,
"message": format_error(e),
"message": f"Could not reach the inbox: {format_error(e)}",
})
async def _test_exchange(self, handler: dict, results: list[dict]):
try:
account = await connect_exchange(
server=handler.get("imap_server", ""),
username=handler.get("username", ""),
password=handler.get("password", ""),
)
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: account.inbox.total_count)
results.append({
"test": "Incoming",
"ok": True,
"message": "Exchange inbox looks good.",
})
except Exception as e:
results.append({
"test": "Incoming",
"ok": False,
"message": f"Could not reach the Exchange inbox: {format_error(e)}",
})
def _smtp_config(self, handler: dict) -> SmtpConfig:
@ -62,18 +88,22 @@ class TestConnection(ApiHandler):
try:
error = await test_smtp(self._smtp_config(handler))
if error:
results.append({"test": "SMTP", "ok": False, "message": error})
results.append({
"test": "Outgoing",
"ok": False,
"message": f"Could not sign in for sending mail: {error}",
})
else:
results.append({
"test": "SMTP",
"test": "Outgoing",
"ok": True,
"message": "Authenticated successfully",
"message": "Outgoing mail looks good.",
})
except Exception as e:
results.append({
"test": "SMTP",
"test": "Outgoing",
"ok": False,
"message": format_error(e),
"message": f"Could not sign in for sending mail: {format_error(e)}",
})
async def _test_send(self, handler: dict, results: list[dict]):
@ -85,16 +115,20 @@ class TestConnection(ApiHandler):
body="This is a test email from Agent Zero email integration.",
)
if error:
results.append({"test": "Send", "ok": False, "message": error})
results.append({
"test": "Send test email",
"ok": False,
"message": f"Could not send the test email: {error}",
})
else:
results.append({
"test": "Send",
"test": "Send test email",
"ok": True,
"message": "Test email sent to self",
"message": "Test email sent to this inbox.",
})
except Exception as e:
results.append({
"test": "Send",
"test": "Send test email",
"ok": False,
"message": format_error(e),
"message": f"Could not send the test email: {format_error(e)}",
})

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,404 @@
import { createStore } from "/js/AlpineStore.js";
import * as API from "/js/api.js";
const API_BASE = "/plugins/_email_integration";
const GMAIL_APP_PASSWORDS_URL = "https://support.google.com/mail/answer/185833?hl=en";
const PRESETS = {
"": {
label: "Choose a provider",
account_type: "imap",
imap_server: "",
imap_port: 993,
smtp_server: "",
smtp_port: 587,
},
gmail: {
label: "Gmail",
account_type: "imap",
imap_server: "imap.gmail.com",
imap_port: 993,
smtp_server: "smtp.gmail.com",
smtp_port: 587,
},
icloud: {
label: "iCloud Mail",
account_type: "imap",
imap_server: "imap.mail.me.com",
imap_port: 993,
smtp_server: "smtp.mail.me.com",
smtp_port: 587,
},
microsoft365: {
label: "Outlook / Microsoft 365",
account_type: "imap",
imap_server: "outlook.office365.com",
imap_port: 993,
smtp_server: "smtp.office365.com",
smtp_port: 587,
},
yahoo: {
label: "Yahoo Mail",
account_type: "imap",
imap_server: "imap.mail.yahoo.com",
imap_port: 993,
smtp_server: "smtp.mail.yahoo.com",
smtp_port: 587,
},
exchange: {
label: "Exchange",
account_type: "exchange",
imap_server: "outlook.office365.com",
imap_port: 993,
smtp_server: "smtp.office365.com",
smtp_port: 587,
},
"custom-imap": {
label: "Custom IMAP",
account_type: "imap",
imap_server: "",
imap_port: 993,
smtp_server: "",
smtp_port: 587,
},
};
function ensureConfig(config) {
if (!config || typeof config !== "object") return;
if (!Array.isArray(config.handlers)) config.handlers = [];
}
export const store = createStore("emailConfig", {
config: null,
context: null,
editing: null,
testing: null,
testResults: null,
testResultsFor: null,
guideOpen: false,
didInit: false,
projects: [],
presets: PRESETS,
get handlers() {
ensureConfig(this.config);
return Array.isArray(this.config?.handlers) ? this.config.handlers : [];
},
async init(config, context = null) {
this.config = config || null;
this.context = context;
this.didInit = false;
ensureConfig(this.config);
this.editing = this.handlers.length === 1 ? 0 : null;
this.testing = null;
this.testResults = null;
this.testResultsFor = null;
this.guideOpen = this.handlers.length === 0 && window.innerWidth > 720;
if (this.handlers.length === 0) this._startInitialHandlerFlow();
this.didInit = true;
try {
const response = await API.callJsonApi("projects", { action: "list" });
this.projects = response.data || [];
} catch (_) {
this.projects = [];
}
},
cleanup() {
this.config = null;
this.context = null;
this.editing = null;
this.testing = null;
this.testResults = null;
this.testResultsFor = null;
this.guideOpen = false;
this.didInit = false;
},
newHandler() {
return {
name: "",
enabled: false,
account_type: "imap",
imap_server: "",
imap_port: 993,
smtp_server: "",
smtp_port: 587,
username: "",
password: "",
poll_mode: "seconds",
poll_interval_seconds: 60,
poll_interval_cron: "*/2 * * * *",
process_unread_days: 0,
sender_whitelist: [],
project: "",
dispatcher_model: "utility",
dispatcher_instructions: "",
agent_instructions: "",
};
},
addHandler() {
ensureConfig(this.config);
this.config.handlers.push(this.newHandler());
this.editing = this.config.handlers.length - 1;
this.testResults = null;
this.testResultsFor = null;
},
removeHandler(idx) {
this.handlers.splice(idx, 1);
if (this.editing === idx) this.editing = null;
if (this.editing !== null && this.editing > idx) this.editing -= 1;
if (this.testResultsFor === idx) {
this.testResults = null;
this.testResultsFor = null;
}
},
toggleEditing(idx) {
this.editing = this.editing === idx ? null : idx;
if (this.testResultsFor !== idx) {
this.testResults = null;
this.testResultsFor = null;
}
},
providerValue(handler) {
const incoming = String(handler.imap_server || "").trim().toLowerCase();
const outgoing = String(handler.smtp_server || "").trim().toLowerCase();
const accountType = handler.account_type || "imap";
if (!incoming && !outgoing && !handler.username && !handler.password) return "";
if (accountType === "exchange") return "exchange";
if (incoming === "imap.gmail.com" || outgoing === "smtp.gmail.com") return "gmail";
if (incoming === "imap.mail.me.com" || outgoing === "smtp.mail.me.com") return "icloud";
if (incoming === "outlook.office365.com" || outgoing === "smtp.office365.com") return "microsoft365";
if (incoming === "imap.mail.yahoo.com" || outgoing === "smtp.mail.yahoo.com") return "yahoo";
return "custom-imap";
},
providerLabel(value) {
return this.presets[value]?.label || "Custom IMAP";
},
applyProvider(handler, value) {
const preset = this.presets[value];
if (!preset) return;
const previous = this.providerValue(handler);
handler.account_type = preset.account_type;
if (value === "custom-imap") {
if (previous !== "custom-imap") {
handler.imap_server = "";
handler.smtp_server = "";
}
if (!handler.imap_port) handler.imap_port = 993;
if (!handler.smtp_port) handler.smtp_port = 587;
return;
}
handler.imap_server = preset.imap_server;
handler.imap_port = preset.imap_port;
handler.smtp_server = preset.smtp_server;
handler.smtp_port = preset.smtp_port;
},
providerHint(handler) {
const provider = this.providerValue(handler);
if (provider === "gmail") return "Use a Google App Password. A regular Gmail password usually will not work here.";
if (provider === "icloud") return "Use an app-specific password from your Apple account settings.";
if (provider === "microsoft365") return "Most Outlook and Microsoft 365 inboxes work with this preset.";
if (provider === "yahoo") return "Yahoo Mail usually works best with an app password.";
if (provider === "exchange") return "Choose Exchange only if your organization requires it. For the simplest setup, try Outlook / Microsoft 365 first.";
if (provider === "custom-imap") return "Bring your own incoming and outgoing mail server details.";
return "How to start: turn on inbox, pick your provider, then add your email address and password.";
},
providerHelpUrl(handler) {
return this.providerValue(handler) === "gmail" ? GMAIL_APP_PASSWORDS_URL : "";
},
providerHelpLabel(handler) {
if (this.providerValue(handler) !== "gmail") return "";
return "Google's guide to create a Gmail App Password";
},
showManualServers(handler) {
return this.providerValue(handler) === "custom-imap";
},
showExchangeServer(handler) {
return this.providerValue(handler) === "exchange";
},
incomingLabel(handler) {
return this.showExchangeServer(handler) ? "Exchange server" : "Incoming mail server";
},
incomingDescription(handler) {
return this.showExchangeServer(handler)
? "The Exchange or Microsoft 365 server for this inbox"
: "The IMAP server for incoming mail";
},
incomingPlaceholder(handler) {
return this.showExchangeServer(handler) ? "outlook.office365.com" : "imap.your-provider.com";
},
scheduleLabel(handler) {
const value = this.frequencyValue(handler);
if (value === "15") return "Checks every 15 seconds";
if (value === "30") return "Checks every 30 seconds";
if (value === "60") return "Checks every minute";
if (value === "300") return "Checks every 5 minutes";
if (value === "900") return "Checks every 15 minutes";
return "Uses a custom schedule";
},
frequencyValue(handler) {
if (handler.poll_mode !== "seconds") return "custom";
const seconds = Number(handler.poll_interval_seconds || 0);
if ([15, 30, 60, 300, 900].includes(seconds)) return String(seconds);
return "custom";
},
applyFrequency(handler, value) {
if (value === "custom") {
if (handler.poll_mode !== "cron") handler.poll_mode = "seconds";
if (!handler.poll_interval_seconds) handler.poll_interval_seconds = 60;
return;
}
handler.poll_mode = "seconds";
handler.poll_interval_seconds = Number(value);
},
frequencyHint(handler) {
return this.frequencyValue(handler) === "custom"
? "You are using a custom schedule. You can change the raw timing in Advanced."
: this.scheduleLabel(handler);
},
whitelistText(handler) {
return (handler.sender_whitelist || []).join(", ");
},
setWhitelist(handler, value) {
handler.sender_whitelist = value
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry);
},
slugify(value) {
return String(value || "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
},
maybeAutoname(handler) {
const current = String(handler.name || "").trim();
if (current && !/^handler_\d+$/.test(current)) return;
const email = String(handler.username || "").trim();
const localPart = email.split("@")[0] || "";
const nextName = this.slugify(localPart);
if (nextName) handler.name = nextName;
},
missingBits(handler) {
const missing = [];
const provider = this.providerValue(handler);
if (!provider) missing.push("provider");
if (!handler.username) missing.push("email address");
if (!handler.password) missing.push("password");
if ((this.showManualServers(handler) || this.showExchangeServer(handler)) && !handler.imap_server) {
missing.push(this.showExchangeServer(handler) ? "Exchange server" : "incoming server");
}
if (this.showManualServers(handler) && !handler.smtp_server) missing.push("outgoing server");
return missing;
},
canTest(handler) {
return this.missingBits(handler).length === 0;
},
statusLabel(handler) {
if (!handler.username && !this.providerValue(handler)) return "New";
if (handler.enabled && this.canTest(handler)) return "Live";
if (this.canTest(handler)) return "Ready";
return "Needs info";
},
statusTone(handler) {
if (!handler.username && !this.providerValue(handler)) return "muted";
if (handler.enabled && this.canTest(handler)) return "success";
if (this.canTest(handler)) return "ready";
return "warning";
},
handlerTitle(handler, idx) {
return handler.username || handler.name || `Inbox ${idx + 1}`;
},
handlerSubtitle(handler) {
const pieces = [];
const provider = this.providerValue(handler);
if (provider) pieces.push(this.providerLabel(provider));
pieces.push(this.scheduleLabel(handler).replace("Checks ", ""));
if (handler.project) pieces.push(`Project: ${handler.project}`);
return pieces.join(" · ");
},
testButtonLabel(handler, idx) {
if (this.testing === idx) return "Checking...";
if (this.canTest(handler)) return "Check setup";
return "Fill in the basics first";
},
testIntro(handler) {
if (this.providerValue(handler) === "exchange") {
return "We will check the inbox, check outgoing mail, then send a test email to this address.";
}
return "We will check incoming mail, check outgoing mail, then send a test email to this inbox.";
},
resultTitle(result) {
return result.test || "Check";
},
resultMessage(result) {
return result.message || (result.ok ? "Done." : "Something went wrong.");
},
_startInitialHandlerFlow() {
this.addHandler();
if (!this.context) return;
const toComparableJson = typeof this.context._toComparableJson === "function"
? this.context._toComparableJson.bind(this.context)
: JSON.stringify;
this.context.settingsSnapshotJson = toComparableJson(this.context.settings);
},
async testConnection(idx) {
const handler = this.handlers[idx];
if (!handler || !this.canTest(handler)) return;
this.testing = idx;
this.testResults = null;
this.testResultsFor = idx;
try {
this.testResults = await API.callJsonApi(`${API_BASE}/test_connection`, { handler });
} catch (error) {
this.testResults = {
success: false,
results: [{ test: "Connection", ok: false, message: String(error) }],
};
}
this.testing = null;
},
});

View file

@ -12,9 +12,9 @@ class TestConnection(ApiHandler):
if not token:
results.append({
"test": "Token",
"test": "Bot token",
"ok": False,
"message": "No bot token provided",
"message": "Add your bot token first.",
})
return {"success": False, "results": results}
@ -23,15 +23,15 @@ class TestConnection(ApiHandler):
from plugins._telegram_integration.helpers.bot_manager import test_token
ok, message = await test_token(token)
results.append({
"test": "Bot Token",
"test": "Telegram bot",
"ok": ok,
"message": message,
"message": "Telegram accepted the bot token." if ok else message,
})
except Exception as e:
results.append({
"test": "Bot Token",
"test": "Telegram bot",
"ok": False,
"message": format_error(e),
"message": f"Could not validate the bot token: {format_error(e)}",
})
return {"success": all(r["ok"] for r in results), "results": results}

File diff suppressed because it is too large Load diff

View file

@ -1,30 +1,129 @@
import { createStore } from "/js/AlpineStore.js";
import * as API from "/js/api.js";
const API_BASE = "/plugins/_telegram_integration";
const STEPS = [
{
title: "Connect your bot",
description: "Start with BotFather, then paste the bot token here.",
},
{
title: "Choose who can use it",
description: "Finish the core setup, choose access, and decide how messages arrive.",
},
{
title: "Shape the conversation",
description: "Choose how the bot behaves in groups and how the agent should reply.",
},
];
const BOTFATHER_QR =
"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";
function ensureConfig(config) {
if (!config || typeof config !== "object") return;
if (!Array.isArray(config.bots)) config.bots = [];
}
export const store = createStore("telegramConfig", {
config: null,
projects: [],
expandedIdx: null,
editing: null,
testing: null,
testResults: null,
_loaded: false,
testResultsFor: null,
botSteps: [],
didInit: false,
steps: STEPS,
botFatherQr: BOTFATHER_QR,
_projectsLoaded: false,
context: null,
async init() {
if (this._loaded) return;
get bots() {
ensureConfig(this.config);
return Array.isArray(this.config?.bots) ? this.config.bots : [];
},
get activeIndex() {
return typeof this.editing === "number" ? this.editing : -1;
},
get activeBot() {
return this.activeIndex >= 0 ? this.bots[this.activeIndex] || null : null;
},
get showFooterNav() {
return this.activeIndex >= 0;
},
get currentStep() {
return this.activeIndex >= 0 && typeof this.botSteps[this.activeIndex] === "number"
? this.botSteps[this.activeIndex]
: 0;
},
get isFirstStep() {
return this.currentStep === 0;
},
get isLastStep() {
return this.currentStep >= this.steps.length - 1;
},
get nextDisabled() {
return !!this.stepBlockedReason();
},
get nextButtonLabel() {
return this.isLastStep ? "Done" : "Next";
},
get footerStepLabel() {
return `Step ${this.currentStep + 1} of ${this.steps.length}`;
},
async init(config, context = null) {
this.config = config || null;
this.context = context;
this.didInit = false;
ensureConfig(this.config);
this.editing = this.bots.length === 1 ? 0 : null;
this.testing = null;
this.testResults = null;
this.testResultsFor = null;
this.botSteps = this.bots.map((bot) => this.initialStepForBot(bot));
if (this.bots.length === 0) this._startInitialBotFlow();
this._installWizardFooter();
this.didInit = true;
if (this._projectsLoaded) return;
try {
const { callJsonApi } = await import("/js/api.js");
const res = await callJsonApi("projects", { action: "list" });
this.projects = res.data || [];
const response = await API.callJsonApi("projects", { action: "list" });
this.projects = response.data || [];
} catch (_) {
this.projects = [];
}
this._loaded = true;
this._projectsLoaded = true;
},
cleanup() {
if (this.context?.wizardFooter?.owner === "telegramConfig") {
this.context.wizardFooter = null;
}
this.config = null;
this.context = null;
this.editing = null;
this.testing = null;
this.testResults = null;
this.testResultsFor = null;
this.botSteps = [];
this.didInit = false;
},
defaultBot() {
return {
name: "",
enabled: true,
enabled: false,
notify_messages: false,
token: "",
mode: "polling",
@ -41,71 +140,208 @@ export const store = createStore("telegramConfig", {
};
},
addBot(config) {
if (!config.bots) config.bots = [];
const bot = this.defaultBot();
bot.name = "bot_" + (config.bots.length + 1);
config.bots.push(bot);
this.expandedIdx = config.bots.length - 1;
},
removeBot(config, idx) {
config.bots.splice(idx, 1);
this.expandedIdx = null;
},
toggle(idx) {
this.expandedIdx = this.expandedIdx === idx ? null : idx;
addBot() {
ensureConfig(this.config);
this.config.bots.push(this.defaultBot());
this.botSteps.push(0);
this.editing = this.config.bots.length - 1;
this.testResults = null;
this.testResultsFor = null;
},
removeBot(idx) {
this.bots.splice(idx, 1);
this.botSteps.splice(idx, 1);
if (this.editing === idx) this.editing = null;
if (this.editing !== null && this.editing > idx) this.editing -= 1;
if (this.testResultsFor === idx) {
this.testResults = null;
this.testResultsFor = null;
}
},
toggleEditing(idx) {
this.editing = this.editing === idx ? null : idx;
if (this.editing !== null && typeof this.botSteps[this.editing] !== "number") {
this.botSteps[this.editing] = this.initialStepForBot(this.bots[this.editing]);
}
if (this.testResultsFor !== idx) {
this.testResults = null;
this.testResultsFor = null;
}
},
currentStepMeta() {
return this.steps[this.currentStep] || this.steps[0];
},
setStep(step) {
if (this.activeIndex < 0) return;
const next = Math.max(0, Math.min(this.steps.length - 1, Number(step) || 0));
this.botSteps[this.activeIndex] = next;
},
previousStep() {
if (this.isFirstStep) return;
this.setStep(this.currentStep - 1);
},
nextStep() {
if (this.stepBlockedReason()) return;
if (this.isLastStep) {
this.editing = null;
return;
}
this.setStep(this.currentStep + 1);
},
stepBlockedReason() {
const bot = this.activeBot;
if (!bot) return "";
if (this.currentStep === 0 && !String(bot.token || "").trim()) {
return "Add your bot token first.";
}
if (this.currentStep === 1 && bot.mode === "webhook" && !String(bot.webhook_url || "").trim()) {
return "Add your webhook URL first.";
}
return "";
},
canTest(bot) {
if (!bot) return false;
if (!String(bot.token || "").trim()) return false;
if (bot.mode === "webhook" && !String(bot.webhook_url || "").trim()) return false;
return true;
},
botStatusLabel(bot) {
if (!String(bot?.token || "").trim()) return "New";
if (bot?.mode === "webhook" && !String(bot?.webhook_url || "").trim()) return "Needs URL";
if (bot?.enabled && this.canTest(bot)) return "Live";
if (this.canTest(bot)) return "Ready";
return "Needs info";
},
botStatusTone(bot) {
const label = this.botStatusLabel(bot);
if (label === "Live") return "success";
if (label === "Ready") return "ready";
if (label === "New") return "muted";
return "warning";
},
botTitle(bot, idx) {
return String(bot?.name || "").trim() || `Bot ${idx + 1}`;
},
botSubtitle(bot) {
const pieces = [bot.mode === "webhook" ? "Webhook" : "Polling"];
pieces.push(Array.isArray(bot.allowed_users) && bot.allowed_users.length > 0 ? "Private access" : "Open access");
if (bot.default_project) pieces.push(`Project: ${bot.default_project}`);
return pieces.join(" · ");
},
whitelistText(bot) {
return (bot.allowed_users || []).join(", ");
},
setWhitelist(bot, val) {
bot.allowed_users = val
setWhitelist(bot, value) {
bot.allowed_users = value
.split(",")
.map((s) => s.trim())
.filter((s) => s);
.map((item) => item.trim())
.filter((item) => item);
},
userProjectsText(bot) {
const up = bot.user_projects || {};
return Object.entries(up)
.map(([k, v]) => k + "=" + v)
return Object.entries(bot.user_projects || {})
.map(([userId, project]) => `${userId}=${project}`)
.join(", ");
},
setUserProjects(bot, val) {
const obj = {};
val
setUserProjects(bot, value) {
const mapping = {};
value
.split(",")
.map((s) => s.trim())
.filter((s) => s)
.map((item) => item.trim())
.filter((item) => item)
.forEach((item) => {
const parts = item.split("=").map((p) => p.trim());
const k = parts[0];
if (k) obj[k] = parts[1] || "";
const [userId, project] = item.split("=").map((part) => part.trim());
if (userId) mapping[userId] = project || "";
});
bot.user_projects = obj;
bot.user_projects = mapping;
},
async testConnection(config, idx) {
accessWarning(bot) {
if (!bot?.enabled) return "";
if (Array.isArray(bot.allowed_users) && bot.allowed_users.length > 0) return "";
return "Allowed users is empty. Anyone who finds this bot can reach your Agent Zero.";
},
async testConnection(idx) {
const bot = this.bots[idx];
if (!this.canTest(bot)) return;
this.testing = idx;
this.testResults = null;
this.testResultsFor = idx;
try {
const { callJsonApi } = await import("/js/api.js");
const res = await callJsonApi(`${API_BASE}/test_connection`, {
bot: config.bots[idx],
});
this.testResults = res;
} catch (e) {
this.testResults = await API.callJsonApi(`${API_BASE}/test_connection`, { bot });
} catch (error) {
this.testResults = {
success: false,
results: [{ test: "Connection", ok: false, message: String(e) }],
results: [{ test: "Telegram bot", ok: false, message: String(error) }],
};
}
this.testing = null;
},
testButtonLabel(bot, idx) {
if (this.testing === idx) return "Checking...";
if (this.canTest(bot)) return "Check Telegram connection";
return "Fill in the basics first";
},
testIntro() {
return "We will validate the bot token with Telegram so you know this bot can connect.";
},
resultTitle(result) {
return result.test || "Check";
},
resultMessage(result) {
return result.message || (result.ok ? "Done." : "Something went wrong.");
},
initialStepForBot(bot) {
return String(bot?.token || "").trim() ? 1 : 0;
},
_startInitialBotFlow() {
this.addBot();
if (!this.context) return;
const toComparableJson = typeof this.context._toComparableJson === "function"
? this.context._toComparableJson.bind(this.context)
: JSON.stringify;
this.context.settingsSnapshotJson = toComparableJson(this.context.settings);
},
_installWizardFooter() {
if (!this.context) return;
this.context.wizardFooter = {
owner: "telegramConfig",
visible: () => this.showFooterNav,
canGoBack: () => !this.isFirstStep,
backLabel: () => "Back",
note: () => this.footerStepLabel,
showNext: () => this.showFooterNav && !this.isLastStep,
nextLabel: () => this.nextButtonLabel,
nextDisabled: () => this.nextDisabled,
showSave: () => this.showFooterNav && this.isLastStep,
onBack: () => this.previousStep(),
onNext: () => this.nextStep(),
};
},
});

View file

@ -32,19 +32,19 @@ class TestConnection(ApiHandler):
if status == "connected":
results.append({
"test": "Bridge",
"test": "WhatsApp bridge",
"ok": True,
"message": f"Connected (uptime: {uptime:.0f}s, queue: {queue})",
"message": f"Connected and ready (uptime: {uptime:.0f}s, queue: {queue})",
})
else:
results.append({
"test": "Bridge",
"test": "WhatsApp bridge",
"ok": False,
"message": f"Bridge running but status: {status}",
"message": f"The bridge is running, but WhatsApp is not fully connected yet (status: {status}).",
})
except Exception as e:
results.append({
"test": "Bridge",
"test": "WhatsApp bridge",
"ok": False,
"message": f"Bridge not reachable: {format_error(e)}",
"message": f"Could not reach the local WhatsApp bridge: {format_error(e)}",
})

View file

@ -1,337 +1,617 @@
<html>
<head>
<title>WhatsApp Integration</title>
<script type="module">
import { store } from "/plugins/_whatsapp_integration/webui/whatsapp-config-store.js";
</script>
</head>
<body>
<div x-data="{
testing: false,
test_results: null,
projects: [],
qr_visible: false,
qr_status: '',
qr_message: '',
qr_data_url: null,
qr_poll_timer: null,
disconnecting: false,
disconnect_message: '',
async init() {
try {
const { callJsonApi } = await import('/js/api.js');
const res = await callJsonApi('projects', { action: 'list' });
this.projects = res.data || [];
} catch (e) { this.projects = []; }
},
allowed_text() {
const value = config?.allowed_numbers;
if (Array.isArray(value)) return value.join(', ');
return typeof value === 'string' ? value : '';
},
allowed_is_empty() {
const value = config?.allowed_numbers;
if (Array.isArray(value)) return value.length === 0;
return !String(value || '').trim();
},
set_allowed(val) {
config.allowed_numbers = val.split(',')
.map(s => s.trim())
.filter(s => s);
},
async test_connection() {
this.testing = true;
this.test_results = null;
try {
const { callJsonApi } = await import('/js/api.js');
const res = await callJsonApi('/plugins/_whatsapp_integration/test_connection', {
config: { bridge_port: config.bridge_port }
});
this.test_results = res;
} catch (e) {
this.test_results = { success: false, results: [{ test: 'Connection', ok: false, message: String(e) }] };
}
this.testing = false;
},
async show_qr() {
this.qr_visible = true;
this.qr_status = 'loading';
this.qr_message = 'Starting bridge...';
this.qr_data_url = null;
await this.poll_qr();
this.qr_poll_timer = setInterval(() => this.poll_qr(), 3000);
},
hide_qr() {
this.qr_visible = false;
this.qr_data_url = null;
this.qr_status = '';
if (this.qr_poll_timer) {
clearInterval(this.qr_poll_timer);
this.qr_poll_timer = null;
}
},
async poll_qr() {
try {
const { callJsonApi } = await import('/js/api.js');
const res = await callJsonApi('/plugins/_whatsapp_integration/qr_code', {});
this.qr_status = res.status || 'error';
this.qr_message = res.message || '';
this.qr_data_url = res.qr || null;
if (res.status === 'connected') {
if (this.qr_poll_timer) {
clearInterval(this.qr_poll_timer);
this.qr_poll_timer = null;
}
}
} catch (e) {
this.qr_status = 'error';
this.qr_message = String(e);
this.qr_data_url = null;
}
},
async disconnect_account() {
if (!confirm('Disconnect this WhatsApp account? You will need to scan a new QR code to reconnect.')) return;
this.disconnecting = true;
this.disconnect_message = '';
try {
const { callJsonApi } = await import('/js/api.js');
const res = await callJsonApi('/plugins/_whatsapp_integration/disconnect', {});
this.disconnect_message = res.success ? 'Account disconnected' : (res.message || 'Failed');
} catch (e) {
this.disconnect_message = String(e);
}
this.disconnecting = false;
}
}">
<div x-data x-init="$store.whatsappConfig.init(config, context)" x-destroy="$store.whatsappConfig.cleanup()">
<template x-if="config">
<div>
<div class="section-title">WhatsApp Integration</div>
<div class="wa-page">
<div class="section-title">WhatsApp</div>
<div class="field">
<div class="field-label">
<div class="field-title">Enabled</div>
<div class="field-description">Enable WhatsApp bridge and message polling</div>
<template x-if="config.enabled && $store.whatsappConfig.hasMeaningfulConfig()">
<div class="wa-summary-card">
<div class="wa-summary-copy">
<div class="wa-summary-title">Current setup</div>
<div class="wa-summary-subtitle"
x-text="$store.whatsappConfig.modeSummary() + ' · ' + $store.whatsappConfig.projectSummary()">
</div>
</div>
<span class="wa-status-pill" :class="'tone-' + $store.whatsappConfig.statusTone()"
x-text="$store.whatsappConfig.statusLabel()"></span>
</div>
<div class="field-control">
<label class="toggle">
<input type="checkbox" x-model="config.enabled" />
<span class="toggler"></span>
</label>
</div>
</div>
</template>
<!-- WhatsApp Account (shown when enabled) -->
<template x-if="config.enabled">
<div>
<div class="field">
<div class="field-label">
<div class="field-title">WhatsApp Account</div>
<div class="field-description">
<span x-show="!disconnect_message">Pair or switch your WhatsApp account</span>
<span x-show="disconnect_message" x-text="disconnect_message"
:style="'color:' + (disconnect_message === 'Account disconnected' ? '#4caf50' : '#f44336')"></span>
<div class="wa-wizard-card">
<div class="wa-step-header">
<div class="wa-step-copy">
<div class="wa-step-title" x-text="$store.whatsappConfig.currentStepMeta().title"></div>
<div class="wa-step-description"
x-text="$store.whatsappConfig.currentStepMeta().description"></div>
</div>
<div class="wa-step-dots" aria-hidden="true">
<template x-for="(step, idx) in $store.whatsappConfig.steps" :key="step.title">
<button type="button" class="wa-step-dot"
:class="{ 'is-active': $store.whatsappConfig.currentStep === idx }"
@click="$store.whatsappConfig.setStep(idx)"></button>
</template>
</div>
</div>
<template x-if="$store.whatsappConfig.currentStep === 0">
<div class="wa-step-panel">
<div class="field">
<div class="field-label">
<div class="field-title">Turn on WhatsApp</div>
<div class="field-description">Enable the local bridge to start.</div>
</div>
<div class="field-control">
<label class="toggle">
<input type="checkbox"
x-model="config.enabled"
@change="$store.whatsappConfig.onEnabledChange()" />
<span class="toggler"></span>
</label>
</div>
</div>
<div class="field-control" style="display: flex; gap: 8px;">
<button class="btn btn-field" @click="show_qr()">
Show QR Code
</button>
<button class="btn btn-field" @click="disconnect_account()" :disabled="disconnecting">
<span x-show="!disconnecting">Disconnect</span>
<span x-show="disconnecting">Disconnecting...</span>
</button>
</div>
</div>
<!-- QR Code panel -->
<template x-if="qr_visible">
<div style="margin-top: 8px; padding: 16px; border-radius: 8px;
border: 1px solid var(--border-color, #333);
text-align: center;">
<!-- Connected state -->
<template x-if="qr_status === 'connected'">
<div>
<div style="font-size: 1.5rem; margin-bottom: 8px;">&#10003;</div>
<div style="font-weight: 500; color: #4caf50;" x-text="qr_message"></div>
<button class="btn btn-field" @click="hide_qr()" style="margin-top: 12px;">
Close
</button>
</div>
</template>
<!-- QR code ready -->
<template x-if="qr_status === 'waiting_scan' && qr_data_url">
<div>
<div style="font-weight: 500; margin-bottom: 12px;">
Scan with WhatsApp on your phone
<template x-if="config.enabled">
<div>
<div class="field">
<div class="field-label">
<div class="field-title">WhatsApp account</div>
<div class="field-description">
<span x-show="!$store.whatsappConfig.disconnectMessage">Click "Show QR code" to pair your WhatsApp account.</span>
<span x-show="$store.whatsappConfig.disconnectMessage"
x-text="$store.whatsappConfig.disconnectMessage"></span>
</div>
</div>
<img :src="qr_data_url" alt="WhatsApp QR Code"
style="width: 256px; height: 256px; border-radius: 8px;
background: white; padding: 4px;" />
<div style="margin-top: 8px; font-size: 0.8rem; opacity: 0.6;">
QR code refreshes automatically
<div class="field-control wa-inline-actions">
<button class="btn btn-field" @click="$store.whatsappConfig.showQr()">
Show QR code
</button>
<button class="btn btn-field" @click="$store.whatsappConfig.disconnectAccount()"
:disabled="$store.whatsappConfig.disconnecting">
<span x-show="!$store.whatsappConfig.disconnecting">Disconnect</span>
<span x-show="$store.whatsappConfig.disconnecting">Disconnecting...</span>
</button>
</div>
<button class="btn btn-field" @click="hide_qr()" style="margin-top: 12px;">
Cancel
</button>
</div>
</template>
<!-- Loading / waiting for QR -->
<template x-if="qr_status !== 'connected' && !(qr_status === 'waiting_scan' && qr_data_url)">
<div>
<div style="font-weight: 500; margin-bottom: 8px;" x-text="qr_message || 'Connecting...'"></div>
<div style="font-size: 0.85rem; opacity: 0.6;">
<template x-if="qr_status === 'error'">
<span style="color: #f44336;" x-text="qr_message"></span>
<template x-if="$store.whatsappConfig.qrVisible">
<div class="wa-qr-panel">
<template x-if="$store.whatsappConfig.qrStatus === 'connected'">
<div>
<div class="wa-qr-status ok">Connected</div>
<div class="wa-qr-message" x-text="$store.whatsappConfig.qrMessage"></div>
<button class="btn btn-field" @click="$store.whatsappConfig.hideQr()"
style="margin-top: 12px;">
Close
</button>
</div>
</template>
<template x-if="qr_status !== 'error'">
<span>Please wait...</span>
<template
x-if="$store.whatsappConfig.qrStatus === 'waiting_scan' && $store.whatsappConfig.qrDataUrl">
<div>
<div class="wa-qr-status">Scan with WhatsApp on your phone</div>
<img :src="$store.whatsappConfig.qrDataUrl" alt="WhatsApp QR Code"
class="wa-qr-image" />
<div class="wa-qr-help">The QR code refreshes automatically.</div>
<button class="btn btn-field" @click="$store.whatsappConfig.hideQr()"
style="margin-top: 12px;">
Cancel
</button>
</div>
</template>
<template
x-if="$store.whatsappConfig.qrStatus !== 'connected' && !($store.whatsappConfig.qrStatus === 'waiting_scan' && $store.whatsappConfig.qrDataUrl)">
<div>
<div class="wa-qr-status"
x-text="$store.whatsappConfig.qrMessage || 'Connecting...'"></div>
<div class="wa-qr-help"
x-show="$store.whatsappConfig.qrStatus !== 'error'">
Please wait...</div>
<div class="wa-qr-help error"
x-show="$store.whatsappConfig.qrStatus === 'error'"
x-text="$store.whatsappConfig.qrMessage"></div>
<button class="btn btn-field" @click="$store.whatsappConfig.hideQr()"
style="margin-top: 12px;">
Cancel
</button>
</div>
</template>
</div>
<button class="btn btn-field" @click="hide_qr()" style="margin-top: 12px;">
Cancel
</button>
</div>
</template>
</div>
</template>
</div>
</template>
<div class="field">
<div class="field-label">
<div class="field-title">Mode</div>
<div class="field-description">
<span x-show="config.mode === 'self-chat'">
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.
</span>
<span x-show="config.mode !== 'self-chat'">
Use a separate WhatsApp number dedicated to Agent Zero conversations.
</span>
</div>
</div>
<div class="field-control">
<select x-model="config.mode">
<option value="self-chat">Personal number (self-chat)</option>
<option value="dedicated">Separate number (dedicated)</option>
</select>
</div>
</div>
<template x-if="config.enabled && allowed_is_empty()">
<div style="margin: 8px 0 20px; padding: 12px 14px; border-radius: 10px;
border: 1px solid rgba(255, 170, 0, 0.45);
background: rgba(255, 170, 0, 0.12);
color: var(--color-warning-text);">
<div style="font-weight: 600; display: flex; align-items: center; gap: 8px;">
<span aria-hidden="true">&#9888;</span>
<span>Warning</span>
</div>
<div style="margin-top: 4px; line-height: 1.45;">
Allowed Numbers is empty. If other people can message this WhatsApp number, they can use your Agent Zero.
</div>
</div>
</template>
<div class="field">
<div class="field-label">
<div class="field-title">Allowed Numbers</div>
<div class="field-description">Comma-separated phone numbers. Matching is normalized by the backend, so punctuation and + prefixes are okay. Empty = allow all.</div>
</div>
<div class="field-control">
<input type="text" :value="allowed_text()" @change="set_allowed($event.target.value)" placeholder="+1 (415) 555-1234, +44 7911 123456" />
</div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Allow Group</div>
<div class="field-description">Respond in group chats when mentioned or replied to</div>
</div>
<div class="field-control">
<label class="toggle">
<input type="checkbox" x-model="config.allow_group" />
<span class="toggler"></span>
</label>
</div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Project</div>
<div class="field-description">Project to activate for WhatsApp chats</div>
</div>
<div class="field-control">
<select :value="config.project" @change="config.project = $event.target.value">
<option value="">No project</option>
<template x-for="proj in projects" :key="proj.name">
<option :value="proj.name" x-text="proj.title || proj.name" :selected="config.project === proj.name"></option>
</template>
</div>
</template>
</select>
</div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Agent Instructions</div>
<div class="field-description">Extra instructions for the agent in WhatsApp chats</div>
</div>
<div class="field-control">
<textarea x-model="config.agent_instructions" rows="3" placeholder="e.g. Always respond concisely..."></textarea>
</div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Bridge Port</div>
<div class="field-description">Local port for the WhatsApp bridge HTTP server</div>
</div>
<div class="field-control">
<input type="number" x-model.number="config.bridge_port" placeholder="3100" />
</div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Poll Interval (seconds)</div>
<div class="field-description">How often to check for new messages (minimum 2)</div>
</div>
<div class="field-control">
<input type="number" x-model.number="config.poll_interval_seconds" min="2" placeholder="3" />
</div>
</div>
<!-- Test connection -->
<div style="margin-top: 16px; display: flex; align-items: center; gap: 12px;">
<button class="btn btn-field" @click="test_connection()" :disabled="testing">
<span x-show="!testing">Test Connection</span>
<span x-show="testing">Testing...</span>
</button>
</div>
<!-- Test results -->
<template x-if="test_results">
<div style="margin-top: 8px; padding: 8px 12px; border-radius: 6px; font-size: 0.85rem;
border: 1px solid var(--border-color, #333);">
<template x-for="r in test_results.results" :key="r.test">
<div style="display: flex; align-items: center; gap: 8px; padding: 4px 0;">
<span x-text="r.ok ? '✓' : '✗'"
:style="'font-weight: bold; color:' + (r.ok ? '#4caf50' : '#f44336')"></span>
<span style="font-weight: 500; min-width: 50px;" x-text="r.test"></span>
<span style="opacity: 0.8;" x-text="r.message"></span>
<div class="wa-note" x-show="!config.enabled">
Turn on WhatsApp to pair or switch your account.
</div>
</template>
<div class="field">
<div class="field-label">
<div class="field-title">Mode</div>
<div class="field-description">
<span x-show="config.mode === 'self-chat'">
Use your own number. You can message yourself to talk to the agent.
</span>
<span x-show="config.mode !== 'self-chat'">
Use a separate number dedicated to Agent Zero conversations.
</span>
</div>
</div>
<div class="field-control">
<select x-model="config.mode">
<option value="self-chat">Personal number (self-chat)</option>
<option value="dedicated">Separate number (dedicated)</option>
</select>
</div>
</div>
<div class="wa-mode-note">
<strong>Good to know:</strong> Self-chat uses your own number. Dedicated is better for
shared or public access. You can pair now or come back to it later.
</div>
<div class="wa-warning" x-show="$store.whatsappConfig.accessWarning()">
<div class="wa-warning-title">
<span aria-hidden="true">&#9888;</span>
<span>Warning</span>
</div>
<div class="wa-warning-body" x-text="$store.whatsappConfig.accessWarning()"></div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Allowed numbers</div>
<div class="field-description">Comma-separated phone numbers. Punctuation and +
prefixes are okay. Leave empty only if you want open access.</div>
</div>
<div class="field-control">
<input type="text" :value="$store.whatsappConfig.allowedText()"
@input="$store.whatsappConfig.setAllowed($event.target.value)"
placeholder="+1 (415) 555-1234, +44 7911 123456" />
</div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Allow groups</div>
<div class="field-description">Reply in group chats when mentioned or replied to.
</div>
</div>
<div class="field-control">
<label class="toggle">
<input type="checkbox" x-model="config.allow_group" />
<span class="toggler"></span>
</label>
</div>
</div>
</div>
</template>
<template x-if="$store.whatsappConfig.currentStep === 1">
<div class="wa-step-panel">
<div class="field">
<div class="field-label">
<div class="field-title">Project</div>
<div class="field-description">Optional project to activate for WhatsApp
conversations.</div>
</div>
<div class="field-control">
<select :value="config.project" @change="config.project = $event.target.value">
<option value="">No project</option>
<template x-for="proj in $store.whatsappConfig.projects" :key="proj.name">
<option :value="proj.name" x-text="proj.title || proj.name"
:selected="config.project === proj.name"></option>
</template>
</select>
</div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Agent instructions</div>
<div class="field-description">Extra guidance for how the agent should reply in
WhatsApp chats.</div>
</div>
<div class="field-control">
<textarea x-model="config.agent_instructions" rows="3"
placeholder="Reply briefly and naturally, like a mobile conversation."></textarea>
</div>
</div>
</div>
</template>
<div class="wa-test-panel">
<div class="wa-test-copy">
<div class="wa-test-title">Check the connection</div>
<div class="wa-test-description">We will check whether the local WhatsApp bridge is up and
connected.</div>
</div>
<button class="btn btn-field" @click="$store.whatsappConfig.testConnection()"
:disabled="$store.whatsappConfig.testing">
<span x-text="$store.whatsappConfig.testButtonLabel()"></span>
</button>
</div>
</template>
<template x-if="$store.whatsappConfig.testResults">
<div class="wa-results" :class="{ 'is-error': !$store.whatsappConfig.testResults.success }">
<template x-for="result in $store.whatsappConfig.testResults.results"
:key="result.test + result.message">
<div class="wa-result-row">
<span class="wa-result-icon" x-text="result.ok ? '✓' : '✗'"></span>
<div class="wa-result-copy">
<div class="wa-result-title" x-text="result.test"></div>
<div class="wa-result-message" x-text="result.message"></div>
</div>
</div>
</template>
</div>
</template>
<template x-if="$store.whatsappConfig.currentStep > 0">
<details class="wa-advanced">
<summary>
<span>Advanced</span>
<span class="material-symbols-outlined wa-advanced-chevron"
aria-hidden="true">keyboard_arrow_down</span>
</summary>
<div class="wa-advanced-body">
<div class="wa-info-box">
These settings are here when you need them, but the defaults are fine for most
setups.
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Bridge port</div>
<div class="field-description">Local port for the WhatsApp bridge HTTP server.
</div>
</div>
<div class="field-control">
<input type="number" x-model.number="config.bridge_port" placeholder="3100" />
</div>
</div>
<div class="field">
<div class="field-label">
<div class="field-title">Poll interval (seconds)</div>
<div class="field-description">How often to check for new messages. The minimum
is 2 seconds.</div>
</div>
<div class="field-control">
<input type="number" x-model.number="config.poll_interval_seconds" min="2"
placeholder="3" />
</div>
</div>
</div>
</details>
</template>
</div>
</div>
</template>
</div>
<style>
.wa-page {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.wa-summary-card,
.wa-wizard-card {
border-radius: 0.5rem;
}
.wa-advanced summary {
cursor: pointer;
list-style: none;
font-weight: 700;
display: flex;
align-items: center;
gap: 0.75rem;
}
.wa-advanced summary::-webkit-details-marker {
display: none;
}
.wa-summary-title,
.wa-step-title,
.wa-test-title {
font-weight: 700;
}
.wa-summary-subtitle,
.wa-step-description,
.wa-note,
.wa-mode-note,
.wa-test-description,
.wa-result-message {
color: var(--color-text-secondary);
line-height: 1.5;
}
.wa-summary-card {
padding: 1rem;
border: 1px solid var(--color-border);
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
}
.wa-step-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
margin-bottom: 1rem;
}
.wa-step-dots {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
}
.wa-step-dot {
width: 0.85rem;
height: 0.85rem;
border-radius: 999px;
border: 1px solid var(--color-border);
background: transparent;
cursor: pointer;
padding: 0;
}
.wa-step-dot.is-active {
background: rgba(59, 130, 246, 0.9);
border-color: rgba(59, 130, 246, 0.9);
}
.wa-step-panel {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.wa-info-box,
.wa-note {
margin-bottom: 0.9rem;
padding: 0.8rem 0.9rem;
border-radius: 12px;
font-size: var(--font-size-small);
}
.wa-info-box {
background: color-mix(in srgb, #3b82f6 12%, var(--color-background) 88%);
}
.wa-note {
background: color-mix(in srgb, var(--color-background) 88%, white 12%);
}
.wa-warning {
margin: 0.5rem 0 1.25rem;
padding: 0.75rem 0.9rem;
border-radius: 10px;
border: 1px solid rgba(255, 170, 0, 0.45);
background: rgba(255, 170, 0, 0.12);
color: var(--color-warning-text);
font-size: var(--font-size-small);
}
.wa-warning-title {
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.wa-warning-body {
margin-top: 0.25rem;
line-height: 1.45;
}
.wa-mode-note {
margin-top: -0.2rem;
margin-bottom: 0.9rem;
font-size: var(--font-size-small);
}
.wa-inline-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.wa-qr-panel {
margin-top: 0.25rem;
padding: 1rem;
border-radius: 14px;
border: 1px solid color-mix(in srgb, var(--color-border) 88%, white 12%);
background: color-mix(in srgb, var(--color-background) 90%, white 10%);
text-align: center;
}
.wa-qr-status {
font-weight: 700;
margin-bottom: 0.5rem;
}
.wa-qr-status.ok {
color: #7ee7a4;
}
.wa-qr-message,
.wa-qr-help {
color: var(--color-text-secondary);
line-height: 1.5;
}
.wa-qr-help {
font-size: var(--font-size-small);
margin-top: 0.4rem;
}
.wa-qr-help.error {
color: #fca5a5;
}
.wa-qr-image {
width: 256px;
height: 256px;
max-width: 100%;
border-radius: 8px;
background: white;
padding: 4px;
}
.wa-advanced {
margin-top: 1rem;
border: 1px solid color-mix(in srgb, var(--color-border) 88%, white 12%);
border-radius: 14px;
overflow: hidden;
}
.wa-advanced summary {
padding: 0.9rem 1rem;
background: color-mix(in srgb, var(--color-background) 88%, white 12%);
}
.wa-advanced-chevron {
margin-left: auto;
flex: 0 0 auto;
transition: transform 0.18s ease, opacity 0.18s ease;
opacity: 0.72;
}
.wa-advanced[open] .wa-advanced-chevron {
transform: rotate(180deg);
opacity: 1;
}
.wa-advanced-body {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.wa-test-panel {
margin-top: 1rem;
padding: var(--spacing-xs) 0;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.wa-test-copy,
.wa-result-copy {
min-width: 0;
}
.wa-results {
margin-top: 0.9rem;
border-radius: 14px;
border: 1px solid color-mix(in srgb, #22c55e 28%, var(--color-border) 72%);
background: color-mix(in srgb, #22c55e 8%, var(--color-background) 92%);
padding: 0.35rem 0.9rem;
}
.wa-results.is-error {
border-color: color-mix(in srgb, #ef4444 28%, var(--color-border) 72%);
background: color-mix(in srgb, #ef4444 8%, var(--color-background) 92%);
}
.wa-result-row {
display: flex;
align-items: flex-start;
gap: 0.8rem;
padding: 0.7rem 0;
}
.wa-result-row+.wa-result-row {
border-top: 1px solid color-mix(in srgb, var(--color-border) 85%, white 15%);
}
.wa-result-icon {
width: 1.25rem;
font-weight: 800;
line-height: 1.3;
}
.wa-result-title {
font-weight: 700;
margin-bottom: 0.18rem;
}
.wa-status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 5.25rem;
padding: 0.35rem 0.7rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 700;
}
.wa-status-pill.tone-success {
background: rgba(34, 197, 94, 0.14);
color: #7ee7a4;
}
.wa-status-pill.tone-ready {
background: rgba(59, 130, 246, 0.16);
color: #93c5fd;
}
.wa-status-pill.tone-warning {
background: rgba(245, 158, 11, 0.16);
color: #fcd34d;
}
.wa-status-pill.tone-muted {
background: rgba(148, 163, 184, 0.14);
color: #cbd5e1;
}
@media (max-width: 640px) {
.wa-summary-card,
.wa-step-header,
.wa-test-panel {
flex-direction: column;
align-items: stretch;
}
.wa-step-dots {
width: 100%;
justify-content: space-between;
}
.wa-status-pill {
min-width: 0;
align-self: flex-start;
}
}
</style>
</body>
</html>

View file

@ -0,0 +1,273 @@
import { createStore } from "/js/AlpineStore.js";
import * as API from "/js/api.js";
const API_BASE = "/plugins/_whatsapp_integration";
const STEPS = [
{
title: "Pair your account and set access",
description: "Turn on WhatsApp, connect the account, and choose who can reach it.",
},
{
title: "Choose where conversations go",
description: "Pick a project if you want one and shape how the agent should reply.",
},
];
function ensureConfig(config) {
if (!config || typeof config !== "object") return;
if (typeof config.enabled !== "boolean") config.enabled = false;
if (!config.mode) config.mode = "self-chat";
if (!config.bridge_port) config.bridge_port = 3100;
if (!config.poll_interval_seconds) config.poll_interval_seconds = 3;
if (typeof config.allow_group !== "boolean") config.allow_group = false;
if (Array.isArray(config.allowed_numbers)) return;
if (typeof config.allowed_numbers === "string") {
config.allowed_numbers = config.allowed_numbers
.split(",")
.map((item) => item.trim())
.filter((item) => item);
return;
}
config.allowed_numbers = [];
}
export const store = createStore("whatsappConfig", {
config: null,
projects: [],
guideOpen: false,
currentStep: 0,
testing: false,
testResults: null,
qrVisible: false,
qrStatus: "",
qrMessage: "",
qrDataUrl: null,
qrPollTimer: null,
disconnecting: false,
disconnectMessage: "",
steps: STEPS,
_projectsLoaded: false,
context: null,
get showFooterNav() {
return true;
},
get isFirstStep() {
return this.currentStep === 0;
},
get isLastStep() {
return this.currentStep >= this.steps.length - 1;
},
get nextButtonLabel() {
return this.isLastStep ? "Done" : "Next";
},
get footerStepLabel() {
return `Step ${this.currentStep + 1} of ${this.steps.length}`;
},
async init(config, context = null) {
this.config = config || null;
this.context = context;
ensureConfig(this.config);
this.guideOpen = !this.hasMeaningfulConfig() && window.innerWidth > 720;
this.currentStep = 0;
this.testing = false;
this.testResults = null;
this._installWizardFooter();
if (this._projectsLoaded) return;
try {
const response = await API.callJsonApi("projects", { action: "list" });
this.projects = response.data || [];
} catch (_) {
this.projects = [];
}
this._projectsLoaded = true;
},
cleanup() {
if (this.context?.wizardFooter?.owner === "whatsappConfig") {
this.context.wizardFooter = null;
}
this.hideQr();
this.config = null;
this.context = null;
this.guideOpen = false;
this.currentStep = 0;
this.testing = false;
this.testResults = null;
this.disconnecting = false;
this.disconnectMessage = "";
},
currentStepMeta() {
return this.steps[this.currentStep] || this.steps[0];
},
setStep(step) {
this.currentStep = Math.max(0, Math.min(this.steps.length - 1, Number(step) || 0));
},
nextStep() {
if (!this.isLastStep) this.currentStep += 1;
},
previousStep() {
if (!this.isFirstStep) this.currentStep -= 1;
},
hasMeaningfulConfig() {
if (!this.config) return false;
return !!(
this.config.enabled
|| String(this.config.project || "").trim()
|| String(this.config.agent_instructions || "").trim()
|| (Array.isArray(this.config.allowed_numbers) && this.config.allowed_numbers.length > 0)
);
},
allowedText() {
ensureConfig(this.config);
return (this.config?.allowed_numbers || []).join(", ");
},
allowedIsEmpty() {
ensureConfig(this.config);
return (this.config?.allowed_numbers || []).length === 0;
},
setAllowed(value) {
ensureConfig(this.config);
this.config.allowed_numbers = value
.split(",")
.map((item) => item.trim())
.filter((item) => item);
},
onEnabledChange() {
if (this.config?.enabled) return;
this.hideQr();
},
accessWarning() {
if (!this.config?.enabled) return "";
if (!this.allowedIsEmpty()) return "";
return "Allowed numbers is empty. If other people can message this number, they can reach your Agent Zero.";
},
statusLabel() {
if (!this.config?.enabled) return "Off";
if (this.qrStatus === "connected") return "Live";
if (this.allowedIsEmpty()) return "Open access";
return "Ready";
},
statusTone() {
const label = this.statusLabel();
if (label === "Live") return "success";
if (label === "Ready") return "ready";
if (label === "Off") return "muted";
return "warning";
},
modeSummary() {
if (!this.config) return "";
return this.config.mode === "self-chat" ? "Self-chat" : "Dedicated number";
},
projectSummary() {
return this.config?.project ? `Project: ${this.config.project}` : "No project";
},
async testConnection() {
this.testing = true;
this.testResults = null;
try {
this.testResults = await API.callJsonApi(`${API_BASE}/test_connection`, {
config: { bridge_port: this.config?.bridge_port },
});
} catch (error) {
this.testResults = {
success: false,
results: [{ test: "WhatsApp", ok: false, message: String(error) }],
};
}
this.testing = false;
},
testButtonLabel() {
return this.testing ? "Checking..." : "Check WhatsApp connection";
},
async showQr() {
this.qrVisible = true;
this.qrStatus = "loading";
this.qrMessage = "Starting the WhatsApp bridge...";
this.qrDataUrl = null;
await this.pollQr();
this.qrPollTimer = setInterval(() => this.pollQr(), 3000);
},
hideQr() {
this.qrVisible = false;
this.qrDataUrl = null;
this.qrStatus = "";
if (this.qrPollTimer) {
clearInterval(this.qrPollTimer);
this.qrPollTimer = null;
}
},
async pollQr() {
try {
const response = await API.callJsonApi(`${API_BASE}/qr_code`, {});
this.qrStatus = response.status || "error";
this.qrMessage = response.message || "";
this.qrDataUrl = response.qr || null;
if (response.status === "connected" && this.qrPollTimer) {
clearInterval(this.qrPollTimer);
this.qrPollTimer = null;
}
} catch (error) {
this.qrStatus = "error";
this.qrMessage = String(error);
this.qrDataUrl = null;
}
},
async disconnectAccount() {
if (!window.confirm("Disconnect this WhatsApp account? You will need to scan a new QR code to reconnect.")) return;
this.disconnecting = true;
this.disconnectMessage = "";
try {
const response = await API.callJsonApi(`${API_BASE}/disconnect`, {});
this.disconnectMessage = response.success ? "Account disconnected" : (response.message || "Disconnect failed");
} catch (error) {
this.disconnectMessage = String(error);
}
this.disconnecting = false;
},
_installWizardFooter() {
if (!this.context) return;
this.context.wizardFooter = {
owner: "whatsappConfig",
visible: () => this.showFooterNav,
canGoBack: () => !this.isFirstStep,
backLabel: () => "Back",
note: () => this.footerStepLabel,
showNext: () => !this.isLastStep,
nextLabel: () => this.nextButtonLabel,
nextDisabled: () => false,
showSave: () => this.isLastStep,
onBack: () => this.previousStep(),
onNext: () => this.nextStep(),
};
},
});