@rubytech/taskmaster 1.29.1 → 1.30.0
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/tools/cron-tool.js +10 -3
- package/dist/build-info.json +3 -3
- package/dist/control-ui/assets/{index-B_QfEVs7.js → index-CkGA_2Sq.js} +543 -469
- package/dist/control-ui/assets/index-CkGA_2Sq.js.map +1 -0
- package/dist/control-ui/assets/{index-CAE6wsJy.css → index-CxVLKv6r.css} +1 -1
- package/dist/control-ui/index.html +2 -2
- package/dist/cron/normalize.js +8 -4
- package/dist/cron/parse.js +48 -7
- package/dist/cron/service/jobs.js +4 -1
- package/dist/cron/service/ops.js +16 -0
- package/dist/gateway/server-methods/whatsapp-conversations.js +41 -7
- package/dist/web/auto-reply/monitor/group-activation.js +1 -1
- package/dist/web/inbound/monitor.js +34 -3
- package/package.json +1 -1
- package/skills/event-management/references/events.md +16 -6
- package/skills/whatsapp-business/SKILL.md +47 -0
- package/skills/whatsapp-business/references/setup-guide.md +258 -0
- package/dist/control-ui/assets/index-B_QfEVs7.js.map +0 -1
|
@@ -6,8 +6,8 @@
|
|
|
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-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="./assets/index-
|
|
9
|
+
<script type="module" crossorigin src="./assets/index-CkGA_2Sq.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="./assets/index-CxVLKv6r.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<taskmaster-app></taskmaster-app>
|
package/dist/cron/normalize.js
CHANGED
|
@@ -7,15 +7,19 @@ const DEFAULT_OPTIONS = {
|
|
|
7
7
|
function isRecord(value) {
|
|
8
8
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
9
|
}
|
|
10
|
-
function coerceSchedule(schedule) {
|
|
10
|
+
function coerceSchedule(schedule, opts) {
|
|
11
11
|
const next = { ...schedule };
|
|
12
12
|
const kind = typeof schedule.kind === "string" ? schedule.kind : undefined;
|
|
13
13
|
const atMsRaw = schedule.atMs;
|
|
14
14
|
const atRaw = schedule.at;
|
|
15
|
+
// When the agent passes a timezone-naive ISO string (e.g. "2026-03-11T09:00:00"),
|
|
16
|
+
// interpret it in the user's configured timezone — not UTC. A schedule-level
|
|
17
|
+
// `tz` field takes priority over the account default.
|
|
18
|
+
const tz = (typeof schedule.tz === "string" ? schedule.tz.trim() : undefined) || opts?.userTimezone;
|
|
15
19
|
const parsedAtMs = typeof atMsRaw === "string"
|
|
16
|
-
? parseAbsoluteTimeMs(atMsRaw)
|
|
20
|
+
? parseAbsoluteTimeMs(atMsRaw, tz)
|
|
17
21
|
: typeof atRaw === "string"
|
|
18
|
-
? parseAbsoluteTimeMs(atRaw)
|
|
22
|
+
? parseAbsoluteTimeMs(atRaw, tz)
|
|
19
23
|
: null;
|
|
20
24
|
if (!kind) {
|
|
21
25
|
if (typeof schedule.atMs === "number" ||
|
|
@@ -92,7 +96,7 @@ export function normalizeCronJobInput(raw, options = DEFAULT_OPTIONS) {
|
|
|
92
96
|
}
|
|
93
97
|
}
|
|
94
98
|
if (isRecord(base.schedule)) {
|
|
95
|
-
next.schedule = coerceSchedule(base.schedule);
|
|
99
|
+
next.schedule = coerceSchedule(base.schedule, { userTimezone: options.userTimezone });
|
|
96
100
|
}
|
|
97
101
|
if (isRecord(base.payload)) {
|
|
98
102
|
next.payload = coercePayload(base.payload);
|
package/dist/cron/parse.js
CHANGED
|
@@ -1,16 +1,57 @@
|
|
|
1
1
|
const ISO_TZ_RE = /(Z|[+-]\d{2}:?\d{2})$/i;
|
|
2
2
|
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
3
3
|
const ISO_DATE_TIME_RE = /^\d{4}-\d{2}-\d{2}T/;
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the UTC offset string (e.g. "+01:00") for a given IANA timezone at
|
|
6
|
+
* a specific instant. Returns undefined if the timezone is invalid.
|
|
7
|
+
*/
|
|
8
|
+
function offsetForTimezone(tz, date) {
|
|
9
|
+
try {
|
|
10
|
+
// Format in en-US with timeZoneName to extract the UTC offset.
|
|
11
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
12
|
+
timeZone: tz,
|
|
13
|
+
timeZoneName: "longOffset",
|
|
14
|
+
}).formatToParts(date);
|
|
15
|
+
const tzPart = parts.find((p) => p.type === "timeZoneName")?.value ?? "";
|
|
16
|
+
// Intl returns "GMT" for UTC, "GMT+1:00", "GMT-5:00", etc.
|
|
17
|
+
if (tzPart === "GMT")
|
|
18
|
+
return "+00:00";
|
|
19
|
+
const match = tzPart.match(/GMT([+-]\d{1,2}):?(\d{2})?/);
|
|
20
|
+
if (!match)
|
|
21
|
+
return undefined;
|
|
22
|
+
const hours = match[1].padStart(3, match[1][0] === "-" ? "-" : "+");
|
|
23
|
+
const minutes = match[2] ?? "00";
|
|
24
|
+
return `${hours}:${minutes}`;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function normalizeIso(raw, tz) {
|
|
5
31
|
if (ISO_TZ_RE.test(raw))
|
|
6
32
|
return raw;
|
|
7
|
-
if (ISO_DATE_RE.test(raw))
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
33
|
+
if (ISO_DATE_RE.test(raw)) {
|
|
34
|
+
const suffix = tz ? resolveOffsetSuffix(`${raw}T00:00:00`, tz) : "Z";
|
|
35
|
+
return `${raw}T00:00:00${suffix}`;
|
|
36
|
+
}
|
|
37
|
+
if (ISO_DATE_TIME_RE.test(raw)) {
|
|
38
|
+
const suffix = tz ? resolveOffsetSuffix(raw, tz) : "Z";
|
|
39
|
+
return `${raw}${suffix}`;
|
|
40
|
+
}
|
|
11
41
|
return raw;
|
|
12
42
|
}
|
|
13
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Compute the UTC offset suffix for a timezone-naive ISO string interpreted in
|
|
45
|
+
* the given IANA timezone. We do a two-pass approach: first parse as UTC to
|
|
46
|
+
* get a rough Date, then look up the real offset for that instant.
|
|
47
|
+
*/
|
|
48
|
+
function resolveOffsetSuffix(naiveIso, tz) {
|
|
49
|
+
const rough = new Date(`${naiveIso}Z`);
|
|
50
|
+
if (Number.isNaN(rough.getTime()))
|
|
51
|
+
return "Z";
|
|
52
|
+
return offsetForTimezone(tz, rough) ?? "Z";
|
|
53
|
+
}
|
|
54
|
+
export function parseAbsoluteTimeMs(input, tz) {
|
|
14
55
|
const raw = input.trim();
|
|
15
56
|
if (!raw)
|
|
16
57
|
return null;
|
|
@@ -19,6 +60,6 @@ export function parseAbsoluteTimeMs(input) {
|
|
|
19
60
|
if (Number.isFinite(n) && n > 0)
|
|
20
61
|
return Math.floor(n);
|
|
21
62
|
}
|
|
22
|
-
const parsed = Date.parse(
|
|
63
|
+
const parsed = Date.parse(normalizeIso(raw, tz));
|
|
23
64
|
return Number.isFinite(parsed) ? parsed : null;
|
|
24
65
|
}
|
|
@@ -23,7 +23,10 @@ export function computeJobNextRunAtMs(job, nowMs) {
|
|
|
23
23
|
// One-shot jobs stay due until they successfully finish.
|
|
24
24
|
if (job.state.lastStatus === "ok" && job.state.lastRunAtMs)
|
|
25
25
|
return undefined;
|
|
26
|
-
|
|
26
|
+
// Guard: if atMs is in the past, treat as expired — never fire immediately.
|
|
27
|
+
// A past atMs typically means the agent computed the timestamp incorrectly
|
|
28
|
+
// (e.g. timezone-naive ISO string interpreted as UTC).
|
|
29
|
+
return job.schedule.atMs > nowMs ? job.schedule.atMs : undefined;
|
|
27
30
|
}
|
|
28
31
|
// For "every" schedules without an explicit anchor, use the job's creation
|
|
29
32
|
// time so the schedule stays grid-aligned across daemon restarts. Without
|
package/dist/cron/service/ops.js
CHANGED
|
@@ -65,6 +65,22 @@ export async function add(state, input) {
|
|
|
65
65
|
warnIfDisabled(state, "add");
|
|
66
66
|
await ensureLoaded(state);
|
|
67
67
|
const job = createJob(state, input);
|
|
68
|
+
// If a one-shot "at" job resolved to no nextRunAtMs, the atMs was in the
|
|
69
|
+
// past. Reject it outright so the agent gets a clear error instead of a
|
|
70
|
+
// silently disabled job that never fires.
|
|
71
|
+
if (job.schedule.kind === "at" &&
|
|
72
|
+
job.enabled &&
|
|
73
|
+
job.state.nextRunAtMs == null &&
|
|
74
|
+
!job.state.lastRunAtMs) {
|
|
75
|
+
const nowMs = state.deps.nowMs();
|
|
76
|
+
const delta = nowMs - job.schedule.atMs;
|
|
77
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
78
|
+
const atIso = new Date(job.schedule.atMs).toISOString();
|
|
79
|
+
state.deps.log.warn({ atMs: job.schedule.atMs, atIso, nowIso, deltaMs: delta }, "cron.add rejected: atMs is in the past — reminder would fire immediately");
|
|
80
|
+
throw new Error(`schedule.atMs resolved to ${atIso} which is ${Math.round(delta / 1000)}s in the past ` +
|
|
81
|
+
`(current time: ${nowIso}). The reminder would fire immediately. ` +
|
|
82
|
+
"Call current_time to confirm the current date and year, then retry with a future timestamp.");
|
|
83
|
+
}
|
|
68
84
|
state.store?.jobs.push(job);
|
|
69
85
|
await persist(state);
|
|
70
86
|
armTimer(state);
|
|
@@ -4,7 +4,7 @@ import { updateSessionStore } from "../../config/sessions.js";
|
|
|
4
4
|
import { listRecords } from "../../records/records-manager.js";
|
|
5
5
|
import { normalizeAccountId } from "../../routing/session-key.js";
|
|
6
6
|
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
|
|
7
|
-
import { requireActiveWebListener } from "../../web/active-listener.js";
|
|
7
|
+
import { getActiveWebListener, requireActiveWebListener } from "../../web/active-listener.js";
|
|
8
8
|
import { stripEnvelope } from "../chat-sanitize.js";
|
|
9
9
|
import { ErrorCodes, errorShape, formatValidationErrors, validateWhatsAppConversationsParams, validateWhatsAppGroupInfoParams, validateWhatsAppMessagesParams, validateWhatsAppSendMessageParams, validateWhatsAppSetActivationParams, } from "../protocol/index.js";
|
|
10
10
|
import { loadCombinedSessionStoreForGateway, parseGroupKey } from "../session-utils.js";
|
|
@@ -239,9 +239,9 @@ export const whatsappConversationsHandlers = {
|
|
|
239
239
|
respond(true, { messages: [] });
|
|
240
240
|
return;
|
|
241
241
|
}
|
|
242
|
-
//
|
|
242
|
+
// --- Session transcript messages (agent-processed only) ---
|
|
243
243
|
const rawMessages = readSessionMessages(matchedSessionId, storePath, matchedSessionFile);
|
|
244
|
-
const
|
|
244
|
+
const transcriptMessages = [];
|
|
245
245
|
for (const raw of rawMessages) {
|
|
246
246
|
const msg = raw;
|
|
247
247
|
if (!msg.role || (msg.role !== "user" && msg.role !== "assistant"))
|
|
@@ -263,8 +263,8 @@ export const whatsappConversationsHandlers = {
|
|
|
263
263
|
}
|
|
264
264
|
if (!text.trim())
|
|
265
265
|
continue;
|
|
266
|
-
|
|
267
|
-
id:
|
|
266
|
+
transcriptMessages.push({
|
|
267
|
+
id: `transcript-${matchedSessionId}-${transcriptMessages.length}`,
|
|
268
268
|
sender,
|
|
269
269
|
senderName,
|
|
270
270
|
body: text,
|
|
@@ -272,8 +272,42 @@ export const whatsappConversationsHandlers = {
|
|
|
272
272
|
fromMe,
|
|
273
273
|
});
|
|
274
274
|
}
|
|
275
|
-
//
|
|
276
|
-
const
|
|
275
|
+
// --- Baileys in-memory store (all messages seen since gateway started) ---
|
|
276
|
+
const listener = getActiveWebListener(accountId);
|
|
277
|
+
const baileysMessages = listener?.getMessages
|
|
278
|
+
? await listener.getMessages(jid, limit * 4)
|
|
279
|
+
: [];
|
|
280
|
+
// Merge: use Baileys as the primary source for inbound messages.
|
|
281
|
+
// Keep transcript assistant (fromMe) messages that Baileys doesn't have,
|
|
282
|
+
// and deduplicate inbound messages that appear in both sources.
|
|
283
|
+
let merged;
|
|
284
|
+
if (baileysMessages.length > 0) {
|
|
285
|
+
// Build a dedup set from Baileys entries: timestamp (±5s window) + body prefix
|
|
286
|
+
const baileysSignatures = new Set();
|
|
287
|
+
for (const m of baileysMessages) {
|
|
288
|
+
// Use 5-second timestamp buckets so minor clock drift doesn't cause duplicates
|
|
289
|
+
const bucket = Math.floor(m.timestamp / 5);
|
|
290
|
+
baileysSignatures.add(`${bucket}:${m.body.slice(0, 60)}`);
|
|
291
|
+
}
|
|
292
|
+
// From transcript, keep only assistant messages not already in Baileys
|
|
293
|
+
const transcriptOnly = transcriptMessages.filter((m) => {
|
|
294
|
+
if (!m.fromMe) {
|
|
295
|
+
const bucket = Math.floor(m.timestamp / 5);
|
|
296
|
+
return !baileysSignatures.has(`${bucket}:${m.body.slice(0, 60)}`);
|
|
297
|
+
}
|
|
298
|
+
// Always include assistant messages — Baileys store has no agent responses
|
|
299
|
+
return true;
|
|
300
|
+
});
|
|
301
|
+
merged = [...baileysMessages, ...transcriptOnly];
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
// Baileys store is empty (gateway just started or account not connected) —
|
|
305
|
+
// fall back to session transcript only.
|
|
306
|
+
merged = transcriptMessages;
|
|
307
|
+
}
|
|
308
|
+
// Sort by timestamp, then return the most recent N
|
|
309
|
+
merged.sort((a, b) => a.timestamp - b.timestamp);
|
|
310
|
+
const limited = merged.slice(-limit);
|
|
277
311
|
respond(true, { messages: limited });
|
|
278
312
|
}
|
|
279
313
|
catch (err) {
|
|
@@ -88,6 +88,18 @@ export async function monitorWebInbox(options) {
|
|
|
88
88
|
inboundConsoleLog.error(`Failed handling inbound web message: ${String(err)}`);
|
|
89
89
|
},
|
|
90
90
|
});
|
|
91
|
+
// In-memory message store: captures all inbound messages per JID (including
|
|
92
|
+
// history catch-up / append type) so the UI can show a complete conversation view.
|
|
93
|
+
const messageStore = new Map();
|
|
94
|
+
const MESSAGE_STORE_MAX = 500; // max entries per JID
|
|
95
|
+
const storeMessageEntry = (jid, entry) => {
|
|
96
|
+
const entries = messageStore.get(jid) ?? [];
|
|
97
|
+
entries.push(entry);
|
|
98
|
+
if (entries.length > MESSAGE_STORE_MAX) {
|
|
99
|
+
entries.splice(0, entries.length - MESSAGE_STORE_MAX);
|
|
100
|
+
}
|
|
101
|
+
messageStore.set(jid, entries);
|
|
102
|
+
};
|
|
91
103
|
const groupMetaCache = new Map();
|
|
92
104
|
const GROUP_META_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
93
105
|
const lidLookup = sock.signalRepository?.lidMapping;
|
|
@@ -177,6 +189,25 @@ export async function monitorWebInbox(options) {
|
|
|
177
189
|
const messageTimestampMs = msg.messageTimestamp
|
|
178
190
|
? Number(msg.messageTimestamp) * 1000
|
|
179
191
|
: undefined;
|
|
192
|
+
// Store in the in-memory conversation store so the UI sees all messages,
|
|
193
|
+
// including history catch-up (append type) and messages from participants
|
|
194
|
+
// that don't trigger the agent. Runs before access control.
|
|
195
|
+
const storeBody = extractText(msg.message ?? undefined);
|
|
196
|
+
if (storeBody) {
|
|
197
|
+
const fromMe = Boolean(msg.key?.fromMe);
|
|
198
|
+
const replyCtx = describeReplyContext(msg.message);
|
|
199
|
+
storeMessageEntry(remoteJid, {
|
|
200
|
+
id: id ?? `${remoteJid}-${messageTimestampMs ?? 0}`,
|
|
201
|
+
sender: senderE164 ?? participantJid ?? (fromMe ? (selfE164 ?? "") : ""),
|
|
202
|
+
senderName: msg.pushName ?? undefined,
|
|
203
|
+
body: storeBody,
|
|
204
|
+
timestamp: messageTimestampMs ? Math.floor(messageTimestampMs / 1000) : 0,
|
|
205
|
+
fromMe,
|
|
206
|
+
quoted: replyCtx
|
|
207
|
+
? { id: replyCtx.id ?? "", body: replyCtx.body, sender: replyCtx.sender }
|
|
208
|
+
: undefined,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
180
211
|
const access = await checkInboundAccessControl({
|
|
181
212
|
accountId: options.accountId,
|
|
182
213
|
from,
|
|
@@ -414,9 +445,9 @@ export async function monitorWebInbox(options) {
|
|
|
414
445
|
}
|
|
415
446
|
return entries;
|
|
416
447
|
},
|
|
417
|
-
getMessages: async (
|
|
418
|
-
|
|
419
|
-
return
|
|
448
|
+
getMessages: async (jid, limit) => {
|
|
449
|
+
const entries = messageStore.get(jid) ?? [];
|
|
450
|
+
return limit ? entries.slice(-limit) : entries;
|
|
420
451
|
},
|
|
421
452
|
getGroupMetadata: async (jid) => {
|
|
422
453
|
const meta = await sock.groupMetadata(jid);
|
package/package.json
CHANGED
|
@@ -105,14 +105,24 @@ The reminder's payload text should reference the event clearly so the recipient
|
|
|
105
105
|
|
|
106
106
|
### Computing the cron timestamp safely
|
|
107
107
|
|
|
108
|
-
The `cron` tool
|
|
108
|
+
The `cron` tool accepts `atMs` as either a numeric Unix timestamp in milliseconds or an **ISO 8601 datetime string**. The ISO string approach is strongly preferred because it avoids epoch arithmetic errors.
|
|
109
109
|
|
|
110
|
-
**Always call `current_time` before scheduling a cron job
|
|
110
|
+
**Always call `current_time` before scheduling a cron job** to confirm the current date and year.
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
112
|
+
**Preferred approach — ISO string:**
|
|
113
|
+
|
|
114
|
+
1. Call `current_time` to confirm today's date and year.
|
|
115
|
+
2. Pass `atMs` as an ISO 8601 string: `"2026-03-11T09:00:00"` (no timezone suffix needed — the system uses the user's configured timezone automatically).
|
|
116
|
+
3. Double-check that the year in your string matches the year from `current_time`.
|
|
117
|
+
|
|
118
|
+
The system rejects timestamps that resolve to the past — you will get a clear error with the current time if this happens.
|
|
119
|
+
|
|
120
|
+
**Fallback approach — numeric timestamp:**
|
|
121
|
+
|
|
122
|
+
1. Get `unix` from `current_time`.
|
|
123
|
+
2. Calculate offset in seconds (days × 86400 + hours × 3600 + minutes × 60).
|
|
124
|
+
3. Compute: `atMs = (unix + offset) × 1000`.
|
|
125
|
+
4. Verify `atMs > unix × 1000` before submitting.
|
|
116
126
|
|
|
117
127
|
Never compute a Unix timestamp from a fixed epoch date (e.g. "January 1, 2026 = X"). Epoch arithmetic done from scratch is the most common source of year-off errors.
|
|
118
128
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: whatsapp-business
|
|
3
|
+
description: WhatsApp Business Cloud API setup — guides the user through creating a Meta Business Portfolio, registering a phone number, generating a permanent System User access token, configuring the webhook, and entering credentials into Taskmaster. Use when a user wants to connect WhatsApp via Meta's official Cloud API (verified badge, no QR dependency).
|
|
4
|
+
metadata: {"taskmaster":{"emoji":"📱"}}
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# WhatsApp Business Cloud API
|
|
8
|
+
|
|
9
|
+
Handles the full Meta Cloud API setup. This is the official WhatsApp Business Platform — messages arrive via Meta webhooks instead of a QR-linked device. It unlocks a verified green checkmark, message templates for proactive outreach, and eliminates the phone dependency entirely.
|
|
10
|
+
|
|
11
|
+
## When to activate
|
|
12
|
+
|
|
13
|
+
- User asks about connecting WhatsApp via Meta's official API or Business Platform
|
|
14
|
+
- User mentions "Cloud API", "WABA", "Meta Business Manager", "verified badge", or "green checkmark"
|
|
15
|
+
- User wants WhatsApp without needing a physical phone linked
|
|
16
|
+
- User has a dedicated number for a business WhatsApp account
|
|
17
|
+
- User asks why they need a separate number for Cloud API
|
|
18
|
+
|
|
19
|
+
## What it unlocks
|
|
20
|
+
|
|
21
|
+
- **Verified business profile** — Meta's green checkmark badge builds customer trust
|
|
22
|
+
- **No phone dependency** — no linked device, no QR scan every 14 days
|
|
23
|
+
- **Message templates** — send proactive outreach messages to opted-in customers
|
|
24
|
+
- **Higher reliability** — webhooks from Meta's infrastructure, not a linked device session
|
|
25
|
+
|
|
26
|
+
## Important: Number exclusivity
|
|
27
|
+
|
|
28
|
+
A phone number can only be on **one** WhatsApp connection at a time. A number registered with Meta Cloud API cannot also be used with Baileys (QR). The two providers can coexist on one Taskmaster instance but must use different phone numbers.
|
|
29
|
+
|
|
30
|
+
## Key Storage
|
|
31
|
+
|
|
32
|
+
Cloud API credentials are stored directly in the Taskmaster config, not in API key slots. Use the Setup page (WhatsApp → gear icon → Add Cloud API Account) to enter them. The four required values are:
|
|
33
|
+
|
|
34
|
+
| Field | What it is | Where to find it |
|
|
35
|
+
|-------|-----------|-----------------|
|
|
36
|
+
| Phone Number ID | Numeric ID for the registered number | WhatsApp Manager → Phone Numbers tab |
|
|
37
|
+
| Business Account ID | Your WABA (WhatsApp Business Account) ID | WhatsApp Manager → Business Account section |
|
|
38
|
+
| Access Token | Permanent System User token | Meta Business Settings → System Users |
|
|
39
|
+
| Webhook Verify Token | A string you define (shared secret) | You generate this; enter same value in Taskmaster and Meta |
|
|
40
|
+
|
|
41
|
+
## References
|
|
42
|
+
|
|
43
|
+
| Task | When to use | Reference |
|
|
44
|
+
|------|-------------|-----------|
|
|
45
|
+
| Full Meta Cloud API setup | Credentials not yet entered or user starting from scratch | `references/setup-guide.md` |
|
|
46
|
+
|
|
47
|
+
Load the reference and follow it step by step.
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# WhatsApp Business Cloud API — Setup Guide
|
|
2
|
+
|
|
3
|
+
Walk the user through the full Meta Cloud API setup from scratch: Business Portfolio → WABA → number registration → System User token → webhook → Taskmaster config.
|
|
4
|
+
|
|
5
|
+
## Before You Start
|
|
6
|
+
|
|
7
|
+
**Remote Access must be enabled first.** Cloud API requires a publicly reachable webhook URL so Meta can deliver messages. Check that Remote Access (Tailscale Funnel) is active in Setup. If it isn't, the "Cloud API" option in Setup will be greyed out. Guide the user to enable it before proceeding — the `taskmaster` skill has a Remote Access setup guide.
|
|
8
|
+
|
|
9
|
+
**Only one QR-linked (Baileys) account is supported per workspace.** If the workspace already has a Baileys account, the Baileys option will be greyed out in Setup. Cloud API accounts have no Taskmaster-enforced limit (Meta allows up to 2 phone numbers per WABA on the free tier).
|
|
10
|
+
|
|
11
|
+
**Sessions are shared across numbers.** Taskmaster sessions are keyed by customer phone number, not by which WhatsApp number they messaged. A customer who texts both the Baileys number and a Cloud API number lands in the same conversation thread. This is usually desirable (one agent, continuous history), but means the agent cannot distinguish which number the customer used.
|
|
12
|
+
|
|
13
|
+
1. Check memory (e.g. `memory/admin/infrastructure.md`) for the public URL — do NOT ask the user for it. Construct the webhook URL as `https://{public-host}/webhook/whatsapp`. The Setup page shows this URL automatically once Remote Access is active.
|
|
14
|
+
2. The user needs a phone number that has never been registered with WhatsApp — or one that has been fully deleted from any previous WhatsApp account. A fresh SIM or a VoIP number works. They said they already have one — confirm this.
|
|
15
|
+
3. Have the user ready at a desktop browser.
|
|
16
|
+
|
|
17
|
+
## What to tell the user upfront
|
|
18
|
+
|
|
19
|
+
> "To connect via Meta's official WhatsApp Business API, we need to set up four things in Meta:
|
|
20
|
+
>
|
|
21
|
+
> 1. A Meta Business Portfolio (if you don't have one)
|
|
22
|
+
> 2. A WhatsApp Business Account with your new number registered
|
|
23
|
+
> 3. A permanent access token from a System User
|
|
24
|
+
> 4. A webhook so Meta can send messages to Taskmaster
|
|
25
|
+
>
|
|
26
|
+
> This takes about 15–20 minutes. I'll guide you through each step."
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Step 1: Meta Business Portfolio
|
|
31
|
+
|
|
32
|
+
**URL: business.facebook.com**
|
|
33
|
+
|
|
34
|
+
> "Go to **business.facebook.com** and sign in with your Facebook/Meta account.
|
|
35
|
+
>
|
|
36
|
+
> If you see a Business Portfolio dashboard, you're good. If you're prompted to create one, click **Create account**, enter your business name, and follow the prompts."
|
|
37
|
+
|
|
38
|
+
Wait for confirmation.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Step 2: Create a WhatsApp Business Account (WABA) and register the phone number
|
|
43
|
+
|
|
44
|
+
This is a single 3-step wizard in Meta Business Suite that covers both WABA creation and phone number registration.
|
|
45
|
+
|
|
46
|
+
**Before starting the wizard**, make sure the Business Portfolio has complete contact information — Meta will block the wizard if it doesn't:
|
|
47
|
+
|
|
48
|
+
> "First, click the gear icon (⚙) at the bottom-left to open Settings → **Business info**.
|
|
49
|
+
>
|
|
50
|
+
> Check that the following are filled in: business address (street, city, postcode, country), phone number, and email. If any are missing, add them and save before continuing."
|
|
51
|
+
|
|
52
|
+
Then start the wizard:
|
|
53
|
+
|
|
54
|
+
> "Still in Settings, click the gear icon (⚙) at the bottom-left of business.facebook.com to open Settings.
|
|
55
|
+
>
|
|
56
|
+
> In the left sidebar, click **Accounts** → **WhatsApp accounts**.
|
|
57
|
+
>
|
|
58
|
+
> Click the **+ Add** button (top-right), then choose **Create a new WhatsApp business account** (not 'Link')."
|
|
59
|
+
|
|
60
|
+
**Step 2a — Details:**
|
|
61
|
+
> "Fill in:
|
|
62
|
+
> - **WhatsApp business display name** — your business name (e.g. Beagle Taxi)
|
|
63
|
+
> - **Category** — choose the most appropriate (e.g. Travel and transportation)
|
|
64
|
+
> - Optionally expand **Show more options** to set timezone, description, and website
|
|
65
|
+
>
|
|
66
|
+
> Click **Continue**."
|
|
67
|
+
|
|
68
|
+
**Step 2b — Phone number:**
|
|
69
|
+
> "Enter your dedicated phone number. Click **Continue**."
|
|
70
|
+
|
|
71
|
+
**Step 2c — Phone verification:**
|
|
72
|
+
> "Meta will call or SMS the number with a verification code. Enter the code to confirm ownership."
|
|
73
|
+
|
|
74
|
+
Wait for confirmation the WABA and phone number are verified and the wizard has completed.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Step 3: Get the Business Account ID and Phone Number ID
|
|
79
|
+
|
|
80
|
+
Now open WhatsApp Manager (the WABA now exists, so access is granted):
|
|
81
|
+
|
|
82
|
+
> "In your browser, go to: **https://business.facebook.com/latest/whatsapp_manager**"
|
|
83
|
+
|
|
84
|
+
**Phone Number ID:**
|
|
85
|
+
> "In the left sidebar, click **Phone numbers**. Click on your number to open its details.
|
|
86
|
+
>
|
|
87
|
+
> Look for **Phone number ID** — a long string of digits. Copy it and send it to me."
|
|
88
|
+
|
|
89
|
+
Store as `phoneNumberId` in config.
|
|
90
|
+
|
|
91
|
+
**Business Account ID (WABA ID):**
|
|
92
|
+
> "At the top of WhatsApp Manager, near your account name, you'll see the **WhatsApp Business Account ID** (a long number). Copy it and send it to me."
|
|
93
|
+
|
|
94
|
+
Store as `businessAccountId` in config.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Step 5: Create a Meta App (if needed)
|
|
99
|
+
|
|
100
|
+
To use the Cloud API, you need a Meta Developer App linked to your WABA.
|
|
101
|
+
|
|
102
|
+
**URL: developers.facebook.com/apps**
|
|
103
|
+
|
|
104
|
+
> "Go to **developers.facebook.com/apps** and sign in.
|
|
105
|
+
>
|
|
106
|
+
> Click **Create App**. Choose **Business** as the app type. Enter a name (e.g. 'Taskmaster') and click **Create app**."
|
|
107
|
+
|
|
108
|
+
Once the app is created:
|
|
109
|
+
|
|
110
|
+
> "Inside the app dashboard, click **Add Product** → find **WhatsApp** → click **Set up**.
|
|
111
|
+
>
|
|
112
|
+
> You'll be asked to connect a WhatsApp Business Account. Select the WABA you just created."
|
|
113
|
+
|
|
114
|
+
Wait for confirmation.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Step 6: Create a System User with a permanent access token
|
|
119
|
+
|
|
120
|
+
User access tokens expire. A System User token is permanent.
|
|
121
|
+
|
|
122
|
+
**Navigation: business.facebook.com → Business Settings → System Users**
|
|
123
|
+
|
|
124
|
+
> "Go back to **business.facebook.com**. In the left sidebar, click **Business Settings** → **Users** → **System Users**.
|
|
125
|
+
>
|
|
126
|
+
> Click **Add** and create a new system user:
|
|
127
|
+
> - **Name:** something like 'Taskmaster Bot'
|
|
128
|
+
> - **Role:** Admin (needed to send messages)
|
|
129
|
+
>
|
|
130
|
+
> Click **Add system user**."
|
|
131
|
+
|
|
132
|
+
Now grant the system user access to the WhatsApp asset:
|
|
133
|
+
|
|
134
|
+
> "With the system user selected, click **Add Assets**.
|
|
135
|
+
>
|
|
136
|
+
> Choose **Apps**, find your Taskmaster app, tick **Manage app**, and click **Save changes**.
|
|
137
|
+
>
|
|
138
|
+
> Then choose **WhatsApp Accounts**, select your WABA, tick **Full control**, and click **Save changes**."
|
|
139
|
+
|
|
140
|
+
Now generate the token:
|
|
141
|
+
|
|
142
|
+
> "Click **Generate token** at the top of the system user page.
|
|
143
|
+
>
|
|
144
|
+
> Select your Taskmaster app from the dropdown.
|
|
145
|
+
>
|
|
146
|
+
> Tick these permissions: `whatsapp_business_messaging` and `whatsapp_business_management`.
|
|
147
|
+
>
|
|
148
|
+
> Set expiry to **Never**.
|
|
149
|
+
>
|
|
150
|
+
> Click **Generate token** and copy the token — it starts with `EAA...`. Send it to me."
|
|
151
|
+
|
|
152
|
+
Validate: must start with `EAA`, 100+ characters.
|
|
153
|
+
|
|
154
|
+
Store: enter as Access Token in Taskmaster UI (next step).
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Step 7: Generate a Webhook Verify Token
|
|
159
|
+
|
|
160
|
+
The verify token is a shared secret you define — Meta sends it to Taskmaster when verifying the webhook.
|
|
161
|
+
|
|
162
|
+
> "We need a short random string for the webhook verify token. I'll suggest one — or you can make up your own (letters and numbers, no spaces):
|
|
163
|
+
>
|
|
164
|
+
> `tm-{random 8 chars}` — for example: `tm-xk29mq7r`
|
|
165
|
+
>
|
|
166
|
+
> This goes into both Taskmaster and Meta. Note it down."
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Step 8: Enter credentials in Taskmaster
|
|
171
|
+
|
|
172
|
+
> "Now open the Taskmaster control panel at `{control_panel_url}`.
|
|
173
|
+
>
|
|
174
|
+
> Go to **Setup** → find the WhatsApp row → click the gear icon → scroll to **Add Cloud API Account**.
|
|
175
|
+
>
|
|
176
|
+
> Enter:
|
|
177
|
+
> - **Account name:** something like 'Business' or 'Meta'
|
|
178
|
+
> - **Phone Number ID:** (from Step 4)
|
|
179
|
+
> - **Business Account ID:** (from Step 4)
|
|
180
|
+
> - **Access Token:** (from Step 6)
|
|
181
|
+
> - **Webhook Verify Token:** (from Step 7)
|
|
182
|
+
>
|
|
183
|
+
> Click **Add Account**. Note the **Webhook URL** shown — you'll need it in the next step."
|
|
184
|
+
|
|
185
|
+
Wait for confirmation. The webhook URL will be shown in the UI after saving.
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Step 9: Configure the webhook in Meta
|
|
190
|
+
|
|
191
|
+
**Navigation: developers.facebook.com → Your App → WhatsApp → Configuration → Webhooks**
|
|
192
|
+
|
|
193
|
+
> "Back in the Meta Developer App dashboard:
|
|
194
|
+
>
|
|
195
|
+
> Click **WhatsApp** in the left sidebar → **Configuration**.
|
|
196
|
+
>
|
|
197
|
+
> Under **Webhooks**, click **Edit** (or **Configure webhooks**).
|
|
198
|
+
>
|
|
199
|
+
> Enter:
|
|
200
|
+
> - **Callback URL:** `{webhook_url}` (from the Taskmaster UI in the previous step)
|
|
201
|
+
> - **Verify token:** `{verify_token}` (the string from Step 7)
|
|
202
|
+
>
|
|
203
|
+
> Click **Verify and save**."
|
|
204
|
+
|
|
205
|
+
Meta will send a GET request to Taskmaster to verify. If it succeeds, the webhook status changes to Verified.
|
|
206
|
+
|
|
207
|
+
> "After verification, subscribe to webhook fields. Tick **messages** under the WhatsApp Business Account section. Click **Save**."
|
|
208
|
+
|
|
209
|
+
Wait for confirmation.
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Step 10: Confirm
|
|
214
|
+
|
|
215
|
+
> "WhatsApp Business Cloud API is now connected:
|
|
216
|
+
>
|
|
217
|
+
> - **Number:** {phone number} — registered with Meta
|
|
218
|
+
> - **Provider:** Meta Cloud API (webhook-based, no phone dependency)
|
|
219
|
+
> - **Webhook:** verified and subscribed
|
|
220
|
+
>
|
|
221
|
+
> Send a test message to the number from a customer's phone. Taskmaster should respond within a few seconds."
|
|
222
|
+
|
|
223
|
+
Store setup details in memory (WABA ID, phone number ID, app name, webhook URL, system user name) for future reference.
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Webhook URL
|
|
228
|
+
|
|
229
|
+
The webhook URL is always: `https://{public-host}/webhook/whatsapp`
|
|
230
|
+
|
|
231
|
+
Where `{public-host}` is the device's public URL (Tailscale Funnel or other tunnel). This must be accessible from the public internet — Meta's servers must be able to reach it.
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## Troubleshooting
|
|
236
|
+
|
|
237
|
+
| Problem | Solution |
|
|
238
|
+
|---------|----------|
|
|
239
|
+
| "Setup can't be completed — business has not met WhatsApp's policy requirements" | The Business Portfolio is missing contact information. Go to Settings → **Business info** and fill in: business address (street, city, postcode, country), phone number, and email. Save, then retry the WABA creation wizard. |
|
|
240
|
+
| Webhook verification fails | Check the webhook URL is publicly accessible (not just on Tailscale). Tailscale Funnel must be enabled. Check the verify token matches exactly. |
|
|
241
|
+
| Phone number already in use | The number must be removed from any existing WhatsApp account first. Go to WhatsApp app → Settings → Linked Devices and remove if it's there. Or contact Meta support if it's stuck. |
|
|
242
|
+
| Token expires | System User tokens with "Never" expiry don't expire. If it does, regenerate in Business Settings → System Users → Generate token. |
|
|
243
|
+
| Messages not arriving | Check webhook subscription: App Dashboard → WhatsApp → Configuration → confirm "messages" field is subscribed. |
|
|
244
|
+
| "Permission denied" errors | System user needs Admin role AND asset access to both the App and the WABA. Recheck Step 6. |
|
|
245
|
+
| 401 from Meta API | Access token invalid or revoked. Regenerate in Business Settings → System Users. |
|
|
246
|
+
| Number not eligible | Some VoIP numbers are rejected by Meta. Use a real mobile number or try a different VoIP provider. |
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## Test vs Live
|
|
251
|
+
|
|
252
|
+
| | Test number | Registered number |
|
|
253
|
+
|---|---|---|
|
|
254
|
+
| Provided by | Meta (in Developer app sandbox) | You register your own number |
|
|
255
|
+
| Recipients | Only pre-approved test numbers | Any WhatsApp user |
|
|
256
|
+
| For | Development and testing | Production |
|
|
257
|
+
|
|
258
|
+
The Developer App sandbox provides a free test number and pre-approved recipients. Use it to verify the integration works before registering your real business number.
|