meta-sovereign 0.52.0 → 0.53.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.hi.md +1 -0
- package/README.md +2 -1
- package/README.ru.md +1 -0
- package/README.zh.md +1 -0
- package/js/src/cli/trust-commands.js +32 -5
- package/js/src/marketplaces/catalogue.js +79 -0
- package/js/src/marketplaces/profile-page.js +118 -0
- package/js/src/marketplaces/sessions.js +184 -0
- package/js/src/replies/reply-cache.js +30 -26
- package/js/src/server/index.js +6 -1
- package/js/src/server/live-subset.js +46 -0
- package/js/src/server/local-key.js +14 -5
- package/js/src/server/routes-automations.js +9 -1
- package/js/src/server/routes-derived.js +36 -20
- package/js/src/server/routes-marketplaces.js +78 -0
- package/js/src/server/routes-telegram.js +7 -0
- package/js/src/sources/telegram-account.js +8 -3
- package/js/src/sources/telegram-backfill.js +5 -4
- package/js/src/sources/telegram-business-bots.js +1 -1
- package/js/src/sources/telegram-dialog-state.js +60 -0
- package/js/src/sources/telegram-forum.js +48 -1
- package/js/src/storage/app-directory.js +4 -3
- package/js/src/storage/dual-store.js +6 -0
- package/js/src/storage/secret-store.js +6 -1
- package/js/src/sync/webrtc-transport.js +58 -20
- package/js/src/web/api-errors.js +4 -1
- package/js/src/web/app.css +53 -1
- package/js/src/web/app.min.js +95 -95
- package/js/src/web/chat-business-bots.js +9 -2
- package/js/src/web/chat-drag.js +26 -2
- package/js/src/web/chat-feed.js +165 -0
- package/js/src/web/chat-history-status.js +3 -21
- package/js/src/web/chat-live.js +23 -140
- package/js/src/web/chat-model.js +27 -5
- package/js/src/web/chat-row.js +3 -1
- package/js/src/web/chat-state.js +9 -3
- package/js/src/web/chat-suggest.js +4 -7
- package/js/src/web/chat-view.js +123 -60
- package/js/src/web/client.js +33 -83
- package/js/src/web/connections/index.js +10 -0
- package/js/src/web/discover.js +19 -9
- package/js/src/web/dom.js +38 -6
- package/js/src/web/locales/en-chat.js +11 -5
- package/js/src/web/locales/en.js +38 -0
- package/js/src/web/locales/hi-chat.js +12 -5
- package/js/src/web/locales/hi.js +36 -0
- package/js/src/web/locales/ru-chat.js +12 -5
- package/js/src/web/locales/ru.js +38 -0
- package/js/src/web/locales/zh-chat.js +11 -5
- package/js/src/web/locales/zh.js +33 -0
- package/js/src/web/marketplace-accounts.js +280 -0
- package/js/src/web/navigation-counts.js +1 -1
- package/js/src/web/operator-model.js +1 -1
- package/js/src/web/operator-unread.js +95 -0
- package/js/src/web/operator-view.js +61 -39
- package/js/src/web/server-availability.js +71 -0
- package/js/src/web/shell/app-shell.js +5 -1
- package/js/src/web/telegram-status.js +36 -0
- package/js/src/web/views.js +15 -1
- package/js/src/web/webrtc-sync.js +4 -1
- package/package.json +3 -1
package/README.hi.md
CHANGED
|
@@ -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
|
@@ -61,7 +61,7 @@ The hosted app is the default UI: `serve` prints its address, <https://link-foun
|
|
|
61
61
|
|
|
62
62
|
On macOS, interactive `serve` now handles the full setup: it protects this installation’s TLS key with Secure Enclave, detects existing certificate trust, requests trust only when needed, and opens the GitHub Pages app. For a headless launch use `node js/bin/meta-sovereign.js serve --trust=true --open=true`. `--trust=false` and `--open=false` opt out. The Swift helper needs the macOS Command Line Tools; `--hardware-key=false` explicitly opts into a file key on unsupported Macs. Hardware protection applies to the key on disk; Node uses it in memory. See [local server setup and security](docs/LOCAL-SERVER-SECURITY.md) for the exact access rules and limits.
|
|
63
63
|
|
|
64
|
-
Open conversations load first and show loading or retry status until their history is ready. All / Private / Groups / Channels filters remain available inside every Telegram folder. Drag the vertical dividers to resize columns; widths are remembered. Calls is in the main menu after Chat, and Telegram account editing is under Profile. The default dark controls use white line icons. The JS server keeps its store under your OS user: `~/Library/Application Support/Meta Sovereign/store` on macOS, `%APPDATA%/Meta Sovereign/store` on Windows, and `${XDG_CONFIG_HOME:-~/.config}/Meta Sovereign/store` on Linux. Starting `serve` from an older checkout migrates its `.meta-sovereign` store once, preserving messages, contacts, sessions, media and certificate; a compatibility link preserves old paths. Restarts and a different working directory reuse the same store. `--store` or `META_SOVEREIGN_STORE_DIR` override it. See [chat readiness and data storage](docs/CHAT-READINESS.md).
|
|
64
|
+
Open conversations load first and show loading or retry status until their history is ready. Earlier history loads automatically on scroll in Chat and Operator. Message selection and menus work across the full row; unread cards use refreshed Telegram counters and retain cached data during connection failures. All / Private / Groups / Channels filters remain available inside every Telegram folder. Drag the vertical dividers to resize columns; widths are remembered. Calls is in the main menu after Chat, and Telegram account editing is under Profile. The default dark controls use white line icons. The JS server keeps its store under your OS user: `~/Library/Application Support/Meta Sovereign/store` on macOS, `%APPDATA%/Meta Sovereign/store` on Windows, and `${XDG_CONFIG_HOME:-~/.config}/Meta Sovereign/store` on Linux. Starting `serve` from an older checkout migrates its `.meta-sovereign` store once, preserving messages, contacts, sessions, media and certificate; a compatibility link preserves old paths. Restarts and a different working directory reuse the same store. `--store` or `META_SOVEREIGN_STORE_DIR` override it. See [chat readiness and data storage](docs/CHAT-READINESS.md).
|
|
65
65
|
|
|
66
66
|
## Connect the SPA to your server
|
|
67
67
|
|
|
@@ -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
|
@@ -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
|
@@ -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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
+
};
|
|
@@ -289,30 +289,34 @@ export const createReplyCache = ({
|
|
|
289
289
|
* none — three at most, as the strip shows them.
|
|
290
290
|
*/
|
|
291
291
|
export const mergeSuggestions = (local, remote, { limit = 8 } = {}) => {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
292
|
+
const base = local ?? remote ?? { stage: 'none', suggestions: [] };
|
|
293
|
+
const seen = new Set();
|
|
294
|
+
const unique = (list) =>
|
|
295
|
+
list.filter((suggestion) => {
|
|
296
|
+
const key = normalizeMessage(suggestion.text);
|
|
297
|
+
if (!key || seen.has(key)) {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
seen.add(key);
|
|
301
|
+
return true;
|
|
302
|
+
});
|
|
303
|
+
const localItems = local?.suggestions ?? [];
|
|
304
|
+
const remoteItems = remote?.suggestions ?? [];
|
|
305
|
+
const whole = unique(
|
|
306
|
+
[...localItems, ...remoteItems].filter((s) => s.kind !== 'word')
|
|
307
|
+
).slice(0, limit);
|
|
308
|
+
const knownWords = localItems.filter((s) => s.kind === 'word');
|
|
309
|
+
const words = unique(
|
|
310
|
+
knownWords.length
|
|
311
|
+
? knownWords
|
|
312
|
+
: remoteItems.filter((s) => s.kind === 'word')
|
|
313
|
+
).slice(0, Math.min(3, limit - whole.length));
|
|
314
|
+
const suggestions = [...whole, ...words];
|
|
315
|
+
return {
|
|
316
|
+
...base,
|
|
317
|
+
stage: suggestions.length
|
|
318
|
+
? (suggestions[0]?.kind ?? base.stage ?? 'none')
|
|
319
|
+
: 'none',
|
|
320
|
+
suggestions,
|
|
321
|
+
};
|
|
318
322
|
};
|
package/js/src/server/index.js
CHANGED
|
@@ -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 =
|
|
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();
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Keep a small derived set current without rescanning the entire archive. */
|
|
2
|
+
export const createLiveSubset = (store, matches) => {
|
|
3
|
+
if (typeof store.subscribe !== 'function') {
|
|
4
|
+
return () => store.query(matches);
|
|
5
|
+
}
|
|
6
|
+
const records = new Map();
|
|
7
|
+
let pending = null;
|
|
8
|
+
let loaded = false;
|
|
9
|
+
let during = [];
|
|
10
|
+
const apply = (event) => {
|
|
11
|
+
if (event.type === 'delete') {
|
|
12
|
+
records.delete(event.id ?? event.link?.id);
|
|
13
|
+
}
|
|
14
|
+
if (event.type === 'put') {
|
|
15
|
+
if (matches(event.link)) {
|
|
16
|
+
records.set(event.link.id, event.link);
|
|
17
|
+
} else {
|
|
18
|
+
records.delete(event.link.id);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
store.subscribe?.((event) => {
|
|
23
|
+
if (loaded) {
|
|
24
|
+
apply(event);
|
|
25
|
+
} else {
|
|
26
|
+
during.push(event);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
return async () => {
|
|
30
|
+
if (!loaded) {
|
|
31
|
+
pending ??= Promise.resolve()
|
|
32
|
+
.then(() => store.query(matches))
|
|
33
|
+
.then((links) => {
|
|
34
|
+
links.forEach((link) => records.set(link.id, link));
|
|
35
|
+
during.forEach(apply);
|
|
36
|
+
during = [];
|
|
37
|
+
loaded = true;
|
|
38
|
+
})
|
|
39
|
+
.finally(() => {
|
|
40
|
+
pending = null;
|
|
41
|
+
});
|
|
42
|
+
await pending;
|
|
43
|
+
}
|
|
44
|
+
return [...records.values()];
|
|
45
|
+
};
|
|
46
|
+
};
|
|
@@ -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
|
|
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
|
|
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
|
};
|