@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.
Files changed (346) hide show
  1. package/README.md +35 -194
  2. package/README.zh-CN.md +42 -260
  3. package/cli-manifest.json +8160 -4392
  4. package/clis/12306/me.js +73 -0
  5. package/clis/12306/orders.js +96 -0
  6. package/clis/12306/passengers.js +90 -0
  7. package/clis/12306/price.js +166 -0
  8. package/clis/12306/stations.js +66 -0
  9. package/clis/12306/train.js +91 -0
  10. package/clis/12306/trains.js +119 -0
  11. package/clis/12306/utils.js +272 -0
  12. package/clis/12306/utils.test.js +331 -0
  13. package/clis/36kr/article.js +6 -3
  14. package/clis/36kr/article.test.js +46 -0
  15. package/clis/_atlassian/shared.js +577 -0
  16. package/clis/_atlassian/shared.test.js +170 -0
  17. package/clis/apple-podcasts/commands.test.js +20 -0
  18. package/clis/apple-podcasts/search.js +2 -2
  19. package/clis/barchart/greeks.js +144 -56
  20. package/clis/barchart/greeks.test.js +138 -0
  21. package/clis/bilibili/comment.js +125 -0
  22. package/clis/bilibili/comment.test.js +153 -0
  23. package/clis/bilibili/comments.js +116 -21
  24. package/clis/bilibili/comments.test.js +77 -18
  25. package/clis/bilibili/subtitle.js +76 -31
  26. package/clis/bilibili/subtitle.test.js +156 -9
  27. package/clis/bilibili/summary.js +167 -0
  28. package/clis/bilibili/summary.test.js +210 -0
  29. package/clis/bilibili/utils.js +63 -5
  30. package/clis/bilibili/utils.test.js +45 -1
  31. package/clis/booking/booking.test.js +356 -0
  32. package/clis/booking/search.js +351 -0
  33. package/clis/chatgpt/envelope.test.js +108 -0
  34. package/clis/chatgpt/image.js +2 -2
  35. package/clis/chatgpt/image.test.js +6 -0
  36. package/clis/chatgpt/utils.js +148 -41
  37. package/clis/chatgpt/utils.test.js +92 -2
  38. package/clis/chess/analyze.js +35 -0
  39. package/clis/chess/analyze.test.js +79 -0
  40. package/clis/chess/game.js +114 -0
  41. package/clis/chess/game.test.js +178 -0
  42. package/clis/chess/games.js +67 -0
  43. package/clis/chess/games.test.js +164 -0
  44. package/clis/chess/stats.js +32 -0
  45. package/clis/chess/stats.test.js +79 -0
  46. package/clis/chess/utils.js +170 -0
  47. package/clis/chess/utils.test.js +230 -0
  48. package/clis/confluence/commands.test.js +195 -0
  49. package/clis/confluence/create.js +39 -0
  50. package/clis/confluence/page.js +23 -0
  51. package/clis/confluence/search.js +34 -0
  52. package/clis/confluence/shared.js +173 -0
  53. package/clis/confluence/update.js +38 -0
  54. package/clis/douyin/_shared/browser-fetch.js +44 -20
  55. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  56. package/clis/douyin/_shared/evaluate-result.js +16 -0
  57. package/clis/douyin/_shared/tos-upload.js +105 -69
  58. package/clis/douyin/_shared/vod-upload.js +212 -0
  59. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  60. package/clis/douyin/delete.js +137 -4
  61. package/clis/douyin/delete.test.js +90 -1
  62. package/clis/douyin/hashtag.js +84 -23
  63. package/clis/douyin/hashtag.test.js +113 -0
  64. package/clis/douyin/publish-upload-id.test.js +170 -0
  65. package/clis/douyin/publish.js +88 -42
  66. package/clis/douyin/user-videos.js +9 -2
  67. package/clis/douyin/user-videos.test.js +43 -0
  68. package/clis/flomo/memos.js +228 -0
  69. package/clis/flomo/memos.test.js +144 -0
  70. package/clis/geogebra/add-circle.js +46 -0
  71. package/clis/geogebra/add-line.js +35 -0
  72. package/clis/geogebra/add-point.js +27 -0
  73. package/clis/geogebra/add-polygon.js +25 -0
  74. package/clis/geogebra/eval.js +35 -0
  75. package/clis/geogebra/geogebra.test.js +175 -0
  76. package/clis/geogebra/hexagon.js +62 -0
  77. package/clis/geogebra/info.js +72 -0
  78. package/clis/geogebra/list.js +35 -0
  79. package/clis/geogebra/triangle.js +60 -0
  80. package/clis/geogebra/utils.js +271 -0
  81. package/clis/gitee/search.js +2 -2
  82. package/clis/gitee/search.test.js +65 -0
  83. package/clis/jike/post.js +27 -17
  84. package/clis/jike/read.test.js +86 -0
  85. package/clis/jike/topic.js +32 -19
  86. package/clis/jike/user.js +33 -20
  87. package/clis/jira/attachments.js +28 -0
  88. package/clis/jira/commands.test.js +287 -0
  89. package/clis/jira/comments.js +28 -0
  90. package/clis/jira/issue.js +28 -0
  91. package/clis/jira/links.js +28 -0
  92. package/clis/jira/search.js +47 -0
  93. package/clis/jira/shared.js +256 -0
  94. package/clis/lesswrong/comments.js +1 -1
  95. package/clis/lesswrong/curated.js +1 -1
  96. package/clis/lesswrong/frontpage.js +1 -1
  97. package/clis/lesswrong/frontpage.test.js +37 -0
  98. package/clis/lesswrong/new.js +1 -1
  99. package/clis/lesswrong/read.js +1 -1
  100. package/clis/lesswrong/sequences.js +1 -1
  101. package/clis/lesswrong/shortform.js +1 -1
  102. package/clis/lesswrong/tag.js +1 -1
  103. package/clis/lesswrong/top-month.js +1 -1
  104. package/clis/lesswrong/top-week.js +1 -1
  105. package/clis/lesswrong/top-year.js +1 -1
  106. package/clis/lesswrong/top.js +1 -1
  107. package/clis/linkedin/connect.js +401 -0
  108. package/clis/linkedin/connect.test.js +213 -0
  109. package/clis/linkedin/inbox.js +234 -0
  110. package/clis/linkedin/inbox.test.js +152 -0
  111. package/clis/linkedin/job-detail.js +167 -0
  112. package/clis/linkedin/job-detail.test.js +38 -0
  113. package/clis/linkedin/jobs-preferences.js +113 -0
  114. package/clis/linkedin/jobs-preferences.test.js +43 -0
  115. package/clis/linkedin/people-search.js +262 -0
  116. package/clis/linkedin/people-search.test.js +216 -0
  117. package/clis/linkedin/post-analytics.js +74 -0
  118. package/clis/linkedin/post-analytics.test.js +40 -0
  119. package/clis/linkedin/posts-core.js +241 -0
  120. package/clis/linkedin/posts.js +22 -0
  121. package/clis/linkedin/posts.test.js +40 -0
  122. package/clis/linkedin/profile-analytics.js +104 -0
  123. package/clis/linkedin/profile-analytics.test.js +67 -0
  124. package/clis/linkedin/profile-experience.js +671 -0
  125. package/clis/linkedin/profile-experience.test.js +152 -0
  126. package/clis/linkedin/profile-projects.js +311 -0
  127. package/clis/linkedin/profile-projects.test.js +111 -0
  128. package/clis/linkedin/profile-read.js +148 -0
  129. package/clis/linkedin/profile-read.test.js +77 -0
  130. package/clis/linkedin/safe-send.js +357 -0
  131. package/clis/linkedin/safe-send.test.js +204 -0
  132. package/clis/linkedin/salesnav-inbox.js +210 -0
  133. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  134. package/clis/linkedin/salesnav-message.js +360 -0
  135. package/clis/linkedin/salesnav-message.test.js +172 -0
  136. package/clis/linkedin/salesnav-search.js +186 -0
  137. package/clis/linkedin/salesnav-search.test.js +76 -0
  138. package/clis/linkedin/salesnav-thread.js +212 -0
  139. package/clis/linkedin/salesnav-thread.test.js +79 -0
  140. package/clis/linkedin/sent-invitations.js +92 -0
  141. package/clis/linkedin/sent-invitations.test.js +62 -0
  142. package/clis/linkedin/services-read.js +213 -0
  143. package/clis/linkedin/services-read.test.js +105 -0
  144. package/clis/linkedin/shared.js +124 -0
  145. package/clis/linkedin/thread-snapshot.js +214 -0
  146. package/clis/linkedin/thread-snapshot.test.js +89 -0
  147. package/clis/linkedin/timeline.js +14 -7
  148. package/clis/linkedin-learning/course.js +138 -0
  149. package/clis/linkedin-learning/course.test.js +114 -0
  150. package/clis/linkedin-learning/search.js +155 -0
  151. package/clis/linkedin-learning/search.test.js +144 -0
  152. package/clis/linkedin-learning/trending.js +133 -0
  153. package/clis/linkedin-learning/trending.test.js +123 -0
  154. package/clis/notebooklm/add-source.js +269 -0
  155. package/clis/notebooklm/add-source.test.js +97 -0
  156. package/clis/notebooklm/create.js +76 -0
  157. package/clis/notebooklm/create.test.js +58 -0
  158. package/clis/notebooklm/generate-audio.js +91 -0
  159. package/clis/notebooklm/generate-audio.test.js +63 -0
  160. package/clis/notebooklm/generate-slides.js +106 -0
  161. package/clis/notebooklm/generate-slides.test.js +75 -0
  162. package/clis/notebooklm/open.test.js +10 -10
  163. package/clis/notebooklm/rpc.js +20 -6
  164. package/clis/notebooklm/rpc.test.js +27 -1
  165. package/clis/notebooklm/utils.js +100 -24
  166. package/clis/notebooklm/utils.test.js +60 -1
  167. package/clis/notebooklm/write-note.js +103 -0
  168. package/clis/notebooklm/write-note.test.js +70 -0
  169. package/clis/pixiv/detail.js +41 -34
  170. package/clis/pixiv/detail.test.js +93 -0
  171. package/clis/pixiv/user.js +36 -31
  172. package/clis/pixiv/user.test.js +100 -0
  173. package/clis/pixiv/utils.js +56 -7
  174. package/clis/powerchina/search.js +3 -3
  175. package/clis/powerchina/search.test.js +27 -1
  176. package/clis/reddit/extract-media.test.js +149 -0
  177. package/clis/reddit/frontpage.js +47 -9
  178. package/clis/reddit/frontpage.test.js +34 -0
  179. package/clis/reddit/home.js +31 -1
  180. package/clis/reddit/home.test.js +46 -3
  181. package/clis/reddit/hot.js +32 -1
  182. package/clis/reddit/hot.test.js +15 -1
  183. package/clis/reddit/popular.js +39 -1
  184. package/clis/reddit/popular.test.js +26 -0
  185. package/clis/reddit/saved.js +1 -1
  186. package/clis/reddit/search.js +38 -1
  187. package/clis/reddit/search.test.js +26 -0
  188. package/clis/reddit/subreddit.js +52 -7
  189. package/clis/reddit/subreddit.test.js +31 -0
  190. package/clis/reddit/subscribed.js +165 -0
  191. package/clis/reddit/subscribed.test.js +168 -0
  192. package/clis/reddit/upvoted.js +1 -1
  193. package/clis/suno/commands.test.js +188 -0
  194. package/clis/suno/download.js +140 -0
  195. package/clis/suno/download.test.js +151 -0
  196. package/clis/suno/generate.js +231 -0
  197. package/clis/suno/generate.test.js +252 -0
  198. package/clis/suno/list.js +79 -0
  199. package/clis/suno/status.js +63 -0
  200. package/clis/suno/utils.js +549 -0
  201. package/clis/suno/utils.test.js +329 -0
  202. package/clis/twitter/device-follow.js +193 -0
  203. package/clis/twitter/device-follow.test.js +287 -0
  204. package/clis/twitter/download.js +443 -73
  205. package/clis/twitter/download.test.js +457 -0
  206. package/clis/twitter/followers.js +6 -2
  207. package/clis/twitter/followers.test.js +19 -1
  208. package/clis/twitter/following.js +14 -5
  209. package/clis/twitter/following.test.js +29 -0
  210. package/clis/twitter/likes.js +12 -4
  211. package/clis/twitter/likes.test.js +26 -1
  212. package/clis/twitter/list-add.js +1 -1
  213. package/clis/twitter/list-create.js +155 -0
  214. package/clis/twitter/list-create.test.js +169 -0
  215. package/clis/twitter/list-remove.js +13 -6
  216. package/clis/twitter/list-remove.test.js +74 -0
  217. package/clis/twitter/list-tweets.js +6 -2
  218. package/clis/twitter/list-tweets.test.js +41 -1
  219. package/clis/twitter/lists.js +31 -4
  220. package/clis/twitter/lists.test.js +152 -16
  221. package/clis/twitter/notifications.js +4 -4
  222. package/clis/twitter/post.js +62 -4
  223. package/clis/twitter/post.test.js +35 -3
  224. package/clis/twitter/profile.js +81 -28
  225. package/clis/twitter/profile.test.js +113 -2
  226. package/clis/twitter/quote.js +9 -4
  227. package/clis/twitter/reply.js +13 -10
  228. package/clis/twitter/reply.test.js +41 -0
  229. package/clis/twitter/search.js +7 -3
  230. package/clis/twitter/search.test.js +41 -0
  231. package/clis/twitter/shared.js +155 -0
  232. package/clis/twitter/shared.test.js +465 -1
  233. package/clis/twitter/thread.js +10 -2
  234. package/clis/twitter/thread.test.js +58 -0
  235. package/clis/twitter/timeline.js +6 -2
  236. package/clis/twitter/timeline.test.js +2 -0
  237. package/clis/twitter/tweets.js +3 -2
  238. package/clis/twitter/tweets.test.js +1 -1
  239. package/clis/twitter/utils.js +53 -16
  240. package/clis/upwork/detail.js +132 -0
  241. package/clis/upwork/feed.js +109 -0
  242. package/clis/upwork/search.js +115 -0
  243. package/clis/upwork/upwork.test.js +566 -0
  244. package/clis/upwork/utils.js +323 -0
  245. package/clis/weibo/delete.js +172 -0
  246. package/clis/weibo/delete.test.js +94 -0
  247. package/clis/weibo/publish.js +37 -14
  248. package/clis/weibo/publish.test.js +14 -5
  249. package/clis/weibo/user-posts.js +234 -0
  250. package/clis/weibo/user-posts.test.js +92 -0
  251. package/clis/weread/book-search.js +438 -0
  252. package/clis/weread/book-search.test.js +242 -0
  253. package/clis/weread/search-regression.test.js +98 -11
  254. package/clis/weread/search.js +32 -9
  255. package/clis/weread-official/book.js +135 -0
  256. package/clis/weread-official/commands.test.js +385 -0
  257. package/clis/weread-official/discover.js +107 -0
  258. package/clis/weread-official/list-apis.js +95 -0
  259. package/clis/weread-official/notes.js +171 -0
  260. package/clis/weread-official/readdata.js +158 -0
  261. package/clis/weread-official/review.js +93 -0
  262. package/clis/weread-official/search.js +106 -0
  263. package/clis/weread-official/shelf.js +97 -0
  264. package/clis/weread-official/utils.js +293 -0
  265. package/clis/weread-official/utils.test.js +242 -0
  266. package/clis/wikipedia/trending.js +7 -3
  267. package/clis/wikipedia/trending.test.js +57 -0
  268. package/clis/xianyu/chat.js +24 -109
  269. package/clis/xianyu/chat.test.js +5 -0
  270. package/clis/xianyu/im.js +322 -0
  271. package/clis/xianyu/im.test.js +253 -0
  272. package/clis/xianyu/inbox.js +96 -0
  273. package/clis/xianyu/messages.js +91 -0
  274. package/clis/xianyu/reply.js +82 -0
  275. package/clis/xiaohongshu/creator-note-detail.js +166 -28
  276. package/clis/xiaohongshu/creator-note-detail.test.js +196 -36
  277. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  278. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  279. package/clis/xiaohongshu/creator-notes.js +252 -2
  280. package/clis/xiaohongshu/creator-notes.test.js +90 -1
  281. package/clis/xiaohongshu/creator-stats.js +2 -1
  282. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  283. package/clis/xiaohongshu/delete-note.js +260 -0
  284. package/clis/xiaohongshu/delete-note.test.js +172 -0
  285. package/clis/xiaohongshu/download.js +97 -39
  286. package/clis/xiaohongshu/download.test.js +201 -0
  287. package/clis/xiaohongshu/publish.js +48 -8
  288. package/clis/xiaohongshu/publish.test.js +65 -10
  289. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  290. package/clis/xiaohongshu/user.js +27 -4
  291. package/clis/xiaoyuzhou/download.js +1 -1
  292. package/clis/xiaoyuzhou/transcript.js +1 -1
  293. package/clis/youdao/note.js +258 -0
  294. package/clis/youdao/note.test.js +99 -0
  295. package/clis/youtube/transcript.js +397 -24
  296. package/clis/youtube/transcript.test.js +196 -6
  297. package/clis/zhihu/answer-comments.js +280 -0
  298. package/clis/zhihu/answer-comments.test.js +287 -0
  299. package/clis/zhihu/answer-detail.js +2 -19
  300. package/clis/zhihu/answer-detail.test.js +8 -0
  301. package/clis/zhihu/collection.js +17 -16
  302. package/clis/zhihu/collection.test.js +50 -3
  303. package/clis/zhihu/download.js +1 -1
  304. package/clis/zhihu/question.js +42 -17
  305. package/clis/zhihu/question.test.js +113 -11
  306. package/clis/zhihu/search.js +195 -43
  307. package/clis/zhihu/search.test.js +198 -0
  308. package/clis/zhihu/text.js +29 -0
  309. package/clis/zhihu/text.test.js +24 -0
  310. package/dist/src/browser/errors.js +4 -2
  311. package/dist/src/browser/errors.test.js +6 -0
  312. package/dist/src/browser/network-cache.js +13 -1
  313. package/dist/src/browser/network-cache.test.js +17 -0
  314. package/dist/src/browser/page.js +30 -4
  315. package/dist/src/browser/page.test.js +42 -0
  316. package/dist/src/browser/utils.d.ts +1 -1
  317. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  318. package/dist/src/cli-argv-preprocess.js +138 -0
  319. package/dist/src/cli-argv-preprocess.test.js +79 -0
  320. package/dist/src/convention-audit.js +15 -8
  321. package/dist/src/convention-audit.test.js +21 -0
  322. package/dist/src/download/index.js +13 -1
  323. package/dist/src/download/index.test.js +23 -1
  324. package/dist/src/download/media-download.js +15 -2
  325. package/dist/src/download/media-download.test.d.ts +1 -0
  326. package/dist/src/download/media-download.test.js +112 -0
  327. package/dist/src/download/progress.js +2 -2
  328. package/dist/src/download/progress.test.js +12 -1
  329. package/dist/src/electron-apps.js +1 -1
  330. package/dist/src/electron-apps.test.js +7 -2
  331. package/dist/src/errors.d.ts +17 -0
  332. package/dist/src/errors.js +22 -0
  333. package/dist/src/external-clis.yaml +8 -0
  334. package/dist/src/main.js +14 -2
  335. package/dist/src/output.js +11 -1
  336. package/dist/src/output.test.js +6 -0
  337. package/dist/src/registry.js +1 -0
  338. package/dist/src/registry.test.js +11 -0
  339. package/dist/src/utils.d.ts +43 -0
  340. package/dist/src/utils.js +97 -0
  341. package/dist/src/utils.test.d.ts +1 -0
  342. package/dist/src/utils.test.js +155 -0
  343. package/package.json +8 -2
  344. package/scripts/silent-column-drop-baseline.json +0 -52
  345. package/scripts/typed-error-lint-baseline.json +28 -380
  346. package/clis/slock/_utils.js +0 -12
