@rubytech/taskmaster 1.16.3 → 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.
Files changed (45) hide show
  1. package/dist/agents/tools/logs-read-tool.js +9 -0
  2. package/dist/agents/tools/memory-tool.js +1 -0
  3. package/dist/auto-reply/group-activation.js +2 -0
  4. package/dist/auto-reply/reply/commands-session.js +28 -11
  5. package/dist/build-info.json +3 -3
  6. package/dist/config/group-policy.js +16 -0
  7. package/dist/config/zod-schema.providers-whatsapp.js +2 -0
  8. package/dist/control-ui/assets/{index-Bd75cI7J.js → index-Beuhzjy_.js} +525 -492
  9. package/dist/control-ui/assets/index-Beuhzjy_.js.map +1 -0
  10. package/dist/control-ui/assets/index-XqRo9tNW.css +1 -0
  11. package/dist/control-ui/index.html +2 -2
  12. package/dist/cron/preloaded.js +27 -23
  13. package/dist/gateway/protocol/index.js +7 -2
  14. package/dist/gateway/protocol/schema/logs-chat.js +6 -0
  15. package/dist/gateway/protocol/schema/protocol-schemas.js +6 -0
  16. package/dist/gateway/protocol/schema/sessions-transcript.js +1 -0
  17. package/dist/gateway/protocol/schema/sessions.js +6 -1
  18. package/dist/gateway/protocol/schema/whatsapp.js +24 -0
  19. package/dist/gateway/protocol/schema.js +1 -0
  20. package/dist/gateway/public-chat/session-token.js +52 -0
  21. package/dist/gateway/public-chat-api.js +40 -13
  22. package/dist/gateway/server-methods/logs.js +17 -1
  23. package/dist/gateway/server-methods/public-chat.js +5 -0
  24. package/dist/gateway/server-methods/sessions-transcript.js +30 -6
  25. package/dist/gateway/server-methods/whatsapp-conversations.js +387 -0
  26. package/dist/gateway/server-methods-list.js +6 -0
  27. package/dist/gateway/server-methods.js +7 -0
  28. package/dist/gateway/server.impl.js +3 -1
  29. package/dist/gateway/sessions-patch.js +1 -1
  30. package/dist/hooks/bundled/ride-dispatch/HOOK.md +7 -6
  31. package/dist/hooks/bundled/ride-dispatch/handler.js +75 -30
  32. package/dist/memory/manager.js +3 -3
  33. package/dist/tui/tui-command-handlers.js +1 -1
  34. package/dist/web/auto-reply/monitor/group-activation.js +12 -10
  35. package/dist/web/auto-reply/monitor/group-gating.js +23 -2
  36. package/dist/web/auto-reply/monitor/on-message.js +27 -5
  37. package/dist/web/auto-reply/monitor/process-message.js +64 -53
  38. package/dist/web/inbound/monitor.js +30 -0
  39. package/extensions/whatsapp/src/channel.ts +1 -1
  40. package/package.json +1 -1
  41. package/skills/log-review/SKILL.md +17 -4
  42. package/skills/log-review/references/review-protocol.md +4 -4
  43. package/taskmaster-docs/USER-GUIDE.md +14 -0
  44. package/dist/control-ui/assets/index-Bd75cI7J.js.map +0 -1
  45. 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 sessionKey)
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 sessionKey)
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 sessionKey returned
18
- * from /session or /otp/verify is passed via the X-Session-Key header on
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
- sendJson(res, 200, { session_key: sessionKey, agent_id: agentId });
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 = validateSessionKey(getSessionKeyHeader(req));
398
+ const sessionKey = validateSession(req);
372
399
  if (!sessionKey) {
373
- sendInvalidRequest(res, "X-Session-Key header required (obtain from /session or /otp/verify)");
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 = validateSessionKey(getSessionKeyHeader(req));
723
+ const sessionKey = validateSession(req);
697
724
  if (!sessionKey) {
698
- sendInvalidRequest(res, "X-Session-Key header required");
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 = validateSessionKey(getSessionKeyHeader(req));
766
+ const sessionKey = validateSession(req);
740
767
  if (!sessionKey) {
741
- sendInvalidRequest(res, "X-Session-Key header required");
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
- respond(true, { file, ...result }, undefined);
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 content = line.result != null
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 && typeof block === "object" && typeof block.text === "string" && block.text.trim()) {
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: msg.isError ? `[error] ${content}` : 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
- // Sort by timestamp descending and truncate to limit
347
- allEntries.sort((a, b) => {
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 = allEntries.slice(0, limit);
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
+ };