meta-sovereign 0.51.2 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.hi.md CHANGED
@@ -6,13 +6,13 @@ meta-sovereign **किसी व्यक्ति या संगठन क
6
6
 
7
7
  ### किसके लिए
8
8
 
9
- | कौन / क्या | meta-sovereign कैसे मदद करता है |
10
- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
- | **व्यक्ति** | सभी चैट एक जगह, लंबे समय से चुप लोगों से फिर जुड़ना, जो कहा गया वह याद रखना (तथ्य), हर जॉब साइट पर एक जैसा CV। |
12
- | **बिक्री** | हर नेटवर्क के लीड एक CRM में, नियम से ऑडियंस, mass-personal outreach जिसमें हर संदेश एक ही बार और शालीनता से जाता है, फ़ॉलो-अप, तेज़ उत्तर। |
13
- | **मार्केटिंग** | ऑडियंस को broadcast, Telegram चैनल, LinkedIn और X पर पोस्ट, community सदस्य हमेशा ताज़ा, एक opt-out सूची जिसे हर स्वचालित भेजना मानता है। |
14
- | **उपयोगकर्ता सहायता** | हर आने वाले संदेश के लिए एक Operator कतार, VK community या Telegram Business खाते से उत्तर, सुरक्षित ड्राफ़्ट के रूप में auto-reply, तथ्यों का ज्ञान-आधार। |
15
- | **संगठन** | ब्रांड, दुकान या प्रोजेक्ट के लिए वही उपकरण: उसके खाते, उसके संपर्क, उसका डेटा उसकी अपनी मशीन पर। टीम मोड (कई ऑपरेटर) रोडमैप में है। |
9
+ | कौन / क्या | meta-sovereign कैसे मदद करता है |
10
+ | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
11
+ | **व्यक्ति** | सभी चैट एक जगह, लंबे समय से चुप लोगों से फिर जुड़ना, जो कहा गया वह याद रखना (तथ्य), हर जॉब साइट पर एक जैसा CV। |
12
+ | **बिक्री** | हर नेटवर्क के लीड एक CRM में, नियम से ऑडियंस, mass-personal outreach जिसमें हर संदेश एक ही बार और शालीनता से जाता है, फ़ॉलो-अप, तेज़ उत्तर। |
13
+ | **मार्केटिंग** | ऑडियंस को broadcast, Telegram चैनल, LinkedIn और X पर पोस्ट, community सदस्य हमेशा ताज़ा, एक opt-out सूची जिसे हर स्वचालित भेजना मानता है। |
14
+ | **उपयोगकर्ता सहायता** | अपठित बातचीत के लिए एक Operator कतार, VK community या Telegram Business खाते से उत्तर, सुरक्षित ड्राफ़्ट के रूप में auto-reply, तथ्यों का ज्ञान-आधार। |
15
+ | **संगठन** | ब्रांड, दुकान या प्रोजेक्ट के लिए वही उपकरण: उसके खाते, उसके संपर्क, उसका डेटा उसकी अपनी मशीन पर। टीम मोड (कई ऑपरेटर) रोडमैप में है। |
16
16
 
17
17
  हर परिदृश्य के लिए आज क्या चलता है, क्या आंशिक है और आगे क्या है — रोडमैप में: [`docs/ROADMAP.hi.md`](docs/ROADMAP.hi.md)।
18
18
 
