@rubytech/taskmaster 1.16.3 → 1.17.4
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/logs-read-tool.js +9 -0
- package/dist/agents/tools/memory-tool.js +1 -0
- package/dist/agents/workspace-migrations.js +61 -0
- package/dist/auto-reply/group-activation.js +2 -0
- package/dist/auto-reply/reply/commands-session.js +28 -11
- package/dist/build-info.json +3 -3
- package/dist/config/agent-tools-reconcile.js +58 -0
- package/dist/config/group-policy.js +16 -0
- package/dist/config/zod-schema.providers-whatsapp.js +2 -0
- package/dist/control-ui/assets/index-XqRo9tNW.css +1 -0
- package/dist/control-ui/assets/{index-Bd75cI7J.js → index-koe4eKhk.js} +526 -493
- package/dist/control-ui/assets/index-koe4eKhk.js.map +1 -0
- package/dist/control-ui/index.html +2 -2
- package/dist/cron/preloaded.js +27 -23
- package/dist/cron/service/timer.js +5 -1
- package/dist/gateway/protocol/index.js +7 -2
- package/dist/gateway/protocol/schema/logs-chat.js +6 -0
- package/dist/gateway/protocol/schema/protocol-schemas.js +6 -0
- package/dist/gateway/protocol/schema/sessions-transcript.js +1 -0
- package/dist/gateway/protocol/schema/sessions.js +6 -1
- package/dist/gateway/protocol/schema/whatsapp.js +24 -0
- package/dist/gateway/protocol/schema.js +1 -0
- package/dist/gateway/public-chat/session-token.js +52 -0
- package/dist/gateway/public-chat-api.js +40 -13
- package/dist/gateway/server-methods/apikeys.js +2 -0
- package/dist/gateway/server-methods/logs.js +17 -1
- package/dist/gateway/server-methods/public-chat.js +5 -0
- package/dist/gateway/server-methods/sessions-transcript.js +30 -6
- package/dist/gateway/server-methods/whatsapp-conversations.js +387 -0
- package/dist/gateway/server-methods-list.js +6 -0
- package/dist/gateway/server-methods.js +7 -0
- package/dist/gateway/server.impl.js +19 -2
- package/dist/gateway/sessions-patch.js +1 -1
- package/dist/hooks/bundled/ride-dispatch/HOOK.md +7 -6
- package/dist/hooks/bundled/ride-dispatch/handler.js +98 -39
- package/dist/memory/manager.js +3 -3
- package/dist/tui/tui-command-handlers.js +1 -1
- package/dist/web/auto-reply/monitor/group-activation.js +12 -10
- package/dist/web/auto-reply/monitor/group-gating.js +23 -2
- package/dist/web/auto-reply/monitor/on-message.js +27 -5
- package/dist/web/auto-reply/monitor/process-message.js +64 -53
- package/dist/web/inbound/monitor.js +30 -0
- package/extensions/whatsapp/src/channel.ts +1 -1
- package/package.json +1 -1
- package/skills/log-review/SKILL.md +17 -4
- package/skills/log-review/references/review-protocol.md +4 -4
- package/taskmaster-docs/USER-GUIDE.md +14 -0
- package/templates/beagle-zanzibar/agents/admin/AGENTS.md +16 -8
- package/templates/beagle-zanzibar/agents/public/AGENTS.md +10 -5
- package/dist/control-ui/assets/index-Bd75cI7J.js.map +0 -1
- package/dist/control-ui/assets/index-BkymP95Y.css +0 -1
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { loadConfig, writeConfigFile } from "../../config/config.js";
|
|
2
|
+
import { resolveChannelGroupActivation } from "../../config/group-policy.js";
|
|
3
|
+
import { updateSessionStore } from "../../config/sessions.js";
|
|
4
|
+
import { listRecords } from "../../records/records-manager.js";
|
|
5
|
+
import { normalizeAccountId } from "../../routing/session-key.js";
|
|
6
|
+
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
|
|
7
|
+
import { requireActiveWebListener } from "../../web/active-listener.js";
|
|
8
|
+
import { stripEnvelope } from "../chat-sanitize.js";
|
|
9
|
+
import { ErrorCodes, errorShape, formatValidationErrors, validateWhatsAppConversationsParams, validateWhatsAppGroupInfoParams, validateWhatsAppMessagesParams, validateWhatsAppSendMessageParams, validateWhatsAppSetActivationParams, } from "../protocol/index.js";
|
|
10
|
+
import { loadCombinedSessionStoreForGateway, parseGroupKey } from "../session-utils.js";
|
|
11
|
+
import { readSessionMessages } from "../session-utils.fs.js";
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Helpers for building conversation + message lists from persistent state
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
/**
|
|
16
|
+
* Extract a WhatsApp JID from a DM session key rest segment.
|
|
17
|
+
* Handles both `whatsapp:dm:{peer}` and `dm:{peer}` formats.
|
|
18
|
+
* Returns `null` if the key doesn't match a WhatsApp DM pattern.
|
|
19
|
+
*/
|
|
20
|
+
function extractDmJid(rest, entryChannel, entryLastChannel) {
|
|
21
|
+
const channelDmMatch = rest.match(/^whatsapp:dm:(.+)$/);
|
|
22
|
+
if (channelDmMatch) {
|
|
23
|
+
const peer = channelDmMatch[1];
|
|
24
|
+
return peer.includes("@") ? peer : `${peer.replace(/^\+/, "")}@s.whatsapp.net`;
|
|
25
|
+
}
|
|
26
|
+
const plainDmMatch = rest.match(/^dm:(.+)$/);
|
|
27
|
+
if (plainDmMatch) {
|
|
28
|
+
const isWhatsApp = entryChannel === "whatsapp" || entryLastChannel === "whatsapp";
|
|
29
|
+
if (!isWhatsApp)
|
|
30
|
+
return null;
|
|
31
|
+
const peer = plainDmMatch[1];
|
|
32
|
+
return peer.includes("@") ? peer : `${peer.replace(/^\+/, "")}@s.whatsapp.net`;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
function extractTextFromTranscriptMessage(msg) {
|
|
37
|
+
if (typeof msg.content === "string")
|
|
38
|
+
return msg.content.trim() || null;
|
|
39
|
+
if (Array.isArray(msg.content)) {
|
|
40
|
+
for (const part of msg.content) {
|
|
41
|
+
if (part && typeof part.text === "string" && part.text.trim()) {
|
|
42
|
+
return part.text.trim();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Extract sender info from the `[from: Name (phone)]` tag in group user messages.
|
|
50
|
+
*/
|
|
51
|
+
const FROM_TAG_RE = /\n?\[from:\s*(.+?)\]\s*$/;
|
|
52
|
+
function extractSenderFromBody(body) {
|
|
53
|
+
const match = body.match(FROM_TAG_RE);
|
|
54
|
+
if (!match)
|
|
55
|
+
return { cleanBody: body, sender: "" };
|
|
56
|
+
const cleanBody = body.slice(0, match.index).trimEnd();
|
|
57
|
+
const senderRaw = match[1];
|
|
58
|
+
// Format: "Name (+447857934268)" or just "+447857934268"
|
|
59
|
+
const namePhoneMatch = senderRaw.match(/^(.+?)\s*\(([^)]+)\)$/);
|
|
60
|
+
if (namePhoneMatch) {
|
|
61
|
+
return {
|
|
62
|
+
cleanBody,
|
|
63
|
+
sender: namePhoneMatch[2],
|
|
64
|
+
senderName: namePhoneMatch[1],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
return { cleanBody, sender: senderRaw };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Build a digits-to-contact-name lookup from the contact records store.
|
|
71
|
+
*/
|
|
72
|
+
function buildContactNameLookup() {
|
|
73
|
+
const lookup = new Map();
|
|
74
|
+
try {
|
|
75
|
+
const records = listRecords();
|
|
76
|
+
for (const record of records) {
|
|
77
|
+
if (!record.name)
|
|
78
|
+
continue;
|
|
79
|
+
const phone = record.phone ?? record.id;
|
|
80
|
+
const digits = phone.replace(/\D/g, "");
|
|
81
|
+
if (digits)
|
|
82
|
+
lookup.set(digits, record.name);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Records file missing or unreadable — no enrichment
|
|
87
|
+
}
|
|
88
|
+
return lookup;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Find the session entry matching a WhatsApp JID in the session store.
|
|
92
|
+
*/
|
|
93
|
+
function findSessionForJid(store, jid, accountId) {
|
|
94
|
+
for (const [key, entry] of Object.entries(store)) {
|
|
95
|
+
if (!entry.sessionId)
|
|
96
|
+
continue;
|
|
97
|
+
if (accountId !== "default" &&
|
|
98
|
+
entry.lastAccountId &&
|
|
99
|
+
normalizeAccountId(entry.lastAccountId) !== accountId) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const parsed = parseGroupKey(key);
|
|
103
|
+
if (parsed?.channel === "whatsapp" && parsed.id === jid) {
|
|
104
|
+
return { key, entry };
|
|
105
|
+
}
|
|
106
|
+
const agentParsed = parseAgentSessionKey(key);
|
|
107
|
+
if (agentParsed) {
|
|
108
|
+
const dmJid = extractDmJid(agentParsed.rest, entry.channel, entry.lastChannel);
|
|
109
|
+
if (dmJid === jid)
|
|
110
|
+
return { key, entry };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Handlers
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
export const whatsappConversationsHandlers = {
|
|
119
|
+
"whatsapp.conversations": async ({ respond, params }) => {
|
|
120
|
+
if (!validateWhatsAppConversationsParams(params)) {
|
|
121
|
+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `invalid whatsapp.conversations params: ${formatValidationErrors(validateWhatsAppConversationsParams.errors)}`));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const accountId = normalizeAccountId(params.accountId);
|
|
125
|
+
try {
|
|
126
|
+
const cfg = loadConfig();
|
|
127
|
+
// 1. Build conversations from persistent session store
|
|
128
|
+
const { store } = loadCombinedSessionStoreForGateway(cfg);
|
|
129
|
+
const conversationMap = new Map();
|
|
130
|
+
for (const [key, entry] of Object.entries(store)) {
|
|
131
|
+
// Account filter
|
|
132
|
+
if (accountId !== "default" &&
|
|
133
|
+
entry.lastAccountId &&
|
|
134
|
+
normalizeAccountId(entry.lastAccountId) !== accountId) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
// Groups: key contains :whatsapp:group:{jid}
|
|
138
|
+
const parsed = parseGroupKey(key);
|
|
139
|
+
if (parsed?.channel === "whatsapp" &&
|
|
140
|
+
(parsed.kind === "group" || parsed.kind === "channel") &&
|
|
141
|
+
parsed.id) {
|
|
142
|
+
conversationMap.set(parsed.id, {
|
|
143
|
+
jid: parsed.id,
|
|
144
|
+
type: "group",
|
|
145
|
+
name: entry.subject ?? entry.displayName ?? parsed.id,
|
|
146
|
+
lastMessageTimestamp: entry.updatedAt ? Math.floor(entry.updatedAt / 1000) : undefined,
|
|
147
|
+
});
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
// DMs: extract JID from key + entry channel metadata
|
|
151
|
+
const agentParsed = parseAgentSessionKey(key);
|
|
152
|
+
if (!agentParsed)
|
|
153
|
+
continue;
|
|
154
|
+
const dmJid = extractDmJid(agentParsed.rest, entry.channel, entry.lastChannel);
|
|
155
|
+
if (dmJid && !conversationMap.has(dmJid)) {
|
|
156
|
+
// Derive display name from entry or peer identifier
|
|
157
|
+
const peer = agentParsed.rest.replace(/^(?:whatsapp:)?dm:/, "");
|
|
158
|
+
conversationMap.set(dmJid, {
|
|
159
|
+
jid: dmJid,
|
|
160
|
+
type: "dm",
|
|
161
|
+
name: entry.displayName ?? peer,
|
|
162
|
+
lastMessageTimestamp: entry.updatedAt ? Math.floor(entry.updatedAt / 1000) : undefined,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// 2. Merge with live Baileys data (optional enrichment — may not be connected)
|
|
167
|
+
try {
|
|
168
|
+
const { listener } = requireActiveWebListener(accountId);
|
|
169
|
+
if (listener.listConversations) {
|
|
170
|
+
const live = await listener.listConversations();
|
|
171
|
+
for (const c of live) {
|
|
172
|
+
const existing = conversationMap.get(c.jid);
|
|
173
|
+
if (existing) {
|
|
174
|
+
// Prefer live name when available and meaningful
|
|
175
|
+
if (c.name && c.name !== c.jid)
|
|
176
|
+
existing.name = c.name;
|
|
177
|
+
if (c.lastMessageTimestamp &&
|
|
178
|
+
(!existing.lastMessageTimestamp ||
|
|
179
|
+
c.lastMessageTimestamp > existing.lastMessageTimestamp)) {
|
|
180
|
+
existing.lastMessageTimestamp = c.lastMessageTimestamp;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
conversationMap.set(c.jid, c);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Baileys not connected — session store data is sufficient
|
|
191
|
+
}
|
|
192
|
+
// 3. Enrich DM names from contact records
|
|
193
|
+
const contactNames = buildContactNameLookup();
|
|
194
|
+
for (const conv of conversationMap.values()) {
|
|
195
|
+
if (conv.type !== "dm")
|
|
196
|
+
continue;
|
|
197
|
+
// JID format: digits@s.whatsapp.net
|
|
198
|
+
const digits = conv.jid.replace(/@.*$/, "");
|
|
199
|
+
const contactName = contactNames.get(digits);
|
|
200
|
+
if (contactName)
|
|
201
|
+
conv.name = contactName;
|
|
202
|
+
}
|
|
203
|
+
// 4. Enrich with activation and sort by recency
|
|
204
|
+
const conversations = [...conversationMap.values()]
|
|
205
|
+
.map((c) => ({
|
|
206
|
+
...c,
|
|
207
|
+
activation: c.type === "group"
|
|
208
|
+
? (resolveChannelGroupActivation({
|
|
209
|
+
cfg,
|
|
210
|
+
channel: "whatsapp",
|
|
211
|
+
groupId: c.jid,
|
|
212
|
+
accountId,
|
|
213
|
+
}) ?? "mention")
|
|
214
|
+
: undefined,
|
|
215
|
+
}))
|
|
216
|
+
.sort((a, b) => (b.lastMessageTimestamp ?? 0) - (a.lastMessageTimestamp ?? 0));
|
|
217
|
+
respond(true, { conversations });
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
"whatsapp.messages": async ({ respond, params }) => {
|
|
224
|
+
if (!validateWhatsAppMessagesParams(params)) {
|
|
225
|
+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `invalid whatsapp.messages params: ${formatValidationErrors(validateWhatsAppMessagesParams.errors)}`));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const accountId = normalizeAccountId(params.accountId);
|
|
229
|
+
const jid = params.jid;
|
|
230
|
+
const limit = params.limit ?? 50;
|
|
231
|
+
try {
|
|
232
|
+
const cfg = loadConfig();
|
|
233
|
+
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
|
|
234
|
+
// Find the session matching this JID
|
|
235
|
+
const matched = findSessionForJid(store, jid, accountId);
|
|
236
|
+
const matchedSessionId = matched?.entry.sessionId;
|
|
237
|
+
const matchedSessionFile = matched?.entry.sessionFile;
|
|
238
|
+
if (!matchedSessionId) {
|
|
239
|
+
respond(true, { messages: [] });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
// Read transcript and transform to WhatsApp message format
|
|
243
|
+
const rawMessages = readSessionMessages(matchedSessionId, storePath, matchedSessionFile);
|
|
244
|
+
const messages = [];
|
|
245
|
+
for (const raw of rawMessages) {
|
|
246
|
+
const msg = raw;
|
|
247
|
+
if (!msg.role || (msg.role !== "user" && msg.role !== "assistant"))
|
|
248
|
+
continue;
|
|
249
|
+
let text = extractTextFromTranscriptMessage(msg);
|
|
250
|
+
if (!text)
|
|
251
|
+
continue;
|
|
252
|
+
const fromMe = msg.role === "assistant";
|
|
253
|
+
let sender = "";
|
|
254
|
+
let senderName;
|
|
255
|
+
if (msg.role === "user") {
|
|
256
|
+
// Strip envelope header (e.g. "[WhatsApp 2026-03-05 10:30]")
|
|
257
|
+
text = stripEnvelope(text);
|
|
258
|
+
// Extract sender from [from: ...] tag in group messages
|
|
259
|
+
const extracted = extractSenderFromBody(text);
|
|
260
|
+
text = extracted.cleanBody;
|
|
261
|
+
sender = extracted.sender;
|
|
262
|
+
senderName = extracted.senderName;
|
|
263
|
+
}
|
|
264
|
+
if (!text.trim())
|
|
265
|
+
continue;
|
|
266
|
+
messages.push({
|
|
267
|
+
id: `${matchedSessionId}-${messages.length}`,
|
|
268
|
+
sender,
|
|
269
|
+
senderName,
|
|
270
|
+
body: text,
|
|
271
|
+
timestamp: msg.timestamp ? Math.floor(msg.timestamp / 1000) : 0,
|
|
272
|
+
fromMe,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
// Return the most recent N messages
|
|
276
|
+
const limited = messages.slice(-limit);
|
|
277
|
+
respond(true, { messages: limited });
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
"whatsapp.groupInfo": async ({ respond, params }) => {
|
|
284
|
+
if (!validateWhatsAppGroupInfoParams(params)) {
|
|
285
|
+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `invalid whatsapp.groupInfo params: ${formatValidationErrors(validateWhatsAppGroupInfoParams.errors)}`));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const accountId = normalizeAccountId(params.accountId);
|
|
289
|
+
const jid = params.jid;
|
|
290
|
+
const cfg = loadConfig();
|
|
291
|
+
const activation = resolveChannelGroupActivation({ cfg, channel: "whatsapp", groupId: jid, accountId }) ??
|
|
292
|
+
"mention";
|
|
293
|
+
// Try live Baileys metadata first (has participants)
|
|
294
|
+
try {
|
|
295
|
+
const { listener } = requireActiveWebListener(accountId);
|
|
296
|
+
if (listener.getGroupMetadata) {
|
|
297
|
+
const meta = await listener.getGroupMetadata(jid);
|
|
298
|
+
// Update stored subject if it changed
|
|
299
|
+
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
|
|
300
|
+
const matched = findSessionForJid(store, jid, accountId);
|
|
301
|
+
if (matched && meta.subject && matched.entry.subject !== meta.subject) {
|
|
302
|
+
updateSessionStore(storePath, (current) => {
|
|
303
|
+
const entry = current[matched.key];
|
|
304
|
+
if (entry)
|
|
305
|
+
entry.subject = meta.subject;
|
|
306
|
+
}).catch(() => { });
|
|
307
|
+
}
|
|
308
|
+
respond(true, { ...meta, activation });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
// Baileys unavailable — fall through to session store
|
|
314
|
+
}
|
|
315
|
+
// Fallback: session store data (no participants, but at least subject + activation)
|
|
316
|
+
try {
|
|
317
|
+
const { store } = loadCombinedSessionStoreForGateway(cfg);
|
|
318
|
+
const matched = findSessionForJid(store, jid, accountId);
|
|
319
|
+
respond(true, {
|
|
320
|
+
subject: matched?.entry.subject ?? matched?.entry.displayName ?? jid,
|
|
321
|
+
participants: [],
|
|
322
|
+
activation,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
"whatsapp.setActivation": async ({ respond, params }) => {
|
|
330
|
+
if (!validateWhatsAppSetActivationParams(params)) {
|
|
331
|
+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `invalid whatsapp.setActivation params: ${formatValidationErrors(validateWhatsAppSetActivationParams.errors)}`));
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const accountId = normalizeAccountId(params.accountId);
|
|
335
|
+
try {
|
|
336
|
+
const cfg = loadConfig();
|
|
337
|
+
if (!cfg.channels)
|
|
338
|
+
cfg.channels = {};
|
|
339
|
+
if (!cfg.channels.whatsapp)
|
|
340
|
+
cfg.channels.whatsapp = {};
|
|
341
|
+
const wa = cfg.channels.whatsapp;
|
|
342
|
+
if (accountId !== "default") {
|
|
343
|
+
if (!wa.accounts)
|
|
344
|
+
wa.accounts = {};
|
|
345
|
+
const accounts = wa.accounts;
|
|
346
|
+
if (!accounts[accountId])
|
|
347
|
+
accounts[accountId] = {};
|
|
348
|
+
if (!accounts[accountId].groups)
|
|
349
|
+
accounts[accountId].groups = {};
|
|
350
|
+
const groups = accounts[accountId].groups;
|
|
351
|
+
groups[params.jid] = {
|
|
352
|
+
...groups[params.jid],
|
|
353
|
+
activation: params.activation,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
if (!wa.groups)
|
|
358
|
+
wa.groups = {};
|
|
359
|
+
const groups = wa.groups;
|
|
360
|
+
groups[params.jid] = {
|
|
361
|
+
...groups[params.jid],
|
|
362
|
+
activation: params.activation,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
await writeConfigFile(cfg);
|
|
366
|
+
respond(true, { ok: true });
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
|
|
370
|
+
}
|
|
371
|
+
},
|
|
372
|
+
"whatsapp.sendMessage": async ({ respond, params }) => {
|
|
373
|
+
if (!validateWhatsAppSendMessageParams(params)) {
|
|
374
|
+
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, `invalid whatsapp.sendMessage params: ${formatValidationErrors(validateWhatsAppSendMessageParams.errors)}`));
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const accountId = normalizeAccountId(params.accountId);
|
|
378
|
+
try {
|
|
379
|
+
const { listener } = requireActiveWebListener(accountId);
|
|
380
|
+
const result = await listener.sendMessage(params.jid, params.body);
|
|
381
|
+
respond(true, { id: result.messageId, timestamp: Date.now() });
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
};
|
|
@@ -95,6 +95,12 @@ const BASE_METHODS = [
|
|
|
95
95
|
"chat.history",
|
|
96
96
|
"chat.abort",
|
|
97
97
|
"chat.send",
|
|
98
|
+
// WhatsApp conversation browser
|
|
99
|
+
"whatsapp.conversations",
|
|
100
|
+
"whatsapp.messages",
|
|
101
|
+
"whatsapp.groupInfo",
|
|
102
|
+
"whatsapp.setActivation",
|
|
103
|
+
"whatsapp.sendMessage",
|
|
98
104
|
];
|
|
99
105
|
export function listGatewayMethods() {
|
|
100
106
|
const channelMethods = listChannelPlugins().flatMap((plugin) => plugin.gatewayMethods ?? []);
|
|
@@ -40,6 +40,7 @@ import { wifiHandlers } from "./server-methods/wifi.js";
|
|
|
40
40
|
import { workspacesHandlers } from "./server-methods/workspaces.js";
|
|
41
41
|
import { brandHandlers } from "./server-methods/brand.js";
|
|
42
42
|
import { businessHandlers } from "./server-methods/business.js";
|
|
43
|
+
import { whatsappConversationsHandlers } from "./server-methods/whatsapp-conversations.js";
|
|
43
44
|
const ADMIN_SCOPE = "operator.admin";
|
|
44
45
|
const READ_SCOPE = "operator.read";
|
|
45
46
|
const WRITE_SCOPE = "operator.write";
|
|
@@ -109,6 +110,9 @@ const READ_METHODS = new Set([
|
|
|
109
110
|
"memory.audit",
|
|
110
111
|
"qr.generate",
|
|
111
112
|
"business.openingHours.get",
|
|
113
|
+
"whatsapp.conversations",
|
|
114
|
+
"whatsapp.messages",
|
|
115
|
+
"whatsapp.groupInfo",
|
|
112
116
|
]);
|
|
113
117
|
const WRITE_METHODS = new Set([
|
|
114
118
|
"send",
|
|
@@ -124,6 +128,8 @@ const WRITE_METHODS = new Set([
|
|
|
124
128
|
"node.invoke",
|
|
125
129
|
"chat.send",
|
|
126
130
|
"chat.abort",
|
|
131
|
+
"whatsapp.setActivation",
|
|
132
|
+
"whatsapp.sendMessage",
|
|
127
133
|
]);
|
|
128
134
|
function authorizeGatewayMethod(method, client) {
|
|
129
135
|
// Access methods bypass all scope checks — needed before PIN login
|
|
@@ -250,6 +256,7 @@ export const coreGatewayHandlers = {
|
|
|
250
256
|
...networkHandlers,
|
|
251
257
|
...tailscaleHandlers,
|
|
252
258
|
...wifiHandlers,
|
|
259
|
+
...whatsappConversationsHandlers,
|
|
253
260
|
};
|
|
254
261
|
export async function handleGatewayRequest(opts) {
|
|
255
262
|
const { req, respond, client, isWebchatConnect, context } = opts;
|
|
@@ -10,7 +10,7 @@ import { CONFIG_PATH_TASKMASTER, isNixMode, loadConfig, migrateLegacyConfig, rea
|
|
|
10
10
|
import { VERSION } from "../version.js";
|
|
11
11
|
import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js";
|
|
12
12
|
import { logAcceptedEnvOption } from "../infra/env.js";
|
|
13
|
-
import { reconcileAgentContactTools, reconcileBeaglePublicTools, reconcileQrGenerateTool, reconcileStaleToolEntries, } from "../config/agent-tools-reconcile.js";
|
|
13
|
+
import { reconcileAgentContactTools, reconcileBeaglePublicTools, reconcileControlPanelTools, reconcileQrGenerateTool, reconcileStaleToolEntries, } from "../config/agent-tools-reconcile.js";
|
|
14
14
|
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
|
15
15
|
import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js";
|
|
16
16
|
import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
|
|
@@ -198,6 +198,21 @@ export async function startGatewayServer(port = 18789, opts = {}) {
|
|
|
198
198
|
log.warn(`gateway: failed to persist qr_generate tool reconciliation: ${String(err)}`);
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
|
+
// Upgrade admin agents from individual control-panel tools to group:control-panel.
|
|
202
|
+
// Agents set up before the group existed miss tools added to it later (e.g. logs_read).
|
|
203
|
+
const cpReconcile = reconcileControlPanelTools({ config: configSnapshot.config });
|
|
204
|
+
if (cpReconcile.changes.length > 0) {
|
|
205
|
+
try {
|
|
206
|
+
await writeConfigFile(cpReconcile.config);
|
|
207
|
+
configSnapshot = await readConfigFileSnapshot();
|
|
208
|
+
log.info(`gateway: reconciled control-panel tools:\n${cpReconcile.changes
|
|
209
|
+
.map((entry) => `- ${entry}`)
|
|
210
|
+
.join("\n")}`);
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
log.warn(`gateway: failed to persist control-panel tools reconciliation: ${String(err)}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
201
216
|
// Stamp config with running version on startup so upgrades keep the stamp current.
|
|
202
217
|
const storedVersion = configSnapshot.config.meta?.lastTouchedVersion;
|
|
203
218
|
if (configSnapshot.exists && storedVersion !== VERSION) {
|
|
@@ -475,11 +490,13 @@ export async function startGatewayServer(port = 18789, opts = {}) {
|
|
|
475
490
|
if (!bundledSkillsDir)
|
|
476
491
|
return;
|
|
477
492
|
try {
|
|
493
|
+
const workspaceIds = Object.keys(cfgAtStart.workspaces ?? {});
|
|
494
|
+
const accountIds = workspaceIds.length > 0 ? workspaceIds : [DEFAULT_ACCOUNT_ID];
|
|
478
495
|
const seeded = await seedPreloadedCronJobs({
|
|
479
496
|
bundledSkillsDir,
|
|
480
497
|
trackerPath: DEFAULT_SEED_TRACKER_PATH,
|
|
481
498
|
cronService: cron,
|
|
482
|
-
|
|
499
|
+
accountIds,
|
|
483
500
|
});
|
|
484
501
|
if (seeded > 0) {
|
|
485
502
|
logCron.info(`cron: seeded ${seeded} preloaded job(s)`);
|
|
@@ -274,7 +274,7 @@ export async function applySessionsPatchToStore(params) {
|
|
|
274
274
|
else if (raw !== undefined) {
|
|
275
275
|
const normalized = normalizeGroupActivation(String(raw));
|
|
276
276
|
if (!normalized) {
|
|
277
|
-
return invalid('invalid groupActivation (use "mention"|"always")');
|
|
277
|
+
return invalid('invalid groupActivation (use "mention"|"always"|"off")');
|
|
278
278
|
}
|
|
279
279
|
next.groupActivation = normalized;
|
|
280
280
|
}
|
|
@@ -7,7 +7,7 @@ metadata:
|
|
|
7
7
|
"taskmaster":
|
|
8
8
|
{
|
|
9
9
|
"emoji": "🚕",
|
|
10
|
-
"events": ["memory:add", "message:
|
|
10
|
+
"events": ["memory:add", "message:before-dispatch"],
|
|
11
11
|
"requires": { "config": ["workspace.dir"] },
|
|
12
12
|
"install": [{ "id": "bundled", "kind": "bundled", "label": "Bundled with Taskmaster" }],
|
|
13
13
|
},
|
|
@@ -28,13 +28,13 @@ When the public agent writes a file matching `shared/dispatch/{jobId}-{phase}.md
|
|
|
28
28
|
2. **Dispatches to admin agent** in a session scoped to the booking (`ride-{jobId}`)
|
|
29
29
|
3. **Admin agent autonomously processes** — contacts drivers, generates payment links, or finalises bookings
|
|
30
30
|
|
|
31
|
-
### Driver Replies (message:
|
|
31
|
+
### Driver Replies (message:before-dispatch)
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
Before a WhatsApp DM is dispatched to the public agent's LLM:
|
|
34
34
|
|
|
35
35
|
1. **Checks the active negotiation index** at `shared/active-negotiations/{phone}.md`
|
|
36
|
-
2. **If the driver has an active negotiation**, dispatches
|
|
37
|
-
3. **If no active negotiation**,
|
|
36
|
+
2. **If the driver has an active negotiation**, sets `event.suppress = true` and dispatches the reply to the admin's ride session — the public agent never sees the message
|
|
37
|
+
3. **If no active negotiation**, does nothing — the message proceeds normally to the public agent
|
|
38
38
|
|
|
39
39
|
## Why This Exists
|
|
40
40
|
|
|
@@ -44,7 +44,8 @@ The public agent must not have `contact_lookup` or `message` tools — exposing
|
|
|
44
44
|
|
|
45
45
|
- Only fires for **Beagle Zanzibar agents** (detected by `beagle` in agent ID)
|
|
46
46
|
- **Deduplicates** — same dispatch file within 30 seconds is ignored
|
|
47
|
-
- **
|
|
47
|
+
- **Driver suppression** — driver replies set `event.suppress = true` so the public agent's LLM is never invoked for driver messages
|
|
48
|
+
- **Non-blocking** — admin dispatch is fire-and-forget after suppression is set
|
|
48
49
|
- Admin agent uses `contact_lookup` → `message` → `memory_write` for driver outreach
|
|
49
50
|
- Admin agent uses `message` to inject results (offers, payment links, driver details) into tourist's conversation via cross-agent echo
|
|
50
51
|
|