@oxygen-agent/cli 1.1003.12 → 1.1010.644
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 +1 -1
- package/dist/column-decision-options.d.ts +20 -0
- package/dist/column-decision-options.js +54 -0
- package/dist/command-manifest.js +15 -2
- package/dist/functions-commands.js +11 -11
- package/dist/index.js +1222 -159
- package/dist/search-ai-filter-notice.d.ts +17 -0
- package/dist/search-ai-filter-notice.js +38 -0
- package/dist/skills.js +34 -10
- package/dist/util.d.ts +9 -0
- package/dist/util.js +14 -0
- package/node_modules/@oxygen/cli-ugc/dist/commands.js +296 -140
- package/node_modules/@oxygen/cli-ugc/dist/field-parser.d.ts +9 -0
- package/node_modules/@oxygen/cli-ugc/dist/field-parser.js +34 -0
- package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +13 -0
- package/node_modules/@oxygen/shared/dist/billing.d.ts +21 -0
- package/node_modules/@oxygen/shared/dist/billing.js +45 -0
- package/node_modules/@oxygen/shared/dist/byok-connect.js +5 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +223 -13
- package/node_modules/@oxygen/shared/dist/cli-http-error.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/cli-http-error.js +8 -0
- package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
- package/node_modules/@oxygen/shared/dist/column-autofill.js +5 -23
- package/node_modules/@oxygen/shared/dist/column-decision.d.ts +50 -0
- package/node_modules/@oxygen/shared/dist/column-decision.js +228 -0
- package/node_modules/@oxygen/shared/dist/company-enrichment-fields.d.ts +9 -4
- package/node_modules/@oxygen/shared/dist/company-enrichment-fields.js +11 -8
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +4 -4
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +4 -4
- package/node_modules/@oxygen/shared/dist/cutover-freeze.d.ts +26 -0
- package/node_modules/@oxygen/shared/dist/cutover-freeze.js +52 -0
- package/node_modules/@oxygen/shared/dist/data-suppliers.d.ts +57 -0
- package/node_modules/@oxygen/shared/dist/data-suppliers.js +59 -0
- package/node_modules/@oxygen/shared/dist/enrichment-intents.d.ts +6 -2
- package/node_modules/@oxygen/shared/dist/enrichment-intents.js +13 -23
- package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +60 -4
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +125 -10
- package/node_modules/@oxygen/shared/dist/index.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/index.js +2 -0
- package/node_modules/@oxygen/shared/dist/langfuse.d.ts +44 -1
- package/node_modules/@oxygen/shared/dist/langfuse.js +407 -14
- package/node_modules/@oxygen/shared/dist/linkedin-countries.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/linkedin-countries.js +2 -0
- package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.js +276 -0
- package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.js +5 -0
- package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.d.ts +44 -0
- package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.js +116 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +96 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +123 -0
- package/node_modules/@oxygen/shared/dist/llm-durable-capture.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/llm-durable-capture.js +89 -0
- package/node_modules/@oxygen/shared/dist/llm-prompts.d.ts +75 -0
- package/node_modules/@oxygen/shared/dist/llm-prompts.js +161 -0
- package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.d.ts +90 -0
- package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.js +130 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.js +73 -0
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +29 -4
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +189 -36
- package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +21 -2
- package/node_modules/@oxygen/shared/dist/product-analytics-events.js +21 -1
- package/node_modules/@oxygen/shared/dist/redaction.js +4 -1
- package/node_modules/@oxygen/shared/dist/scraper-lane-credential.d.ts +18 -0
- package/node_modules/@oxygen/shared/dist/scraper-lane-credential.js +23 -0
- package/node_modules/@oxygen/shared/dist/sequences.js +5 -1
- package/node_modules/@oxygen/shared/dist/signup-lead-payload.d.ts +80 -0
- package/node_modules/@oxygen/shared/dist/signup-lead-payload.js +198 -0
- package/node_modules/@oxygen/shared/dist/social-capabilities.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/social-capabilities.js +25 -16
- package/node_modules/@oxygen/shared/dist/social-post-metrics-core.d.ts +32 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-core.js +32 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.d.ts +31 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.js +103 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-series.d.ts +96 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-series.js +213 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-x.d.ts +13 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-x.js +78 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics.d.ts +36 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics.js +51 -0
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +36 -0
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +184 -0
- package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.d.ts +41 -0
- package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.js +44 -0
- package/node_modules/@oxygen/shared/dist/table-limits.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/table-limits.js +3 -0
- package/node_modules/@oxygen/shared/dist/telemetry-export-observer.d.ts +94 -0
- package/node_modules/@oxygen/shared/dist/telemetry-export-observer.js +298 -0
- package/node_modules/@oxygen/shared/dist/telemetry.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/telemetry.js +19 -1
- package/node_modules/@oxygen/shared/dist/ugc.d.ts +22 -11
- package/node_modules/@oxygen/shared/dist/ugc.js +10 -0
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/version.generated.js +2 -0
- package/node_modules/@oxygen/shared/dist/version.js +8 -1
- package/node_modules/@oxygen/shared/dist/workspace-event-catalog.js +0 -23
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +29 -0
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +31 -0
- package/node_modules/@oxygen/shared/package.json +9 -0
- package/package.json +2 -1
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Primary IANA time zone for each LinkedIn country code.
|
|
3
|
+
*
|
|
4
|
+
* WHY: a LinkedIn sender's working hours and daily-quota rollover run in the
|
|
5
|
+
* account owner's local time. The only geo signal OXYGEN holds for a new
|
|
6
|
+
* account is the country the owner chose at connect ("where the account owner
|
|
7
|
+
* normally uses LinkedIn"), so that country picks the zone whenever no explicit
|
|
8
|
+
* timezone was stored. Without it every account ran on America/New_York and a
|
|
9
|
+
* German account was active 13:00–04:00 Berlin time.
|
|
10
|
+
*
|
|
11
|
+
* GENERATED once from ICU's region → zone data (`new Intl.Locale("und-XX")
|
|
12
|
+
* .timeZones`) for the codes in LINKEDIN_COUNTRIES, then frozen here so a Node
|
|
13
|
+
* upgrade cannot silently move an account's window. Countries spanning several
|
|
14
|
+
* zones carry the zone of their largest business population (US → New York,
|
|
15
|
+
* CA → Toronto, AU → Sydney, BR → São Paulo, RU → Moscow, …). Territories with
|
|
16
|
+
* no populated zone (AQ, BV, HM) are absent and fall back to the default. An
|
|
17
|
+
* explicit per-account timezone always wins over this table.
|
|
18
|
+
*/
|
|
19
|
+
export const LINKEDIN_COUNTRY_PRIMARY_TIME_ZONES = {
|
|
20
|
+
AD: "Europe/Andorra",
|
|
21
|
+
AE: "Asia/Dubai",
|
|
22
|
+
AF: "Asia/Kabul",
|
|
23
|
+
AG: "America/Antigua",
|
|
24
|
+
AI: "America/Anguilla",
|
|
25
|
+
AL: "Europe/Tirane",
|
|
26
|
+
AM: "Asia/Yerevan",
|
|
27
|
+
AO: "Africa/Luanda",
|
|
28
|
+
AR: "America/Argentina/Buenos_Aires",
|
|
29
|
+
AS: "Pacific/Pago_Pago",
|
|
30
|
+
AT: "Europe/Vienna",
|
|
31
|
+
AU: "Australia/Sydney",
|
|
32
|
+
AW: "America/Aruba",
|
|
33
|
+
AX: "Europe/Mariehamn",
|
|
34
|
+
AZ: "Asia/Baku",
|
|
35
|
+
BA: "Europe/Sarajevo",
|
|
36
|
+
BB: "America/Barbados",
|
|
37
|
+
BD: "Asia/Dhaka",
|
|
38
|
+
BE: "Europe/Brussels",
|
|
39
|
+
BF: "Africa/Ouagadougou",
|
|
40
|
+
BG: "Europe/Sofia",
|
|
41
|
+
BH: "Asia/Bahrain",
|
|
42
|
+
BI: "Africa/Bujumbura",
|
|
43
|
+
BJ: "Africa/Porto-Novo",
|
|
44
|
+
BL: "America/St_Barthelemy",
|
|
45
|
+
BM: "Atlantic/Bermuda",
|
|
46
|
+
BN: "Asia/Brunei",
|
|
47
|
+
BO: "America/La_Paz",
|
|
48
|
+
BQ: "America/Kralendijk",
|
|
49
|
+
BR: "America/Sao_Paulo",
|
|
50
|
+
BS: "America/Nassau",
|
|
51
|
+
BT: "Asia/Thimphu",
|
|
52
|
+
BW: "Africa/Gaborone",
|
|
53
|
+
BY: "Europe/Minsk",
|
|
54
|
+
BZ: "America/Belize",
|
|
55
|
+
CA: "America/Toronto",
|
|
56
|
+
CC: "Indian/Cocos",
|
|
57
|
+
CD: "Africa/Kinshasa",
|
|
58
|
+
CF: "Africa/Bangui",
|
|
59
|
+
CG: "Africa/Brazzaville",
|
|
60
|
+
CH: "Europe/Zurich",
|
|
61
|
+
CI: "Africa/Abidjan",
|
|
62
|
+
CK: "Pacific/Rarotonga",
|
|
63
|
+
CL: "America/Santiago",
|
|
64
|
+
CM: "Africa/Douala",
|
|
65
|
+
CN: "Asia/Shanghai",
|
|
66
|
+
CO: "America/Bogota",
|
|
67
|
+
CR: "America/Costa_Rica",
|
|
68
|
+
CU: "America/Havana",
|
|
69
|
+
CV: "Atlantic/Cape_Verde",
|
|
70
|
+
CW: "America/Curacao",
|
|
71
|
+
CX: "Indian/Christmas",
|
|
72
|
+
CY: "Asia/Nicosia",
|
|
73
|
+
CZ: "Europe/Prague",
|
|
74
|
+
DE: "Europe/Berlin",
|
|
75
|
+
DJ: "Africa/Djibouti",
|
|
76
|
+
DK: "Europe/Copenhagen",
|
|
77
|
+
DM: "America/Dominica",
|
|
78
|
+
DO: "America/Santo_Domingo",
|
|
79
|
+
DZ: "Africa/Algiers",
|
|
80
|
+
EC: "America/Guayaquil",
|
|
81
|
+
EE: "Europe/Tallinn",
|
|
82
|
+
EG: "Africa/Cairo",
|
|
83
|
+
EH: "Africa/El_Aaiun",
|
|
84
|
+
ER: "Africa/Asmara",
|
|
85
|
+
ES: "Europe/Madrid",
|
|
86
|
+
ET: "Africa/Addis_Ababa",
|
|
87
|
+
FI: "Europe/Helsinki",
|
|
88
|
+
FJ: "Pacific/Fiji",
|
|
89
|
+
FK: "Atlantic/Stanley",
|
|
90
|
+
FM: "Pacific/Pohnpei",
|
|
91
|
+
FO: "Atlantic/Faroe",
|
|
92
|
+
FR: "Europe/Paris",
|
|
93
|
+
GA: "Africa/Libreville",
|
|
94
|
+
GB: "Europe/London",
|
|
95
|
+
GD: "America/Grenada",
|
|
96
|
+
GE: "Asia/Tbilisi",
|
|
97
|
+
GF: "America/Cayenne",
|
|
98
|
+
GG: "Europe/Guernsey",
|
|
99
|
+
GH: "Africa/Accra",
|
|
100
|
+
GI: "Europe/Gibraltar",
|
|
101
|
+
GL: "America/Nuuk",
|
|
102
|
+
GM: "Africa/Banjul",
|
|
103
|
+
GN: "Africa/Conakry",
|
|
104
|
+
GP: "America/Guadeloupe",
|
|
105
|
+
GQ: "Africa/Malabo",
|
|
106
|
+
GR: "Europe/Athens",
|
|
107
|
+
GS: "Atlantic/South_Georgia",
|
|
108
|
+
GT: "America/Guatemala",
|
|
109
|
+
GU: "Pacific/Guam",
|
|
110
|
+
GW: "Africa/Bissau",
|
|
111
|
+
GY: "America/Guyana",
|
|
112
|
+
HK: "Asia/Hong_Kong",
|
|
113
|
+
HN: "America/Tegucigalpa",
|
|
114
|
+
HR: "Europe/Zagreb",
|
|
115
|
+
HT: "America/Port-au-Prince",
|
|
116
|
+
HU: "Europe/Budapest",
|
|
117
|
+
ID: "Asia/Jakarta",
|
|
118
|
+
IE: "Europe/Dublin",
|
|
119
|
+
IL: "Asia/Jerusalem",
|
|
120
|
+
IM: "Europe/Isle_of_Man",
|
|
121
|
+
IN: "Asia/Kolkata",
|
|
122
|
+
IO: "Indian/Chagos",
|
|
123
|
+
IQ: "Asia/Baghdad",
|
|
124
|
+
IR: "Asia/Tehran",
|
|
125
|
+
IS: "Atlantic/Reykjavik",
|
|
126
|
+
IT: "Europe/Rome",
|
|
127
|
+
JE: "Europe/Jersey",
|
|
128
|
+
JM: "America/Jamaica",
|
|
129
|
+
JO: "Asia/Amman",
|
|
130
|
+
JP: "Asia/Tokyo",
|
|
131
|
+
KE: "Africa/Nairobi",
|
|
132
|
+
KG: "Asia/Bishkek",
|
|
133
|
+
KH: "Asia/Phnom_Penh",
|
|
134
|
+
KI: "Pacific/Tarawa",
|
|
135
|
+
KM: "Indian/Comoro",
|
|
136
|
+
KN: "America/St_Kitts",
|
|
137
|
+
KP: "Asia/Pyongyang",
|
|
138
|
+
KR: "Asia/Seoul",
|
|
139
|
+
KW: "Asia/Kuwait",
|
|
140
|
+
KY: "America/Cayman",
|
|
141
|
+
KZ: "Asia/Almaty",
|
|
142
|
+
LA: "Asia/Vientiane",
|
|
143
|
+
LB: "Asia/Beirut",
|
|
144
|
+
LC: "America/St_Lucia",
|
|
145
|
+
LI: "Europe/Vaduz",
|
|
146
|
+
LK: "Asia/Colombo",
|
|
147
|
+
LR: "Africa/Monrovia",
|
|
148
|
+
LS: "Africa/Maseru",
|
|
149
|
+
LT: "Europe/Vilnius",
|
|
150
|
+
LU: "Europe/Luxembourg",
|
|
151
|
+
LV: "Europe/Riga",
|
|
152
|
+
LY: "Africa/Tripoli",
|
|
153
|
+
MA: "Africa/Casablanca",
|
|
154
|
+
MC: "Europe/Monaco",
|
|
155
|
+
MD: "Europe/Chisinau",
|
|
156
|
+
ME: "Europe/Podgorica",
|
|
157
|
+
MF: "America/Marigot",
|
|
158
|
+
MG: "Indian/Antananarivo",
|
|
159
|
+
MH: "Pacific/Majuro",
|
|
160
|
+
MK: "Europe/Skopje",
|
|
161
|
+
ML: "Africa/Bamako",
|
|
162
|
+
MM: "Asia/Yangon",
|
|
163
|
+
MN: "Asia/Ulaanbaatar",
|
|
164
|
+
MO: "Asia/Macau",
|
|
165
|
+
MP: "Pacific/Saipan",
|
|
166
|
+
MQ: "America/Martinique",
|
|
167
|
+
MR: "Africa/Nouakchott",
|
|
168
|
+
MS: "America/Montserrat",
|
|
169
|
+
MT: "Europe/Malta",
|
|
170
|
+
MU: "Indian/Mauritius",
|
|
171
|
+
MV: "Indian/Maldives",
|
|
172
|
+
MW: "Africa/Blantyre",
|
|
173
|
+
MX: "America/Mexico_City",
|
|
174
|
+
MY: "Asia/Kuala_Lumpur",
|
|
175
|
+
MZ: "Africa/Maputo",
|
|
176
|
+
NA: "Africa/Windhoek",
|
|
177
|
+
NC: "Pacific/Noumea",
|
|
178
|
+
NE: "Africa/Niamey",
|
|
179
|
+
NF: "Pacific/Norfolk",
|
|
180
|
+
NG: "Africa/Lagos",
|
|
181
|
+
NI: "America/Managua",
|
|
182
|
+
NL: "Europe/Amsterdam",
|
|
183
|
+
NO: "Europe/Oslo",
|
|
184
|
+
NP: "Asia/Kathmandu",
|
|
185
|
+
NR: "Pacific/Nauru",
|
|
186
|
+
NU: "Pacific/Niue",
|
|
187
|
+
NZ: "Pacific/Auckland",
|
|
188
|
+
OM: "Asia/Muscat",
|
|
189
|
+
PA: "America/Panama",
|
|
190
|
+
PE: "America/Lima",
|
|
191
|
+
PF: "Pacific/Tahiti",
|
|
192
|
+
PG: "Pacific/Port_Moresby",
|
|
193
|
+
PH: "Asia/Manila",
|
|
194
|
+
PK: "Asia/Karachi",
|
|
195
|
+
PL: "Europe/Warsaw",
|
|
196
|
+
PM: "America/Miquelon",
|
|
197
|
+
PN: "Pacific/Pitcairn",
|
|
198
|
+
PR: "America/Puerto_Rico",
|
|
199
|
+
PS: "Asia/Hebron",
|
|
200
|
+
PT: "Europe/Lisbon",
|
|
201
|
+
PW: "Pacific/Palau",
|
|
202
|
+
PY: "America/Asuncion",
|
|
203
|
+
QA: "Asia/Qatar",
|
|
204
|
+
RE: "Indian/Reunion",
|
|
205
|
+
RO: "Europe/Bucharest",
|
|
206
|
+
RS: "Europe/Belgrade",
|
|
207
|
+
RU: "Europe/Moscow",
|
|
208
|
+
RW: "Africa/Kigali",
|
|
209
|
+
SA: "Asia/Riyadh",
|
|
210
|
+
SB: "Pacific/Guadalcanal",
|
|
211
|
+
SC: "Indian/Mahe",
|
|
212
|
+
SD: "Africa/Khartoum",
|
|
213
|
+
SE: "Europe/Stockholm",
|
|
214
|
+
SG: "Asia/Singapore",
|
|
215
|
+
SH: "Atlantic/St_Helena",
|
|
216
|
+
SI: "Europe/Ljubljana",
|
|
217
|
+
SJ: "Arctic/Longyearbyen",
|
|
218
|
+
SK: "Europe/Bratislava",
|
|
219
|
+
SL: "Africa/Freetown",
|
|
220
|
+
SM: "Europe/San_Marino",
|
|
221
|
+
SN: "Africa/Dakar",
|
|
222
|
+
SO: "Africa/Mogadishu",
|
|
223
|
+
SR: "America/Paramaribo",
|
|
224
|
+
SS: "Africa/Juba",
|
|
225
|
+
ST: "Africa/Sao_Tome",
|
|
226
|
+
SV: "America/El_Salvador",
|
|
227
|
+
SX: "America/Lower_Princes",
|
|
228
|
+
SY: "Asia/Damascus",
|
|
229
|
+
SZ: "Africa/Mbabane",
|
|
230
|
+
TC: "America/Grand_Turk",
|
|
231
|
+
TD: "Africa/Ndjamena",
|
|
232
|
+
TF: "Indian/Kerguelen",
|
|
233
|
+
TG: "Africa/Lome",
|
|
234
|
+
TH: "Asia/Bangkok",
|
|
235
|
+
TJ: "Asia/Dushanbe",
|
|
236
|
+
TK: "Pacific/Fakaofo",
|
|
237
|
+
TL: "Asia/Dili",
|
|
238
|
+
TM: "Asia/Ashgabat",
|
|
239
|
+
TN: "Africa/Tunis",
|
|
240
|
+
TO: "Pacific/Tongatapu",
|
|
241
|
+
TR: "Europe/Istanbul",
|
|
242
|
+
TT: "America/Port_of_Spain",
|
|
243
|
+
TV: "Pacific/Funafuti",
|
|
244
|
+
TW: "Asia/Taipei",
|
|
245
|
+
TZ: "Africa/Dar_es_Salaam",
|
|
246
|
+
UA: "Europe/Kyiv",
|
|
247
|
+
UG: "Africa/Kampala",
|
|
248
|
+
UM: "Pacific/Midway",
|
|
249
|
+
US: "America/New_York",
|
|
250
|
+
UY: "America/Montevideo",
|
|
251
|
+
UZ: "Asia/Tashkent",
|
|
252
|
+
VA: "Europe/Vatican",
|
|
253
|
+
VC: "America/St_Vincent",
|
|
254
|
+
VE: "America/Caracas",
|
|
255
|
+
VG: "America/Tortola",
|
|
256
|
+
VI: "America/St_Thomas",
|
|
257
|
+
VN: "Asia/Ho_Chi_Minh",
|
|
258
|
+
VU: "Pacific/Efate",
|
|
259
|
+
WF: "Pacific/Wallis",
|
|
260
|
+
WS: "Pacific/Apia",
|
|
261
|
+
XK: "Europe/Belgrade",
|
|
262
|
+
YE: "Asia/Aden",
|
|
263
|
+
YT: "Indian/Mayotte",
|
|
264
|
+
ZA: "Africa/Johannesburg",
|
|
265
|
+
ZM: "Africa/Lusaka",
|
|
266
|
+
ZW: "Africa/Harare",
|
|
267
|
+
};
|
|
268
|
+
/**
|
|
269
|
+
* The primary IANA zone for an ISO 3166-1 alpha-2 country code (any case), or
|
|
270
|
+
* null when the code is unknown or has no populated zone.
|
|
271
|
+
*/
|
|
272
|
+
export function linkedInCountryPrimaryTimeZone(country) {
|
|
273
|
+
if (typeof country !== "string")
|
|
274
|
+
return null;
|
|
275
|
+
return LINKEDIN_COUNTRY_PRIMARY_TIME_ZONES[country.trim().toUpperCase()] ?? null;
|
|
276
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** LinkedIn permits revoking a sent message only during its first hour. */
|
|
2
|
+
export function linkedInMessageDeletionWindowOpen(sentAt, now = Date.now()) {
|
|
3
|
+
const sent = sentAt instanceof Date ? sentAt.getTime() : sentAt ? Date.parse(sentAt) : NaN;
|
|
4
|
+
return Number.isFinite(sent) && sent <= now && now - sent < 60 * 60 * 1000;
|
|
5
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export declare const LINKEDIN_POST_KEYWORD_FIELDS: {
|
|
2
|
+
readonly all: "all_keywords";
|
|
3
|
+
readonly any: "any_keywords";
|
|
4
|
+
readonly exclude: "exclude_keywords";
|
|
5
|
+
};
|
|
6
|
+
/** Each "any of" keyword is one upstream search; this bounds the fan-out. */
|
|
7
|
+
export declare const MAX_ANY_KEYWORDS = 5;
|
|
8
|
+
export declare const MAX_ALL_KEYWORDS = 5;
|
|
9
|
+
export declare const MAX_EXCLUDE_KEYWORDS = 20;
|
|
10
|
+
export declare const MAX_KEYWORD_LENGTH = 100;
|
|
11
|
+
export type LinkedInPostKeywordRules = {
|
|
12
|
+
/** Every one of these must appear in the post. */
|
|
13
|
+
all: string[];
|
|
14
|
+
/** At least one of these must appear in the post. */
|
|
15
|
+
any: string[];
|
|
16
|
+
/** A post containing any of these is dropped. */
|
|
17
|
+
exclude: string[];
|
|
18
|
+
};
|
|
19
|
+
export type LinkedInPostKeywordMatch = {
|
|
20
|
+
keep: boolean;
|
|
21
|
+
/** The "all of" and "any of" keywords found in the post, in rule order. */
|
|
22
|
+
matched: string[];
|
|
23
|
+
};
|
|
24
|
+
/** Trim, drop wrapping quotes and collapse inner whitespace. Case is kept for display. */
|
|
25
|
+
export declare function normalizeLinkedInPostKeyword(value: string): string;
|
|
26
|
+
/**
|
|
27
|
+
* Reads the three rule lists from a request (arrays, or comma-separated strings
|
|
28
|
+
* from a CLI flag). Returns null when no rule is present, so callers can keep
|
|
29
|
+
* the plain single-search path untouched.
|
|
30
|
+
*/
|
|
31
|
+
export declare function readLinkedInPostKeywordRules(input: Record<string, unknown>): LinkedInPostKeywordRules | null;
|
|
32
|
+
/** Everything wrong with a rule set, as customer-readable sentences. Empty when valid. */
|
|
33
|
+
export declare function linkedInPostKeywordRuleProblems(rules: LinkedInPostKeywordRules): string[];
|
|
34
|
+
/**
|
|
35
|
+
* The keyword strings sent upstream, one search each. `search` is the caller's
|
|
36
|
+
* free-text query and leads every search; "all of" keywords follow; each "any
|
|
37
|
+
* of" keyword gets its own search. Exclusions are never sent — they are only
|
|
38
|
+
* enforced on the returned text. Empty when there is no positive keyword at all.
|
|
39
|
+
*/
|
|
40
|
+
export declare function linkedInPostSearchQueries(search: string | null | undefined, rules: LinkedInPostKeywordRules): string[];
|
|
41
|
+
/** How many upstream searches a request fans out to (at least one). */
|
|
42
|
+
export declare function linkedInPostSearchCount(input: Record<string, unknown>): number;
|
|
43
|
+
/** Applies the rules to one post's text. A post with no text matches nothing positive. */
|
|
44
|
+
export declare function matchLinkedInPostKeywords(text: string | null | undefined, rules: LinkedInPostKeywordRules): LinkedInPostKeywordMatch;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Boolean keyword rules for LinkedIn post search.
|
|
2
|
+
//
|
|
3
|
+
// The managed post-search endpoint takes a keyword list and documents neither
|
|
4
|
+
// AND/OR semantics between entries nor any boolean syntax, so OXYGEN does not
|
|
5
|
+
// rely on the provider for them. Instead:
|
|
6
|
+
//
|
|
7
|
+
// - every "any of" keyword becomes its own upstream search (the "all of"
|
|
8
|
+
// keywords ride along in each one), which is what makes an OR recall-safe;
|
|
9
|
+
// - the rules are then enforced here, on the returned post text, which is
|
|
10
|
+
// what makes them exact: case-insensitive, whitespace-normalized substring
|
|
11
|
+
// matching, the same way a person scanning the post would read it.
|
|
12
|
+
//
|
|
13
|
+
// The provider caps searches per hour for the whole account, so the number of
|
|
14
|
+
// upstream searches one request may fan out to is bounded by MAX_ANY_KEYWORDS.
|
|
15
|
+
// Pure and dependency-free: the provider adapter, the pricing resolver, the
|
|
16
|
+
// signal planner and the web filter rail all read the same rules.
|
|
17
|
+
export const LINKEDIN_POST_KEYWORD_FIELDS = {
|
|
18
|
+
all: "all_keywords",
|
|
19
|
+
any: "any_keywords",
|
|
20
|
+
exclude: "exclude_keywords",
|
|
21
|
+
};
|
|
22
|
+
/** Each "any of" keyword is one upstream search; this bounds the fan-out. */
|
|
23
|
+
export const MAX_ANY_KEYWORDS = 5;
|
|
24
|
+
export const MAX_ALL_KEYWORDS = 5;
|
|
25
|
+
export const MAX_EXCLUDE_KEYWORDS = 20;
|
|
26
|
+
export const MAX_KEYWORD_LENGTH = 100;
|
|
27
|
+
/** Trim, drop wrapping quotes and collapse inner whitespace. Case is kept for display. */
|
|
28
|
+
export function normalizeLinkedInPostKeyword(value) {
|
|
29
|
+
return value.trim().replace(/^["'“”‘’]+|["'“”‘’]+$/g, "").replace(/\s+/g, " ").trim();
|
|
30
|
+
}
|
|
31
|
+
// A hyphen or dash reads as a space: LinkedIn's own search matches "founder led
|
|
32
|
+
// sales" to posts that say "founder-led sales", and on 2026-09-24 all 25 posts it
|
|
33
|
+
// returned for that keyword were dropped here for the hyphen alone.
|
|
34
|
+
function comparable(value) {
|
|
35
|
+
return value.toLowerCase().replace(/[-\u2010-\u2015]/g, " ").replace(/\s+/g, " ");
|
|
36
|
+
}
|
|
37
|
+
function readTerms(value) {
|
|
38
|
+
if (value === undefined || value === null || value === "")
|
|
39
|
+
return [];
|
|
40
|
+
const raw = Array.isArray(value) ? value.map((entry) => String(entry)) : String(value).split(",");
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
const terms = [];
|
|
43
|
+
for (const entry of raw) {
|
|
44
|
+
const term = normalizeLinkedInPostKeyword(entry);
|
|
45
|
+
if (!term)
|
|
46
|
+
continue;
|
|
47
|
+
const key = comparable(term);
|
|
48
|
+
if (seen.has(key))
|
|
49
|
+
continue;
|
|
50
|
+
seen.add(key);
|
|
51
|
+
terms.push(term);
|
|
52
|
+
}
|
|
53
|
+
return terms;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Reads the three rule lists from a request (arrays, or comma-separated strings
|
|
57
|
+
* from a CLI flag). Returns null when no rule is present, so callers can keep
|
|
58
|
+
* the plain single-search path untouched.
|
|
59
|
+
*/
|
|
60
|
+
export function readLinkedInPostKeywordRules(input) {
|
|
61
|
+
const rules = {
|
|
62
|
+
all: readTerms(input[LINKEDIN_POST_KEYWORD_FIELDS.all]),
|
|
63
|
+
any: readTerms(input[LINKEDIN_POST_KEYWORD_FIELDS.any]),
|
|
64
|
+
exclude: readTerms(input[LINKEDIN_POST_KEYWORD_FIELDS.exclude]),
|
|
65
|
+
};
|
|
66
|
+
return rules.all.length + rules.any.length + rules.exclude.length > 0 ? rules : null;
|
|
67
|
+
}
|
|
68
|
+
/** Everything wrong with a rule set, as customer-readable sentences. Empty when valid. */
|
|
69
|
+
export function linkedInPostKeywordRuleProblems(rules) {
|
|
70
|
+
const problems = [];
|
|
71
|
+
if (rules.any.length > MAX_ANY_KEYWORDS) {
|
|
72
|
+
problems.push(`any_keywords takes at most ${MAX_ANY_KEYWORDS} keywords; each one is a separate LinkedIn search.`);
|
|
73
|
+
}
|
|
74
|
+
if (rules.all.length > MAX_ALL_KEYWORDS)
|
|
75
|
+
problems.push(`all_keywords takes at most ${MAX_ALL_KEYWORDS} keywords.`);
|
|
76
|
+
if (rules.exclude.length > MAX_EXCLUDE_KEYWORDS)
|
|
77
|
+
problems.push(`exclude_keywords takes at most ${MAX_EXCLUDE_KEYWORDS} keywords.`);
|
|
78
|
+
const long = [...rules.all, ...rules.any, ...rules.exclude].find((term) => term.length > MAX_KEYWORD_LENGTH);
|
|
79
|
+
if (long)
|
|
80
|
+
problems.push(`Keywords must be at most ${MAX_KEYWORD_LENGTH} characters.`);
|
|
81
|
+
const excluded = new Set(rules.exclude.map(comparable));
|
|
82
|
+
const conflict = [...rules.all, ...rules.any].find((term) => excluded.has(comparable(term)));
|
|
83
|
+
if (conflict)
|
|
84
|
+
problems.push(`"${conflict}" is both required and excluded.`);
|
|
85
|
+
return problems;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The keyword strings sent upstream, one search each. `search` is the caller's
|
|
89
|
+
* free-text query and leads every search; "all of" keywords follow; each "any
|
|
90
|
+
* of" keyword gets its own search. Exclusions are never sent — they are only
|
|
91
|
+
* enforced on the returned text. Empty when there is no positive keyword at all.
|
|
92
|
+
*/
|
|
93
|
+
export function linkedInPostSearchQueries(search, rules) {
|
|
94
|
+
const base = [search?.trim() ?? "", ...rules.all].filter(Boolean);
|
|
95
|
+
if (rules.any.length === 0)
|
|
96
|
+
return base.length > 0 ? [base.join(" ")] : [];
|
|
97
|
+
return rules.any.map((term) => [...base, term].join(" "));
|
|
98
|
+
}
|
|
99
|
+
/** How many upstream searches a request fans out to (at least one). */
|
|
100
|
+
export function linkedInPostSearchCount(input) {
|
|
101
|
+
const rules = readLinkedInPostKeywordRules(input);
|
|
102
|
+
if (!rules)
|
|
103
|
+
return 1;
|
|
104
|
+
return Math.max(1, Math.min(MAX_ANY_KEYWORDS, rules.any.length));
|
|
105
|
+
}
|
|
106
|
+
/** Applies the rules to one post's text. A post with no text matches nothing positive. */
|
|
107
|
+
export function matchLinkedInPostKeywords(text, rules) {
|
|
108
|
+
const body = comparable(text ?? "");
|
|
109
|
+
const has = (term) => body.includes(comparable(term));
|
|
110
|
+
const matchedAll = rules.all.filter(has);
|
|
111
|
+
const matchedAny = rules.any.filter(has);
|
|
112
|
+
const keep = matchedAll.length === rules.all.length
|
|
113
|
+
&& (rules.any.length === 0 || matchedAny.length > 0)
|
|
114
|
+
&& !rules.exclude.some(has);
|
|
115
|
+
return { keep, matched: [...matchedAll, ...matchedAny] };
|
|
116
|
+
}
|
|
@@ -151,4 +151,100 @@ export declare function waitStepDelayMs(step: LinkedInWaitStep): number;
|
|
|
151
151
|
* seed is derived.
|
|
152
152
|
*/
|
|
153
153
|
export declare function renderLinkedInTemplate(template: string, values: Record<string, unknown>, options?: RenderTemplateOptions): string;
|
|
154
|
+
/**
|
|
155
|
+
* LinkedIn's connection-note ceiling for a PREMIUM sending account.
|
|
156
|
+
*
|
|
157
|
+
* Basic accounts are capped lower — see {@link LINKEDIN_INVITE_NOTE_LIMIT_BASIC}.
|
|
158
|
+
* One constant, three consumers (the definition validator, the launch copy
|
|
159
|
+
* preflight and the dispatcher); they carried three independent `300`s before.
|
|
160
|
+
*/
|
|
161
|
+
export declare const LINKEDIN_INVITE_NOTE_LIMIT = 300;
|
|
162
|
+
/**
|
|
163
|
+
* The ceiling that actually holds for a sending account whose tier we do not
|
|
164
|
+
* know, which today is every account: nothing in `sender_accounts.metadata`
|
|
165
|
+
* records a LinkedIn Premium signal.
|
|
166
|
+
*
|
|
167
|
+
* Measured on production (2026-09-20, one tenant, 30 days). Two senders, same
|
|
168
|
+
* tenant, same table:
|
|
169
|
+
*
|
|
170
|
+
* eca988d5 notes > 200 chars: 74 sent, 1 failed, 0 length refusals
|
|
171
|
+
* d44fe895 notes > 200 chars: 0 sent, 138 failed, 138 length refusals
|
|
172
|
+
*
|
|
173
|
+
* d44fe895 has never delivered a single noted invite over 200 characters, and
|
|
174
|
+
* the length refusal never fires below 200 on either account. Sequence f729fc2c
|
|
175
|
+
* rendered 205–238 character notes onto that sender and destroyed 138 leads at a
|
|
176
|
+
* 100% invite failure rate, because a validator that only knows the Premium
|
|
177
|
+
* ceiling let every one of them through.
|
|
178
|
+
*
|
|
179
|
+
* 200 is safe on any tier, so it is the default and the account may only be
|
|
180
|
+
* relaxed upward from evidence.
|
|
181
|
+
*/
|
|
182
|
+
export declare const LINKEDIN_INVITE_NOTE_LIMIT_BASIC = 200;
|
|
183
|
+
/**
|
|
184
|
+
* What a LinkedIn refusal is actually ABOUT — the question the HTTP status band
|
|
185
|
+
* cannot answer.
|
|
186
|
+
*
|
|
187
|
+
* Measured on production (2026-09-21, 14 days, one tenant), a single 403 covered
|
|
188
|
+
* a sender-side allowance, an unreadable profile and a step sent to a
|
|
189
|
+
* non-connection, and all three terminated the lead as a recipient bounce. The
|
|
190
|
+
* provider's own sentence separates them; the status never could.
|
|
191
|
+
*/
|
|
192
|
+
export type LinkedInRefusalScope =
|
|
193
|
+
/** Our request was malformed for this account. Ours to fix; the lead is fine. */
|
|
194
|
+
"oxygen_request_invalid"
|
|
195
|
+
/** We could not READ the lead's profile. Not a verdict on a send. */
|
|
196
|
+
| "profile_unreadable"
|
|
197
|
+
/** This sender has exhausted a LinkedIn allowance. Nothing to do with the lead. */
|
|
198
|
+
| "sender_allowance"
|
|
199
|
+
/** The step needs a connection this lead has not accepted. Ours to sequence. */
|
|
200
|
+
| "relationship_required"
|
|
201
|
+
/** An invite already exists for this lead. The journey is effectively running. */
|
|
202
|
+
| "already_engaged"
|
|
203
|
+
/** Unrecognised. Callers MUST preserve their existing behaviour. */
|
|
204
|
+
| "unknown";
|
|
205
|
+
/**
|
|
206
|
+
* Classify a LinkedIn refusal from the provider's own account of it.
|
|
207
|
+
*
|
|
208
|
+
* Deliberately a CLOSED set of anchored, full-string matches, in the same
|
|
209
|
+
* discipline as the sequence-recovery allowlists: matching vendor prose is
|
|
210
|
+
* fragile, so an unrecognised message must degrade to `unknown` and leave the
|
|
211
|
+
* caller's behaviour exactly as it was. Extending the set is an evidence-driven
|
|
212
|
+
* act — callers log what they could not classify.
|
|
213
|
+
*
|
|
214
|
+
* Pure and total; never throws.
|
|
215
|
+
*/
|
|
216
|
+
export declare function classifyLinkedInRefusal(input: {
|
|
217
|
+
providerType: string | null;
|
|
218
|
+
providerMessage: string | null;
|
|
219
|
+
status: number | null;
|
|
220
|
+
operation: string | null;
|
|
221
|
+
}): LinkedInRefusalScope;
|
|
222
|
+
/**
|
|
223
|
+
* Does this refusal scope say the LEAD is unreachable?
|
|
224
|
+
*
|
|
225
|
+
* Only `already_engaged` is genuinely about the lead, and even that one means
|
|
226
|
+
* "an invite exists", not "this person is dead". Everything else is about our
|
|
227
|
+
* request, our sender or our sequencing, and must never brand the lead.
|
|
228
|
+
*/
|
|
229
|
+
export declare function linkedInRefusalBlamesLead(scope: LinkedInRefusalScope): boolean;
|
|
230
|
+
/**
|
|
231
|
+
* Trim a rendered connection note to a LinkedIn account's ceiling, at a word
|
|
232
|
+
* boundary.
|
|
233
|
+
*
|
|
234
|
+
* Counts CODE POINTS, matching the launch copy preflight exactly — `.length`
|
|
235
|
+
* would count an emoji or a ZWJ sequence as two or more and the two surfaces
|
|
236
|
+
* would then disagree about the same note.
|
|
237
|
+
*
|
|
238
|
+
* Cuts at the last whitespace inside the limit so the note never ends mid-word;
|
|
239
|
+
* a single unbroken run longer than the limit has no boundary to find and is cut
|
|
240
|
+
* hard, which is the only case where that can happen.
|
|
241
|
+
*
|
|
242
|
+
* Returns the note unchanged (and `truncated: false`) when it already fits, so
|
|
243
|
+
* callers can record the trim only when there was one.
|
|
244
|
+
*/
|
|
245
|
+
export declare function truncateLinkedInInviteNote(note: string, limit: number): {
|
|
246
|
+
note: string;
|
|
247
|
+
truncated: boolean;
|
|
248
|
+
originalLength: number;
|
|
249
|
+
};
|
|
154
250
|
export {};
|
|
@@ -73,3 +73,126 @@ export function waitStepDelayMs(step) {
|
|
|
73
73
|
export function renderLinkedInTemplate(template, values, options) {
|
|
74
74
|
return renderTemplate(template, values, options);
|
|
75
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* LinkedIn's connection-note ceiling for a PREMIUM sending account.
|
|
78
|
+
*
|
|
79
|
+
* Basic accounts are capped lower — see {@link LINKEDIN_INVITE_NOTE_LIMIT_BASIC}.
|
|
80
|
+
* One constant, three consumers (the definition validator, the launch copy
|
|
81
|
+
* preflight and the dispatcher); they carried three independent `300`s before.
|
|
82
|
+
*/
|
|
83
|
+
export const LINKEDIN_INVITE_NOTE_LIMIT = 300;
|
|
84
|
+
/**
|
|
85
|
+
* The ceiling that actually holds for a sending account whose tier we do not
|
|
86
|
+
* know, which today is every account: nothing in `sender_accounts.metadata`
|
|
87
|
+
* records a LinkedIn Premium signal.
|
|
88
|
+
*
|
|
89
|
+
* Measured on production (2026-09-20, one tenant, 30 days). Two senders, same
|
|
90
|
+
* tenant, same table:
|
|
91
|
+
*
|
|
92
|
+
* eca988d5 notes > 200 chars: 74 sent, 1 failed, 0 length refusals
|
|
93
|
+
* d44fe895 notes > 200 chars: 0 sent, 138 failed, 138 length refusals
|
|
94
|
+
*
|
|
95
|
+
* d44fe895 has never delivered a single noted invite over 200 characters, and
|
|
96
|
+
* the length refusal never fires below 200 on either account. Sequence f729fc2c
|
|
97
|
+
* rendered 205–238 character notes onto that sender and destroyed 138 leads at a
|
|
98
|
+
* 100% invite failure rate, because a validator that only knows the Premium
|
|
99
|
+
* ceiling let every one of them through.
|
|
100
|
+
*
|
|
101
|
+
* 200 is safe on any tier, so it is the default and the account may only be
|
|
102
|
+
* relaxed upward from evidence.
|
|
103
|
+
*/
|
|
104
|
+
export const LINKEDIN_INVITE_NOTE_LIMIT_BASIC = 200;
|
|
105
|
+
/**
|
|
106
|
+
* Classify a LinkedIn refusal from the provider's own account of it.
|
|
107
|
+
*
|
|
108
|
+
* Deliberately a CLOSED set of anchored, full-string matches, in the same
|
|
109
|
+
* discipline as the sequence-recovery allowlists: matching vendor prose is
|
|
110
|
+
* fragile, so an unrecognised message must degrade to `unknown` and leave the
|
|
111
|
+
* caller's behaviour exactly as it was. Extending the set is an evidence-driven
|
|
112
|
+
* act — callers log what they could not classify.
|
|
113
|
+
*
|
|
114
|
+
* Pure and total; never throws.
|
|
115
|
+
*/
|
|
116
|
+
export function classifyLinkedInRefusal(input) {
|
|
117
|
+
const message = typeof input.providerMessage === "string" ? input.providerMessage.trim() : "";
|
|
118
|
+
if (message.length === 0)
|
|
119
|
+
return "unknown";
|
|
120
|
+
// Our invite note is longer than THIS account's ceiling. The 164 rows this
|
|
121
|
+
// matched in 14 days are leads we broke, filed as dead recipients.
|
|
122
|
+
if (input.status === 400
|
|
123
|
+
&& input.providerType === "provider/invalid_parameters"
|
|
124
|
+
&& /^Invalid parameters: You have reached the maximum number of characters for the custom message\.$/u.test(message)) {
|
|
125
|
+
return "oxygen_request_invalid";
|
|
126
|
+
}
|
|
127
|
+
// The profile READ failed. 121 of 318 failures in 14 days were this, on
|
|
128
|
+
// `users_get` — the lookup that runs BEFORE the invite or message, so LinkedIn
|
|
129
|
+
// never saw a send at all. It can be a genuinely unreachable profile or this
|
|
130
|
+
// sender's own profile-view allowance, and the message cannot tell them apart,
|
|
131
|
+
// which is exactly why it must not terminate the lead on the first attempt.
|
|
132
|
+
if (input.status === 403
|
|
133
|
+
&& input.operation === "users_get"
|
|
134
|
+
&& /^Insufficient permissions: This profile can't be accessed$/u.test(message)) {
|
|
135
|
+
return "profile_unreadable";
|
|
136
|
+
}
|
|
137
|
+
if (input.status === 403
|
|
138
|
+
&& input.providerType === "provider/access_restricted"
|
|
139
|
+
&& /^Access restricted: You have reached the limit for custom messages\.$/u.test(message)) {
|
|
140
|
+
return "sender_allowance";
|
|
141
|
+
}
|
|
142
|
+
if (input.status === 403
|
|
143
|
+
&& input.providerType === "provider/access_restricted"
|
|
144
|
+
&& /^Access restricted: This user is not a relation\.$/u.test(message)) {
|
|
145
|
+
return "relationship_required";
|
|
146
|
+
}
|
|
147
|
+
if (input.status === 422
|
|
148
|
+
&& input.providerType === "provider/unprocessable_entity"
|
|
149
|
+
&& /^Unprocessable entity: This user has already handled a request from you in the past 3 weeks\./u.test(message)) {
|
|
150
|
+
return "already_engaged";
|
|
151
|
+
}
|
|
152
|
+
if (input.status === 409
|
|
153
|
+
&& input.providerType === "provider/conflict"
|
|
154
|
+
&& /^Conflict: A relation request has already been sent to this user\.$/u.test(message)) {
|
|
155
|
+
return "already_engaged";
|
|
156
|
+
}
|
|
157
|
+
return "unknown";
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Does this refusal scope say the LEAD is unreachable?
|
|
161
|
+
*
|
|
162
|
+
* Only `already_engaged` is genuinely about the lead, and even that one means
|
|
163
|
+
* "an invite exists", not "this person is dead". Everything else is about our
|
|
164
|
+
* request, our sender or our sequencing, and must never brand the lead.
|
|
165
|
+
*/
|
|
166
|
+
export function linkedInRefusalBlamesLead(scope) {
|
|
167
|
+
return scope === "already_engaged";
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Trim a rendered connection note to a LinkedIn account's ceiling, at a word
|
|
171
|
+
* boundary.
|
|
172
|
+
*
|
|
173
|
+
* Counts CODE POINTS, matching the launch copy preflight exactly — `.length`
|
|
174
|
+
* would count an emoji or a ZWJ sequence as two or more and the two surfaces
|
|
175
|
+
* would then disagree about the same note.
|
|
176
|
+
*
|
|
177
|
+
* Cuts at the last whitespace inside the limit so the note never ends mid-word;
|
|
178
|
+
* a single unbroken run longer than the limit has no boundary to find and is cut
|
|
179
|
+
* hard, which is the only case where that can happen.
|
|
180
|
+
*
|
|
181
|
+
* Returns the note unchanged (and `truncated: false`) when it already fits, so
|
|
182
|
+
* callers can record the trim only when there was one.
|
|
183
|
+
*/
|
|
184
|
+
export function truncateLinkedInInviteNote(note, limit) {
|
|
185
|
+
const points = [...note];
|
|
186
|
+
const originalLength = points.length;
|
|
187
|
+
if (limit <= 0 || originalLength <= limit) {
|
|
188
|
+
return { note, truncated: false, originalLength };
|
|
189
|
+
}
|
|
190
|
+
const head = points.slice(0, limit);
|
|
191
|
+
let cut = head.length;
|
|
192
|
+
while (cut > 0 && !/\s/u.test(head[cut - 1]))
|
|
193
|
+
cut -= 1;
|
|
194
|
+
// No whitespace inside the window: one unbroken run, so a hard cut is the
|
|
195
|
+
// only option left.
|
|
196
|
+
const kept = cut === 0 ? head : head.slice(0, cut);
|
|
197
|
+
return { note: kept.join("").trimEnd(), truncated: true, originalLength };
|
|
198
|
+
}
|