@@ -86,6 +86,7 @@ SPA server को इस क्रम में चुनता है ([`js/src
86
86
 
87
87
  - **Outreach, broadcast और ऑटोमेशन**: किसे, कब, किन शर्तों पर और किस पाठ से लिखना है; कौन क्या पाएगा इसका preview; डिफ़ॉल्ट रूप से सुरक्षित ड्राफ़्ट, या quiet hours, हर चैट का cooldown, दैनिक सीमा और opt-out सूची के साथ धीमी गति से स्वचालित भेजना। हर स्वचालित भेजना एक ही outbox से जाता है: अधिक से अधिक एक बार, और अस्पष्ट परिणाम अपने आप दोहराया नहीं जाता ([`docs/USER-GUIDE.hi.md`](docs/USER-GUIDE.hi.md), §11)।
88
88
  - **Operator कतार और उत्तर**: अपठित संदेश चैट-दर-चैट, आपके पिछले उत्तरों से सुझाव (Tab एक चुनता है), पैटर्न और उत्तर के रूप, मिलते संदेश पर auto-reply।
89
+ - **कार्य खाते**: Upwork, Fiverr, PeoplePerHour और Wellfound के लिए अलग ब्राउज़र विंडो में मैन्युअल साइन-इन, स्थायी सत्र और स्थानीय प्रोफ़ाइल स्नैपशॉट Connections और Profile में उपलब्ध हैं। यह शुरुआती समर्थन है; [गाइड](docs/WORK-ACCOUNTS.md) देखें।
89
90
  - **व्यवसाय और community खाते**: VK community token Bots Long Poll चलाता है; Telegram Business के quick replies, स्वागत और अनुपस्थिति संदेश, कार्य-समय और chatbots।
90
91
  - **स्मृति**: बातचीत से निकाले गए `प्रश्न → उत्तर` तथ्य, चैट में जाँच के साथ।
91
92
  - **अभिवादन**: VK, Telegram, WhatsApp, Messenger, X और ईमेल पर "hi" या स्टिकर से बातचीत शुरू करें, [konard/vk-bot](https://github.com/konard/vk-bot) की तरह ([`docs/GREETINGS.hi.md`](docs/GREETINGS.hi.md))।
package/README.md CHANGED
@@ -6,13 +6,13 @@ meta-sovereign represents **a person or an organisation online**. Connect Telegr
6
6
 
7
7
  ### Built for
8
8
 
9
- | Who / what | How meta-sovereign helps |
10
- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
- | **Individuals** | All your chats in one place, reconnect with people gone quiet, remember what was said (facts), one CV on every job site. |
12
- | **Sales** | Leads from every network in one CRM, audiences by rule, mass-personal outreach that sends each message once and politely, follow-ups, fast replies. |
13
- | **Marketing** | Broadcasts to audiences, posts to Telegram channels, LinkedIn and X, community members kept fresh, an opt-out list every automated send honours. |
14
- | **User support** | One Operator queue for every incoming message, answers from a VK community or Telegram Business account, auto-replies as safe drafts, a knowledge base of facts. |
15
- | **Organisations** | The same tools for a brand, a shop or a project: its accounts, its contacts, its data on its own machine. Team mode (several operators) is on the roadmap. |
9
+ | Who / what | How meta-sovereign helps |
10
+ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
+ | **Individuals** | All your chats in one place, reconnect with people gone quiet, remember what was said (facts), one CV on every job site. |
12
+ | **Sales** | Leads from every network in one CRM, audiences by rule, mass-personal outreach that sends each message once and politely, follow-ups, fast replies. |
13
+ | **Marketing** | Broadcasts to audiences, posts to Telegram channels, LinkedIn and X, community members kept fresh, an opt-out list every automated send honours. |
14
+ | **User support** | One Operator queue of unread conversations, answers from a VK community or Telegram Business account, auto-replies as safe drafts, a knowledge base of facts. |
15
+ | **Organisations** | The same tools for a brand, a shop or a project: its accounts, its contacts, its data on its own machine. Team mode (several operators) is on the roadmap. |
16
16
 
17
17
  What works for each scenario today, what is partial and what comes next is in the roadmap: [`docs/ROADMAP.md`](docs/ROADMAP.md).
18
18
 
@@ -85,6 +85,7 @@ The full user-facing flow — install nothing, install Rust server, install JS s
85
85
  - **Contact imports**: preview and import macOS Contacts (including synced iCloud accounts) or vCard exports from any platform; repeated imports keep stable identities. The directory updates during sync and includes people without messages. See [contact imports](docs/CONTACTS-IMPORT.md).
86
86
 
87
87
  - **Outreach, broadcasts and automations**: who to message, when, on what conditions and with which text; a preview of who gets what; safe drafts by default or paced auto-send with quiet hours, a per-chat cooldown, daily caps and an opt-out list. Every automated send goes through one outbox, so it happens at most once and an unclear result is never retried by itself ([`docs/USER-GUIDE.md` §11](docs/USER-GUIDE.md#11-automations-message-people-by-rule-safely)).
88
+ - **Work accounts**: manual sign-in and saved profile snapshots for Upwork, Fiverr, PeoplePerHour and Wellfound in Connections and Profile. [Setup and initial support limits](docs/WORK-ACCOUNTS.md).
88
89
  - **Operator queue and replies**: walk unread messages chat by chat, reply suggestions from your own past answers (Tab takes one), patterns and reply variations, auto-replies on a matching message.
89
90
  - **Business and community accounts**: a VK community token runs the Bots Long Poll; Telegram Business quick replies, greeting and away messages, hours and chatbots.
90
91
  - **Memory**: structured `question → answer` facts captured from conversations, checked in the chat.
package/README.ru.md CHANGED
@@ -6,13 +6,13 @@ meta-sovereign представляет **человека или организ
6
6
 
7
7
  ### Для кого
8
8
 
9
- | Кто / что | Чем помогает meta-sovereign |
10
- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
11
- | **Люди** | Все чаты в одном месте, возобновить общение с теми, кто давно молчит, помнить, что было сказано (факты), одно резюме на всех сайтах вакансий. |
12
- | **Продажи** | Лиды из всех сетей в одной CRM, аудитории по правилам, массово-персональные рассылки, где каждое сообщение уходит один раз и вежливо, follow-up, быстрые ответы. |
13
- | **Маркетинг** | Рассылки по аудиториям, посты в каналы Telegram, LinkedIn и X, актуальные участники сообществ, список отписавшихся, который соблюдает каждая автоматическая отправка. |
14
- | **Поддержка** | Одна очередь оператора для всех входящих, ответы от имени сообщества VK или аккаунта Telegram Business, автоответы как безопасные черновики, база знаний из фактов. |
15
- | **Организации** | Те же инструменты для бренда, магазина или проекта: его аккаунты, его контакты, его данные на его компьютере. Командный режим (несколько операторов) — в дорожной карте. |
9
+ | Кто / что | Чем помогает meta-sovereign |
10
+ | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
+ | **Люди** | Все чаты в одном месте, возобновить общение с теми, кто давно молчит, помнить, что было сказано (факты), одно резюме на всех сайтах вакансий. |
12
+ | **Продажи** | Лиды из всех сетей в одной CRM, аудитории по правилам, массово-персональные рассылки, где каждое сообщение уходит один раз и вежливо, follow-up, быстрые ответы. |
13
+ | **Маркетинг** | Рассылки по аудиториям, посты в каналы Telegram, LinkedIn и X, актуальные участники сообществ, список отписавшихся, который соблюдает каждая автоматическая отправка. |
14
+ | **Поддержка** | Одна очередь оператора для непрочитанных диалогов, ответы от имени сообщества VK или аккаунта Telegram Business, автоответы как безопасные черновики, база знаний из фактов. |
15
+ | **Организации** | Те же инструменты для бренда, магазина или проекта: его аккаунты, его контакты, его данные на его компьютере. Командный режим (несколько операторов) — в дорожной карте. |
16
16
 
17
17
  Что уже работает для каждого сценария, что частично и что дальше — в дорожной карте: [`docs/ROADMAP.ru.md`](docs/ROADMAP.ru.md).
18
18
 
@@ -86,6 +86,7 @@ SPA выбирает сервер в таком порядке ([`js/src/web/dis
86
86
 
87
87
  - **Рассылки, broadcast и автоматизации**: кому писать, когда, при каких условиях и каким текстом; предпросмотр, кто что получит; по умолчанию безопасные черновики или отправка с паузами, тихими часами, перерывом для каждого чата, дневными лимитами и списком отписавшихся. Каждая автоматическая отправка идёт через один outbox: не больше одного раза, а неясный результат сам не повторяется ([`docs/USER-GUIDE.ru.md`](docs/USER-GUIDE.ru.md), §11).
88
88
  - **Очередь оператора и ответы**: непрочитанные сообщения чат за чатом, подсказки ответов из ваших прошлых ответов (Tab берёт подсказку), паттерны и варианты ответов, автоответы на подходящее сообщение.
89
+ - **Рабочие аккаунты**: ручной вход в Upwork, Fiverr, PeoplePerHour и Wellfound в отдельном окне браузера, сохранение сессии и локального снимка профиля в Подключениях и Профиле. Это начальная поддержка; см. [руководство](docs/WORK-ACCOUNTS.md).
89
90
  - **Аккаунты бизнеса и сообществ**: токен сообщества VK запускает Bots Long Poll; Telegram Business: быстрые ответы, приветствие и сообщение об отсутствии, часы работы, чат-боты.
90
91
  - **Память**: структурированные факты `вопрос → ответ` из разговоров, с проверкой прямо в чате.
91
92
  - **Приветствия**: начинать диалоги в VK, Telegram, WhatsApp, Messenger, X и почте с «привет» или стикера, как [konard/vk-bot](https://github.com/konard/vk-bot) ([`docs/GREETINGS.ru.md`](docs/GREETINGS.ru.md)).
package/README.zh.md CHANGED
@@ -11,7 +11,7 @@ meta-sovereign 在网上**代表一个人或一个组织**。连接 Telegram、V
11
11
  | **个人** | 所有聊天集中一处,重新联系久未联系的人,记住说过的话(事实),在每个招聘网站上保持同一份简历。 |
12
12
  | **销售** | 来自每个网络的潜在客户汇入一个 CRM,按规则划分受众,大规模个性化外联(每条消息只发一次、礼貌有节奏),跟进,快速回复。 |
13
13
  | **市场营销** | 向受众群发,在 Telegram 频道、LinkedIn 和 X 发帖,社区成员保持最新,每次自动发送都遵守退订名单。 |
14
- | **用户支持** | 所有来信进入一个 Operator 队列,以 VK 社区或 Telegram Business 账号回复,自动回复以安全草稿形式出现,事实构成知识库。 |
14
+ | **用户支持** | 未读会话进入一个 Operator 队列,以 VK 社区或 Telegram Business 账号回复,自动回复以安全草稿形式出现,事实构成知识库。 |
15
15
  | **组织** | 同样的工具服务于品牌、店铺或项目:它的账号、它的联系人、它的数据都在它自己的电脑上。团队模式(多名操作员)已列入路线图。 |
16
16
 
17
17
  每个场景今天能用什么、哪些是部分支持、下一步做什么,请见路线图:[`docs/ROADMAP.zh.md`](docs/ROADMAP.zh.md)。
@@ -86,6 +86,7 @@ SPA 按以下顺序选择服务器([`js/src/web/discover.js`](js/src/web/disco
86
86
 
87
87
  - **外联、群发与自动化**:给谁发、何时发、在什么条件下发、发什么内容;预览谁会收到什么;默认生成安全草稿,或按节奏自动发送,并有安静时段、每个聊天的冷却期、每日上限和退订名单。每次自动发送都经过同一个发件箱:最多发送一次,结果不明时不会自行重试([`docs/USER-GUIDE.zh.md`](docs/USER-GUIDE.zh.md) 第 11 节)。
88
88
  - **Operator 队列与回复**:逐个聊天处理未读消息,根据您以往的回答给出回复建议(按 Tab 采用),模式与回复变体,对匹配消息自动回复。
89
+ - **工作账户**:在 Connections 和 Profile 中打开独立浏览器窗口,手动登录 Upwork、Fiverr、PeoplePerHour 和 Wellfound,保留会话并保存本地资料快照。这是初步支持;见[指南](docs/WORK-ACCOUNTS.md)。
89
90
  - **企业与社区账号**:VK 社区令牌运行 Bots Long Poll;Telegram Business 的快捷回复、问候与离开消息、营业时间和聊天机器人。
90
91
  - **记忆**:从对话中提取结构化的 `问题 → 答案` 事实,并可在聊天中核对。
91
92
  - **问候**:像 [konard/vk-bot](https://github.com/konard/vk-bot) 一样,在 VK、Telegram、WhatsApp、Messenger、X 和电子邮件中用一句“你好”或问候贴纸开启对话([`docs/GREETINGS.zh.md`](docs/GREETINGS.zh.md))。
@@ -25,6 +25,31 @@ export const TRUST_HELP = ` trust [--store=<dir>] [--remove=true]
25
25
  const enabled = (value, fallback = true) =>
26
26
  value === undefined ? fallback : String(value) !== 'false';
27
27
 
28
+ /** A locked device keeps its identity: retry only this temporary OS refusal. */
29
+ export const waitForLocalCertificate = async (
30
+ create,
31
+ log,
32
+ pause = () => new Promise((resolve) => globalThis.setTimeout(resolve, 5000))
33
+ ) => {
34
+ let announced = false;
35
+ for (;;) {
36
+ try {
37
+ return await create();
38
+ } catch (error) {
39
+ if (error.code !== 'DEVICE_KEY_LOCKED') {
40
+ throw error;
41
+ }
42
+ if (!announced) {
43
+ log(
44
+ 'Waiting for this Mac to be unlocked to access its protected TLS key…'
45
+ );
46
+ announced = true;
47
+ }
48
+ await pause();
49
+ }
50
+ }
51
+ };
52
+
28
53
  /** Ask macOS to evaluate this exact leaf certificate for local TLS only. */
29
54
  export const certificateIsTrusted = (
30
55
  certPath,
@@ -64,11 +89,13 @@ export const setupLocalCertificate = async (args, log, options = {}) => {
64
89
  return null;
65
90
  }
66
91
  const storeDir = args.store ?? defaultStoreDir();
67
- const protector =
68
- enabled(args['hardware-key']) && platform === 'darwin'
69
- ? await deviceProtector(storeDir)
70
- : null;
71
- const local = await load(storeDir, { protector });
92
+ const local = await waitForLocalCertificate(async () => {
93
+ const protector =
94
+ enabled(args['hardware-key']) && platform === 'darwin'
95
+ ? await deviceProtector(storeDir)
96
+ : null;
97
+ return load(storeDir, { protector });
98
+ }, log);
72
99
  log(`local TLS identity (SHA-256) → ${local.fingerprint}`);
73
100
  log(`local TLS key protection → ${local.protection}`);
74
101
  if (platform !== 'darwin' || trusted(local.certPath)) {
@@ -0,0 +1,79 @@
1
+ // Issue #42: initial sign-in/profile support, separate from messaging adapters.
2
+ // Login and challenges are completed by the person in an app-owned browser.
3
+ export const MARKETPLACES = Object.freeze({
4
+ upwork: {
5
+ label: 'Upwork',
6
+ hosts: ['www.upwork.com', 'upwork.com'],
7
+ loginUrl: 'https://www.upwork.com/ab/account-security/login',
8
+ profilePath: '^/(?:freelancers/(?:~[^/]+|settings/profile)|fl/[^/]+)/?$',
9
+ docsUrl:
10
+ 'https://support.upwork.com/hc/en-us/articles/360016252373-How-to-build-your-freelancer-profile-the-essentials',
11
+ },
12
+ fiverr: {
13
+ label: 'Fiverr',
14
+ hosts: ['www.fiverr.com', 'fiverr.com'],
15
+ loginUrl: 'https://www.fiverr.com/login',
16
+ profilePath: '^/(?:[a-zA-Z0-9_]{2,32}|users/[a-zA-Z0-9_]{2,32})/?$',
17
+ docsUrl:
18
+ 'https://help.fiverr.com/hc/en-us/articles/360010558598-Creating-editing-your-freelancer-profile',
19
+ },
20
+ peopleperhour: {
21
+ label: 'PeoplePerHour',
22
+ hosts: ['www.peopleperhour.com', 'peopleperhour.com'],
23
+ loginUrl: 'https://www.peopleperhour.com/site/login',
24
+ profilePath: '^/(?:freelancer|buyer|profile)/[^?#]+/?$',
25
+ docsUrl:
26
+ 'https://support.peopleperhour.com/hc/en-us/articles/115016023568-Create-a-buyer-account',
27
+ },
28
+ wellfound: {
29
+ label: 'Wellfound',
30
+ hosts: ['wellfound.com', 'www.wellfound.com'],
31
+ loginUrl: 'https://wellfound.com/login',
32
+ profilePath: '^/(?:u/[^/]+|profile(?:/edit)?)/?$',
33
+ docsUrl: 'https://help.wellfound.com/article/758-profiles',
34
+ },
35
+ });
36
+
37
+ const NON_PROFILE = new Set([
38
+ 'login',
39
+ 'signup',
40
+ 'join',
41
+ 'search',
42
+ 'categories',
43
+ 'seller_dashboard',
44
+ 'settings',
45
+ 'users',
46
+ 'dashboard',
47
+ 'logout',
48
+ 'contact',
49
+ 'about',
50
+ 'terms',
51
+ 'privacy',
52
+ ]);
53
+
54
+ /** Accept only an HTTPS profile URL on this service, never a redirect query. */
55
+ export const marketplaceProfileUrl = (id, value) => {
56
+ const provider = MARKETPLACES[id];
57
+ try {
58
+ const url = new URL(value);
59
+ if (
60
+ !provider ||
61
+ url.protocol !== 'https:' ||
62
+ url.username ||
63
+ url.password ||
64
+ url.port ||
65
+ !provider.hosts.includes(url.hostname) ||
66
+ NON_PROFILE.has(url.pathname.replace(/^\/|\/$/g, '')) ||
67
+ !new RegExp(provider.profilePath).test(url.pathname)
68
+ ) {
69
+ return null;
70
+ }
71
+ url.search = '';
72
+ url.hash = '';
73
+ return url.href;
74
+ } catch {
75
+ return null;
76
+ }
77
+ };
78
+
79
+ export const marketplaceProfileId = (id) => `account:marketplace:${id}`;
@@ -0,0 +1,118 @@
1
+ // Runs only on the page the person explicitly chose to save as their profile.
2
+ // Reads named profile fields, never cookies, storage, passwords or hidden inputs.
3
+ export const captureProfilePage = () => {
4
+ const visible = (node) => {
5
+ if (
6
+ !node?.getClientRects().length ||
7
+ node.closest('[hidden], [aria-hidden="true"]')
8
+ ) {
9
+ return false;
10
+ }
11
+ const style = globalThis.getComputedStyle(node);
12
+ return style.visibility === 'visible' && style.opacity !== '0';
13
+ };
14
+ const text = (selectors) => {
15
+ for (const selector of selectors) {
16
+ const node = globalThis.document.querySelector(selector);
17
+ if (visible(node) && node.type !== 'password') {
18
+ const value = String(node.value ?? node.textContent ?? '').trim();
19
+ if (value) {
20
+ return value;
21
+ }
22
+ }
23
+ }
24
+ return '';
25
+ };
26
+ const nodes = [];
27
+ for (const script of globalThis.document.querySelectorAll(
28
+ 'script[type="application/ld+json"]'
29
+ )) {
30
+ try {
31
+ const json = JSON.parse(script.textContent);
32
+ for (const item of Array.isArray(json) ? json : [json]) {
33
+ nodes.push(
34
+ item,
35
+ ...(Array.isArray(item?.['@graph']) ? item['@graph'] : [])
36
+ );
37
+ }
38
+ } catch {
39
+ /* A malformed unrelated script is not profile data. */
40
+ }
41
+ }
42
+ const findPerson = () => {
43
+ const type = (node, wanted) => [node?.['@type']].flat().includes(wanted);
44
+ const page = nodes.find((node) => type(node, 'ProfilePage'));
45
+ return page?.mainEntity && type(page.mainEntity, 'Person')
46
+ ? page.mainEntity
47
+ : nodes.find(
48
+ (node) =>
49
+ type(node, 'Person') &&
50
+ node.url &&
51
+ String(node.url).split(/[?#]/)[0].replace(/\/$/, '') ===
52
+ globalThis.location.href.split(/[?#]/)[0].replace(/\/$/, '')
53
+ );
54
+ };
55
+ const person = findPerson();
56
+ const name =
57
+ person?.name ||
58
+ text([
59
+ '[data-test="profile-name"]',
60
+ '[data-testid="profile-name"]',
61
+ '[data-testid="display-name"]',
62
+ '[data-qa="profile-name"]',
63
+ 'main [itemprop="name"]',
64
+ '[itemprop="name"]',
65
+ 'input[name="name"]',
66
+ 'input[name="fullName"]',
67
+ 'input[autocomplete="name"]',
68
+ 'input[name="user[name]"]',
69
+ 'h1',
70
+ ]);
71
+ const headline =
72
+ person?.jobTitle ||
73
+ text([
74
+ '[data-test="profile-title"]',
75
+ '[data-testid="profile-title"]',
76
+ '[itemprop="jobTitle"]',
77
+ '[data-test="headline"]',
78
+ ]);
79
+ const bio =
80
+ person?.description ||
81
+ text([
82
+ '[data-test="profile-description"]',
83
+ '[data-testid="profile-description"]',
84
+ '[itemprop="description"]',
85
+ ]);
86
+ const skills = [
87
+ ...globalThis.document.querySelectorAll(
88
+ '[data-test="skills"] li, [data-testid="skills"] li, [itemprop="skills"]'
89
+ ),
90
+ ]
91
+ .filter(visible)
92
+ .map((node) => node.textContent.trim());
93
+ // Positive evidence is recorded as an observation, never inferred from a URL.
94
+ const signInObserved = [
95
+ ...globalThis.document.querySelectorAll('a, button'),
96
+ ].some(
97
+ (node) =>
98
+ visible(node) &&
99
+ /^(?:sign out|log out|logout)$/i.test(node.textContent.trim())
100
+ );
101
+ return {
102
+ challenge:
103
+ /just a moment|attention required|access denied|verify.*human|security check/i.test(
104
+ globalThis.document.title
105
+ ) ||
106
+ [
107
+ ...globalThis.document.querySelectorAll(
108
+ '#challenge-running, #cf-challenge-running, #challenge-stage, #px-captcha'
109
+ ),
110
+ ].some(visible),
111
+ url: globalThis.location.href,
112
+ name,
113
+ headline,
114
+ bio,
115
+ skills,
116
+ signInObserved,
117
+ };
118
+ };
@@ -0,0 +1,184 @@
1
+ import path from 'node:path';
2
+ import { promises as fs } from 'node:fs';
3
+ import { openBrowserSession } from '../cv/browser.js';
4
+ import {
5
+ MARKETPLACES,
6
+ marketplaceProfileId,
7
+ marketplaceProfileUrl,
8
+ } from './catalogue.js';
9
+ import { captureProfilePage } from './profile-page.js';
10
+
11
+ const failure = (code) => Object.assign(new Error(code), { code });
12
+ const field = (value, length) =>
13
+ typeof value === 'string' ? value.trim().slice(0, length) : '';
14
+
15
+ /** Whitelist stored/displayed identity fields, dropping all other page data. */
16
+ export const cleanMarketplaceProfile = (id, data, now) => {
17
+ const url = marketplaceProfileUrl(id, data?.url);
18
+ const name = field(data?.name, 160).replace(/\s+/g, ' ');
19
+ if (
20
+ data?.challenge === true ||
21
+ !url ||
22
+ !name ||
23
+ /^(?:sign in|log in|login|welcome back|loading|verify|just a moment|your profile$|my profile$|profile$|edit profile$)/i.test(
24
+ name
25
+ )
26
+ ) {
27
+ throw failure('needs-profile');
28
+ }
29
+ return {
30
+ name,
31
+ url,
32
+ headline: field(data.headline, 300),
33
+ bio: field(data.bio, 5000),
34
+ skills: Array.isArray(data.skills)
35
+ ? data.skills
36
+ .slice(0, 64)
37
+ .map((skill) => field(skill, 100))
38
+ .filter(Boolean)
39
+ : [],
40
+ capturedAt: now.toISOString(),
41
+ signInObservedAt: data.signInObserved === true ? now.toISOString() : null,
42
+ };
43
+ };
44
+
45
+ export const createMarketplaceSessions = ({
46
+ store,
47
+ storeDir,
48
+ openSession = openBrowserSession,
49
+ clock = () => new Date(),
50
+ }) => {
51
+ const sessions = new Map();
52
+ const opening = new Map();
53
+ let stopping = false;
54
+ const providerOf = (id) => {
55
+ if (!Object.hasOwn(MARKETPLACES, id)) {
56
+ throw failure('unknown-marketplace');
57
+ }
58
+ return MARKETPLACES[id];
59
+ };
60
+ const status = async (id) => {
61
+ const provider = providerOf(id);
62
+ const session = sessions.get(id);
63
+ const open = Boolean(session && !session.page.isClosed?.());
64
+ if (session && !open) {
65
+ sessions.delete(id);
66
+ await session.close().catch(() => {});
67
+ }
68
+ const saved = (await store.get(marketplaceProfileId(id)))?.profile;
69
+ let profile = null;
70
+ if (saved) {
71
+ try {
72
+ profile = cleanMarketplaceProfile(
73
+ id,
74
+ { ...saved, signInObserved: Boolean(saved.signInObservedAt) },
75
+ new Date(saved.capturedAt)
76
+ );
77
+ } catch {
78
+ /* Do not expose malformed or non-profile records. */
79
+ }
80
+ }
81
+ return {
82
+ id,
83
+ label: provider.label,
84
+ open,
85
+ opening: opening.has(id),
86
+ profile,
87
+ };
88
+ };
89
+ const launch = async (id) => {
90
+ const provider = providerOf(id);
91
+ const profileDir = path.join(storeDir, 'browser-profiles', id);
92
+ await fs.mkdir(profileDir, { recursive: true, mode: 0o700 });
93
+ const session = await openSession({
94
+ platform: id,
95
+ profileDir,
96
+ headless: false,
97
+ locale: 'en-US',
98
+ });
99
+ try {
100
+ const saved = (await store.get(marketplaceProfileId(id)))?.profile;
101
+ await session.page.goto(
102
+ marketplaceProfileUrl(id, saved?.url) ?? provider.loginUrl,
103
+ { waitUntil: 'domcontentloaded', timeout: 30_000 }
104
+ );
105
+ sessions.set(id, session);
106
+ } catch (error) {
107
+ await session.close().catch(() => {});
108
+ throw error;
109
+ }
110
+ };
111
+ const open = async (id) => {
112
+ providerOf(id);
113
+ if (stopping) {
114
+ throw failure('server-stopping');
115
+ }
116
+ if (opening.has(id)) {
117
+ await opening.get(id);
118
+ return status(id);
119
+ }
120
+ if ((await status(id)).open) {
121
+ await sessions.get(id)?.page.bringToFront?.();
122
+ return status(id);
123
+ }
124
+ // Recheck after the asynchronous status read: concurrent requests share it.
125
+ if (!opening.has(id)) {
126
+ const pending = launch(id);
127
+ opening.set(id, pending);
128
+ }
129
+ try {
130
+ await opening.get(id);
131
+ } finally {
132
+ opening.delete(id);
133
+ }
134
+ return status(id);
135
+ };
136
+ const close = async (id) => {
137
+ providerOf(id);
138
+ await opening.get(id)?.catch(() => {});
139
+ const session = sessions.get(id);
140
+ sessions.delete(id);
141
+ await session?.close();
142
+ return status(id);
143
+ };
144
+ const capture = async (id) => {
145
+ providerOf(id);
146
+ const session = sessions.get(id);
147
+ if (!session || session.page.isClosed?.()) {
148
+ throw failure('session-closed');
149
+ }
150
+ const before = marketplaceProfileUrl(id, session.page.url());
151
+ if (!before) {
152
+ throw failure('needs-profile');
153
+ }
154
+ const data = await session.page.evaluate(captureProfilePage);
155
+ if (
156
+ marketplaceProfileUrl(id, session.page.url()) !== before ||
157
+ marketplaceProfileUrl(id, data?.url) !== before
158
+ ) {
159
+ throw failure('page-changed');
160
+ }
161
+ const profile = cleanMarketplaceProfile(id, data, clock());
162
+ await store.put({
163
+ id: marketplaceProfileId(id),
164
+ kind: 'marketplace-profile',
165
+ source: id,
166
+ tokens: ['account', 'marketplace', id],
167
+ profile,
168
+ });
169
+ return status(id);
170
+ };
171
+ return {
172
+ status,
173
+ open,
174
+ close,
175
+ capture,
176
+ list: () => Promise.all(Object.keys(MARKETPLACES).map(status)),
177
+ stop: async () => {
178
+ stopping = true;
179
+ await Promise.allSettled(
180
+ [...new Set([...opening.keys(), ...sessions.keys()])].map(close)
181
+ );
182
+ },
183
+ };
184
+ };
@@ -94,6 +94,7 @@ import { handleChatRoutes } from './routes-chat.js';
94
94
  import { createClinkReplyStore } from '../replies/reply-store-clink.js';
95
95
  import { handleAutoImportRoutes } from './routes-auto-import.js';
96
96
  import { handleWhatsAppRoutes } from './routes-whatsapp.js';
97
+ import { handleMarketplaceRoutes } from './routes-marketplaces.js';
97
98
  import { profileDirIn } from '../sources/whatsapp-web-signin.js';
98
99
  import { handleTelegramRoutes } from './routes-telegram.js';
99
100
  import { handleMessageRoutes } from './routes-messages.js';
@@ -628,6 +629,7 @@ const routeOn = async (store, req, res, ctx, url, p) => {
628
629
  () => ctx.clientLog?.(req, res, p, ctx) ?? false,
629
630
  () => handleAutoImportRoutes(store, req, res, p, ctx),
630
631
  () => handleWhatsAppRoutes(store, req, res, p, ctx),
632
+ () => handleMarketplaceRoutes(store, req, res, p, ctx),
631
633
  () => handleArchiveUpload(store, req, res, p, ctx),
632
634
  () => handlePullRoutes(store, req, res, p, ctx),
633
635
  () => handleTelegramRoutes(req, res, p, ctx),
@@ -960,7 +962,10 @@ export const startServer = async ({
960
962
  bus?.close();
961
963
  ctx.replies.close().catch(() => {});
962
964
  ctx.closeAutomations().catch(() => {});
963
- const jobsDone = ctx.stopJobs();
965
+ const jobsDone = Promise.allSettled([
966
+ ctx.stopJobs(),
967
+ ctx.marketplaceSessions?.stop(),
968
+ ]);
964
969
  ctx.chatIndex.close();
965
970
  stopAll(telegramAccount, ctx.telegramAccounts, ctx.telegramExtras);
966
971
  wsHandle?.close();
@@ -11,8 +11,19 @@ import { compileSwiftHelper } from './native-helper.js';
11
11
  const SOURCE = fileURLToPath(
12
12
  new URL('./local-key-enclave.swift', import.meta.url)
13
13
  );
14
- const failure = (message) =>
15
- Object.assign(new Error(message), { code: 'DEVICE_KEY_UNAVAILABLE' });
14
+ const failure = (message, code = 'DEVICE_KEY_UNAVAILABLE') =>
15
+ Object.assign(new Error(message), { code });
16
+
17
+ /** OSStatus -25308 is a temporarily inaccessible key, not another device. */
18
+ export const deviceKeyFailure = (stderr) =>
19
+ /OSStatus error -25308/.test(String(stderr))
20
+ ? failure(
21
+ 'Unlock this Mac to access its device-protected TLS key.',
22
+ 'DEVICE_KEY_LOCKED'
23
+ )
24
+ : failure(
25
+ 'The TLS key cannot be unlocked by this Mac. Unlock the original device; copied keys cannot be used on another computer.'
26
+ );
16
27
 
17
28
  /** Compile our small CryptoKit helper locally, once per source revision. */
18
29
  const helper = async (storeDir, run) => {
@@ -47,9 +58,7 @@ export const createDeviceKeyProtector = async (
47
58
  maxBuffer: 64 * 1024,
48
59
  });
49
60
  if (result.status !== 0) {
50
- throw failure(
51
- 'The TLS key cannot be unlocked by this Mac. Unlock the original device; copied keys cannot be used on another computer.'
52
- );
61
+ throw deviceKeyFailure(result.stderr);
53
62
  }
54
63
  return JSON.parse(result.stdout);
55
64
  };