mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-04-28 03:30:23 +00:00
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:
parent
c06e13f8c2
commit
2000ba74a3
13 changed files with 3040 additions and 1066 deletions
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
404
plugins/_email_integration/webui/email-config-store.js
Normal file
404
plugins/_email_integration/webui/email-config-store.js
Normal 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;
|
||||
},
|
||||
});
|
||||
|
|
@ -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
|
|
@ -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(),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}",
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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;">✓</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">⚠</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">⚠</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>
|
||||
|
|
|
|||
273
plugins/_whatsapp_integration/webui/whatsapp-config-store.js
Normal file
273
plugins/_whatsapp_integration/webui/whatsapp-config-store.js
Normal 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(),
|
||||
};
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue