@rubytech/taskmaster 1.11.2 → 1.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agents/skills/workspace.js +3 -0
- package/dist/agents/tools/brand-settings-tool.js +7 -2
- package/dist/agents/tools/channel-settings-tool.js +18 -4
- package/dist/agents/tools/public-chat-settings-tool.js +15 -5
- package/dist/agents/tools/system-status-tool.js +24 -6
- package/dist/build-info.json +3 -3
- package/dist/config/port-defaults.js +0 -8
- package/dist/config/zod-schema.js +24 -1
- package/dist/control-ui/assets/{index-s8s_YKvR.js → index-4h8fLLNN.js} +395 -381
- package/dist/control-ui/assets/index-4h8fLLNN.js.map +1 -0
- package/dist/control-ui/index.html +1 -1
- package/dist/gateway/control-ui.js +25 -10
- package/dist/gateway/public-chat/deliver-email.js +5 -5
- package/dist/gateway/public-chat/deliver-otp.js +50 -16
- package/dist/gateway/public-chat/deliver-sms.js +53 -14
- package/dist/gateway/public-chat-api.js +35 -15
- package/dist/gateway/server-methods/public-chat.js +13 -6
- package/package.json +1 -1
- package/skills/brevo/references/sms-credits.md +28 -20
- package/taskmaster-docs/USER-GUIDE.md +51 -8
- package/dist/control-ui/assets/index-s8s_YKvR.js.map +0 -1
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<title>Taskmaster Control</title>
|
|
7
7
|
<meta name="color-scheme" content="dark light" />
|
|
8
8
|
<link rel="icon" type="image/png" href="./favicon.png" />
|
|
9
|
-
<script type="module" crossorigin src="./assets/index-
|
|
9
|
+
<script type="module" crossorigin src="./assets/index-4h8fLLNN.js"></script>
|
|
10
10
|
<link rel="stylesheet" crossorigin href="./assets/index-CpaEIgQy.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
@@ -5,7 +5,7 @@ import { resolveAgentWorkspaceRoot } from "../agents/agent-scope.js";
|
|
|
5
5
|
import { buildAgentSummaries } from "../commands/agents.config.js";
|
|
6
6
|
import { DEFAULT_ASSISTANT_IDENTITY, resolveAssistantIdentity } from "./assistant-identity.js";
|
|
7
7
|
import { resolveAgentBoundAccountId } from "../routing/bindings.js";
|
|
8
|
-
import { detectOtpChannels } from "./public-chat/deliver-otp.js";
|
|
8
|
+
import { detectOtpChannels, normalizeVerifyMethods } from "./public-chat/deliver-otp.js";
|
|
9
9
|
import { resolvePublicAgentId } from "./public-chat/session.js";
|
|
10
10
|
import { findBrandLogo } from "./server-methods/brand.js";
|
|
11
11
|
import { buildControlUiAvatarUrl, CONTROL_UI_AVATAR_PREFIX, normalizeControlUiBasePath, resolveAssistantAvatarUrl, } from "./control-ui-shared.js";
|
|
@@ -527,7 +527,9 @@ export function handlePublicChatHttpRequest(req, res, opts) {
|
|
|
527
527
|
const avatarValue = resolvedAvatar && /^(https?:\/\/|data:image\/|\/)/i.test(resolvedAvatar)
|
|
528
528
|
? resolvedAvatar
|
|
529
529
|
: undefined;
|
|
530
|
-
const authMode = config.publicChat
|
|
530
|
+
const authMode = (config.publicChat?.accountSettings?.[accountId]?.auth ??
|
|
531
|
+
config.publicChat?.auth ??
|
|
532
|
+
"anonymous");
|
|
531
533
|
const cookieTtlDays = config.publicChat.cookieTtlDays ?? 30;
|
|
532
534
|
const brandName = config.ui?.brand?.name;
|
|
533
535
|
const brandIconUrl = resolveBrandIconUrl(config.ui?.brand?.icon, root);
|
|
@@ -570,7 +572,7 @@ export function handlePublicChatHttpRequest(req, res, opts) {
|
|
|
570
572
|
// Inject public-chat globals before </head>
|
|
571
573
|
const publicScript = `<script>` +
|
|
572
574
|
`window.__TASKMASTER_PUBLIC_CHAT__=true;` +
|
|
573
|
-
`window.__TASKMASTER_PUBLIC_CHAT_CONFIG__=${JSON.stringify({ accountId, auth: authMode, cookieTtlDays, otpChannels, verifyMethods: config.publicChat?.verifyMethods ?? ["
|
|
575
|
+
`window.__TASKMASTER_PUBLIC_CHAT_CONFIG__=${JSON.stringify({ accountId, auth: authMode, cookieTtlDays, otpChannels, verifyMethods: normalizeVerifyMethods(config.publicChat?.accountSettings?.[accountId]?.verifyMethods ?? config.publicChat?.verifyMethods ?? ["whatsapp", "sms"]), greeting: config.publicChat?.greetings?.[accountId] || undefined })};` +
|
|
574
576
|
`</script>`;
|
|
575
577
|
const headClose = baseInjected.indexOf("</head>");
|
|
576
578
|
const withPublic = headClose !== -1
|
|
@@ -584,24 +586,37 @@ export function handlePublicChatHttpRequest(req, res, opts) {
|
|
|
584
586
|
/** Widget script content — self-contained JS for embedding. */
|
|
585
587
|
const WIDGET_SCRIPT = `(function(){
|
|
586
588
|
"use strict";
|
|
587
|
-
var cfg={server:"",accountId:"",color:"
|
|
589
|
+
var cfg={server:"",accountId:"",color:"",bgColor:"#1a1a2e",btnColor:""};
|
|
588
590
|
var isOpen=false;
|
|
589
591
|
var btn,overlay,iframe;
|
|
592
|
+
var SVG_NS="http://www.w3.org/2000/svg";
|
|
593
|
+
function svgEl(tag,attrs){var e=document.createElementNS(SVG_NS,tag);for(var k in attrs)e.setAttribute(k,attrs[k]);return e;}
|
|
594
|
+
function makeChatIcon(){var s=svgEl("svg",{width:"22",height:"22",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2","stroke-linecap":"round","stroke-linejoin":"round"});s.appendChild(svgEl("path",{d:"M7.9 20A9 9 0 1 0 4 16.1L2 22Z"}));return s;}
|
|
595
|
+
function makeCloseIcon(){var s=svgEl("svg",{width:"20",height:"20",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":"2.5","stroke-linecap":"round","stroke-linejoin":"round"});s.appendChild(svgEl("line",{x1:"18",y1:"6",x2:"6",y2:"18"}));s.appendChild(svgEl("line",{x1:"6",y1:"6",x2:"18",y2:"18"}));return s;}
|
|
596
|
+
function setIcon(el,fn){while(el.firstChild)el.removeChild(el.firstChild);el.appendChild(fn());}
|
|
590
597
|
|
|
591
598
|
function init(opts){
|
|
592
599
|
if(opts&&opts.server) cfg.server=opts.server.replace(/\\/$/,"");
|
|
593
600
|
if(opts&&opts.accountId) cfg.accountId=opts.accountId;
|
|
594
|
-
if(opts&&opts.color) cfg.
|
|
601
|
+
if(opts&&opts.color) cfg.btnColor=opts.color;
|
|
595
602
|
if(opts&&opts.bgColor) cfg.bgColor=opts.bgColor;
|
|
596
|
-
|
|
603
|
+
if(!cfg.btnColor){
|
|
604
|
+
fetch(cfg.server+"/public/api/v1/"+encodeURIComponent(cfg.accountId)+"/capabilities")
|
|
605
|
+
.then(function(r){return r.json()})
|
|
606
|
+
.then(function(d){if(d.background_color) cfg.btnColor=d.background_color; else if(d.accent_color) cfg.btnColor=d.accent_color; build();})
|
|
607
|
+
.catch(function(){build();});
|
|
608
|
+
} else {
|
|
609
|
+
build();
|
|
610
|
+
}
|
|
597
611
|
}
|
|
598
612
|
|
|
599
613
|
function build(){
|
|
614
|
+
if(!cfg.btnColor) cfg.btnColor="#1a1a2e";
|
|
600
615
|
var css=document.createElement("style");
|
|
601
616
|
css.textContent=[
|
|
602
617
|
".tm-widget-btn{position:fixed;bottom:20px;right:20px;width:48px;height:48px;",
|
|
603
|
-
"border-radius:50%;background:"+cfg.
|
|
604
|
-
"box-shadow:0 2px 8px rgba(0,0,0,.3);z-index:999999;
|
|
618
|
+
"border-radius:50%;background:"+cfg.btnColor+";color:#fff;border:none;cursor:pointer;",
|
|
619
|
+
"box-shadow:0 2px 8px rgba(0,0,0,.3);z-index:999999;",
|
|
605
620
|
"display:flex;align-items:center;justify-content:center;transition:transform .2s}",
|
|
606
621
|
".tm-widget-btn:hover{transform:scale(1.08)}",
|
|
607
622
|
".tm-widget-overlay{position:fixed;bottom:78px;right:20px;width:400px;height:600px;",
|
|
@@ -618,7 +633,7 @@ const WIDGET_SCRIPT = `(function(){
|
|
|
618
633
|
|
|
619
634
|
btn=document.createElement("button");
|
|
620
635
|
btn.className="tm-widget-btn";
|
|
621
|
-
btn
|
|
636
|
+
setIcon(btn,makeChatIcon);
|
|
622
637
|
btn.setAttribute("aria-label","Chat");
|
|
623
638
|
btn.onclick=toggle;
|
|
624
639
|
document.body.appendChild(btn);
|
|
@@ -635,7 +650,7 @@ const WIDGET_SCRIPT = `(function(){
|
|
|
635
650
|
function toggle(){
|
|
636
651
|
isOpen=!isOpen;
|
|
637
652
|
overlay.classList.toggle("open",isOpen);
|
|
638
|
-
btn
|
|
653
|
+
setIcon(btn,isOpen?makeCloseIcon:makeChatIcon);
|
|
639
654
|
}
|
|
640
655
|
|
|
641
656
|
window.Taskmaster={init:init};
|
|
@@ -45,24 +45,24 @@ async function fetchVerifiedSender(apiKey) {
|
|
|
45
45
|
* in order: explicit config value → cached sender → live Brevo API lookup.
|
|
46
46
|
* Returns null if no API key is configured.
|
|
47
47
|
*/
|
|
48
|
-
export async function resolveEmailCredentials() {
|
|
48
|
+
export async function resolveEmailCredentials(fromName) {
|
|
49
49
|
const cfg = loadConfig();
|
|
50
50
|
const email = cfg.publicChat?.email;
|
|
51
51
|
if (!email?.apiKey)
|
|
52
52
|
return null;
|
|
53
53
|
// Explicit from address in config — use it directly
|
|
54
54
|
if (email.from)
|
|
55
|
-
return { apiKey: email.apiKey, from: email.from };
|
|
55
|
+
return { apiKey: email.apiKey, from: email.from, fromName };
|
|
56
56
|
// Cached sender for this API key
|
|
57
57
|
if (cachedSender && cachedSender.apiKey === email.apiKey) {
|
|
58
|
-
return { apiKey: email.apiKey, from: cachedSender.from };
|
|
58
|
+
return { apiKey: email.apiKey, from: cachedSender.from, fromName };
|
|
59
59
|
}
|
|
60
60
|
// Auto-detect from Brevo senders API
|
|
61
61
|
const sender = await fetchVerifiedSender(email.apiKey);
|
|
62
62
|
if (!sender)
|
|
63
63
|
return null;
|
|
64
64
|
cachedSender = { apiKey: email.apiKey, from: sender };
|
|
65
|
-
return { apiKey: email.apiKey, from: sender };
|
|
65
|
+
return { apiKey: email.apiKey, from: sender, fromName };
|
|
66
66
|
}
|
|
67
67
|
/**
|
|
68
68
|
* Send an email via the Brevo transactional email API.
|
|
@@ -76,7 +76,7 @@ export async function sendEmail(to, subject, body, creds) {
|
|
|
76
76
|
Accept: "application/json",
|
|
77
77
|
},
|
|
78
78
|
body: JSON.stringify({
|
|
79
|
-
sender: { email: creds.from },
|
|
79
|
+
sender: creds.fromName ? { email: creds.from, name: creds.fromName } : { email: creds.from },
|
|
80
80
|
to: [{ email: to }],
|
|
81
81
|
subject,
|
|
82
82
|
textContent: body,
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Deliver OTP verification codes via WhatsApp
|
|
2
|
+
* Deliver OTP verification codes via WhatsApp, SMS (Brevo), or email (Brevo).
|
|
3
3
|
* Both SMS and email use the same Brevo API key — SMS requires prepaid credits.
|
|
4
|
+
*
|
|
5
|
+
* The admin controls which channels are enabled via `verifyMethods` config:
|
|
6
|
+
* "whatsapp", "sms", "email". The delivery logic only tries enabled channels.
|
|
4
7
|
*/
|
|
5
8
|
import { getActiveWebListener } from "../../web/active-listener.js";
|
|
6
9
|
import { sendMessageWhatsApp } from "../../web/outbound.js";
|
|
@@ -10,6 +13,27 @@ import { hasEmailApiKey, resolveEmailCredentials, sendEmail } from "./deliver-em
|
|
|
10
13
|
function isEmail(identifier) {
|
|
11
14
|
return identifier.includes("@");
|
|
12
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Normalize verify methods — expand deprecated "phone" into ["whatsapp", "sms"].
|
|
18
|
+
* Returns a deduplicated array of canonical method names.
|
|
19
|
+
*/
|
|
20
|
+
export function normalizeVerifyMethods(methods) {
|
|
21
|
+
const result = new Set();
|
|
22
|
+
for (const m of methods) {
|
|
23
|
+
if (m === "phone") {
|
|
24
|
+
result.add("whatsapp");
|
|
25
|
+
result.add("sms");
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
result.add(m);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return [...result];
|
|
32
|
+
}
|
|
33
|
+
/** Check whether any phone-based verify method is enabled. */
|
|
34
|
+
export function hasPhoneMethod(methods) {
|
|
35
|
+
return methods.includes("whatsapp") || methods.includes("sms");
|
|
36
|
+
}
|
|
13
37
|
/**
|
|
14
38
|
* Detect which OTP delivery channels are currently available.
|
|
15
39
|
* Does not attempt delivery — used by the capabilities endpoint.
|
|
@@ -28,38 +52,48 @@ export function detectOtpChannels(whatsappAccountId) {
|
|
|
28
52
|
* Deliver a verification code to the given identifier.
|
|
29
53
|
*
|
|
30
54
|
* Email identifiers (containing @) are sent via Brevo email API.
|
|
31
|
-
* Phone identifiers
|
|
32
|
-
*
|
|
55
|
+
* Phone identifiers try WhatsApp then SMS, but only channels that
|
|
56
|
+
* are enabled in `enabledMethods` are attempted.
|
|
33
57
|
*/
|
|
34
|
-
export async function deliverOtp(identifier, code, accountId) {
|
|
58
|
+
export async function deliverOtp(identifier, code, accountId, enabledMethods, brandName) {
|
|
35
59
|
const message = `Your verification code is: ${code}`;
|
|
60
|
+
const methods = enabledMethods ?? ["whatsapp", "sms", "email"];
|
|
36
61
|
// Email identifiers — deliver via Brevo email API
|
|
37
62
|
if (isEmail(identifier)) {
|
|
38
|
-
const emailCreds = await resolveEmailCredentials();
|
|
63
|
+
const emailCreds = await resolveEmailCredentials(brandName);
|
|
39
64
|
if (!emailCreds) {
|
|
40
65
|
throw new Error("Email verification is not configured. Add a Brevo API key and verify a sender in the Brevo console.");
|
|
41
66
|
}
|
|
42
67
|
await sendEmail(identifier, "Your verification code", message, emailCreds);
|
|
43
68
|
return { channel: "email" };
|
|
44
69
|
}
|
|
45
|
-
// Phone identifiers — WhatsApp
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
70
|
+
// Phone identifiers — try enabled channels in order: WhatsApp, then SMS
|
|
71
|
+
const tryWhatsApp = methods.includes("whatsapp") && !!getActiveWebListener(accountId);
|
|
72
|
+
const trySms = methods.includes("sms");
|
|
73
|
+
if (tryWhatsApp) {
|
|
49
74
|
try {
|
|
50
75
|
await sendMessageWhatsApp(identifier, message, { verbose: false, accountId });
|
|
51
76
|
return { channel: "whatsapp" };
|
|
52
77
|
}
|
|
53
78
|
catch {
|
|
54
|
-
// WhatsApp failed — fall through to SMS
|
|
79
|
+
// WhatsApp failed — fall through to SMS if enabled
|
|
55
80
|
}
|
|
56
81
|
}
|
|
57
|
-
if (
|
|
58
|
-
await
|
|
59
|
-
|
|
82
|
+
if (trySms) {
|
|
83
|
+
const smsCreds = await resolveSmsCredentials(brandName);
|
|
84
|
+
if (smsCreds) {
|
|
85
|
+
await sendSms(identifier, message, smsCreds);
|
|
86
|
+
return { channel: "sms" };
|
|
87
|
+
}
|
|
60
88
|
}
|
|
61
|
-
|
|
62
|
-
|
|
89
|
+
// Build a specific error message based on what was tried
|
|
90
|
+
const tried = [];
|
|
91
|
+
if (methods.includes("whatsapp"))
|
|
92
|
+
tried.push("WhatsApp");
|
|
93
|
+
if (methods.includes("sms"))
|
|
94
|
+
tried.push("SMS");
|
|
95
|
+
if (tried.length === 0) {
|
|
96
|
+
throw new Error("No phone verification channel is enabled. Enable WhatsApp or SMS in Public Chat settings.");
|
|
63
97
|
}
|
|
64
|
-
throw new Error(
|
|
98
|
+
throw new Error(`Failed to send verification code via ${tried.join(" and ")}. Check that the channel is connected and configured.`);
|
|
65
99
|
}
|
|
@@ -1,18 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Lightweight Brevo SMS sender — single HTTP POST, no SDK dependency.
|
|
3
3
|
*
|
|
4
|
-
* Uses the same Brevo API key as email delivery. The sender name
|
|
5
|
-
*
|
|
4
|
+
* Uses the same Brevo API key as email delivery. The SMS sender name comes
|
|
5
|
+
* from the same Brevo verified sender that email uses — matched by email
|
|
6
|
+
* address, so both channels present consistent branding.
|
|
6
7
|
* SMS requires prepaid credits in the Brevo account.
|
|
7
8
|
*/
|
|
8
9
|
import { loadConfig } from "../../config/config.js";
|
|
9
|
-
|
|
10
|
+
import { resolveEmailCredentials } from "./deliver-email.js";
|
|
11
|
+
/** Cached sender name (already truncated to ≤11 chars). */
|
|
10
12
|
let cachedSenderName = null;
|
|
11
13
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
+
* Truncate a sender name to fit the GSM alphanumeric sender limit (11 chars).
|
|
15
|
+
* Tries to break at a word boundary to avoid ugly truncations.
|
|
14
16
|
*/
|
|
15
|
-
|
|
17
|
+
function truncateSender(name) {
|
|
18
|
+
if (name.length <= 11)
|
|
19
|
+
return name;
|
|
20
|
+
// Try the first word — often the business name
|
|
21
|
+
const firstWord = name.split(/\s/)[0];
|
|
22
|
+
if (firstWord.length <= 11)
|
|
23
|
+
return firstWord;
|
|
24
|
+
// Last resort: hard truncate
|
|
25
|
+
return name.slice(0, 11);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the SMS sender name from Brevo's verified senders.
|
|
29
|
+
*
|
|
30
|
+
* If an email address is provided (from config), finds the matching sender
|
|
31
|
+
* and uses its display name — this ensures SMS and email use the same identity.
|
|
32
|
+
* Falls back to the first active sender, then "Verify" as a safe default.
|
|
33
|
+
*/
|
|
34
|
+
async function fetchSenderName(apiKey, matchEmail) {
|
|
16
35
|
try {
|
|
17
36
|
const res = await fetch("https://api.brevo.com/v3/senders", {
|
|
18
37
|
headers: { "api-key": apiKey, Accept: "application/json" },
|
|
@@ -20,8 +39,17 @@ async function fetchSenderName(apiKey) {
|
|
|
20
39
|
if (!res.ok)
|
|
21
40
|
return "Verify";
|
|
22
41
|
const json = (await res.json());
|
|
23
|
-
const
|
|
24
|
-
|
|
42
|
+
const senders = json.senders ?? [];
|
|
43
|
+
// Match the sender whose email matches the one configured for email delivery
|
|
44
|
+
if (matchEmail) {
|
|
45
|
+
const match = senders.find((s) => s.email?.toLowerCase() === matchEmail.toLowerCase() && s.name);
|
|
46
|
+
if (match?.name)
|
|
47
|
+
return truncateSender(match.name);
|
|
48
|
+
}
|
|
49
|
+
// Fallback: first active sender with a name
|
|
50
|
+
const active = senders.find((s) => s.active && s.name);
|
|
51
|
+
const raw = active?.name ?? senders[0]?.name ?? "Verify";
|
|
52
|
+
return truncateSender(raw);
|
|
25
53
|
}
|
|
26
54
|
catch {
|
|
27
55
|
return "Verify";
|
|
@@ -38,19 +66,30 @@ export function hasSmsApiKey() {
|
|
|
38
66
|
/**
|
|
39
67
|
* Resolve SMS credentials from config.
|
|
40
68
|
*
|
|
41
|
-
* Uses the same Brevo API key as email. The sender name is resolved
|
|
42
|
-
*
|
|
69
|
+
* Uses the same Brevo API key as email. The sender name is resolved by asking
|
|
70
|
+
* the email delivery module which sender it uses, then finding that sender's
|
|
71
|
+
* display name in Brevo. This ensures both email and SMS present the same
|
|
72
|
+
* brand identity. The name is truncated to 11 characters (GSM limit).
|
|
43
73
|
* Returns null if no API key is configured.
|
|
44
74
|
*/
|
|
45
|
-
export async function resolveSmsCredentials() {
|
|
75
|
+
export async function resolveSmsCredentials(brandName) {
|
|
46
76
|
const cfg = loadConfig();
|
|
47
77
|
const apiKey = cfg.publicChat?.email?.apiKey;
|
|
48
78
|
if (!apiKey)
|
|
49
79
|
return null;
|
|
50
|
-
|
|
80
|
+
// Brand name override takes precedence — use it directly (truncated to GSM limit)
|
|
81
|
+
if (brandName) {
|
|
82
|
+
return { apiKey, sender: truncateSender(brandName) };
|
|
83
|
+
}
|
|
84
|
+
if (cachedSenderName &&
|
|
85
|
+
cachedSenderName.apiKey === apiKey &&
|
|
86
|
+
cachedSenderName.name.length <= 11) {
|
|
51
87
|
return { apiKey, sender: cachedSenderName.name };
|
|
52
88
|
}
|
|
53
|
-
|
|
89
|
+
// Resolve the email address that email delivery uses — ensures SMS matches
|
|
90
|
+
// the same Brevo sender regardless of whether `from` is set in config.
|
|
91
|
+
const emailCreds = await resolveEmailCredentials();
|
|
92
|
+
const name = await fetchSenderName(apiKey, emailCreds?.from);
|
|
54
93
|
cachedSenderName = { apiKey, name };
|
|
55
94
|
return { apiKey, sender: name };
|
|
56
95
|
}
|
|
@@ -74,7 +113,7 @@ export async function sendSms(to, body, creds) {
|
|
|
74
113
|
});
|
|
75
114
|
if (!res.ok) {
|
|
76
115
|
const text = await res.text().catch(() => "");
|
|
77
|
-
throw new Error(`Brevo SMS failed (${res.status}): ${text}`);
|
|
116
|
+
throw new Error(`Brevo SMS failed (${res.status}): ${text} [sender=${JSON.stringify(creds.sender)}, recipient=${to}]`);
|
|
78
117
|
}
|
|
79
118
|
const json = (await res.json());
|
|
80
119
|
return { id: String(json.messageId ?? "unknown") };
|
|
@@ -36,7 +36,7 @@ import { createInternalHookEvent, triggerInternalHook } from "../hooks/internal-
|
|
|
36
36
|
import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js";
|
|
37
37
|
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js";
|
|
38
38
|
import { requestOtp, verifyOtp } from "./public-chat/otp.js";
|
|
39
|
-
import { deliverOtp, detectOtpChannels } from "./public-chat/deliver-otp.js";
|
|
39
|
+
import { deliverOtp, detectOtpChannels, hasPhoneMethod, normalizeVerifyMethods, } from "./public-chat/deliver-otp.js";
|
|
40
40
|
import { buildPublicSessionKey, resolvePublicAgentId } from "./public-chat/session.js";
|
|
41
41
|
import { loadSessionEntry, readSessionMessages } from "./session-utils.js";
|
|
42
42
|
import { extractFileAttachments, sanitizeMediaForChat, stripEnvelopeFromMessages, } from "./chat-sanitize.js";
|
|
@@ -98,14 +98,23 @@ function isValidEmail(email) {
|
|
|
98
98
|
function normalizePhone(raw) {
|
|
99
99
|
return raw.replace(/[\s\-()]/g, "");
|
|
100
100
|
}
|
|
101
|
+
/** Resolve the auth mode for a specific account, falling back to the global setting. */
|
|
102
|
+
function resolveAccountAuthMode(cfg, accountId) {
|
|
103
|
+
return cfg.publicChat?.accountSettings?.[accountId]?.auth ?? cfg.publicChat?.auth ?? "anonymous";
|
|
104
|
+
}
|
|
105
|
+
/** Resolve the verify methods for a specific account, falling back to the global setting. */
|
|
106
|
+
function resolveAccountVerifyMethods(cfg, accountId) {
|
|
107
|
+
return (cfg.publicChat?.accountSettings?.[accountId]?.verifyMethods ??
|
|
108
|
+
cfg.publicChat?.verifyMethods ?? ["whatsapp", "sms"]);
|
|
109
|
+
}
|
|
101
110
|
/** Check whether the auth mode allows anonymous sessions. */
|
|
102
|
-
function allowsAnonymous(cfg) {
|
|
103
|
-
const mode = cfg
|
|
111
|
+
function allowsAnonymous(cfg, accountId) {
|
|
112
|
+
const mode = resolveAccountAuthMode(cfg, accountId);
|
|
104
113
|
return mode === "anonymous" || mode === "choice";
|
|
105
114
|
}
|
|
106
115
|
/** Check whether the auth mode allows OTP verification. */
|
|
107
|
-
function allowsVerified(cfg) {
|
|
108
|
-
const mode = cfg
|
|
116
|
+
function allowsVerified(cfg, accountId) {
|
|
117
|
+
const mode = resolveAccountAuthMode(cfg, accountId);
|
|
109
118
|
return mode === "verified" || mode === "choice";
|
|
110
119
|
}
|
|
111
120
|
function writeSse(res, data) {
|
|
@@ -184,7 +193,7 @@ async function handleSession(req, res, accountId, cfg, maxBodyBytes) {
|
|
|
184
193
|
sendMethodNotAllowed(res);
|
|
185
194
|
return;
|
|
186
195
|
}
|
|
187
|
-
if (!allowsAnonymous(cfg)) {
|
|
196
|
+
if (!allowsAnonymous(cfg, accountId)) {
|
|
188
197
|
sendForbidden(res, "anonymous sessions are disabled — use OTP verification");
|
|
189
198
|
return;
|
|
190
199
|
}
|
|
@@ -214,7 +223,7 @@ async function handleOtpRequest(req, res, accountId, cfg, maxBodyBytes) {
|
|
|
214
223
|
sendMethodNotAllowed(res);
|
|
215
224
|
return;
|
|
216
225
|
}
|
|
217
|
-
if (!allowsVerified(cfg)) {
|
|
226
|
+
if (!allowsVerified(cfg, accountId)) {
|
|
218
227
|
sendForbidden(res, "OTP verification is disabled for this account");
|
|
219
228
|
return;
|
|
220
229
|
}
|
|
@@ -228,10 +237,10 @@ async function handleOtpRequest(req, res, accountId, cfg, maxBodyBytes) {
|
|
|
228
237
|
: typeof payload.phone === "string"
|
|
229
238
|
? payload.phone.trim()
|
|
230
239
|
: "";
|
|
231
|
-
const
|
|
232
|
-
const verifyMethods = cfg
|
|
240
|
+
const isEmailId = rawIdentifier.includes("@");
|
|
241
|
+
const verifyMethods = normalizeVerifyMethods(resolveAccountVerifyMethods(cfg, accountId));
|
|
233
242
|
let identifier;
|
|
234
|
-
if (
|
|
243
|
+
if (isEmailId) {
|
|
235
244
|
if (!verifyMethods.includes("email")) {
|
|
236
245
|
sendForbidden(res, "email verification is not enabled for this account");
|
|
237
246
|
return;
|
|
@@ -243,7 +252,7 @@ async function handleOtpRequest(req, res, accountId, cfg, maxBodyBytes) {
|
|
|
243
252
|
}
|
|
244
253
|
}
|
|
245
254
|
else {
|
|
246
|
-
if (!verifyMethods
|
|
255
|
+
if (!hasPhoneMethod(verifyMethods)) {
|
|
247
256
|
sendForbidden(res, "phone verification is not enabled for this account");
|
|
248
257
|
return;
|
|
249
258
|
}
|
|
@@ -265,8 +274,14 @@ async function handleOtpRequest(req, res, accountId, cfg, maxBodyBytes) {
|
|
|
265
274
|
// OTP code is sent from the correct number (not the first active account).
|
|
266
275
|
const agentId = resolvePublicAgentId(cfg, accountId);
|
|
267
276
|
const whatsappAccountId = resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ?? undefined;
|
|
277
|
+
// Narrow delivery to visitor's preferred channel if specified and admin-enabled
|
|
278
|
+
const preferredChannel = typeof payload.channel === "string" ? payload.channel : undefined;
|
|
279
|
+
const deliveryMethods = preferredChannel && verifyMethods.includes(preferredChannel)
|
|
280
|
+
? [preferredChannel]
|
|
281
|
+
: verifyMethods;
|
|
282
|
+
const brandName = accountId || undefined;
|
|
268
283
|
try {
|
|
269
|
-
const delivery = await deliverOtp(identifier, result.code, whatsappAccountId);
|
|
284
|
+
const delivery = await deliverOtp(identifier, result.code, whatsappAccountId, deliveryMethods, brandName);
|
|
270
285
|
sendJson(res, 200, { ok: true, channel: delivery.channel });
|
|
271
286
|
}
|
|
272
287
|
catch (err) {
|
|
@@ -282,7 +297,7 @@ async function handleOtpVerify(req, res, accountId, cfg, maxBodyBytes) {
|
|
|
282
297
|
sendMethodNotAllowed(res);
|
|
283
298
|
return;
|
|
284
299
|
}
|
|
285
|
-
if (!allowsVerified(cfg)) {
|
|
300
|
+
if (!allowsVerified(cfg, accountId)) {
|
|
286
301
|
sendForbidden(res, "OTP verification is disabled for this account");
|
|
287
302
|
return;
|
|
288
303
|
}
|
|
@@ -750,14 +765,19 @@ async function handleCapabilities(req, res, accountId, cfg) {
|
|
|
750
765
|
sendMethodNotAllowed(res, "GET");
|
|
751
766
|
return;
|
|
752
767
|
}
|
|
753
|
-
const authMode = cfg
|
|
754
|
-
const verifyMethods = cfg
|
|
768
|
+
const authMode = resolveAccountAuthMode(cfg, accountId);
|
|
769
|
+
const verifyMethods = normalizeVerifyMethods(resolveAccountVerifyMethods(cfg, accountId));
|
|
755
770
|
const agentId = resolvePublicAgentId(cfg, accountId);
|
|
756
771
|
const whatsappAccountId = resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ?? undefined;
|
|
757
772
|
const channels = detectOtpChannels(whatsappAccountId);
|
|
773
|
+
const wsBrand = cfg.workspaces?.[accountId]?.brand;
|
|
774
|
+
const accentColor = wsBrand?.accentColor ?? cfg.ui?.seamColor ?? null;
|
|
775
|
+
const backgroundColor = wsBrand?.backgroundColor ?? null;
|
|
758
776
|
sendJson(res, 200, {
|
|
759
777
|
auth: authMode,
|
|
760
778
|
verify_methods: verifyMethods,
|
|
779
|
+
accent_color: accentColor,
|
|
780
|
+
background_color: backgroundColor,
|
|
761
781
|
otp: {
|
|
762
782
|
available: channels.length > 0,
|
|
763
783
|
channels,
|
|
@@ -6,7 +6,7 @@ import { resolveAgentBoundAccountId } from "../../routing/bindings.js";
|
|
|
6
6
|
import { generateGreeting } from "../../suggestions/greeting.js";
|
|
7
7
|
import { ErrorCodes, errorShape } from "../protocol/index.js";
|
|
8
8
|
import { requestOtp, verifyOtp } from "../public-chat/otp.js";
|
|
9
|
-
import { deliverOtp } from "../public-chat/deliver-otp.js";
|
|
9
|
+
import { deliverOtp, hasPhoneMethod, normalizeVerifyMethods } from "../public-chat/deliver-otp.js";
|
|
10
10
|
import { buildPublicSessionKey, resolvePublicAgentId } from "../public-chat/session.js";
|
|
11
11
|
/** Strip spaces, dashes, and parentheses from a phone number. */
|
|
12
12
|
function normalizePhone(raw) {
|
|
@@ -46,10 +46,11 @@ export const publicChatHandlers = {
|
|
|
46
46
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "public chat disabled"));
|
|
47
47
|
return;
|
|
48
48
|
}
|
|
49
|
-
const
|
|
50
|
-
const verifyMethods = cfg.publicChat?.
|
|
49
|
+
const isEmailId = rawIdentifier.includes("@");
|
|
50
|
+
const verifyMethods = normalizeVerifyMethods(cfg.publicChat?.accountSettings?.[accountId ?? ""]?.verifyMethods ??
|
|
51
|
+
cfg.publicChat?.verifyMethods ?? ["whatsapp", "sms"]);
|
|
51
52
|
let identifier;
|
|
52
|
-
if (
|
|
53
|
+
if (isEmailId) {
|
|
53
54
|
if (!verifyMethods.includes("email")) {
|
|
54
55
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "email verification is not enabled"));
|
|
55
56
|
return;
|
|
@@ -61,7 +62,7 @@ export const publicChatHandlers = {
|
|
|
61
62
|
}
|
|
62
63
|
}
|
|
63
64
|
else {
|
|
64
|
-
if (!verifyMethods
|
|
65
|
+
if (!hasPhoneMethod(verifyMethods)) {
|
|
65
66
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "phone verification is not enabled"));
|
|
66
67
|
return;
|
|
67
68
|
}
|
|
@@ -82,8 +83,14 @@ export const publicChatHandlers = {
|
|
|
82
83
|
const whatsappAccountId = agentId
|
|
83
84
|
? (resolveAgentBoundAccountId(cfg, agentId, "whatsapp") ?? undefined)
|
|
84
85
|
: undefined;
|
|
86
|
+
// Narrow delivery to visitor's preferred channel if specified and admin-enabled
|
|
87
|
+
const preferredChannel = typeof params.channel === "string" ? params.channel : undefined;
|
|
88
|
+
const deliveryMethods = preferredChannel && verifyMethods.includes(preferredChannel)
|
|
89
|
+
? [preferredChannel]
|
|
90
|
+
: verifyMethods;
|
|
91
|
+
const brandName = accountId || undefined;
|
|
85
92
|
try {
|
|
86
|
-
const delivery = await deliverOtp(identifier, result.code, whatsappAccountId);
|
|
93
|
+
const delivery = await deliverOtp(identifier, result.code, whatsappAccountId, deliveryMethods, brandName);
|
|
87
94
|
respond(true, { ok: true, channel: delivery.channel });
|
|
88
95
|
}
|
|
89
96
|
catch (err) {
|
package/package.json
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Walk the user through purchasing SMS credits in Brevo. SMS verification uses the same Brevo API key as email — no additional configuration in Taskmaster. The user just needs credits in their Brevo account.
|
|
4
4
|
|
|
5
|
-
**Important:** SMS credits are prepaid and never expire.
|
|
5
|
+
**Important:** SMS credits are prepaid and never expire. The cost depends on the destination country and number of messages.
|
|
6
|
+
|
|
7
|
+
**SMS sender name:** The name that appears on the SMS (e.g. "Taskmaster") is taken from the same Brevo verified sender used for email — matched by email address. If you have multiple senders in Brevo, the one whose email matches the configured sender is used. The name is truncated to 11 characters (GSM limit) — if longer, the first word is used (e.g. "Rubytech LLC" becomes "Rubytech"). To control the SMS sender name, edit the sender's display name in Brevo under **Senders, Domains & Dedicated IPs → Senders**.
|
|
6
8
|
|
|
7
9
|
---
|
|
8
10
|
|
|
@@ -21,38 +23,43 @@ Walk the user through purchasing SMS credits in Brevo. SMS verification uses the
|
|
|
21
23
|
|
|
22
24
|
## Step 2: Purchase SMS credits
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
>
|
|
27
|
-
>
|
|
28
|
-
>
|
|
29
|
-
>
|
|
30
|
-
>
|
|
31
|
-
> -
|
|
32
|
-
>
|
|
26
|
+
**Navigation: Dashboard home → lightning icon (top-right toolbar) → "Campaign usage and plan" popup → SMS section → "Get more credits".**
|
|
27
|
+
|
|
28
|
+
> "1. Go to **app.brevo.com** and log in — you should see the main dashboard ('Hello [name]')
|
|
29
|
+
> 2. In the **top-right toolbar**, click the **lightning bolt icon** (⚡) — it's to the left of the help (?) and gear (⚙️) icons
|
|
30
|
+
> 3. A **'Campaign usage and plan'** popup appears showing your plan, email credits, and SMS credits
|
|
31
|
+
> 4. Under the **SMS** section (shows '0 credits left' if you haven't bought any), click **'Get more credits'**
|
|
32
|
+
> 5. On the **'Add messages credits'** page:
|
|
33
|
+
> - **Type of message** — select **SMS**
|
|
34
|
+
> - **Number of messages** — enter **100** (a good starting amount for verification codes)
|
|
35
|
+
> - **Message destination** — select your country (e.g. **United Kingdom**)
|
|
36
|
+
> - The **Purchase summary** on the right shows the total cost
|
|
37
|
+
> 6. Click **'Proceed to checkout'** and complete the purchase
|
|
33
38
|
>
|
|
34
39
|
> Let me know when that's done."
|
|
35
40
|
|
|
41
|
+
If the user can't find the lightning icon:
|
|
42
|
+
|
|
43
|
+
> "In the top-right toolbar of the Brevo dashboard, there are several small icons. The lightning bolt (⚡) is the first one on the left, before the help (?), gear (⚙️), bell (🔔), and chart icons. Click that — it opens a popup showing your plan and credits."
|
|
44
|
+
|
|
36
45
|
Wait for the user to confirm.
|
|
37
46
|
|
|
38
47
|
## Step 3: Confirm
|
|
39
48
|
|
|
40
|
-
> "SMS verification
|
|
49
|
+
> "SMS credits are ready. To enable SMS verification, go to the **Setup** page → **Public Chat** settings → check the **SMS** checkbox under verification methods. You can enable SMS alongside WhatsApp, or on its own. When a visitor chooses SMS on the public chat page, the code is sent by text message. No restart needed — it uses your existing Brevo API key."
|
|
41
50
|
|
|
42
51
|
---
|
|
43
52
|
|
|
44
53
|
## Pricing reference
|
|
45
54
|
|
|
46
|
-
|
|
55
|
+
Pricing depends on the destination country and volume. The exact cost is shown on the checkout page when you select a country and message count. As a rough guide:
|
|
47
56
|
|
|
48
|
-
|
|
|
49
|
-
|
|
57
|
+
| Destination | 100 messages |
|
|
58
|
+
|-------------|-------------|
|
|
59
|
+
| UK | ~£2.82 |
|
|
50
60
|
| US / Canada | ~$1.09 |
|
|
51
|
-
| UK | ~$3.45 |
|
|
52
|
-
| Australia | ~$5.50 |
|
|
53
|
-
| EU (varies) | ~$3.00–$7.00 |
|
|
54
61
|
|
|
55
|
-
|
|
62
|
+
Credits are called "message credits" and can be used for SMS. On the free plan, WhatsApp messages via Brevo require a Professional plan upgrade — but we don't use Brevo for WhatsApp, so this doesn't apply.
|
|
56
63
|
|
|
57
64
|
---
|
|
58
65
|
|
|
@@ -61,8 +68,9 @@ Exact pricing is shown during purchase in the Brevo dashboard.
|
|
|
61
68
|
| Question | Answer |
|
|
62
69
|
|----------|--------|
|
|
63
70
|
| "Do credits expire?" | No — prepaid SMS credits never expire. |
|
|
64
|
-
| "How many credits per SMS?" |
|
|
71
|
+
| "How many credits per SMS?" | Depends on the destination country. The purchase page shows the exact conversion (e.g. 316.5 credits = 100 UK messages). |
|
|
65
72
|
| "What phone number do SMS come from?" | Brevo assigns a sender automatically based on the destination country. You don't need to buy or manage a phone number. |
|
|
66
|
-
| "What name appears on the SMS?" | The
|
|
73
|
+
| "What name appears on the SMS?" | The display name from the same Brevo verified sender used for email (matched by email address). Must be 11 characters or fewer — if longer, the first word is used. Edit sender names under **Senders, Domains & Dedicated IPs → Senders** in Brevo. |
|
|
67
74
|
| "Do I need to change anything in Taskmaster?" | No. If your Brevo API key is already configured, SMS works automatically when you have credits. |
|
|
68
75
|
| "Can I use SMS without email?" | The Brevo API key is configured through the email setup, but once configured it serves both email and SMS. You need at least one verified sender. |
|
|
76
|
+
| "Where do I check my remaining credits?" | Click the lightning bolt icon (⚡) in the top-right toolbar. The popup shows your SMS credits remaining. |
|