@jackwener/opencli 1.7.22 → 1.8.1
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 +35 -194
- package/README.zh-CN.md +42 -260
- package/cli-manifest.json +8160 -4392
- package/clis/12306/me.js +73 -0
- package/clis/12306/orders.js +96 -0
- package/clis/12306/passengers.js +90 -0
- package/clis/12306/price.js +166 -0
- package/clis/12306/stations.js +66 -0
- package/clis/12306/train.js +91 -0
- package/clis/12306/trains.js +119 -0
- package/clis/12306/utils.js +272 -0
- package/clis/12306/utils.test.js +331 -0
- package/clis/36kr/article.js +6 -3
- package/clis/36kr/article.test.js +46 -0
- package/clis/_atlassian/shared.js +577 -0
- package/clis/_atlassian/shared.test.js +170 -0
- package/clis/apple-podcasts/commands.test.js +20 -0
- package/clis/apple-podcasts/search.js +2 -2
- package/clis/barchart/greeks.js +144 -56
- package/clis/barchart/greeks.test.js +138 -0
- package/clis/bilibili/comment.js +125 -0
- package/clis/bilibili/comment.test.js +153 -0
- package/clis/bilibili/comments.js +116 -21
- package/clis/bilibili/comments.test.js +77 -18
- package/clis/bilibili/subtitle.js +76 -31
- package/clis/bilibili/subtitle.test.js +156 -9
- package/clis/bilibili/summary.js +167 -0
- package/clis/bilibili/summary.test.js +210 -0
- package/clis/bilibili/utils.js +63 -5
- package/clis/bilibili/utils.test.js +45 -1
- package/clis/booking/booking.test.js +356 -0
- package/clis/booking/search.js +351 -0
- package/clis/chatgpt/envelope.test.js +108 -0
- package/clis/chatgpt/image.js +2 -2
- package/clis/chatgpt/image.test.js +6 -0
- package/clis/chatgpt/utils.js +148 -41
- package/clis/chatgpt/utils.test.js +92 -2
- package/clis/chess/analyze.js +35 -0
- package/clis/chess/analyze.test.js +79 -0
- package/clis/chess/game.js +114 -0
- package/clis/chess/game.test.js +178 -0
- package/clis/chess/games.js +67 -0
- package/clis/chess/games.test.js +164 -0
- package/clis/chess/stats.js +32 -0
- package/clis/chess/stats.test.js +79 -0
- package/clis/chess/utils.js +170 -0
- package/clis/chess/utils.test.js +230 -0
- package/clis/confluence/commands.test.js +195 -0
- package/clis/confluence/create.js +39 -0
- package/clis/confluence/page.js +23 -0
- package/clis/confluence/search.js +34 -0
- package/clis/confluence/shared.js +173 -0
- package/clis/confluence/update.js +38 -0
- package/clis/douyin/_shared/browser-fetch.js +44 -20
- package/clis/douyin/_shared/browser-fetch.test.js +22 -1
- package/clis/douyin/_shared/evaluate-result.js +16 -0
- package/clis/douyin/_shared/tos-upload.js +105 -69
- package/clis/douyin/_shared/vod-upload.js +212 -0
- package/clis/douyin/_shared/vod-upload.test.js +38 -0
- package/clis/douyin/delete.js +137 -4
- package/clis/douyin/delete.test.js +90 -1
- package/clis/douyin/hashtag.js +84 -23
- package/clis/douyin/hashtag.test.js +113 -0
- package/clis/douyin/publish-upload-id.test.js +170 -0
- package/clis/douyin/publish.js +88 -42
- package/clis/douyin/user-videos.js +9 -2
- package/clis/douyin/user-videos.test.js +43 -0
- package/clis/flomo/memos.js +228 -0
- package/clis/flomo/memos.test.js +144 -0
- package/clis/geogebra/add-circle.js +46 -0
- package/clis/geogebra/add-line.js +35 -0
- package/clis/geogebra/add-point.js +27 -0
- package/clis/geogebra/add-polygon.js +25 -0
- package/clis/geogebra/eval.js +35 -0
- package/clis/geogebra/geogebra.test.js +175 -0
- package/clis/geogebra/hexagon.js +62 -0
- package/clis/geogebra/info.js +72 -0
- package/clis/geogebra/list.js +35 -0
- package/clis/geogebra/triangle.js +60 -0
- package/clis/geogebra/utils.js +271 -0
- package/clis/gitee/search.js +2 -2
- package/clis/gitee/search.test.js +65 -0
- package/clis/jike/post.js +27 -17
- package/clis/jike/read.test.js +86 -0
- package/clis/jike/topic.js +32 -19
- package/clis/jike/user.js +33 -20
- package/clis/jira/attachments.js +28 -0
- package/clis/jira/commands.test.js +287 -0
- package/clis/jira/comments.js +28 -0
- package/clis/jira/issue.js +28 -0
- package/clis/jira/links.js +28 -0
- package/clis/jira/search.js +47 -0
- package/clis/jira/shared.js +256 -0
- package/clis/lesswrong/comments.js +1 -1
- package/clis/lesswrong/curated.js +1 -1
- package/clis/lesswrong/frontpage.js +1 -1
- package/clis/lesswrong/frontpage.test.js +37 -0
- package/clis/lesswrong/new.js +1 -1
- package/clis/lesswrong/read.js +1 -1
- package/clis/lesswrong/sequences.js +1 -1
- package/clis/lesswrong/shortform.js +1 -1
- package/clis/lesswrong/tag.js +1 -1
- package/clis/lesswrong/top-month.js +1 -1
- package/clis/lesswrong/top-week.js +1 -1
- package/clis/lesswrong/top-year.js +1 -1
- package/clis/lesswrong/top.js +1 -1
- package/clis/linkedin/connect.js +401 -0
- package/clis/linkedin/connect.test.js +213 -0
- package/clis/linkedin/inbox.js +234 -0
- package/clis/linkedin/inbox.test.js +152 -0
- package/clis/linkedin/job-detail.js +167 -0
- package/clis/linkedin/job-detail.test.js +38 -0
- package/clis/linkedin/jobs-preferences.js +113 -0
- package/clis/linkedin/jobs-preferences.test.js +43 -0
- package/clis/linkedin/people-search.js +262 -0
- package/clis/linkedin/people-search.test.js +216 -0
- package/clis/linkedin/post-analytics.js +74 -0
- package/clis/linkedin/post-analytics.test.js +40 -0
- package/clis/linkedin/posts-core.js +241 -0
- package/clis/linkedin/posts.js +22 -0
- package/clis/linkedin/posts.test.js +40 -0
- package/clis/linkedin/profile-analytics.js +104 -0
- package/clis/linkedin/profile-analytics.test.js +67 -0
- package/clis/linkedin/profile-experience.js +671 -0
- package/clis/linkedin/profile-experience.test.js +152 -0
- package/clis/linkedin/profile-projects.js +311 -0
- package/clis/linkedin/profile-projects.test.js +111 -0
- package/clis/linkedin/profile-read.js +148 -0
- package/clis/linkedin/profile-read.test.js +77 -0
- package/clis/linkedin/safe-send.js +357 -0
- package/clis/linkedin/safe-send.test.js +204 -0
- package/clis/linkedin/salesnav-inbox.js +210 -0
- package/clis/linkedin/salesnav-inbox.test.js +113 -0
- package/clis/linkedin/salesnav-message.js +360 -0
- package/clis/linkedin/salesnav-message.test.js +172 -0
- package/clis/linkedin/salesnav-search.js +186 -0
- package/clis/linkedin/salesnav-search.test.js +76 -0
- package/clis/linkedin/salesnav-thread.js +212 -0
- package/clis/linkedin/salesnav-thread.test.js +79 -0
- package/clis/linkedin/sent-invitations.js +92 -0
- package/clis/linkedin/sent-invitations.test.js +62 -0
- package/clis/linkedin/services-read.js +213 -0
- package/clis/linkedin/services-read.test.js +105 -0
- package/clis/linkedin/shared.js +124 -0
- package/clis/linkedin/thread-snapshot.js +214 -0
- package/clis/linkedin/thread-snapshot.test.js +89 -0
- package/clis/linkedin/timeline.js +14 -7
- package/clis/linkedin-learning/course.js +138 -0
- package/clis/linkedin-learning/course.test.js +114 -0
- package/clis/linkedin-learning/search.js +155 -0
- package/clis/linkedin-learning/search.test.js +144 -0
- package/clis/linkedin-learning/trending.js +133 -0
- package/clis/linkedin-learning/trending.test.js +123 -0
- package/clis/notebooklm/add-source.js +269 -0
- package/clis/notebooklm/add-source.test.js +97 -0
- package/clis/notebooklm/create.js +76 -0
- package/clis/notebooklm/create.test.js +58 -0
- package/clis/notebooklm/generate-audio.js +91 -0
- package/clis/notebooklm/generate-audio.test.js +63 -0
- package/clis/notebooklm/generate-slides.js +106 -0
- package/clis/notebooklm/generate-slides.test.js +75 -0
- package/clis/notebooklm/open.test.js +10 -10
- package/clis/notebooklm/rpc.js +20 -6
- package/clis/notebooklm/rpc.test.js +27 -1
- package/clis/notebooklm/utils.js +100 -24
- package/clis/notebooklm/utils.test.js +60 -1
- package/clis/notebooklm/write-note.js +103 -0
- package/clis/notebooklm/write-note.test.js +70 -0
- package/clis/pixiv/detail.js +41 -34
- package/clis/pixiv/detail.test.js +93 -0
- package/clis/pixiv/user.js +36 -31
- package/clis/pixiv/user.test.js +100 -0
- package/clis/pixiv/utils.js +56 -7
- package/clis/powerchina/search.js +3 -3
- package/clis/powerchina/search.test.js +27 -1
- package/clis/reddit/extract-media.test.js +149 -0
- package/clis/reddit/frontpage.js +47 -9
- package/clis/reddit/frontpage.test.js +34 -0
- package/clis/reddit/home.js +31 -1
- package/clis/reddit/home.test.js +46 -3
- package/clis/reddit/hot.js +32 -1
- package/clis/reddit/hot.test.js +15 -1
- package/clis/reddit/popular.js +39 -1
- package/clis/reddit/popular.test.js +26 -0
- package/clis/reddit/saved.js +1 -1
- package/clis/reddit/search.js +38 -1
- package/clis/reddit/search.test.js +26 -0
- package/clis/reddit/subreddit.js +52 -7
- package/clis/reddit/subreddit.test.js +31 -0
- package/clis/reddit/subscribed.js +165 -0
- package/clis/reddit/subscribed.test.js +168 -0
- package/clis/reddit/upvoted.js +1 -1
- package/clis/suno/commands.test.js +188 -0
- package/clis/suno/download.js +140 -0
- package/clis/suno/download.test.js +151 -0
- package/clis/suno/generate.js +231 -0
- package/clis/suno/generate.test.js +252 -0
- package/clis/suno/list.js +79 -0
- package/clis/suno/status.js +63 -0
- package/clis/suno/utils.js +549 -0
- package/clis/suno/utils.test.js +329 -0
- package/clis/twitter/device-follow.js +193 -0
- package/clis/twitter/device-follow.test.js +287 -0
- package/clis/twitter/download.js +443 -73
- package/clis/twitter/download.test.js +457 -0
- package/clis/twitter/followers.js +6 -2
- package/clis/twitter/followers.test.js +19 -1
- package/clis/twitter/following.js +14 -5
- package/clis/twitter/following.test.js +29 -0
- package/clis/twitter/likes.js +12 -4
- package/clis/twitter/likes.test.js +26 -1
- package/clis/twitter/list-add.js +1 -1
- package/clis/twitter/list-create.js +155 -0
- package/clis/twitter/list-create.test.js +169 -0
- package/clis/twitter/list-remove.js +13 -6
- package/clis/twitter/list-remove.test.js +74 -0
- package/clis/twitter/list-tweets.js +6 -2
- package/clis/twitter/list-tweets.test.js +41 -1
- package/clis/twitter/lists.js +31 -4
- package/clis/twitter/lists.test.js +152 -16
- package/clis/twitter/notifications.js +4 -4
- package/clis/twitter/post.js +62 -4
- package/clis/twitter/post.test.js +35 -3
- package/clis/twitter/profile.js +81 -28
- package/clis/twitter/profile.test.js +113 -2
- package/clis/twitter/quote.js +9 -4
- package/clis/twitter/reply.js +13 -10
- package/clis/twitter/reply.test.js +41 -0
- package/clis/twitter/search.js +7 -3
- package/clis/twitter/search.test.js +41 -0
- package/clis/twitter/shared.js +155 -0
- package/clis/twitter/shared.test.js +465 -1
- package/clis/twitter/thread.js +10 -2
- package/clis/twitter/thread.test.js +58 -0
- package/clis/twitter/timeline.js +6 -2
- package/clis/twitter/timeline.test.js +2 -0
- package/clis/twitter/tweets.js +3 -2
- package/clis/twitter/tweets.test.js +1 -1
- package/clis/twitter/utils.js +53 -16
- package/clis/upwork/detail.js +132 -0
- package/clis/upwork/feed.js +109 -0
- package/clis/upwork/search.js +115 -0
- package/clis/upwork/upwork.test.js +566 -0
- package/clis/upwork/utils.js +323 -0
- package/clis/weibo/delete.js +172 -0
- package/clis/weibo/delete.test.js +94 -0
- package/clis/weibo/publish.js +37 -14
- package/clis/weibo/publish.test.js +14 -5
- package/clis/weibo/user-posts.js +234 -0
- package/clis/weibo/user-posts.test.js +92 -0
- package/clis/weread/book-search.js +438 -0
- package/clis/weread/book-search.test.js +242 -0
- package/clis/weread/search-regression.test.js +98 -11
- package/clis/weread/search.js +32 -9
- package/clis/weread-official/book.js +135 -0
- package/clis/weread-official/commands.test.js +385 -0
- package/clis/weread-official/discover.js +107 -0
- package/clis/weread-official/list-apis.js +95 -0
- package/clis/weread-official/notes.js +171 -0
- package/clis/weread-official/readdata.js +158 -0
- package/clis/weread-official/review.js +93 -0
- package/clis/weread-official/search.js +106 -0
- package/clis/weread-official/shelf.js +97 -0
- package/clis/weread-official/utils.js +293 -0
- package/clis/weread-official/utils.test.js +242 -0
- package/clis/wikipedia/trending.js +7 -3
- package/clis/wikipedia/trending.test.js +57 -0
- package/clis/xianyu/chat.js +24 -109
- package/clis/xianyu/chat.test.js +5 -0
- package/clis/xianyu/im.js +322 -0
- package/clis/xianyu/im.test.js +253 -0
- package/clis/xianyu/inbox.js +96 -0
- package/clis/xianyu/messages.js +91 -0
- package/clis/xianyu/reply.js +82 -0
- package/clis/xiaohongshu/creator-note-detail.js +166 -28
- package/clis/xiaohongshu/creator-note-detail.test.js +196 -36
- package/clis/xiaohongshu/creator-notes-summary.js +2 -1
- package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
- package/clis/xiaohongshu/creator-notes.js +252 -2
- package/clis/xiaohongshu/creator-notes.test.js +90 -1
- package/clis/xiaohongshu/creator-stats.js +2 -1
- package/clis/xiaohongshu/creator-stats.test.js +24 -0
- package/clis/xiaohongshu/delete-note.js +260 -0
- package/clis/xiaohongshu/delete-note.test.js +172 -0
- package/clis/xiaohongshu/download.js +97 -39
- package/clis/xiaohongshu/download.test.js +201 -0
- package/clis/xiaohongshu/publish.js +48 -8
- package/clis/xiaohongshu/publish.test.js +65 -10
- package/clis/xiaohongshu/user-helpers.test.js +41 -0
- package/clis/xiaohongshu/user.js +27 -4
- package/clis/xiaoyuzhou/download.js +1 -1
- package/clis/xiaoyuzhou/transcript.js +1 -1
- package/clis/youdao/note.js +258 -0
- package/clis/youdao/note.test.js +99 -0
- package/clis/youtube/transcript.js +397 -24
- package/clis/youtube/transcript.test.js +196 -6
- package/clis/zhihu/answer-comments.js +280 -0
- package/clis/zhihu/answer-comments.test.js +287 -0
- package/clis/zhihu/answer-detail.js +2 -19
- package/clis/zhihu/answer-detail.test.js +8 -0
- package/clis/zhihu/collection.js +17 -16
- package/clis/zhihu/collection.test.js +50 -3
- package/clis/zhihu/download.js +1 -1
- package/clis/zhihu/question.js +42 -17
- package/clis/zhihu/question.test.js +113 -11
- package/clis/zhihu/search.js +195 -43
- package/clis/zhihu/search.test.js +198 -0
- package/clis/zhihu/text.js +29 -0
- package/clis/zhihu/text.test.js +24 -0
- package/dist/src/browser/errors.js +4 -2
- package/dist/src/browser/errors.test.js +6 -0
- package/dist/src/browser/network-cache.js +13 -1
- package/dist/src/browser/network-cache.test.js +17 -0
- package/dist/src/browser/page.js +30 -4
- package/dist/src/browser/page.test.js +42 -0
- package/dist/src/browser/utils.d.ts +1 -1
- package/dist/src/cli-argv-preprocess.d.ts +26 -0
- package/dist/src/cli-argv-preprocess.js +138 -0
- package/dist/src/cli-argv-preprocess.test.js +79 -0
- package/dist/src/convention-audit.js +15 -8
- package/dist/src/convention-audit.test.js +21 -0
- package/dist/src/download/index.js +13 -1
- package/dist/src/download/index.test.js +23 -1
- package/dist/src/download/media-download.js +15 -2
- package/dist/src/download/media-download.test.d.ts +1 -0
- package/dist/src/download/media-download.test.js +112 -0
- package/dist/src/download/progress.js +2 -2
- package/dist/src/download/progress.test.js +12 -1
- package/dist/src/electron-apps.js +1 -1
- package/dist/src/electron-apps.test.js +7 -2
- package/dist/src/errors.d.ts +17 -0
- package/dist/src/errors.js +22 -0
- package/dist/src/external-clis.yaml +8 -0
- package/dist/src/main.js +14 -2
- package/dist/src/output.js +11 -1
- package/dist/src/output.test.js +6 -0
- package/dist/src/registry.js +1 -0
- package/dist/src/registry.test.js +11 -0
- package/dist/src/utils.d.ts +43 -0
- package/dist/src/utils.js +97 -0
- package/dist/src/utils.test.d.ts +1 -0
- package/dist/src/utils.test.js +155 -0
- package/package.json +8 -2
- package/scripts/silent-column-drop-baseline.json +0 -52
- package/scripts/typed-error-lint-baseline.json +28 -380
- package/clis/slock/_utils.js +0 -12
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
|
|
4
|
+
const LINKEDIN_DOMAIN = 'www.linkedin.com';
|
|
5
|
+
const SALES_HOME = 'https://www.linkedin.com/sales/';
|
|
6
|
+
const PROFILE_DECO = '(entityUrn,objectUrn,firstName,lastName,fullName,headline,degree,inmailRestriction,memberBadges,defaultPosition)';
|
|
7
|
+
const CREDITS_URL = 'https://www.linkedin.com/sales-api/salesApiCredits?q=findCreditGrant&creditGrantType=LSS_INMAIL';
|
|
8
|
+
const MESSAGE_ACTION_URL = 'https://www.linkedin.com/sales-api/salesApiMessageActions?action=createMessage';
|
|
9
|
+
|
|
10
|
+
function normalizeWhitespace(value) {
|
|
11
|
+
return String(value ?? '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function requireStringArg(args, key, label = key) {
|
|
15
|
+
const value = normalizeWhitespace(args[key]);
|
|
16
|
+
if (!value) throw new ArgumentError(`${label} is required`);
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function unwrapEvaluateResult(payload) {
|
|
21
|
+
if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
|
|
22
|
+
return payload;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isLinkedInHost(hostname) {
|
|
26
|
+
const host = String(hostname || '').toLowerCase();
|
|
27
|
+
return host === 'linkedin.com' || host.endsWith('.linkedin.com');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseSalesProfileUrn(value) {
|
|
31
|
+
const raw = normalizeWhitespace(value);
|
|
32
|
+
const match = raw.match(/^urn:li:fs_salesProfile:\(([^,()]+),([^,()]+),([^,()]+)\)$/);
|
|
33
|
+
if (!match) return null;
|
|
34
|
+
if (!isResolvedSalesProfileParts(match[1], match[2], match[3])) return null;
|
|
35
|
+
return { profileId: match[1], authType: match[2], authToken: match[3], entityUrn: raw };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isResolvedSalesProfileParts(profileId, authType, authToken) {
|
|
39
|
+
return [profileId, authType, authToken].every((part) => {
|
|
40
|
+
const clean = normalizeWhitespace(part).toLowerCase();
|
|
41
|
+
return clean && clean !== 'undefined' && clean !== 'null' && clean !== 'not_available';
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function salesLeadUrlFromParts({ profileId, authType, authToken }) {
|
|
46
|
+
return `https://www.linkedin.com/sales/lead/${encodeURIComponent(profileId)},${encodeURIComponent(authType)},${encodeURIComponent(authToken)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseRecipient(value) {
|
|
50
|
+
const raw = normalizeWhitespace(value);
|
|
51
|
+
const urn = parseSalesProfileUrn(raw);
|
|
52
|
+
if (urn) return urn;
|
|
53
|
+
try {
|
|
54
|
+
const url = new URL(raw);
|
|
55
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.port || !isLinkedInHost(url.hostname)) return null;
|
|
56
|
+
const salesMatch = url.pathname.match(/^\/sales\/lead\/([^,/]+),([^,/]+),([^/]+)\/?$/i);
|
|
57
|
+
if (salesMatch) {
|
|
58
|
+
const profileId = decodeURIComponent(salesMatch[1]);
|
|
59
|
+
const authType = decodeURIComponent(salesMatch[2]);
|
|
60
|
+
const authToken = decodeURIComponent(salesMatch[3]);
|
|
61
|
+
if (!isResolvedSalesProfileParts(profileId, authType, authToken)) return null;
|
|
62
|
+
return { profileId, authType, authToken, entityUrn: `urn:li:fs_salesProfile:(${profileId},${authType},${authToken})` };
|
|
63
|
+
}
|
|
64
|
+
const profileMatch = url.pathname.match(/^\/in\/([^/]+)\/?$/i);
|
|
65
|
+
if (profileMatch) {
|
|
66
|
+
return { profileId: decodeURIComponent(profileMatch[1]), authType: '', authToken: '', entityUrn: '' };
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function encodeRestliDecoration(value) {
|
|
75
|
+
return encodeURIComponent(value).replace(/\(/g, '%28').replace(/\)/g, '%29');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function profileApiUrl(recipient) {
|
|
79
|
+
if (!recipient?.profileId || !recipient?.authType || !recipient?.authToken) return '';
|
|
80
|
+
const key = `(profileId:${recipient.profileId},authType:${recipient.authType},authToken:${recipient.authToken})`;
|
|
81
|
+
return `https://www.linkedin.com/sales-api/salesApiProfiles/${key}?decoration=${encodeRestliDecoration(PROFILE_DECO)}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function randomTrackingId() {
|
|
85
|
+
const bytes = new Uint8Array(8);
|
|
86
|
+
if (globalThis.crypto?.getRandomValues) globalThis.crypto.getRandomValues(bytes);
|
|
87
|
+
else for (let i = 0; i < bytes.length; i += 1) bytes[i] = Math.floor(Math.random() * 256);
|
|
88
|
+
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildCreateMessagePayload({ recipientUrn, subject, body, trackingId = randomTrackingId(), copyToCrm = false }) {
|
|
92
|
+
const cleanRecipient = normalizeWhitespace(recipientUrn);
|
|
93
|
+
if (!parseSalesProfileUrn(cleanRecipient)) throw new ArgumentError('--recipient must resolve to a Sales Navigator lead urn');
|
|
94
|
+
const cleanSubject = normalizeWhitespace(subject);
|
|
95
|
+
const cleanBody = String(body ?? '').trim();
|
|
96
|
+
if (!cleanSubject) throw new ArgumentError('--subject is required');
|
|
97
|
+
if (!cleanBody) throw new ArgumentError('--body is required');
|
|
98
|
+
if (cleanSubject.length > 200) throw new ArgumentError('--subject must be 200 characters or fewer');
|
|
99
|
+
if (cleanBody.length > 1900) throw new ArgumentError('--body must be 1900 characters or fewer');
|
|
100
|
+
return {
|
|
101
|
+
createMessageRequest: {
|
|
102
|
+
recipients: [cleanRecipient],
|
|
103
|
+
subject: cleanSubject,
|
|
104
|
+
body: cleanBody,
|
|
105
|
+
copyToCrm: Boolean(copyToCrm),
|
|
106
|
+
trackingId,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function extractRemainingCredits(json) {
|
|
112
|
+
const elements = Array.isArray(json?.elements) ? json.elements : [];
|
|
113
|
+
const inmailGrant = elements.find((el) => el?.type === 'LSS_INMAIL' && Number.isInteger(el.value));
|
|
114
|
+
if (inmailGrant) return inmailGrant.value;
|
|
115
|
+
const candidates = [];
|
|
116
|
+
const visit = (value) => {
|
|
117
|
+
if (value === null || value === undefined) return;
|
|
118
|
+
if (typeof value === 'number' && Number.isFinite(value)) candidates.push(value);
|
|
119
|
+
if (Array.isArray(value)) value.forEach(visit);
|
|
120
|
+
else if (typeof value === 'object') {
|
|
121
|
+
for (const [key, child] of Object.entries(value)) {
|
|
122
|
+
if (/remaining|available|balance|value/i.test(key) && typeof child === 'number') candidates.unshift(child);
|
|
123
|
+
else if (!/^count$|^start$|^id$/i.test(key)) visit(child);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
visit(json);
|
|
128
|
+
return candidates.find((n) => Number.isInteger(n) && n >= 0) ?? null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function fetchJsonScript(url, csrf, options = {}) {
|
|
132
|
+
return String.raw`(async () => {
|
|
133
|
+
const headers = {
|
|
134
|
+
'csrf-token': ${JSON.stringify(csrf)},
|
|
135
|
+
'x-restli-protocol-version': '2.0.0',
|
|
136
|
+
accept: ${JSON.stringify(options.accept || 'application/json')},
|
|
137
|
+
...((${JSON.stringify(Boolean(options.body))}) ? { 'content-type': 'application/json' } : {}),
|
|
138
|
+
};
|
|
139
|
+
try {
|
|
140
|
+
const res = await fetch(${JSON.stringify(url)}, {
|
|
141
|
+
credentials: 'include',
|
|
142
|
+
method: ${JSON.stringify(options.method || 'GET')},
|
|
143
|
+
headers,
|
|
144
|
+
body: ${options.body ? JSON.stringify(JSON.stringify(options.body)) : 'undefined'},
|
|
145
|
+
});
|
|
146
|
+
const text = await res.text();
|
|
147
|
+
let json = null;
|
|
148
|
+
try { json = text ? JSON.parse(text) : null; } catch (_) { json = null; }
|
|
149
|
+
if (res.status === 401 || res.status === 403) return ['auth', res.status, json, text];
|
|
150
|
+
if (!res.ok) return ['error', res.status, json, text, 'HTTP ' + res.status];
|
|
151
|
+
return ['ok', res.status, json, text];
|
|
152
|
+
} catch (e) {
|
|
153
|
+
return ['error', 0, null, '', 'fetch failed: ' + ((e && e.message) || String(e))];
|
|
154
|
+
}
|
|
155
|
+
})()`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function requireFetchResult(result, label, { requireJson = true } = {}) {
|
|
159
|
+
if (Array.isArray(result)) {
|
|
160
|
+
const [kind, status, json, text, error] = result;
|
|
161
|
+
result = {
|
|
162
|
+
authRequired: kind === 'auth',
|
|
163
|
+
error: kind === 'error' ? error || `HTTP ${status}` : '',
|
|
164
|
+
status,
|
|
165
|
+
json,
|
|
166
|
+
text,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
if (result?.authRequired) throw new AuthRequiredError(LINKEDIN_DOMAIN, `${label} auth failed.`);
|
|
170
|
+
if (result?.error) throw new CommandExecutionError(`${label} failed`, result.error);
|
|
171
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
|
172
|
+
throw new CommandExecutionError(`${label} returned malformed response`);
|
|
173
|
+
}
|
|
174
|
+
if (requireJson && (!result.json || typeof result.json !== 'object' || Array.isArray(result.json))) {
|
|
175
|
+
throw new CommandExecutionError(`${label} returned malformed response`, 'missing_json');
|
|
176
|
+
}
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function salesPageShowsSentMessage(text, recipientName) {
|
|
181
|
+
const normalizedText = normalizeWhitespace(text);
|
|
182
|
+
const firstName = normalizeWhitespace(recipientName).split(' ')[0];
|
|
183
|
+
return normalizedText.includes('You sent a Sales Navigator message')
|
|
184
|
+
&& (!firstName || normalizedText.includes(firstName));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function getCsrf(page) {
|
|
188
|
+
const cookies = await page.getCookies({ url: 'https://www.linkedin.com' });
|
|
189
|
+
const jsession = cookies.find((c) => c.name === 'JSESSIONID')?.value;
|
|
190
|
+
if (!jsession) throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn JSESSIONID cookie not found. Please sign in to LinkedIn.');
|
|
191
|
+
return jsession.replace(/^\"|\"$/g, '');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function resolveRecipient(page, parsed, csrf) {
|
|
195
|
+
if (!parsed) throw new ArgumentError('--recipient must be a Sales Navigator lead URL, Sales Navigator profile URL, LinkedIn /in/ URL, or urn:li:fs_salesProfile:(...)');
|
|
196
|
+
if (parsed.entityUrn && parsed.authType && parsed.authToken) return parsed;
|
|
197
|
+
|
|
198
|
+
await page.goto(`https://www.linkedin.com/sales/lead/${encodeURIComponent(parsed.profileId)}`);
|
|
199
|
+
await page.wait(6);
|
|
200
|
+
const probe = unwrapEvaluateResult(await page.evaluate(String.raw`(() => {
|
|
201
|
+
const href = location.href;
|
|
202
|
+
const text = document.body ? document.body.innerText : '';
|
|
203
|
+
const resourceUrns = Array.from(performance.getEntriesByType('resource'))
|
|
204
|
+
.map((entry) => entry.name)
|
|
205
|
+
.filter((name) => name.includes('/sales-api/salesApiProfiles/'))
|
|
206
|
+
.slice(-20);
|
|
207
|
+
return { href, text: text.slice(0, 1000), resourceUrns };
|
|
208
|
+
})()`));
|
|
209
|
+
const urlMatch = String(probe?.href || '').match(/\/sales\/lead\/([^,/]+),([^,/]+),([^/?#]+)/i);
|
|
210
|
+
if (urlMatch && isResolvedSalesProfileParts(urlMatch[1], urlMatch[2], urlMatch[3])) {
|
|
211
|
+
return {
|
|
212
|
+
profileId: decodeURIComponent(urlMatch[1]),
|
|
213
|
+
authType: decodeURIComponent(urlMatch[2]),
|
|
214
|
+
authToken: decodeURIComponent(urlMatch[3]),
|
|
215
|
+
entityUrn: `urn:li:fs_salesProfile:(${decodeURIComponent(urlMatch[1])},${decodeURIComponent(urlMatch[2])},${decodeURIComponent(urlMatch[3])})`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
for (const resource of probe?.resourceUrns || []) {
|
|
219
|
+
const resourceMatch = String(resource).match(/profileId:([^,)]+),authType:([^,)]+),authToken:([^,)]+)\)/);
|
|
220
|
+
if (resourceMatch && resourceMatch[1] === parsed.profileId) {
|
|
221
|
+
return {
|
|
222
|
+
profileId: resourceMatch[1],
|
|
223
|
+
authType: resourceMatch[2],
|
|
224
|
+
authToken: resourceMatch[3],
|
|
225
|
+
entityUrn: `urn:li:fs_salesProfile:(${resourceMatch[1]},${resourceMatch[2]},${resourceMatch[3]})`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
void csrf;
|
|
230
|
+
throw new CommandExecutionError('Could not resolve Sales Navigator auth token for recipient', `Observed URL: ${probe?.href || 'url_not_available'}\nBody: ${normalizeWhitespace(probe?.text || '').slice(0, 500)}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function profileSummary(json) {
|
|
234
|
+
const data = json?.data || json || {};
|
|
235
|
+
const pos = data.defaultPosition || (Array.isArray(data.positions) ? data.positions.find((p) => p.current) || data.positions[0] : {}) || {};
|
|
236
|
+
return {
|
|
237
|
+
recipient: normalizeWhitespace(data.fullName || [data.firstName, data.lastName].filter(Boolean).join(' ')),
|
|
238
|
+
title: normalizeWhitespace(pos.title || data.headline || ''),
|
|
239
|
+
company: normalizeWhitespace(pos.companyName || pos.company?.name || ''),
|
|
240
|
+
degree: normalizeWhitespace(data.degree || ''),
|
|
241
|
+
inmail_restriction: normalizeWhitespace(data.inmailRestriction || ''),
|
|
242
|
+
open_link: Boolean(data.memberBadges?.openLink),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function requireProfileSummary(json) {
|
|
247
|
+
const summary = profileSummary(json);
|
|
248
|
+
if (!summary.recipient) {
|
|
249
|
+
throw new CommandExecutionError('Sales Navigator profile lookup returned malformed profile data', 'missing_recipient_name');
|
|
250
|
+
}
|
|
251
|
+
return summary;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
cli({
|
|
255
|
+
site: 'linkedin',
|
|
256
|
+
name: 'salesnav-message',
|
|
257
|
+
access: 'write',
|
|
258
|
+
description: 'Send or dry-run a LinkedIn Sales Navigator InMail to a lead using the Sales Navigator messaging API',
|
|
259
|
+
domain: LINKEDIN_DOMAIN,
|
|
260
|
+
strategy: Strategy.UI,
|
|
261
|
+
browser: true,
|
|
262
|
+
args: [
|
|
263
|
+
{ name: 'recipient', type: 'string', required: true, positional: true, help: 'Sales Navigator lead URL, LinkedIn /in/ URL from salesnav-search, or urn:li:fs_salesProfile:(...)' },
|
|
264
|
+
{ name: 'subject', type: 'string', required: true, help: 'InMail subject' },
|
|
265
|
+
{ name: 'body', type: 'string', required: true, help: 'InMail body' },
|
|
266
|
+
{ name: 'send', type: 'bool', default: false, help: 'Actually send the InMail. Default is dry-run validation only.' },
|
|
267
|
+
{ name: 'copy-to-crm', type: 'bool', default: false, help: 'Set Sales Navigator copyToCrm on the message request' },
|
|
268
|
+
],
|
|
269
|
+
columns: ['status', 'recipient', 'title', 'company', 'credits_remaining', 'credits_before', 'credits_after', 'sent_in_salesnav', 'message_chars', 'subject_chars', 'recipient_urn', 'degree', 'inmail_restriction', 'open_link'],
|
|
270
|
+
func: async (page, args) => {
|
|
271
|
+
if (!page) throw new CommandExecutionError('Browser session required for linkedin salesnav-message');
|
|
272
|
+
const recipientArg = requireStringArg(args, 'recipient', '--recipient');
|
|
273
|
+
const subject = requireStringArg(args, 'subject', '--subject');
|
|
274
|
+
const body = String(args.body ?? '').trim();
|
|
275
|
+
if (!body) throw new ArgumentError('--body is required');
|
|
276
|
+
|
|
277
|
+
await page.goto(SALES_HOME);
|
|
278
|
+
await page.wait(4);
|
|
279
|
+
const csrf = await getCsrf(page);
|
|
280
|
+
const recipient = await resolveRecipient(page, parseRecipient(recipientArg), csrf);
|
|
281
|
+
|
|
282
|
+
let summary = { recipient: '', title: '', company: '', degree: '', inmail_restriction: '', open_link: false };
|
|
283
|
+
const profileUrl = profileApiUrl(recipient);
|
|
284
|
+
if (profileUrl) {
|
|
285
|
+
const profileResult = requireFetchResult(unwrapEvaluateResult(await page.evaluate(fetchJsonScript(profileUrl, csrf))), 'LinkedIn Sales Navigator profile API');
|
|
286
|
+
summary = requireProfileSummary(profileResult.json);
|
|
287
|
+
}
|
|
288
|
+
if (summary.inmail_restriction && summary.inmail_restriction !== 'NO_RESTRICTION') {
|
|
289
|
+
throw new CommandExecutionError('Sales Navigator InMail blocked by recipient restriction', summary.inmail_restriction);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const creditsResult = requireFetchResult(unwrapEvaluateResult(await page.evaluate(fetchJsonScript(CREDITS_URL, csrf))), 'LinkedIn Sales Navigator credits API');
|
|
293
|
+
const creditsRemaining = extractRemainingCredits(creditsResult?.json);
|
|
294
|
+
|
|
295
|
+
const payload = buildCreateMessagePayload({ recipientUrn: recipient.entityUrn, subject, body, copyToCrm: args['copy-to-crm'] });
|
|
296
|
+
if (!args.send) {
|
|
297
|
+
return [{
|
|
298
|
+
status: 'validated_dry_run',
|
|
299
|
+
recipient: summary.recipient,
|
|
300
|
+
title: summary.title,
|
|
301
|
+
company: summary.company,
|
|
302
|
+
credits_remaining: creditsRemaining,
|
|
303
|
+
credits_before: creditsRemaining,
|
|
304
|
+
credits_after: '',
|
|
305
|
+
sent_in_salesnav: false,
|
|
306
|
+
message_chars: body.length,
|
|
307
|
+
subject_chars: subject.length,
|
|
308
|
+
recipient_urn: recipient.entityUrn,
|
|
309
|
+
degree: summary.degree,
|
|
310
|
+
inmail_restriction: summary.inmail_restriction,
|
|
311
|
+
open_link: summary.open_link,
|
|
312
|
+
}];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const sendResult = requireFetchResult(unwrapEvaluateResult(await page.evaluate(fetchJsonScript(MESSAGE_ACTION_URL, csrf, {
|
|
316
|
+
method: 'POST',
|
|
317
|
+
accept: 'application/vnd.linkedin.normalized+json+2.1',
|
|
318
|
+
body: payload,
|
|
319
|
+
}))), 'LinkedIn Sales Navigator message API', { requireJson: false });
|
|
320
|
+
void sendResult;
|
|
321
|
+
await page.wait(3);
|
|
322
|
+
const creditsAfterResult = requireFetchResult(unwrapEvaluateResult(await page.evaluate(fetchJsonScript(CREDITS_URL, csrf))), 'LinkedIn Sales Navigator credits API after send');
|
|
323
|
+
const creditsAfter = extractRemainingCredits(creditsAfterResult?.json);
|
|
324
|
+
await page.goto(salesLeadUrlFromParts(recipient));
|
|
325
|
+
await page.wait(6);
|
|
326
|
+
const salesPageText = unwrapEvaluateResult(await page.evaluate('document.body ? document.body.innerText : ""'));
|
|
327
|
+
const sentInSalesNav = salesPageShowsSentMessage(salesPageText, summary.recipient);
|
|
328
|
+
if (!sentInSalesNav) throw new CommandExecutionError('Sales Navigator post-send verification failed', 'Sent activity was not found on the Sales Navigator lead page.');
|
|
329
|
+
return [{
|
|
330
|
+
status: 'sent',
|
|
331
|
+
recipient: summary.recipient,
|
|
332
|
+
title: summary.title,
|
|
333
|
+
company: summary.company,
|
|
334
|
+
credits_remaining: creditsAfter,
|
|
335
|
+
credits_before: creditsRemaining,
|
|
336
|
+
credits_after: creditsAfter,
|
|
337
|
+
sent_in_salesnav: sentInSalesNav,
|
|
338
|
+
message_chars: body.length,
|
|
339
|
+
subject_chars: subject.length,
|
|
340
|
+
recipient_urn: recipient.entityUrn,
|
|
341
|
+
degree: summary.degree,
|
|
342
|
+
inmail_restriction: summary.inmail_restriction,
|
|
343
|
+
open_link: summary.open_link,
|
|
344
|
+
}];
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
export const __test__ = {
|
|
349
|
+
normalizeWhitespace,
|
|
350
|
+
parseSalesProfileUrn,
|
|
351
|
+
isResolvedSalesProfileParts,
|
|
352
|
+
parseRecipient,
|
|
353
|
+
salesLeadUrlFromParts,
|
|
354
|
+
profileApiUrl,
|
|
355
|
+
buildCreateMessagePayload,
|
|
356
|
+
extractRemainingCredits,
|
|
357
|
+
profileSummary,
|
|
358
|
+
requireProfileSummary,
|
|
359
|
+
salesPageShowsSentMessage,
|
|
360
|
+
};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
4
|
+
import './salesnav-message.js';
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
parseSalesProfileUrn,
|
|
8
|
+
parseRecipient,
|
|
9
|
+
salesLeadUrlFromParts,
|
|
10
|
+
profileApiUrl,
|
|
11
|
+
buildCreateMessagePayload,
|
|
12
|
+
extractRemainingCredits,
|
|
13
|
+
profileSummary,
|
|
14
|
+
requireProfileSummary,
|
|
15
|
+
salesPageShowsSentMessage,
|
|
16
|
+
} = await import('./salesnav-message.js').then((m) => m.__test__);
|
|
17
|
+
|
|
18
|
+
function createPageMock(evaluateResults = []) {
|
|
19
|
+
const evaluate = vi.fn();
|
|
20
|
+
for (const result of evaluateResults) evaluate.mockResolvedValueOnce(result);
|
|
21
|
+
evaluate.mockResolvedValue(undefined);
|
|
22
|
+
return {
|
|
23
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
24
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
evaluate,
|
|
26
|
+
getCookies: vi.fn().mockResolvedValue([{ name: 'JSESSIONID', value: '"csrf"', domain: '.linkedin.com' }]),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('linkedin salesnav-message command', () => {
|
|
31
|
+
it('parses Sales Navigator profile urns and lead URLs', () => {
|
|
32
|
+
const urn = 'urn:li:fs_salesProfile:(ACwAAAJS8TABxyz,NAME_SEARCH,Enlo)';
|
|
33
|
+
expect(parseSalesProfileUrn(urn)).toMatchObject({
|
|
34
|
+
profileId: 'ACwAAAJS8TABxyz',
|
|
35
|
+
authType: 'NAME_SEARCH',
|
|
36
|
+
authToken: 'Enlo',
|
|
37
|
+
entityUrn: urn,
|
|
38
|
+
});
|
|
39
|
+
expect(parseSalesProfileUrn('urn:li:fs_salesProfile:(ACwAAAJS8TABxyz,undefined,undefined)')).toBeNull();
|
|
40
|
+
|
|
41
|
+
const parsed = parseRecipient('https://www.linkedin.com/sales/lead/ACwAAAJS8TABxyz,NAME_SEARCH,Enlo');
|
|
42
|
+
expect(parsed).toMatchObject({ profileId: 'ACwAAAJS8TABxyz', authType: 'NAME_SEARCH', authToken: 'Enlo' });
|
|
43
|
+
expect(parsed.entityUrn).toBe(urn);
|
|
44
|
+
expect(salesLeadUrlFromParts(parsed)).toBe('https://www.linkedin.com/sales/lead/ACwAAAJS8TABxyz,NAME_SEARCH,Enlo');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('accepts LinkedIn /in tokens as unresolved recipients', () => {
|
|
48
|
+
expect(parseRecipient('https://www.linkedin.com/in/ACwAAAJS8TABxyz/')).toMatchObject({
|
|
49
|
+
profileId: 'ACwAAAJS8TABxyz',
|
|
50
|
+
authType: '',
|
|
51
|
+
authToken: '',
|
|
52
|
+
entityUrn: '',
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('builds profile API URLs with the Sales Navigator auth key', () => {
|
|
57
|
+
const url = profileApiUrl({ profileId: 'P1', authType: 'NAME_SEARCH', authToken: 'T1' });
|
|
58
|
+
expect(url).toContain('/sales-api/salesApiProfiles/(profileId:P1,authType:NAME_SEARCH,authToken:T1)');
|
|
59
|
+
expect(url).toContain('decoration=');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('constructs the createMessage action payload used by Sales Navigator', () => {
|
|
63
|
+
const payload = buildCreateMessagePayload({
|
|
64
|
+
recipientUrn: 'urn:li:fs_salesProfile:(P1,NAME_SEARCH,T1)',
|
|
65
|
+
subject: 'Quick QA doc question',
|
|
66
|
+
body: 'Hi Jane, can I ask a quick question?',
|
|
67
|
+
trackingId: '0123456789abcdef',
|
|
68
|
+
copyToCrm: false,
|
|
69
|
+
});
|
|
70
|
+
expect(payload).toEqual({
|
|
71
|
+
createMessageRequest: {
|
|
72
|
+
recipients: ['urn:li:fs_salesProfile:(P1,NAME_SEARCH,T1)'],
|
|
73
|
+
subject: 'Quick QA doc question',
|
|
74
|
+
body: 'Hi Jane, can I ask a quick question?',
|
|
75
|
+
copyToCrm: false,
|
|
76
|
+
trackingId: '0123456789abcdef',
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('validates payload fields before any send attempt', () => {
|
|
82
|
+
expect(() => buildCreateMessagePayload({ recipientUrn: '', subject: 's', body: 'b' })).toThrow();
|
|
83
|
+
expect(() => buildCreateMessagePayload({ recipientUrn: 'urn:li:fs_salesProfile:(P,A,T)', subject: '', body: 'b' })).toThrow();
|
|
84
|
+
expect(() => buildCreateMessagePayload({ recipientUrn: 'urn:li:fs_salesProfile:(P,A,T)', subject: 's', body: '' })).toThrow();
|
|
85
|
+
expect(() => buildCreateMessagePayload({ recipientUrn: 'urn:li:fs_salesProfile:(P,A,T)', subject: 'x'.repeat(201), body: 'b' })).toThrow();
|
|
86
|
+
expect(() => buildCreateMessagePayload({ recipientUrn: 'urn:li:fs_salesProfile:(P,undefined,undefined)', subject: 's', body: 'b' })).toThrow();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('detects the Sales Navigator sent activity on a verified lead page', () => {
|
|
90
|
+
expect(salesPageShowsSentMessage('5/18/2026 You sent a Sales Navigator message to Jane', 'Jane Q')).toBe(true);
|
|
91
|
+
expect(salesPageShowsSentMessage('No recent activity', 'Jane Q')).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('extracts a plausible remaining InMail credit count', () => {
|
|
95
|
+
expect(extractRemainingCredits({ elements: [{ type: 'LSS_INMAIL', value: 149, id: 1 }], paging: { count: 10 } })).toBe(149);
|
|
96
|
+
expect(extractRemainingCredits({ data: { remaining: 149, used: 1 } })).toBe(149);
|
|
97
|
+
expect(extractRemainingCredits({ elements: [{ availableCount: 12 }] })).toBe(12);
|
|
98
|
+
expect(extractRemainingCredits({})).toBe(null);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('summarizes decorated Sales Navigator profile data', () => {
|
|
102
|
+
expect(profileSummary({ data: {
|
|
103
|
+
fullName: 'Rayki Goh',
|
|
104
|
+
headline: 'Food Safety',
|
|
105
|
+
degree: 3,
|
|
106
|
+
defaultPosition: { title: 'FSQA Manager', companyName: 'Acme Foods' },
|
|
107
|
+
memberBadges: { openLink: false },
|
|
108
|
+
} })).toMatchObject({
|
|
109
|
+
recipient: 'Rayki Goh',
|
|
110
|
+
title: 'FSQA Manager',
|
|
111
|
+
company: 'Acme Foods',
|
|
112
|
+
degree: '3',
|
|
113
|
+
open_link: false,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('fails typed when decorated profile data has no recipient identity', () => {
|
|
118
|
+
expect(() => requireProfileSummary({ data: { defaultPosition: { title: 'FSQA' } } }))
|
|
119
|
+
.toThrow(CommandExecutionError);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('keeps manifest columns aligned with dry-run rows and fails typed on malformed profile API', async () => {
|
|
123
|
+
const cmd = getRegistry().get('linkedin/salesnav-message');
|
|
124
|
+
expect(cmd?.columns).toEqual([
|
|
125
|
+
'status',
|
|
126
|
+
'recipient',
|
|
127
|
+
'title',
|
|
128
|
+
'company',
|
|
129
|
+
'credits_remaining',
|
|
130
|
+
'credits_before',
|
|
131
|
+
'credits_after',
|
|
132
|
+
'sent_in_salesnav',
|
|
133
|
+
'message_chars',
|
|
134
|
+
'subject_chars',
|
|
135
|
+
'recipient_urn',
|
|
136
|
+
'degree',
|
|
137
|
+
'inmail_restriction',
|
|
138
|
+
'open_link',
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
const goodPage = createPageMock([
|
|
142
|
+
{ status: 200, json: { data: { fullName: 'Jane Doe', defaultPosition: { title: 'QA', companyName: 'Acme' }, degree: 2, inmailRestriction: 'NO_RESTRICTION', memberBadges: { openLink: true } } } },
|
|
143
|
+
{ status: 200, json: { elements: [{ type: 'LSS_INMAIL', value: 12 }] } },
|
|
144
|
+
]);
|
|
145
|
+
const rows = await cmd.func(goodPage, {
|
|
146
|
+
recipient: 'urn:li:fs_salesProfile:(P1,NAME_SEARCH,T1)',
|
|
147
|
+
subject: 'Hello',
|
|
148
|
+
body: 'Quick question',
|
|
149
|
+
});
|
|
150
|
+
expect(Object.keys(rows[0]).sort()).toEqual([...cmd.columns].sort());
|
|
151
|
+
expect(rows[0]).toMatchObject({
|
|
152
|
+
status: 'validated_dry_run',
|
|
153
|
+
recipient: 'Jane Doe',
|
|
154
|
+
credits_remaining: 12,
|
|
155
|
+
credits_before: 12,
|
|
156
|
+
credits_after: '',
|
|
157
|
+
sent_in_salesnav: false,
|
|
158
|
+
degree: '2',
|
|
159
|
+
inmail_restriction: 'NO_RESTRICTION',
|
|
160
|
+
open_link: true,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const malformedPage = createPageMock([
|
|
164
|
+
{ status: 200, json: { data: { defaultPosition: { title: 'QA' } } } },
|
|
165
|
+
]);
|
|
166
|
+
await expect(cmd.func(malformedPage, {
|
|
167
|
+
recipient: 'urn:li:fs_salesProfile:(P1,NAME_SEARCH,T1)',
|
|
168
|
+
subject: 'Hello',
|
|
169
|
+
body: 'Quick question',
|
|
170
|
+
})).rejects.toThrow(CommandExecutionError);
|
|
171
|
+
});
|
|
172
|
+
});
|