@@ -0,0 +1,204 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
4
+ import './safe-send.js';
5
+
6
+ const {
7
+ normalizeWhitespace,
8
+ normalizeName,
9
+ canonicalizeLinkedInThreadUrl,
10
+ hashText,
11
+ assessThreadSafety,
12
+ } = await import('./safe-send.js').then((m) => m.__test__);
13
+
14
+ function makeFakePage(probe) {
15
+ let composerText = probe.composerText || '';
16
+ return {
17
+ goto: vi.fn(async () => undefined),
18
+ wait: vi.fn(async () => undefined),
19
+ evaluate: vi.fn(async (script) => {
20
+ const text = String(script);
21
+ if (text.includes('__OPENCLI_LINKEDIN_PROBE__')) return probe;
22
+ if (text.includes('__OPENCLI_LINKEDIN_FOCUS_COMPOSER__')) return { ok: true, composerText: '' };
23
+ if (text.includes('__OPENCLI_LINKEDIN_READ_COMPOSER__')) return { ok: true, composerText };
24
+ if (text.includes('__OPENCLI_LINKEDIN_CLICK_SEND__')) return { ok: true, sent: true };
25
+ return undefined;
26
+ }),
27
+ insertText: vi.fn(async (text) => {
28
+ composerText = text;
29
+ }),
30
+ pressKey: vi.fn(async () => undefined),
31
+ screenshot: vi.fn(async () => 'base64-screenshot'),
32
+ };
33
+ }
34
+
35
+ describe('linkedin safe-send helpers', () => {
36
+ it('normalizes whitespace and LinkedIn names for exact-ish comparisons', () => {
37
+ expect(normalizeWhitespace(' Lokesh\n\tRamesh ')).toBe('Lokesh Ramesh');
38
+ expect(normalizeName('Lokesh Ramesh • 1st')).toBe('lokesh ramesh');
39
+ });
40
+
41
+ it('canonicalizes thread URLs while dropping query and hash noise', () => {
42
+ expect(canonicalizeLinkedInThreadUrl('https://www.linkedin.com/messaging/thread/abc/?foo=1#bar'))
43
+ .toBe('https://www.linkedin.com/messaging/thread/abc/');
44
+ expect(canonicalizeLinkedInThreadUrl('https://www.linkedin.com/messaging/thread/abc/extra')).toBe('');
45
+ expect(canonicalizeLinkedInThreadUrl('https://evil-linkedin.com/messaging/thread/abc/')).toBe('');
46
+ expect(canonicalizeLinkedInThreadUrl('http://www.linkedin.com/messaging/thread/abc/')).toBe('');
47
+ });
48
+
49
+ it('fails closed when LinkedIn search produced no results even if a composer is visible', () => {
50
+ const result = assessThreadSafety({
51
+ url: 'https://www.linkedin.com/messaging/thread/bora/',
52
+ headerNames: ['Bora Nicholson'],
53
+ bodyText: "We didn't find anything for Victoria Munoz\nBora Nicholson",
54
+ searchFailure: true,
55
+ composerFound: true,
56
+ latestMessageHash: hashText('hello'),
57
+ }, {
58
+ expectedName: 'Victoria Munoz',
59
+ threadUrl: 'https://www.linkedin.com/messaging/thread/victoria/',
60
+ expectedLastText: 'hello',
61
+ });
62
+
63
+ expect(result.ok).toBe(false);
64
+ expect(result.blockReason).toBe('search_failure_visible');
65
+ });
66
+
67
+ it('fails closed on recipient header mismatch', () => {
68
+ const result = assessThreadSafety({
69
+ url: 'https://www.linkedin.com/messaging/thread/bora/',
70
+ headerNames: ['Bora Nicholson'],
71
+ bodyText: 'Bora Nicholson\nhello',
72
+ composerFound: true,
73
+ latestMessageHash: hashText('hello'),
74
+ }, {
75
+ expectedName: 'Victoria Munoz',
76
+ expectedLastText: 'hello',
77
+ });
78
+
79
+ expect(result.ok).toBe(false);
80
+ expect(result.blockReason).toBe('recipient_header_mismatch');
81
+ expect(result.actualValue).toBe('Bora Nicholson');
82
+ });
83
+
84
+ it('fails closed when the stored latest message is no longer visible', () => {
85
+ const result = assessThreadSafety({
86
+ url: 'https://www.linkedin.com/messaging/thread/lokesh/',
87
+ headerNames: ['Lokesh Ramesh'],
88
+ bodyText: 'Lokesh Ramesh\na newer inbound arrived',
89
+ composerFound: true,
90
+ latestMessageHash: hashText('a newer inbound arrived'),
91
+ }, {
92
+ expectedName: 'Lokesh Ramesh',
93
+ expectedLastText: 'old inbound text',
94
+ });
95
+
96
+ expect(result.ok).toBe(false);
97
+ expect(result.blockReason).toBe('latest_message_mismatch');
98
+ });
99
+
100
+ it('passes only when recipient, thread, latest text, and composer are all verified', () => {
101
+ const result = assessThreadSafety({
102
+ url: 'https://www.linkedin.com/messaging/thread/lokesh/?mini=true',
103
+ headerNames: ['Lokesh Ramesh'],
104
+ bodyText: 'Lokesh Ramesh\nI think outside help would fit best for provider doc follow ups',
105
+ composerFound: true,
106
+ latestMessageHash: hashText('I think outside help would fit best for provider doc follow ups'),
107
+ }, {
108
+ expectedName: 'Lokesh Ramesh',
109
+ threadUrl: 'https://www.linkedin.com/messaging/thread/lokesh/',
110
+ expectedLastText: 'provider doc follow ups',
111
+ });
112
+
113
+ expect(result.ok).toBe(true);
114
+ expect(result.blockReason).toBe('verified');
115
+ });
116
+ });
117
+
118
+ describe('linkedin safe-send command', () => {
119
+ it('registers as a write command with safe output columns', () => {
120
+ const command = getRegistry().get('linkedin/safe-send');
121
+ expect(command).toBeDefined();
122
+ expect(command.access).toBe('write');
123
+ expect(command.columns).toEqual(expect.arrayContaining(['status', 'recipient', 'reason']));
124
+ });
125
+
126
+ it('does not type or send when verification fails', async () => {
127
+ const command = getRegistry().get('linkedin/safe-send');
128
+ const page = makeFakePage({
129
+ url: 'https://www.linkedin.com/messaging/thread/bora/',
130
+ headerNames: ['Bora Nicholson'],
131
+ bodyText: 'Bora Nicholson',
132
+ composerFound: true,
133
+ searchFailure: false,
134
+ });
135
+
136
+ await expect(command.func(page, {
137
+ 'thread-url': 'https://www.linkedin.com/messaging/thread/victoria/',
138
+ 'expected-name': 'Victoria Munoz',
139
+ message: 'hello victoria',
140
+ send: true,
141
+ })).rejects.toBeInstanceOf(CommandExecutionError);
142
+
143
+ expect(page.insertText).not.toHaveBeenCalled();
144
+ expect(page.pressKey).not.toHaveBeenCalled();
145
+ });
146
+
147
+ it('rejects non-thread URLs before navigating or typing', async () => {
148
+ const command = getRegistry().get('linkedin/safe-send');
149
+ const page = makeFakePage({});
150
+
151
+ await expect(command.func(page, {
152
+ 'thread-url': 'https://www.linkedin.com/feed/',
153
+ 'expected-name': 'Victoria Munoz',
154
+ message: 'hello victoria',
155
+ send: true,
156
+ })).rejects.toBeInstanceOf(ArgumentError);
157
+
158
+ expect(page.goto).not.toHaveBeenCalled();
159
+ expect(page.insertText).not.toHaveBeenCalled();
160
+ });
161
+
162
+ it('dry-runs by default after verification without filling or sending', async () => {
163
+ const command = getRegistry().get('linkedin/safe-send');
164
+ const page = makeFakePage({
165
+ url: 'https://www.linkedin.com/messaging/thread/lokesh/',
166
+ headerNames: ['Lokesh Ramesh'],
167
+ bodyText: 'Lokesh Ramesh\nprovider doc follow ups',
168
+ composerFound: true,
169
+ searchFailure: false,
170
+ });
171
+
172
+ const rows = await command.func(page, {
173
+ 'thread-url': 'https://www.linkedin.com/messaging/thread/lokesh/',
174
+ 'expected-name': 'Lokesh Ramesh',
175
+ message: 'both, but starting hands on',
176
+ });
177
+
178
+ expect(rows[0]).toMatchObject({ status: 'verified_dry_run', recipient: 'Lokesh Ramesh', reason: 'verified' });
179
+ expect(page.insertText).not.toHaveBeenCalled();
180
+ expect(page.pressKey).not.toHaveBeenCalled();
181
+ });
182
+
183
+ it('fills and sends only when --send is explicitly true and post-fill verification matches exactly', async () => {
184
+ const command = getRegistry().get('linkedin/safe-send');
185
+ const page = makeFakePage({
186
+ url: 'https://www.linkedin.com/messaging/thread/lokesh/',
187
+ headerNames: ['Lokesh Ramesh'],
188
+ bodyText: 'Lokesh Ramesh\nprovider doc follow ups',
189
+ composerFound: true,
190
+ searchFailure: false,
191
+ });
192
+
193
+ const rows = await command.func(page, {
194
+ 'thread-url': 'https://www.linkedin.com/messaging/thread/lokesh/',
195
+ 'expected-name': 'Lokesh Ramesh',
196
+ message: 'both, but starting hands on',
197
+ send: true,
198
+ });
199
+
200
+ expect(rows[0]).toMatchObject({ status: 'sent', recipient: 'Lokesh Ramesh', reason: 'verified' });
201
+ expect(page.insertText).toHaveBeenCalledWith('both, but starting hands on');
202
+ expect(page.pressKey).not.toHaveBeenCalled();
203
+ });
204
+ });
@@ -0,0 +1,210 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
+
4
+ const LINKEDIN_DOMAIN = 'www.linkedin.com';
5
+ const SALES_INBOX_URL = 'https://www.linkedin.com/sales/inbox/';
6
+ const THREADS_BASE = 'https://www.linkedin.com/sales-api/salesApiMessagingThreads';
7
+ const PAGE_SIZE = 20;
8
+ const DEFAULT_LIMIT = 40;
9
+ const MAX_LIMIT = 500;
10
+ const THREAD_DECORATION = '(id,restrictions,archived,unreadMessageCount,nextPageStartsAt,totalMessageCount,messages*(id,type,contentFlag,deliveredAt,lastEditedAt,subject,body,footerText,blockCopy,attachments,author,systemMessageContent),participants*~fs_salesProfile(entityUrn,firstName,lastName,fullName,degree,profilePictureDisplayImage,objectUrn,inmailRestriction))';
11
+
12
+ export function normalizeWhitespace(value) {
13
+ return String(value ?? '').replace(/[\u00a0\u202f]/g, ' ').replace(/\s+/g, ' ').trim();
14
+ }
15
+
16
+ export function parseLimit(value, defaultValue = DEFAULT_LIMIT) {
17
+ if (value === undefined || value === null || value === '') return defaultValue;
18
+ const limit = Number(value);
19
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
20
+ throw new ArgumentError(`--limit must be an integer between 1 and ${MAX_LIMIT}`);
21
+ }
22
+ return limit;
23
+ }
24
+
25
+ function unwrapEvaluateResult(payload) {
26
+ if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
27
+ return payload;
28
+ }
29
+
30
+ export function encodeRestliDecoration(value) {
31
+ // LinkedIn's Sales Navigator Rest.li endpoint returns HTTP 400 when this
32
+ // decoration is sent with literal parentheses. Keep parentheses percent-encoded.
33
+ return encodeURIComponent(value).replace(/\(/g, '%28').replace(/\)/g, '%29');
34
+ }
35
+
36
+ function salesnavThreadUrl(threadId) {
37
+ return threadId ? `https://www.linkedin.com/sales/inbox/${encodeURIComponent(threadId)}` : '';
38
+ }
39
+
40
+ function threadListUrl({ count = PAGE_SIZE, pageStartsAt = '' } = {}) {
41
+ let url = `${THREADS_BASE}?decoration=${encodeRestliDecoration(THREAD_DECORATION)}&count=${count}&filter=INBOX&q=filter`;
42
+ if (pageStartsAt) url += `&pageStartsAt=${encodeURIComponent(pageStartsAt)}`;
43
+ return url;
44
+ }
45
+
46
+ function getThreadParticipants(thread) {
47
+ const resolution = thread?.participantsResolutionResults || {};
48
+ const participants = Array.isArray(thread?.participants) ? thread.participants : Object.keys(resolution);
49
+ return participants.map((urn) => resolution[urn] || { entityUrn: urn }).filter(Boolean);
50
+ }
51
+
52
+ function isSelfParticipant(profile) {
53
+ const degree = String(profile?.degree ?? '').trim();
54
+ return degree === '0';
55
+ }
56
+
57
+ function otherParticipantName(thread) {
58
+ const participants = getThreadParticipants(thread);
59
+ const other = participants.find((p) => !isSelfParticipant(p)) || participants[0];
60
+ return normalizeWhitespace(other?.fullName || [other?.firstName, other?.lastName].filter(Boolean).join(' '));
61
+ }
62
+
63
+ function parseSalesnavThreads(json) {
64
+ if (!json || typeof json !== 'object' || !Array.isArray(json.elements)) {
65
+ throw new CommandExecutionError('Sales Navigator messaging threads API returned malformed payload');
66
+ }
67
+ return json.elements.map((thread) => {
68
+ if (!thread || typeof thread !== 'object') {
69
+ throw new CommandExecutionError('Sales Navigator messaging threads API returned malformed thread row');
70
+ }
71
+ const messages = Array.isArray(thread?.messages) ? thread.messages : [];
72
+ const lastMessage = messages[0] || {};
73
+ const deliveredAt = Number(lastMessage.deliveredAt || thread?.nextPageStartsAt || 0);
74
+ const threadId = normalizeWhitespace(thread?.id || '');
75
+ if (!threadId) {
76
+ throw new CommandExecutionError('Sales Navigator messaging thread row missing id');
77
+ }
78
+ return {
79
+ thread_id: threadId,
80
+ thread_url: salesnavThreadUrl(threadId),
81
+ person_name: otherParticipantName(thread),
82
+ last_message_snippet: normalizeWhitespace(lastMessage.body || lastMessage.subject || '').slice(0, 300),
83
+ last_activity_time: deliveredAt ? new Date(deliveredAt).toISOString() : '',
84
+ unread: Number(thread?.unreadMessageCount || 0) > 0,
85
+ unread_count: Number(thread?.unreadMessageCount || 0),
86
+ total_message_count: Number(thread?.totalMessageCount || messages.length || 0),
87
+ archived: Boolean(thread?.archived),
88
+ next_page_starts_at: normalizeWhitespace(thread?.nextPageStartsAt || ''),
89
+ participants: getThreadParticipants(thread).map((p) => ({
90
+ name: normalizeWhitespace(p.fullName || [p.firstName, p.lastName].filter(Boolean).join(' ')),
91
+ entity_urn: normalizeWhitespace(p.entityUrn || ''),
92
+ object_urn: normalizeWhitespace(p.objectUrn || ''),
93
+ degree: normalizeWhitespace(p.degree ?? ''),
94
+ })),
95
+ };
96
+ });
97
+ }
98
+
99
+ function fetchJsonScript(url, csrf) {
100
+ return String.raw`(async () => {
101
+ try {
102
+ const res = await fetch(${JSON.stringify(url)}, {
103
+ credentials: 'include',
104
+ headers: {
105
+ 'csrf-token': ${JSON.stringify(csrf)},
106
+ 'x-restli-protocol-version': '2.0.0',
107
+ accept: 'application/json',
108
+ },
109
+ });
110
+ const text = await res.text();
111
+ let json = null;
112
+ try { json = text ? JSON.parse(text) : null; } catch (_) { json = null; }
113
+ if (res.status === 401 || res.status === 403) return { authRequired: true, status: res.status, text };
114
+ if (!res.ok) return { error: 'HTTP ' + res.status, status: res.status, text, json };
115
+ return { status: res.status, json };
116
+ } catch (e) {
117
+ return { error: 'fetch failed: ' + ((e && e.message) || String(e)) };
118
+ }
119
+ })()`;
120
+ }
121
+
122
+ export async function getCsrf(page) {
123
+ const cookies = await page.getCookies({ url: 'https://www.linkedin.com' });
124
+ const jsession = cookies.find((c) => c.name === 'JSESSIONID')?.value;
125
+ if (!jsession) throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn JSESSIONID cookie not found. Please sign in to LinkedIn.');
126
+ return jsession.replace(/^\"|\"$/g, '');
127
+ }
128
+
129
+ export async function fetchSalesnavJson(page, csrf, url, label) {
130
+ const result = unwrapEvaluateResult(await page.evaluate(fetchJsonScript(url, csrf)));
131
+ if (result?.authRequired) throw new AuthRequiredError(LINKEDIN_DOMAIN, `${label} authentication failed (HTTP ${result.status || 'auth_required'}).`);
132
+ if (result?.error || !result?.json) throw new CommandExecutionError(`${label} returned an unexpected response`, `${result?.error || 'no_json'}\n${normalizeWhitespace(result?.text || '').slice(0, 500)}`);
133
+ return result.json;
134
+ }
135
+
136
+ export async function fetchInboxRows(page, { limit = DEFAULT_LIMIT, maxPages = 30 } = {}) {
137
+ const csrf = await getCsrf(page);
138
+ const rows = [];
139
+ const seen = new Set();
140
+ let pageStartsAt = '';
141
+ let pagesFetched = 0;
142
+ let hasMorePages = false;
143
+ while (rows.length < limit && pagesFetched < maxPages) {
144
+ const json = await fetchSalesnavJson(page, csrf, threadListUrl({ count: PAGE_SIZE, pageStartsAt }), 'Sales Navigator messaging threads API');
145
+ pagesFetched += 1;
146
+ const pageRows = parseSalesnavThreads(json);
147
+ if (pageRows.length === 0) break;
148
+ for (const row of pageRows) {
149
+ if (seen.has(row.thread_id)) continue;
150
+ seen.add(row.thread_id);
151
+ rows.push(row);
152
+ if (rows.length >= limit) break;
153
+ }
154
+ const last = pageRows[pageRows.length - 1];
155
+ const next = last?.next_page_starts_at;
156
+ hasMorePages = Boolean(next);
157
+ if (!next) break;
158
+ if (next === pageStartsAt) {
159
+ throw new CommandExecutionError('Sales Navigator messaging threads API returned the same cursor twice');
160
+ }
161
+ pageStartsAt = next;
162
+ }
163
+ if (rows.length < limit && hasMorePages && pagesFetched >= maxPages) {
164
+ throw new CommandExecutionError(`Sales Navigator messaging threads API reached the ${maxPages}-page safety cap before collecting ${limit} conversations`);
165
+ }
166
+ return rows.slice(0, limit).map((row, index) => ({ ...row, rank: index + 1 }));
167
+ }
168
+
169
+ export { THREAD_DECORATION, THREADS_BASE };
170
+
171
+ cli({
172
+ site: 'linkedin',
173
+ name: 'salesnav-inbox',
174
+ access: 'read',
175
+ description: 'List LinkedIn Sales Navigator message conversations with API pagination',
176
+ domain: LINKEDIN_DOMAIN,
177
+ strategy: Strategy.UI,
178
+ browser: true,
179
+ args: [
180
+ { name: 'limit', type: 'number', default: DEFAULT_LIMIT, help: 'Maximum conversations to return (1-500)' },
181
+ { name: 'max-pages', type: 'number', default: 30, help: 'Maximum Sales Navigator API pages to fetch' },
182
+ { name: 'unread-only', type: 'bool', default: false, help: 'Return only unread conversations' },
183
+ ],
184
+ columns: ['rank', 'thread_id', 'thread_url', 'person_name', 'last_message_snippet', 'last_activity_time', 'unread', 'unread_count', 'total_message_count', 'archived', 'participants', 'next_page_starts_at'],
185
+ func: async (page, args) => {
186
+ if (!page) throw new CommandExecutionError('Browser session required for linkedin salesnav-inbox');
187
+ const limit = parseLimit(args.limit);
188
+ const maxPages = parseLimit(args['max-pages'], 30);
189
+ await page.goto(SALES_INBOX_URL);
190
+ await page.wait(4);
191
+ let rows = await fetchInboxRows(page, { limit, maxPages });
192
+ if (args['unread-only']) rows = rows.filter((row) => row.unread);
193
+ if (rows.length === 0) {
194
+ if (args['unread-only']) return [];
195
+ throw new EmptyResultError('linkedin salesnav-inbox', 'No Sales Navigator conversations were found.');
196
+ }
197
+ return rows.slice(0, limit).map((row, index) => ({ ...row, rank: index + 1 }));
198
+ },
199
+ });
200
+
201
+ export const __test__ = {
202
+ THREAD_DECORATION,
203
+ normalizeWhitespace,
204
+ parseLimit,
205
+ encodeRestliDecoration,
206
+ salesnavThreadUrl,
207
+ threadListUrl,
208
+ parseSalesnavThreads,
209
+ fetchInboxRows,
210
+ };
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import './salesnav-inbox.js';
4
+
5
+ const {
6
+ THREAD_DECORATION,
7
+ encodeRestliDecoration,
8
+ parseLimit,
9
+ parseSalesnavThreads,
10
+ salesnavThreadUrl,
11
+ threadListUrl,
12
+ } = await import('./salesnav-inbox.js').then((m) => m.__test__);
13
+
14
+ describe('linkedin salesnav-inbox command', () => {
15
+ it('percent-encodes Rest.li decoration parentheses for Sales Navigator messaging', () => {
16
+ const encoded = encodeRestliDecoration('(id,messages*(body))');
17
+ expect(encoded).toBe('%28id%2Cmessages*%28body%29%29');
18
+ expect(encoded).not.toContain('(');
19
+ expect(encoded).not.toContain(')');
20
+ });
21
+
22
+ it('builds the paginated salesApiMessagingThreads inbox URL', () => {
23
+ const url = threadListUrl({ count: 20, pageStartsAt: '1779070755626' });
24
+ expect(url).toContain('/sales-api/salesApiMessagingThreads?');
25
+ expect(url).toContain('q=filter');
26
+ expect(url).toContain('filter=INBOX');
27
+ expect(url).toContain('count=20');
28
+ expect(url).toContain('pageStartsAt=1779070755626');
29
+ expect(url).toContain(encodeRestliDecoration(THREAD_DECORATION));
30
+ });
31
+
32
+ it('validates limits without silent clamping', () => {
33
+ expect(parseLimit(undefined)).toBe(40);
34
+ expect(parseLimit(12)).toBe(12);
35
+ expect(() => parseLimit(0)).toThrow();
36
+ expect(() => parseLimit(501)).toThrow();
37
+ expect(() => parseLimit('abc')).toThrow();
38
+ });
39
+
40
+ it('parses Sales Navigator thread rows with other participant and unread state', () => {
41
+ const rows = parseSalesnavThreads({ elements: [{
42
+ id: '2-thread',
43
+ unreadMessageCount: 1,
44
+ archived: false,
45
+ totalMessageCount: 2,
46
+ nextPageStartsAt: 1778206803669,
47
+ participants: [
48
+ 'urn:li:fs_salesProfile:(OTHER,NAME_SEARCH,T1)',
49
+ 'urn:li:fs_salesProfile:(SELF,NAME_SEARCH,T2)',
50
+ ],
51
+ participantsResolutionResults: {
52
+ 'urn:li:fs_salesProfile:(OTHER,NAME_SEARCH,T1)': {
53
+ entityUrn: 'urn:li:fs_salesProfile:(OTHER,NAME_SEARCH,T1)',
54
+ firstName: 'Rachael',
55
+ lastName: 'Stolberg',
56
+ fullName: 'Rachael Stolberg',
57
+ degree: 2,
58
+ },
59
+ 'urn:li:fs_salesProfile:(SELF,NAME_SEARCH,T2)': {
60
+ entityUrn: 'urn:li:fs_salesProfile:(SELF,NAME_SEARCH,T2)',
61
+ firstName: 'Hanzi',
62
+ lastName: 'Li',
63
+ fullName: 'Hanzi Li',
64
+ degree: 0,
65
+ },
66
+ },
67
+ messages: [{
68
+ id: 'msg-1',
69
+ author: 'urn:li:fs_salesProfile:(OTHER,NAME_SEARCH,T1)',
70
+ body: 'Hi hanzi, happy to chat',
71
+ deliveredAt: 1778206803669,
72
+ }],
73
+ }] });
74
+ expect(rows).toHaveLength(1);
75
+ expect(rows[0]).toMatchObject({
76
+ thread_id: '2-thread',
77
+ thread_url: salesnavThreadUrl('2-thread'),
78
+ person_name: 'Rachael Stolberg',
79
+ last_message_snippet: 'Hi hanzi, happy to chat',
80
+ last_activity_time: '2026-05-08T02:20:03.669Z',
81
+ unread: true,
82
+ unread_count: 1,
83
+ total_message_count: 2,
84
+ });
85
+ });
86
+
87
+ it('does not hard-code a specific account name as the inbox owner', () => {
88
+ const rows = parseSalesnavThreads({ elements: [{
89
+ id: '2-thread',
90
+ participants: [
91
+ 'urn:li:fs_salesProfile:(HANZI,NAME_SEARCH,T1)',
92
+ 'urn:li:fs_salesProfile:(ME,NAME_SEARCH,T2)',
93
+ ],
94
+ participantsResolutionResults: {
95
+ 'urn:li:fs_salesProfile:(HANZI,NAME_SEARCH,T1)': {
96
+ fullName: 'Hanzi Li',
97
+ degree: 2,
98
+ },
99
+ 'urn:li:fs_salesProfile:(ME,NAME_SEARCH,T2)': {
100
+ fullName: 'Current User',
101
+ degree: 0,
102
+ },
103
+ },
104
+ messages: [],
105
+ }] });
106
+ expect(rows[0].person_name).toBe('Hanzi Li');
107
+ });
108
+
109
+ it('fails typed on malformed thread payloads and missing thread identity', () => {
110
+ expect(() => parseSalesnavThreads({})).toThrow(CommandExecutionError);
111
+ expect(() => parseSalesnavThreads({ elements: [{}] })).toThrow(CommandExecutionError);
112
+ });
113
+ });