@rubytech/taskmaster 1.16.2 → 1.17.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/taskmaster-tools.js +1 -1
- package/dist/agents/tools/logs-read-tool.js +9 -0
- package/dist/agents/tools/memory-tool.js +1 -0
- package/dist/agents/tools/qr-generate-tool.js +7 -3
- 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/group-policy.js +16 -0
- package/dist/config/zod-schema.providers-whatsapp.js +2 -0
- package/dist/control-ui/assets/{index-Bd75cI7J.js → index-Beuhzjy_.js} +525 -492
- package/dist/control-ui/assets/index-Beuhzjy_.js.map +1 -0
- package/dist/control-ui/assets/index-XqRo9tNW.css +1 -0
- package/dist/control-ui/index.html +2 -2
- package/dist/cron/preloaded.js +27 -23
- 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/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 +3 -1
- 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 +75 -30
- 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/dist/control-ui/assets/index-Bd75cI7J.js.map +0 -1
- package/dist/control-ui/assets/index-BkymP95Y.css +0 -1
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* Base path: /public/api/v1/:accountId/
|
|
5
5
|
*
|
|
6
6
|
* Endpoints:
|
|
7
|
-
* POST /session — create an anonymous session (returns
|
|
7
|
+
* POST /session — create an anonymous session (returns session_key and session_token)
|
|
8
8
|
* POST /otp/request — request an OTP code (phone via WhatsApp/SMS, or email via Brevo)
|
|
9
|
-
* POST /otp/verify — verify OTP and get a verified session (returns
|
|
9
|
+
* POST /otp/verify — verify OTP and get a verified session (returns session_key and session_token)
|
|
10
10
|
* POST /chat — send a message, receive the agent reply (sync or SSE stream)
|
|
11
11
|
* GET /chat/history — retrieve past messages for a session
|
|
12
12
|
* POST /chat/abort — cancel an in-progress agent run
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Authentication mirrors the public chat widget: anonymous sessions use a
|
|
16
16
|
* client-provided identity string; verified sessions use OTP sent via phone
|
|
17
|
-
* (WhatsApp with SMS fallback) or email (Brevo). The
|
|
18
|
-
* from /session or /otp/verify
|
|
19
|
-
* subsequent requests.
|
|
17
|
+
* (WhatsApp with SMS fallback) or email (Brevo). The session_key and
|
|
18
|
+
* session_token returned from /session or /otp/verify are passed via
|
|
19
|
+
* X-Session-Key and X-Session-Token headers on subsequent requests.
|
|
20
20
|
*
|
|
21
21
|
* The chat endpoint uses `dispatchInboundMessage` — the same full pipeline
|
|
22
22
|
* as the WebSocket `chat.send` handler — so filler messages, internal hooks,
|
|
@@ -26,6 +26,7 @@ import { randomUUID } from "node:crypto";
|
|
|
26
26
|
import fs from "node:fs";
|
|
27
27
|
import path from "node:path";
|
|
28
28
|
import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "../agents/agent-scope.js";
|
|
29
|
+
import { loadOrCreateSessionSecret, signSessionKey, verifySessionToken, } from "./public-chat/session-token.js";
|
|
29
30
|
import { resolveEffectiveMessagesConfig, resolveIdentityName } from "../agents/identity.js";
|
|
30
31
|
import { dispatchInboundMessage } from "../auto-reply/dispatch.js";
|
|
31
32
|
import { resolveAgentBoundAccountId } from "../routing/bindings.js";
|
|
@@ -50,7 +51,7 @@ const API_PREFIX = "/public/api/v1/";
|
|
|
50
51
|
function setCorsHeaders(res) {
|
|
51
52
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
52
53
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
53
|
-
res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Session-Key");
|
|
54
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-Session-Key, X-Session-Token");
|
|
54
55
|
res.setHeader("Access-Control-Max-Age", "86400");
|
|
55
56
|
}
|
|
56
57
|
function sendNotFound(res) {
|
|
@@ -86,6 +87,29 @@ function getSessionKeyHeader(req) {
|
|
|
86
87
|
return raw[0]?.trim();
|
|
87
88
|
return undefined;
|
|
88
89
|
}
|
|
90
|
+
function getSessionTokenHeader(req) {
|
|
91
|
+
const raw = req.headers["x-session-token"];
|
|
92
|
+
if (typeof raw === "string")
|
|
93
|
+
return raw.trim();
|
|
94
|
+
if (Array.isArray(raw))
|
|
95
|
+
return raw[0]?.trim();
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Validate both the session key format and its HMAC token.
|
|
100
|
+
* Returns the session key string on success, null on any failure.
|
|
101
|
+
*/
|
|
102
|
+
function validateSession(req) {
|
|
103
|
+
const sessionKey = validateSessionKey(getSessionKeyHeader(req));
|
|
104
|
+
if (!sessionKey)
|
|
105
|
+
return null;
|
|
106
|
+
const token = getSessionTokenHeader(req);
|
|
107
|
+
if (!token)
|
|
108
|
+
return null;
|
|
109
|
+
if (!verifySessionToken(loadOrCreateSessionSecret(), sessionKey, token))
|
|
110
|
+
return null;
|
|
111
|
+
return sessionKey;
|
|
112
|
+
}
|
|
89
113
|
/** Minimal phone format check: starts with +, digits only, 7–15 digits. */
|
|
90
114
|
function isValidPhone(phone) {
|
|
91
115
|
return /^\+\d{7,15}$/.test(phone);
|
|
@@ -213,7 +237,8 @@ async function handleSession(req, res, accountId, cfg, maxBodyBytes) {
|
|
|
213
237
|
const agentId = resolvePublicAgentId(cfg, accountId);
|
|
214
238
|
const identifier = `anon-${sessionId}`;
|
|
215
239
|
const sessionKey = buildPublicSessionKey(agentId, identifier);
|
|
216
|
-
|
|
240
|
+
const sessionToken = signSessionKey(loadOrCreateSessionSecret(), sessionKey);
|
|
241
|
+
sendJson(res, 200, { session_key: sessionKey, session_token: sessionToken, agent_id: agentId });
|
|
217
242
|
}
|
|
218
243
|
// ---------------------------------------------------------------------------
|
|
219
244
|
// Route: POST /otp/request
|
|
@@ -351,8 +376,10 @@ async function handleOtpVerify(req, res, accountId, cfg, maxBodyBytes) {
|
|
|
351
376
|
}
|
|
352
377
|
const agentId = resolvePublicAgentId(cfg, accountId);
|
|
353
378
|
const sessionKey = buildPublicSessionKey(agentId, identifier);
|
|
379
|
+
const sessionToken = signSessionKey(loadOrCreateSessionSecret(), sessionKey);
|
|
354
380
|
sendJson(res, 200, {
|
|
355
381
|
session_key: sessionKey,
|
|
382
|
+
session_token: sessionToken,
|
|
356
383
|
agent_id: agentId,
|
|
357
384
|
identifier,
|
|
358
385
|
// Backward-compat: include named field matching the identifier type.
|
|
@@ -368,9 +395,9 @@ async function handleChat(req, res, _accountId, cfg, maxBodyBytes) {
|
|
|
368
395
|
sendMethodNotAllowed(res);
|
|
369
396
|
return;
|
|
370
397
|
}
|
|
371
|
-
const sessionKey =
|
|
398
|
+
const sessionKey = validateSession(req);
|
|
372
399
|
if (!sessionKey) {
|
|
373
|
-
sendInvalidRequest(res, "X-Session-Key
|
|
400
|
+
sendInvalidRequest(res, "X-Session-Key and X-Session-Token headers required (obtain from /session or /otp/verify)");
|
|
374
401
|
return;
|
|
375
402
|
}
|
|
376
403
|
const body = await readJsonBodyOrError(req, res, maxBodyBytes);
|
|
@@ -693,9 +720,9 @@ async function handleChatHistory(req, res) {
|
|
|
693
720
|
sendMethodNotAllowed(res, "GET");
|
|
694
721
|
return;
|
|
695
722
|
}
|
|
696
|
-
const sessionKey =
|
|
723
|
+
const sessionKey = validateSession(req);
|
|
697
724
|
if (!sessionKey) {
|
|
698
|
-
sendInvalidRequest(res, "X-Session-Key
|
|
725
|
+
sendInvalidRequest(res, "X-Session-Key and X-Session-Token headers required");
|
|
699
726
|
return;
|
|
700
727
|
}
|
|
701
728
|
const { cfg, storePath, entry } = loadSessionEntry(sessionKey);
|
|
@@ -736,9 +763,9 @@ async function handleChatAbort(req, res, maxBodyBytes) {
|
|
|
736
763
|
sendMethodNotAllowed(res);
|
|
737
764
|
return;
|
|
738
765
|
}
|
|
739
|
-
const sessionKey =
|
|
766
|
+
const sessionKey = validateSession(req);
|
|
740
767
|
if (!sessionKey) {
|
|
741
|
-
sendInvalidRequest(res, "X-Session-Key
|
|
768
|
+
sendInvalidRequest(res, "X-Session-Key and X-Session-Token headers required");
|
|
742
769
|
return;
|
|
743
770
|
}
|
|
744
771
|
const body = await readJsonBodyOrError(req, res, maxBodyBytes);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { getResolvedLoggerSettings } from "../../logging.js";
|
|
4
|
+
import { levelToMinLevel, ALLOWED_LOG_LEVELS } from "../../logging/levels.js";
|
|
5
|
+
import { parseLogLine } from "../../logging/parse-log-line.js";
|
|
4
6
|
import { ErrorCodes, errorShape, formatValidationErrors, validateLogsTailParams, } from "../protocol/index.js";
|
|
5
7
|
const DEFAULT_LIMIT = 500;
|
|
6
8
|
const DEFAULT_MAX_BYTES = 250_000;
|
|
@@ -134,7 +136,21 @@ export const logsHandlers = {
|
|
|
134
136
|
limit: p.limit ?? DEFAULT_LIMIT,
|
|
135
137
|
maxBytes: p.maxBytes ?? DEFAULT_MAX_BYTES,
|
|
136
138
|
});
|
|
137
|
-
|
|
139
|
+
const minLevelNum = p.minLevel !== undefined ? levelToMinLevel(p.minLevel) : undefined;
|
|
140
|
+
const filteredLines = minLevelNum !== undefined
|
|
141
|
+
? result.lines.filter((line) => {
|
|
142
|
+
const parsed = parseLogLine(line);
|
|
143
|
+
if (!parsed || parsed.level === undefined)
|
|
144
|
+
return true; // keep unparseable
|
|
145
|
+
const levelNum = ALLOWED_LOG_LEVELS.includes(parsed.level)
|
|
146
|
+
? levelToMinLevel(parsed.level)
|
|
147
|
+
: undefined;
|
|
148
|
+
if (levelNum === undefined)
|
|
149
|
+
return true; // unknown level — keep
|
|
150
|
+
return levelNum <= minLevelNum;
|
|
151
|
+
})
|
|
152
|
+
: result.lines;
|
|
153
|
+
respond(true, { file, ...result, lines: filteredLines }, undefined);
|
|
138
154
|
}
|
|
139
155
|
catch (err) {
|
|
140
156
|
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, `log read failed: ${String(err)}`));
|
|
@@ -8,6 +8,7 @@ import { ErrorCodes, errorShape } from "../protocol/index.js";
|
|
|
8
8
|
import { requestOtp, verifyOtp } from "../public-chat/otp.js";
|
|
9
9
|
import { deliverOtp, hasPhoneMethod, normalizeVerifyMethods } from "../public-chat/deliver-otp.js";
|
|
10
10
|
import { buildPublicSessionKey, resolvePublicAgentId } from "../public-chat/session.js";
|
|
11
|
+
import { loadOrCreateSessionSecret, signSessionKey } from "../public-chat/session-token.js";
|
|
11
12
|
/** Strip spaces, dashes, and parentheses from a phone number. */
|
|
12
13
|
function normalizePhone(raw) {
|
|
13
14
|
return raw.replace(/[\s\-()]/g, "");
|
|
@@ -155,9 +156,11 @@ export const publicChatHandlers = {
|
|
|
155
156
|
}
|
|
156
157
|
const agentId = resolvePublicAgentId(cfg, accountId);
|
|
157
158
|
const sessionKey = buildPublicSessionKey(agentId, identifier);
|
|
159
|
+
const sessionToken = signSessionKey(loadOrCreateSessionSecret(), sessionKey); // secret is memoised after first load
|
|
158
160
|
respond(true, {
|
|
159
161
|
ok: true,
|
|
160
162
|
sessionKey,
|
|
163
|
+
sessionToken,
|
|
161
164
|
agentId,
|
|
162
165
|
identifier,
|
|
163
166
|
// Backward compat: include named field matching the identifier type.
|
|
@@ -188,9 +191,11 @@ export const publicChatHandlers = {
|
|
|
188
191
|
const agentId = resolvePublicAgentId(cfg, accountId);
|
|
189
192
|
const identifier = `anon-${cookieId}`;
|
|
190
193
|
const sessionKey = buildPublicSessionKey(agentId, identifier);
|
|
194
|
+
const sessionToken = signSessionKey(loadOrCreateSessionSecret(), sessionKey); // secret is memoised after first load
|
|
191
195
|
respond(true, {
|
|
192
196
|
ok: true,
|
|
193
197
|
sessionKey,
|
|
198
|
+
sessionToken,
|
|
194
199
|
agentId,
|
|
195
200
|
});
|
|
196
201
|
},
|
|
@@ -30,6 +30,18 @@ function extractTextFromContentBlocks(blocks) {
|
|
|
30
30
|
}
|
|
31
31
|
return parts.join("\n");
|
|
32
32
|
}
|
|
33
|
+
/** Detect soft-failure tool results: content is JSON with success === false. */
|
|
34
|
+
function isToolResultSoftError(content) {
|
|
35
|
+
if (!content.trim().startsWith("{"))
|
|
36
|
+
return false;
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(content);
|
|
39
|
+
return parsed.success === false;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
33
45
|
/** Format tool input as readable key=value pairs instead of raw JSON. */
|
|
34
46
|
function formatToolInput(input) {
|
|
35
47
|
if (input == null)
|
|
@@ -76,11 +88,12 @@ function expandLineToEntries(line, sessionId, sessionKey, agentId, fileMtimeMs)
|
|
|
76
88
|
return entries;
|
|
77
89
|
}
|
|
78
90
|
if (line.type === "tool_result") {
|
|
79
|
-
const
|
|
91
|
+
const rawContent = line.result != null
|
|
80
92
|
? typeof line.result === "string"
|
|
81
93
|
? line.result
|
|
82
94
|
: JSON.stringify(line.result)
|
|
83
95
|
: "";
|
|
96
|
+
const content = isToolResultSoftError(rawContent) ? `[error] ${rawContent}` : rawContent;
|
|
84
97
|
entries.push({
|
|
85
98
|
sessionId,
|
|
86
99
|
sessionKey,
|
|
@@ -102,7 +115,10 @@ function expandLineToEntries(line, sessionId, sessionKey, agentId, fileMtimeMs)
|
|
|
102
115
|
if (msg.role === "toolResult") {
|
|
103
116
|
const textParts = [];
|
|
104
117
|
for (const block of contentBlocks) {
|
|
105
|
-
if (block &&
|
|
118
|
+
if (block &&
|
|
119
|
+
typeof block === "object" &&
|
|
120
|
+
typeof block.text === "string" &&
|
|
121
|
+
block.text.trim()) {
|
|
106
122
|
textParts.push(block.text.trim());
|
|
107
123
|
}
|
|
108
124
|
}
|
|
@@ -112,13 +128,14 @@ function expandLineToEntries(line, sessionId, sessionKey, agentId, fileMtimeMs)
|
|
|
112
128
|
const content = textParts.length > 0 ? textParts.join("\n") : "(empty result)";
|
|
113
129
|
const toolName = typeof msg.toolName === "string" ? msg.toolName : undefined;
|
|
114
130
|
const toolCallId = typeof msg.toolCallId === "string" ? msg.toolCallId : undefined;
|
|
131
|
+
const isError = msg.isError || isToolResultSoftError(content);
|
|
115
132
|
entries.push({
|
|
116
133
|
sessionId,
|
|
117
134
|
sessionKey,
|
|
118
135
|
agentId,
|
|
119
136
|
timestamp: ts,
|
|
120
137
|
type: "tool_result",
|
|
121
|
-
content:
|
|
138
|
+
content: isError ? `[error] ${content}` : content,
|
|
122
139
|
...(toolName ? { toolName } : {}),
|
|
123
140
|
...(toolCallId ? { toolCallId } : {}),
|
|
124
141
|
...(model ? { model } : {}),
|
|
@@ -259,6 +276,7 @@ export const sessionsTranscriptHandlers = {
|
|
|
259
276
|
}
|
|
260
277
|
const p = params;
|
|
261
278
|
const limit = p.limit ?? DEFAULT_LIMIT;
|
|
279
|
+
const errorsOnly = p.errorsOnly === true;
|
|
262
280
|
const inputCursors = p.cursors ?? {};
|
|
263
281
|
const agentFilter = p.agents && p.agents.length > 0 ? new Set(p.agents) : null;
|
|
264
282
|
try {
|
|
@@ -343,13 +361,19 @@ export const sessionsTranscriptHandlers = {
|
|
|
343
361
|
}
|
|
344
362
|
}
|
|
345
363
|
}
|
|
346
|
-
//
|
|
347
|
-
|
|
364
|
+
// Filter to errors only if requested: session-level errors OR tool results that failed
|
|
365
|
+
const filtered = errorsOnly
|
|
366
|
+
? allEntries.filter((e) => e.type === "error" ||
|
|
367
|
+
(e.type === "tool_result" &&
|
|
368
|
+
typeof e.content === "string" &&
|
|
369
|
+
e.content.startsWith("[error]")))
|
|
370
|
+
: allEntries;
|
|
371
|
+
filtered.sort((a, b) => {
|
|
348
372
|
const ta = new Date(a.timestamp).getTime();
|
|
349
373
|
const tb = new Date(b.timestamp).getTime();
|
|
350
374
|
return tb - ta;
|
|
351
375
|
});
|
|
352
|
-
const entries =
|
|
376
|
+
const entries = filtered.slice(0, limit);
|
|
353
377
|
// Include configured agents so UI filter chips appear even when an
|
|
354
378
|
// agent has no sessions yet. When an agent filter is active (account
|
|
355
379
|
// scoping), only add config agents that match the filter — otherwise
|
|
@@ -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
|
+
};
|