@agifyai/leadify-mcp 8.5.0 → 8.5.2
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/README.md +16 -1
- package/dist/server.js +2 -0
- package/dist/tools/campaigns.d.ts +2 -1
- package/dist/tools/campaigns.js +10 -6
- package/dist/tools/commercial_truth.d.ts +48 -0
- package/dist/tools/commercial_truth.js +1141 -0
- package/dist/tools/fine_tuning.d.ts +4 -1
- package/dist/tools/fine_tuning.js +38 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
|
@@ -0,0 +1,1141 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getClient } from "../client.js";
|
|
3
|
+
import { handleToolError, toolError, toolResult } from "../types.js";
|
|
4
|
+
const DEFAULT_LIMIT = 25;
|
|
5
|
+
const MAX_LIMIT = 50;
|
|
6
|
+
const DEFAULT_CONTENT_LIMIT = 1_000;
|
|
7
|
+
const MAX_CONTENT_LIMIT = 5_000;
|
|
8
|
+
const DEFAULT_FRESHNESS_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
9
|
+
const MAX_FRESHNESS_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1_000;
|
|
10
|
+
const MAX_TO_ADDRESSES = 10;
|
|
11
|
+
const MAX_CONTACTS = 10;
|
|
12
|
+
const MAX_AMBIGUITIES = 20;
|
|
13
|
+
class IntegrationNotConfiguredError extends Error {
|
|
14
|
+
source;
|
|
15
|
+
organizationId;
|
|
16
|
+
constructor(source, organizationId) {
|
|
17
|
+
super(`${source} integration is not configured for organization "${organizationId}"`);
|
|
18
|
+
this.name = "IntegrationNotConfiguredError";
|
|
19
|
+
this.source = source;
|
|
20
|
+
this.organizationId = organizationId;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function record(value) {
|
|
24
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
25
|
+
? value
|
|
26
|
+
: {};
|
|
27
|
+
}
|
|
28
|
+
function stringValue(value, max = 500) {
|
|
29
|
+
if (typeof value !== "string")
|
|
30
|
+
return null;
|
|
31
|
+
const trimmed = value.trim();
|
|
32
|
+
if (!trimmed)
|
|
33
|
+
return null;
|
|
34
|
+
const marker = "… [truncated]";
|
|
35
|
+
return trimmed.length > max
|
|
36
|
+
? `${trimmed.slice(0, Math.max(0, max - marker.length))}${marker}`
|
|
37
|
+
: trimmed;
|
|
38
|
+
}
|
|
39
|
+
function firstString(source, keys, max = 500) {
|
|
40
|
+
return keys.map((key) => stringValue(source[key], max)).find((value) => value !== null) ?? null;
|
|
41
|
+
}
|
|
42
|
+
function booleanValue(value) {
|
|
43
|
+
if (typeof value === "boolean")
|
|
44
|
+
return value;
|
|
45
|
+
if (typeof value !== "string")
|
|
46
|
+
return undefined;
|
|
47
|
+
const normalized = value.trim().toLowerCase();
|
|
48
|
+
if (["true", "1", "yes", "y", "oui"].includes(normalized))
|
|
49
|
+
return true;
|
|
50
|
+
if (["false", "0", "no", "n", "non", "null", "none"].includes(normalized))
|
|
51
|
+
return false;
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
function safeUrl(value, max = 500) {
|
|
55
|
+
const raw = stringValue(value, max);
|
|
56
|
+
if (!raw)
|
|
57
|
+
return null;
|
|
58
|
+
try {
|
|
59
|
+
const url = new URL(raw);
|
|
60
|
+
url.search = "";
|
|
61
|
+
url.hash = "";
|
|
62
|
+
return url.toString().slice(0, max);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const SECRET_KEY = /(api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|authorization|cookie|password|secret|private[_-]?key)/i;
|
|
69
|
+
function safeValue(value, contentLimit, depth = 0) {
|
|
70
|
+
if (value === null || typeof value === "boolean" || typeof value === "number")
|
|
71
|
+
return value;
|
|
72
|
+
if (typeof value === "string")
|
|
73
|
+
return stringValue(value, contentLimit);
|
|
74
|
+
if (depth >= 2)
|
|
75
|
+
return "[omitted]";
|
|
76
|
+
if (Array.isArray(value))
|
|
77
|
+
return value.slice(0, 10).map((item) => safeValue(item, contentLimit, depth + 1));
|
|
78
|
+
if (typeof value !== "object")
|
|
79
|
+
return undefined;
|
|
80
|
+
return Object.entries(value)
|
|
81
|
+
.slice(0, 20)
|
|
82
|
+
.reduce((result, [key, item]) => {
|
|
83
|
+
if (!SECRET_KEY.test(key))
|
|
84
|
+
result[key] = safeValue(item, contentLimit, depth + 1);
|
|
85
|
+
return result;
|
|
86
|
+
}, {});
|
|
87
|
+
}
|
|
88
|
+
function boundedArray(value, limit) {
|
|
89
|
+
return Array.isArray(value) ? value.slice(0, limit) : [];
|
|
90
|
+
}
|
|
91
|
+
function arrayAt(source, keys) {
|
|
92
|
+
return keys.map((key) => source[key]).find((value) => Array.isArray(value)) ?? [];
|
|
93
|
+
}
|
|
94
|
+
function nestedArrayAt(source, parentKeys, keys) {
|
|
95
|
+
return parentKeys
|
|
96
|
+
.map((parentKey) => record(source[parentKey]))
|
|
97
|
+
.map((parent) => arrayAt(parent, keys))
|
|
98
|
+
.find((items) => items.length > 0) ?? [];
|
|
99
|
+
}
|
|
100
|
+
function normalizeDirection(value) {
|
|
101
|
+
const normalized = String(value ?? "").trim().toLowerCase();
|
|
102
|
+
if (["inbound", "incoming", "received", "replied"].includes(normalized))
|
|
103
|
+
return "inbound";
|
|
104
|
+
if (["outbound", "outgoing", "sent", "from_us"].includes(normalized))
|
|
105
|
+
return "outbound";
|
|
106
|
+
if (["system", "internal"].includes(normalized))
|
|
107
|
+
return "system";
|
|
108
|
+
return "unknown";
|
|
109
|
+
}
|
|
110
|
+
function normalizeStatus(value) {
|
|
111
|
+
return String(value ?? "").trim().toLowerCase().replace(/[\s-]+/g, "_");
|
|
112
|
+
}
|
|
113
|
+
function looksLikeDate(value) {
|
|
114
|
+
return typeof value === "string" && !Number.isNaN(Date.parse(value));
|
|
115
|
+
}
|
|
116
|
+
function observedDate(values) {
|
|
117
|
+
return values.filter(looksLikeDate).sort().at(-1) ?? null;
|
|
118
|
+
}
|
|
119
|
+
function dateFrom(source) {
|
|
120
|
+
return firstString(source, [
|
|
121
|
+
"date",
|
|
122
|
+
"occurredAt",
|
|
123
|
+
"createdAt",
|
|
124
|
+
"updatedAt",
|
|
125
|
+
"timestamp",
|
|
126
|
+
"sentAt",
|
|
127
|
+
"receivedAt",
|
|
128
|
+
"lastActivity",
|
|
129
|
+
], 100);
|
|
130
|
+
}
|
|
131
|
+
function limitFor(target) {
|
|
132
|
+
return Math.min(Math.max(1, Math.floor(target.limit ?? DEFAULT_LIMIT)), MAX_LIMIT);
|
|
133
|
+
}
|
|
134
|
+
function contentLimitFor(target) {
|
|
135
|
+
return Math.min(Math.max(100, Math.floor(target.max_content_chars ?? DEFAULT_CONTENT_LIMIT)), MAX_CONTENT_LIMIT);
|
|
136
|
+
}
|
|
137
|
+
function freshnessMaxAgeFor(target) {
|
|
138
|
+
return Math.min(Math.max(1, Math.floor(target.freshness_max_age_ms ?? DEFAULT_FRESHNESS_MAX_AGE_MS)), MAX_FRESHNESS_MAX_AGE_MS);
|
|
139
|
+
}
|
|
140
|
+
function makeFreshness(date, target) {
|
|
141
|
+
if (!date || !looksLikeDate(date))
|
|
142
|
+
return { observedAt: date, state: "unknown" };
|
|
143
|
+
const ageMs = Math.max(0, Date.now() - Date.parse(date));
|
|
144
|
+
return {
|
|
145
|
+
observedAt: date,
|
|
146
|
+
ageMs,
|
|
147
|
+
state: ageMs <= freshnessMaxAgeFor(target) ? "fresh" : "stale",
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function uniqueAmbiguities(values) {
|
|
151
|
+
return [...new Set(values.filter(Boolean))].slice(0, MAX_AMBIGUITIES);
|
|
152
|
+
}
|
|
153
|
+
function makeProjection(source, target, values) {
|
|
154
|
+
return {
|
|
155
|
+
source,
|
|
156
|
+
date: values.date ?? null,
|
|
157
|
+
direction: values.direction ?? "unknown",
|
|
158
|
+
status: values.status ?? "unknown",
|
|
159
|
+
optOut: values.optOut === true,
|
|
160
|
+
bounce: values.bounce === true,
|
|
161
|
+
suppression: values.suppression === true,
|
|
162
|
+
handoff: values.handoff === true,
|
|
163
|
+
freshness: makeFreshness(values.date ?? null, target),
|
|
164
|
+
ambiguities: uniqueAmbiguities(values.ambiguities ?? []),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function addPagination(query, target) {
|
|
168
|
+
query.set("limit", String(limitFor(target)));
|
|
169
|
+
if (target.cursor)
|
|
170
|
+
query.set("cursor", target.cursor);
|
|
171
|
+
}
|
|
172
|
+
function scopedQuery(target) {
|
|
173
|
+
const query = new URLSearchParams({
|
|
174
|
+
organizationId: target.organization_id,
|
|
175
|
+
leadGroupId: target.lead_group_id,
|
|
176
|
+
});
|
|
177
|
+
if (target.campaign_id)
|
|
178
|
+
query.set("campaignId", target.campaign_id);
|
|
179
|
+
if (target.page !== undefined)
|
|
180
|
+
query.set("page", String(Math.max(1, Math.floor(target.page))));
|
|
181
|
+
addPagination(query, target);
|
|
182
|
+
return query;
|
|
183
|
+
}
|
|
184
|
+
function pagination(data, target, returned, rawCount) {
|
|
185
|
+
const source = record(data.pagination ?? data.pageInfo ?? data.meta);
|
|
186
|
+
const keys = ["page", "limit", "total", "totalPages", "hasMore", "nextCursor", "next_cursor", "previousCursor", "previous_cursor"];
|
|
187
|
+
const result = keys.reduce((output, key) => {
|
|
188
|
+
if (source[key] !== undefined)
|
|
189
|
+
output[key] = safeValue(source[key], 200);
|
|
190
|
+
return output;
|
|
191
|
+
}, {});
|
|
192
|
+
const next = firstString(data, ["nextCursor", "next_cursor"], 500)
|
|
193
|
+
?? firstString(source, ["nextCursor", "next_cursor"], 500);
|
|
194
|
+
if (next && result.nextCursor === undefined && result.next_cursor === undefined)
|
|
195
|
+
result.nextCursor = next;
|
|
196
|
+
if (result.hasMore === undefined && next)
|
|
197
|
+
result.hasMore = true;
|
|
198
|
+
result.returned = returned;
|
|
199
|
+
if ((rawCount ?? returned) > returned)
|
|
200
|
+
result.truncated = true;
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
function responseOrganizationId(data) {
|
|
204
|
+
const organization = record(data.organization);
|
|
205
|
+
const leadGroup = record(data.leadGroup);
|
|
206
|
+
const campaign = record(data.campaign);
|
|
207
|
+
const nestedData = record(data.data);
|
|
208
|
+
const response = record(data.response);
|
|
209
|
+
return firstString(data, ["organizationId", "organization_id"], 200)
|
|
210
|
+
?? firstString(organization, ["id", "organizationId"], 200)
|
|
211
|
+
?? firstString(leadGroup, ["organizationId", "organization_id"], 200)
|
|
212
|
+
?? firstString(campaign, ["organizationId", "organization_id"], 200)
|
|
213
|
+
?? firstString(nestedData, ["organizationId", "organization_id"], 200)
|
|
214
|
+
?? firstString(response, ["organizationId", "organization_id"], 200);
|
|
215
|
+
}
|
|
216
|
+
function responseGroupId(data) {
|
|
217
|
+
const leadGroup = record(data.leadGroup);
|
|
218
|
+
const nestedData = record(data.data);
|
|
219
|
+
const response = record(data.response);
|
|
220
|
+
return firstString(data, ["leadGroupId", "lead_group_id", "groupId", "group_id"], 200)
|
|
221
|
+
?? firstString(leadGroup, ["id", "leadGroupId", "groupId"], 200)
|
|
222
|
+
?? firstString(nestedData, ["leadGroupId", "lead_group_id", "groupId", "group_id"], 200)
|
|
223
|
+
?? firstString(response, ["leadGroupId", "lead_group_id", "groupId", "group_id"], 200);
|
|
224
|
+
}
|
|
225
|
+
function responseLeadId(data) {
|
|
226
|
+
const nestedData = record(data.data);
|
|
227
|
+
return firstString(data, ["leadId", "lead_id"], 200)
|
|
228
|
+
?? firstString(nestedData, ["leadId", "lead_id"], 200);
|
|
229
|
+
}
|
|
230
|
+
function responseCampaignId(data) {
|
|
231
|
+
const campaign = record(data.campaign);
|
|
232
|
+
const nestedData = record(data.data);
|
|
233
|
+
return firstString(data, ["campaignId", "campaign_id"], 200)
|
|
234
|
+
?? firstString(campaign, ["id", "campaignId"], 200)
|
|
235
|
+
?? firstString(nestedData, ["campaignId", "campaign_id"], 200);
|
|
236
|
+
}
|
|
237
|
+
function assertScopedResponse(data, target, source) {
|
|
238
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
239
|
+
throw new Error(`Leadify returned an invalid ${source} read response`);
|
|
240
|
+
}
|
|
241
|
+
const value = data;
|
|
242
|
+
const actualOrganizationId = responseOrganizationId(value);
|
|
243
|
+
if (actualOrganizationId !== null && actualOrganizationId !== target.organization_id) {
|
|
244
|
+
throw new Error(`${source} response belongs to organization "${actualOrganizationId}", not the explicitly selected organization "${target.organization_id}"`);
|
|
245
|
+
}
|
|
246
|
+
const actualGroupId = responseGroupId(value);
|
|
247
|
+
if (actualGroupId !== null && actualGroupId !== target.lead_group_id) {
|
|
248
|
+
throw new Error(`${source} response belongs to lead group "${actualGroupId}", not the explicitly selected group "${target.lead_group_id}"`);
|
|
249
|
+
}
|
|
250
|
+
const actualLeadId = responseLeadId(value);
|
|
251
|
+
if (target.lead_id && actualLeadId !== null && actualLeadId !== target.lead_id) {
|
|
252
|
+
throw new Error(`${source} response belongs to lead "${actualLeadId}", not the explicitly selected lead "${target.lead_id}"`);
|
|
253
|
+
}
|
|
254
|
+
const actualCampaignId = responseCampaignId(value);
|
|
255
|
+
if (target.campaign_id && actualCampaignId !== null && actualCampaignId !== target.campaign_id) {
|
|
256
|
+
throw new Error(`${source} response belongs to campaign "${actualCampaignId}", not the explicitly selected campaign "${target.campaign_id}"`);
|
|
257
|
+
}
|
|
258
|
+
const nestedItems = ["messages", "entries", "activities", "items", "data", "deals", "domainContacts", "conversations"]
|
|
259
|
+
.flatMap((key) => Array.isArray(value[key]) ? value[key].slice(0, MAX_LIMIT) : []);
|
|
260
|
+
nestedItems.forEach((itemValue) => {
|
|
261
|
+
const item = record(itemValue);
|
|
262
|
+
const itemOrganizationId = responseOrganizationId(item);
|
|
263
|
+
if (itemOrganizationId !== null && itemOrganizationId !== target.organization_id) {
|
|
264
|
+
throw new Error(`${source} response contains a record from organization "${itemOrganizationId}" outside the explicitly selected organization`);
|
|
265
|
+
}
|
|
266
|
+
const itemGroupId = responseGroupId(item);
|
|
267
|
+
if (itemGroupId !== null && itemGroupId !== target.lead_group_id) {
|
|
268
|
+
throw new Error(`${source} response contains a record from lead group "${itemGroupId}" outside the explicitly selected group`);
|
|
269
|
+
}
|
|
270
|
+
const itemLeadId = responseLeadId(item);
|
|
271
|
+
if (target.lead_id && itemLeadId !== null && itemLeadId !== target.lead_id) {
|
|
272
|
+
throw new Error(`${source} response contains a record from lead "${itemLeadId}" outside the explicitly selected lead`);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
return value;
|
|
276
|
+
}
|
|
277
|
+
function configuredFlag(data, source) {
|
|
278
|
+
const key = {
|
|
279
|
+
crm: "crmConfigured",
|
|
280
|
+
email: "emailConfigured",
|
|
281
|
+
linkedin: "linkedinConfigured",
|
|
282
|
+
unipile: "unipileConfigured",
|
|
283
|
+
leadify_activity: "activityConfigured",
|
|
284
|
+
}[source];
|
|
285
|
+
if (data[key] === false || booleanValue(data[key]) === false)
|
|
286
|
+
return false;
|
|
287
|
+
if (data.configured === false || booleanValue(data.configured) === false || data.integrationConfigured === false || booleanValue(data.integrationConfigured) === false)
|
|
288
|
+
return false;
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
function requireConfigured(data, target, source) {
|
|
292
|
+
if (!configuredFlag(data, source))
|
|
293
|
+
throw new IntegrationNotConfiguredError(source, target.organization_id);
|
|
294
|
+
}
|
|
295
|
+
function explicitFlag(value, keys) {
|
|
296
|
+
const wanted = new Set(keys.map((key) => key.toLowerCase().replace(/[^a-z]/g, "")));
|
|
297
|
+
const visit = (candidate, depth) => {
|
|
298
|
+
if (depth > 4 || !candidate || typeof candidate !== "object")
|
|
299
|
+
return false;
|
|
300
|
+
if (Array.isArray(candidate))
|
|
301
|
+
return candidate.some((item) => visit(item, depth + 1));
|
|
302
|
+
return Object.entries(candidate).some(([key, item]) => {
|
|
303
|
+
const normalizedKey = key.toLowerCase().replace(/[^a-z]/g, "");
|
|
304
|
+
if (wanted.has(normalizedKey)) {
|
|
305
|
+
if (booleanValue(item) === true)
|
|
306
|
+
return true;
|
|
307
|
+
if (normalizedKey.includes("handoff") && item && typeof item === "object")
|
|
308
|
+
return true;
|
|
309
|
+
if (typeof item === "string" && item.trim() && (normalizedKey.endsWith("at") || normalizedKey.includes("handoff")))
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
if (["status", "deliverystatus", "event"].includes(normalizedKey)) {
|
|
313
|
+
const normalizedValue = normalizeStatus(item);
|
|
314
|
+
if (keys.includes("bounce") && ["bounce", "bounced", "hard_bounce", "soft_bounce", "undeliverable"].includes(normalizedValue))
|
|
315
|
+
return true;
|
|
316
|
+
if (keys.includes("optOut") && ["opted_out", "unsubscribed", "do_not_contact"].includes(normalizedValue))
|
|
317
|
+
return true;
|
|
318
|
+
if (keys.includes("suppression") && ["suppressed", "suppression", "do_not_contact"].includes(normalizedValue))
|
|
319
|
+
return true;
|
|
320
|
+
if (keys.includes("handoff") && ["handoff", "handed_off", "crm_handoff"].includes(normalizedValue))
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
return visit(item, depth + 1);
|
|
324
|
+
});
|
|
325
|
+
};
|
|
326
|
+
return visit(value, 0);
|
|
327
|
+
}
|
|
328
|
+
function commercialFlags(value) {
|
|
329
|
+
return {
|
|
330
|
+
optOut: explicitFlag(value, ["optOut", "optedOut", "unsubscribed", "unsubscribe", "unsubscribedAt", "optOutAt", "doNotContact", "excluded"]),
|
|
331
|
+
bounce: explicitFlag(value, ["bounce", "bounced", "hardBounce", "softBounce", "bouncedAt", "bounceAt", "undeliverable"]),
|
|
332
|
+
suppression: explicitFlag(value, ["suppression", "suppressed", "suppressionStatus", "suppressedAt"]),
|
|
333
|
+
handoff: explicitFlag(value, ["handoff", "crmHandoff", "handedOff", "handoffAt"]),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
function safePerson(value) {
|
|
337
|
+
const source = record(value);
|
|
338
|
+
if (Object.keys(source).length === 0)
|
|
339
|
+
return null;
|
|
340
|
+
return {
|
|
341
|
+
id: firstString(source, ["id", "personId"], 200),
|
|
342
|
+
name: firstString(source, ["name", "fullName"], 300),
|
|
343
|
+
email: firstString(source, ["email"], 320),
|
|
344
|
+
jobTitle: firstString(source, ["jobTitle", "title"], 300),
|
|
345
|
+
companyId: firstString(source, ["companyId"], 200),
|
|
346
|
+
companyName: firstString(source, ["companyName", "company"], 300),
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
function safeDeal(value, contentLimit) {
|
|
350
|
+
const source = record(value);
|
|
351
|
+
const contacts = boundedArray(source.contacts, MAX_CONTACTS).map((contact) => {
|
|
352
|
+
const item = record(contact);
|
|
353
|
+
return {
|
|
354
|
+
id: firstString(item, ["id"], 200),
|
|
355
|
+
name: firstString(item, ["name", "fullName"], 300),
|
|
356
|
+
email: firstString(item, ["email"], 320),
|
|
357
|
+
};
|
|
358
|
+
});
|
|
359
|
+
const result = {
|
|
360
|
+
id: firstString(source, ["id", "dealId"], 200),
|
|
361
|
+
name: firstString(source, ["name", "title"], 300),
|
|
362
|
+
status: normalizeStatus(source.status),
|
|
363
|
+
nativeStage: firstString(source, ["nativeStage", "stage"], 300),
|
|
364
|
+
amount: typeof source.amount === "number" ? source.amount : null,
|
|
365
|
+
currency: firstString(source, ["currency"], 50),
|
|
366
|
+
contacts,
|
|
367
|
+
};
|
|
368
|
+
const date = dateFrom(source);
|
|
369
|
+
if (date)
|
|
370
|
+
result.date = date;
|
|
371
|
+
if (source.description !== undefined)
|
|
372
|
+
result.description = stringValue(source.description, contentLimit);
|
|
373
|
+
return result;
|
|
374
|
+
}
|
|
375
|
+
function safeDomainContact(value) {
|
|
376
|
+
return safePerson(value) ?? { id: null, name: null, email: null };
|
|
377
|
+
}
|
|
378
|
+
function safeMailboxes(value) {
|
|
379
|
+
return boundedArray(value, MAX_LIMIT).map((mailbox) => {
|
|
380
|
+
const source = record(mailbox);
|
|
381
|
+
return {
|
|
382
|
+
provider: firstString(source, ["provider"], 100),
|
|
383
|
+
email: firstString(source, ["email", "mailboxEmail"], 320),
|
|
384
|
+
readable: booleanValue(source.readable) ?? false,
|
|
385
|
+
status: firstString(source, ["status"], 200),
|
|
386
|
+
reason: firstString(source, ["reason", "error"], 500),
|
|
387
|
+
};
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
function safeAddresses(value) {
|
|
391
|
+
return boundedArray(value, MAX_TO_ADDRESSES)
|
|
392
|
+
.map((item) => stringValue(item, 320))
|
|
393
|
+
.filter((item) => item !== null);
|
|
394
|
+
}
|
|
395
|
+
function safeEmailMessage(value, contentLimit) {
|
|
396
|
+
const source = record(value);
|
|
397
|
+
const result = {
|
|
398
|
+
id: firstString(source, ["id", "messageId"], 200),
|
|
399
|
+
threadId: firstString(source, ["threadId", "thread_id"], 200),
|
|
400
|
+
conversationId: firstString(source, ["conversationId", "conversation_id"], 200),
|
|
401
|
+
direction: normalizeDirection(source.direction),
|
|
402
|
+
counterpartyEmail: firstString(source, ["counterpartyEmail", "counterparty_email"], 320),
|
|
403
|
+
from: firstString(source, ["from", "sender", "senderEmail"], 320),
|
|
404
|
+
to: safeAddresses(source.to),
|
|
405
|
+
date: dateFrom(source),
|
|
406
|
+
subject: stringValue(source.subject, contentLimit),
|
|
407
|
+
snippet: stringValue(source.snippet, contentLimit),
|
|
408
|
+
bodyText: stringValue(source.bodyText ?? source.body ?? source.text, contentLimit),
|
|
409
|
+
};
|
|
410
|
+
const mailboxProvider = firstString(source, ["mailboxProvider", "provider"], 100);
|
|
411
|
+
const mailboxEmail = firstString(source, ["mailboxEmail"], 320);
|
|
412
|
+
if (mailboxProvider)
|
|
413
|
+
result.mailboxProvider = mailboxProvider;
|
|
414
|
+
if (mailboxEmail)
|
|
415
|
+
result.mailboxEmail = mailboxEmail;
|
|
416
|
+
const status = firstString(source, ["status", "deliveryStatus"], 100);
|
|
417
|
+
if (status)
|
|
418
|
+
result.status = status;
|
|
419
|
+
const flags = commercialFlags(source);
|
|
420
|
+
if (flags.optOut)
|
|
421
|
+
result.optOut = true;
|
|
422
|
+
if (flags.bounce)
|
|
423
|
+
result.bounced = true;
|
|
424
|
+
if (flags.suppression)
|
|
425
|
+
result.suppressed = true;
|
|
426
|
+
return result;
|
|
427
|
+
}
|
|
428
|
+
function safeSocialMessage(value, contentLimit) {
|
|
429
|
+
const source = record(value);
|
|
430
|
+
const result = {
|
|
431
|
+
id: firstString(source, ["id", "messageId"], 200),
|
|
432
|
+
conversationId: firstString(source, ["conversationId", "conversation_id", "threadId"], 200),
|
|
433
|
+
direction: normalizeDirection(source.direction),
|
|
434
|
+
from: firstString(source, ["from", "sender", "senderName"], 320),
|
|
435
|
+
date: dateFrom(source),
|
|
436
|
+
text: stringValue(source.text ?? source.bodyText ?? source.body ?? source.content, contentLimit),
|
|
437
|
+
};
|
|
438
|
+
const profileUrl = safeUrl(source.profileUrl ?? source.linkedinUrl ?? source.participantLinkedinUrl);
|
|
439
|
+
if (profileUrl)
|
|
440
|
+
result.profileUrl = profileUrl;
|
|
441
|
+
const attachments = boundedArray(source.attachments, MAX_TO_ADDRESSES).map((attachment) => {
|
|
442
|
+
const item = record(attachment);
|
|
443
|
+
return { name: stringValue(item.name, 300), url: safeUrl(item.url) };
|
|
444
|
+
});
|
|
445
|
+
if (attachments.length > 0)
|
|
446
|
+
result.attachments = attachments;
|
|
447
|
+
const flags = commercialFlags(source);
|
|
448
|
+
if (flags.optOut)
|
|
449
|
+
result.optOut = true;
|
|
450
|
+
if (flags.bounce)
|
|
451
|
+
result.bounced = true;
|
|
452
|
+
if (flags.suppression)
|
|
453
|
+
result.suppressed = true;
|
|
454
|
+
return result;
|
|
455
|
+
}
|
|
456
|
+
function dedupeEmailMessages(messages) {
|
|
457
|
+
const seen = new Set();
|
|
458
|
+
return messages.filter((message) => {
|
|
459
|
+
const source = record(message);
|
|
460
|
+
const key = `${dateFrom(source) ?? ""}|${(firstString(source, ["from", "sender", "senderEmail"], 320) ?? "").toLowerCase()}|${(firstString(source, ["subject"], 500) ?? "").toLowerCase()}`;
|
|
461
|
+
if (seen.has(key) && key !== "||")
|
|
462
|
+
return false;
|
|
463
|
+
seen.add(key);
|
|
464
|
+
return true;
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
function latestDirection(messages) {
|
|
468
|
+
const directions = messages
|
|
469
|
+
.map((message) => normalizeDirection(message.direction))
|
|
470
|
+
.filter((direction) => direction !== "unknown");
|
|
471
|
+
if (directions.length === 0)
|
|
472
|
+
return "unknown";
|
|
473
|
+
if (new Set(directions).size > 1)
|
|
474
|
+
return "mixed";
|
|
475
|
+
return directions[0];
|
|
476
|
+
}
|
|
477
|
+
function statusForMessages(messages, flags) {
|
|
478
|
+
if (flags.optOut || flags.bounce || flags.suppression)
|
|
479
|
+
return "excluded";
|
|
480
|
+
if (messages.some((message) => normalizeDirection(message.direction) === "inbound"))
|
|
481
|
+
return "replied";
|
|
482
|
+
if (messages.some((message) => normalizeDirection(message.direction) === "outbound"))
|
|
483
|
+
return "contacted";
|
|
484
|
+
return "no_observed_activity";
|
|
485
|
+
}
|
|
486
|
+
function crmProjection(data, target) {
|
|
487
|
+
requireConfigured(data, target, "crm");
|
|
488
|
+
const contentLimit = contentLimitFor(target);
|
|
489
|
+
const rawDeals = arrayAt(data, ["deals"]);
|
|
490
|
+
const deals = boundedArray(rawDeals, limitFor(target)).map((deal) => safeDeal(deal, contentLimit));
|
|
491
|
+
const domainContacts = boundedArray(data.domainContacts, limitFor(target)).map(safeDomainContact);
|
|
492
|
+
const person = safePerson(data.person);
|
|
493
|
+
const statuses = rawDeals.map((deal) => normalizeStatus(record(deal).status));
|
|
494
|
+
const found = booleanValue(data.found) ?? person !== null;
|
|
495
|
+
const stage = statuses.some((status) => ["won", "closed_won"].includes(status))
|
|
496
|
+
? "client"
|
|
497
|
+
: statuses.some((status) => ["open", "in_progress", "qualified"].includes(status))
|
|
498
|
+
? "prospect"
|
|
499
|
+
: found
|
|
500
|
+
? "in_crm"
|
|
501
|
+
: "not_in_crm";
|
|
502
|
+
const flags = commercialFlags(data);
|
|
503
|
+
const dates = [dateFrom(data), ...rawDeals.map((deal) => dateFrom(record(deal))), dateFrom(record(data.person))];
|
|
504
|
+
const ambiguities = [];
|
|
505
|
+
if (rawDeals.length > deals.length)
|
|
506
|
+
ambiguities.push(`CRM deals truncated to ${limitFor(target)} records`);
|
|
507
|
+
if (booleanValue(data.domainSearched) === true && domainContacts.length >= limitFor(target))
|
|
508
|
+
ambiguities.push("CRM domain contacts may be truncated");
|
|
509
|
+
const projectionValue = makeProjection("crm", target, {
|
|
510
|
+
date: observedDate(dates),
|
|
511
|
+
direction: "system",
|
|
512
|
+
status: flags.optOut || flags.bounce || flags.suppression ? "excluded" : stage,
|
|
513
|
+
...flags,
|
|
514
|
+
ambiguities,
|
|
515
|
+
});
|
|
516
|
+
const payload = {
|
|
517
|
+
source: "crm",
|
|
518
|
+
target: targetDescription(target),
|
|
519
|
+
organizationId: target.organization_id,
|
|
520
|
+
leadGroupId: target.lead_group_id,
|
|
521
|
+
...(target.campaign_id ? { campaignId: target.campaign_id } : {}),
|
|
522
|
+
crmConfigured: true,
|
|
523
|
+
provider: firstString(data, ["provider"], 100),
|
|
524
|
+
found,
|
|
525
|
+
person,
|
|
526
|
+
deals,
|
|
527
|
+
companyDomain: firstString(data, ["domain", "companyDomain"], 300),
|
|
528
|
+
domainSearched: booleanValue(data.domainSearched) ?? false,
|
|
529
|
+
domainContacts,
|
|
530
|
+
pagination: pagination(data, target, deals.length, rawDeals.length),
|
|
531
|
+
stage,
|
|
532
|
+
optOut: projectionValue.optOut,
|
|
533
|
+
bounce: projectionValue.bounce,
|
|
534
|
+
suppression: projectionValue.suppression,
|
|
535
|
+
handoff: projectionValue.handoff,
|
|
536
|
+
freshness: projectionValue.freshness,
|
|
537
|
+
ambiguities: projectionValue.ambiguities,
|
|
538
|
+
projection: projectionValue,
|
|
539
|
+
commercialTruth: projectionValue,
|
|
540
|
+
redactions: { secretsOmitted: true, maxContentChars: contentLimit },
|
|
541
|
+
};
|
|
542
|
+
return { source: "crm", configured: true, projection: projectionValue, pagination: payload.pagination, evidence: { found, dealCount: deals.length, stage }, payload };
|
|
543
|
+
}
|
|
544
|
+
function emailProjection(data, target) {
|
|
545
|
+
requireConfigured(data, target, "email");
|
|
546
|
+
const contentLimit = contentLimitFor(target);
|
|
547
|
+
const rawMessages = arrayAt(data, ["messages"]);
|
|
548
|
+
const uniqueMessages = dedupeEmailMessages(rawMessages);
|
|
549
|
+
const selectedMessages = boundedArray(uniqueMessages, limitFor(target));
|
|
550
|
+
const messages = selectedMessages.map((message) => safeEmailMessage(message, contentLimit));
|
|
551
|
+
const messageRecords = messages.map(record);
|
|
552
|
+
const flags = commercialFlags({ ...data, messages: rawMessages });
|
|
553
|
+
const reportedMessageCount = typeof data.messageCount === "number" ? data.messageCount : rawMessages.length;
|
|
554
|
+
const mailboxes = safeMailboxes(data.mailboxesSearched);
|
|
555
|
+
const problematicMailboxes = mailboxes.filter((mailbox) => mailbox.readable !== true || mailbox.reason !== null);
|
|
556
|
+
const ambiguities = [];
|
|
557
|
+
if (rawMessages.length !== uniqueMessages.length)
|
|
558
|
+
ambiguities.push("Duplicate mailbox copies were collapsed before reconciliation");
|
|
559
|
+
if (Math.max(rawMessages.length, reportedMessageCount) > selectedMessages.length)
|
|
560
|
+
ambiguities.push(`Email messages truncated to ${limitFor(target)} records`);
|
|
561
|
+
if (problematicMailboxes.length > 0)
|
|
562
|
+
ambiguities.push(`${problematicMailboxes.length} mailbox(es) could not be fully read`);
|
|
563
|
+
const status = statusForMessages(messageRecords, flags);
|
|
564
|
+
const projectionValue = makeProjection("email", target, {
|
|
565
|
+
date: observedDate([dateFrom(data), ...messageRecords.map(dateFrom)]),
|
|
566
|
+
direction: latestDirection(messageRecords),
|
|
567
|
+
status,
|
|
568
|
+
...flags,
|
|
569
|
+
ambiguities,
|
|
570
|
+
});
|
|
571
|
+
const payload = {
|
|
572
|
+
source: "email",
|
|
573
|
+
target: targetDescription(target),
|
|
574
|
+
organizationId: target.organization_id,
|
|
575
|
+
leadGroupId: target.lead_group_id,
|
|
576
|
+
...(target.campaign_id ? { campaignId: target.campaign_id } : {}),
|
|
577
|
+
emailConfigured: true,
|
|
578
|
+
counterparty: firstString(data, ["counterparty"], 320),
|
|
579
|
+
companyDomain: firstString(data, ["domain", "companyDomain"], 300),
|
|
580
|
+
domainSearched: booleanValue(data.domainSearched) ?? false,
|
|
581
|
+
mailboxesSearched: mailboxes,
|
|
582
|
+
messageCount: messages.length,
|
|
583
|
+
rawMessageCount: reportedMessageCount,
|
|
584
|
+
messages,
|
|
585
|
+
pagination: pagination(data, target, messages.length, Math.max(rawMessages.length, reportedMessageCount)),
|
|
586
|
+
responded: status === "replied",
|
|
587
|
+
optOut: projectionValue.optOut,
|
|
588
|
+
bounce: projectionValue.bounce,
|
|
589
|
+
suppression: projectionValue.suppression,
|
|
590
|
+
handoff: projectionValue.handoff,
|
|
591
|
+
freshness: projectionValue.freshness,
|
|
592
|
+
ambiguities: projectionValue.ambiguities,
|
|
593
|
+
projection: projectionValue,
|
|
594
|
+
commercialTruth: projectionValue,
|
|
595
|
+
redactions: { secretsOmitted: true, maxContentChars: contentLimit },
|
|
596
|
+
};
|
|
597
|
+
return {
|
|
598
|
+
source: "email",
|
|
599
|
+
configured: true,
|
|
600
|
+
projection: projectionValue,
|
|
601
|
+
pagination: payload.pagination,
|
|
602
|
+
evidence: {
|
|
603
|
+
messageCount: messages.length,
|
|
604
|
+
inboundCount: messageRecords.filter((message) => normalizeDirection(message.direction) === "inbound").length,
|
|
605
|
+
outboundCount: messageRecords.filter((message) => normalizeDirection(message.direction) === "outbound").length,
|
|
606
|
+
responseObserved: status === "replied",
|
|
607
|
+
},
|
|
608
|
+
payload,
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
function socialProjection(data, target, source) {
|
|
612
|
+
requireConfigured(data, target, source);
|
|
613
|
+
const contentLimit = contentLimitFor(target);
|
|
614
|
+
const nestedMessages = nestedArrayAt(data, ["conversation", "thread", "data"], ["messages"]);
|
|
615
|
+
const rawMessages = arrayAt(data, ["messages"]);
|
|
616
|
+
const allMessages = rawMessages.length > 0 ? rawMessages : nestedMessages;
|
|
617
|
+
const selectedMessages = boundedArray(allMessages, limitFor(target));
|
|
618
|
+
const messages = selectedMessages.map((message) => safeSocialMessage(message, contentLimit));
|
|
619
|
+
const messageRecords = messages.map(record);
|
|
620
|
+
const flags = commercialFlags({ ...data, messages: allMessages });
|
|
621
|
+
const reportedMessageCount = typeof data.totalMessageCount === "number" ? data.totalMessageCount : allMessages.length;
|
|
622
|
+
const ambiguities = [];
|
|
623
|
+
if (Math.max(allMessages.length, reportedMessageCount) > selectedMessages.length)
|
|
624
|
+
ambiguities.push(`${source} messages truncated to ${limitFor(target)} records`);
|
|
625
|
+
const status = statusForMessages(messageRecords, flags);
|
|
626
|
+
const configuredKey = source === "linkedin" ? "linkedinConfigured" : "unipileConfigured";
|
|
627
|
+
const conversations = arrayAt(data, ["conversations"]);
|
|
628
|
+
const conversation = record(data.conversation);
|
|
629
|
+
const safeConversations = boundedArray(conversations, limitFor(target)).map((value) => {
|
|
630
|
+
const item = record(value);
|
|
631
|
+
return {
|
|
632
|
+
id: firstString(item, ["id", "conversationId"], 200),
|
|
633
|
+
participantName: firstString(item, ["participantName", "name"], 300),
|
|
634
|
+
participantLinkedinUrl: safeUrl(item.participantLinkedinUrl ?? item.linkedinUrl),
|
|
635
|
+
lastActivity: dateFrom(item),
|
|
636
|
+
messageCount: typeof item.messageCount === "number" ? item.messageCount : null,
|
|
637
|
+
};
|
|
638
|
+
});
|
|
639
|
+
if (Object.keys(conversation).length > 0 && safeConversations.length === 0) {
|
|
640
|
+
safeConversations.push({
|
|
641
|
+
id: firstString(conversation, ["id", "conversationId"], 200),
|
|
642
|
+
participantName: firstString(conversation, ["participantName", "name"], 300),
|
|
643
|
+
participantLinkedinUrl: safeUrl(conversation.participantLinkedinUrl ?? conversation.linkedinUrl),
|
|
644
|
+
lastActivity: dateFrom(conversation),
|
|
645
|
+
messageCount: typeof conversation.messageCount === "number" ? conversation.messageCount : messages.length,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
const projectionValue = makeProjection(source, target, {
|
|
649
|
+
date: observedDate([dateFrom(data), ...messageRecords.map(dateFrom), ...safeConversations.map(dateFrom)]),
|
|
650
|
+
direction: latestDirection(messageRecords),
|
|
651
|
+
status,
|
|
652
|
+
...flags,
|
|
653
|
+
ambiguities,
|
|
654
|
+
});
|
|
655
|
+
const payload = {
|
|
656
|
+
source,
|
|
657
|
+
target: targetDescription(target),
|
|
658
|
+
organizationId: target.organization_id,
|
|
659
|
+
leadGroupId: target.lead_group_id,
|
|
660
|
+
...(target.campaign_id ? { campaignId: target.campaign_id } : {}),
|
|
661
|
+
[configuredKey]: true,
|
|
662
|
+
counterparty: firstString(data, ["counterparty"], 320),
|
|
663
|
+
conversations: safeConversations,
|
|
664
|
+
messages,
|
|
665
|
+
totalMessageCount: reportedMessageCount,
|
|
666
|
+
pagination: pagination(data, target, messages.length, Math.max(allMessages.length, reportedMessageCount)),
|
|
667
|
+
responded: status === "replied",
|
|
668
|
+
optOut: projectionValue.optOut,
|
|
669
|
+
bounce: projectionValue.bounce,
|
|
670
|
+
suppression: projectionValue.suppression,
|
|
671
|
+
handoff: projectionValue.handoff,
|
|
672
|
+
freshness: projectionValue.freshness,
|
|
673
|
+
ambiguities: projectionValue.ambiguities,
|
|
674
|
+
projection: projectionValue,
|
|
675
|
+
commercialTruth: projectionValue,
|
|
676
|
+
redactions: { secretsOmitted: true, maxContentChars: contentLimit },
|
|
677
|
+
};
|
|
678
|
+
return {
|
|
679
|
+
source,
|
|
680
|
+
configured: true,
|
|
681
|
+
projection: projectionValue,
|
|
682
|
+
pagination: payload.pagination,
|
|
683
|
+
evidence: {
|
|
684
|
+
messageCount: messages.length,
|
|
685
|
+
inboundCount: messageRecords.filter((message) => normalizeDirection(message.direction) === "inbound").length,
|
|
686
|
+
outboundCount: messageRecords.filter((message) => normalizeDirection(message.direction) === "outbound").length,
|
|
687
|
+
responseObserved: status === "replied",
|
|
688
|
+
},
|
|
689
|
+
payload,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
function activityProjection(data, target) {
|
|
693
|
+
requireConfigured(data, target, "leadify_activity");
|
|
694
|
+
const contentLimit = contentLimitFor(target);
|
|
695
|
+
const rawEntries = arrayAt(data, ["entries", "activities", "items", "data"]);
|
|
696
|
+
const selectedEntries = boundedArray(rawEntries, limitFor(target));
|
|
697
|
+
const entries = selectedEntries.map((entry) => {
|
|
698
|
+
const item = record(entry);
|
|
699
|
+
return {
|
|
700
|
+
id: firstString(item, ["id"], 200),
|
|
701
|
+
leadId: firstString(item, ["leadId", "lead_id"], 200),
|
|
702
|
+
action: firstString(item, ["action"], 200),
|
|
703
|
+
target: firstString(item, ["target"], 200),
|
|
704
|
+
before: safeValue(item.before, contentLimit),
|
|
705
|
+
after: safeValue(item.after, contentLimit),
|
|
706
|
+
reason: stringValue(item.reason, contentLimit),
|
|
707
|
+
date: dateFrom(item),
|
|
708
|
+
createdAt: firstString(item, ["createdAt"], 100),
|
|
709
|
+
};
|
|
710
|
+
});
|
|
711
|
+
const ambiguities = ["Activity feed is an audit trail; it never proves that a person replied."];
|
|
712
|
+
if (rawEntries.length > selectedEntries.length)
|
|
713
|
+
ambiguities.push(`Activity entries truncated to ${limitFor(target)} records`);
|
|
714
|
+
const projectionValue = makeProjection("leadify_activity", target, {
|
|
715
|
+
date: observedDate([dateFrom(data), ...entries.map((entry) => entry.date)]),
|
|
716
|
+
direction: "system",
|
|
717
|
+
status: "audit_only",
|
|
718
|
+
// Activity rows are a mutation audit trail and cannot become commercial truth.
|
|
719
|
+
optOut: false,
|
|
720
|
+
bounce: false,
|
|
721
|
+
suppression: false,
|
|
722
|
+
handoff: false,
|
|
723
|
+
ambiguities,
|
|
724
|
+
});
|
|
725
|
+
const payload = {
|
|
726
|
+
source: "leadify_activity",
|
|
727
|
+
target: targetDescription(target),
|
|
728
|
+
organizationId: target.organization_id,
|
|
729
|
+
leadGroupId: target.lead_group_id,
|
|
730
|
+
...(target.campaign_id ? { campaignId: target.campaign_id } : {}),
|
|
731
|
+
entries,
|
|
732
|
+
pagination: pagination(data, target, entries.length, rawEntries.length),
|
|
733
|
+
optOut: false,
|
|
734
|
+
bounce: false,
|
|
735
|
+
suppression: false,
|
|
736
|
+
handoff: false,
|
|
737
|
+
freshness: projectionValue.freshness,
|
|
738
|
+
ambiguities: projectionValue.ambiguities,
|
|
739
|
+
projection: projectionValue,
|
|
740
|
+
commercialTruth: projectionValue,
|
|
741
|
+
redactions: { secretsOmitted: true, maxContentChars: contentLimit, runIdentityOmitted: true },
|
|
742
|
+
};
|
|
743
|
+
return {
|
|
744
|
+
source: "leadify_activity",
|
|
745
|
+
configured: true,
|
|
746
|
+
projection: projectionValue,
|
|
747
|
+
pagination: payload.pagination,
|
|
748
|
+
evidence: { entryCount: entries.length, responseObserved: false, auditOnly: true },
|
|
749
|
+
payload,
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
function emailQuery(target) {
|
|
753
|
+
const query = scopedQuery(target);
|
|
754
|
+
query.set("email", target.email);
|
|
755
|
+
return query;
|
|
756
|
+
}
|
|
757
|
+
function linkedinQuery(target) {
|
|
758
|
+
const query = scopedQuery(target);
|
|
759
|
+
query.set("linkedinUrl", target.linkedin_url);
|
|
760
|
+
return query;
|
|
761
|
+
}
|
|
762
|
+
function activityQuery(target) {
|
|
763
|
+
const query = scopedQuery(target);
|
|
764
|
+
query.set("leadId", target.lead_id);
|
|
765
|
+
if (target.target)
|
|
766
|
+
query.set("target", target.target);
|
|
767
|
+
return query;
|
|
768
|
+
}
|
|
769
|
+
async function readCrm(client, target) {
|
|
770
|
+
const data = await client.get("/api/crm/lookup-person", emailQuery(target));
|
|
771
|
+
return crmProjection(assertScopedResponse(data, target, "crm"), target);
|
|
772
|
+
}
|
|
773
|
+
async function readEmail(client, target) {
|
|
774
|
+
const data = await client.get("/api/email/read-thread", emailQuery(target));
|
|
775
|
+
return emailProjection(assertScopedResponse(data, target, "email"), target);
|
|
776
|
+
}
|
|
777
|
+
async function readLinkedin(client, target) {
|
|
778
|
+
const data = await client.get("/api/linkedin/conversation", linkedinQuery(target));
|
|
779
|
+
return socialProjection(assertScopedResponse(data, target, "linkedin"), target, "linkedin");
|
|
780
|
+
}
|
|
781
|
+
async function readUnipile(client, target) {
|
|
782
|
+
const data = await client.get("/api/unipile/read-messages", linkedinQuery(target));
|
|
783
|
+
return socialProjection(assertScopedResponse(data, target, "unipile"), target, "unipile");
|
|
784
|
+
}
|
|
785
|
+
async function readActivity(client, target) {
|
|
786
|
+
const data = await client.get("/activity-feed", activityQuery(target));
|
|
787
|
+
return activityProjection(assertScopedResponse(data, target, "leadify_activity"), target);
|
|
788
|
+
}
|
|
789
|
+
function readTargetFromInput(input) {
|
|
790
|
+
return {
|
|
791
|
+
...input,
|
|
792
|
+
organization_id: input.organization_id.trim(),
|
|
793
|
+
lead_group_id: input.lead_group_id.trim(),
|
|
794
|
+
email: input.email?.trim().toLowerCase(),
|
|
795
|
+
linkedin_url: input.linkedin_url?.trim(),
|
|
796
|
+
lead_id: input.lead_id?.trim(),
|
|
797
|
+
page: input.page,
|
|
798
|
+
campaign_id: input.campaign_id?.trim(),
|
|
799
|
+
cursor: input.cursor?.trim(),
|
|
800
|
+
target: input.target?.trim(),
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
function targetDescription(target) {
|
|
804
|
+
return {
|
|
805
|
+
organizationId: target.organization_id,
|
|
806
|
+
leadGroupId: target.lead_group_id,
|
|
807
|
+
...(target.campaign_id ? { campaignId: target.campaign_id } : {}),
|
|
808
|
+
...(target.email ? { email: target.email } : {}),
|
|
809
|
+
...(target.linkedin_url ? { linkedinUrl: safeUrl(target.linkedin_url) ?? "[invalid-url]" } : {}),
|
|
810
|
+
...(target.lead_id ? { leadId: target.lead_id } : {}),
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
export function reconcileCommercialSources(sources) {
|
|
814
|
+
const crm = sources.find((source) => source.source === "crm");
|
|
815
|
+
const conversationSources = sources.filter((source) => ["email", "linkedin", "unipile"].includes(source.source));
|
|
816
|
+
const responded = conversationSources.some((source) => source.projection.status === "replied" || source.evidence.responseObserved === true);
|
|
817
|
+
const optOut = sources.some((source) => source.projection.optOut);
|
|
818
|
+
const bounce = sources.some((source) => source.projection.bounce);
|
|
819
|
+
const suppression = sources.some((source) => source.projection.suppression);
|
|
820
|
+
const handoff = sources.some((source) => source.projection.handoff);
|
|
821
|
+
const crmStage = crm?.projection.status === "client"
|
|
822
|
+
? "client"
|
|
823
|
+
: crm?.projection.status === "prospect"
|
|
824
|
+
? "prospect"
|
|
825
|
+
: crm?.projection.status === "in_crm"
|
|
826
|
+
? "none"
|
|
827
|
+
: "unknown";
|
|
828
|
+
const inCrm = crm?.projection.status === "client" || crm?.projection.status === "prospect" || crm?.projection.status === "in_crm";
|
|
829
|
+
const ambiguities = sources.flatMap((source) => source.projection.ambiguities);
|
|
830
|
+
if (crm?.projection.status === "not_in_crm" && conversationSources.some((source) => source.evidence.responseObserved === true || Number(source.evidence.messageCount ?? 0) > 0)) {
|
|
831
|
+
ambiguities.push("CRM found no matching person and a conversation source has observed messages");
|
|
832
|
+
}
|
|
833
|
+
const email = sources.find((source) => source.source === "email");
|
|
834
|
+
const unipile = sources.find((source) => source.source === "unipile");
|
|
835
|
+
if (email && unipile && Number(email.evidence.messageCount ?? 0) === 0 && Number(unipile.evidence.inboundCount ?? 0) > 0) {
|
|
836
|
+
ambiguities.push("Email has no observed messages and Unipile has an inbound message");
|
|
837
|
+
}
|
|
838
|
+
if (sources.some((source) => source.projection.freshness.state === "stale")) {
|
|
839
|
+
ambiguities.push("At least one commercial source is older than the requested freshness window");
|
|
840
|
+
}
|
|
841
|
+
const unique = uniqueAmbiguities(ambiguities);
|
|
842
|
+
let status;
|
|
843
|
+
let recommendedAction;
|
|
844
|
+
if (optOut || bounce || suppression) {
|
|
845
|
+
status = "excluded";
|
|
846
|
+
recommendedAction = "Do not contact, send, or create a follow-up activity; preserve the observed exclusion evidence.";
|
|
847
|
+
}
|
|
848
|
+
else if (unique.some((ambiguity) => /CRM found no|Email has no observed/i.test(ambiguity))) {
|
|
849
|
+
status = "review_required";
|
|
850
|
+
recommendedAction = "Pause outreach and review the divergent source evidence ahead of any qualification or CRM handoff.";
|
|
851
|
+
}
|
|
852
|
+
else if (crmStage === "client") {
|
|
853
|
+
status = "client";
|
|
854
|
+
recommendedAction = "Do not cold-prospect this company; review the existing client relationship instead.";
|
|
855
|
+
}
|
|
856
|
+
else if (handoff) {
|
|
857
|
+
status = "handoff_required";
|
|
858
|
+
recommendedAction = "Review the existing CRM handoff evidence; do not create a duplicate handoff or activity.";
|
|
859
|
+
}
|
|
860
|
+
else if (responded) {
|
|
861
|
+
status = "replied";
|
|
862
|
+
recommendedAction = "Stop cold outreach and review the observed reply before deciding on a human follow-up.";
|
|
863
|
+
}
|
|
864
|
+
else if (crmStage === "prospect") {
|
|
865
|
+
status = "prospect";
|
|
866
|
+
recommendedAction = "Treat as warm only; the CRM has an open sales process.";
|
|
867
|
+
}
|
|
868
|
+
else if (inCrm) {
|
|
869
|
+
status = "in_crm";
|
|
870
|
+
recommendedAction = "Use the existing CRM record and avoid creating a duplicate person or handoff.";
|
|
871
|
+
}
|
|
872
|
+
else if (conversationSources.some((source) => source.projection.status === "contacted")) {
|
|
873
|
+
status = "contacted";
|
|
874
|
+
recommendedAction = "Do not duplicate the observed outbound contact; inspect the source ahead of another touch.";
|
|
875
|
+
}
|
|
876
|
+
else if (sources.length === 0 || !sources.some((source) => !["no_observed_activity", "not_in_crm", "audit_only"].includes(source.projection.status))) {
|
|
877
|
+
status = "no_observed_activity";
|
|
878
|
+
recommendedAction = "Leave without action when no configured source provides new evidence.";
|
|
879
|
+
}
|
|
880
|
+
else {
|
|
881
|
+
status = "no_action";
|
|
882
|
+
recommendedAction = "No action is justified by the observed commercial sources.";
|
|
883
|
+
}
|
|
884
|
+
const dates = sources.map((source) => source.projection.date).filter((date) => date !== null);
|
|
885
|
+
const freshnessDate = observedDate(dates);
|
|
886
|
+
const freshness = freshnessDate
|
|
887
|
+
? {
|
|
888
|
+
observedAt: freshnessDate,
|
|
889
|
+
state: sources.some((source) => source.projection.freshness.state === "stale") ? "stale" : "fresh",
|
|
890
|
+
ageMs: Math.max(...sources.map((source) => source.projection.freshness.ageMs ?? 0)),
|
|
891
|
+
}
|
|
892
|
+
: { observedAt: null, state: "unknown" };
|
|
893
|
+
return {
|
|
894
|
+
status,
|
|
895
|
+
recommendedAction,
|
|
896
|
+
responded,
|
|
897
|
+
inCrm,
|
|
898
|
+
crmStage,
|
|
899
|
+
optOut,
|
|
900
|
+
bounce,
|
|
901
|
+
suppression,
|
|
902
|
+
handoff,
|
|
903
|
+
freshness,
|
|
904
|
+
ambiguities: unique,
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
function integrationAction(source) {
|
|
908
|
+
if (source === "crm")
|
|
909
|
+
return "Connect a CRM integration in this organization, then retry the read.";
|
|
910
|
+
if (source === "email")
|
|
911
|
+
return "Connect a readable Gmail or Outlook mailbox in this organization, then retry the read.";
|
|
912
|
+
if (source === "unipile")
|
|
913
|
+
return "Connect an Unipile account in this organization, then retry the read.";
|
|
914
|
+
if (source === "linkedin")
|
|
915
|
+
return "Configure the LinkedIn conversation integration in this organization, then retry the read.";
|
|
916
|
+
return "Enable the Leadify activity feed in this organization, then retry the read.";
|
|
917
|
+
}
|
|
918
|
+
function missingIntegrationResult(error) {
|
|
919
|
+
const action = integrationAction(error.source);
|
|
920
|
+
return toolError(`${error.message}. ${action}`, {
|
|
921
|
+
code: "INTEGRATION_NOT_CONFIGURED",
|
|
922
|
+
source: error.source,
|
|
923
|
+
organizationId: error.organizationId,
|
|
924
|
+
readOnly: true,
|
|
925
|
+
action,
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
function safeErrorMessage(error) {
|
|
929
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
930
|
+
return message.replace(/Bearer\s+[^\s]+/gi, "Bearer [redacted]").slice(0, 500);
|
|
931
|
+
}
|
|
932
|
+
function handleCommercialError(error) {
|
|
933
|
+
const result = handleToolError(error);
|
|
934
|
+
try {
|
|
935
|
+
const body = JSON.parse(result.content[0].text);
|
|
936
|
+
if (body.error !== undefined)
|
|
937
|
+
body.error = safeErrorMessage(body.error);
|
|
938
|
+
const details = record(body.details);
|
|
939
|
+
if (Object.hasOwn(details, "response"))
|
|
940
|
+
details.response = safeValue(details.response, DEFAULT_CONTENT_LIMIT);
|
|
941
|
+
if (Object.keys(details).length > 0)
|
|
942
|
+
body.details = details;
|
|
943
|
+
return {
|
|
944
|
+
...result,
|
|
945
|
+
content: [{ type: "text", text: JSON.stringify(body, null, 2) }],
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
catch {
|
|
949
|
+
return result;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
async function readSelectedSources(client, target) {
|
|
953
|
+
const requests = [
|
|
954
|
+
{ source: "crm", enabled: Boolean(target.email), read: () => readCrm(client, target) },
|
|
955
|
+
{ source: "email", enabled: Boolean(target.email), read: () => readEmail(client, target) },
|
|
956
|
+
{ source: "linkedin", enabled: Boolean(target.linkedin_url), read: () => readLinkedin(client, target) },
|
|
957
|
+
{ source: "unipile", enabled: Boolean(target.linkedin_url), read: () => readUnipile(client, target) },
|
|
958
|
+
{ source: "leadify_activity", enabled: Boolean(target.lead_id), read: () => readActivity(client, target) },
|
|
959
|
+
];
|
|
960
|
+
const snapshots = [];
|
|
961
|
+
const errors = [];
|
|
962
|
+
const skipped = [];
|
|
963
|
+
const processRequest = async (request) => {
|
|
964
|
+
if (!request.enabled) {
|
|
965
|
+
skipped.push(request.source);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
try {
|
|
969
|
+
snapshots.push(await request.read());
|
|
970
|
+
}
|
|
971
|
+
catch (error) {
|
|
972
|
+
if (error instanceof IntegrationNotConfiguredError) {
|
|
973
|
+
errors.push({ source: request.source, code: "INTEGRATION_NOT_CONFIGURED", message: safeErrorMessage(error), action: integrationAction(request.source) });
|
|
974
|
+
}
|
|
975
|
+
else {
|
|
976
|
+
errors.push({ source: request.source, code: "READ_FAILED", message: safeErrorMessage(error) });
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
};
|
|
980
|
+
await requests.reduce((chain, request) => chain.then(() => processRequest(request)), Promise.resolve());
|
|
981
|
+
return { snapshots, errors, skipped };
|
|
982
|
+
}
|
|
983
|
+
function aggregatePayload(target, result) {
|
|
984
|
+
const baseReconciliation = reconcileCommercialSources(result.snapshots);
|
|
985
|
+
const reconciliation = result.errors.length === 0 || baseReconciliation.status === "excluded"
|
|
986
|
+
? baseReconciliation
|
|
987
|
+
: {
|
|
988
|
+
...baseReconciliation,
|
|
989
|
+
status: "review_required",
|
|
990
|
+
recommendedAction: "Pause action and configure or retry every source reported in errors before relying on this read.",
|
|
991
|
+
ambiguities: uniqueAmbiguities([
|
|
992
|
+
...baseReconciliation.ambiguities,
|
|
993
|
+
...result.errors.map((error) => `${String(error.source)} could not be read`),
|
|
994
|
+
]),
|
|
995
|
+
};
|
|
996
|
+
const sourcePayloads = result.snapshots.map((snapshot) => ({
|
|
997
|
+
source: snapshot.source,
|
|
998
|
+
configured: snapshot.configured,
|
|
999
|
+
projection: snapshot.projection,
|
|
1000
|
+
evidence: snapshot.evidence,
|
|
1001
|
+
pagination: snapshot.pagination,
|
|
1002
|
+
data: snapshot.payload,
|
|
1003
|
+
}));
|
|
1004
|
+
const directionSet = new Set(result.snapshots.map((snapshot) => snapshot.projection.direction));
|
|
1005
|
+
const projectionValue = {
|
|
1006
|
+
source: "reconciled",
|
|
1007
|
+
date: reconciliation.freshness.observedAt,
|
|
1008
|
+
direction: result.snapshots.length === 0 ? "unknown" : directionSet.size === 1 ? result.snapshots[0].projection.direction : "mixed",
|
|
1009
|
+
status: reconciliation.status,
|
|
1010
|
+
optOut: reconciliation.optOut,
|
|
1011
|
+
bounce: reconciliation.bounce,
|
|
1012
|
+
suppression: reconciliation.suppression,
|
|
1013
|
+
handoff: reconciliation.handoff,
|
|
1014
|
+
freshness: reconciliation.freshness,
|
|
1015
|
+
ambiguities: reconciliation.ambiguities,
|
|
1016
|
+
};
|
|
1017
|
+
return {
|
|
1018
|
+
target: targetDescription(target),
|
|
1019
|
+
readOnly: true,
|
|
1020
|
+
noWrites: true,
|
|
1021
|
+
sources: sourcePayloads,
|
|
1022
|
+
skippedSources: result.skipped,
|
|
1023
|
+
errors: result.errors,
|
|
1024
|
+
projection: projectionValue,
|
|
1025
|
+
commercialTruth: projectionValue,
|
|
1026
|
+
reconciliation,
|
|
1027
|
+
recommendedAction: reconciliation.recommendedAction,
|
|
1028
|
+
sourceCount: sourcePayloads.length,
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
function readSchema() {
|
|
1032
|
+
return {
|
|
1033
|
+
organization_id: z.string().trim().min(1).max(200).describe("Organization selected explicitly from list_organizations or discover_leadify_context."),
|
|
1034
|
+
lead_group_id: z.string().trim().min(1).max(200).describe("Lead group selected explicitly inside that organization; no implicit group is allowed."),
|
|
1035
|
+
campaign_id: z.string().trim().min(1).max(200).optional().describe("Optional campaign selected explicitly to narrow the read."),
|
|
1036
|
+
page: z.number().int().positive().max(10_000).optional().describe("One-based page number when the source uses page pagination."),
|
|
1037
|
+
limit: z.number().int().min(1).max(MAX_LIMIT).optional().default(DEFAULT_LIMIT).describe(`Maximum records returned; hard-bounded to ${MAX_LIMIT}.`),
|
|
1038
|
+
cursor: z.string().trim().min(1).max(500).optional().describe("Opaque cursor returned by the previous page."),
|
|
1039
|
+
max_content_chars: z.number().int().min(100).max(MAX_CONTENT_LIMIT).optional().default(DEFAULT_CONTENT_LIMIT).describe(`Maximum characters kept in each content field; hard-bounded to ${MAX_CONTENT_LIMIT}.`),
|
|
1040
|
+
freshness_max_age_ms: z.number().int().positive().max(MAX_FRESHNESS_MAX_AGE_MS).optional().default(DEFAULT_FRESHNESS_MAX_AGE_MS).describe("Maximum source age used by the common freshness projection; at most 90 days."),
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
const commonDescription = "The organization, group, person and optional campaign are explicit. This read-only result is tenant-checked, paginated and redacted; it never sends a message or writes an activity.";
|
|
1044
|
+
function registerReadOnlyTool(server, name, description, schema, handler) {
|
|
1045
|
+
const candidate = server;
|
|
1046
|
+
if (typeof candidate.registerTool === "function") {
|
|
1047
|
+
candidate.registerTool(name, {
|
|
1048
|
+
title: name,
|
|
1049
|
+
description,
|
|
1050
|
+
inputSchema: schema,
|
|
1051
|
+
annotations: { readOnlyHint: true },
|
|
1052
|
+
}, handler);
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
server.tool(name, description, schema, handler);
|
|
1056
|
+
}
|
|
1057
|
+
export function registerCommercialTruthTools(server, client = getClient()) {
|
|
1058
|
+
registerReadOnlyTool(server, "crm_lookup_person", `Read normalized CRM person and sales-stage evidence for one explicit person. ${commonDescription} A won deal is exposed as client, an open deal as prospect, and a missing person is distinct from a missing company deal. If no CRM is configured, the response explains the configuration action.`, {
|
|
1059
|
+
...readSchema(),
|
|
1060
|
+
email: z.string().trim().email().max(320).describe("Exact email of the person to look up."),
|
|
1061
|
+
}, async (params) => {
|
|
1062
|
+
try {
|
|
1063
|
+
const target = readTargetFromInput(params);
|
|
1064
|
+
return toolResult((await readCrm(client, target)).payload);
|
|
1065
|
+
}
|
|
1066
|
+
catch (error) {
|
|
1067
|
+
return error instanceof IntegrationNotConfiguredError ? missingIntegrationResult(error) : handleCommercialError(error);
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
registerReadOnlyTool(server, "email_read_thread", `Read the normalized email thread for one explicit person. ${commonDescription} Inbound messages are observed replies, delivery exclusions are preserved, and unreadable mailboxes are reported as ambiguity rather than a false empty thread.`, {
|
|
1071
|
+
...readSchema(),
|
|
1072
|
+
email: z.string().trim().email().max(320).describe("Exact email whose thread must be read."),
|
|
1073
|
+
}, async (params) => {
|
|
1074
|
+
try {
|
|
1075
|
+
const target = readTargetFromInput(params);
|
|
1076
|
+
return toolResult((await readEmail(client, target)).payload);
|
|
1077
|
+
}
|
|
1078
|
+
catch (error) {
|
|
1079
|
+
return error instanceof IntegrationNotConfiguredError ? missingIntegrationResult(error) : handleCommercialError(error);
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
registerReadOnlyTool(server, "linkedin_read_conversation", `Read the normalized LinkedIn conversation of one explicit profile. ${commonDescription} Only bounded message content and conversation metadata are returned; enrichment, send and activity endpoints are never called.`, {
|
|
1083
|
+
...readSchema(),
|
|
1084
|
+
linkedin_url: z.string().trim().url().max(500).describe("Explicit LinkedIn profile URL whose conversation must be read."),
|
|
1085
|
+
}, async (params) => {
|
|
1086
|
+
try {
|
|
1087
|
+
const target = readTargetFromInput(params);
|
|
1088
|
+
return toolResult((await readLinkedin(client, target)).payload);
|
|
1089
|
+
}
|
|
1090
|
+
catch (error) {
|
|
1091
|
+
return error instanceof IntegrationNotConfiguredError ? missingIntegrationResult(error) : handleCommercialError(error);
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
registerReadOnlyTool(server, "unipile_read_messages", `Read normalized Unipile LinkedIn messages of one explicit profile. ${commonDescription} Attachment URLs are reduced to a safe path and message text is bounded.`, {
|
|
1095
|
+
...readSchema(),
|
|
1096
|
+
linkedin_url: z.string().trim().url().max(500).describe("Explicit LinkedIn profile URL whose Unipile messages must be read."),
|
|
1097
|
+
}, async (params) => {
|
|
1098
|
+
try {
|
|
1099
|
+
const target = readTargetFromInput(params);
|
|
1100
|
+
return toolResult((await readUnipile(client, target)).payload);
|
|
1101
|
+
}
|
|
1102
|
+
catch (error) {
|
|
1103
|
+
return error instanceof IntegrationNotConfiguredError ? missingIntegrationResult(error) : handleCommercialError(error);
|
|
1104
|
+
}
|
|
1105
|
+
});
|
|
1106
|
+
registerReadOnlyTool(server, "leadify_read_activity_feed", `Read the bounded Leadify activity feed of one explicit lead. ${commonDescription} This is an audit source only: agent logs and synthetic entries never prove that a person replied.`, {
|
|
1107
|
+
...readSchema(),
|
|
1108
|
+
lead_id: z.string().trim().min(1).max(200).describe("Explicit Leadify lead whose activity feed must be read."),
|
|
1109
|
+
target: z.string().trim().min(1).max(200).optional().describe("Optional exact activity target filter, such as a signal ID."),
|
|
1110
|
+
}, async (params) => {
|
|
1111
|
+
try {
|
|
1112
|
+
const target = readTargetFromInput(params);
|
|
1113
|
+
return toolResult((await readActivity(client, target)).payload);
|
|
1114
|
+
}
|
|
1115
|
+
catch (error) {
|
|
1116
|
+
return error instanceof IntegrationNotConfiguredError ? missingIntegrationResult(error) : handleCommercialError(error);
|
|
1117
|
+
}
|
|
1118
|
+
});
|
|
1119
|
+
registerReadOnlyTool(server, "get_commercial_truth", `Reconcile observed CRM, email, LinkedIn, Unipile and Leadify activity evidence of one explicit target. ${commonDescription} Provide an email, a LinkedIn URL and/or a lead ID to select person-level sources; omitted sources are listed as skipped. Exclusions win, divergent CRM/conversation evidence becomes review_required, and activity feed rows never prove a reply.`, {
|
|
1120
|
+
...readSchema(),
|
|
1121
|
+
email: z.string().trim().email().max(320).optional().describe("Exact person email; enables CRM and email reads."),
|
|
1122
|
+
linkedin_url: z.string().trim().url().max(500).optional().describe("Explicit LinkedIn profile URL; enables LinkedIn and Unipile reads."),
|
|
1123
|
+
lead_id: z.string().trim().min(1).max(200).optional().describe("Explicit Leadify lead ID; enables the bounded activity read."),
|
|
1124
|
+
}, async (params) => {
|
|
1125
|
+
try {
|
|
1126
|
+
const target = readTargetFromInput(params);
|
|
1127
|
+
if (!target.email && !target.linkedin_url && !target.lead_id) {
|
|
1128
|
+
return toolError("Provide at least one explicit person selector: email, linkedin_url, or lead_id. The MCP never reads a whole group or organization implicitly.", {
|
|
1129
|
+
code: "EXPLICIT_PERSON_SELECTOR_REQUIRED",
|
|
1130
|
+
organizationId: target.organization_id,
|
|
1131
|
+
leadGroupId: target.lead_group_id,
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
const result = await readSelectedSources(client, target);
|
|
1135
|
+
return toolResult(aggregatePayload(target, result));
|
|
1136
|
+
}
|
|
1137
|
+
catch (error) {
|
|
1138
|
+
return handleCommercialError(error);
|
|
1139
|
+
}
|
|
1140
|
+
});
|
|
1141
|
+
}
|